From 3769262f47e686e26ad5fcca255409182efa1222 Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Tue, 29 Jul 2025 19:36:38 +0000 Subject: [PATCH 1/7] Restore user's original Next config. --- .../adapter-nextjs/src/bin/build.ts | 22 +++++++++------ .../adapter-nextjs/src/overrides.ts | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index f1cc826c..43b6d844 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -9,7 +9,7 @@ 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,29 +19,33 @@ 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(); + const adapterMetadata = getAdapterMetadata(); -const nextBuildDirectory = join(opts.projectDirectory, originalConfig.distDir); +const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir); const outputBundleOptions = populateOutputBundleOptions( root, opts.projectDirectory, @@ -50,7 +54,7 @@ const outputBundleOptions = populateOutputBundleOptions( await addRouteOverrides( outputBundleOptions.outputDirectoryAppPath, - originalConfig.distDir, + nextConfig.distDir, adapterMetadata, ); @@ -64,3 +68,5 @@ await generateBuildOutput( adapterMetadata, ); await validateOutputDirectory(outputBundleOptions, nextBuildDirectory); + +await restoreNextConfig(root, nextConfig.configFileName); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index f8f8e992..143f376c 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -142,6 +142,34 @@ export async function validateNextConfigOverride( } } +/** + * + */ +export async function restoreNextConfig(projectRoot: string, nextConfigFileName: string) { + // Check if the file exists in the current working directory + const configPath = join(projectRoot, nextConfigFileName); + if (!(await exists(configPath))) { + return; + } + + // Determine the file extension + const fileExtension = extname(nextConfigFileName); + const originalConfigPath = join(projectRoot, `next.config.original${fileExtension}`); + if (!(await exists(originalConfigPath))) { + console.warn(`next config may have been overwritten but original contents not found`); + return; + } + console.log(`Restoring original next config in project root`); + + try { + await renamePromise(originalConfigPath, configPath); + } catch (error) { + console.error(`Error restoring Next config: ${error}`); + } + return; + +} + /** * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting * specific overrides (i.e headers). From 68eaa28b90da4261922a8cb611fc2d19c42141e5 Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Thu, 31 Jul 2025 02:57:34 +0000 Subject: [PATCH 2/7] wrap in try-finally and add e2e tests --- .../e2e/config-override.spec.ts | 41 ++++++++++++++- .../adapter-nextjs/src/bin/build.ts | 52 ++++++++++--------- 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts index a2ab5beb..f9b46e25 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -1,7 +1,8 @@ import * as assert from "assert"; -import { posix } from "path"; +import { posix, join } from "path"; import fsExtra from "fs-extra"; + const host = process.env.HOST; if (!host) { throw new Error("HOST environment variable expected"); @@ -27,9 +28,47 @@ 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") + ) { + 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), "found original config file in root"); + + const standaloneFiles = await fsExtra.readdir(standalonePath); + const standaloneConfigFiles = standaloneFiles + .filter((file) => file.match(configRegex)); + assert.strictEqual(standaloneConfigFiles.length, 2); + assert.ok(configFiles.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 43b6d844..4941a855 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -41,32 +41,34 @@ if (await exists(nextConfigPath)) { await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); } -await runBuild(); +try { + await runBuild(); -const adapterMetadata = getAdapterMetadata(); -const nextBuildDirectory = join(opts.projectDirectory, nextConfig.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, - nextConfig.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); - -await restoreNextConfig(root, nextConfig.configFileName); + 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); +} From 24a94659bc6debf172ff27e21defb72a152944e2 Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Fri, 1 Aug 2025 03:10:57 +0000 Subject: [PATCH 3/7] unit test --- .../adapter-nextjs/src/overrides.spec.ts | 61 +++++++++++++++++++ .../adapter-nextjs/src/overrides.ts | 3 +- 2 files changed, 63 insertions(+), 1 deletion(-) 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 143f376c..70d11f8b 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -143,7 +143,8 @@ 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) { // Check if the file exists in the current working directory From 55cba56999f5afa575417f336d1c66866872ee18 Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Fri, 1 Aug 2025 17:55:17 +0000 Subject: [PATCH 4/7] don't error when output directory already exists --- packages/@apphosting/adapter-nextjs/src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index ed6cbc0a..4c239194 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -15,7 +15,7 @@ import { NextConfigComplete } from "next/dist/server/config-shared.js"; import { OutputBundleConfig } from "@apphosting/common"; // fs-extra is CJS, readJson can't be imported using shorthand -export const { copy, exists, writeFile, readJson, readdir, readFileSync, existsSync, mkdir } = +export const { copy, exists, writeFile, readJson, readdir, readFileSync, existsSync, ensureDir } = fsExtra; // Loads the user's next.config.js file. @@ -181,7 +181,7 @@ async function generateBundleYaml( nextVersion: string, adapterMetadata: AdapterMetadata, ): Promise { - await mkdir(opts.outputDirectoryBasePath); + await ensureDir(opts.outputDirectoryBasePath); const outputBundle: OutputBundleConfig = { version: "v1", runConfig: { From a7c7e30443c7abdcb5cf84a2a66b3cc3a3b63657 Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Fri, 1 Aug 2025 19:24:41 +0000 Subject: [PATCH 5/7] functionally complete --- .../@apphosting/adapter-nextjs/src/utils.ts | 3 ++- packages/@apphosting/common/src/index.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index 4c239194..68ba38b6 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -12,7 +12,7 @@ import { MiddlewareManifest, } from "./interfaces.js"; import { NextConfigComplete } from "next/dist/server/config-shared.js"; -import { OutputBundleConfig } from "@apphosting/common"; +import { OutputBundleConfig, UpdateOrCreateGitignore } from "@apphosting/common"; // fs-extra is CJS, readJson can't be imported using shorthand export const { copy, exists, writeFile, readJson, readdir, readFileSync, existsSync, ensureDir } = @@ -203,6 +203,7 @@ async function generateBundleYaml( } await writeFile(opts.bundleYamlPath, yamlStringify(outputBundle)); + UpdateOrCreateGitignore(cwd, ["/.apphosting/"]); return; } diff --git a/packages/@apphosting/common/src/index.ts b/packages/@apphosting/common/src/index.ts index 6319a0cc..78baa4df 100644 --- a/packages/@apphosting/common/src/index.ts +++ b/packages/@apphosting/common/src/index.ts @@ -1,4 +1,7 @@ import { spawn } from "child_process"; +import * as path from "node:path"; +import * as fs from "fs-extra"; + // Output bundle metadata specifications to be written to bundle.yaml export interface OutputBundleConfig { @@ -139,3 +142,27 @@ export function getBuildOptions(): BuildOptions { projectDirectory: process.cwd(), }; } + + +/** + * Updates or creates a .gitignore file with the given entries in the given path + */ +export function UpdateOrCreateGitignore(dirPath: string, entries: string[]) { + const gitignorePath = path.join(dirPath, ".gitignore"); + + if (!fs.existsSync(gitignorePath)) { + console.log(`creating ${gitignorePath} with entries: ${entries.join("\n")}`); + fs.writeFileSync(gitignorePath, entries.join("\n")); + return; + } + + let content = fs.readFileSync(gitignorePath, "utf-8"); + for (const entry of entries) { + if (!content.includes(entry)) { + console.log(`adding ${entry} to ${gitignorePath}`); + content += `\n${entry}\n`; + } + } + + fs.writeFileSync(gitignorePath, content); +} From a80af76b4dafa5c6b2f65f41e5ba7dda00f7caad Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Fri, 1 Aug 2025 19:34:20 +0000 Subject: [PATCH 6/7] refine functional --- packages/@apphosting/adapter-nextjs/src/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index 68ba38b6..46c1a96f 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -203,7 +203,8 @@ async function generateBundleYaml( } await writeFile(opts.bundleYamlPath, yamlStringify(outputBundle)); - UpdateOrCreateGitignore(cwd, ["/.apphosting/"]); + const normalizedBundleDir = normalize(relative(cwd, opts.outputDirectoryBasePath)); + UpdateOrCreateGitignore(cwd, [`/${normalizedBundleDir}/`]); return; } From c0a62cb20ea9d7dbba9b6b0c55981996909f3e5a Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Fri, 1 Aug 2025 19:45:29 +0000 Subject: [PATCH 7/7] lint --- packages/@apphosting/common/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@apphosting/common/src/index.ts b/packages/@apphosting/common/src/index.ts index 78baa4df..9384233f 100644 --- a/packages/@apphosting/common/src/index.ts +++ b/packages/@apphosting/common/src/index.ts @@ -2,7 +2,6 @@ import { spawn } from "child_process"; import * as path from "node:path"; import * as fs from "fs-extra"; - // Output bundle metadata specifications to be written to bundle.yaml export interface OutputBundleConfig { version: "v1"; @@ -143,7 +142,6 @@ export function getBuildOptions(): BuildOptions { }; } - /** * Updates or creates a .gitignore file with the given entries in the given path */