Revert "refactor(analysis): rust based page-static-info, deprecate js parse interface in next-swc" (#61021)

Reverts vercel/next.js#59300

Breaks `app/page.mdx` files

Closes PACK-2279
This commit is contained in:
Tobias Koppers 2024-01-23 12:00:36 +01:00 committed by GitHub
parent 30fe0b8322
commit 3a7fea4034
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 682 additions and 1256 deletions

3
Cargo.lock generated
View file

@ -3450,7 +3450,6 @@ dependencies = [
"either",
"fxhash",
"hex",
"lazy_static",
"once_cell",
"pathdiff",
"react_remove_properties",
@ -3482,7 +3481,6 @@ dependencies = [
"next-core",
"next-custom-transforms",
"once_cell",
"regex",
"serde",
"serde_json",
"shadow-rs",
@ -8637,7 +8635,6 @@ dependencies = [
"once_cell",
"parking_lot_core 0.8.0",
"path-clean",
"regex",
"serde",
"serde-wasm-bindgen",
"serde_json",

View file

@ -8,7 +8,7 @@ members = [
"packages/next-swc/crates/next-api",
"packages/next-swc/crates/next-build",
"packages/next-swc/crates/next-core",
"packages/next-swc/crates/next-custom-transforms"
"packages/next-swc/crates/next-custom-transforms",
]
[workspace.lints.clippy]

View file

@ -76,7 +76,6 @@ next-build = { workspace = true }
next-core = { workspace = true }
turbo-tasks = { workspace = true }
once_cell = { workspace = true }
regex = "1.5"
serde = "1"
serde_json = "1"
shadow-rs = { workspace = true }

View file

@ -1,233 +1,92 @@
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Context as _;
use napi::bindgen_prelude::*;
use next_custom_transforms::transforms::page_static_info::{
build_ast_from_source, collect_exports, collect_rsc_module_info, extract_expored_const_values,
Const, ExportInfo, ExportInfoWarning, RscModuleInfo,
use turbopack_binding::swc::core::{
base::{config::ParseOptions, try_with_handler},
common::{
comments::Comments, errors::ColorConfig, FileName, FilePathMapping, SourceMap, GLOBALS,
},
};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::util::MapErr;
/// wrap read file to suppress errors conditionally.
/// [NOTE] currently next.js passes _every_ file in the paths regardless of if
/// it's an asset or an ecmascript, So skipping non-utf8 read errors. Probably
/// should skip based on file extension.
fn read_file_wrapped_err(path: &str, raise_err: bool) -> Result<String> {
let buf = std::fs::read(path).map_err(|e| {
napi::Error::new(
Status::GenericFailure,
format!("Next.js ERROR: Failed to read file {}:\n{:#?}", path, e),
)
});
match buf {
Ok(buf) => Ok(String::from_utf8(buf).ok().unwrap_or("".to_string())),
Err(e) if raise_err => Err(e),
_ => Ok("".to_string()),
}
}
/// A regex pattern to determine if is_dynamic_metadata_route should continue to
/// parse the page or short circuit and return false.
static DYNAMIC_METADATA_ROUTE_SHORT_CURCUIT: Lazy<Regex> =
Lazy::new(|| Regex::new("generateImageMetadata|generateSitemaps").unwrap());
/// A regex pattern to determine if get_page_static_info should continue to
/// parse the page or short circuit and return default.
static PAGE_STATIC_INFO_SHORT_CURCUIT: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"runtime|preferredRegion|getStaticProps|getServerSideProps|generateStaticParams|export const"#,
)
.unwrap()
});
pub struct DetectMetadataRouteTask {
page_file_path: String,
file_content: Option<String>,
pub struct ParseTask {
pub filename: FileName,
pub src: String,
pub options: Buffer,
}
#[napi]
impl Task for DetectMetadataRouteTask {
type Output = Option<ExportInfo>;
type JsValue = Object;
impl Task for ParseTask {
type Output = String;
type JsValue = String;
fn compute(&mut self) -> napi::Result<Self::Output> {
let file_content = if let Some(file_content) = &self.file_content {
file_content.clone()
} else {
read_file_wrapped_err(self.page_file_path.as_str(), true)?
};
GLOBALS.set(&Default::default(), || {
let c = turbopack_binding::swc::core::base::Compiler::new(Arc::new(SourceMap::new(
FilePathMapping::empty(),
)));
if !DYNAMIC_METADATA_ROUTE_SHORT_CURCUIT.is_match(file_content.as_str()) {
return Ok(None);
}
let options: ParseOptions = serde_json::from_slice(self.options.as_ref())?;
let comments = c.comments().clone();
let comments: Option<&dyn Comments> = if options.comments {
Some(&comments)
} else {
None
};
let fm =
c.cm.new_source_file(self.filename.clone(), self.src.clone());
let program = try_with_handler(
c.cm.clone(),
turbopack_binding::swc::core::base::HandlerOpts {
color: ColorConfig::Never,
skip_filename: false,
},
|handler| {
c.parse_js(
fm,
handler,
options.target,
options.syntax,
options.is_module,
comments,
)
},
)
.convert_err()?;
let (source_ast, _) = build_ast_from_source(&file_content, &self.page_file_path)?;
collect_exports(&source_ast).convert_err()
}
let ast_json = serde_json::to_string(&program)
.context("failed to serialize Program")
.convert_err()?;
fn resolve(&mut self, env: Env, exports_info: Self::Output) -> napi::Result<Self::JsValue> {
let mut ret = env.create_object()?;
let mut warnings = env.create_array(0)?;
match exports_info {
Some(exports_info) => {
let is_dynamic_metadata_route =
!exports_info.generate_image_metadata.unwrap_or_default()
|| !exports_info.generate_sitemaps.unwrap_or_default();
ret.set_named_property(
"isDynamicMetadataRoute",
env.get_boolean(is_dynamic_metadata_route),
)?;
for ExportInfoWarning { key, message } in exports_info.warnings {
let mut warning_obj = env.create_object()?;
warning_obj.set_named_property("key", env.create_string(&key)?)?;
warning_obj.set_named_property("message", env.create_string(&message)?)?;
warnings.insert(warning_obj)?;
}
ret.set_named_property("warnings", warnings)?;
}
None => {
ret.set_named_property("warnings", warnings)?;
ret.set_named_property("isDynamicMetadataRoute", env.get_boolean(false))?;
}
}
Ok(ret)
}
}
/// Detect if metadata routes is a dynamic route, which containing
/// generateImageMetadata or generateSitemaps as export
#[napi]
pub fn is_dynamic_metadata_route(
page_file_path: String,
file_content: Option<String>,
) -> AsyncTask<DetectMetadataRouteTask> {
AsyncTask::new(DetectMetadataRouteTask {
page_file_path,
file_content,
})
}
#[napi(object, object_to_js = false)]
pub struct CollectPageStaticInfoOption {
pub page_file_path: String,
pub is_dev: Option<bool>,
pub page: Option<String>,
pub page_type: String, //'pages' | 'app' | 'root'
}
pub struct CollectPageStaticInfoTask {
option: CollectPageStaticInfoOption,
file_content: Option<String>,
}
#[napi]
impl Task for CollectPageStaticInfoTask {
type Output = Option<(
ExportInfo,
HashMap<String, Value>,
RscModuleInfo,
Vec<String>,
)>;
type JsValue = Option<String>;
fn compute(&mut self) -> napi::Result<Self::Output> {
let CollectPageStaticInfoOption {
page_file_path,
is_dev,
..
} = &self.option;
let file_content = if let Some(file_content) = &self.file_content {
file_content.clone()
} else {
read_file_wrapped_err(page_file_path.as_str(), !is_dev.unwrap_or_default())?
};
if !PAGE_STATIC_INFO_SHORT_CURCUIT.is_match(file_content.as_str()) {
return Ok(None);
}
let (source_ast, comments) = build_ast_from_source(&file_content, page_file_path)?;
let exports_info = collect_exports(&source_ast)?;
match exports_info {
None => Ok(None),
Some(exports_info) => {
let rsc_info = collect_rsc_module_info(&comments, true);
let mut properties_to_extract = exports_info.extra_properties.clone();
properties_to_extract.insert("config".to_string());
let mut exported_const_values =
extract_expored_const_values(&source_ast, properties_to_extract);
let mut extracted_values = HashMap::new();
let mut warnings = vec![];
for (key, value) in exported_const_values.drain() {
match value {
Some(Const::Value(v)) => {
extracted_values.insert(key.clone(), v);
}
Some(Const::Unsupported(msg)) => {
warnings.push(msg);
}
_ => {}
}
}
Ok(Some((exports_info, extracted_values, rsc_info, warnings)))
}
}
Ok(ast_json)
})
}
fn resolve(&mut self, _env: Env, result: Self::Output) -> napi::Result<Self::JsValue> {
if let Some((exports_info, extracted_values, rsc_info, warnings)) = result {
// [TODO] this is stopgap; there are some non n-api serializable types in the
// nested result. However, this is still much smaller than passing whole ast.
// Should go away once all of logics in the getPageStaticInfo is internalized.
let ret = StaticPageInfo {
exports_info: Some(exports_info),
extracted_values,
rsc_info: Some(rsc_info),
warnings,
};
let ret = serde_json::to_string(&ret)
.context("failed to serialize static info result")
.convert_err()?;
Ok(Some(ret))
} else {
Ok(None)
}
Ok(result)
}
}
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StaticPageInfo {
pub exports_info: Option<ExportInfo>,
pub extracted_values: HashMap<String, Value>,
pub rsc_info: Option<RscModuleInfo>,
pub warnings: Vec<String>,
}
#[napi]
pub fn get_page_static_info(
option: CollectPageStaticInfoOption,
file_content: Option<String>,
) -> AsyncTask<CollectPageStaticInfoTask> {
AsyncTask::new(CollectPageStaticInfoTask {
option,
file_content,
})
pub fn parse(
src: String,
options: Buffer,
filename: Option<String>,
signal: Option<AbortSignal>,
) -> AsyncTask<ParseTask> {
let filename = if let Some(value) = filename {
FileName::Real(value.into())
} else {
FileName::Anon
};
AsyncTask::with_optional_signal(
ParseTask {
filename,
src,
options,
},
signal,
)
}

View file

@ -26,7 +26,6 @@ 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",

View file

@ -8,7 +8,6 @@ pub mod import_analyzer;
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;

View file

@ -1,210 +0,0 @@
use std::collections::{HashMap, HashSet};
use serde_json::{Map, Number, Value};
use swc_core::{
common::{pass::AstNodePath, Mark, SyntaxContext},
ecma::{
ast::{
BindingIdent, Decl, ExportDecl, Expr, Lit, Pat, Prop, PropName, PropOrSpread, VarDecl,
VarDeclKind, VarDeclarator,
},
utils::{ExprCtx, ExprExt},
visit::{AstParentNodeRef, VisitAstPath, VisitWithPath},
},
};
/// 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<String, Option<Const>>,
expr_ctx: ExprCtx,
}
impl CollectExportedConstVisitor {
pub fn new(properties_to_extract: HashSet<String>) -> 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 VisitAstPath for CollectExportedConstVisitor {
fn visit_export_decl<'ast: 'r, 'r>(
&mut self,
export_decl: &'ast ExportDecl,
ast_path: &mut AstNodePath<AstParentNodeRef<'r>>,
) {
match &export_decl.decl {
Decl::Var(box VarDecl { kind, decls, .. }) 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());
};
}
}
}
_ => {}
}
export_decl.visit_children_with_path(self, ast_path);
}
}
/// Coerece the actual value of the given ast node.
fn extract_value(ctx: &ExprCtx, init: &Expr, id: String) -> Option<Const> {
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),
_ => {
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 kv = match prop {
PropOrSpread::Prop(box Prop::KeyValue(kv)) => match kv.key {
PropName::Ident(_) | PropName::Str(_) => kv,
_ => {
return Some(Const::Unsupported(format!(
"Unsupported key type in the Object Expression at \"{}\"",
id
)))
}
},
_ => {
return Some(Const::Unsupported(format!(
"Unsupported spread operator in the Object Expression at \"{}\"",
id
)))
}
};
let key = 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",
kv.key
)))
}
};
let new_value = extract_value(ctx, &kv.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
))),
}
}

