next_core/
app_segment_config.rs

1use std::{future::Future, ops::Deref};
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use swc_core::{
7    common::{GLOBALS, Span, Spanned, source_map::SmallPos},
8    ecma::ast::{Decl, Expr, FnExpr, Ident, Program},
9};
10use turbo_rcstr::{RcStr, rcstr};
11use turbo_tasks::{
12    NonLocalValue, ResolvedVc, TryJoinIterExt, ValueDefault, Vc, trace::TraceRawVcs,
13    util::WrapFuture,
14};
15use turbo_tasks_fs::FileSystemPath;
16use turbopack_core::{
17    file_source::FileSource,
18    ident::AssetIdent,
19    issue::{
20        Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, OptionIssueSource,
21        OptionStyledString, StyledString,
22    },
23    source::Source,
24};
25use turbopack_ecmascript::{
26    EcmascriptInputTransforms, EcmascriptModuleAssetType,
27    analyzer::{ConstantNumber, ConstantValue, JsValue, graph::EvalContext},
28    parse::{ParseResult, parse},
29};
30
31use crate::{app_structure::AppPageLoaderTree, util::NextRuntime};
32
33#[derive(
34    Default, PartialEq, Eq, Clone, Copy, Debug, TraceRawVcs, Serialize, Deserialize, NonLocalValue,
35)]
36#[serde(rename_all = "kebab-case")]
37pub enum NextSegmentDynamic {
38    #[default]
39    Auto,
40    ForceDynamic,
41    Error,
42    ForceStatic,
43}
44
45#[derive(
46    Default, PartialEq, Eq, Clone, Copy, Debug, TraceRawVcs, Serialize, Deserialize, NonLocalValue,
47)]
48#[serde(rename_all = "kebab-case")]
49pub enum NextSegmentFetchCache {
50    #[default]
51    Auto,
52    DefaultCache,
53    OnlyCache,
54    ForceCache,
55    DefaultNoStore,
56    OnlyNoStore,
57    ForceNoStore,
58}
59
60#[derive(
61    Default, PartialEq, Eq, Clone, Copy, Debug, TraceRawVcs, Serialize, Deserialize, NonLocalValue,
62)]
63pub enum NextRevalidate {
64    #[default]
65    Never,
66    ForceCache,
67    Frequency {
68        seconds: u32,
69    },
70}
71
72#[turbo_tasks::value(into = "shared")]
73#[derive(Debug, Default, Clone)]
74pub struct NextSegmentConfig {
75    pub dynamic: Option<NextSegmentDynamic>,
76    pub dynamic_params: Option<bool>,
77    pub revalidate: Option<NextRevalidate>,
78    pub fetch_cache: Option<NextSegmentFetchCache>,
79    pub runtime: Option<NextRuntime>,
80    pub preferred_region: Option<Vec<RcStr>>,
81    pub experimental_ppr: Option<bool>,
82    /// Whether these metadata exports are defined in the source file.
83    pub generate_image_metadata: bool,
84    pub generate_sitemaps: bool,
85}
86
87#[turbo_tasks::value_impl]
88impl ValueDefault for NextSegmentConfig {
89    #[turbo_tasks::function]
90    pub fn value_default() -> Vc<Self> {
91        NextSegmentConfig::default().cell()
92    }
93}
94
95impl NextSegmentConfig {
96    /// Applies the parent config to this config, setting any unset values to
97    /// the parent's values.
98    pub fn apply_parent_config(&mut self, parent: &Self) {
99        let NextSegmentConfig {
100            dynamic,
101            dynamic_params,
102            revalidate,
103            fetch_cache,
104            runtime,
105            preferred_region,
106            experimental_ppr,
107            ..
108        } = self;
109        *dynamic = dynamic.or(parent.dynamic);
110        *dynamic_params = dynamic_params.or(parent.dynamic_params);
111        *revalidate = revalidate.or(parent.revalidate);
112        *fetch_cache = fetch_cache.or(parent.fetch_cache);
113        *runtime = runtime.or(parent.runtime);
114        *preferred_region = preferred_region.take().or(parent.preferred_region.clone());
115        *experimental_ppr = experimental_ppr.or(parent.experimental_ppr);
116    }
117
118    /// Applies a config from a parallel route to this config, returning an
119    /// error if there are conflicting values.
120    pub fn apply_parallel_config(&mut self, parallel_config: &Self) -> Result<()> {
121        fn merge_parallel<T: PartialEq + Clone>(
122            a: &mut Option<T>,
123            b: &Option<T>,
124            name: &str,
125        ) -> Result<()> {
126            match (a.as_ref(), b) {
127                (Some(a), Some(b)) => {
128                    if *a != *b {
129                        bail!(
130                            "Sibling segment configs have conflicting values for {}",
131                            name
132                        )
133                    }
134                }
135                (None, Some(b)) => {
136                    *a = Some(b.clone());
137                }
138                _ => {}
139            }
140            Ok(())
141        }
142        let Self {
143            dynamic,
144            dynamic_params,
145            revalidate,
146            fetch_cache,
147            runtime,
148            preferred_region,
149            experimental_ppr,
150            ..
151        } = self;
152        merge_parallel(dynamic, &parallel_config.dynamic, "dynamic")?;
153        merge_parallel(
154            dynamic_params,
155            &parallel_config.dynamic_params,
156            "dynamicParams",
157        )?;
158        merge_parallel(revalidate, &parallel_config.revalidate, "revalidate")?;
159        merge_parallel(fetch_cache, &parallel_config.fetch_cache, "fetchCache")?;
160        merge_parallel(runtime, &parallel_config.runtime, "runtime")?;
161        merge_parallel(
162            preferred_region,
163            &parallel_config.preferred_region,
164            "referredRegion",
165        )?;
166        merge_parallel(
167            experimental_ppr,
168            &parallel_config.experimental_ppr,
169            "experimental_ppr",
170        )?;
171        Ok(())
172    }
173}
174
175/// An issue that occurred while parsing the app segment config.
176#[turbo_tasks::value(shared)]
177pub struct NextSegmentConfigParsingIssue {
178    ident: ResolvedVc<AssetIdent>,
179    detail: ResolvedVc<StyledString>,
180    source: IssueSource,
181}
182
183#[turbo_tasks::value_impl]
184impl NextSegmentConfigParsingIssue {
185    #[turbo_tasks::function]
186    pub fn new(
187        ident: ResolvedVc<AssetIdent>,
188        detail: ResolvedVc<StyledString>,
189        source: IssueSource,
190    ) -> Vc<Self> {
191        Self {
192            ident,
193            detail,
194            source,
195        }
196        .cell()
197    }
198}
199
200#[turbo_tasks::value_impl]
201impl Issue for NextSegmentConfigParsingIssue {
202    fn severity(&self) -> IssueSeverity {
203        IssueSeverity::Warning
204    }
205
206    #[turbo_tasks::function]
207    fn title(&self) -> Vc<StyledString> {
208        StyledString::Text(rcstr!(
209            "Next.js can't recognize the exported `config` field in route"
210        ))
211        .cell()
212    }
213
214    #[turbo_tasks::function]
215    fn stage(&self) -> Vc<IssueStage> {
216        IssueStage::Parse.into()
217    }
218
219    #[turbo_tasks::function]
220    fn file_path(&self) -> Vc<FileSystemPath> {
221        self.ident.path()
222    }
223
224    #[turbo_tasks::function]
225    fn description(&self) -> Vc<OptionStyledString> {
226        Vc::cell(Some(
227            StyledString::Text(rcstr!(
228                "The exported configuration object in a source file needs to have a very specific \
229                 format from which some properties can be statically parsed at compiled-time."
230            ))
231            .resolved_cell(),
232        ))
233    }
234
235    #[turbo_tasks::function]
236    fn detail(&self) -> Vc<OptionStyledString> {
237        Vc::cell(Some(self.detail))
238    }
239
240    #[turbo_tasks::function]
241    fn documentation_link(&self) -> Vc<RcStr> {
242        Vc::cell(rcstr!(
243            "https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config"
244        ))
245    }
246
247    #[turbo_tasks::function]
248    fn source(&self) -> Vc<OptionIssueSource> {
249        Vc::cell(Some(self.source))
250    }
251}
252
253#[turbo_tasks::function]
254pub async fn parse_segment_config_from_source(
255    source: ResolvedVc<Box<dyn Source>>,
256) -> Result<Vc<NextSegmentConfig>> {
257    let path = source.ident().path().await?;
258
259    // Don't try parsing if it's not a javascript file, otherwise it will emit an
260    // issue causing the build to "fail".
261    if path.path.ends_with(".d.ts")
262        || !(path.path.ends_with(".js")
263            || path.path.ends_with(".jsx")
264            || path.path.ends_with(".ts")
265            || path.path.ends_with(".tsx"))
266    {
267        return Ok(Default::default());
268    }
269
270    let result = &*parse(
271        *source,
272        if path.path.ends_with(".ts") {
273            EcmascriptModuleAssetType::Typescript {
274                tsx: false,
275                analyze_types: false,
276            }
277        } else if path.path.ends_with(".tsx") {
278            EcmascriptModuleAssetType::Typescript {
279                tsx: true,
280                analyze_types: false,
281            }
282        } else {
283            EcmascriptModuleAssetType::Ecmascript
284        },
285        EcmascriptInputTransforms::empty(),
286    )
287    .await?;
288
289    let ParseResult::Ok {
290        program: Program::Module(module_ast),
291        eval_context,
292        globals,
293        ..
294    } = result
295    else {
296        return Ok(Default::default());
297    };
298
299    let config = WrapFuture::new(
300        async {
301            let mut config = NextSegmentConfig::default();
302
303            for item in &module_ast.body {
304                let Some(export_decl) = item
305                    .as_module_decl()
306                    .and_then(|mod_decl| mod_decl.as_export_decl())
307                else {
308                    continue;
309                };
310
311                match &export_decl.decl {
312                    Decl::Var(var_decl) => {
313                        for decl in &var_decl.decls {
314                            let Some(ident) = decl.name.as_ident().map(|ident| ident.deref())
315                            else {
316                                continue;
317                            };
318
319                            if let Some(init) = decl.init.as_ref() {
320                                parse_config_value(source, &mut config, ident, init, eval_context)
321                                    .await?;
322                            }
323                        }
324                    }
325                    Decl::Fn(fn_decl) => {
326                        let ident = &fn_decl.ident;
327                        // create an empty expression of {}, we don't need init for function
328                        let init = Expr::Fn(FnExpr {
329                            ident: None,
330                            function: fn_decl.function.clone(),
331                        });
332                        parse_config_value(source, &mut config, ident, &init, eval_context).await?;
333                    }
334                    _ => {}
335                }
336            }
337            anyhow::Ok(config)
338        },
339        |f, ctx| GLOBALS.set(globals, || f.poll(ctx)),
340    )
341    .await?;
342
343    Ok(config.cell())
344}
345
346async fn parse_config_value(
347    source: ResolvedVc<Box<dyn Source>>,
348    config: &mut NextSegmentConfig,
349    ident: &Ident,
350    init: &Expr,
351    eval_context: &EvalContext,
352) -> Result<()> {
353    let span = init.span();
354    async fn invalid_config(
355        source: ResolvedVc<Box<dyn Source>>,
356        span: Span,
357        detail: &str,
358        value: &JsValue,
359    ) -> Result<()> {
360        let (explainer, hints) = value.explain(2, 0);
361        let detail =
362            StyledString::Text(format!("{detail} Got {explainer}.{hints}").into()).resolved_cell();
363
364        NextSegmentConfigParsingIssue::new(
365            source.ident(),
366            *detail,
367            IssueSource::from_swc_offsets(source, span.lo.to_u32(), span.hi.to_u32()),
368        )
369        .to_resolved()
370        .await?
371        .emit();
372        Ok(())
373    }
374
375    match &*ident.sym {
376        "dynamic" => {
377            let value = eval_context.eval(init);
378            let Some(val) = value.as_str() else {
379                invalid_config(
380                    source,
381                    span,
382                    "`dynamic` needs to be a static string",
383                    &value,
384                )
385                .await?;
386                return Ok(());
387            };
388
389            config.dynamic = match serde_json::from_value(Value::String(val.to_string())) {
390                Ok(dynamic) => Some(dynamic),
391                Err(err) => {
392                    invalid_config(
393                        source,
394                        span,
395                        &format!("`dynamic` has an invalid value: {err}"),
396                        &value,
397                    )
398                    .await?;
399                    return Ok(());
400                }
401            };
402        }
403        "dynamicParams" => {
404            let value = eval_context.eval(init);
405            let Some(val) = value.as_bool() else {
406                invalid_config(
407                    source,
408                    span,
409                    "`dynamicParams` needs to be a static boolean",
410                    &value,
411                )
412                .await?;
413                return Ok(());
414            };
415
416            config.dynamic_params = Some(val);
417        }
418        "revalidate" => {
419            let value = eval_context.eval(init);
420            match value {
421                JsValue::Constant(ConstantValue::Num(ConstantNumber(val))) if val >= 0.0 => {
422                    config.revalidate = Some(NextRevalidate::Frequency {
423                        seconds: val as u32,
424                    });
425                }
426                JsValue::Constant(ConstantValue::False) => {
427                    config.revalidate = Some(NextRevalidate::Never);
428                }
429                JsValue::Constant(ConstantValue::Str(str)) if str.as_str() == "force-cache" => {
430                    config.revalidate = Some(NextRevalidate::ForceCache);
431                }
432                _ => {
433                    //noop; revalidate validation occurs in runtime at
434                    //https://github.com/vercel/next.js/blob/cd46c221d2b7f796f963d2b81eea1e405023db23/packages/next/src/server/lib/patch-fetch.ts#L20
435                }
436            }
437        }
438        "fetchCache" => {
439            let value = eval_context.eval(init);
440            let Some(val) = value.as_str() else {
441                return invalid_config(
442                    source,
443                    span,
444                    "`fetchCache` needs to be a static string",
445                    &value,
446                )
447                .await;
448            };
449
450            config.fetch_cache = match serde_json::from_value(Value::String(val.to_string())) {
451                Ok(fetch_cache) => Some(fetch_cache),
452                Err(err) => {
453                    return invalid_config(
454                        source,
455                        span,
456                        &format!("`fetchCache` has an invalid value: {err}"),
457                        &value,
458                    )
459                    .await;
460                }
461            };
462        }
463        "runtime" => {
464            let value = eval_context.eval(init);
465            let Some(val) = value.as_str() else {
466                return invalid_config(
467                    source,
468                    span,
469                    "`runtime` needs to be a static string",
470                    &value,
471                )
472                .await;
473            };
474
475            config.runtime = match serde_json::from_value(Value::String(val.to_string())) {
476                Ok(runtime) => Some(runtime),
477                Err(err) => {
478                    return invalid_config(
479                        source,
480                        span,
481                        &format!("`runtime` has an invalid value: {err}"),
482                        &value,
483                    )
484                    .await;
485                }
486            };
487        }
488        "preferredRegion" => {
489            let value = eval_context.eval(init);
490
491            let preferred_region = match value {
492                // Single value is turned into a single-element Vec.
493                JsValue::Constant(ConstantValue::Str(str)) => vec![str.to_string().into()],
494                // Array of strings is turned into a Vec. If one of the values in not a String it
495                // will error.
496                JsValue::Array { items, .. } => {
497                    let mut regions = Vec::new();
498                    for item in items {
499                        if let JsValue::Constant(ConstantValue::Str(str)) = item {
500                            regions.push(str.to_string().into());
501                        } else {
502                            return invalid_config(
503                                source,
504                                span,
505                                "Values of the `preferredRegion` array need to static strings",
506                                &item,
507                            )
508                            .await;
509                        }
510                    }
511                    regions
512                }
513                _ => {
514                    return invalid_config(
515                        source,
516                        span,
517                        "`preferredRegion` needs to be a static string or array of static strings",
518                        &value,
519                    )
520                    .await;
521                }
522            };
523
524            config.preferred_region = Some(preferred_region);
525        }
526        // Match exported generateImageMetadata function and generateSitemaps function, and pass
527        // them to config.
528        "generateImageMetadata" => {
529            config.generate_image_metadata = true;
530        }
531        "generateSitemaps" => {
532            config.generate_sitemaps = true;
533        }
534        "experimental_ppr" => {
535            let value = eval_context.eval(init);
536            let Some(val) = value.as_bool() else {
537                return invalid_config(
538                    source,
539                    span,
540                    "`experimental_ppr` needs to be a static boolean",
541                    &value,
542                )
543                .await;
544            };
545
546            config.experimental_ppr = Some(val);
547        }
548        _ => {}
549    }
550
551    Ok(())
552}
553
554#[turbo_tasks::function]
555pub async fn parse_segment_config_from_loader_tree(
556    loader_tree: Vc<AppPageLoaderTree>,
557) -> Result<Vc<NextSegmentConfig>> {
558    let loader_tree = &*loader_tree.await?;
559
560    Ok(parse_segment_config_from_loader_tree_internal(loader_tree)
561        .await?
562        .cell())
563}
564
565pub async fn parse_segment_config_from_loader_tree_internal(
566    loader_tree: &AppPageLoaderTree,
567) -> Result<NextSegmentConfig> {
568    let mut config = NextSegmentConfig::default();
569
570    let parallel_configs = loader_tree
571        .parallel_routes
572        .values()
573        .map(|loader_tree| async move {
574            Box::pin(parse_segment_config_from_loader_tree_internal(loader_tree)).await
575        })
576        .try_join()
577        .await?;
578
579    for tree in parallel_configs {
580        config.apply_parallel_config(&tree)?;
581    }
582
583    let modules = &loader_tree.modules;
584    for path in [
585        modules.page.clone(),
586        modules.default.clone(),
587        modules.layout.clone(),
588    ]
589    .into_iter()
590    .flatten()
591    {
592        let source = Vc::upcast(FileSource::new(path.clone()));
593        config.apply_parent_config(&*parse_segment_config_from_source(source).await?);
594    }
595
596    Ok(config)
597}