Repository: vercel/next.js
Pull request: Turbopack: throw large static metadata error earlier

Throw it during bundling, as opposed to at runtime.

This circumnavigates a panic in Turbopack when inlining a 40mb string into a JS module.

Closes PACK-5326

Changed files:
filename: crates/next-core/src/next_app/metadata/route.rs
patch: @@ -5,12 +5,16 @@
 use anyhow::{Ok, Result, bail};
 use base64::{display::Base64Display, engine::general_purpose::STANDARD};
 use indoc::{formatdoc, indoc};
-use turbo_rcstr::rcstr;
+use turbo_rcstr::{RcStr, rcstr};
 use turbo_tasks::Vc;
 use turbo_tasks_fs::{self, File, FileContent, FileSystemPath};
 use turbopack::ModuleAssetContext;
 use turbopack_core::{
-    asset::AssetContent, file_source::FileSource, source::Source, virtual_source::VirtualSource,
+    asset::AssetContent,
+    file_source::FileSource,
+    issue::{Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString},
+    source::Source,
+    virtual_source::VirtualSource,
 };
 use turbopack_ecmascript::utils::StringifyJs;
 
@@ -133,8 +137,6 @@ async fn static_route_source(mode: NextMode, path: FileSystemPath) -> Result<Vc<
     let stem = path.file_stem();
     let stem = stem.unwrap_or_default();
 
-    let content_type = get_content_type(path.clone()).await?;
-
     let cache_control = if stem == "favicon" {
         CACHE_HEADER_REVALIDATE
     } else if mode.is_production() {
@@ -143,16 +145,40 @@ async fn static_route_source(mode: NextMode, path: FileSystemPath) -> Result<Vc<
         CACHE_HEADER_NONE
     };
 
-    let original_file_content_b64 = get_base64_file_content(path.clone()).await?;
-
     let is_twitter = stem == "twitter-image";
     let is_open_graph = stem == "opengraph-image";
+
+    let content_type = get_content_type(path.clone()).await?;
+    let original_file_content_b64;
+
     // Twitter image file size limit is 5MB.
     // General Open Graph image file size limit is 8MB.
     // x-ref: https://developer.x.com/en/docs/x-for-websites/cards/overview/summary
     // x-ref(facebook): https://developers.facebook.com/docs/sharing/webmasters/images
