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