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_tasks::{ValueToString, Vc};
9use turbo_tasks_fs::{self, File, FileContent, FileSystemPath};
10use turbopack::ModuleAssetContext;
11use turbopack_core::{
12    asset::AssetContent, file_source::FileSource, source::Source, virtual_source::VirtualSource,
13};
14use turbopack_ecmascript::utils::StringifyJs;
15
16use super::get_content_type;
17use crate::{
18    app_structure::MetadataItem,
19    mode::NextMode,
20    next_app::{
21        AppPage, PageSegment, PageType, app_entry::AppEntry, app_route_entry::get_app_route_entry,
22    },
23    next_config::NextConfig,
24    parse_segment_config_from_source,
25};
26
27/// Computes the route source for a Next.js metadata file.
28#[turbo_tasks::function]
29pub async fn get_app_metadata_route_source(
30    mode: NextMode,
31    metadata: MetadataItem,
32    is_multi_dynamic: bool,
33) -> Result<Vc<Box<dyn Source>>> {
34    Ok(match metadata {
35        MetadataItem::Static { path } => static_route_source(mode, *path),
36        MetadataItem::Dynamic { path } => {
37            let stem = path.file_stem().await?;
38            let stem = stem.as_deref().unwrap_or_default();
39
40            if stem == "robots" || stem == "manifest" {
41                dynamic_text_route_source(*path)
42            } else if stem == "sitemap" {
43                dynamic_site_map_route_source(mode, *path, is_multi_dynamic)
44            } else {
45                dynamic_image_route_source(*path)
46            }
47        }
48    })
49}
50
51#[turbo_tasks::function]
52pub async fn get_app_metadata_route_entry(
53    nodejs_context: Vc<ModuleAssetContext>,
54    edge_context: Vc<ModuleAssetContext>,
55    project_root: Vc<FileSystemPath>,
56    mut page: AppPage,
57    mode: NextMode,
58    metadata: MetadataItem,
59    next_config: Vc<NextConfig>,
60) -> Vc<AppEntry> {
61    // Read original source's segment config before replacing source into
62    // dynamic|static metadata route handler.
63    let original_path = metadata.into_path();
64
65    let source = Vc::upcast(FileSource::new(*original_path));
66    let segment_config = parse_segment_config_from_source(source);
67    let is_dynamic_metadata = matches!(metadata, MetadataItem::Dynamic { .. });
68    let is_multi_dynamic: bool = if Some(segment_config).is_some() {
69        // is_multi_dynamic is true when config.generateSitemaps or
70        // config.generateImageMetadata is defined in dynamic routes
71        let config = segment_config.await.unwrap();
72        config.generate_sitemaps || config.generate_image_metadata
73    } else {
74        false
75    };
76
77    // Map dynamic sitemap and image routes based on the exports.
78    // if there's generator export: add /[__metadata_id__] to the route;
79    // otherwise keep the original route.
80    // For sitemap, if the last segment is sitemap, appending .xml suffix.
81    if is_dynamic_metadata {
82        // remove the last /route segment of page
83        page.0.pop();
84
85        let _ = if is_multi_dynamic {
86            page.push(PageSegment::Dynamic("__metadata_id__".into()))
87        } else {
88            // if page last segment is sitemap, change to sitemap.xml
89            if page.last() == Some(&PageSegment::Static("sitemap".into())) {
90                page.0.pop();
91                page.push(PageSegment::Static("sitemap.xml".into()))
92            } else {
93                Ok(())
94            }
95        };
96        // Push /route back
97        let _ = page.push(PageSegment::PageType(PageType::Route));
98    };
99
100    get_app_route_entry(
101        nodejs_context,
102        edge_context,
103        get_app_metadata_route_source(mode, metadata, is_multi_dynamic),
104        page,
105        project_root,
106        Some(segment_config),
107        next_config,
108    )
109}
110
111const CACHE_HEADER_NONE: &str = "no-cache, no-store";
112const CACHE_HEADER_LONG_CACHE: &str = "public, immutable, no-transform, max-age=31536000";
113const CACHE_HEADER_REVALIDATE: &str = "public, max-age=0, must-revalidate";
114
115async fn get_base64_file_content(path: Vc<FileSystemPath>) -> Result<String> {
116    let original_file_content = path.read().await?;
117
118    Ok(match &*original_file_content {
119        FileContent::Content(content) => {
120            let content = content.content().to_bytes()?;
121            Base64Display::new(&content, &STANDARD).to_string()
122        }
123        FileContent::NotFound => {
124            bail!("metadata file not found: {}", &path.to_string().await?);
125        }
126    })
127}
128
129#[turbo_tasks::function]
130async fn static_route_source(
131    mode: NextMode,
132    path: Vc<FileSystemPath>,
133) -> Result<Vc<Box<dyn Source>>> {
134    let stem = path.file_stem().await?;
135    let stem = stem.as_deref().unwrap_or_default();
136
137    let content_type = get_content_type(path).await?;
138
139    let cache_control = if stem == "favicon" {
140        CACHE_HEADER_REVALIDATE
141    } else if mode.is_production() {
142        CACHE_HEADER_LONG_CACHE
143    } else {
144        CACHE_HEADER_NONE
145    };
146
147    let original_file_content_b64 = get_base64_file_content(path).await?;
148
149    let is_twitter = stem == "twitter-image";
150    let is_open_graph = stem == "opengraph-image";
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 = if is_twitter { 5 } else { 8 };
156    let img_name = if is_twitter { "Twitter" } else { "Open Graph" };
157
158    let code = formatdoc! {
159        r#"
160            import {{ NextResponse }} from 'next/server'
161
162            const contentType = {content_type}
163            const cacheControl = {cache_control}
164            const buffer = Buffer.from({original_file_content_b64}, 'base64')
165
166            if ({is_twitter} || {is_open_graph}) {{
167                const fileSizeInMB = buffer.byteLength / 1024 / 1024
168                if (fileSizeInMB > {file_size_limit}) {{
169                    throw new Error('File size for {img_name} image {path} exceeds {file_size_limit}MB. ' +
170                    `(Current: ${{fileSizeInMB.toFixed(2)}}MB)\n` +
171                    'Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image#image-files-jpg-png-gif'
172                    )
173                }}
174            }}
175
176            export function GET() {{
177                return new NextResponse(buffer, {{
178                    headers: {{
179                        'Content-Type': contentType,
180                        'Cache-Control': cacheControl,
181                    }},
182                }})
183            }}
184
185            export const dynamic = 'force-static'
186        "#,
187        content_type = StringifyJs(&content_type),
188        cache_control = StringifyJs(cache_control),
189        original_file_content_b64 = StringifyJs(&original_file_content_b64),
190        is_twitter = is_twitter,
191        is_open_graph = is_open_graph,
192        file_size_limit = file_size_limit,
193        img_name = img_name,
194        path = StringifyJs(&path.to_string().await?),
195    };
196
197    let file = File::from(code);
198    let source = VirtualSource::new(
199        path.parent().join(format!("{stem}--route-entry.js").into()),
200        AssetContent::file(file.into()),
201    );
202
203    Ok(Vc::upcast(source))
204}
205
206#[turbo_tasks::function]
207async fn dynamic_text_route_source(path: Vc<FileSystemPath>) -> Result<Vc<Box<dyn Source>>> {
208    let stem = path.file_stem().await?;
209    let stem = stem.as_deref().unwrap_or_default();
210    let ext = &*path.extension().await?;
211
212    let content_type = get_content_type(path).await?;
213
214    // refer https://github.com/vercel/next.js/blob/7b2b9823432fb1fa28ae0ac3878801d638d93311/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts#L84
215    // for the original template.
216    let code = formatdoc! {
217        r#"
218            import {{ NextResponse }} from 'next/server'
219            import handler from {resource_path}
220            import {{ resolveRouteData }} from
221'next/dist/build/webpack/loaders/metadata/resolve-route-data'
222
223            const contentType = {content_type}
224            const cacheControl = {cache_control}
225            const fileType = {file_type}
226
227            if (typeof handler !== 'function') {{
228                throw new Error('Default export is missing in {resource_path}')
229            }}
230
231            export async function GET() {{
232              const data = await handler()
233              const content = resolveRouteData(data, fileType)
234
235              return new NextResponse(content, {{
236                headers: {{
237                  'Content-Type': contentType,
238                  'Cache-Control': cacheControl,
239                }},
240              }})
241            }}
242        "#,
243        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
244        content_type = StringifyJs(&content_type),
245        file_type = StringifyJs(&stem),
246        cache_control = StringifyJs(CACHE_HEADER_REVALIDATE),
247    };
248
249    let file = File::from(code);
250    let source = VirtualSource::new(
251        path.parent().join(format!("{stem}--route-entry.js").into()),
252        AssetContent::file(file.into()),
253    );
254
255    Ok(Vc::upcast(source))
256}
257
258#[turbo_tasks::function]
259async fn dynamic_site_map_route_source(
260    mode: NextMode,
261    path: Vc<FileSystemPath>,
262    is_multi_dynamic: bool,
263) -> Result<Vc<Box<dyn Source>>> {
264    let stem = path.file_stem().await?;
265    let stem = stem.as_deref().unwrap_or_default();
266    let ext = &*path.extension().await?;
267    let content_type = get_content_type(path).await?;
268    let mut static_generation_code = "";
269
270    if mode.is_production() && is_multi_dynamic {
271        static_generation_code = indoc! {
272            r#"
273                export async function generateStaticParams() {
274                    const sitemaps = await generateSitemaps()
275                    const params = []
276
277                    for (const item of sitemaps) {{
278                        params.push({ __metadata_id__: item.id.toString() + '.xml' })
279                    }}
280                    return params
281                }
282            "#,
283        };
284    }
285
286    let code = formatdoc! {
287        r#"
288            import {{ NextResponse }} from 'next/server'
289            import * as _sitemapModule from {resource_path}
290            import {{ resolveRouteData }} from 'next/dist/build/webpack/loaders/metadata/resolve-route-data'
291
292            const sitemapModule = {{ ..._sitemapModule }}
293            const handler = sitemapModule.default
294            const generateSitemaps = sitemapModule.generateSitemaps
295            const contentType = {content_type}
296            const cacheControl = {cache_control}
297            const fileType = {file_type}
298
299            if (typeof handler !== 'function') {{
300                throw new Error('Default export is missing in {resource_path}')
301            }}
302
303            export async function GET(_, ctx) {{
304                const {{ __metadata_id__: id, ...params }} = await ctx.params || {{}}
305                const hasXmlExtension = id ? id.endsWith('.xml') : false
306                if (id && !hasXmlExtension) {{
307                    return new NextResponse('Not Found', {{
308                        status: 404,
309                    }})
310                }}
311
312                if (process.env.NODE_ENV !== 'production' && sitemapModule.generateSitemaps) {{
313                    const sitemaps = await sitemapModule.generateSitemaps()
314                    for (const item of sitemaps) {{
315                        if (item?.id == null) {{
316                            throw new Error('id property is required for every item returned from generateSitemaps')
317                        }}
318                    }}
319                }}
320                
321                const targetId = id && hasXmlExtension ? id.slice(0, -4) : undefined
322                const data = await handler({{ id: targetId }})
323                const content = resolveRouteData(data, fileType)
324
325                return new NextResponse(content, {{
326                    headers: {{
327                        'Content-Type': contentType,
328                        'Cache-Control': cacheControl,
329                    }},
330                }})
331            }}
332
333            {static_generation_code}
334        "#,
335        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
336        content_type = StringifyJs(&content_type),
337        file_type = StringifyJs(&stem),
338        cache_control = StringifyJs(CACHE_HEADER_REVALIDATE),
339        static_generation_code = static_generation_code,
340    };
341
342    let file = File::from(code);
343    let source = VirtualSource::new(
344        path.parent().join(format!("{stem}--route-entry.js").into()),
345        AssetContent::file(file.into()),
346    );
347
348    Ok(Vc::upcast(source))
349}
350
351#[turbo_tasks::function]
352async fn dynamic_image_route_source(path: Vc<FileSystemPath>) -> Result<Vc<Box<dyn Source>>> {
353    let stem = path.file_stem().await?;
354    let stem = stem.as_deref().unwrap_or_default();
355    let ext = &*path.extension().await?;
356
357    let code = formatdoc! {
358        r#"
359            import {{ NextResponse }} from 'next/server'
360            import * as _imageModule from {resource_path}
361
362            const imageModule = {{ ..._imageModule }}
363
364            const handler = imageModule.default
365            const generateImageMetadata = imageModule.generateImageMetadata
366
367            if (typeof handler !== 'function') {{
368                throw new Error('Default export is missing in {resource_path}')
369            }}
370
371            export async function GET(_, ctx) {{
372                const params = await ctx.params
373                const {{ __metadata_id__, ...rest }} = params || {{}}
374                const restParams = params ? rest : undefined
375                const targetId = __metadata_id__
376                let id = undefined
377
378                if (generateImageMetadata) {{
379                    const imageMetadata = await generateImageMetadata({{ params: restParams }})
380                    id = imageMetadata.find((item) => {{
381                        if (process.env.NODE_ENV !== 'production') {{
382                            if (item?.id == null) {{
383                                throw new Error('id property is required for every item returned from generateImageMetadata')
384                            }}
385                        }}
386                        return item.id.toString() === targetId
387                    }})?.id
388
389                    if (id == null) {{
390                        return new NextResponse('Not Found', {{
391                            status: 404,
392                        }})
393                    }}
394                }}
395
396                return handler({{ params: restParams, id }})
397            }}
398        "#,
399        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
400    };
401
402    let file = File::from(code);
403    let source = VirtualSource::new(
404        path.parent().join(format!("{stem}--route-entry.js").into()),
405        AssetContent::file(file.into()),
406    );
407
408    Ok(Vc::upcast(source))
409}