next_custom_transforms/transforms/page_static_info/
mod.rs1use std::collections::{HashMap, HashSet};
2
3use anyhow::Result;
4pub use collect_exported_const_visitor::Const;
5use collect_exports_visitor::CollectExportsVisitor;
6use once_cell::sync::Lazy;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use swc_core::{
10 base::SwcComments,
11 common::GLOBALS,
12 ecma::{ast::Program, visit::VisitWith},
13};
14
15pub mod collect_exported_const_visitor;
16pub mod collect_exports_visitor;
17
18#[derive(Debug, Default)]
19pub struct MiddlewareConfig {}
20
21#[derive(Debug)]
22pub enum Amp {
23 Boolean(bool),
24 Hybrid,
25}
26
27#[derive(Debug, Default)]
28pub struct PageStaticInfo {
29 pub runtime: Option<String>, pub preferred_region: Vec<String>,
33 pub ssg: Option<bool>,
34 pub ssr: Option<bool>,
35 pub rsc: Option<String>, pub generate_static_params: Option<bool>,
37 pub middleware: Option<MiddlewareConfig>,
38 pub amp: Option<Amp>,
39}
40
41#[derive(Debug, Default, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ExportInfoWarning {
44 pub key: String,
45 pub message: String,
46}
47
48impl ExportInfoWarning {
49 pub fn new(key: String, message: String) -> Self {
50 Self { key, message }
51 }
52}
53
54#[derive(Debug, Default, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct ExportInfo {
57 pub ssr: bool,
58 pub ssg: bool,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub runtime: Option<String>,
61 #[serde(skip_serializing_if = "Vec::is_empty")]
62 pub preferred_region: Vec<String>,
63 pub generate_image_metadata: Option<bool>,
64 pub generate_sitemaps: Option<bool>,
65 pub generate_static_params: bool,
66 pub extra_properties: HashSet<String>,
67 pub directives: HashSet<String>,
68 pub warnings: Vec<ExportInfoWarning>,
71}
72
73pub fn collect_exports(program: &Program) -> Result<Option<ExportInfo>> {
77 let mut collect_export_visitor = CollectExportsVisitor::new();
78 program.visit_with(&mut collect_export_visitor);
79
80 Ok(collect_export_visitor.export_info)
81}
82
83static CLIENT_MODULE_LABEL: Lazy<Regex> = Lazy::new(|| {
84 Regex::new(" __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) ").unwrap()
85});
86static ACTION_MODULE_LABEL: Lazy<Regex> =
87 Lazy::new(|| Regex::new(r#" __next_internal_action_entry_do_not_use__ (\{[^}]+\}) "#).unwrap());
88
89#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct RscModuleInfo {
92 #[serde(rename = "type")]
93 pub module_type: String,
94 pub actions: Option<Vec<String>>,
95 pub is_client_ref: bool,
96 pub client_refs: Option<Vec<String>>,
97 pub client_entry_type: Option<String>,
98}
99
100impl RscModuleInfo {
101 pub fn new(module_type: String) -> Self {
102 Self {
103 module_type,
104 actions: None,
105 is_client_ref: false,
106 client_refs: None,
107 client_entry_type: None,
108 }
109 }
110}
111
112pub fn collect_rsc_module_info(
115 comments: &SwcComments,
116 is_react_server_layer: bool,
117) -> RscModuleInfo {
118 let mut captured = None;
119
120 for comment in comments.leading.iter() {
121 let parsed = comment.iter().find_map(|c| {
122 let actions_json = ACTION_MODULE_LABEL.captures(&c.text);
123 let client_info_match = CLIENT_MODULE_LABEL.captures(&c.text);
124
125 if actions_json.is_none() && client_info_match.is_none() {
126 return None;
127 }
128
129 let actions = if let Some(actions_json) = actions_json {
130 if let Ok(serde_json::Value::Object(map)) =
131 serde_json::from_str::<serde_json::Value>(&actions_json[1])
132 {
133 Some(
134 map.iter()
135 .map(|(_, v)| v.as_str().unwrap_or_default().to_string())
137 .collect::<Vec<_>>(),
138 )
139 } else {
140 None
141 }
142 } else {
143 None
144 };
145
146 let is_client_ref = client_info_match.is_some();
147 let client_info = client_info_match.map(|client_info_match| {
148 (
149 client_info_match[1]
150 .split(',')
151 .map(|s| s.to_string())
152 .collect::<Vec<_>>(),
153 client_info_match[2].to_string(),
154 )
155 });
156
157 Some((actions, is_client_ref, client_info))
158 });
159
160 if captured.is_none() {
161 captured = parsed;
162 break;
163 }
164 }
165
166 match captured {
167 Some((actions, is_client_ref, client_info)) => {
168 if !is_react_server_layer {
169 let mut module_info = RscModuleInfo::new("client".to_string());
170 module_info.actions = actions;
171 module_info.is_client_ref = is_client_ref;
172 module_info
173 } else {
174 let mut module_info = RscModuleInfo::new(if client_info.is_some() {
175 "client".to_string()
176 } else {
177 "server".to_string()
178 });
179 module_info.actions = actions;
180 module_info.is_client_ref = is_client_ref;
181 if let Some((client_refs, client_entry_type)) = client_info {
182 module_info.client_refs = Some(client_refs);
183 module_info.client_entry_type = Some(client_entry_type);
184 }
185
186 module_info
187 }
188 }
189 None => RscModuleInfo::new(if !is_react_server_layer {
190 "client".to_string()
191 } else {
192 "server".to_string()
193 }),
194 }
195}
196
197pub fn extract_exported_const_values(
211 source_ast: &Program,
212 properties_to_extract: HashSet<String>,
213) -> HashMap<String, Option<Const>> {
214 GLOBALS.set(&Default::default(), || {
215 let mut visitor =
216 collect_exported_const_visitor::CollectExportedConstVisitor::new(properties_to_extract);
217
218 source_ast.visit_with(&mut visitor);
219
220 visitor.properties
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(|p| (p, comments))
290 })
291 }
292
293 #[test]
294 fn should_parse_server_info() {
295 let input = r#"export default function Page() {
296 return <p>app-edge-ssr</p>
297 }
298
299 export const runtime = 'edge'
300 export const maxDuration = 4
301 "#;
302
303 let (_, comments) = build_ast_from_source(input, "some-file.js")
304 .expect("Should able to parse test fixture input");
305
306 let module_info = collect_rsc_module_info(&comments, true);
307 let expected = RscModuleInfo {
308 module_type: "server".to_string(),
309 actions: None,
310 is_client_ref: false,
311 client_refs: None,
312 client_entry_type: None,
313 };
314
315 assert_eq!(module_info, expected);
316 }
317
318 #[test]
319 fn should_parse_actions_json() {
320 let input = r#"
321 /* __next_internal_action_entry_do_not_use__ {"ab21efdafbe611287bc25c0462b1e0510d13e48b":"foo"} */ import { createActionProxy } from "private-next-rsc-action-proxy";
322 import { encryptActionBoundArgs, decryptActionBoundArgs } from "private-next-rsc-action-encryption";
323 export function foo() {}
324 import { ensureServerEntryExports } from "private-next-rsc-action-validate";
325 ensureServerEntryExports([
326 foo
327 ]);
328 createActionProxy("ab21efdafbe611287bc25c0462b1e0510d13e48b", foo);
329 "#;
330
331 let (_, comments) = build_ast_from_source(input, "some-file.js")
332 .expect("Should able to parse test fixture input");
333
334 let module_info = collect_rsc_module_info(&comments, true);
335 let expected = RscModuleInfo {
336 module_type: "server".to_string(),
337 actions: Some(vec!["foo".to_string()]),
338 is_client_ref: false,
339 client_refs: None,
340 client_entry_type: None,
341 };
342
343 assert_eq!(module_info, expected);
344 }
345
346 #[test]
347 fn should_parse_client_refs() {
348 let input = r#"
349 // This is a comment.
350 /* __next_internal_client_entry_do_not_use__ default,a,b,c,*,f auto */ const { createProxy } = require("private-next-rsc-mod-ref-proxy");
351 module.exports = createProxy("/some-project/src/some-file.js");
352 "#;
353
354 let (_, comments) = build_ast_from_source(input, "some-file.js")
355 .expect("Should able to parse test fixture input");
356
357 let module_info = collect_rsc_module_info(&comments, true);
358
359 let expected = RscModuleInfo {
360 module_type: "client".to_string(),
361 actions: None,
362 is_client_ref: true,
363 client_refs: Some(vec![
364 "default".to_string(),
365 "a".to_string(),
366 "b".to_string(),
367 "c".to_string(),
368 "*".to_string(),
369 "f".to_string(),
370 ]),
371 client_entry_type: Some("auto".to_string()),
372 };
373
374 assert_eq!(module_info, expected);
375 }
376}