next_custom_transforms/transforms/page_static_info/
mod.rs

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