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 f1cc826c..4941a855 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,48 +19,56 @@ 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..70d11f8b 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -142,6 +142,35 @@ 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 + 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). diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index ed6cbc0a..46c1a96f 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -12,10 +12,10 @@ 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, 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: { @@ -203,6 +203,8 @@ async function generateBundleYaml( } await writeFile(opts.bundleYamlPath, yamlStringify(outputBundle)); + const normalizedBundleDir = normalize(relative(cwd, opts.outputDirectoryBasePath)); + UpdateOrCreateGitignore(cwd, [`/${normalizedBundleDir}/`]); return; } diff --git a/packages/@apphosting/common/src/index.ts b/packages/@apphosting/common/src/index.ts index 6319a0cc..9384233f 100644 --- a/packages/@apphosting/common/src/index.ts +++ b/packages/@apphosting/common/src/index.ts @@ -1,4 +1,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 { @@ -139,3 +141,26 @@ 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); +}