next_custom_transforms/transforms/page_static_info/
mod.rs

1use 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    // [TODO] next-core have NextRuntime type, but the order of dependency won't allow to import
32    // Since this value is being passed into JS context anyway, we can just use string for now.
33    pub runtime: Option<Atom>, // 'nodejs' | 'experimental-edge' | 'edge'
34    pub preferred_region: Vec<Atom>,
35    pub ssg: Option<bool>,
36    pub ssr: Option<bool>,
37    pub rsc: Option<Atom>, // 'server' | 'client'
38    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    /// extra properties to bubble up warning messages from visitor,
71    /// since this isn't a failure to abort the process.
72    pub warnings: Vec<ExportInfoWarning>,
73}
74
75/// Collects static page export information for the next.js from given source's
76/// AST. This is being used for some places like detecting page
77/// is a dynamic route or not, or building a PageStaticInfo object.
78pub 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
114/// Parse comments from the given source code and collect the RSC module info.
115/// This doesn't use visitor, only read comments to parse necessary information.
116pub 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                            // values for the action json should be a string
138                            .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
199/// Extracts the value of an exported const variable named `exportedName`
200/// (e.g. "export const config = { runtime: 'edge' }") from swc's AST.
201/// The value must be one of
202///   - string
203///   - boolean
204///   - number
205///   - null
206///   - undefined
207///   - array containing values listed in this list
208///   - object containing values listed in this list
209///
210/// Returns a map of the extracted values, or either contains corresponding
211/// error.
212pub 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}