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_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#[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 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 let config = segment_config.await.unwrap();
72 config.generate_sitemaps || config.generate_image_metadata
73 } else {
74 false
75 };
76
77 if is_dynamic_metadata {
82 page.0.pop();
84
85 let _ = if is_multi_dynamic {
86 page.push(PageSegment::Dynamic("__metadata_id__".into()))
87 } else {
88 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 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 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 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}