vercel/next.js · #98641
Turbopack: improve segment config parsing
crates/next-api/src/app.rs2 + / 2 −
@@ -1776,8 +1776,8 @@ impl AppEndpoint { .config .await? .preferred_region- .clone()- .map(Regions::Multiple),+ .as_ref()+ .map(|region| Regions::Multiple(region.to_vec())), matchers: vec![matchers], env: project.edge_env().owned().await?, };crates/next-api/src/middleware.rs17 + / 74 −
@@ -6,8 +6,8 @@ use next_core::{ middleware::get_middleware_module, next_edge::entry::wrap_edge_entry, next_manifests::{EdgeFunctionDefinition, MiddlewaresManifestV2, ProxyMatcher, Regions},- segment_config::NextSegmentConfig,- util::{MiddlewareMatcherKind, NextRuntime},+ segment_config::{NextSegmentConfig, NextSegmentRegion},+ util::NextRuntime, }; use tracing::Instrument; use turbo_rcstr::{RcStr, rcstr};@@ -161,68 +161,15 @@ impl MiddlewareEndpoint { let has_i18n_locales = i18n.is_some(); let base_path = next_config.base_path().await?; - let matchers = if let Some(matchers) = config.middleware_matcher.as_ref() {- matchers- .iter()- .map(|matcher| {- let mut matcher = match matcher {- MiddlewareMatcherKind::Str(matcher) => ProxyMatcher {- original_source: matcher.as_str().into(),- ..Default::default()- },- MiddlewareMatcherKind::Matcher(matcher) => matcher.clone(),- };-- // Mirrors implementation in get-page-static-info.ts getMiddlewareMatchers- let mut source = matcher.original_source.to_string();- let is_root = source == "/";- let has_locale = matcher.locale;-- if has_i18n_locales && has_locale {- if is_root {- source.clear();- }- source.insert_str(0, "/:nextInternalLocale((?!_next/)[^/.]{1,})");- }-- // Match transport-specific route forms that resolve to the- // same page:- // - Pages Router data routes: /_next/data/<build-id>/...- // - App Router transport routes: .rsc, ...segments/...segment.rsc- if is_root {- source.push('(');- if has_i18n {- source.push_str("|\\.json|");- }- source.push_str("/?index|/?index\\.json|");- source.push_str("/?index(?:\\.rsc|\\.segments/.+\\.segment\\.rsc)");- source.push_str(")?");- } else {- source.push_str("{(\\.json|\\.rsc|\\.segments/.+\\.segment\\.rsc)}?");- };-- source.insert_str(0, "/:nextData(_next/data/[^/]{1,})?");-- if let Some(base_path) = base_path.as_ref() {- source.insert_str(0, base_path);- }-- // TODO: The implementation of getMiddlewareMatchers outputs a regex here- // using path-to-regexp. Currently there is no- // equivalent of that so it post-processes- // this value to the relevant regex in manifest-loader.ts- matcher.regexp = Some(RcStr::from(source));-- matcher- })- .collect()- } else {- vec![ProxyMatcher {- regexp: Some(rcstr!("^/.*$")),- original_source: rcstr!("/:path*"),- ..Default::default()- }]- };+ let matchers = config+ .get_proxy_matchers(has_i18n, has_i18n_locales, base_path.as_deref())+ .unwrap_or_else(|| {+ vec![ProxyMatcher {+ regexp: Some(rcstr!("^/.*$")),+ original_source: rcstr!("/:path*"),+ ..Default::default()+ }]+ }); if matches!(this.runtime, NextRuntime::NodeJs) { let chunk = self.node_chunk().to_resolved().await?;@@ -282,17 +229,13 @@ impl MiddlewareEndpoint { let all_assets = get_asset_paths_from_root(&node_root_value, edge_all_assets.await?).await?; - let regions = if let Some(regions) = config.preferred_region.as_ref() {- if regions.len() == 1 {- regions- .first()- .map(|region| Regions::Single(region.clone()))- } else {- Some(Regions::Multiple(regions.clone()))+ let regions = config.preferred_region.as_ref().map(|region| match region {+ NextSegmentRegion::Single(region) => Regions::Single(region.clone()),+ NextSegmentRegion::Multiple(regions) if regions.len() == 1 => {+ Regions::Single(regions[0].clone()) }- } else {- None- };+ NextSegmentRegion::Multiple(regions) => Regions::Multiple(regions.clone()),+ }); let edge_function_definition = EdgeFunctionDefinition { files: file_paths_from_root,crates/next-api/src/pages.rs9 + / 3 −
@@ -26,7 +26,7 @@ use next_core::{ PagesDirectoryStructure, PagesStructure, PagesStructureItem, find_pages_structure, }, parse_segment_config_from_source,- segment_config::ParseSegmentMode,+ segment_config::{NextSegmentRegion, ParseSegmentMode}, util::{NextRuntime, get_asset_prefix_from_pathname, pages_function_name}, }; use smallvec::smallvec;@@ -966,7 +966,10 @@ impl PageEndpoint { // /_app and /_document are always rendered for Node.js for this case. For edge // they're included in the page bundle. runtime: NextRuntime::NodeJs,- regions: config.preferred_region.clone(),+ regions: config+ .preferred_region+ .as_ref()+ .map(NextSegmentRegion::to_vec), } } else { let modules = create_page_ssr_entry_module(@@ -991,7 +994,10 @@ impl PageEndpoint { app_module: modules.app_module, document_module: modules.document_module, runtime,- regions: config.preferred_region.clone(),+ regions: config+ .preferred_region+ .as_ref()+ .map(NextSegmentRegion::to_vec), } } .cell(),crates/next-core/src/segment_config.rs243 + / 17 −
@@ -3,7 +3,7 @@ use std::borrow::Cow; use anyhow::{Result, bail}; use async_trait::async_trait; use bincode::{Decode, Encode};-use serde::Deserialize;+use serde::{Deserialize, Serialize}; use serde_json::Value; use swc_core::{ common::{DUMMY_SP, GLOBALS, Span, Spanned, source_map::SmallPos},@@ -77,16 +77,41 @@ pub enum NextRevalidate { }, } +#[derive(PartialEq, Eq, Clone, Debug, NonLocalValue, Encode, Decode)]+pub enum NextSegmentRegion {+ Single(RcStr),+ Multiple(Vec<RcStr>),+}++#[derive(PartialEq, Eq, Clone, Copy, Debug, NonLocalValue, Encode, Decode, Serialize)]+#[serde(rename_all = "lowercase")]+pub enum NextSegmentRsc {+ Server,+ Client,+}++impl NextSegmentRegion {+ pub fn to_vec(&self) -> Vec<RcStr> {+ match self {+ Self::Single(region) => vec![region.clone()],+ Self::Multiple(regions) => regions.clone(),+ }+ }+}+ #[turbo_tasks::value(shared)] #[derive(Debug, Default, Clone)] pub struct NextSegmentConfig {+ pub rsc: Option<NextSegmentRsc>, pub dynamic: Option<NextSegmentDynamic>, pub dynamic_params: Option<bool>, pub revalidate: Option<NextRevalidate>,+ pub max_duration: Option<u32>, pub fetch_cache: Option<NextSegmentFetchCache>, pub runtime: Option<NextRuntime>,- pub preferred_region: Option<Vec<RcStr>>,+ pub preferred_region: Option<NextSegmentRegion>, pub middleware_matcher: Option<Vec<MiddlewareMatcherKind>>,+ pub unstable_allow_dynamic: Option<Vec<RcStr>>, /// Whether these exports are defined in the source file. pub generate_image_metadata: bool,@@ -105,6 +130,13 @@ pub struct NextSegmentConfig { pub ensure_static: Option<Span>, } +/// Legacy config fields that are fallbacks for individual exports outside the App Router.+#[derive(Default)]+struct LegacySegmentConfig {+ runtime: Option<NextRuntime>,+ max_duration: Option<u32>,+}+ #[turbo_tasks::value_impl] impl ValueDefault for NextSegmentConfig { #[turbo_tasks::function]@@ -118,17 +150,30 @@ impl NextSegmentConfig { /// the parent's values. pub fn apply_parent_config(&mut self, parent: &Self) { let NextSegmentConfig {+ // A layout's RSC classification does not apply to its child page.+ rsc: _, dynamic, dynamic_params, revalidate,+ max_duration, fetch_cache, runtime, preferred_region,- ..+ // TODO what about these?+ instant: _,+ prefetch: _,+ ensure_static: _,+ // Don't need merging+ middleware_matcher: _,+ unstable_allow_dynamic: _,+ generate_image_metadata: _,+ generate_sitemaps: _,+ generate_static_params: _, } = self; *dynamic = dynamic.or(parent.dynamic); *dynamic_params = dynamic_params.or(parent.dynamic_params); *revalidate = revalidate.or(parent.revalidate);+ *max_duration = max_duration.or(parent.max_duration); *fetch_cache = fetch_cache.or(parent.fetch_cache); *runtime = runtime.or(parent.runtime); *preferred_region = preferred_region.take().or(parent.preferred_region.clone());@@ -157,30 +202,113 @@ impl NextSegmentConfig { Ok(()) } let Self {+ rsc, dynamic, dynamic_params, revalidate,+ max_duration, fetch_cache, runtime, preferred_region,- ..+ // TODO what about these?+ instant: _,+ prefetch: _,+ ensure_static: _,+ // Don't need merging+ middleware_matcher: _,+ unstable_allow_dynamic: _,+ generate_image_metadata: _,+ generate_sitemaps: _,+ generate_static_params: _, } = self;+ // Carry the leaf page's RSC classification up through its loader-tree branch. Unlike route+ // segment config, classifications in different parallel branches are not conflicts.+ *rsc = (*rsc).or(parallel_config.rsc); merge_parallel(dynamic, ¶llel_config.dynamic, "dynamic")?; merge_parallel( dynamic_params, ¶llel_config.dynamic_params, "dynamicParams", )?; merge_parallel(revalidate, ¶llel_config.revalidate, "revalidate")?;+ merge_parallel(max_duration, ¶llel_config.max_duration, "maxDuration")?; merge_parallel(fetch_cache, ¶llel_config.fetch_cache, "fetchCache")?; merge_parallel(runtime, ¶llel_config.runtime, "runtime")?;- merge_parallel(- preferred_region,- ¶llel_config.preferred_region,- "preferredRegion",- )?;+ match (preferred_region.as_ref(), ¶llel_config.preferred_region) {+ (Some(a), Some(b)) if a.to_vec() != b.to_vec() => {+ bail!("Sibling segment configs have conflicting values for preferredRegion")+ }+ (None, Some(b)) => {+ *preferred_region = Some(b.clone());+ }+ _ => {}+ } Ok(()) }++ pub fn get_proxy_matchers(+ &self,+ has_i18n: bool,+ has_i18n_locales: bool,+ base_path: Option<&str>,+ ) -> Option<Vec<ProxyMatcher>> {+ self.middleware_matcher.as_ref().map(|matchers| {+ matchers+ .iter()+ .map(|matcher| {+ let mut matcher = match matcher {+ MiddlewareMatcherKind::Str(matcher) => ProxyMatcher {+ original_source: matcher.as_str().into(),+ ..Default::default()+ },+ MiddlewareMatcherKind::Matcher(matcher) => matcher.clone(),+ };++ // Mirrors implementation in get-page-static-info.ts getMiddlewareMatchers+ let mut source = matcher.original_source.to_string();+ let is_root = source == "/";+ let has_locale = matcher.locale;++ if has_i18n_locales && has_locale {+ if is_root {+ source.clear();+ }+ source.insert_str(0, "/:nextInternalLocale((?!_next/)[^/.]{1,})");+ }++ // Match transport-specific route forms that resolve to the+ // same page:+ // - Pages Router data routes: /_next/data/<build-id>/...+ // - App Router transport routes: .rsc, ...segments/...segment.rsc+ if is_root {+ source.push('(');+ if has_i18n {+ source.push_str("|\\.json|");+ }+ source.push_str("/?index|/?index\\.json|");+ source.push_str("/?index(?:\\.rsc|\\.segments/.+\\.segment\\.rsc)");+ source.push_str(")?");+ } else {+ source.push_str("{(\\.json|\\.rsc|\\.segments/.+\\.segment\\.rsc)}?");+ };++ source.insert_str(0, "/:nextData(_next/data/[^/]{1,})?");++ if let Some(base_path) = base_path.as_ref() {+ source.insert_str(0, base_path);+ }++ // TODO: The implementation of getMiddlewareMatchers outputs a regex here+ // using path-to-regexp. Currently there is no+ // equivalent of that so it post-processes+ // this value to the relevant regex in manifest-loader.ts+ matcher.regexp = Some(RcStr::from(source));++ matcher+ })+ .collect()+ })+ } } /// An issue that occurred while parsing the app segment config.@@ -396,15 +524,17 @@ pub async fn parse_segment_config_from_source( // Arena for the `JsValue`s produced while evaluating config expressions; // freed when this function returns. let arena = ThreadLocal::new();- let config = WrapFuture::new(+ let mut config = WrapFuture::new( async { let mut config = NextSegmentConfig::default();+ let mut legacy_config = LegacySegmentConfig::default(); let mut parse = async |ident, init, span| { parse_config_value( source, mode, &mut config,+ &mut legacy_config, eval_context, &arena, ident,@@ -489,6 +619,10 @@ pub async fn parse_segment_config_from_source( } } }+ // Individual exports take precedence over legacy config fields, regardless of+ // export order. App Router ignores legacy config, so its fallback stays empty.+ config.runtime = config.runtime.or(legacy_config.runtime);+ config.max_duration = config.max_duration.or(legacy_config.max_duration); anyhow::Ok(config) }, |f, ctx| GLOBALS.set(globals, || f.poll(ctx)),@@ -511,6 +645,12 @@ pub async fn parse_segment_config_from_source( _ => false, }); + config.rsc = Some(if is_client_entry {+ NextSegmentRsc::Client+ } else {+ NextSegmentRsc::Server+ });+ if mode == ParseSegmentMode::App && is_client_entry { if let Some(span) = config.generate_static_params { invalid_config(@@ -589,6 +729,7 @@ async fn parse_config_value( source: ResolvedVc<Box<dyn Source>>, mode: ParseSegmentMode, config: &mut NextSegmentConfig,+ legacy_config: &mut LegacySegmentConfig, eval_context: &EvalContext, arena: &ThreadLocal<Bump>, key: Cow<'_, str>,@@ -731,18 +872,44 @@ async fn parse_config_value( continue; } - config.runtime = runtime+ legacy_config.runtime = runtime } "matcher" => { config.middleware_matcher = parse_route_matcher_from_js_value(source, span, value).await?; }+ "unstable_allowDynamic" => {+ config.unstable_allow_dynamic = parse_static_string_or_array_from_js_value(+ source,+ span,+ "config",+ "unstable_allowDynamic",+ value,+ )+ .await?;+ } "regions" => {- config.preferred_region = parse_static_string_or_array_from_js_value(+ config.preferred_region = parse_preferred_region_from_js_value( source, span, "config", "regions", value, ) .await?; }+ "maxDuration" => {+ legacy_config.max_duration = match parse_max_duration(value) {+ Ok(duration) => duration,+ Err(error) => {+ return invalid_config(+ source,+ "config",+ span,+ format!("`maxDuration` {error}").into(),+ Some(value),+ IssueSeverity::Error,+ )+ .await;+ }+ };+ } _ => { // Ignore, }@@ -851,6 +1018,34 @@ async fn parse_config_value( } } }+ "maxDuration" => {+ let Some(value) = get_value() else {+ return invalid_config(+ source,+ "maxDuration",+ span,+ rcstr!("It mustn't be reexported."),+ None,+ IssueSeverity::Error,+ )+ .await;+ };++ config.max_duration = match parse_max_duration(&value) {+ Ok(duration) => duration,+ Err(error) => {+ return invalid_config(+ source,+ "maxDuration",+ span,+ format!("It {error}").into(),+ Some(&value),+ IssueSeverity::Error,+ )+ .await;+ }+ };+ } "fetchCache" => { let Some(value) = get_value() else { return invalid_config(@@ -951,17 +1146,14 @@ async fn parse_config_value( return Ok(()); } - if let Some(preferred_region) = parse_static_string_or_array_from_js_value(+ config.preferred_region = parse_preferred_region_from_js_value( source, span, "preferredRegion", "preferredRegion", &value, )- .await?- {- config.preferred_region = Some(preferred_region);- }+ .await?; } "generateImageMetadata" => { config.generate_image_metadata = true;@@ -987,6 +1179,40 @@ async fn parse_config_value( Ok(()) } +fn parse_max_duration(value: &JsValue<'_>) -> Result<Option<u32>, &'static str> {+ if matches!(value, JsValue::Constant(ConstantValue::Undefined)) {+ return Ok(None);+ }+ let JsValue::Constant(ConstantValue::Num(ConstantNumber(val))) = value else {+ return Err("needs to be a static number.");+ };+ if !val.is_finite() || *val < 0.0 || val.fract() != 0.0 || *val > u32::MAX as f64 {+ return Err("needs to be a non-negative integer no greater than 4294967295.");+ }+ Ok(Some(*val as u32))+}++async fn parse_preferred_region_from_js_value(+ source: ResolvedVc<Box<dyn Source>>,+ span: Span,+ key: &str,+ sub_key: &str,+ value: &JsValue<'_>,+) -> Result<Option<NextSegmentRegion>> {+ let is_array = matches!(value, JsValue::Array { .. });+ Ok(+ parse_static_string_or_array_from_js_value(source, span, key, sub_key, value)+ .await?+ .and_then(|regions| {+ if is_array {+ Some(NextSegmentRegion::Multiple(regions))+ } else {+ regions.into_iter().next().map(NextSegmentRegion::Single)+ }+ }),+ )+}+ async fn parse_static_string_or_array_from_js_value( source: ResolvedVc<Box<dyn Source>>, span: Span,test/production/edge-config-validations/index.test.ts4 + / 2 −
@@ -1,7 +1,7 @@ import { nextTestSetup } from 'e2e-utils' describe('Edge config validations', () => {- const { next } = nextTestSetup({+ const { next, isTurbopack } = nextTestSetup({ skipStart: true, files: { 'pages/index.js': `@@ -24,7 +24,9 @@ describe('Edge config validations', () => { const res = await next.build() expect(res.exitCode).toBe(1) expect(res.cliOutput).toContain(- 'middleware contains invalid middleware config: Expected string, received boolean at "unstable_allowDynamic", or Expected array, received boolean at "unstable_allowDynamic"'+ isTurbopack+ ? "Next.js can't recognize the exported `config` field in route. `unstable_allowDynamic` needs to be a static string or array of static strings."+ : 'middleware contains invalid middleware config: Expected string, received boolean at "unstable_allowDynamic", or Expected array, received boolean at "unstable_allowDynamic"' ) }) })test/production/segment-config-precedence/app/layout.tsxadded9 + / 0 −
@@ -0,0 +1,9 @@+import type { ReactNode } from 'react'++export default function Root({ children }: { children: ReactNode }) {+ return (+ <html>+ <body>{children}</body>+ </html>+ )+}test/production/segment-config-precedence/app/page.tsxadded5 + / 0 −
@@ -0,0 +1,5 @@+export const maxDuration = undefined++export default function Page() {+ return <p>hello world</p>+}test/production/segment-config-precedence/app/region-array-first/@slot/page.tsxadded5 + / 0 −
@@ -0,0 +1,5 @@+export const preferredRegion = 'iad1'++export default function Page() {+ return <p id="slot">slot</p>+}test/production/segment-config-precedence/app/region-array-first/layout.tsxadded16 + / 0 −
@@ -0,0 +1,16 @@+import type { ReactNode } from 'react'++export default function Layout({+ children,+ slot,+}: {+ children: ReactNode+ slot: ReactNode+}) {+ return (+ <>+ {children}+ {slot}+ </>+ )+}test/production/segment-config-precedence/app/region-array-first/page.tsxadded5 + / 0 −
@@ -0,0 +1,5 @@+export const preferredRegion = ['iad1']++export default function Page() {+ return <p id="children">children</p>+}test/production/segment-config-precedence/app/region-string-first/@slot/page.tsxadded5 + / 0 −
@@ -0,0 +1,5 @@+export const preferredRegion = ['iad1']++export default function Page() {+ return <p id="slot">slot</p>+}test/production/segment-config-precedence/app/region-string-first/layout.tsxadded16 + / 0 −
@@ -0,0 +1,16 @@+import type { ReactNode } from 'react'++export default function Layout({+ children,+ slot,+}: {+ children: ReactNode+ slot: ReactNode+}) {+ return (+ <>+ {children}+ {slot}+ </>+ )+}test/production/segment-config-precedence/app/region-string-first/page.tsxadded5 + / 0 −
@@ -0,0 +1,5 @@+export const preferredRegion = 'iad1'++export default function Page() {+ return <p id="children">children</p>+}test/production/segment-config-precedence/next.config.tsadded5 + / 0 −
@@ -0,0 +1,5 @@+import type { NextConfig } from 'next'++const nextConfig: NextConfig = {}++export default nextConfigtest/production/segment-config-precedence/pages/api/config-first.jsadded7 + / 0 −
@@ -0,0 +1,7 @@+export const config = { runtime: 'edge', maxDuration: 20 }+export const runtime = 'nodejs'+export const maxDuration = 10++export default function handler(_req, res) {+ res.status(200).json({ runtime: process.env.NEXT_RUNTIME })+}test/production/segment-config-precedence/pages/api/export-first.jsadded7 + / 0 −
@@ -0,0 +1,7 @@+export const runtime = 'nodejs'+export const maxDuration = 10+export const config = { runtime: 'edge', maxDuration: 20 }++export default function handler(_req, res) {+ res.status(200).json({ runtime: process.env.NEXT_RUNTIME })+}test/production/segment-config-precedence/pages/api/undefined-config-first.jsadded7 + / 0 −
@@ -0,0 +1,7 @@+export const config = { runtime: 'edge', maxDuration: 20 }+export const runtime = undefined+export const maxDuration = undefined++export default function handler() {+ return Response.json({ runtime: process.env.NEXT_RUNTIME })+}test/production/segment-config-precedence/pages/api/undefined-export-first.jsadded7 + / 0 −
@@ -0,0 +1,7 @@+export const runtime = undefined+export const maxDuration = undefined+export const config = { runtime: 'edge', maxDuration: 20 }++export default function handler() {+ return Response.json({ runtime: process.env.NEXT_RUNTIME })+}test/production/segment-config-precedence/segment-config-precedence.test.tsadded77 + / 0 −
@@ -0,0 +1,77 @@+import { nextTestSetup } from 'e2e-utils'++describe('segment-config-precedence', () => {+ const { next } = nextTestSetup({+ files: __dirname,+ })++ it('accepts an undefined maxDuration export', async () => {+ const $ = await next.render$('/')+ expect($('p').text()).toBe('hello world')+ })++ it.each(['region-string-first', 'region-array-first'])(+ 'accepts equivalent preferredRegion values in parallel routes: %s',+ async (route) => {+ const $ = await next.render$(`/${route}`)+ expect($('#children').text()).toBe('children')+ expect($('#slot').text()).toBe('slot')+ }+ )++ it.each([+ ['export-first', 'nodejs'],+ ['config-first', 'nodejs'],+ ['undefined-export-first', 'edge'],+ ['undefined-config-first', 'edge'],+ ])(+ 'resolves runtime independently of export order: %s',+ async (route, runtime) => {+ const res = await next.fetch(`/api/${route}`)+ expect(res.status).toBe(200)+ expect(await res.json()).toEqual({ runtime })+ }+ )++ it('gives top-level maxDuration precedence and falls back for undefined exports', async () => {+ const manifest = await next.readJSON(+ '.next/server/functions-config-manifest.json'+ )+ expect(manifest.functions['/api/export-first'].maxDuration).toBe(10)+ expect(manifest.functions['/api/config-first'].maxDuration).toBe(10)+ expect(manifest.functions['/api/undefined-export-first'].maxDuration).toBe(+ 20+ )+ expect(manifest.functions['/api/undefined-config-first'].maxDuration).toBe(+ 20+ )+ expect(manifest.functions['/']).toBeUndefined()+ })+})++describe('segment-config validation', () => {+ describe.each(['-1', '1.5', 'NaN', 'Infinity'])(+ 'maxDuration = %s',+ (value) => {+ const { next } = nextTestSetup({+ files: __dirname,+ skipStart: true,+ overrideFiles: {+ 'app/page.tsx': `+ export const maxDuration = ${value}++ export default function Page() {+ return <p>hello world</p>+ }+ `,+ },+ })++ it('validates maxDuration at build time', async () => {+ const result = await next.build()+ expect(result.exitCode).toBe(1)+ expect(result.cliOutput).toContain('maxDuration')+ })+ }+ )+})