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