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