diff --git a/package-lock.json b/package-lock.json index 4320b440..27eda41d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25618,7 +25618,7 @@ } }, "packages/@apphosting/adapter-nextjs": { - "version": "14.0.11", + "version": "14.0.12", "license": "Apache-2.0", "dependencies": { "@apphosting/common": "*", diff --git a/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml new file mode 100644 index 00000000..ee40c3b3 --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml @@ -0,0 +1,202 @@ +tests: + - name: with-js-config-object-style + config: | + /** @type {import('next').NextConfig} */ + const nextConfig = { + reactStrictMode: true, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-custom-header', + value: 'js-config-value', + }, + { + key: 'x-config-type', + value: 'object', + }, + ], + }, + ]; + }, + }; + + module.exports = nextConfig; + file: next.config.js + - name: with-js-config-function-style + config: | + /** @type {import('next').NextConfig} */ + const nextConfig = (phase, { defaultConfig }) => { + return { + reactStrictMode: true, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-custom-header', + value: 'js-config-value', + }, + { + key: 'x-config-type', + value: 'function', + }, + ], + }, + ]; + } + }; + }; + + module.exports = nextConfig; + file: next.config.js + - name: with-js-async-function + config: | + // @ts-check + + module.exports = async (phase, { defaultConfig }) => { + /** + * @type {import('next').NextConfig} + */ + const nextConfig = { + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-custom-header', + value: 'js-config-value', + }, + { + key: 'x-config-type', + value: 'function', + }, + ], + }, + ]; + } + } + return nextConfig + } + file: next.config.js + - name: with-ts-config + config: | + import type { NextConfig } from 'next' + + const nextConfig: NextConfig = { + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-custom-header', + value: 'ts-config-value', + } + ], + }, + ]; + } + } + + export default nextConfig + file: next.config.ts + - name: with-ecmascript-modules + config: | + // @ts-check + + /** + * @type {import('next').NextConfig} + */ + const nextConfig = { + /* config options here */ + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-custom-header', + value: 'mjs-config-value', + }, + ], + }, + ]; + } + } + + export default nextConfig + file: next.config.mjs + - name: with-empty-config + config: | + // @ts-check + + /** @type {import('next').NextConfig} */ + const nextConfig = { + /* config options here */ + } + + module.exports = nextConfig + file: next.config.js + - name: with-images-unoptimized-false + config: | + /** @type {import('next').NextConfig} */ + const nextConfig = { + reactStrictMode: true, + images: { + unoptimized: false, + }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-custom-header', + value: 'js-config-value', + }, + { + key: 'x-config-type', + value: 'object', + }, + ], + }, + ]; + }, + }; + + module.exports = nextConfig; + file: next.config.js + - name: with-custom-image-loader + config: | + /** @type {import('next').NextConfig} */ + const nextConfig = { + images: { + loader: "akamai", + path: "", + }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-custom-header', + value: 'js-config-value', + }, + { + key: 'x-config-type', + value: 'object', + }, + ], + }, + ]; + }, + }; + + module.exports = nextConfig; + file: next.config.js diff --git a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts new file mode 100644 index 00000000..a2ab5beb --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -0,0 +1,125 @@ +import * as assert from "assert"; +import { posix } from "path"; +import fsExtra from "fs-extra"; + +const host = process.env.HOST; +if (!host) { + throw new Error("HOST environment variable expected"); +} + +const scenario = process.env.SCENARIO; +if (!scenario) { + throw new Error("SCENARIO environment variable expected"); +} + +const runId = process.env.RUN_ID; +if (!runId) { + throw new Error("RUN_ID environment variable expected"); +} + +const compiledFilesPath = posix.join( + process.cwd(), + "e2e", + "runs", + runId, + ".next", + "standalone", + ".next", +); + +const requiredServerFilePath = posix.join(compiledFilesPath, "required-server-files.json"); + +describe("next.config override", () => { + it("should have images optimization disabled", 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 serverFiles = await fsExtra.readJson(requiredServerFilePath); + const config = serverFiles.config; + + // Verify that images.unoptimized is set to true + assert.ok(config.images, "Config should have images property"); + assert.strictEqual( + config.images.unoptimized, + true, + "Images should have unoptimized set to true", + ); + }); + + it("should preserve other user set next configs", async function () { + if (scenario.includes("with-empty-config")) { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.skip(); + } + + // This test checks if the user's original config settings are preserved + // We'll check for the custom header that was set in the next.config + const response = await fetch(posix.join(host, "/")); + + assert.ok(response.ok); + + // Check for the custom header that was set in the next.config + const customHeader = response.headers.get("x-custom-header") ?? ""; + const validValues = ["js-config-value", "ts-config-value", "mjs-config-value"]; + assert.ok( + validValues.includes(customHeader), + `Expected header to be one of ${validValues.join(", ")} but got "${customHeader}"`, + ); + }); + + it("should handle function-style config correctly", async function () { + // Only run this test for scenarios with function-style config + if (!scenario.includes("function-style")) { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.skip(); + } + + // Check for the custom header that indicates function-style config was processed correctly + const response = await fetch(posix.join(host, "/")); + assert.ok(response.ok); + assert.equal(response.headers.get("x-config-type") ?? "", "function"); + }); + + it("should handle object-style config correctly", async function () { + // Only run this test for scenarios with object-style config + if (!scenario.includes("object-style") && !scenario.includes("with-empty-config")) { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.skip(); + } + + // Check for the custom header that indicates object-style config was processed correctly + const response = await fetch(posix.join(host, "/")); + assert.ok(response.ok); + + // Empty config doesn't set this header + if (!scenario.includes("with-empty-config")) { + assert.equal(response.headers.get("x-config-type") ?? "", "object"); + } + }); + + it("should not override images.unoptimized if user explicitly defines configs", async function () { + if ( + !scenario.includes("with-images-unoptimized-false") && + !scenario.includes("with-custom-image-loader") + ) { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.skip(); + } + + const serverFiles = await fsExtra.readJson(requiredServerFilePath); + const config = serverFiles.config; + + assert.ok(config.images, "Config should have images property"); + assert.strictEqual( + config.images.unoptimized, + false, + "Images should have unoptimized set to false", + ); + }); +}); diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index abc44bd4..23104afa 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -19,6 +19,11 @@ interface Scenario { tests?: string[]; // List of test files to run } +// Load test data for config override +const configOverrideTestScenarios = parseYaml( + readFileSync(join(__dirname, "config-override-test-cases.yaml"), "utf8"), +).tests; + const scenarios: Scenario[] = [ { name: "basic", @@ -47,6 +52,27 @@ const scenarios: Scenario[] = [ }, tests: ["middleware.spec.ts"], // Only run middleware-specific tests }, + ...configOverrideTestScenarios.map( + (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.")) + .map((file) => join(cwd, file)); + + for (const file of configFiles) { + await fsExtra.remove(file); + console.log(`Removed existing config file: ${file}`); + } + + await fsExtra.writeFile(join(cwd, scenario.file), configContent); + console.log(`Created ${scenario.file} file with ${scenario.name} config`); + }, + tests: ["config-override.spec.ts"], + }), + ), ]; const errors: any[] = []; @@ -55,7 +81,11 @@ await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined) // Run each scenario for (const scenario of scenarios) { - console.log(`\n\nRunning scenario: ${scenario.name}`); + console.log( + `\n\n${"=".repeat(80)}\n${" ".repeat( + 5, + )}RUNNING SCENARIO: ${scenario.name.toUpperCase()}${" ".repeat(5)}\n${"=".repeat(80)}`, + ); const runId = `${scenario.name}-${Math.random().toString().split(".")[1]}`; const cwd = join(__dirname, "runs", runId); @@ -170,6 +200,7 @@ for (const scenario of scenarios) { ...process.env, HOST: host, SCENARIO: scenario.name, + RUN_ID: runId, }, }).finally(() => { run.stdin.end(); diff --git a/packages/@apphosting/adapter-nextjs/package.json b/packages/@apphosting/adapter-nextjs/package.json index 99bafabc..e9105f40 100644 --- a/packages/@apphosting/adapter-nextjs/package.json +++ b/packages/@apphosting/adapter-nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@apphosting/adapter-nextjs", - "version": "14.0.11", + "version": "14.0.12", "main": "dist/index.js", "description": "Experimental addon to the Firebase CLI to add web framework support", "repository": { diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index a2241174..eedb6bbb 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -5,10 +5,11 @@ import { generateBuildOutput, validateOutputDirectory, getAdapterMetadata, + exists, } from "../utils.js"; import { join } from "path"; import { getBuildOptions, runBuild } from "@apphosting/common"; -import { addRouteOverrides } from "../overrides.js"; +import { addRouteOverrides, overrideNextConfig, validateNextConfigOverride } from "../overrides.js"; const root = process.cwd(); const opts = getBuildOptions(); @@ -20,19 +21,41 @@ process.env.NEXT_TELEMETRY_DISABLED = "1"; if (!process.env.FRAMEWORK_VERSION) { throw new Error("Could not find the nextjs version of the application"); } + +const originalConfig = 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. + * + * 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); +} + await runBuild(); const adapterMetadata = getAdapterMetadata(); - -const { distDir } = await loadConfig(root, opts.projectDirectory); -const nextBuildDirectory = join(opts.projectDirectory, distDir); +const nextBuildDirectory = join(opts.projectDirectory, originalConfig.distDir); const outputBundleOptions = populateOutputBundleOptions( root, opts.projectDirectory, nextBuildDirectory, ); -await addRouteOverrides(outputBundleOptions.outputDirectoryAppPath, distDir, adapterMetadata); +await addRouteOverrides( + outputBundleOptions.outputDirectoryAppPath, + originalConfig.distDir, + adapterMetadata, +); await generateBuildOutput( root, diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index b608717c..3d478b18 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -163,3 +163,218 @@ describe("route overrides", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); }); + +describe("next config overrides", () => { + let tmpDir: string; + const nextConfigOverrideBody = ` + // 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("should set images.unoptimized to true - js normal config", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = ` + // @ts-check + + /** @type {import('next').NextConfig} */ + const nextConfig = { + /* config options here */ + } + + module.exports = nextConfig + `; + + fs.writeFileSync(path.join(tmpDir, "next.config.js"), originalConfig); + await overrideNextConfig(tmpDir, "next.config.js"); + + const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.js"), "utf-8"); + + assert.equal( + normalizeWhitespace(updatedConfig), + normalizeWhitespace(` + // @ts-nocheck + const originalConfig = require('./next.config.original.js'); + + ${nextConfigOverrideBody} + + module.exports = config; + `), + ); + }); + + it("should set images.unoptimized to true - ECMAScript Modules", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = ` + // @ts-check + + /** + * @type {import('next').NextConfig} + */ + const nextConfig = { + /* config options here */ + } + + export default nextConfig + `; + + fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), originalConfig); + await overrideNextConfig(tmpDir, "next.config.mjs"); + + const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8"); + assert.equal( + normalizeWhitespace(updatedConfig), + normalizeWhitespace(` + // @ts-nocheck + import originalConfig from './next.config.original.mjs'; + + ${nextConfigOverrideBody} + + export default config; + `), + ); + }); + + it("should set images.unoptimized to true - ECMAScript Function", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = ` + // @ts-check + + export default (phase, { defaultConfig }) => { + /** + * @type {import('next').NextConfig} + */ + const nextConfig = { + /* config options here */ + } + return nextConfig + } + `; + + fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), originalConfig); + await overrideNextConfig(tmpDir, "next.config.mjs"); + + const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8"); + assert.equal( + normalizeWhitespace(updatedConfig), + normalizeWhitespace(` + // @ts-nocheck + import originalConfig from './next.config.original.mjs'; + + ${nextConfigOverrideBody} + + export default config; + `), + ); + }); + + it("should set images.unoptimized to true - TypeScript", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = ` + import type { NextConfig } from 'next' + + const nextConfig: NextConfig = { + /* config options here */ + } + + export default nextConfig + `; + + fs.writeFileSync(path.join(tmpDir, "next.config.ts"), originalConfig); + await overrideNextConfig(tmpDir, "next.config.ts"); + + const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.ts"), "utf-8"); + assert.equal( + normalizeWhitespace(updatedConfig), + normalizeWhitespace(` + // @ts-nocheck + import originalConfig from './next.config.original'; + + ${nextConfigOverrideBody} + + module.exports = config; + `), + ); + }); + + it("should not do anything if no next.config.* file exists", 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"); + }); +}); + +describe("validateNextConfigOverride", () => { + let tmpDir: string; + let root: string; + let projectRoot: string; + let originalConfigFileName: string; + let newConfigFileName: string; + let originalConfigPath: string; + let newConfigPath: 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); + + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + 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 = {}"); + + const { validateNextConfigOverride } = await importOverrides; + + await assert.rejects( + async () => await validateNextConfigOverride(root, projectRoot, originalConfigFileName), + /New Next.js config file not found/, + ); + }); + + it("should throw an error when original config file doesn't exist", async () => { + fs.writeFileSync(newConfigPath, "module.exports = {}"); + + const { validateNextConfigOverride } = await importOverrides; + + await assert.rejects( + async () => await validateNextConfigOverride(root, projectRoot, originalConfigFileName), + /Original Next.js config file not found/, + ); + }); +}); + +// 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 d4672638..d680e319 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -1,5 +1,147 @@ import { AdapterMetadata, MiddlewareManifest } from "./interfaces.js"; -import { loadRouteManifest, writeRouteManifest, loadMiddlewareManifest } from "./utils.js"; +import { + loadRouteManifest, + writeRouteManifest, + loadMiddlewareManifest, + exists, + writeFile, + loadConfig, +} from "./utils.js"; +import { join, extname } from "path"; +import { rename as renamePromise } from "fs/promises"; + +/** + * 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}`); + return; + } + + // Determine the file extension + const fileExtension = extname(nextConfigFileName); + const originalConfigName = `next.config.original${fileExtension}`; + + // Rename the original config file + try { + const originalPath = join(projectRoot, originalConfigName); + await renamePromise(configPath, originalPath); + + // Create a new config file with the appropriate import + let importStatement; + switch (fileExtension) { + case ".js": + importStatement = `const originalConfig = require('./${originalConfigName}');`; + break; + case ".mjs": + importStatement = `import originalConfig from './${originalConfigName}';`; + break; + case ".ts": + importStatement = `import originalConfig from './${originalConfigName.replace( + ".ts", + "", + )}';`; + break; + default: + throw new Error( + `Unsupported file extension for Next Config: "${fileExtension}", please use ".js", ".mjs", or ".ts"`, + ); + } + + // Create the new config content with our overrides + const newConfigContent = getCustomNextConfig(importStatement, fileExtension); + + // Write the new config file + await writeFile(join(projectRoot, nextConfigFileName), newConfigContent); + console.log(`Successfully created ${nextConfigFileName} with Firebase App Hosting overrides`); + } catch (error) { + console.error(`Error overriding Next.js config: ${error}`); + throw error; + } +} + +/** + * Returns a custom Next.js config that optimizes the app for Firebase App Hosting. + * + * Current overrides include: + * - images.unoptimized = true, unless user explicitly sets images.unoptimized to false or + * is using a custom image loader. + * + * @param importStatement The import statement for the original config. + * @param fileExtension The file extension of the original config. Use ".js", ".mjs", or ".ts" + * @returns The custom Next.js config. + */ +function getCustomNextConfig(importStatement: string, fileExtension: string) { + return ` + // @ts-nocheck + ${importStatement} + + // 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); + + ${fileExtension === ".mjs" ? "export default config;" : "module.exports = config;"} + `; +} + +/** + * This function is used to validate the state of an app after running the + * 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. + */ +export async function validateNextConfigOverride( + root: string, + projectRoot: string, + originalConfigFileName: 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 originalNextConfigFilePath = join(root, originalConfigFileName); + if (!(await exists(originalNextConfigFilePath))) { + throw new Error( + `Next Config Override Failed: Original Next.js config file not found at ${originalNextConfigFilePath}`, + ); + } + + try { + await loadConfig(root, projectRoot); + } catch (error) { + throw new Error( + `Resulting Next Config is invalid: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } +} /** * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting @@ -36,8 +178,8 @@ export async function addRouteOverrides( ] : []), ], - /* - NextJs converts the source string to a regex using path-to-regexp (https://github.com/pillarjs/path-to-regexp) at + /* + NextJs converts the source string to a regex using path-to-regexp (https://github.com/pillarjs/path-to-regexp) at build time: https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts#L1273. This regex is then used to match the route against the request path.