-    let file_size_limit = if is_twitter { 5 } else { 8 };
-    let img_name = if is_twitter { "Twitter" } else { "Open Graph" };
+    let file_size_limit_mb = if is_twitter { 5 } else { 8 };
+    if (is_twitter || is_open_graph)
+        && let Some(content) = path.read().await?.as_content()
+        && let file_size = content.content().to_bytes().len()
+        && file_size > (file_size_limit_mb * 1024 * 1024)
+    {
+        StaticMetadataFileSizeIssue {
+            img_name: if is_twitter {
+                rcstr!("Twitter")
+            } else {
+                rcstr!("Open Graph")
+            },
+            path: path.clone(),
+            file_size_limit_mb,
+            file_size,
+        }
+        .resolved_cell()
+        .emit();
+
+        // Don't inline huge string, just insert placeholder
+        original_file_content_b64 = "".to_string();
+    } else {
+        original_file_content_b64 = get_base64_file_content(path.clone()).await?
+    }
 
     let code = formatdoc! {
         r#"
@@ -162,16 +188,6 @@ async fn static_route_source(mode: NextMode, path: FileSystemPath) -> Result<Vc<
             const cacheControl = {cache_control}
             const buffer = Buffer.from({original_file_content_b64}, 'base64')
 
-            if ({is_twitter} || {is_open_graph}) {{
-                const fileSizeInMB = buffer.byteLength / 1024 / 1024
-                if (fileSizeInMB > {file_size_limit}) {{
-                    throw new Error('File size for {img_name} image {path} exceeds {file_size_limit}MB. ' +
-                    `(Current: ${{fileSizeInMB.toFixed(2)}}MB)\n` +
-                    'Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image#image-files-jpg-png-gif'
-                    )
-                }}
-            }}
-
             export function GET() {{
                 return new NextResponse(buffer, {{
                     headers: {{
@@ -186,11 +202,6 @@ async fn static_route_source(mode: NextMode, path: FileSystemPath) -> Result<Vc<
         content_type = StringifyJs(&content_type),
         cache_control = StringifyJs(cache_control),
         original_file_content_b64 = StringifyJs(&original_file_content_b64),
-        is_twitter = is_twitter,
-        is_open_graph = is_open_graph,
-        file_size_limit = file_size_limit,
-        img_name = img_name,
-        path = StringifyJs(&path.value_to_string().await?),
     };
 
     let file = File::from(code);
@@ -412,3 +423,57 @@ async fn dynamic_image_route_source(path: FileSystemPath) -> Result<Vc<Box<dyn S
 
     Ok(Vc::upcast(source))
 }
+
+#[turbo_tasks::value(shared)]
+struct StaticMetadataFileSizeIssue {
+    img_name: RcStr,
+    path: FileSystemPath,
+    file_size: usize,
+    file_size_limit_mb: usize,
+}
+
+#[turbo_tasks::value_impl]
+impl Issue for StaticMetadataFileSizeIssue {
+    fn severity(&self) -> IssueSeverity {
+        IssueSeverity::Error
+    }
+
+    #[turbo_tasks::function]
+    fn title(&self) -> Vc<StyledString> {
+        StyledString::Text(rcstr!("Static metadata file size exceeded")).cell()
+    }
+
+    #[turbo_tasks::function]
+    fn stage(&self) -> Vc<IssueStage> {
+        IssueStage::ProcessModule.into()
+    }
+
+    #[turbo_tasks::function]
+    fn file_path(&self) -> Vc<FileSystemPath> {
+        self.path.clone().cell()
+    }
+
+    #[turbo_tasks::function]
+    async fn description(&self) -> Result<Vc<OptionStyledString>> {
+        Ok(Vc::cell(Some(
+            StyledString::Text(
+                format!(
+                    "File size for {} image \"{}\" exceeds {}MB. (Current: {:.1}MB)",
+                    self.img_name,
+                    self.path.value_to_string().await?,
+                    self.file_size_limit_mb,
+                    (self.file_size as f32) / 1024.0 / 1024.0
+                )
+                .into(),
+            )
+            .resolved_cell(),
+        )))
+    }
+
+    #[turbo_tasks::function]
+    fn documentation_link(&self) -> Vc<RcStr> {
+        Vc::cell(rcstr!(
+            "https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image#image-files-jpg-png-gif"
+        ))
+    }
+}

filename: test/production/app-dir/metadata-img-too-large/opengraph-image/index.test.ts
patch: @@ -14,8 +14,8 @@ describe('app-dir - metadata-img-too-large opengraph-image', () => {
     await next.build()
 
     const regex = isTurbopack
-      ? // in Turbopack, the path is simplified as [project]/...
-        /Error: File size for Open Graph image "\[project\]\/app\/opengraph-image\.png" exceeds 8MB/
+      ? // in Turbopack, the path is simplified as [project]/.... It's also thrown earlier, so the prefix is slightly different.
+        /File size for Open Graph image "\[project\]\/app\/opengraph-image\.png" exceeds 8MB/
       : /Error: File size for Open Graph image ".*\/app\/opengraph-image\.png" exceeds 8MB/
 
     expect(next.cliOutput).toMatch(regex)

filename: test/production/app-dir/metadata-img-too-large/twitter-image/index.test.ts
patch: @@ -14,8 +14,8 @@ describe('app-dir - metadata-img-too-large twitter-image', () => {
     await next.build()
 
     const regex = isTurbopack
-      ? // in Turbopack, the path is simplified as [project]/...
-        /Error: File size for Twitter image "\[project\]\/app\/twitter-image\.png" exceeds 5MB/
+      ? // in Turbopack, the path is simplified as [project]/.... It's also thrown earlier, so the prefix is slightly different.
+        /File size for Twitter image "\[project\]\/app\/twitter-image\.png" exceeds 5MB/
       : /Error: File size for Twitter image ".*\/app\/twitter-image\.png" exceeds 5MB/
 
     expect(next.cliOutput).toMatch(regex)