next_core/next_app/metadata/
route.rs1use 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#[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 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 let config = segment_config.await.unwrap();
77 config.generate_sitemaps || config.generate_image_metadata
78 } else {
79 false
80 };
81
82 if is_dynamic_metadata {
87 page.0.pop();
89
90 if is_multi_dynamic {
91 page.push(PageSegment::Dynamic(rcstr!("__metadata_id__")))?;
92 } else {
93 if page.last() == Some(&PageSegment::Static(rcstr!("sitemap"))) {
95 page.0.pop();
96 page.push(PageSegment::Static(rcstr!("sitemap.xml")))?
97 }
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 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 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 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 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}