next_core/next_app/metadata/
route.rs1use anyhow::{Ok, Result};
6use base64::{display::Base64Display, engine::general_purpose::STANDARD};
7use indoc::formatdoc;
8use turbo_rcstr::{RcStr, rcstr};
9use turbo_tasks::{Vc, turbobail, turbofmt};
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#[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 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 let config = segment_config.await.unwrap();
78 config.generate_sitemaps || config.generate_image_metadata
79 } else {
80 false
81 };
82
83 if is_dynamic_metadata {
87 page.0.pop();
89
90 if is_multi_dynamic {
91 if page.last() == Some(&PageSegment::Static(rcstr!("sitemap.xml"))) {
94 page.0.pop();
95 page.push(PageSegment::Static(rcstr!("sitemap")))?;
96 }
97 page.push(PageSegment::Dynamic(rcstr!("__metadata_id__")))?;
98 };
99 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 turbobail!("metadata file not found: {path}")
127 }
128 })
129}
130
131#[turbo_tasks::function]
132async fn static_route_source(mode: NextMode, path: FileSystemPath) -> Result<Vc<Box<dyn Source>>> {
133 let stem = path.file_stem();
134 let stem = stem.unwrap_or_default();
135
136 let cache_control = if mode.is_production() {
137 CACHE_HEADER_REVALIDATE
138 } else {
139 CACHE_HEADER_NONE
140 };
141
142 let is_twitter = stem == "twitter-image";
143 let is_open_graph = stem == "opengraph-image";
144
145 let content_type = get_content_type(path.clone()).await?;
146 let original_file_content_b64;
147
148 let file_size_limit_mb = if is_twitter { 5 } else { 8 };
153 if (is_twitter || is_open_graph)
154 && let Some(content) = path.read().await?.as_content()
155 && let file_size = content.content().to_bytes().len()
156 && file_size > (file_size_limit_mb * 1024 * 1024)
157 {
158 StaticMetadataFileSizeIssue {
159 img_name: if is_twitter {
160 rcstr!("Twitter")
161 } else {
162 rcstr!("Open Graph")
163 },
164 path: path.clone(),
165 file_size_limit_mb,
166 file_size,
167 }
168 .resolved_cell()
169 .emit();
170
171 original_file_content_b64 = "".to_string();
173 } else {
174 original_file_content_b64 = get_base64_file_content(path.clone()).await?
175 }
176
177 let code = formatdoc! {
178 r#"
179 import {{ NextResponse }} from 'next/server'
180
181 const contentType = {content_type}
182 const cacheControl = {cache_control}
183 const buffer = Buffer.from({original_file_content_b64}, 'base64')
184
185 export function GET() {{
186 return new NextResponse(buffer, {{
187 headers: {{
188 'Content-Type': contentType,
189 'Cache-Control': cacheControl,
190 }},
191 }})
192 }}
193
194 export const dynamic = 'force-static'
195 "#,
196 content_type = StringifyJs(&content_type),
197 cache_control = StringifyJs(cache_control),
198 original_file_content_b64 = StringifyJs(&original_file_content_b64),
199 };
200
201 let filename = path.file_name();
204
205 let file = File::from(code);
206 let source = VirtualSource::new(
207 path.parent().join(&format!("{filename}--route-entry.js"))?,
208 AssetContent::file(FileContent::Content(file).cell()),
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 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(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 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(FileContent::Content(file).cell()),
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(FileContent::Content(file).cell()),
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(FileContent::Content(file).cell()),
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(FileContent::Content(file).cell()),
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.cell()
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 let current_size = (self.file_size as f32) / 1024.0 / 1024.0;
571 Ok(Vc::cell(Some(
572 StyledString::Text(
573 turbofmt!(
574 "File size for {} image \"{}\" exceeds {}MB. (Current: {current_size:.1}MB)",
575 self.img_name,
576 self.path,
577 self.file_size_limit_mb,
578 )
579 .await?,
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}