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