diff --git a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts index a2ab5beb..ea4d686f 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -27,9 +27,40 @@ const compiledFilesPath = posix.join( ".next", ); +const standalonePath = posix.join(process.cwd(), "e2e", "runs", runId, ".next", "standalone"); + +const appPath = posix.join(process.cwd(), "e2e", "runs", runId); + const requiredServerFilePath = posix.join(compiledFilesPath, "required-server-files.json"); describe("next.config override", () => { + it("Should not overwrite original next config", async function () { + if ( + scenario.includes("with-empty-config") || + scenario.includes("with-images-unoptimized-false") || + scenario.includes("with-custom-image-loader") + ) { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.skip(); + } + const files = await fsExtra.readdir(appPath); + const configRegex = /^next\.config\..*$/g; + const configOriginalRegex = /^next\.config\.(?!original).*$/g; + const configFiles = files.filter((file) => file.match(configRegex)); + assert.strictEqual(configFiles.length, 1); + assert.ok( + configFiles[0].match(configOriginalRegex), + "temporary original config not properly removed", + ); + + const standaloneFiles = await fsExtra.readdir(standalonePath); + const standaloneConfigFiles = standaloneFiles.filter((file) => file.match(configRegex)); + assert.strictEqual(standaloneConfigFiles.length, 2); + assert.ok( + standaloneConfigFiles.some((file) => file.match(configOriginalRegex)), + "no original config found in standalone", + ); + }); it("should have images optimization disabled", async function () { if ( scenario.includes("with-empty-config") || diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index f1cc826c..1572adee 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -9,7 +9,12 @@ import { } from "../utils.js"; import { join } from "path"; import { getBuildOptions, runBuild } from "@apphosting/common"; -import { addRouteOverrides, overrideNextConfig, validateNextConfigOverride } from "../overrides.js"; +import { + addRouteOverrides, + overrideNextConfig, + restoreNextConfig, + validateNextConfigOverride, +} from "../overrides.js"; const root = process.cwd(); const opts = getBuildOptions(); @@ -19,48 +24,55 @@ process.env.NEXT_PRIVATE_STANDALONE = "true"; // Opt-out sending telemetry to Vercel process.env.NEXT_TELEMETRY_DISABLED = "1"; -const originalConfig = await loadConfig(root, opts.projectDirectory); +const nextConfig = await loadConfig(root, opts.projectDirectory); /** * Override user's Next Config to optimize the app for Firebase App Hosting * and validate that the override resulted in a valid config that Next.js can * load. * + * We restore the user's Next Config at the end of the build, after the config file has been + * copied over to the output directory, so that the user's original code is not modified. + * * If the app does not have a next.config.[js|mjs|ts] file in the first place, * then can skip config override. * * Note: loadConfig always returns a fileName (default: next.config.js) even if * one does not exist in the app's root: https://github.com/vercel/next.js/blob/23681508ca34b66a6ef55965c5eac57de20eb67f/packages/next/src/server/config.ts#L1115 */ -const originalConfigPath = join(root, originalConfig.configFileName); -if (await exists(originalConfigPath)) { - await overrideNextConfig(root, originalConfig.configFileName); - await validateNextConfigOverride(root, opts.projectDirectory, originalConfig.configFileName); +const nextConfigPath = join(root, nextConfig.configFileName); +if (await exists(nextConfigPath)) { + await overrideNextConfig(root, nextConfig.configFileName); + await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); } -await runBuild(); +try { + await runBuild(); -const adapterMetadata = getAdapterMetadata(); -const nextBuildDirectory = join(opts.projectDirectory, originalConfig.distDir); -const outputBundleOptions = populateOutputBundleOptions( - root, - opts.projectDirectory, - nextBuildDirectory, -); + const adapterMetadata = getAdapterMetadata(); + const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir); + const outputBundleOptions = populateOutputBundleOptions( + root, + opts.projectDirectory, + nextBuildDirectory, + ); -await addRouteOverrides( - outputBundleOptions.outputDirectoryAppPath, - originalConfig.distDir, - adapterMetadata, -); + await addRouteOverrides( + outputBundleOptions.outputDirectoryAppPath, + nextConfig.distDir, + adapterMetadata, + ); -const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified"; -await generateBuildOutput( - root, - opts.projectDirectory, - outputBundleOptions, - nextBuildDirectory, - nextjsVersion, - adapterMetadata, -); -await validateOutputDirectory(outputBundleOptions, nextBuildDirectory); + const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified"; + await generateBuildOutput( + root, + opts.projectDirectory, + outputBundleOptions, + nextBuildDirectory, + nextjsVersion, + adapterMetadata, + ); + await validateOutputDirectory(outputBundleOptions, nextBuildDirectory); +} finally { + await restoreNextConfig(root, nextConfig.configFileName); +} diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index d5ea7928..e223037c 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -378,6 +378,67 @@ describe("validateNextConfigOverride", () => { }); }); +describe("next config restore", () => { + let tmpDir: string; + const nextConfigOriginalBody = ` + // @ts-check + + /** @type {import('next').NextConfig} */ + const nextConfig = { + /* config options here */ + } + + module.exports = nextConfig + `; + const nextConfigBody = ` + // This file was automatically generated by Firebase App Hosting adapter + const fahOptimizedConfig = (config) => ({ + ...config, + images: { + ...(config.images || {}), + ...(config.images?.unoptimized === undefined && config.images?.loader === undefined + ? { unoptimized: true } + : {}), + }, + }); + + const config = typeof originalConfig === 'function' + ? async (...args) => { + const resolvedConfig = await originalConfig(...args); + return fahOptimizedConfig(resolvedConfig); + } + : fahOptimizedConfig(originalConfig); + `; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides")); + }); + + it("handle no original config file found", async () => { + const { restoreNextConfig } = await importOverrides; + fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), nextConfigBody); + await restoreNextConfig(tmpDir, "next.config.mjs"); + + const restoredConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8"); + assert.equal(restoredConfig, nextConfigBody); + }); + + it("handle no config file found", async () => { + const { restoreNextConfig } = await importOverrides; + assert.doesNotReject(restoreNextConfig(tmpDir, "next.config.mjs")); + }); + + it("original config file restored", async () => { + const { restoreNextConfig } = await importOverrides; + fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), nextConfigBody); + fs.writeFileSync(path.join(tmpDir, "next.config.original.mjs"), nextConfigOriginalBody); + await restoreNextConfig(tmpDir, "next.config.mjs"); + + const restoredConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8"); + assert.equal(restoredConfig, nextConfigOriginalBody); + }); +}); + // Normalize whitespace for comparison function normalizeWhitespace(str: string) { return str.replace(/\s+/g, " ").trim(); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index f8f8e992..5edd0d54 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -142,6 +142,29 @@ export async function validateNextConfigOverride( } } +/** + * Restores the user's original Next Config file (next.config.original.[ts|js|mjs]) + * to leave user code the way we found it. + */ +export async function restoreNextConfig(projectRoot: string, nextConfigFileName: string) { + // Determine the file extension + const fileExtension = extname(nextConfigFileName); + const originalConfigPath = join(projectRoot, `next.config.original${fileExtension}`); + + if (!(await exists(originalConfigPath))) { + // No backup file found, nothing to restore. + return; + } + console.log(`Restoring original next config in project root`); + + const configPath = join(projectRoot, nextConfigFileName); + try { + await renamePromise(originalConfigPath, configPath); + } catch (error) { + console.error(`Error restoring Next config: ${error}`); + } +} + /** * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting * specific overrides (i.e headers).