next_core/next_app/metadata/
route.rs

1//! Rust port of the `next-metadata-route-loader`
2//!
3//! See `next/src/build/webpack/loaders/next-metadata-route-loader`
4
5use anyhow::{Ok, Result, bail};
6use base64::{display::Base64Display, engine::general_purpose::STANDARD};
7use indoc::formatdoc;
8use turbo_rcstr::{RcStr, rcstr};
9use turbo_tasks::Vc;
10use turbo_tasks_fs::{self, File, FileContent, FileSystemPath};
11use turbopack::ModuleAssetContext;
12use turbopack_core::{
13    asset::AssetContent,
14    file_source::FileSource,
15    issue::{Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString},
16    source::Source,
17    virtual_source::VirtualSource,
18};
19use turbopack_ecmascript::utils::StringifyJs;
20
21use super::get_content_type;
22use crate::{
23    app_structure::MetadataItem,
24    mode::NextMode,
25    next_app::{
26        AppPage, PageSegment, PageType, app_entry::AppEntry, app_route_entry::get_app_route_entry,
27    },
28    next_config::NextConfig,
29    parse_segment_config_from_source,
30    segment_config::ParseSegmentMode,
31};
32
33/// Computes the route source for a Next.js metadata file.
34#[turbo_tasks::function]
35pub async fn get_app_metadata_route_source(
36    mode: NextMode,
37    metadata: MetadataItem,
38    is_multi_dynamic: bool,
39) -> Result<Vc<Box<dyn Source>>> {
40    Ok(match metadata {
41        MetadataItem::Static { path } => static_route_source(mode, path),
42        MetadataItem::Dynamic { path } => {
43            let stem = path.file_stem();
44            let stem = stem.unwrap_or_default();
45
46            if stem == "robots" || stem == "manifest" {
47                dynamic_text_route_source(path)
48            } else if stem == "sitemap" {
49                dynamic_site_map_route_source(path, is_multi_dynamic)
50            } else {
51                dynamic_image_route_source(path, is_multi_dynamic)
52            }
53        }
54    })
55}
56
57#[turbo_tasks::function]
58pub async fn get_app_metadata_route_entry(
59    nodejs_context: Vc<ModuleAssetContext>,
60    edge_context: Vc<ModuleAssetContext>,
61    project_root: FileSystemPath,
62    mut page: AppPage,
63    mode: NextMode,
64    metadata: MetadataItem,
65    next_config: Vc<NextConfig>,
66) -> Result<Vc<AppEntry>> {
67    // Read original source's segment config before replacing source into
68    // dynamic|static metadata route handler.
69    let original_path = metadata.clone().into_path();
70
71    let source = Vc::upcast(FileSource::new(original_path));
72    let segment_config = parse_segment_config_from_source(source, ParseSegmentMode::App);
73    let is_dynamic_metadata = matches!(metadata, MetadataItem::Dynamic { .. });
74    let is_multi_dynamic: bool = if Some(segment_config).is_some() {
75        // is_multi_dynamic is true when config.generateSitemaps or
76        // config.generateImageMetadata is defined in dynamic routes
77        let config = segment_config.await.unwrap();
78        config.generate_sitemaps || config.generate_image_metadata
79    } else {
80        false
81    };
82
83    // Map dynamic sitemap and image routes based on the exports.
84    // if there's generator export: add /[__metadata_id__] to the route;
85    // otherwise keep the original route.
86    // For sitemap, if the last segment is sitemap, appending .xml suffix.
87    if is_dynamic_metadata {
88        // remove the last /route segment of page
89        page.0.pop();
90
91        if is_multi_dynamic {
92            page.push(PageSegment::Dynamic(rcstr!("__metadata_id__")))?;
93        } else {
94            // if page last segment is sitemap, change to sitemap.xml
95            if page.last() == Some(&PageSegment::Static(rcstr!("sitemap"))) {
96                page.0.pop();
97                page.push(PageSegment::Static(rcstr!("sitemap.xml")))?
98            }
99        };
100        // Push /route back
101        page.push(PageSegment::PageType(PageType::Route))?;
102    };
103
104    Ok(get_app_route_entry(
105        nodejs_context,
106        edge_context,
107        get_app_metadata_route_source(mode, metadata, is_multi_dynamic),
108        page,
109        project_root,
110        Some(segment_config),
111        next_config,
112    ))
113}
114
115const CACHE_HEADER_NONE: &str = "no-cache, no-store";
116const CACHE_HEADER_REVALIDATE: &str = "public, max-age=0, must-revalidate";
117
118async fn get_base64_file_content(path: FileSystemPath) -> Result<String> {
119    let original_file_content = path.read().await?;
120
121    Ok(match &*original_file_content {
122        FileContent::Content(content) => {
123            let content = content.content().to_bytes();
124            Base64Display::new(&content, &STANDARD).to_string()
125        }
126        FileContent::NotFound => {
127            bail!(
128                "metadata file not found: {}",
129                &path.value_to_string().await?
130            );
131        }
132    })
133}
134
135#[turbo_tasks::function]
136async fn static_route_source(mode: NextMode, path: FileSystemPath) -> Result<Vc<Box<dyn Source>>> {
137    let stem = path.file_stem();
138    let stem = stem.unwrap_or_default();
139
140    let cache_control = if mode.is_production() {
141        CACHE_HEADER_REVALIDATE
142    } else {
143        CACHE_HEADER_NONE
144    };
145
146    let is_twitter = stem == "twitter-image";
147    let is_open_graph = stem == "opengraph-image";
148
149    let content_type = get_content_type(path.clone()).await?;
150    let original_file_content_b64;
151
152    // Twitter image file size limit is 5MB.
153    // General Open Graph image file size limit is 8MB.
154    // x-ref: https://developer.x.com/en/docs/x-for-websites/cards/overview/summary
155    // x-ref(facebook): https://developers.facebook.com/docs/sharing/webmasters/images
156    let file_size_limit_mb = if is_twitter { 5 } else { 8 };
157    if (is_twitter || is_open_graph)
158        && let Some(content) = path.read().await?.as_content()
159        && let file_size = content.content().to_bytes().len()
160        && file_size > (file_size_limit_mb * 1024 * 1024)
161    {
162        StaticMetadataFileSizeIssue {
163            img_name: if is_twitter {
164                rcstr!("Twitter")
165            } else {
166                rcstr!("Open Graph")
167            },
168            path: path.clone(),
169            file_size_limit_mb,
170            file_size,
171        }
172        .resolved_cell()
173        .emit();
174
175        // Don't inline huge string, just insert placeholder
176        original_file_content_b64 = "".to_string();
177    } else {
178        original_file_content_b64 = get_base64_file_content(path.clone()).await?
179    }
180
181    let code = formatdoc! {
182        r#"
183            import {{ NextResponse }} from 'next/server'
184
185            const contentType = {content_type}
186            const cacheControl = {cache_control}
187            const buffer = Buffer.from({original_file_content_b64}, 'base64')
188
189            export function GET() {{
190                return new NextResponse(buffer, {{
191                    headers: {{
192                        'Content-Type': contentType,
193                        'Cache-Control': cacheControl,
194                    }},
195                }})
196            }}
197
198            export const dynamic = 'force-static'
199        "#,
200        content_type = StringifyJs(&content_type),
201        cache_control = StringifyJs(cache_control),
202        original_file_content_b64 = StringifyJs(&original_file_content_b64),
203    };
204
205    let file = File::from(code);
206    let source = VirtualSource::new(
207        path.parent().join(&format!("{stem}--route-entry.js"))?,
208        AssetContent::file(file.into()),
209    );
210
211    Ok(Vc::upcast(source))
212}
213
214#[turbo_tasks::function]
215async fn dynamic_text_route_source(path: FileSystemPath) -> Result<Vc<Box<dyn Source>>> {
216    let stem = path.file_stem();
217    let stem = stem.unwrap_or_default();
218    let ext = path.extension();
219
220    let content_type = get_content_type(path.clone()).await?;
221
222    // refer https://github.com/vercel/next.js/blob/7b2b9823432fb1fa28ae0ac3878801d638d93311/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts#L84
223    // for the original template.
224    let code = formatdoc! {
225        r#"
226            import {{ NextResponse }} from 'next/server'
227            import handler from {resource_path}
228            import {{ resolveRouteData }} from
229'next/dist/build/webpack/loaders/metadata/resolve-route-data'
230
231            const contentType = {content_type}
232            const cacheControl = {cache_control}
233            const fileType = {file_type}
234
235            if (typeof handler !== 'function') {{
236                throw new Error('Default export is missing in {resource_path}')
237            }}
238
239            export async function GET() {{
240              const data = await handler()
241              const content = resolveRouteData(data, fileType)
242
243              return new NextResponse(content, {{
244                headers: {{
245                  'Content-Type': contentType,
246                  'Cache-Control': cacheControl,
247                }},
248              }})
249            }}
250
251            export * from {resource_path}
252        "#,
253        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
254        content_type = StringifyJs(&content_type),
255        file_type = StringifyJs(&stem),
256        cache_control = StringifyJs(CACHE_HEADER_REVALIDATE),
257    };
258
259    let file = File::from(code);
260    let source = VirtualSource::new(
261        path.parent().join(&format!("{stem}--route-entry.js"))?,
262        AssetContent::file(file.into()),
263    );
264
265    Ok(Vc::upcast(source))
266}
267
268async fn dynamic_sitemap_route_with_generate_source(
269    path: FileSystemPath,
270) -> Result<Vc<Box<dyn Source>>> {
271    let stem = path.file_stem();
272    let stem = stem.unwrap_or_default();
273    let ext = path.extension();
274    let content_type = get_content_type(path.clone()).await?;
275
276    let code = formatdoc! {
277        r#"
278            import {{ NextResponse }} from 'next/server'
279            import {{ default as handler, generateSitemaps }} from {resource_path}
280            import {{ resolveRouteData }} from 'next/dist/build/webpack/loaders/metadata/resolve-route-data'
281
282            const contentType = {content_type}
283            const cache_control = {cache_control}
284            const fileType = {file_type}
285
286            if (typeof handler !== 'function') {{
287                throw new Error('Default export is missing in {resource_path}')
288            }}
289
290            export async function GET(_, ctx) {{
291                const paramsPromise = ctx.params
292                const idPromise = paramsPromise.then(params => params?.__metadata_id__)
293
294                const id = await idPromise
295                const hasXmlExtension = id ? id.endsWith('.xml') : false
296                const sitemaps = await generateSitemaps()
297                let foundId
298                for (const item of sitemaps) {{
299                    if (item?.id == null) {{
300                        throw new Error('id property is required for every item returned from generateSitemaps')
301                    }}
302                    const baseId = id && hasXmlExtension ? id.slice(0, -4) : undefined
303                    if (item.id.toString() === baseId) {{
304                        foundId = item.id
305                    }}
306                }}
307                if (foundId == null) {{
308                    return new NextResponse('Not Found', {{
309                        status: 404,
310                    }})
311                }}
312                
313                const targetIdPromise = idPromise.then(id => {{
314                    const hasXmlExtension = id ? id.endsWith('.xml') : false
315                    return id && hasXmlExtension ? id.slice(0, -4) : undefined
316                }})
317                const data = await handler({{ id: targetIdPromise }})
318                const content = resolveRouteData(data, fileType)
319
320                return new NextResponse(content, {{
321                    headers: {{
322                        'Content-Type': contentType,
323                        'Cache-Control': cache_control,
324                    }},
325                }})
326            }}
327
328            export * from {resource_path}
329
330            export async function generateStaticParams() {{
331                const sitemaps = await generateSitemaps()
332                const params = []
333
334                for (const item of sitemaps) {{
335                    if (item?.id == null) {{
336                        throw new Error('id property is required for every item returned from generateSitemaps')
337                    }}
338                    params.push({{ __metadata_id__: item.id.toString() + '.xml' }})
339                }}
340                return params
341            }}
342        "#,
343        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
344        content_type = StringifyJs(&content_type),
345        file_type = StringifyJs(&stem),
346        cache_control = StringifyJs(CACHE_HEADER_REVALIDATE),
347    };
348
349    let file = File::from(code);
350    let source = VirtualSource::new(
351        path.parent().join(&format!("{stem}--route-entry.js"))?,
352        AssetContent::file(file.into()),
353    );
354
355    Ok(Vc::upcast(source))
356}
357
358async fn dynamic_sitemap_route_without_generate_source(
359    path: FileSystemPath,
360) -> Result<Vc<Box<dyn Source>>> {
361    let stem = path.file_stem();
362    let stem = stem.unwrap_or_default();
363    let ext = path.extension();
364    let content_type = get_content_type(path.clone()).await?;
365
366    let code = formatdoc! {
367        r#"
368            import {{ NextResponse }} from 'next/server'
369            import {{ default as handler }} from {resource_path}
370            import {{ resolveRouteData }} from 'next/dist/build/webpack/loaders/metadata/resolve-route-data'
371
372            const contentType = {content_type}
373            const cacheControl = {cache_control}
374            const fileType = {file_type}
375
376            if (typeof handler !== 'function') {{
377                throw new Error('Default export is missing in {resource_path}')
378            }}
379
380            export async function GET() {{
381                const data = await handler()
382                const content = resolveRouteData(data, fileType)
383
384                return new NextResponse(content, {{
385                    headers: {{
386                        'Content-Type': contentType,
387                        'Cache-Control': cacheControl,
388                    }},
389                }})
390            }}
391
392            export * from {resource_path}
393        "#,
394        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
395        content_type = StringifyJs(&content_type),
396        file_type = StringifyJs(&stem),
397        cache_control = StringifyJs(CACHE_HEADER_REVALIDATE),
398    };
399
400    let file = File::from(code);
401    let source = VirtualSource::new(
402        path.parent().join(&format!("{stem}--route-entry.js"))?,
403        AssetContent::file(file.into()),
404    );
405
406    Ok(Vc::upcast(source))
407}
408
409#[turbo_tasks::function]
410async fn dynamic_site_map_route_source(
411    path: FileSystemPath,
412    is_multi_dynamic: bool,
413) -> Result<Vc<Box<dyn Source>>> {
414    if is_multi_dynamic {
415        dynamic_sitemap_route_with_generate_source(path).await
416    } else {
417        dynamic_sitemap_route_without_generate_source(path).await
418    }
419}
420
421async fn dynamic_image_route_with_metadata_source(
422    path: FileSystemPath,
423) -> Result<Vc<Box<dyn Source>>> {
424    let stem = path.file_stem();
425    let stem = stem.unwrap_or_default();
426    let ext = path.extension();
427
428    let code = formatdoc! {
429        r#"
430            import {{ NextResponse }} from 'next/server'
431            import {{ default as handler, generateImageMetadata }} from {resource_path}
432
433            if (typeof handler !== 'function') {{
434                throw new Error('Default export is missing in {resource_path}')
435            }}
436
437            export async function GET(_, ctx) {{
438                const paramsPromise = ctx.params
439                const idPromise = paramsPromise.then(params => params?.__metadata_id__)
440                const restParamsPromise = paramsPromise.then(params => {{
441                    if (!params) return undefined
442                    const {{ __metadata_id__, ...rest }} = params
443                    return rest
444                }})
445
446                const restParams = await restParamsPromise
447                const __metadata_id__ = await idPromise
448                const imageMetadata = await generateImageMetadata({{ params: restParams }})
449                const id = imageMetadata.find((item) => {{
450                    if (item?.id == null) {{
451                        throw new Error('id property is required for every item returned from generateImageMetadata')
452                    }}
453
454                    return item.id.toString() === __metadata_id__
455                }})?.id
456
457                if (id == null) {{
458                    return new NextResponse('Not Found', {{
459                        status: 404,
460                    }})
461                }}
462
463                return handler({{ params: restParamsPromise, id: idPromise }})
464            }}
465
466            export * from {resource_path}
467
468            export async function generateStaticParams({{ params }}) {{
469                const imageMetadata = await generateImageMetadata({{ params }})
470                const staticParams = []
471
472                for (const item of imageMetadata) {{
473                    if (item?.id == null) {{
474                        throw new Error('id property is required for every item returned from generateImageMetadata')
475                    }}
476                    staticParams.push({{ __metadata_id__: item.id.toString() }})
477                }}
478                return staticParams
479            }}
480        "#,
481        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
482    };
483
484    let file = File::from(code);
485    let source = VirtualSource::new(
486        path.parent().join(&format!("{stem}--route-entry.js"))?,
487        AssetContent::file(file.into()),
488    );
489
490    Ok(Vc::upcast(source))
491}
492
493async fn dynamic_image_route_without_metadata_source(
494    path: FileSystemPath,
495) -> Result<Vc<Box<dyn Source>>> {
496    let stem = path.file_stem();
497    let stem = stem.unwrap_or_default();
498    let ext = path.extension();
499
500    let code = formatdoc! {
501        r#"
502            import {{ NextResponse }} from 'next/server'
503            import {{ default as handler }} from {resource_path}
504
505            if (typeof handler !== 'function') {{
506                throw new Error('Default export is missing in {resource_path}')
507            }}
508
509            export async function GET(_, ctx) {{
510                return handler({{ params: ctx.params }})
511            }}
512
513            export * from {resource_path}
514        "#,
515        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
516    };
517
518    let file = File::from(code);
519    let source = VirtualSource::new(
520        path.parent().join(&format!("{stem}--route-entry.js"))?,
521        AssetContent::file(file.into()),
522    );
523
524    Ok(Vc::upcast(source))
525}
526
527#[turbo_tasks::function]
528async fn dynamic_image_route_source(
529    path: FileSystemPath,
530    is_multi_dynamic: bool,
531) -> Result<Vc<Box<dyn Source>>> {
532    if is_multi_dynamic {
533        dynamic_image_route_with_metadata_source(path).await
534    } else {
535        dynamic_image_route_without_metadata_source(path).await
536    }
537}
538
539#[turbo_tasks::value(shared)]
540struct StaticMetadataFileSizeIssue {
541    img_name: RcStr,
542    path: FileSystemPath,
543    file_size: usize,
544    file_size_limit_mb: usize,
545}
546
547#[turbo_tasks::value_impl]
548impl Issue for StaticMetadataFileSizeIssue {
549    fn severity(&self) -> IssueSeverity {
550        IssueSeverity::Error
551    }
552
553    #[turbo_tasks::function]
554    fn title(&self) -> Vc<StyledString> {
555        StyledString::Text(rcstr!("Static metadata file size exceeded")).cell()
556    }
557
558    #[turbo_tasks::function]
559    fn stage(&self) -> Vc<IssueStage> {
560        IssueStage::ProcessModule.into()
561    }
562
563    #[turbo_tasks::function]
564    fn file_path(&self) -> Vc<FileSystemPath> {
565        self.path.clone().cell()
566    }
567
568    #[turbo_tasks::function]
569    async fn description(&self) -> Result<Vc<OptionStyledString>> {
570        Ok(Vc::cell(Some(
571            StyledString::Text(
572                format!(
573                    "File size for {} image \"{}\" exceeds {}MB. (Current: {:.1}MB)",
574                    self.img_name,
575                    self.path.value_to_string().await?,
576                    self.file_size_limit_mb,
577                    (self.file_size as f32) / 1024.0 / 1024.0
578                )
579                .into(),
580            )
581            .resolved_cell(),
582        )))
583    }
584
585    #[turbo_tasks::function]
586    fn documentation_link(&self) -> Vc<RcStr> {
587        Vc::cell(rcstr!(
588            "https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image#image-files-jpg-png-gif"
589        ))
590    }
591}