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::{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    // [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    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    /// extra properties to bubble up warning messages from visitor,
72    /// since this isn't a failure to abort the process.
73    pub warnings: Vec<ExportInfoWarning>,
74}
75
76/// Collects static page export information for the next.js from given source's
77/// AST. This is being used for some places like detecting page
78/// is a dynamic route or not, or building a PageStaticInfo object.
79pub 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
115/// Parse comments from the given source code and collect the RSC module info.
116/// This doesn't use visitor, only read comments to parse necessary information.
117pub 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                            // values for the action json should be a string
139                            .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
200/// Extracts the value of an exported const variable named `exportedName`
201/// (e.g. "export const config = { runtime: 'edge' }") from swc's AST.
202/// The value must be one of
203///   - string
204///   - boolean
205///   - number
206///   - null
207///   - undefined
208///   - array containing values listed in this list
209///   - object containing values listed in this list
210///
211/// Returns a map of the extracted values, or either contains corresponding
212/// error.
213pub 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}