next_core/next_app/metadata/
route.rs1use 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#[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 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 let config = segment_config.await.unwrap();
79 config.generate_sitemaps || config.generate_image_metadata
80 } else {
81 false
82 };
83
84 if is_dynamic_metadata {
88 page.0.pop();
90
91 if is_multi_dynamic {
92 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 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 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 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 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 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}