next_custom_transforms/transforms/page_static_info/
mod.rs1use anyhow::Result;
2pub use collect_exported_const_visitor::Const;
3use collect_exports_visitor::CollectExportsVisitor;
4use once_cell::sync::Lazy;
5use regex::Regex;
6use rustc_hash::FxHashSet;
7use serde::{Deserialize, Serialize};
8use swc_core::{
9 atoms::Atom,
10 base::SwcComments,
11 common::GLOBALS,
12 ecma::{ast::Program, visit::VisitWith},
13};
14
15use crate::transforms::page_static_info::collect_exported_const_visitor::GetMut;
16
17pub mod collect_exported_const_visitor;
18pub mod collect_exports_visitor;
19
20#[derive(Debug, Default)]
21pub struct MiddlewareConfig {}
22
23#[derive(Debug)]
24pub enum Amp {
25 Boolean(bool),
26 Hybrid,
27}
28
29#[derive(Debug, Default)]
30pub struct PageStaticInfo {
31 pub runtime: Option<Atom>, pub preferred_region: Vec<Atom>,
35 pub ssg: Option<bool>,
36 pub ssr: Option<bool>,
37 pub rsc: Option<Atom>, pub generate_static_params: Option<bool>,
39 pub middleware: Option<MiddlewareConfig>,
40 pub amp: Option<Amp>,
41}
42
43#[derive(Debug, Default, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct ExportInfoWarning {
46 pub key: Atom,
47 pub message: &'static str,
48}
49
50impl ExportInfoWarning {
51 pub fn new(key: Atom, message: &'static str) -> Self {
52 Self { key, message }
53 }
54}
55
56#[derive(Debug, Default, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ExportInfo {
59 pub ssr: bool,
60 pub ssg: bool,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub runtime: Option<Atom>,
63 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub preferred_region: Vec<Atom>,
65 pub generate_image_metadata: Option<bool>,
66 pub generate_sitemaps: Option<bool>,
67 pub generate_static_params: bool,
68 pub extra_properties: FxHashSet<Atom>,
69 pub directives: FxHashSet<Atom>,
70 pub warnings: Vec<ExportInfoWarning>,
73}
74
75pub fn collect_exports(program: &Program) -> Result<Option<ExportInfo>> {
79 let mut collect_export_visitor = CollectExportsVisitor::new();
80 program.visit_with(&mut collect_export_visitor);
81
82 Ok(collect_export_visitor.export_info)
83}
84
85static CLIENT_MODULE_LABEL: Lazy<Regex> = Lazy::new(|| {
86 Regex::new(" __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) ").unwrap()
87});
88static ACTION_MODULE_LABEL: Lazy<Regex> =
89 Lazy::new(|| Regex::new(r#" __next_internal_action_entry_do_not_use__ (\{[^}]+\}) "#).unwrap());
90
91#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct RscModuleInfo {
94 #[serde(rename = "type")]
95 pub module_type: String,
96 pub actions: Option<Vec<String>>,
97 pub is_client_ref: bool,
98 pub client_refs: Option<Vec<String>>,
99 pub client_entry_type: Option<String>,
100}
101
102impl RscModuleInfo {
103 pub fn new(module_type: String) -> Self {
104 Self {
105 module_type,
106 actions: None,
107 is_client_ref: false,
108 client_refs: None,
109 client_entry_type: None,
110 }
111 }
112}
113
114pub fn collect_rsc_module_info(
117 comments: &SwcComments,
118 is_react_server_layer: bool,
119) -> RscModuleInfo {
120 let mut captured = None;
121
122 for comment in comments.leading.iter() {
123 let parsed = comment.iter().find_map(|c| {
124 let actions_json = ACTION_MODULE_LABEL.captures(&c.text);
125 let client_info_match = CLIENT_MODULE_LABEL.captures(&c.text);
126
127 if actions_json.is_none() && client_info_match.is_none() {
128 return None;
129 }
130
131 let actions = if let Some(actions_json) = actions_json {
132 if let Ok(serde_json::Value::Object(map)) =
133 serde_json::from_str::<serde_json::Value>(&actions_json[1])
134 {
135 Some(
136 map.iter()
137 .map(|(_, v)| v.as_str().unwrap_or_default().to_string())
139 .collect::<Vec<_>>(),
140 )
141 } else {
142 None
143 }
144 } else {
145 None
146 };
147
148 let is_client_ref = client_info_match.is_some();
149 let client_info = client_info_match.map(|client_info_match| {
150 (
151 client_info_match[1]
152 .split(',')
153 .map(|s| s.to_string())
154 .collect::<Vec<_>>(),
155 client_info_match[2].to_string(),
156 )
157 });
158
159 Some((actions, is_client_ref, client_info))
160 });
161
162 if captured.is_none() {
163 captured = parsed;
164 break;
165 }
166 }
167
168 match captured {
169 Some((actions, is_client_ref, client_info)) => {
170 if !is_react_server_layer {
171 let mut module_info = RscModuleInfo::new("client".to_string());
172 module_info.actions = actions;
173 module_info.is_client_ref = is_client_ref;
174 module_info
175 } else {
176 let mut module_info = RscModuleInfo::new(if client_info.is_some() {
177 "client".to_string()
178 } else {
179 "server".to_string()
180 });
181 module_info.actions = actions;
182 module_info.is_client_ref = is_client_ref;
183 if let Some((client_refs, client_entry_type)) = client_info {
184 module_info.client_refs = Some(client_refs);
185 module_info.client_entry_type = Some(client_entry_type);
186 }
187
188 module_info
189 }
190 }
191 None => RscModuleInfo::new(if !is_react_server_layer {
192 "client".to_string()
193 } else {
194 "server".to_string()
195 }),
196 }
197}
198
199pub fn extract_exported_const_values(
213 source_ast: &Program,
214 properties_to_extract: &mut impl GetMut<Atom, Option<Const>>,
215) {
216 GLOBALS.set(&Default::default(), || {
217 let mut visitor =
218 collect_exported_const_visitor::CollectExportedConstVisitor::new(properties_to_extract);
219
220 visitor.check_program(source_ast);
221 })
222}
223
224#[cfg(test)]
225mod tests {
226 use std::{path::PathBuf, sync::Arc};
227
228 use anyhow::Result;
229 use swc_core::{
230 base::{
231 config::{IsModule, ParseOptions},
232 try_with_handler, Compiler, HandlerOpts, SwcComments,
233 },
234 common::{errors::ColorConfig, FilePathMapping, SourceMap, GLOBALS},
235 ecma::{
236 ast::Program,
237 parser::{EsSyntax, Syntax, TsSyntax},
238 },
239 };
240
241 use super::{collect_rsc_module_info, RscModuleInfo};
242
243 fn build_ast_from_source(contents: &str, file_path: &str) -> Result<(Program, SwcComments)> {
244 GLOBALS.set(&Default::default(), || {
245 let c = Compiler::new(Arc::new(SourceMap::new(FilePathMapping::empty())));
246
247 let options = ParseOptions {
248 is_module: IsModule::Unknown,
249 syntax: if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
250 Syntax::Typescript(TsSyntax {
251 tsx: true,
252 decorators: true,
253 ..Default::default()
254 })
255 } else {
256 Syntax::Es(EsSyntax {
257 jsx: true,
258 decorators: true,
259 ..Default::default()
260 })
261 },
262 ..Default::default()
263 };
264
265 let fm = c.cm.new_source_file(
266 swc_core::common::FileName::Real(PathBuf::from(file_path.to_string())).into(),
267 contents.to_string(),
268 );
269
270 let comments = c.comments().clone();
271
272 try_with_handler(
273 c.cm.clone(),
274 HandlerOpts {
275 color: ColorConfig::Never,
276 skip_filename: false,
277 },
278 |handler| {
279 c.parse_js(
280 fm,
281 handler,
282 options.target,
283 options.syntax,
284 options.is_module,
285 Some(&comments),
286 )
287 },
288 )
289 .map_err(|e| e.to_pretty_error())
290 .map(|p| (p, comments))
291 })
292 }
293
294 #[test]
295 fn should_parse_server_info() {
296 let input = r#"export default function Page() {
297 return <p>app-edge-ssr</p>
298 }
299
300 export const runtime = 'edge'
301 export const maxDuration = 4
302 "#;
303
304 let (_, comments) = build_ast_from_source(input, "some-file.js")
305 .expect("Should able to parse test fixture input");
306
307 let module_info = collect_rsc_module_info(&comments, true);
308 let expected = RscModuleInfo {
309 module_type: "server".to_string(),
310 actions: None,
311 is_client_ref: false,
312 client_refs: None,
313 client_entry_type: None,
314 };
315
316 assert_eq!(module_info, expected);
317 }
318
319 #[test]
320 fn should_parse_actions_json() {
321 let input = r#"
322 /* __next_internal_action_entry_do_not_use__ {"ab21efdafbe611287bc25c0462b1e0510d13e48b":"foo"} */ import { createActionProxy } from "private-next-rsc-action-proxy";
323 import { encryptActionBoundArgs, decryptActionBoundArgs } from "private-next-rsc-action-encryption";
324 export function foo() {}
325 import { ensureServerEntryExports } from "private-next-rsc-action-validate";
326 ensureServerEntryExports([
327 foo
328 ]);
329 createActionProxy("ab21efdafbe611287bc25c0462b1e0510d13e48b", foo);
330 "#;
331
332 let (_, comments) = build_ast_from_source(input, "some-file.js")
333 .expect("Should able to parse test fixture input");
334
335 let module_info = collect_rsc_module_info(&comments, true);
336 let expected = RscModuleInfo {
337 module_type: "server".to_string(),
338 actions: Some(vec!["foo".to_string()]),
339 is_client_ref: false,
340 client_refs: None,
341 client_entry_type: None,
342 };
343
344 assert_eq!(module_info, expected);
345 }
346
347 #[test]
348 fn should_parse_client_refs() {
349 let input = r#"
350 // This is a comment.
351 /* __next_internal_client_entry_do_not_use__ default,a,b,c,*,f auto */ const { createProxy } = require("private-next-rsc-mod-ref-proxy");
352 module.exports = createProxy("/some-project/src/some-file.js");
353 "#;
354
355 let (_, comments) = build_ast_from_source(input, "some-file.js")
356 .expect("Should able to parse test fixture input");
357
358 let module_info = collect_rsc_module_info(&comments, true);
359
360 let expected = RscModuleInfo {
361 module_type: "client".to_string(),
362 actions: None,
363 is_client_ref: true,
364 client_refs: Some(vec![
365 "default".to_string(),
366 "a".to_string(),
367 "b".to_string(),
368 "c".to_string(),
369 "*".to_string(),
370 "f".to_string(),
371 ]),
372 client_entry_type: Some("auto".to_string()),
373 };
374
375 assert_eq!(module_info, expected);
376 }
377}