View file

@ -1,183 +0,0 @@
use std::collections::HashSet;
use lazy_static::lazy_static;
use 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<ExportInfo>,
}
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]) {
for stmt in stmts {
if let ModuleItem::Stmt(Stmt::Expr(ExprStmt {
expr: box Expr::Lit(Lit::Str(Str { value, .. })),
..
})) = stmt
{
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());
}
}
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);
}
}

View file

@ -1,375 +0,0 @@
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::Arc,
};
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 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},
visit::{VisitWith, VisitWithPath},
},
};
pub mod collect_exported_const_visitor;
pub mod collect_exports_visitor;
/// Parse given contents of the file as ecmascript via swc's parser.
/// [NOTE] this is being used outside of turbopack (next.js's analysis phase)
/// currently, so we can't use turbopack-ecmascript's parse.
pub 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))
})
}
#[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<String>, // 'nodejs' | 'experimental-edge' | 'edge'
pub preferred_region: Vec<String>,
pub ssg: Option<bool>,
pub ssr: Option<bool>,
pub rsc: Option<String>, // 'server' | 'client'
pub generate_static_params: Option<bool>,
pub middleware: Option<MiddlewareConfig>,
pub amp: Option<Amp>,
}
#[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<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub preferred_region: Vec<String>,
pub generate_image_metadata: Option<bool>,
pub generate_sitemaps: Option<bool>,
pub generate_static_params: bool,
pub extra_properties: HashSet<String>,
pub directives: HashSet<String>,
/// extra properties to bubble up warning messages from visitor,
/// since this isn't a failure to abort the process.
pub warnings: Vec<ExportInfoWarning>,
}
/// 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<Option<ExportInfo>> {
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<Regex> = Lazy::new(|| {
Regex::new(" __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) ").unwrap()
});
static ACTION_MODULE_LABEL: Lazy<Regex> =
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<Vec<String>>,
pub is_client_ref: bool,
pub client_refs: Option<Vec<String>>,
pub client_entry_type: Option<String>,
}
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::<serde_json::Value>(&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::<Vec<_>>(),
)
} 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::<Vec<_>>(),
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<String>,
) -> HashMap<String, Option<Const>> {
GLOBALS.set(&Default::default(), || {
let mut visitor =
collect_exported_const_visitor::CollectExportedConstVisitor::new(properties_to_extract);
source_ast.visit_with_path(&mut visitor, &mut Default::default());
visitor.properties
})
}
#[cfg(test)]
mod tests {
use super::{build_ast_from_source, collect_rsc_module_info, RscModuleInfo};
#[test]
fn should_parse_server_info() {
let input = r#"export default function Page() {
return <p>app-edge-ssr</p>
}
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);
}
}

