vercel/next.js · #98641

Turbopack: improve segment config parsing

mischnic · merged Oct 13, 202619 files · 451 + / 98 −
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, &parallel_config.dynamic, "dynamic")?;         merge_parallel(             dynamic_params,             &parallel_config.dynamic_params,             "dynamicParams",         )?;         merge_parallel(revalidate, &parallel_config.revalidate, "revalidate")?;+        merge_parallel(max_duration, &parallel_config.max_duration, "maxDuration")?;         merge_parallel(fetch_cache, &parallel_config.fetch_cache, "fetchCache")?;         merge_parallel(runtime, &parallel_config.runtime, "runtime")?;-        merge_parallel(-            preferred_region,-            &parallel_config.preferred_region,-            "preferredRegion",-        )?;+        match (preferred_region.as_ref(), &parallel_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 nextConfig
test/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')+      })+    }+  )+})