From 493130b78923d9d73be018add123a956045452a3 Mon Sep 17 00:00:00 2001 From: OJ Kwon <1210596+kwonoj@users.noreply.github.com> Date: Thu, 28 Mar 2024 09:47:33 -0700 Subject: [PATCH] feat(custom-transforms): partial page-static-info visitors (#63741) ### What Supports partial `get-page-static-info` in turbopack. Since turbopack doesn't have equivalent place to webpack's ondemandhandler, it uses turbopack's build time transform rule instead. As noted, this is partial implementation to pagestatic info as it does not have existing js side evaluations. Assertions will be added gradually to ensure regressions, for now having 1 assertion for getstaticparams. Closes PACK-2849 --- Cargo.lock | 2 + .../next-core/src/next_client/transforms.rs | 10 +- .../src/next_shared/transforms/mod.rs | 1 + .../transforms/next_page_static_info.rs | 118 ++++++ .../crates/next-custom-transforms/Cargo.toml | 2 + .../src/transforms/mod.rs | 1 + .../collect_exported_const_visitor.rs | 210 ++++++++++ .../collect_exports_visitor.rs | 189 +++++++++ .../src/transforms/page_static_info/mod.rs | 376 ++++++++++++++++++ .../test/dynamic-missing-gsp-dev.test.ts | 6 +- test/integration/app-dir-export/test/utils.ts | 5 +- test/turbopack-dev-tests-manifest.json | 5 +- 12 files changed, 917 insertions(+), 8 deletions(-) create mode 100644 packages/next-swc/crates/next-core/src/next_shared/transforms/next_page_static_info.rs create mode 100644 packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exported_const_visitor.rs create mode 100644 packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exports_visitor.rs create mode 100644 packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 80c942f0de..3c6cffa426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3162,11 +3162,13 @@ dependencies = [ name = "next-custom-transforms" version = "0.0.0" dependencies = [ + "anyhow", "chrono", "easy-error", "either", "fxhash", "hex", + "lazy_static", "once_cell", "pathdiff", "preset_env_base", diff --git a/packages/next-swc/crates/next-core/src/next_client/transforms.rs b/packages/next-swc/crates/next-core/src/next_client/transforms.rs index 6e28aa21e5..ac11e8daf4 100644 --- a/packages/next-swc/crates/next-core/src/next_client/transforms.rs +++ b/packages/next-swc/crates/next-core/src/next_client/transforms.rs @@ -13,8 +13,9 @@ use crate::{ get_server_actions_transform_rule, next_amp_attributes::get_next_amp_attr_rule, next_cjs_optimizer::get_next_cjs_optimizer_rule, next_disallow_re_export_all_in_page::get_next_disallow_export_all_in_page_rule, - next_page_config::get_next_page_config_rule, next_pure::get_next_pure_rule, - server_actions::ActionsTransform, + next_page_config::get_next_page_config_rule, + next_page_static_info::get_next_page_static_info_assert_rule, + next_pure::get_next_pure_rule, server_actions::ActionsTransform, }, }; @@ -76,6 +77,11 @@ pub async fn get_next_client_transforms_rules( rules.push(get_next_dynamic_transform_rule(false, false, pages_dir, mode, mdx_rs).await?); rules.push(get_next_image_rule()); + rules.push(get_next_page_static_info_assert_rule( + mdx_rs, + None, + Some(context_ty), + )); } Ok(rules) diff --git a/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs b/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs index 12812d0c04..a58276a8ea 100644 --- a/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs +++ b/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod next_font; pub(crate) mod next_middleware_dynamic_assert; pub(crate) mod next_optimize_server_react; pub(crate) mod next_page_config; +pub(crate) mod next_page_static_info; pub(crate) mod next_pure; pub(crate) mod next_react_server_components; pub(crate) mod next_shake_exports; diff --git a/packages/next-swc/crates/next-core/src/next_shared/transforms/next_page_static_info.rs b/packages/next-swc/crates/next-core/src/next_shared/transforms/next_page_static_info.rs new file mode 100644 index 0000000000..329e2eb4c8 --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_shared/transforms/next_page_static_info.rs @@ -0,0 +1,118 @@ +use anyhow::Result; +use async_trait::async_trait; +use next_custom_transforms::transforms::page_static_info::collect_exports; +use turbo_tasks::Vc; +use turbo_tasks_fs::FileSystemPath; +use turbopack_binding::{ + swc::core::ecma::ast::Program, + turbopack::{ + core::issue::{ + Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString, + }, + ecmascript::{CustomTransformer, EcmascriptInputTransform, TransformContext}, + turbopack::module_options::{ModuleRule, ModuleRuleEffect}, + }, +}; + +use super::module_rule_match_js_no_url; +use crate::{next_client::ClientContextType, next_server::ServerContextType}; + +/// Create a rule to run assertions for the page-static-info. +/// This assertion is partial implementation to the original +/// (analysis/get-page-static-info) Due to not able to bring all the evaluations +/// in the js implementation, +pub fn get_next_page_static_info_assert_rule( + enable_mdx_rs: bool, + server_context: Option, + client_context: Option, +) -> ModuleRule { + let transformer = EcmascriptInputTransform::Plugin(Vc::cell(Box::new(NextPageStaticInfo { + server_context, + client_context, + }) as _)); + ModuleRule::new( + module_rule_match_js_no_url(enable_mdx_rs), + vec![ModuleRuleEffect::ExtendEcmascriptTransforms { + prepend: Vc::cell(vec![transformer]), + append: Vc::cell(vec![]), + }], + ) +} + +#[derive(Debug)] +struct NextPageStaticInfo { + server_context: Option, + client_context: Option, +} + +#[async_trait] +impl CustomTransformer for NextPageStaticInfo { + async fn transform(&self, program: &mut Program, ctx: &TransformContext<'_>) -> Result<()> { + if let Some(collected_exports) = collect_exports(program)? { + let mut properties_to_extract = collected_exports.extra_properties.clone(); + properties_to_extract.insert("config".to_string()); + + let is_app_page = + matches!( + self.server_context, + Some(ServerContextType::AppRSC { .. }) | Some(ServerContextType::AppSSR { .. }) + ) || matches!(self.client_context, Some(ClientContextType::App { .. })); + + if collected_exports.directives.contains("client") + && collected_exports.generate_static_params + && is_app_page + { + PageStaticInfoIssue { + file_path: ctx.file_path, + messages: vec![format!(r#"Page "{}" cannot use both "use client" and export function "generateStaticParams()"."#, ctx.file_path_str)], + } + .cell() + .emit(); + } + } + + Ok(()) + } +} + +#[turbo_tasks::value(shared)] +pub struct PageStaticInfoIssue { + pub file_path: Vc, + pub messages: Vec, +} + +#[turbo_tasks::value_impl] +impl Issue for PageStaticInfoIssue { + #[turbo_tasks::function] + fn severity(&self) -> Vc { + IssueSeverity::Error.into() + } + + #[turbo_tasks::function] + fn stage(&self) -> Vc { + IssueStage::Transform.into() + } + + #[turbo_tasks::function] + fn title(&self) -> Vc { + StyledString::Text("Invalid page configuration".into()).cell() + } + + #[turbo_tasks::function] + fn file_path(&self) -> Vc { + self.file_path + } + + #[turbo_tasks::function] + async fn description(&self) -> Result> { + Ok(Vc::cell(Some( + StyledString::Line( + self.messages + .iter() + .map(|v| StyledString::Text(format!("{}\n", v))) + .collect::>(), + ) + .cell(), + ))) + } +} diff --git a/packages/next-swc/crates/next-custom-transforms/Cargo.toml b/packages/next-swc/crates/next-custom-transforms/Cargo.toml index 2f65671fd4..e657183b7f 100644 --- a/packages/next-swc/crates/next-custom-transforms/Cargo.toml +++ b/packages/next-swc/crates/next-custom-transforms/Cargo.toml @@ -24,6 +24,8 @@ serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } sha1 = "0.10.1" tracing = { version = "0.1.37" } +anyhow = { workspace = true } +lazy_static = { workspace = true } turbopack-binding = { workspace = true, features = [ "__swc_core", diff --git a/packages/next-swc/crates/next-custom-transforms/src/transforms/mod.rs b/packages/next-swc/crates/next-custom-transforms/src/transforms/mod.rs index 53b4c0264a..92f7fcd165 100644 --- a/packages/next-swc/crates/next-custom-transforms/src/transforms/mod.rs +++ b/packages/next-swc/crates/next-custom-transforms/src/transforms/mod.rs @@ -9,6 +9,7 @@ pub mod middleware_dynamic; pub mod next_ssg; pub mod optimize_server_react; pub mod page_config; +pub mod page_static_info; pub mod pure; pub mod react_server_components; pub mod server_actions; diff --git a/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exported_const_visitor.rs b/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exported_const_visitor.rs new file mode 100644 index 0000000000..2310425c2a --- /dev/null +++ b/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exported_const_visitor.rs @@ -0,0 +1,210 @@ +use std::collections::{HashMap, HashSet}; + +use serde_json::{Map, Number, Value}; +use turbopack_binding::swc::core::{ + common::{Mark, SyntaxContext}, + ecma::{ + ast::{ + BindingIdent, Decl, ExportDecl, Expr, Lit, ModuleDecl, ModuleItem, Pat, Prop, PropName, + PropOrSpread, VarDecl, VarDeclKind, VarDeclarator, + }, + utils::{ExprCtx, ExprExt}, + visit::{Visit, VisitWith}, + }, +}; + +/// The values extracted for the corresponding AST node. +/// refer extract_expored_const_values for the supported value types. +/// Undefined / null is treated as None. +pub enum Const { + Value(Value), + Unsupported(String), +} + +pub(crate) struct CollectExportedConstVisitor { + pub properties: HashMap>, + expr_ctx: ExprCtx, +} + +impl CollectExportedConstVisitor { + pub fn new(properties_to_extract: HashSet) -> Self { + Self { + properties: properties_to_extract + .into_iter() + .map(|p| (p, None)) + .collect(), + expr_ctx: ExprCtx { + unresolved_ctxt: SyntaxContext::empty().apply_mark(Mark::new()), + is_unresolved_ref_safe: false, + }, + } + } +} + +impl Visit for CollectExportedConstVisitor { + fn visit_module_items(&mut self, module_items: &[ModuleItem]) { + for module_item in module_items { + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + decl: Decl::Var(decl), + .. + })) = module_item + { + let VarDecl { kind, decls, .. } = &**decl; + if kind == &VarDeclKind::Const { + for decl in decls { + if let VarDeclarator { + name: Pat::Ident(BindingIdent { id, .. }), + init: Some(init), + .. + } = decl + { + let id = id.sym.as_ref(); + if let Some(prop) = self.properties.get_mut(id) { + *prop = extract_value(&self.expr_ctx, init, id.to_string()); + }; + } + } + } + } + } + + module_items.visit_children_with(self); + } +} + +/// Coerece the actual value of the given ast node. +fn extract_value(ctx: &ExprCtx, init: &Expr, id: String) -> Option { + match init { + init if init.is_undefined(ctx) => Some(Const::Value(Value::Null)), + Expr::Ident(ident) => Some(Const::Unsupported(format!( + "Unknown identifier \"{}\" at \"{}\".", + ident.sym, id + ))), + Expr::Lit(lit) => match lit { + Lit::Num(num) => Some(Const::Value(Value::Number( + Number::from_f64(num.value).expect("Should able to convert f64 to Number"), + ))), + Lit::Null(_) => Some(Const::Value(Value::Null)), + Lit::Str(s) => Some(Const::Value(Value::String(s.value.to_string()))), + Lit::Bool(b) => Some(Const::Value(Value::Bool(b.value))), + Lit::Regex(r) => Some(Const::Value(Value::String(format!( + "/{}/{}", + r.exp, r.flags + )))), + _ => Some(Const::Unsupported("Unsupported Literal".to_string())), + }, + Expr::Array(arr) => { + let mut a = vec![]; + + for elem in &arr.elems { + match elem { + Some(elem) => { + if elem.spread.is_some() { + return Some(Const::Unsupported(format!( + "Unsupported spread operator in the Array Expression at \"{}\"", + id + ))); + } + + match extract_value(ctx, &elem.expr, id.clone()) { + Some(Const::Value(value)) => a.push(value), + Some(Const::Unsupported(message)) => { + return Some(Const::Unsupported(format!( + "Unsupported value in the Array Expression: {message}" + ))) + } + _ => { + return Some(Const::Unsupported( + "Unsupported value in the Array Expression".to_string(), + )) + } + } + } + None => { + a.push(Value::Null); + } + } + } + + Some(Const::Value(Value::Array(a))) + } + Expr::Object(obj) => { + let mut o = Map::new(); + + for prop in &obj.props { + let (key, value) = match prop { + PropOrSpread::Prop(box Prop::KeyValue(kv)) => ( + match &kv.key { + PropName::Ident(i) => i.sym.as_ref(), + PropName::Str(s) => s.value.as_ref(), + _ => { + return Some(Const::Unsupported(format!( + "Unsupported key type in the Object Expression at \"{}\"", + id + ))) + } + }, + &kv.value, + ), + _ => { + return Some(Const::Unsupported(format!( + "Unsupported spread operator in the Object Expression at \"{}\"", + id + ))) + } + }; + let new_value = extract_value(ctx, value, format!("{}.{}", id, key)); + if let Some(Const::Unsupported(msg)) = new_value { + return Some(Const::Unsupported(msg)); + } + + if let Some(Const::Value(value)) = new_value { + o.insert(key.to_string(), value); + } + } + + Some(Const::Value(Value::Object(o))) + } + Expr::Tpl(tpl) => { + // [TODO] should we add support for `${'e'}d${'g'}'e'`? + if !tpl.exprs.is_empty() { + Some(Const::Unsupported(format!( + "Unsupported template literal with expressions at \"{}\".", + id + ))) + } else { + Some( + tpl.quasis + .first() + .map(|q| { + // When TemplateLiteral has 0 expressions, the length of quasis is + // always 1. Because when parsing + // TemplateLiteral, the parser yields the first quasi, + // then the first expression, then the next quasi, then the next + // expression, etc., until the last quasi. + // Thus if there is no expression, the parser ends at the frst and also + // last quasis + // + // A "cooked" interpretation where backslashes have special meaning, + // while a "raw" interpretation where + // backslashes do not have special meaning https://exploringjs.com/impatient-js/ch_template-literals.html#template-strings-cooked-vs-raw + let cooked = q.cooked.as_ref(); + let raw = q.raw.as_ref(); + + Const::Value(Value::String( + cooked.map(|c| c.to_string()).unwrap_or(raw.to_string()), + )) + }) + .unwrap_or(Const::Unsupported(format!( + "Unsupported node type at \"{}\"", + id + ))), + ) + } + } + _ => Some(Const::Unsupported(format!( + "Unsupported node type at \"{}\"", + id + ))), + } +} diff --git a/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exports_visitor.rs b/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exports_visitor.rs new file mode 100644 index 0000000000..f31124bb5d --- /dev/null +++ b/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/collect_exports_visitor.rs @@ -0,0 +1,189 @@ +use std::collections::HashSet; + +use lazy_static::lazy_static; +use turbopack_binding::swc::core::ecma::{ + ast::{ + Decl, ExportDecl, ExportNamedSpecifier, ExportSpecifier, Expr, ExprOrSpread, ExprStmt, Lit, + ModuleExportName, ModuleItem, NamedExport, Pat, Stmt, Str, VarDeclarator, + }, + visit::{Visit, VisitWith}, +}; + +use super::{ExportInfo, ExportInfoWarning}; + +lazy_static! { + static ref EXPORTS_SET: HashSet<&'static str> = HashSet::from([ + "getStaticProps", + "getServerSideProps", + "generateImageMetadata", + "generateSitemaps", + "generateStaticParams", + ]); +} + +pub(crate) struct CollectExportsVisitor { + pub export_info: Option, +} + +impl CollectExportsVisitor { + pub fn new() -> Self { + Self { + export_info: Default::default(), + } + } +} + +impl Visit for CollectExportsVisitor { + fn visit_module_items(&mut self, stmts: &[swc_core::ecma::ast::ModuleItem]) { + let mut is_directive = true; + + for stmt in stmts { + if let ModuleItem::Stmt(Stmt::Expr(ExprStmt { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + })) = stmt + { + if is_directive { + if value == "use server" { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.directives.insert("server".to_string()); + } + if value == "use client" { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.directives.insert("client".to_string()); + } + } + } else { + is_directive = false; + } + + stmt.visit_children_with(self); + } + } + + fn visit_export_decl(&mut self, export_decl: &ExportDecl) { + match &export_decl.decl { + Decl::Var(box var_decl) => { + if let Some(VarDeclarator { + name: Pat::Ident(name), + .. + }) = var_decl.decls.first() + { + if EXPORTS_SET.contains(&name.sym.as_str()) { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.ssg = name.sym == "getStaticProps"; + export_info.ssr = name.sym == "getServerSideProps"; + export_info.generate_image_metadata = + Some(name.sym == "generateImageMetadata"); + export_info.generate_sitemaps = Some(name.sym == "generateSitemaps"); + export_info.generate_static_params = name.sym == "generateStaticParams"; + } + } + + for decl in &var_decl.decls { + if let Pat::Ident(id) = &decl.name { + if id.sym == "runtime" { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.runtime = decl.init.as_ref().and_then(|init| { + if let Expr::Lit(Lit::Str(Str { value, .. })) = &**init { + Some(value.to_string()) + } else { + None + } + }) + } else if id.sym == "preferredRegion" { + if let Some(init) = &decl.init { + if let Expr::Array(arr) = &**init { + for expr in arr.elems.iter().flatten() { + if let ExprOrSpread { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + } = expr + { + let export_info = + self.export_info.get_or_insert(Default::default()); + export_info.preferred_region.push(value.to_string()); + } + } + } else if let Expr::Lit(Lit::Str(Str { value, .. })) = &**init { + let export_info = + self.export_info.get_or_insert(Default::default()); + export_info.preferred_region.push(value.to_string()); + } + } + } else { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.extra_properties.insert(id.sym.to_string()); + } + } + } + } + Decl::Fn(fn_decl) => { + let id = &fn_decl.ident; + + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.ssg = id.sym == "getStaticProps"; + export_info.ssr = id.sym == "getServerSideProps"; + export_info.generate_image_metadata = Some(id.sym == "generateImageMetadata"); + export_info.generate_sitemaps = Some(id.sym == "generateSitemaps"); + export_info.generate_static_params = id.sym == "generateStaticParams"; + } + _ => {} + } + + export_decl.visit_children_with(self); + } + + fn visit_named_export(&mut self, named_export: &NamedExport) { + for specifier in &named_export.specifiers { + if let ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(value), + .. + }) = specifier + { + let export_info = self.export_info.get_or_insert(Default::default()); + + if !export_info.ssg && value.sym == "getStaticProps" { + export_info.ssg = true; + } + + if !export_info.ssr && value.sym == "getServerSideProps" { + export_info.ssr = true; + } + + if !export_info.generate_image_metadata.unwrap_or_default() + && value.sym == "generateImageMetadata" + { + export_info.generate_image_metadata = Some(true); + } + + if !export_info.generate_sitemaps.unwrap_or_default() + && value.sym == "generateSitemaps" + { + export_info.generate_sitemaps = Some(true); + } + + if !export_info.generate_static_params && value.sym == "generateStaticParams" { + export_info.generate_static_params = true; + } + + if export_info.runtime.is_none() && value.sym == "runtime" { + export_info.warnings.push(ExportInfoWarning::new( + value.sym.to_string(), + "it was not assigned to a string literal".to_string(), + )); + } + + if export_info.preferred_region.is_empty() && value.sym == "preferredRegion" { + export_info.warnings.push(ExportInfoWarning::new( + value.sym.to_string(), + "it was not assigned to a string literal or an array of string literals" + .to_string(), + )); + } + } + } + + named_export.visit_children_with(self); + } +} diff --git a/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/mod.rs b/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/mod.rs new file mode 100644 index 0000000000..0ef34a3c8a --- /dev/null +++ b/packages/next-swc/crates/next-custom-transforms/src/transforms/page_static_info/mod.rs @@ -0,0 +1,376 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::Result; +pub use collect_exported_const_visitor::Const; +use collect_exports_visitor::CollectExportsVisitor; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use turbopack_binding::swc::core::{ + base::SwcComments, + common::GLOBALS, + ecma::{ast::Program, visit::VisitWith}, +}; + +pub mod collect_exported_const_visitor; +pub mod collect_exports_visitor; + +#[derive(Debug, Default)] +pub struct MiddlewareConfig {} + +#[derive(Debug)] +pub enum Amp { + Boolean(bool), + Hybrid, +} + +#[derive(Debug, Default)] +pub struct PageStaticInfo { + // [TODO] next-core have NextRuntime type, but the order of dependency won't allow to import + // Since this value is being passed into JS context anyway, we can just use string for now. + pub runtime: Option, // 'nodejs' | 'experimental-edge' | 'edge' + pub preferred_region: Vec, + pub ssg: Option, + pub ssr: Option, + pub rsc: Option, // 'server' | 'client' + pub generate_static_params: Option, + pub middleware: Option, + pub amp: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportInfoWarning { + pub key: String, + pub message: String, +} + +impl ExportInfoWarning { + pub fn new(key: String, message: String) -> Self { + Self { key, message } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportInfo { + pub ssr: bool, + pub ssg: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub preferred_region: Vec, + pub generate_image_metadata: Option, + pub generate_sitemaps: Option, + pub generate_static_params: bool, + pub extra_properties: HashSet, + pub directives: HashSet, + /// extra properties to bubble up warning messages from visitor, + /// since this isn't a failure to abort the process. + pub warnings: Vec, +} + +/// Collects static page export information for the next.js from given source's +/// AST. This is being used for some places like detecting page +/// is a dynamic route or not, or building a PageStaticInfo object. +pub fn collect_exports(program: &Program) -> Result> { + let mut collect_export_visitor = CollectExportsVisitor::new(); + program.visit_with(&mut collect_export_visitor); + + Ok(collect_export_visitor.export_info) +} + +static CLIENT_MODULE_LABEL: Lazy = Lazy::new(|| { + Regex::new(" __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) ").unwrap() +}); +static ACTION_MODULE_LABEL: Lazy = + Lazy::new(|| Regex::new(r#" __next_internal_action_entry_do_not_use__ (\{[^}]+\}) "#).unwrap()); + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RscModuleInfo { + #[serde(rename = "type")] + pub module_type: String, + pub actions: Option>, + pub is_client_ref: bool, + pub client_refs: Option>, + pub client_entry_type: Option, +} + +impl RscModuleInfo { + pub fn new(module_type: String) -> Self { + Self { + module_type, + actions: None, + is_client_ref: false, + client_refs: None, + client_entry_type: None, + } + } +} + +/// Parse comments from the given source code and collect the RSC module info. +/// This doesn't use visitor, only read comments to parse necessary information. +pub fn collect_rsc_module_info( + comments: &SwcComments, + is_react_server_layer: bool, +) -> RscModuleInfo { + let mut captured = None; + + for comment in comments.leading.iter() { + let parsed = comment.iter().find_map(|c| { + let actions_json = ACTION_MODULE_LABEL.captures(&c.text); + let client_info_match = CLIENT_MODULE_LABEL.captures(&c.text); + + if actions_json.is_none() && client_info_match.is_none() { + return None; + } + + let actions = if let Some(actions_json) = actions_json { + if let Ok(serde_json::Value::Object(map)) = + serde_json::from_str::(&actions_json[1]) + { + Some( + map.iter() + // values for the action json should be a string + .map(|(_, v)| v.as_str().unwrap_or_default().to_string()) + .collect::>(), + ) + } else { + None + } + } else { + None + }; + + let is_client_ref = client_info_match.is_some(); + let client_info = client_info_match.map(|client_info_match| { + ( + client_info_match[1] + .split(',') + .map(|s| s.to_string()) + .collect::>(), + client_info_match[2].to_string(), + ) + }); + + Some((actions, is_client_ref, client_info)) + }); + + if captured.is_none() { + captured = parsed; + break; + } + } + + match captured { + Some((actions, is_client_ref, client_info)) => { + if !is_react_server_layer { + let mut module_info = RscModuleInfo::new("client".to_string()); + module_info.actions = actions; + module_info.is_client_ref = is_client_ref; + module_info + } else { + let mut module_info = RscModuleInfo::new(if client_info.is_some() { + "client".to_string() + } else { + "server".to_string() + }); + module_info.actions = actions; + module_info.is_client_ref = is_client_ref; + if let Some((client_refs, client_entry_type)) = client_info { + module_info.client_refs = Some(client_refs); + module_info.client_entry_type = Some(client_entry_type); + } + + module_info + } + } + None => RscModuleInfo::new(if !is_react_server_layer { + "client".to_string() + } else { + "server".to_string() + }), + } +} + +/// Extracts the value of an exported const variable named `exportedName` +/// (e.g. "export const config = { runtime: 'edge' }") from swc's AST. +/// The value must be one of +/// - string +/// - boolean +/// - number +/// - null +/// - undefined +/// - array containing values listed in this list +/// - object containing values listed in this list +/// +/// Returns a map of the extracted values, or either contains corresponding +/// error. +pub fn extract_expored_const_values( + source_ast: &Program, + properties_to_extract: HashSet, +) -> HashMap> { + GLOBALS.set(&Default::default(), || { + let mut visitor = + collect_exported_const_visitor::CollectExportedConstVisitor::new(properties_to_extract); + + source_ast.visit_with(&mut visitor); + + visitor.properties + }) +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, sync::Arc}; + + use anyhow::Result; + use swc_core::{ + base::{ + config::{IsModule, ParseOptions}, + try_with_handler, Compiler, HandlerOpts, SwcComments, + }, + common::{errors::ColorConfig, FilePathMapping, SourceMap, GLOBALS}, + ecma::{ + ast::Program, + parser::{EsConfig, Syntax, TsConfig}, + }, + }; + + use super::{collect_rsc_module_info, RscModuleInfo}; + + fn build_ast_from_source(contents: &str, file_path: &str) -> Result<(Program, SwcComments)> { + GLOBALS.set(&Default::default(), || { + let c = Compiler::new(Arc::new(SourceMap::new(FilePathMapping::empty()))); + + let options = ParseOptions { + is_module: IsModule::Unknown, + syntax: if file_path.ends_with(".ts") || file_path.ends_with(".tsx") { + Syntax::Typescript(TsConfig { + tsx: true, + decorators: true, + ..Default::default() + }) + } else { + Syntax::Es(EsConfig { + jsx: true, + decorators: true, + ..Default::default() + }) + }, + ..Default::default() + }; + + let fm = c.cm.new_source_file( + swc_core::common::FileName::Real(PathBuf::from(file_path.to_string())), + contents.to_string(), + ); + + let comments = c.comments().clone(); + + try_with_handler( + c.cm.clone(), + HandlerOpts { + color: ColorConfig::Never, + skip_filename: false, + }, + |handler| { + c.parse_js( + fm, + handler, + options.target, + options.syntax, + options.is_module, + Some(&comments), + ) + }, + ) + .map(|p| (p, comments)) + }) + } + + #[test] + fn should_parse_server_info() { + let input = r#"export default function Page() { + return

app-edge-ssr

+ } + + export const runtime = 'edge' + export const maxDuration = 4 + "#; + + let (_, comments) = build_ast_from_source(input, "some-file.js") + .expect("Should able to parse test fixture input"); + + let module_info = collect_rsc_module_info(&comments, true); + let expected = RscModuleInfo { + module_type: "server".to_string(), + actions: None, + is_client_ref: false, + client_refs: None, + client_entry_type: None, + }; + + assert_eq!(module_info, expected); + } + + #[test] + fn should_parse_actions_json() { + let input = r#" + /* __next_internal_action_entry_do_not_use__ {"ab21efdafbe611287bc25c0462b1e0510d13e48b":"foo"} */ import { createActionProxy } from "private-next-rsc-action-proxy"; + import { encryptActionBoundArgs, decryptActionBoundArgs } from "private-next-rsc-action-encryption"; + export function foo() {} + import { ensureServerEntryExports } from "private-next-rsc-action-validate"; + ensureServerEntryExports([ + foo + ]); + createActionProxy("ab21efdafbe611287bc25c0462b1e0510d13e48b", foo); + "#; + + let (_, comments) = build_ast_from_source(input, "some-file.js") + .expect("Should able to parse test fixture input"); + + let module_info = collect_rsc_module_info(&comments, true); + let expected = RscModuleInfo { + module_type: "server".to_string(), + actions: Some(vec!["foo".to_string()]), + is_client_ref: false, + client_refs: None, + client_entry_type: None, + }; + + assert_eq!(module_info, expected); + } + + #[test] + fn should_parse_client_refs() { + let input = r#" + // This is a comment. + /* __next_internal_client_entry_do_not_use__ default,a,b,c,*,f auto */ const { createProxy } = require("private-next-rsc-mod-ref-proxy"); + module.exports = createProxy("/some-project/src/some-file.js"); + "#; + + let (_, comments) = build_ast_from_source(input, "some-file.js") + .expect("Should able to parse test fixture input"); + + let module_info = collect_rsc_module_info(&comments, true); + + let expected = RscModuleInfo { + module_type: "client".to_string(), + actions: None, + is_client_ref: true, + client_refs: Some(vec![ + "default".to_string(), + "a".to_string(), + "b".to_string(), + "c".to_string(), + "*".to_string(), + "f".to_string(), + ]), + client_entry_type: Some("auto".to_string()), + }; + + assert_eq!(module_info, expected); + } +} diff --git a/test/integration/app-dir-export/test/dynamic-missing-gsp-dev.test.ts b/test/integration/app-dir-export/test/dynamic-missing-gsp-dev.test.ts index 3c436a4d4e..2e7fb9f90a 100644 --- a/test/integration/app-dir-export/test/dynamic-missing-gsp-dev.test.ts +++ b/test/integration/app-dir-export/test/dynamic-missing-gsp-dev.test.ts @@ -15,12 +15,14 @@ describe('app dir - with output export - dynamic missing gsp dev', () => { }) it('should error when client component has generateStaticParams', async () => { + const expectedErrMsg = process.env.TURBOPACK_DEV + ? 'Page "test/integration/app-dir-export/app/another/[slug]/page.js" cannot use both "use client" and export function "generateStaticParams()".' + : 'Page "/another/[slug]/page" cannot use both "use client" and export function "generateStaticParams()".' await runTests({ isDev: true, dynamicPage: 'undefined', generateStaticParamsOpt: 'set client', - expectedErrMsg: - 'Page "/another/[slug]/page" cannot use both "use client" and export function "generateStaticParams()".', + expectedErrMsg, }) }) } diff --git a/test/integration/app-dir-export/test/utils.ts b/test/integration/app-dir-export/test/utils.ts index 44b698f6ca..d9f7cdb21a 100644 --- a/test/integration/app-dir-export/test/utils.ts +++ b/test/integration/app-dir-export/test/utils.ts @@ -11,6 +11,7 @@ import { File, findPort, getRedboxHeader, + getRedboxSource, hasRedbox, killApp, launchApp, @@ -154,7 +155,9 @@ export async function runTests({ const url = dynamicPage ? '/another/first' : '/api/json' const browser = await webdriver(port, url) expect(await hasRedbox(browser)).toBe(true) - expect(await getRedboxHeader(browser)).toContain(expectedErrMsg) + const header = await getRedboxHeader(browser) + const source = await getRedboxSource(browser) + expect(`${header}\n${source}`).toContain(expectedErrMsg) } else { await check(() => result.stderr, /error/i) } diff --git a/test/turbopack-dev-tests-manifest.json b/test/turbopack-dev-tests-manifest.json index 798501018d..d8f0bcb8d5 100644 --- a/test/turbopack-dev-tests-manifest.json +++ b/test/turbopack-dev-tests-manifest.json @@ -7490,11 +7490,10 @@ }, "test/integration/app-dir-export/test/dynamic-missing-gsp-dev.test.ts": { "passed": [ - "app dir - with output export - dynamic missing gsp dev development mode should error when dynamic route is missing generateStaticParams" - ], - "failed": [ + "app dir - with output export - dynamic missing gsp dev development mode should error when dynamic route is missing generateStaticParams", "app dir - with output export - dynamic missing gsp dev development mode should error when client component has generateStaticParams" ], + "failed": [], "pending": [], "flakey": [], "runtimeError": false