Skip to main content

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