diff --git a/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml index ee40c3b3..8802f395 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml @@ -200,3 +200,4 @@ tests: module.exports = nextConfig; file: next.config.js + - name: without-a-next-config diff --git a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts index a2ab5beb..d98c4e62 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -30,7 +30,7 @@ const compiledFilesPath = posix.join( const requiredServerFilePath = posix.join(compiledFilesPath, "required-server-files.json"); describe("next.config override", () => { - it("should have images optimization disabled", async function () { + it("should have image optimization disabled", async function () { if ( scenario.includes("with-empty-config") || scenario.includes("with-images-unoptimized-false") || @@ -53,7 +53,7 @@ describe("next.config override", () => { }); it("should preserve other user set next configs", async function () { - if (scenario.includes("with-empty-config")) { + if (scenario.includes("with-empty-config") || scenario.includes("without-a-next-config")) { // eslint-disable-next-line @typescript-eslint/no-invalid-this this.skip(); } diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index a46d2a93..d8a2afd4 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -53,10 +53,9 @@ const scenarios: Scenario[] = [ tests: ["middleware.spec.ts"], // Only run middleware-specific tests }, ...configOverrideTestScenarios.map( - (scenario: { name: string; config: string; file: string }) => ({ + (scenario: { name: string; config?: string; file?: string }) => ({ name: scenario.name, setup: async (cwd: string) => { - const configContent = scenario.config; const files = await fsExtra.readdir(cwd); const configFiles = files .filter((file) => file.startsWith("next.config.")) @@ -67,6 +66,12 @@ const scenarios: Scenario[] = [ console.log(`Removed existing config file: ${file}`); } + // skip creating the test config if data is not provided + if (!scenario.config || !scenario.file) { + return; + } + + const configContent = scenario.config; await fsExtra.writeFile(join(cwd, scenario.file), configContent); console.log(`Created ${scenario.file} file with ${scenario.name} config`); }, diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index eedb6bbb..b7fb4d37 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -5,7 +5,6 @@ import { generateBuildOutput, validateOutputDirectory, getAdapterMetadata, - exists, } from "../utils.js"; import { join } from "path"; import { getBuildOptions, runBuild } from "@apphosting/common"; @@ -30,16 +29,14 @@ const originalConfig = await loadConfig(root, opts.projectDirectory); * load. * * If the app does not have a next.config.[js|mjs|ts] file in the first place, - * then can skip config override. + * then one is created with the overrides. * * 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); -} + +await overrideNextConfig(root, originalConfig.configFileName); +await validateNextConfigOverride(root, opts.projectDirectory, originalConfig.configFileName); await runBuild(); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index d5ea7928..47c748ef 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -190,6 +190,18 @@ describe("next config overrides", () => { : fahOptimizedConfig(originalConfig); `; + const defaultNextConfig = ` + // @ts-nocheck + + /** @type {import('next').NextConfig} */ + const nextConfig = { + images: { + unoptimized: true, + } + } + + module.exports = nextConfig + `; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides")); }); @@ -319,14 +331,11 @@ describe("next config overrides", () => { ); }); - it("should not do anything if no next.config.* file exists", async () => { + it("should create a default next.config.js file if one does not exist yet", async () => { const { overrideNextConfig } = await importOverrides; await overrideNextConfig(tmpDir, "next.config.js"); - - // Assert that no next.config* files were created - const files = fs.readdirSync(tmpDir); - const nextConfigFiles = files.filter((file) => file.startsWith("next.config")); - assert.strictEqual(nextConfigFiles.length, 0, "No next.config files should exist"); + const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.js"), "utf-8"); + assert.equal(normalizeWhitespace(updatedConfig), normalizeWhitespace(defaultNextConfig)); }); }); @@ -334,19 +343,17 @@ describe("validateNextConfigOverride", () => { let tmpDir: string; let root: string; let projectRoot: string; - let originalConfigFileName: string; - let newConfigFileName: string; - let originalConfigPath: string; - let newConfigPath: string; + let configFileName: string; + let preservedConfigFileName: string; + let preservedConfigFilePath: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-next-config-override")); root = tmpDir; projectRoot = tmpDir; - originalConfigFileName = "next.config.js"; - newConfigFileName = "next.config.original.js"; - originalConfigPath = path.join(root, originalConfigFileName); - newConfigPath = path.join(root, newConfigFileName); + configFileName = "next.config.js"; + preservedConfigFileName = "next.config.original.js"; + preservedConfigFilePath = path.join(root, preservedConfigFileName); fs.mkdirSync(root, { recursive: true }); }); @@ -355,25 +362,23 @@ describe("validateNextConfigOverride", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it("should throw an error when new config file doesn't exist", async () => { - fs.writeFileSync(originalConfigPath, "module.exports = {}"); - + it("should throw an error if a next config file was not created because the user did not have one", async () => { const { validateNextConfigOverride } = await importOverrides; await assert.rejects( - async () => await validateNextConfigOverride(root, projectRoot, originalConfigFileName), - /New Next.js config file not found/, + async () => await validateNextConfigOverride(root, projectRoot, configFileName), + /Next.js config file not found/, ); }); - it("should throw an error when original config file doesn't exist", async () => { - fs.writeFileSync(newConfigPath, "module.exports = {}"); + it("should throw an error when main config file doesn't exist", async () => { + fs.writeFileSync(preservedConfigFilePath, "module.exports = {}"); const { validateNextConfigOverride } = await importOverrides; await assert.rejects( - async () => await validateNextConfigOverride(root, projectRoot, originalConfigFileName), - /Original Next.js config file not found/, + async () => await validateNextConfigOverride(root, projectRoot, configFileName), + /Next Config Override Failed: Next.js config file not found/, ); }); }); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index 42ff210a..66494cda 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -10,26 +10,38 @@ import { import { join, extname } from "path"; import { rename as renamePromise } from "fs/promises"; +const DEFAULT_NEXT_CONFIG_FILE = "next.config.js"; + /** * Overrides the user's Next Config file (next.config.[ts|js|mjs]) to add configs * optimized for Firebase App Hosting. */ export async function overrideNextConfig(projectRoot: string, nextConfigFileName: string) { console.log(`Overriding Next Config to add configs optmized for Firebase App Hosting`); - // Check if the file exists in the current working directory - const configPath = join(projectRoot, nextConfigFileName); - - if (!(await exists(configPath))) { - console.log(`No Next.js config file found at ${configPath}`); + const userNextConfigExists = await exists(join(projectRoot, nextConfigFileName)); + if (!userNextConfigExists) { + console.log(`No Next config file found, creating one with Firebase App Hosting overrides`); + try { + await writeFile(join(projectRoot, DEFAULT_NEXT_CONFIG_FILE), defaultNextConfigForFAH()); + console.log( + `Successfully created ${DEFAULT_NEXT_CONFIG_FILE} with Firebase App Hosting overrides`, + ); + } catch (error) { + console.error(`Error creating ${DEFAULT_NEXT_CONFIG_FILE}: ${error}`); + throw error; + } return; } + // A Next Config already exists in the user's project, so it needs to be overriden + // Determine the file extension const fileExtension = extname(nextConfigFileName); const originalConfigName = `next.config.original${fileExtension}`; // Rename the original config file try { + const configPath = join(projectRoot, nextConfigFileName); const originalPath = join(projectRoot, originalConfigName); await renamePromise(configPath, originalPath); @@ -104,30 +116,47 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { } /** - * This function is used to validate the state of an app after running the + * Returns the default Next Config file that is created in the user's project + * if one does not exist already. This config ensures the Next.Js app is optimized + * for Firebase App Hosting. + */ +function defaultNextConfigForFAH() { + return ` + // @ts-nocheck + + /** @type {import('next').NextConfig} */ + const nextConfig = { + images: { + unoptimized: true, + } + } + + module.exports = nextConfig + `; +} + +/** + * This function is used to validate the state of an app after running * overrideNextConfig. It validates that: - * 1. original next config is preserved - * 2. a new next config is created - * 3. new next config can be loaded by NextJs without any issues. + * 1. if user has a next config it is preserved in a next.config.original.[js|ts|mjs] file + * 2. a next config exists (should be created with FAH overrides + * even if user did not create one) + * 3. next config can be loaded by NextJs without any issues. */ export async function validateNextConfigOverride( root: string, projectRoot: string, - originalConfigFileName: string, + configFileName: string, ) { - const originalConfigExtension = extname(originalConfigFileName); - const newConfigFileName = `next.config.original${originalConfigExtension}`; - const newConfigFilePath = join(root, newConfigFileName); - if (!(await exists(newConfigFilePath))) { - throw new Error( - `Next Config Override Failed: New Next.js config file not found at ${newConfigFilePath}`, - ); - } + const userNextConfigExists = await exists(join(root, configFileName)); + const configFilePath = join( + root, + userNextConfigExists ? configFileName : DEFAULT_NEXT_CONFIG_FILE, + ); - const originalNextConfigFilePath = join(root, originalConfigFileName); - if (!(await exists(originalNextConfigFilePath))) { + if (!(await exists(configFilePath))) { throw new Error( - `Next Config Override Failed: Original Next.js config file not found at ${originalNextConfigFilePath}`, + `Next Config Override Failed: Next.js config file not found at ${configFilePath}`, ); }