View file

@ -35,8 +35,8 @@ turbopack-binding = { workspace = true, features = [
"__swc_core_binding_wasm",
"__feature_mdx_rs",
] }
swc_core = { workspace = true, features = ["ecma_ast_serde", "common", "ecma_visit_path"] }
regex = "1.5"
swc_core = { workspace = true, features = ["ecma_ast_serde", "common"] }
# Workaround a bug
[package.metadata.wasm-pack.profile.release]

View file

@ -1,23 +1,18 @@
use std::{collections::HashMap, sync::Arc};
use std::sync::Arc;
use anyhow::{Context, Error};
use js_sys::JsString;
use next_custom_transforms::{
chain_transforms::{custom_before_pass, TransformOptions},
transforms::page_static_info::{
build_ast_from_source, collect_exports, collect_rsc_module_info,
extract_expored_const_values, Const, ExportInfo, RscModuleInfo,
},
};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use next_custom_transforms::chain_transforms::{custom_before_pass, TransformOptions};
use swc_core::common::Mark;
use turbopack_binding::swc::core::{
base::{config::JsMinifyOptions, try_with_handler, Compiler},
base::{
config::{JsMinifyOptions, ParseOptions},
try_with_handler, Compiler,
},
common::{
comments::SingleThreadedComments, errors::ColorConfig, FileName, FilePathMapping,
SourceMap, GLOBALS,
comments::{Comments, SingleThreadedComments},
errors::ColorConfig,
FileName, FilePathMapping, SourceMap, GLOBALS,
},
ecma::transforms::base::pass::noop,
};
@ -26,21 +21,6 @@ use wasm_bindgen_futures::future_to_promise;
pub mod mdx;
/// A regex pattern to determine if is_dynamic_metadata_route should continue to
/// parse the page or short circuit and return false.
static DYNAMIC_METADATA_ROUTE_SHORT_CURCUIT: Lazy<Regex> =
Lazy::new(|| Regex::new("generateImageMetadata|generateSitemaps").unwrap());
/// A regex pattern to determine if get_page_static_info should continue to
/// parse the page or short circuit and return default.
static PAGE_STATIC_INFO_SHORT_CURCUIT: Lazy<Regex> = Lazy::new(|| {
Regex::new(
"runtime|preferredRegion|getStaticProps|getServerSideProps|generateStaticParams|export \
const",
)
.unwrap()
});
fn convert_err(err: Error) -> JsValue {
format!("{:?}", err).into()
}
@ -157,97 +137,57 @@ pub fn transform(s: JsValue, opts: JsValue) -> js_sys::Promise {
future_to_promise(async { transform_sync(s, opts) })
}
/// Detect if metadata routes is a dynamic route, which containing
/// generateImageMetadata or generateSitemaps as export
/// Unlike native bindings, caller should provide the contents of the pages
/// sine our wasm bindings does not have access to the file system
#[wasm_bindgen(js_name = "isDynamicMetadataRoute")]
pub fn is_dynamic_metadata_route(page_file_path: String, page_contents: String) -> js_sys::Promise {
// Returning promise to conform existing interfaces
future_to_promise(async move {
if !DYNAMIC_METADATA_ROUTE_SHORT_CURCUIT.is_match(&page_contents) {
return Ok(JsValue::from(false));
}
#[wasm_bindgen(js_name = "parseSync")]
pub fn parse_sync(s: JsString, opts: JsValue) -> Result<JsValue, JsValue> {
console_error_panic_hook::set_once();
let (source_ast, _) = build_ast_from_source(&page_contents, &page_file_path)
.map_err(|e| JsValue::from_str(format!("{:?}", e).as_str()))?;
collect_exports(&source_ast)
.map(|exports_info| {
exports_info
.map(|exports_info| {
JsValue::from(
!exports_info.generate_image_metadata.unwrap_or_default()
|| !exports_info.generate_sitemaps.unwrap_or_default(),
let c = turbopack_binding::swc::core::base::Compiler::new(Arc::new(SourceMap::new(
FilePathMapping::empty(),
)));
let opts: ParseOptions = serde_wasm_bindgen::from_value(opts)?;
try_with_handler(
c.cm.clone(),
turbopack_binding::swc::core::base::HandlerOpts {
..Default::default()
},
|handler| {
c.run(|| {
GLOBALS.set(&Default::default(), || {
let fm = c.cm.new_source_file(FileName::Anon, s.into());
let cmts = c.comments().clone();
let comments = if opts.comments {
Some(&cmts as &dyn Comments)
} else {
None
};
let program = c
.parse_js(
fm,
handler,
opts.target,
opts.syntax,
opts.is_module,
comments,
)
})
.unwrap_or_default()
.context("failed to parse code")?;
let s = serde_json::to_string(&program).unwrap();
Ok(JsValue::from_str(&s))
})
})
.map_err(|e| JsValue::from_str(format!("{:?}", e).as_str()))
})
},
)
.map_err(convert_err)
}
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StaticPageInfo {
pub exports_info: Option<ExportInfo>,
pub extracted_values: HashMap<String, serde_json::Value>,
pub rsc_info: Option<RscModuleInfo>,
pub warnings: Vec<String>,
}
#[wasm_bindgen(js_name = "getPageStaticInfo")]
pub fn get_page_static_info(page_file_path: String, page_contents: String) -> js_sys::Promise {
future_to_promise(async move {
if !PAGE_STATIC_INFO_SHORT_CURCUIT.is_match(&page_contents) {
return Ok(JsValue::null());
}
let (source_ast, comments) = build_ast_from_source(&page_contents, &page_file_path)
.map_err(|e| JsValue::from_str(format!("{:?}", e).as_str()))?;
let exports_info = collect_exports(&source_ast)
.map_err(|e| JsValue::from_str(format!("{:?}", e).as_str()))?;
match exports_info {
None => Ok(JsValue::null()),
Some(exports_info) => {
let rsc_info = collect_rsc_module_info(&comments, true);
let mut properties_to_extract = exports_info.extra_properties.clone();
properties_to_extract.insert("config".to_string());
let mut exported_const_values =
extract_expored_const_values(&source_ast, properties_to_extract);
let mut extracted_values = HashMap::new();
let mut warnings = vec![];
for (key, value) in exported_const_values.drain() {
match value {
Some(Const::Value(v)) => {
extracted_values.insert(key.clone(), v);
}
Some(Const::Unsupported(msg)) => {
warnings.push(msg);
}
_ => {}
}
}
let ret = StaticPageInfo {
exports_info: Some(exports_info),
extracted_values,
rsc_info: Some(rsc_info),
warnings,
};
let s = serde_json::to_string(&ret)
.map(|s| JsValue::from_str(&s))
.unwrap_or(JsValue::null());
Ok(s)
}
}
})
#[wasm_bindgen(js_name = "parse")]
pub fn parse(s: JsString, opts: JsValue) -> js_sys::Promise {
// TODO: This'll be properly scheduled once wasm have standard backed thread
// support.
future_to_promise(async { parse_sync(s, opts) })
}
/// Get global sourcemap

View file

@ -0,0 +1,249 @@
import type {
ArrayExpression,
BooleanLiteral,
ExportDeclaration,
Identifier,
KeyValueProperty,
Module,
Node,
NullLiteral,
NumericLiteral,
ObjectExpression,
RegExpLiteral,
StringLiteral,
TemplateLiteral,
VariableDeclaration,
} from '@swc/core'
export class NoSuchDeclarationError extends Error {}
function isExportDeclaration(node: Node): node is ExportDeclaration {
return node.type === 'ExportDeclaration'
}
function isVariableDeclaration(node: Node): node is VariableDeclaration {
return node.type === 'VariableDeclaration'
}
function isIdentifier(node: Node): node is Identifier {
return node.type === 'Identifier'
}
function isBooleanLiteral(node: Node): node is BooleanLiteral {
return node.type === 'BooleanLiteral'
}
function isNullLiteral(node: Node): node is NullLiteral {
return node.type === 'NullLiteral'
}
function isStringLiteral(node: Node): node is StringLiteral {
return node.type === 'StringLiteral'
}
function isNumericLiteral(node: Node): node is NumericLiteral {
return node.type === 'NumericLiteral'
}
function isArrayExpression(node: Node): node is ArrayExpression {
return node.type === 'ArrayExpression'
}
function isObjectExpression(node: Node): node is ObjectExpression {
return node.type === 'ObjectExpression'
}
function isKeyValueProperty(node: Node): node is KeyValueProperty {
return node.type === 'KeyValueProperty'
}
function isRegExpLiteral(node: Node): node is RegExpLiteral {
return node.type === 'RegExpLiteral'
}
function isTemplateLiteral(node: Node): node is TemplateLiteral {
return node.type === 'TemplateLiteral'
}
export class UnsupportedValueError extends Error {
/** @example `config.runtime[0].value` */
path?: string
constructor(message: string, paths?: string[]) {
super(message)
// Generating "path" that looks like "config.runtime[0].value"
let codePath: string | undefined
if (paths) {
codePath = ''
for (const path of paths) {
if (path[0] === '[') {
// "array" + "[0]"
codePath += path
} else {
if (codePath === '') {
codePath = path
} else {
// "object" + ".key"
codePath += `.${path}`
}
}
}
}
this.path = codePath
}
}
function extractValue(node: Node, path?: string[]): any {
if (isNullLiteral(node)) {
return null
} else if (isBooleanLiteral(node)) {
// e.g. true / false
return node.value
} else if (isStringLiteral(node)) {
// e.g. "abc"
return node.value
} else if (isNumericLiteral(node)) {
// e.g. 123
return node.value
} else if (isRegExpLiteral(node)) {
// e.g. /abc/i
return new RegExp(node.pattern, node.flags)
} else if (isIdentifier(node)) {
switch (node.value) {
case 'undefined':
return undefined
default:
throw new UnsupportedValueError(
`Unknown identifier "${node.value}"`,
path
)
}
} else if (isArrayExpression(node)) {
// e.g. [1, 2, 3]
const arr = []
for (let i = 0, len = node.elements.length; i < len; i++) {
const elem = node.elements[i]
if (elem) {
if (elem.spread) {
// e.g. [ ...a ]
throw new UnsupportedValueError(
'Unsupported spread operator in the Array Expression',
path
)
}
arr.push(extractValue(elem.expression, path && [...path, `[${i}]`]))
} else {
// e.g. [1, , 2]
// ^^
arr.push(undefined)
}
}
return arr
} else if (isObjectExpression(node)) {
// e.g. { a: 1, b: 2 }
const obj: any = {}
for (const prop of node.properties) {
if (!isKeyValueProperty(prop)) {
// e.g. { ...a }
throw new UnsupportedValueError(
'Unsupported spread operator in the Object Expression',
path
)
}
let key
if (isIdentifier(prop.key)) {
// e.g. { a: 1, b: 2 }
key = prop.key.value
} else if (isStringLiteral(prop.key)) {
// e.g. { "a": 1, "b": 2 }
key = prop.key.value
} else {
throw new UnsupportedValueError(
`Unsupported key type "${prop.key.type}" in the Object Expression`,
path
)
}
obj[key] = extractValue(prop.value, path && [...path, key])
}
return obj
} else if (isTemplateLiteral(node)) {
// e.g. `abc`
if (node.expressions.length !== 0) {
// TODO: should we add support for `${'e'}d${'g'}'e'`?
throw new UnsupportedValueError(
'Unsupported template literal with expressions',
path
)
}
// 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
const [{ cooked, raw }] = node.quasis
return cooked ?? raw
} else {
throw new UnsupportedValueError(
`Unsupported node type "${node.type}"`,
path
)
}
}
/**
* 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 (or throws UnsupportedValueError):
* - string
* - boolean
* - number
* - null
* - undefined
* - array containing values listed in this list
* - object containing values listed in this list
*
* Throws NoSuchDeclarationError if the declaration is not found.
*/
export function extractExportedConstValue(
module: Module,
exportedName: string
): any {
for (const moduleItem of module.body) {
if (!isExportDeclaration(moduleItem)) {
continue
}
const declaration = moduleItem.declaration
if (!isVariableDeclaration(declaration)) {
continue
}
if (declaration.kind !== 'const') {
continue
}
for (const decl of declaration.declarations) {
if (
isIdentifier(decl.id) &&
decl.id.value === exportedName &&
decl.init
) {
return extractValue(decl.init, [exportedName])
}
}
}
throw new NoSuchDeclarationError()
}

View file

@ -1,9 +1,15 @@
import type { NextConfig } from '../../server/config-shared'
import type { Middleware, RouteHas } from '../../lib/load-custom-routes'
import { promises as fs } from 'fs'
import LRUCache from 'next/dist/compiled/lru-cache'
import picomatch from 'next/dist/compiled/picomatch'
import type { ServerRuntime } from 'next/types'
import {
extractExportedConstValue,
UnsupportedValueError,
} from './extract-const-value'
import { parseModule } from './parse-module'
import * as Log from '../output/log'
import { SERVER_RUNTIME } from '../../lib/constants'
import { checkCustomRoutes } from '../../lib/load-custom-routes'
@ -50,6 +56,9 @@ const CLIENT_MODULE_LABEL =
const ACTION_MODULE_LABEL =
/\/\* __next_internal_action_entry_do_not_use__ (\{[^}]+\}) \*\//
const CLIENT_DIRECTIVE = 'use client'
const SERVER_ACTION_DIRECTIVE = 'use server'
export type RSCModuleType = 'server' | 'client'
export function getRSCModuleInformation(
source: string,
@ -84,6 +93,211 @@ export function getRSCModuleInformation(
}
}
const warnedInvalidValueMap = {
runtime: new Map<string, boolean>(),
preferredRegion: new Map<string, boolean>(),
} as const
function warnInvalidValue(
pageFilePath: string,
key: keyof typeof warnedInvalidValueMap,
message: string
): void {
if (warnedInvalidValueMap[key].has(pageFilePath)) return
Log.warn(
`Next.js can't recognize the exported \`${key}\` field in "${pageFilePath}" as ${message}.` +
'\n' +
'The default runtime will be used instead.'
)
warnedInvalidValueMap[key].set(pageFilePath, true)
}
/**
* Receives a parsed AST from SWC and checks if it belongs to a module that
* requires a runtime to be specified. Those are:
* - Modules with `export function getStaticProps | getServerSideProps`
* - Modules with `export { getStaticProps | getServerSideProps } <from ...>`
* - Modules with `export const runtime = ...`
*/
function checkExports(
swcAST: any,
pageFilePath: string
): {
ssr: boolean
ssg: boolean
runtime?: string
preferredRegion?: string | string[]
generateImageMetadata?: boolean
generateSitemaps?: boolean
generateStaticParams: boolean
extraProperties?: Set<string>
directives?: Set<string>
} {
const exportsSet = new Set<string>([
'getStaticProps',
'getServerSideProps',
'generateImageMetadata',
'generateSitemaps',
'generateStaticParams',
])
if (Array.isArray(swcAST?.body)) {
try {
let runtime: string | undefined
let preferredRegion: string | string[] | undefined
let ssr: boolean = false
let ssg: boolean = false
let generateImageMetadata: boolean = false
let generateSitemaps: boolean = false
let generateStaticParams = false
let extraProperties = new Set<string>()
let directives = new Set<string>()
let hasLeadingNonDirectiveNode = false
for (const node of swcAST.body) {
// There should be no non-string literals nodes before directives
if (
node.type === 'ExpressionStatement' &&
node.expression.type === 'StringLiteral'
) {
if (!hasLeadingNonDirectiveNode) {
const directive = node.expression.value
if (CLIENT_DIRECTIVE === directive) {
directives.add('client')
}
if (SERVER_ACTION_DIRECTIVE === directive) {
directives.add('server')
}
}
} else {
hasLeadingNonDirectiveNode = true
}
if (
node.type === 'ExportDeclaration' &&
node.declaration?.type === 'VariableDeclaration'
) {
for (const declaration of node.declaration?.declarations) {
if (declaration.id.value === 'runtime') {
runtime = declaration.init.value
} else if (declaration.id.value === 'preferredRegion') {
if (declaration.init.type === 'ArrayExpression') {
const elements: string[] = []
for (const element of declaration.init.elements) {
const { expression } = element
if (expression.type !== 'StringLiteral') {
continue
}
elements.push(expression.value)
}
preferredRegion = elements
} else {
preferredRegion = declaration.init.value
}
} else {
extraProperties.add(declaration.id.value)
}
}
}
if (
node.type === 'ExportDeclaration' &&
node.declaration?.type === 'FunctionDeclaration' &&
exportsSet.has(node.declaration.identifier?.value)
) {
const id = node.declaration.identifier.value
ssg = id === 'getStaticProps'
ssr = id === 'getServerSideProps'
generateImageMetadata = id === 'generateImageMetadata'
generateSitemaps = id === 'generateSitemaps'
generateStaticParams = id === 'generateStaticParams'
}
if (
node.type === 'ExportDeclaration' &&
node.declaration?.type === 'VariableDeclaration'
) {
const id = node.declaration?.declarations[0]?.id.value
if (exportsSet.has(id)) {
ssg = id === 'getStaticProps'
ssr = id === 'getServerSideProps'
generateImageMetadata = id === 'generateImageMetadata'
generateSitemaps = id === 'generateSitemaps'
generateStaticParams = id === 'generateStaticParams'
}
}
if (node.type === 'ExportNamedDeclaration') {
const values = node.specifiers.map(
(specifier: any) =>
specifier.type === 'ExportSpecifier' &&
specifier.orig?.type === 'Identifier' &&
specifier.orig?.value
)
for (const value of values) {
if (!ssg && value === 'getStaticProps') ssg = true
if (!ssr && value === 'getServerSideProps') ssr = true
if (!generateImageMetadata && value === 'generateImageMetadata')
generateImageMetadata = true
if (!generateSitemaps && value === 'generateSitemaps')
generateSitemaps = true
if (!generateStaticParams && value === 'generateStaticParams')
generateStaticParams = true
if (!runtime && value === 'runtime')
warnInvalidValue(
pageFilePath,
'runtime',
'it was not assigned to a string literal'
)
if (!preferredRegion && value === 'preferredRegion')
warnInvalidValue(
pageFilePath,
'preferredRegion',
'it was not assigned to a string literal or an array of string literals'
)
}
}
}
return {
ssr,
ssg,
runtime,
preferredRegion,
generateImageMetadata,
generateSitemaps,
generateStaticParams,
extraProperties,
directives,
}
} catch (err) {}
}
return {
ssg: false,
ssr: false,
runtime: undefined,
preferredRegion: undefined,
generateImageMetadata: false,
generateSitemaps: false,
generateStaticParams: false,
extraProperties: undefined,
directives: undefined,
}
}
async function tryToReadFile(filePath: string, shouldThrow: boolean) {
try {
return await fs.readFile(filePath, {
encoding: 'utf8',
})
} catch (error: any) {
if (shouldThrow) {
error.message = `Next.js ERROR: Failed to read file ${filePath}:\n${error.message}`
throw error
}
}
}
export function getMiddlewareMatchers(
matcherOrMatchers: unknown,
nextConfig: NextConfig
@ -216,11 +430,10 @@ function warnAboutExperimentalEdge(apiRoute: string | null) {
const warnedUnsupportedValueMap = new LRUCache<string, boolean>({ max: 250 })
// [TODO] next-swc does not returns path where unsupported value is found yet.
function warnAboutUnsupportedValue(
pageFilePath: string,
page: string | undefined,
message: string
error: UnsupportedValueError
) {
if (warnedUnsupportedValueMap.has(pageFilePath)) {
return
@ -230,8 +443,9 @@ function warnAboutUnsupportedValue(
`Next.js can't recognize the exported \`config\` field in ` +
(page ? `route "${page}"` : `"${pageFilePath}"`) +
':\n' +
message +
'\n' +
error.message +
(error.path ? ` at "${error.path}"` : '') +
'.\n' +
'The default config will be used instead.\n' +
'Read More - https://nextjs.org/docs/messages/invalid-page-config'
)
@ -239,6 +453,20 @@ function warnAboutUnsupportedValue(
warnedUnsupportedValueMap.set(pageFilePath, true)
}
// Detect if metadata routes is a dynamic route, which containing
// generateImageMetadata or generateSitemaps as export
export async function isDynamicMetadataRoute(
pageFilePath: string
): Promise<boolean> {
const fileContent = (await tryToReadFile(pageFilePath, true)) || ''
if (/generateImageMetadata|generateSitemaps/.test(fileContent)) {
const swcAST = await parseModule(pageFilePath, fileContent)
const exportsInfo = checkExports(swcAST, pageFilePath)
return !!(exportsInfo.generateImageMetadata || exportsInfo.generateSitemaps)
}
return false
}
/**
* For a given pageFilePath and nextConfig, if the config supports it, this
* function will read the file and return the runtime that should be used.
@ -255,12 +483,13 @@ export async function getPageStaticInfo(params: {
}): Promise<PageStaticInfo> {
const { isDev, pageFilePath, nextConfig, page, pageType } = params
const binding = await require('../swc').loadBindings()
const pageStaticInfo = await binding.analysis.getPageStaticInfo(params)
if (pageStaticInfo) {
const { exportsInfo, extractedValues, rscInfo, warnings } = pageStaticInfo
const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || ''
if (
/(?<!(_jsx|jsx-))runtime|preferredRegion|getStaticProps|getServerSideProps|generateStaticParams|export const/.test(
fileContent
)
) {
const swcAST = await parseModule(pageFilePath, fileContent)
const {
ssg,
ssr,
@ -269,22 +498,33 @@ export async function getPageStaticInfo(params: {
generateStaticParams,
extraProperties,
directives,
} = exportsInfo
} = checkExports(swcAST, pageFilePath)
const rscInfo = getRSCModuleInformation(fileContent, true)
const rsc = rscInfo.type
warnings?.forEach((warning: string) => {
warnAboutUnsupportedValue(pageFilePath, page, warning)
})
// default / failsafe value for config
let config = extractedValues.config
let config: any
try {
config = extractExportedConstValue(swcAST, 'config')
} catch (e) {
if (e instanceof UnsupportedValueError) {
warnAboutUnsupportedValue(pageFilePath, page, e)
}
// `export config` doesn't exist, or other unknown error throw by swc, silence them
}
const extraConfig: Record<string, any> = {}
if (extraProperties && pageType === PAGE_TYPES.APP) {
for (const prop of extraProperties) {
if (!AUTHORIZED_EXTRA_ROUTER_PROPS.includes(prop)) continue
extraConfig[prop] = extractedValues[prop]
try {
extraConfig[prop] = extractExportedConstValue(swcAST, prop)
} catch (e) {
if (e instanceof UnsupportedValueError) {
warnAboutUnsupportedValue(pageFilePath, page, e)
}
}
}
} else if (pageType === PAGE_TYPES.PAGES) {
for (const key in config) {

View file

@ -0,0 +1,15 @@
import LRUCache from 'next/dist/compiled/lru-cache'
import { withPromiseCache } from '../../lib/with-promise-cache'
import { createHash } from 'crypto'
import { parse } from '../swc'
/**
* Parses a module with SWC using an LRU cache where the parsed module will
* be indexed by a sha of its content holding up to 500 entries.
*/
export const parseModule = withPromiseCache(
new LRUCache<string, any>({ max: 500 }),
async (filename: string, content: string) =>
parse(content, { isModule: 'unknown', filename }).catch(() => null),
(_, content) => createHash('sha1').update(content).digest('hex')
)

View file

@ -94,7 +94,10 @@ import {
} from '../telemetry/events'
import type { EventBuildFeatureUsage } from '../telemetry/events'
import { Telemetry } from '../telemetry/storage'
import { getPageStaticInfo } from './analysis/get-page-static-info'
import {
isDynamicMetadataRoute,
getPageStaticInfo,
} from './analysis/get-page-static-info'
import { createPagesMapping, getPageFilePath, sortByPageExts } from './entries'
import { PAGE_TYPES } from '../lib/page-types'
import { generateBuildId } from './generate-build-id'
@ -123,7 +126,6 @@ import { recursiveCopy } from '../lib/recursive-copy'
import { recursiveReadDir } from '../lib/recursive-readdir'
import {
lockfilePatchPromise,
loadBindings,
teardownTraceSubscriber,
teardownHeapProfiler,
} from './swc'
@ -762,6 +764,7 @@ export default async function build(
const cacheDir = getCacheDir(distDir)
const telemetry = new Telemetry({ distDir })
setGlobal('telemetry', telemetry)
const publicDir = path.join(dir, 'public')
@ -769,8 +772,6 @@ export default async function build(
NextBuildContext.pagesDir = pagesDir
NextBuildContext.appDir = appDir
const binding = await loadBindings(config?.experimental?.useWasmBinary)
const enabledDirectories: NextEnabledDirectories = {
app: typeof appDir === 'string',
pages: typeof pagesDir === 'string',
@ -962,10 +963,7 @@ export default async function build(
rootDir,
})
const isDynamic = await binding.analysis.isDynamicMetadataRoute(
pageFilePath
)
const isDynamic = await isDynamicMetadataRoute(pageFilePath)
if (!isDynamic) {
delete mappedAppPages[pageKey]
mappedAppPages[pageKey.replace('[[...__metadata_id__]]/', '')] =

View file

@ -2,9 +2,9 @@
import path from 'path'
import { pathToFileURL } from 'url'
import { platform, arch } from 'os'
import { promises as fs } from 'fs'
import { platformArchTriples } from 'next/dist/compiled/@napi-rs/triples'
import * as Log from '../output/log'
import { getParserOptions } from './options'
import { eventSwcLoadFailure } from '../../telemetry/events/swc-load-failure'
import { patchIncorrectLockfile } from '../../lib/patch-incorrect-lockfile'
import { downloadWasmSwc, downloadNativeNextSwc } from '../../lib/download-swc'
@ -15,7 +15,6 @@ import type { DefineEnvPluginOptions } from '../webpack/plugins/define-env-plugi
import type { PageExtensions } from '../page-extensions-type'
const nextVersion = process.env.__NEXT_VERSION as string
const isYarnPnP = !!process?.versions?.pnp
const ArchName = arch()
const PlatformName = platform()
@ -109,19 +108,6 @@ function checkVersionMismatch(pkgData: any) {
}
}
async function tryToReadFile(filePath: string, shouldThrow: boolean) {
try {
return await fs.readFile(filePath, {
encoding: 'utf8',
})
} catch (error: any) {
if (shouldThrow) {
error.message = `Next.js ERROR: Failed to read file ${filePath}:\n${error.message}`
throw error
}
}
}
// These are the platforms we'll try to load wasm bindings first,
// only try to load native bindings if loading wasm binding somehow fails.
// Fallback to native binding is for migration period only,
@ -174,13 +160,12 @@ export interface Binding {
turboEngineOptions?: TurboEngineOptions
) => Promise<Project>
}
analysis: {
isDynamicMetadataRoute(pageFilePath: string): Promise<boolean>
}
minify: any
minifySync: any
transform: any
transformSync: any
parse: any
parseSync: any
getTargetTriple(): string | undefined
initCustomTraceSubscriber?: any
teardownTraceSubscriber?: any
@ -1147,26 +1132,6 @@ function bindingToApi(binding: any, _wasm: boolean) {
return createProject
}
const warnedInvalidValueMap = {
runtime: new Map<string, boolean>(),
preferredRegion: new Map<string, boolean>(),
} as const
function warnInvalidValue(
pageFilePath: string,
key: keyof typeof warnedInvalidValueMap,
message: string
): void {
if (warnedInvalidValueMap[key].has(pageFilePath)) return
Log.warn(
`Next.js can't recognize the exported \`${key}\` field in "${pageFilePath}" as ${message}.` +
'\n' +
'The default runtime will be used instead.'
)
warnedInvalidValueMap[key].set(pageFilePath, true)
}
async function loadWasm(importPath = '') {
if (wasmBindings) {
return wasmBindings
@ -1208,33 +1173,14 @@ async function loadWasm(importPath = '') {
minifySync(src: string, options: any) {
return bindings.minifySync(src.toString(), options)
},
analysis: {
isDynamicMetadataRoute: async (pageFilePath: string) => {
const fileContent = (await tryToReadFile(pageFilePath, true)) || ''
const { isDynamicMetadataRoute, warnings } =
await bindings.isDynamicMetadataRoute(pageFilePath, fileContent)
warnings?.forEach(
({
key,
message,
}: {
key: keyof typeof warnedInvalidValueMap
message: string
}) => warnInvalidValue(pageFilePath, key, message)
)
return isDynamicMetadataRoute
},
getPageStaticInfo: async (params: Record<string, any>) => {
const fileContent =
(await tryToReadFile(params.pageFilePath, !params.isDev)) || ''
const raw = await bindings.getPageStaticInfo(
params.pageFilePath,
fileContent
)
return coercePageStaticInfo(params.pageFilePath, raw)
},
parse(src: string, options: any) {
return bindings?.parse
? bindings.parse(src.toString(), options)
: Promise.resolve(bindings.parseSync(src.toString(), options))
},
parseSync(src: string, options: any) {
const astStr = bindings.parseSync(src.toString(), options)
return astStr
},
getTargetTriple() {
return undefined
@ -1399,40 +1345,8 @@ function loadNative(importPath?: string) {
return bindings.minifySync(toBuffer(src), toBuffer(options ?? {}))
},
analysis: {
isDynamicMetadataRoute: async (pageFilePath: string) => {
let fileContent: string | undefined = undefined
if (isYarnPnP) {
fileContent = (await tryToReadFile(pageFilePath, true)) || ''
}
const { isDynamicMetadataRoute, warnings } =
await bindings.isDynamicMetadataRoute(pageFilePath, fileContent)
// Instead of passing js callback into napi's context, bindings bubble up the warning messages
// and let next.js logger handles it.
warnings?.forEach(
({
key,
message,
}: {
key: keyof typeof warnedInvalidValueMap
message: string
}) => warnInvalidValue(pageFilePath, key, message)
)
return isDynamicMetadataRoute
},
getPageStaticInfo: async (params: Record<string, any>) => {
let fileContent: string | undefined = undefined
if (isYarnPnP) {
fileContent =
(await tryToReadFile(params.pageFilePath, !params.isDev)) || ''
}
const raw = await bindings.getPageStaticInfo(params, fileContent)
return coercePageStaticInfo(params.pageFilePath, raw)
},
parse(src: string, options: any) {
return bindings.parse(src, toBuffer(options ?? {}))
},
getTargetTriple: bindings.getTargetTriple,
@ -1516,31 +1430,6 @@ function toBuffer(t: any) {
return Buffer.from(JSON.stringify(t))
}
function coercePageStaticInfo(pageFilePath: string, raw?: string) {
if (!raw) return raw
const parsed = JSON.parse(raw)
parsed?.exportsInfo?.warnings?.forEach(
({
key,
message,
}: {
key: keyof typeof warnedInvalidValueMap
message: string
}) => warnInvalidValue(pageFilePath, key, message)
)
return {
...parsed,
exportsInfo: {
...parsed.exportsInfo,
directives: new Set(parsed?.exportsInfo?.directives ?? []),
extraProperties: new Set(parsed?.exportsInfo?.extraProperties ?? []),
},
}
}
export async function isWasm(): Promise<boolean> {
let bindings = await loadBindings()
return bindings.isWasm
@ -1566,6 +1455,14 @@ export function minifySync(src: string, options: any): string {
return bindings.minifySync(src, options)
}
export async function parse(src: string, options: any): Promise<any> {
let bindings = await loadBindings()
let parserOptions = getParserOptions(options)
return bindings
.parse(src, parserOptions)
.then((astStr: any) => JSON.parse(astStr))
}
export function getBinaryMetadata() {
let bindings
try {

View file

@ -249,8 +249,6 @@ createNextDescribe(
await next.fetch('/')
await check(async () => {
expect(next.cliOutput).toContain(`Expected '{', got '}'`)
// [NOTE] [Flaky] expect at least 2 occurrences of the error message,
// on CI sometimes have more message appended somehow
expect(
next.cliOutput.split(`Expected '{', got '}'`).length
).toBeGreaterThanOrEqual(2)

View file

@ -46,7 +46,9 @@ describe('Exported runtimes value validation', () => {
)
)
expect(result.stderr).toEqual(
expect.stringContaining('Unsupported node type at "config.runtime"')
expect.stringContaining(
'Unsupported node type "BinaryExpression" at "config.runtime"'
)
)
// Spread Operator within Object Expression
expect(result.stderr).toEqual(
@ -88,7 +90,9 @@ describe('Exported runtimes value validation', () => {
)
)
expect(result.stderr).toEqual(
expect.stringContaining('Unsupported node type at "config.runtime"')
expect.stringContaining(
'Unsupported node type "CallExpression" at "config.runtime"'
)
)
// Unknown Object Key
expect(result.stderr).toEqual(
@ -98,7 +102,7 @@ describe('Exported runtimes value validation', () => {
)
expect(result.stderr).toEqual(
expect.stringContaining(
'Unsupported key type in the Object Expression at "config.runtime"'
'Unsupported key type "Computed" in the Object Expression at "config.runtime"'
)
)
})