diff --git a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts index a95650f69..4670d2e70 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts @@ -112,6 +112,14 @@ describe("app", () => { assert.notEqual(initialUUID, newUUID, "UUID should change after revalidation"); }); + it("serves public assets", async () => { + for (const asset of ["between-links.svg", "between-cards.svg"]) { + const response = await fetch(posix.join(host, asset)); + assert.ok(response.ok, `${asset} should be served from public/`); + assert.ok((await response.text()).includes(" { const response = await fetch(posix.join(host, Math.random().toString())); assert.equal(response.status, 404); diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts index f324bb95d..a549ce1a1 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts @@ -295,6 +295,37 @@ outputFiles: }; validateTestFiles(tmpDir, expectedFiles); }); + + it("merges the public directory when the standalone output is partially populated", async () => { + const { generateBuildOutput, validateOutputDirectory } = await importUtils; + const files = { + ".next/standalone/.next/package.json": "", + ".next/standalone/server.js": "", + ".next/static/staticfile": "", + "public/a.svg": "a", + "public/b.svg": "b", + // Next already copied a.svg here while tracing a server component's file reads, + // so public/ exists in the output but is incomplete. + ".next/standalone/public/a.svg": "a", + }; + generateTestFiles(tmpDir, files); + await generateBuildOutput( + tmpDir, + tmpDir, + outputBundleOptions, + path.join(tmpDir, ".next"), + defaultNextVersion, + adapterMetadata, + ); + await validateOutputDirectory(outputBundleOptions, path.join(tmpDir, ".next")); + + const expectedFiles = { + ".next/standalone/public/a.svg": "a", + ".next/standalone/public/b.svg": "b", + }; + validateTestFiles(tmpDir, expectedFiles); + }); + it("test populate output bundle options", async () => { const { populateOutputBundleOptions } = await importUtils; const expectedOutputBundleOptions = { diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index 781e39b3f..9456d5f92 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -156,6 +156,7 @@ export async function generateBuildOutput( const staticDirectory = join(nextBuildDirectory, "static"); await Promise.all([ copy(staticDirectory, opts.outputStaticDirectoryPath, { overwrite: true }), + copyPublicDirectory(appDir, opts.outputPublicDirectoryPath), copyResources(appDir, opts.outputDirectoryAppPath, opts.bundleYamlPath), generateBundleYaml(opts, rootDir, nextVersion, adapterMetadata), ]); @@ -166,6 +167,16 @@ export async function generateBuildOutput( return; } +// Copy the app's public directory into the standalone output. Next.js' standalone output does not +// reliably include it: the webpack builder omits it entirely, and file tracing can pre-create it +// holding only the assets a server component reads. Merging per file completes either case. +// https://nextjs.org/docs/app/api-reference/config/next-config-js/output#automatically-copying-traced-files +async function copyPublicDirectory(appDir: string, outputPublicDir: string): Promise { + const publicDirectory = join(appDir, "public"); + if (!(await exists(publicDirectory))) return; + await copy(publicDirectory, outputPublicDir, { overwrite: true }); +} + // Copy all files and directories to apphosting output directory. // Files are skipped if there is already a file with the same name in the output directory async function copyResources( @@ -181,7 +192,9 @@ async function copyResources( const existsInOutputBundle = await exists(join(outputBundleAppDir, path)); // Keep apphosting.yaml files in the root directory still, as later steps expect them to be there const isApphostingYaml = path === "apphosting_preprocessed" || path === "apphosting.yaml"; - if (!isbundleYamlDir && !existsInOutputBundle && !isApphostingYaml) { + // The public directory is handled by copyPublicDirectory. + const isPublicDir = path === "public"; + if (!isbundleYamlDir && !existsInOutputBundle && !isApphostingYaml && !isPublicDir) { await copy(join(appDir, path), join(outputBundleAppDir, path)); } }