From 46e9f48a941c96933da5859eec9222f7745933ce Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Mon, 3 Mar 2025 15:38:28 +0000 Subject: [PATCH 01/28] add function to override next config --- packages/@apphosting/adapter-nextjs/src/bin/build.ts | 5 +++-- packages/@apphosting/adapter-nextjs/src/overrides.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index a2241174..b86522bc 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -20,11 +20,12 @@ process.env.NEXT_TELEMETRY_DISABLED = "1"; if (!process.env.FRAMEWORK_VERSION) { throw new Error("Could not find the nextjs version of the application"); } + +const { distDir, configFileName } = await loadConfig(root, opts.projectDirectory); + await runBuild(); const adapterMetadata = getAdapterMetadata(); - -const { distDir } = await loadConfig(root, opts.projectDirectory); const nextBuildDirectory = join(opts.projectDirectory, distDir); const outputBundleOptions = populateOutputBundleOptions( root, diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index d4672638..a16be049 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -1,6 +1,8 @@ import { AdapterMetadata, MiddlewareManifest } from "./interfaces.js"; import { loadRouteManifest, writeRouteManifest, loadMiddlewareManifest } from "./utils.js"; +export async function overrideNextConfig(nextConfigFileName: string) {} + /** * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting * specific overrides (i.e headers). @@ -36,8 +38,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. From e4c1966aa470f2a5eea0d42de06eb9cbd5623f68 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Mon, 3 Mar 2025 16:37:51 -0500 Subject: [PATCH 02/28] implement next config override logic to import in existing config and export overrided config --- .../adapter-nextjs/src/bin/build.ts | 4 +- .../adapter-nextjs/src/overrides.ts | 77 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index b86522bc..8bf67aaf 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -8,7 +8,7 @@ import { } from "../utils.js"; import { join } from "path"; import { getBuildOptions, runBuild } from "@apphosting/common"; -import { addRouteOverrides } from "../overrides.js"; +import { addRouteOverrides, overrideNextConfig } from "../overrides.js"; const root = process.cwd(); const opts = getBuildOptions(); @@ -22,7 +22,7 @@ if (!process.env.FRAMEWORK_VERSION) { } const { distDir, configFileName } = await loadConfig(root, opts.projectDirectory); - +await overrideNextConfig(configFileName); await runBuild(); const adapterMetadata = getAdapterMetadata(); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index a16be049..0912a16e 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -1,7 +1,80 @@ import { AdapterMetadata, MiddlewareManifest } from "./interfaces.js"; -import { loadRouteManifest, writeRouteManifest, loadMiddlewareManifest } from "./utils.js"; +import { + loadRouteManifest, + writeRouteManifest, + loadMiddlewareManifest, + exists, + writeFile, +} from "./utils.js"; +import { join } from "path"; +import { readFileSync } from "fs"; -export async function overrideNextConfig(nextConfigFileName: string) {} +export async function overrideNextConfig(nextConfigFileName: string) { + // Check if the file exists in the current working directory + const cwd = process.cwd(); + const configPath = join(cwd, nextConfigFileName); + + if (!(await exists(configPath))) { + console.log(`No Next.js config file found at ${configPath}`); + return; + } + + // Determine the file extension + const fileExtension = nextConfigFileName.split(".").pop() || "js"; + const originalConfigName = `next.config.original.${fileExtension}`; + const newConfigName = `next.config.${fileExtension}`; + + // Rename the original config file + try { + const originalContent = readFileSync(configPath, "utf-8"); + await writeFile(join(cwd, originalConfigName), originalContent); + + // Create a new config file with the appropriate import + let importStatement; + if (fileExtension === "js" || fileExtension === "cjs") { + importStatement = `const originalConfig = require('./${originalConfigName}');`; + } else if (fileExtension === "mjs") { + importStatement = `import originalConfig from './${originalConfigName}';`; + } else if (fileExtension === "ts") { + importStatement = `import originalConfig from './${originalConfigName.replace(".ts", "")}';`; + } else { + throw new Error(`Unsupported file extension: ${fileExtension}`); + } + + // Create the new config content with our overrides + const newConfigContent = `${importStatement} + +// This file was automatically generated by Firebase App Hosting adapter +const config = typeof originalConfig === 'function' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return { + ...resolvedConfig, + images: { + ...(resolvedConfig.images || {}), + unoptimized: true, + }, + }; + } + : { + ...originalConfig, + images: { + ...(originalConfig.images || {}), + unoptimized: true, + }, + }; + +${fileExtension === "mjs" ? "export default config;" : "module.exports = config;"} +`; + + // Write the new config file + await writeFile(join(cwd, newConfigName), newConfigContent); + console.log(`Successfully created ${newConfigName} with Firebase App Hosting overrides`); + } catch (error) { + console.error(`Error overriding Next.js config: ${error}`); + throw error; + } +} /** * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting From 7924e7bd62186b088702011d9b1c6bec9e39f678 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Mon, 3 Mar 2025 23:43:19 -0500 Subject: [PATCH 03/28] add unit tests for next config overrides --- .../adapter-nextjs/src/bin/build.ts | 2 +- .../adapter-nextjs/src/overrides.spec.ts | 206 ++++++++++++++++++ .../adapter-nextjs/src/overrides.ts | 9 +- 3 files changed, 211 insertions(+), 6 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index 8bf67aaf..eb2e525a 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -22,7 +22,7 @@ if (!process.env.FRAMEWORK_VERSION) { } const { distDir, configFileName } = await loadConfig(root, opts.projectDirectory); -await overrideNextConfig(configFileName); +await overrideNextConfig(root, configFileName); await runBuild(); const adapterMetadata = getAdapterMetadata(); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index b608717c..35e418ba 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -163,3 +163,209 @@ describe("route overrides", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); }); + +describe("next config overrides", () => { + let tmpDir: string; + + 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(` + const originalConfig = require('./next.config.original.js'); + + // This file was automatically generated by Firebase App Hosting adapter + const config = typeof originalConfig === 'function' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return { + ...resolvedConfig, + images: { + ...(resolvedConfig.images || {}), + unoptimized: true, + }, + }; + } + : { + ...originalConfig, + images: { + ...(originalConfig.images || {}), + unoptimized: true, + }, + }; + + 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(` + import originalConfig from './next.config.original.mjs'; + + // This file was automatically generated by Firebase App Hosting adapter + const config = typeof originalConfig === 'function' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return { + ...resolvedConfig, + images: { + ...(resolvedConfig.images || {}), + unoptimized: true, + }, + }; + } + : { + ...originalConfig, + images: { + ...(originalConfig.images || {}), + unoptimized: true, + }, + }; + + 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(` + import originalConfig from './next.config.original.mjs'; + + // This file was automatically generated by Firebase App Hosting adapter + const config = typeof originalConfig === 'function' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return { + ...resolvedConfig, + images: { + ...(resolvedConfig.images || {}), + unoptimized: true, + }, + }; + } + : { + ...originalConfig, + images: { + ...(originalConfig.images || {}), + unoptimized: true, + }, + }; + + 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(` + import originalConfig from './next.config.original'; + + // This file was automatically generated by Firebase App Hosting adapter + const config = typeof originalConfig === 'function' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return { + ...resolvedConfig, + images: { + ...(resolvedConfig.images || {}), + unoptimized: true, + }, + }; + } + : { + ...originalConfig, + images: { + ...(originalConfig.images || {}), + unoptimized: true, + }, + }; + + module.exports = config; + `), + ); + }); +}); + +// 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 0912a16e..dc0709eb 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -9,10 +9,9 @@ import { import { join } from "path"; import { readFileSync } from "fs"; -export async function overrideNextConfig(nextConfigFileName: string) { +export async function overrideNextConfig(projectRoot: string, nextConfigFileName: string) { // Check if the file exists in the current working directory - const cwd = process.cwd(); - const configPath = join(cwd, nextConfigFileName); + const configPath = join(projectRoot, nextConfigFileName); if (!(await exists(configPath))) { console.log(`No Next.js config file found at ${configPath}`); @@ -27,7 +26,7 @@ export async function overrideNextConfig(nextConfigFileName: string) { // Rename the original config file try { const originalContent = readFileSync(configPath, "utf-8"); - await writeFile(join(cwd, originalConfigName), originalContent); + await writeFile(join(projectRoot, originalConfigName), originalContent); // Create a new config file with the appropriate import let importStatement; @@ -68,7 +67,7 @@ ${fileExtension === "mjs" ? "export default config;" : "module.exports = config; `; // Write the new config file - await writeFile(join(cwd, newConfigName), newConfigContent); + await writeFile(join(projectRoot, newConfigName), newConfigContent); console.log(`Successfully created ${newConfigName} with Firebase App Hosting overrides`); } catch (error) { console.error(`Error overriding Next.js config: ${error}`); From b670497840128274a961d527e58d059077dc35d1 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Wed, 5 Mar 2025 17:25:29 -0500 Subject: [PATCH 04/28] address some comments --- .../adapter-nextjs/src/overrides.spec.ts | 103 +++++------------- .../adapter-nextjs/src/overrides.ts | 52 +++++---- 2 files changed, 54 insertions(+), 101 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index 35e418ba..2291f990 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -166,6 +166,23 @@ describe("route overrides", () => { 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 ? { unoptimized: true } : {}), + }, + }); + + const config = typeof originalConfig === 'function' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return fahOptimizedConfig(resolvedConfig); + } + : fahOptimizedConfig(originalConfig); + `; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides")); @@ -192,27 +209,10 @@ describe("next config overrides", () => { assert.equal( normalizeWhitespace(updatedConfig), normalizeWhitespace(` + // @ts-nocheck const originalConfig = require('./next.config.original.js'); - // This file was automatically generated by Firebase App Hosting adapter - const config = typeof originalConfig === 'function' - ? (...args) => { - const resolvedConfig = originalConfig(...args); - return { - ...resolvedConfig, - images: { - ...(resolvedConfig.images || {}), - unoptimized: true, - }, - }; - } - : { - ...originalConfig, - images: { - ...(originalConfig.images || {}), - unoptimized: true, - }, - }; + ${nextConfigOverrideBody} module.exports = config; `), @@ -241,28 +241,11 @@ describe("next config overrides", () => { assert.equal( normalizeWhitespace(updatedConfig), normalizeWhitespace(` + // @ts-nocheck import originalConfig from './next.config.original.mjs'; - // This file was automatically generated by Firebase App Hosting adapter - const config = typeof originalConfig === 'function' - ? (...args) => { - const resolvedConfig = originalConfig(...args); - return { - ...resolvedConfig, - images: { - ...(resolvedConfig.images || {}), - unoptimized: true, - }, - }; - } - : { - ...originalConfig, - images: { - ...(originalConfig.images || {}), - unoptimized: true, - }, - }; - + ${nextConfigOverrideBody} + export default config; `), ); @@ -291,27 +274,10 @@ describe("next config overrides", () => { assert.equal( normalizeWhitespace(updatedConfig), normalizeWhitespace(` + // @ts-nocheck import originalConfig from './next.config.original.mjs'; - // This file was automatically generated by Firebase App Hosting adapter - const config = typeof originalConfig === 'function' - ? (...args) => { - const resolvedConfig = originalConfig(...args); - return { - ...resolvedConfig, - images: { - ...(resolvedConfig.images || {}), - unoptimized: true, - }, - }; - } - : { - ...originalConfig, - images: { - ...(originalConfig.images || {}), - unoptimized: true, - }, - }; + ${nextConfigOverrideBody} export default config; `), @@ -337,27 +303,10 @@ describe("next config overrides", () => { assert.equal( normalizeWhitespace(updatedConfig), normalizeWhitespace(` + // @ts-nocheck import originalConfig from './next.config.original'; - // This file was automatically generated by Firebase App Hosting adapter - const config = typeof originalConfig === 'function' - ? (...args) => { - const resolvedConfig = originalConfig(...args); - return { - ...resolvedConfig, - images: { - ...(resolvedConfig.images || {}), - unoptimized: true, - }, - }; - } - : { - ...originalConfig, - images: { - ...(originalConfig.images || {}), - unoptimized: true, - }, - }; + ${nextConfigOverrideBody} module.exports = config; `), diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index dc0709eb..8c40c07a 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -41,30 +41,7 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName } // Create the new config content with our overrides - const newConfigContent = `${importStatement} - -// This file was automatically generated by Firebase App Hosting adapter -const config = typeof originalConfig === 'function' - ? (...args) => { - const resolvedConfig = originalConfig(...args); - return { - ...resolvedConfig, - images: { - ...(resolvedConfig.images || {}), - unoptimized: true, - }, - }; - } - : { - ...originalConfig, - images: { - ...(originalConfig.images || {}), - unoptimized: true, - }, - }; - -${fileExtension === "mjs" ? "export default config;" : "module.exports = config;"} -`; + const newConfigContent = getCustomNextConfig(importStatement, fileExtension); // Write the new config file await writeFile(join(projectRoot, newConfigName), newConfigContent); @@ -75,6 +52,33 @@ ${fileExtension === "mjs" ? "export default config;" : "module.exports = 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' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return fahOptimizedConfig(resolvedConfig); + } + : fahOptimizedConfig(originalConfig); + + ${fileExtension === "mjs" ? "export default config;" : "module.exports = config;"} + `; +} + /** * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting * specific overrides (i.e headers). From af99265fd5dc93fe6d1e4cca91605dfbecb4385c Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Wed, 5 Mar 2025 18:57:33 -0500 Subject: [PATCH 05/28] fix test --- .../adapter-nextjs/src/overrides.spec.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index 2291f990..3efa78c3 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -167,22 +167,24 @@ describe("route overrides", () => { 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 ? { unoptimized: true } : {}), - }, - }); + // 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' - ? (...args) => { - const resolvedConfig = originalConfig(...args); - return fahOptimizedConfig(resolvedConfig); - } - : fahOptimizedConfig(originalConfig); - `; + const config = typeof originalConfig === 'function' + ? (...args) => { + const resolvedConfig = originalConfig(...args); + return fahOptimizedConfig(resolvedConfig); + } + : fahOptimizedConfig(originalConfig); + `; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides")); From c6626f93f7f6faba955ee0966e45d4fae971c991 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Wed, 5 Mar 2025 19:07:08 -0500 Subject: [PATCH 06/28] use rename instead of read/write --- packages/@apphosting/adapter-nextjs/src/overrides.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index 8c40c07a..a663bef4 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -7,7 +7,7 @@ import { writeFile, } from "./utils.js"; import { join } from "path"; -import { readFileSync } from "fs"; +import { rename as renamePromise } from "fs/promises"; export async function overrideNextConfig(projectRoot: string, nextConfigFileName: string) { // Check if the file exists in the current working directory @@ -25,8 +25,8 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName // Rename the original config file try { - const originalContent = readFileSync(configPath, "utf-8"); - await writeFile(join(projectRoot, originalConfigName), originalContent); + const originalPath = join(projectRoot, originalConfigName); + await renamePromise(configPath, originalPath); // Create a new config file with the appropriate import let importStatement; From 4a8401a4c17bea95c9aebdf493f845a0be7f5762 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 00:06:40 -0500 Subject: [PATCH 07/28] progress - e2e tests --- .../e2e/config-override.spec.ts | 191 ++++++++++++++++++ .../adapter-nextjs/e2e/run-local.ts | 159 +++++++++++++-- 2 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts 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..2393c505 --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -0,0 +1,191 @@ +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"); +} + +describe("next.config override", () => { + it("should have images optimization disabled", async () => { + const serverFiles = await fsExtra.readJson( + `${process.cwd()}/e2e/runs/${runId}/.next/standalone/.next/required-server-files.json`, + ); + console.log(`serverFiles: ${JSON.stringify(serverFiles)}`); + 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 user config settings", async () => { + // // 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 + // if (scenario.includes("with-js-config")) { + // assert.equal(response.headers.get("x-custom-header") ?? "", "js-config-value"); + // } else if (scenario.includes("with-ts-config")) { + // assert.equal(response.headers.get("x-custom-header") ?? "", "ts-config-value"); + // } else if (scenario.includes("with-mjs-config")) { + // assert.equal(response.headers.get("x-custom-header") ?? "", "mjs-config-value"); + // } else if (scenario.includes("with-complex-config")) { + // assert.equal(response.headers.get("x-custom-header") ?? "", "complex-config-value"); + // } + // }); + + // it("should handle function-style config correctly", async () => { + // // Only run this test for scenarios with function-style config + // if (!scenario.includes("function-style")) { + // this.skip(); + // return; + // } + + // // 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 () => { + // // Only run this test for scenarios with object-style config + // if ( + // !scenario.includes("object-style") && + // !scenario.includes("with-complex-config") && + // !scenario.includes("with-empty-config") + // ) { + // this.skip(); + // return; + // } + + // // 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 handle empty config correctly", async () => { + // // Only run this test for the empty config scenario + // if (!scenario.includes("with-empty-config")) { + // this.skip(); + // return; + // } + + // // Just check that the page loads successfully + // const response = await fetch(posix.join(host, "/")); + // assert.ok(response.ok); + // }); + + // it("should verify original config file was preserved", async () => { + // // This test verifies that the original config file was preserved + // // We'll check the file system to make sure the original config file exists + // let originalConfigExists = false; + + // if (scenario.includes("with-js-config")) { + // originalConfigExists = await fsExtra.pathExists("next.config.original.js"); + // } else if (scenario.includes("with-ts-config")) { + // originalConfigExists = await fsExtra.pathExists("next.config.original.ts"); + // } else if (scenario.includes("with-mjs-config")) { + // originalConfigExists = await fsExtra.pathExists("next.config.original.mjs"); + // } else if ( + // scenario.includes("with-empty-config") || + // scenario.includes("with-complex-config") || + // scenario.includes("with-error-handling") + // ) { + // originalConfigExists = await fsExtra.pathExists("next.config.original.js"); + // } + + // assert.ok(originalConfigExists, "Original config file should be preserved"); + // }); + + // it("should handle error gracefully when config file has syntax errors", async () => { + // // Only run this test for the error handling scenario + // if (!scenario.includes("with-error-handling")) { + // this.skip(); + // return; + // } + + // // The build should have succeeded despite the invalid config file + // // because we started with a valid config + // const response = await fetch(posix.join(host, "/")); + // assert.ok(response.ok); + + // // Check if the invalid config file exists + // const invalidConfigExists = await fsExtra.pathExists("next.config.invalid.js"); + // assert.ok(invalidConfigExists, "Invalid config file should exist"); + // }); + + // it("should verify the generated config file has the correct format", async () => { + // // Skip for error handling scenario + // if (scenario.includes("with-error-handling")) { + // this.skip(); + // return; + // } + + // let configPath = ""; + + // if (scenario.includes("with-js-config")) { + // configPath = "next.config.js"; + // } else if (scenario.includes("with-ts-config")) { + // configPath = "next.config.ts"; + // } else if (scenario.includes("with-mjs-config")) { + // configPath = "next.config.mjs"; + // } else if (scenario.includes("with-empty-config") || scenario.includes("with-complex-config")) { + // configPath = "next.config.js"; + // } + + // // Check if the generated config file exists + // const configExists = await fsExtra.pathExists(configPath); + // assert.ok(configExists, "Generated config file should exist"); + + // // Read the config file content + // const configContent = await fsExtra.readFile(configPath, "utf-8"); + + // // Verify that the config file contains the unoptimized: true setting + // assert.ok(configContent.includes("unoptimized: true"), "Config should have unoptimized: true"); + + // // Verify that the config file imports the original config + // if ( + // scenario.includes("with-js-config") || + // scenario.includes("with-empty-config") || + // scenario.includes("with-complex-config") + // ) { + // assert.ok( + // configContent.includes("require('./next.config.original.js')"), + // "Config should import the original JS config", + // ); + // } else if (scenario.includes("with-ts-config")) { + // assert.ok( + // configContent.includes("import originalConfig from './next.config.original'"), + // "Config should import the original TS config", + // ); + // } else if (scenario.includes("with-mjs-config")) { + // assert.ok( + // configContent.includes("import originalConfig from './next.config.original.mjs'"), + // "Config should import the original MJS config", + // ); + // } + // }); +}); diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index abc44bd4..2dde98c9 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -20,33 +20,151 @@ interface Scenario { } const scenarios: Scenario[] = [ - { - name: "basic", - // No setup needed for basic scenario - tests: ["app.spec.ts"], - }, - { - name: "with-middleware", - setup: async (cwd: string) => { - // Create a middleware.ts file - const middlewareContent = ` - import type { NextRequest } from 'next/server' + // { + // name: "basic", + // // No setup needed for basic scenario + // tests: ["app.spec.ts"], + // }, + // { + // name: "with-middleware", + // setup: async (cwd: string) => { + // // Create a middleware.ts file + // const middlewareContent = ` + // import type { NextRequest } from 'next/server' + + // export function middleware(request: NextRequest) { + // // This is a simple middleware that doesn't modify the request + // console.log('Middleware executed', request.nextUrl.pathname); + // } - export function middleware(request: NextRequest) { - // This is a simple middleware that doesn't modify the request - console.log('Middleware executed', request.nextUrl.pathname); - } + // export const config = { + // matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', + // }; + // `; - export const config = { - matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', + // await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); + // console.log(`Created middleware.ts file`); + // }, + // tests: ["middleware.spec.ts"], // Only run middleware-specific tests + // }, + // New scenarios for testing Next.js config override behavior + { + name: "with-js-config-object-style", + setup: async (cwd: string) => { + // Create a next.config.js file with object-style config + const configContent = ` + /** @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; `; - await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); - console.log(`Created middleware.ts file`); + await fsExtra.writeFile(join(cwd, "next.config.js"), configContent); + console.log(`Created next.config.js file with object-style config`); }, - tests: ["middleware.spec.ts"], // Only run middleware-specific tests + tests: ["config-override.spec.ts"], }, + // { + // name: "with-js-config-object-style", + // setup: async (cwd: string) => { + // // Create a next.config.js file with object-style config + // const configContent = ` + // /** @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', + // }, + // ], + // }, + // ]; + // }, + // // This should be overridden by the adapter + // images: { + // unoptimized: false, + // domains: ['example.com'], + // }, + // }; + + // module.exports = nextConfig; + // `; + + // await fsExtra.writeFile(join(cwd, "next.config.js"), configContent); + // console.log(`Created next.config.js file with object-style config`); + // }, + // tests: ["config-override.spec.ts"], + // }, + // { + // name: "with-js-config-function-style", + // setup: async (cwd: string) => { + // // Create a next.config.js file with function-style config + // const configContent = ` + // /** @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', + // }, + // ], + // }, + // ]; + // }, + // // This should be overridden by the adapter + // images: { + // unoptimized: false, + // domains: ['example.com'], + // }, + // }; + // }; + + // module.exports = nextConfig; + // `; + + // await fsExtra.writeFile(join(cwd, "next.config.js"), configContent); + // console.log(`Created next.config.js file with function-style config`); + // }, + // tests: ["config-override.spec.ts"], + // }, ]; const errors: any[] = []; @@ -170,6 +288,7 @@ for (const scenario of scenarios) { ...process.env, HOST: host, SCENARIO: scenario.name, + RUN_ID: runId, }, }).finally(() => { run.stdin.end(); From 074d6a0b20234135d2a0ebcf1fc6a6e327b66a2f Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 13:01:04 -0500 Subject: [PATCH 08/28] mostly working e2e tests --- .../e2e/config-override-test-cases.yaml | 144 +++++++++++ .../e2e/config-override.spec.ts | 237 ++++++------------ .../adapter-nextjs/e2e/run-local.ts | 144 ++--------- 3 files changed, 245 insertions(+), 280 deletions(-) create mode 100644 packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml 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..c9ff6a7f --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml @@ -0,0 +1,144 @@ +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 diff --git a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts index 2393c505..b8fabeee 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -17,12 +17,23 @@ if (!runId) { throw new Error("RUN_ID environment variable expected"); } +const compiledFilesPath = posix.join( + process.cwd(), + "e2e", + "runs", + runId, + ".next", + "standalone", + ".next", +); + describe("next.config override", () => { - it("should have images optimization disabled", async () => { - const serverFiles = await fsExtra.readJson( - `${process.cwd()}/e2e/runs/${runId}/.next/standalone/.next/required-server-files.json`, - ); - console.log(`serverFiles: ${JSON.stringify(serverFiles)}`); + it("should have images optimization disabled", async function () { + if (scenario.includes("with-empty-config")) { + this.skip(); + } + + const serverFiles = await fsExtra.readJson(`${compiledFilesPath}/required-server-files.json`); const config = serverFiles.config; // Verify that images.unoptimized is set to true @@ -34,158 +45,66 @@ describe("next.config override", () => { ); }); - // it("should preserve user config settings", async () => { - // // 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 - // if (scenario.includes("with-js-config")) { - // assert.equal(response.headers.get("x-custom-header") ?? "", "js-config-value"); - // } else if (scenario.includes("with-ts-config")) { - // assert.equal(response.headers.get("x-custom-header") ?? "", "ts-config-value"); - // } else if (scenario.includes("with-mjs-config")) { - // assert.equal(response.headers.get("x-custom-header") ?? "", "mjs-config-value"); - // } else if (scenario.includes("with-complex-config")) { - // assert.equal(response.headers.get("x-custom-header") ?? "", "complex-config-value"); - // } - // }); - - // it("should handle function-style config correctly", async () => { - // // Only run this test for scenarios with function-style config - // if (!scenario.includes("function-style")) { - // this.skip(); - // return; - // } - - // // 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 () => { - // // Only run this test for scenarios with object-style config - // if ( - // !scenario.includes("object-style") && - // !scenario.includes("with-complex-config") && - // !scenario.includes("with-empty-config") - // ) { - // this.skip(); - // return; - // } - - // // 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 handle empty config correctly", async () => { - // // Only run this test for the empty config scenario - // if (!scenario.includes("with-empty-config")) { - // this.skip(); - // return; - // } - - // // Just check that the page loads successfully - // const response = await fetch(posix.join(host, "/")); - // assert.ok(response.ok); - // }); - - // it("should verify original config file was preserved", async () => { - // // This test verifies that the original config file was preserved - // // We'll check the file system to make sure the original config file exists - // let originalConfigExists = false; - - // if (scenario.includes("with-js-config")) { - // originalConfigExists = await fsExtra.pathExists("next.config.original.js"); - // } else if (scenario.includes("with-ts-config")) { - // originalConfigExists = await fsExtra.pathExists("next.config.original.ts"); - // } else if (scenario.includes("with-mjs-config")) { - // originalConfigExists = await fsExtra.pathExists("next.config.original.mjs"); - // } else if ( - // scenario.includes("with-empty-config") || - // scenario.includes("with-complex-config") || - // scenario.includes("with-error-handling") - // ) { - // originalConfigExists = await fsExtra.pathExists("next.config.original.js"); - // } - - // assert.ok(originalConfigExists, "Original config file should be preserved"); - // }); - - // it("should handle error gracefully when config file has syntax errors", async () => { - // // Only run this test for the error handling scenario - // if (!scenario.includes("with-error-handling")) { - // this.skip(); - // return; - // } - - // // The build should have succeeded despite the invalid config file - // // because we started with a valid config - // const response = await fetch(posix.join(host, "/")); - // assert.ok(response.ok); - - // // Check if the invalid config file exists - // const invalidConfigExists = await fsExtra.pathExists("next.config.invalid.js"); - // assert.ok(invalidConfigExists, "Invalid config file should exist"); - // }); - - // it("should verify the generated config file has the correct format", async () => { - // // Skip for error handling scenario - // if (scenario.includes("with-error-handling")) { - // this.skip(); - // return; - // } - - // let configPath = ""; - - // if (scenario.includes("with-js-config")) { - // configPath = "next.config.js"; - // } else if (scenario.includes("with-ts-config")) { - // configPath = "next.config.ts"; - // } else if (scenario.includes("with-mjs-config")) { - // configPath = "next.config.mjs"; - // } else if (scenario.includes("with-empty-config") || scenario.includes("with-complex-config")) { - // configPath = "next.config.js"; - // } - - // // Check if the generated config file exists - // const configExists = await fsExtra.pathExists(configPath); - // assert.ok(configExists, "Generated config file should exist"); - - // // Read the config file content - // const configContent = await fsExtra.readFile(configPath, "utf-8"); - - // // Verify that the config file contains the unoptimized: true setting - // assert.ok(configContent.includes("unoptimized: true"), "Config should have unoptimized: true"); - - // // Verify that the config file imports the original config - // if ( - // scenario.includes("with-js-config") || - // scenario.includes("with-empty-config") || - // scenario.includes("with-complex-config") - // ) { - // assert.ok( - // configContent.includes("require('./next.config.original.js')"), - // "Config should import the original JS config", - // ); - // } else if (scenario.includes("with-ts-config")) { - // assert.ok( - // configContent.includes("import originalConfig from './next.config.original'"), - // "Config should import the original TS config", - // ); - // } else if (scenario.includes("with-mjs-config")) { - // assert.ok( - // configContent.includes("import originalConfig from './next.config.original.mjs'"), - // "Config should import the original MJS config", - // ); - // } - // }); + it("should preserve other user set next configs", async function () { + if (scenario.includes("with-empty-config")) { + 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, "/")); + + // Log all headers individually for debugging + console.log("Response headers:"); + response.headers.forEach((value, key) => { + console.log(` ${key}: ${value}`); + }); + + 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", + "complex-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")) { + 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-complex-config") && + !scenario.includes("with-empty-config") + ) { + 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"); + } + }); }); diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index 2dde98c9..2319e745 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -6,7 +6,7 @@ import { parse as parseYaml } from "yaml"; import { spawn } from "child_process"; import fsExtra from "fs-extra"; -const { readFileSync, mkdirp, rmdir } = fsExtra; +const { readFileSync, mkdirp, rm } = fsExtra; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -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,133 +52,30 @@ const scenarios: Scenario[] = [ // }, // tests: ["middleware.spec.ts"], // Only run middleware-specific tests // }, - // New scenarios for testing Next.js config override behavior - { - name: "with-js-config-object-style", - setup: async (cwd: string) => { - // Create a next.config.js file with object-style config - const configContent = ` - /** @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; - `; - - await fsExtra.writeFile(join(cwd, "next.config.js"), configContent); - console.log(`Created next.config.js file with object-style config`); - }, - tests: ["config-override.spec.ts"], - }, - // { - // name: "with-js-config-object-style", - // setup: async (cwd: string) => { - // // Create a next.config.js file with object-style config - // const configContent = ` - // /** @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', - // }, - // ], - // }, - // ]; - // }, - // // This should be overridden by the adapter - // images: { - // unoptimized: false, - // domains: ['example.com'], - // }, - // }; - - // module.exports = nextConfig; - // `; - - // await fsExtra.writeFile(join(cwd, "next.config.js"), configContent); - // console.log(`Created next.config.js file with object-style config`); - // }, - // tests: ["config-override.spec.ts"], - // }, - // { - // name: "with-js-config-function-style", - // setup: async (cwd: string) => { - // // Create a next.config.js file with function-style config - // const configContent = ` - // /** @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', - // }, - // ], - // }, - // ]; - // }, - // // This should be overridden by the adapter - // images: { - // unoptimized: false, - // domains: ['example.com'], - // }, - // }; - // }; - - // module.exports = nextConfig; - // `; - - // await fsExtra.writeFile(join(cwd, "next.config.js"), configContent); - // console.log(`Created next.config.js file with function-style config`); - // }, - // tests: ["config-override.spec.ts"], - // }, + ...configOverrideTestScenarios.map( + (scenario: { name: string; config: string; file: string }) => ({ + name: scenario.name, + setup: async (cwd: string) => { + const configContent = scenario.config; + 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[] = []; -await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined); +await rm(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); From 8b729203aa68aa572c1613c80374fa418aa156e0 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 13:12:20 -0500 Subject: [PATCH 09/28] support async next configs --- .../e2e/config-override-test-cases.yaml | 210 +++++++++--------- .../e2e/config-override.spec.ts | 6 - .../adapter-nextjs/e2e/run-local.ts | 54 ++--- .../adapter-nextjs/src/overrides.spec.ts | 6 +- .../adapter-nextjs/src/overrides.ts | 4 +- 5 files changed, 137 insertions(+), 143 deletions(-) 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 c9ff6a7f..94151b31 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml @@ -1,58 +1,58 @@ 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', - # }, - # ], - # }, - # ]; - # }, - # }; + - 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-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 + module.exports = nextConfig; + file: next.config.js - name: with-js-async-function config: | // @ts-check @@ -83,62 +83,62 @@ tests: return nextConfig } file: next.config.js - # - name: with-ts-config - # config: | - # import type { NextConfig } from 'next' + - 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', - # } - # ], - # }, - # ]; - # } - # } + 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 + 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', - # }, - # ], - # }, - # ]; - # } - # } + /** + * @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 + export default nextConfig + file: next.config.mjs + - name: with-empty-config + config: | + // @ts-check - # /** @type {import('next').NextConfig} */ - # const nextConfig = { - # /* config options here */ - # } + /** @type {import('next').NextConfig} */ + const nextConfig = { + /* config options here */ + } - # module.exports = nextConfig - # file: next.config.js + 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 index b8fabeee..6dcc73c9 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -54,12 +54,6 @@ describe("next.config override", () => { // We'll check for the custom header that was set in the next.config const response = await fetch(posix.join(host, "/")); - // Log all headers individually for debugging - console.log("Response headers:"); - response.headers.forEach((value, key) => { - console.log(` ${key}: ${value}`); - }); - assert.ok(response.ok); // Check for the custom header that was set in the next.config diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index 2319e745..6885c15d 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -25,33 +25,33 @@ const configOverrideTestScenarios = parseYaml( ).tests; const scenarios: Scenario[] = [ - // { - // name: "basic", - // // No setup needed for basic scenario - // tests: ["app.spec.ts"], - // }, - // { - // name: "with-middleware", - // setup: async (cwd: string) => { - // // Create a middleware.ts file - // const middlewareContent = ` - // import type { NextRequest } from 'next/server' - - // export function middleware(request: NextRequest) { - // // This is a simple middleware that doesn't modify the request - // console.log('Middleware executed', request.nextUrl.pathname); - // } - - // export const config = { - // matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', - // }; - // `; - - // await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); - // console.log(`Created middleware.ts file`); - // }, - // tests: ["middleware.spec.ts"], // Only run middleware-specific tests - // }, + { + name: "basic", + // No setup needed for basic scenario + tests: ["app.spec.ts"], + }, + { + name: "with-middleware", + setup: async (cwd: string) => { + // Create a middleware.ts file + const middlewareContent = ` + import type { NextRequest } from 'next/server' + + export function middleware(request: NextRequest) { + // This is a simple middleware that doesn't modify the request + console.log('Middleware executed', request.nextUrl.pathname); + } + + export const config = { + matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', + }; + `; + + await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); + console.log(`Created middleware.ts file`); + }, + tests: ["middleware.spec.ts"], // Only run middleware-specific tests + }, ...configOverrideTestScenarios.map( (scenario: { name: string; config: string; file: string }) => ({ name: scenario.name, diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index 3efa78c3..326d1f2b 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -179,12 +179,12 @@ describe("next config overrides", () => { }); const config = typeof originalConfig === 'function' - ? (...args) => { - const resolvedConfig = originalConfig(...args); + ? async (...args) => { + const resolvedConfig = await originalConfig(...args); return fahOptimizedConfig(resolvedConfig); } : fahOptimizedConfig(originalConfig); - `; + `; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides")); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index a663bef4..f5a748ec 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -69,8 +69,8 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { }); const config = typeof originalConfig === 'function' - ? (...args) => { - const resolvedConfig = originalConfig(...args); + ? async (...args) => { + const resolvedConfig = await originalConfig(...args); return fahOptimizedConfig(resolvedConfig); } : fahOptimizedConfig(originalConfig); From 141d46eb9a371f67c7f238b6852e5a81ad5ca93c Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 13:25:32 -0500 Subject: [PATCH 10/28] debug --- .../e2e/config-override-test-cases.yaml | 228 +++++++++--------- .../e2e/config-override.spec.ts | 13 +- .../adapter-nextjs/e2e/run-local.ts | 54 ++--- 3 files changed, 143 insertions(+), 152 deletions(-) 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 94151b31..e3d41a34 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml @@ -1,88 +1,88 @@ 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', - }, - ], - }, - ]; - }, - }; + # - 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-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 = 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 + # 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' @@ -105,40 +105,40 @@ tests: export default nextConfig file: next.config.ts - - name: with-ecmascript-modules - config: | - // @ts-check + # - 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', - }, - ], - }, - ]; - } - } + # /** + # * @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 + # export default nextConfig + # file: next.config.mjs + # - name: with-empty-config + # config: | + # // @ts-check - /** @type {import('next').NextConfig} */ - const nextConfig = { - /* config options here */ - } + # /** @type {import('next').NextConfig} */ + # const nextConfig = { + # /* config options here */ + # } - module.exports = nextConfig - file: next.config.js + # 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 index 6dcc73c9..55c0607a 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -58,12 +58,7 @@ describe("next.config override", () => { // 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", - "complex-config-value", - ]; + 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}"`, @@ -84,11 +79,7 @@ describe("next.config override", () => { 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-complex-config") && - !scenario.includes("with-empty-config") - ) { + if (!scenario.includes("object-style") && !scenario.includes("with-empty-config")) { this.skip(); } diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index 6885c15d..2319e745 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -25,33 +25,33 @@ const configOverrideTestScenarios = parseYaml( ).tests; const scenarios: Scenario[] = [ - { - name: "basic", - // No setup needed for basic scenario - tests: ["app.spec.ts"], - }, - { - name: "with-middleware", - setup: async (cwd: string) => { - // Create a middleware.ts file - const middlewareContent = ` - import type { NextRequest } from 'next/server' - - export function middleware(request: NextRequest) { - // This is a simple middleware that doesn't modify the request - console.log('Middleware executed', request.nextUrl.pathname); - } - - export const config = { - matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', - }; - `; - - await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); - console.log(`Created middleware.ts file`); - }, - tests: ["middleware.spec.ts"], // Only run middleware-specific tests - }, + // { + // name: "basic", + // // No setup needed for basic scenario + // tests: ["app.spec.ts"], + // }, + // { + // name: "with-middleware", + // setup: async (cwd: string) => { + // // Create a middleware.ts file + // const middlewareContent = ` + // import type { NextRequest } from 'next/server' + + // export function middleware(request: NextRequest) { + // // This is a simple middleware that doesn't modify the request + // console.log('Middleware executed', request.nextUrl.pathname); + // } + + // export const config = { + // matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', + // }; + // `; + + // await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); + // console.log(`Created middleware.ts file`); + // }, + // tests: ["middleware.spec.ts"], // Only run middleware-specific tests + // }, ...configOverrideTestScenarios.map( (scenario: { name: string; config: string; file: string }) => ({ name: scenario.name, From 1e98f4ff9f1f8993a21d5f73f3ce22b8bf310b83 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 13:43:05 -0500 Subject: [PATCH 11/28] fix up e2e tests --- package-lock.json | 103 ++++++++ .../e2e/config-override-test-cases.yaml | 228 +++++++++--------- .../adapter-nextjs/e2e/run-local.ts | 63 ++--- .../@apphosting/adapter-nextjs/package.json | 1 + 4 files changed, 254 insertions(+), 141 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18c0844c..32014a55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19774,6 +19774,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/pacote": { "version": "15.2.0", "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.2.0.tgz", @@ -25633,6 +25639,7 @@ "@types/fs-extra": "*", "@types/mocha": "*", "@types/tmp": "*", + "glob": "^11.0.1", "mocha": "*", "next": "~14.0.0", "semver": "*", @@ -25651,6 +25658,102 @@ } } }, + "packages/@apphosting/adapter-nextjs/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/@apphosting/adapter-nextjs/node_modules/glob": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", + "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/@apphosting/adapter-nextjs/node_modules/jackspeak": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", + "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/@apphosting/adapter-nextjs/node_modules/lru-cache": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "packages/@apphosting/adapter-nextjs/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/@apphosting/adapter-nextjs/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "packages/@apphosting/adapter-nextjs/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/@apphosting/build": { "version": "0.1.0", "license": "Apache-2.0", 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 e3d41a34..94151b31 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml @@ -1,88 +1,88 @@ 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', - # }, - # ], - # }, - # ]; - # }, - # }; + - 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-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 = 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 + 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' @@ -105,40 +105,40 @@ tests: export default nextConfig file: next.config.ts - # - name: with-ecmascript-modules - # config: | - # // @ts-check + - 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', - # }, - # ], - # }, - # ]; - # } - # } + /** + * @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 + export default nextConfig + file: next.config.mjs + - name: with-empty-config + config: | + // @ts-check - # /** @type {import('next').NextConfig} */ - # const nextConfig = { - # /* config options here */ - # } + /** @type {import('next').NextConfig} */ + const nextConfig = { + /* config options here */ + } - # module.exports = nextConfig - # file: next.config.js + module.exports = nextConfig + file: next.config.js diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index 2319e745..d97f30ee 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "url"; import { parse as parseYaml } from "yaml"; import { spawn } from "child_process"; import fsExtra from "fs-extra"; +import { glob } from "glob"; const { readFileSync, mkdirp, rm } = fsExtra; @@ -25,38 +26,46 @@ const configOverrideTestScenarios = parseYaml( ).tests; const scenarios: Scenario[] = [ - // { - // name: "basic", - // // No setup needed for basic scenario - // tests: ["app.spec.ts"], - // }, - // { - // name: "with-middleware", - // setup: async (cwd: string) => { - // // Create a middleware.ts file - // const middlewareContent = ` - // import type { NextRequest } from 'next/server' - - // export function middleware(request: NextRequest) { - // // This is a simple middleware that doesn't modify the request - // console.log('Middleware executed', request.nextUrl.pathname); - // } - - // export const config = { - // matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', - // }; - // `; - - // await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); - // console.log(`Created middleware.ts file`); - // }, - // tests: ["middleware.spec.ts"], // Only run middleware-specific tests - // }, + { + name: "basic", + // No setup needed for basic scenario + tests: ["app.spec.ts"], + }, + { + name: "with-middleware", + setup: async (cwd: string) => { + // Create a middleware.ts file + const middlewareContent = ` + import type { NextRequest } from 'next/server' + + export function middleware(request: NextRequest) { + // This is a simple middleware that doesn't modify the request + console.log('Middleware executed', request.nextUrl.pathname); + } + + export const config = { + matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', + }; + `; + + await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent); + console.log(`Created middleware.ts file`); + }, + 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; + + // Remove any existing next.config.* files + const configFiles = await glob(join(cwd, "next.config.*")); + 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`); }, diff --git a/packages/@apphosting/adapter-nextjs/package.json b/packages/@apphosting/adapter-nextjs/package.json index d7c0246d..6403fb70 100644 --- a/packages/@apphosting/adapter-nextjs/package.json +++ b/packages/@apphosting/adapter-nextjs/package.json @@ -56,6 +56,7 @@ } }, "devDependencies": { + "glob": "^11.0.1", "@types/fs-extra": "*", "@types/mocha": "*", "@types/tmp": "*", From 70e1bd804ec969651cb23245fb5dc1304f862bbb Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 14:07:19 -0500 Subject: [PATCH 12/28] add additional tests and fix lint errors --- .../e2e/config-override-test-cases.yaml | 58 +++++++++++++++++++ .../e2e/config-override.spec.ts | 34 ++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) 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 94151b31..ee40c3b3 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override-test-cases.yaml @@ -142,3 +142,61 @@ tests: 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 index 55c0607a..a2ab5beb 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts @@ -27,13 +27,20 @@ const compiledFilesPath = posix.join( ".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")) { + 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(`${compiledFilesPath}/required-server-files.json`); + const serverFiles = await fsExtra.readJson(requiredServerFilePath); const config = serverFiles.config; // Verify that images.unoptimized is set to true @@ -47,6 +54,7 @@ describe("next.config override", () => { 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(); } @@ -68,6 +76,7 @@ describe("next.config override", () => { 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(); } @@ -80,6 +89,7 @@ describe("next.config override", () => { 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(); } @@ -92,4 +102,24 @@ describe("next.config override", () => { 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", + ); + }); }); From 442a69ad500e5e34ef7bd0ea2cd436be988cd633 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 16:18:40 -0500 Subject: [PATCH 13/28] fix lint --- packages/@apphosting/adapter-nextjs/e2e/run-local.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index d97f30ee..e5aa5a97 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -60,6 +60,7 @@ const scenarios: Scenario[] = [ const configContent = scenario.config; // Remove any existing next.config.* files + // eslint-disable-next-line @typescript-eslint/await-thenable const configFiles = await glob(join(cwd, "next.config.*")); for (const file of configFiles) { await fsExtra.remove(file); From cc64e3d53a1af39ddc62181afb56f7364b9d48c2 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 18:54:41 -0500 Subject: [PATCH 14/28] some debug logs --- packages/@apphosting/adapter-nextjs/e2e/run-local.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index e5aa5a97..b5c9c041 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -20,10 +20,12 @@ interface Scenario { tests?: string[]; // List of test files to run } +console.log(`reading config-override-test-cases.yaml`); // Load test data for config override const configOverrideTestScenarios = parseYaml( readFileSync(join(__dirname, "config-override-test-cases.yaml"), "utf8"), ).tests; +console.log(`config-override-test-cases.yaml parsed`); const scenarios: Scenario[] = [ { From 560f0d8b9d2764644c6f0453bfac1fbcba2bea61 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 19:04:24 -0500 Subject: [PATCH 15/28] remove console logs --- packages/@apphosting/adapter-nextjs/e2e/run-local.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index b5c9c041..e5aa5a97 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -20,12 +20,10 @@ interface Scenario { tests?: string[]; // List of test files to run } -console.log(`reading config-override-test-cases.yaml`); // Load test data for config override const configOverrideTestScenarios = parseYaml( readFileSync(join(__dirname, "config-override-test-cases.yaml"), "utf8"), ).tests; -console.log(`config-override-test-cases.yaml parsed`); const scenarios: Scenario[] = [ { From 5a1ce3c82611da3292ba526db731aa7def0afc3f Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 19:14:17 -0500 Subject: [PATCH 16/28] set debuggability for e2e test --- packages/@apphosting/adapter-nextjs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@apphosting/adapter-nextjs/package.json b/packages/@apphosting/adapter-nextjs/package.json index d823446e..e8b8829d 100644 --- a/packages/@apphosting/adapter-nextjs/package.json +++ b/packages/@apphosting/adapter-nextjs/package.json @@ -24,7 +24,7 @@ "build": "rm -rf dist && tsc && chmod +x ./dist/bin/*", "test": "npm run test:unit && npm run test:functional", "test:unit": "ts-mocha -p tsconfig.json 'src/**/*.spec.ts' 'src/*.spec.ts'", - "test:functional": "node --loader ts-node/esm ./e2e/run-local.ts", + "test:functional": "NODE_DEBUG=* node --loader ts-node/esm --trace-warnings --unhandled-rejections=strict ./e2e/run-local.ts", "localregistry:start": "npx verdaccio --config ../publish-dev/verdaccio-config.yaml", "localregistry:publish": "(npm view --registry=http://localhost:4873 @apphosting/adapter-nextjs && npm unpublish --@apphosting:registry=http://localhost:4873 --force); npm publish --@apphosting:registry=http://localhost:4873" }, From 9d6ee74324b23326f3d06e44624a0540fa0cca28 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 20:19:14 -0500 Subject: [PATCH 17/28] remove usage of libraries to fix e2e --- .../@apphosting/adapter-nextjs/e2e/run-local.ts | 13 ++++++------- packages/@apphosting/adapter-nextjs/package.json | 3 +-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index e5aa5a97..042e2364 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -5,9 +5,8 @@ import { fileURLToPath } from "url"; import { parse as parseYaml } from "yaml"; import { spawn } from "child_process"; import fsExtra from "fs-extra"; -import { glob } from "glob"; -const { readFileSync, mkdirp, rm } = fsExtra; +const { readFileSync, mkdirp, rmdir } = fsExtra; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -58,10 +57,10 @@ const scenarios: Scenario[] = [ name: scenario.name, setup: async (cwd: string) => { const configContent = scenario.config; - - // Remove any existing next.config.* files - // eslint-disable-next-line @typescript-eslint/await-thenable - const configFiles = await glob(join(cwd, "next.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}`); @@ -77,7 +76,7 @@ const scenarios: Scenario[] = [ const errors: any[] = []; -await rm(join(__dirname, "runs"), { recursive: true }).catch(() => undefined); +await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined); // Run each scenario for (const scenario of scenarios) { diff --git a/packages/@apphosting/adapter-nextjs/package.json b/packages/@apphosting/adapter-nextjs/package.json index e8b8829d..99bafabc 100644 --- a/packages/@apphosting/adapter-nextjs/package.json +++ b/packages/@apphosting/adapter-nextjs/package.json @@ -24,7 +24,7 @@ "build": "rm -rf dist && tsc && chmod +x ./dist/bin/*", "test": "npm run test:unit && npm run test:functional", "test:unit": "ts-mocha -p tsconfig.json 'src/**/*.spec.ts' 'src/*.spec.ts'", - "test:functional": "NODE_DEBUG=* node --loader ts-node/esm --trace-warnings --unhandled-rejections=strict ./e2e/run-local.ts", + "test:functional": "node --loader ts-node/esm ./e2e/run-local.ts", "localregistry:start": "npx verdaccio --config ../publish-dev/verdaccio-config.yaml", "localregistry:publish": "(npm view --registry=http://localhost:4873 @apphosting/adapter-nextjs && npm unpublish --@apphosting:registry=http://localhost:4873 --force); npm publish --@apphosting:registry=http://localhost:4873" }, @@ -56,7 +56,6 @@ } }, "devDependencies": { - "glob": "^11.0.1", "@types/fs-extra": "*", "@types/mocha": "*", "@types/tmp": "*", From be9d381dd02226cd8f5e2d69727695d86b33f56a Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 20:30:47 -0500 Subject: [PATCH 18/28] fix e2e --- packages/@apphosting/adapter-nextjs/e2e/run-local.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index 042e2364..23104afa 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -58,9 +58,10 @@ const scenarios: Scenario[] = [ 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)); - + 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}`); From 9797602161b2573ee641330e14745b9d3c447c07 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Thu, 6 Mar 2025 21:08:01 -0500 Subject: [PATCH 19/28] add jsdocs --- .../@apphosting/adapter-nextjs/src/overrides.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index f5a748ec..ab9bd9ca 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -9,6 +9,10 @@ import { import { join } 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) { // Check if the file exists in the current working directory const configPath = join(projectRoot, nextConfigFileName); @@ -52,6 +56,17 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName } } +/** + * 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. + * @returns The custom Next.js config. + */ function getCustomNextConfig(importStatement: string, fileExtension: string) { return ` // @ts-nocheck From 2bdecf00a5081807c0bbe285e7280a97324c1c8b Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Sat, 8 Mar 2025 04:36:47 +0000 Subject: [PATCH 20/28] bump version --- package-lock.json | 105 +----------------- .../@apphosting/adapter-nextjs/package.json | 2 +- 2 files changed, 2 insertions(+), 105 deletions(-) diff --git a/package-lock.json b/package-lock.json index 499d8791..27eda41d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19774,12 +19774,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, "node_modules/pacote": { "version": "15.2.0", "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.2.0.tgz", @@ -25624,7 +25618,7 @@ } }, "packages/@apphosting/adapter-nextjs": { - "version": "14.0.11", + "version": "14.0.12", "license": "Apache-2.0", "dependencies": { "@apphosting/common": "*", @@ -25639,7 +25633,6 @@ "@types/fs-extra": "*", "@types/mocha": "*", "@types/tmp": "*", - "glob": "^11.0.1", "mocha": "*", "next": "~14.0.0", "semver": "*", @@ -25658,102 +25651,6 @@ } } }, - "packages/@apphosting/adapter-nextjs/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "packages/@apphosting/adapter-nextjs/node_modules/glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/@apphosting/adapter-nextjs/node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/@apphosting/adapter-nextjs/node_modules/lru-cache": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", - "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", - "dev": true, - "engines": { - "node": "20 || >=22" - } - }, - "packages/@apphosting/adapter-nextjs/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/@apphosting/adapter-nextjs/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "packages/@apphosting/adapter-nextjs/node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "packages/@apphosting/build": { "version": "0.1.0", "license": "Apache-2.0", 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": { From 85cea6e61c0038853ad4a05e685de3fff4ef2f7c Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Sat, 8 Mar 2025 10:17:04 -0500 Subject: [PATCH 21/28] address pr comments --- .../adapter-nextjs/src/overrides.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index ab9bd9ca..b6b506d6 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -6,7 +6,7 @@ import { exists, writeFile, } from "./utils.js"; -import { join } from "path"; +import { join, extname } from "path"; import { rename as renamePromise } from "fs/promises"; /** @@ -23,9 +23,8 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName } // Determine the file extension - const fileExtension = nextConfigFileName.split(".").pop() || "js"; + const fileExtension = extname(nextConfigFileName); const originalConfigName = `next.config.original.${fileExtension}`; - const newConfigName = `next.config.${fileExtension}`; // Rename the original config file try { @@ -34,22 +33,31 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName // Create a new config file with the appropriate import let importStatement; - if (fileExtension === "js" || fileExtension === "cjs") { - importStatement = `const originalConfig = require('./${originalConfigName}');`; - } else if (fileExtension === "mjs") { - importStatement = `import originalConfig from './${originalConfigName}';`; - } else if (fileExtension === "ts") { - importStatement = `import originalConfig from './${originalConfigName.replace(".ts", "")}';`; - } else { - throw new Error(`Unsupported file extension: ${fileExtension}`); + 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, newConfigName), newConfigContent); - console.log(`Successfully created ${newConfigName} with Firebase App Hosting overrides`); + 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; From fff350541b5ca6f94876346263d603a39100cafd Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Sat, 8 Mar 2025 10:29:05 -0500 Subject: [PATCH 22/28] fix errors --- .../@apphosting/adapter-nextjs/src/overrides.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index b6b506d6..4c22e65f 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -24,7 +24,7 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName // Determine the file extension const fileExtension = extname(nextConfigFileName); - const originalConfigName = `next.config.original.${fileExtension}`; + const originalConfigName = `next.config.original${fileExtension}`; // Rename the original config file try { @@ -34,13 +34,13 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName // Create a new config file with the appropriate import let importStatement; switch (fileExtension) { - case "js": + case ".js": importStatement = `const originalConfig = require('./${originalConfigName}');`; break; - case "mjs": + case ".mjs": importStatement = `import originalConfig from './${originalConfigName}';`; break; - case "ts": + case ".ts": importStatement = `import originalConfig from './${originalConfigName.replace( ".ts", "", @@ -48,7 +48,7 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName break; default: throw new Error( - `Unsupported file extension for Next Config: "${fileExtension}", please use "js", "mjs", or "ts"`, + `Unsupported file extension for Next Config: "${fileExtension}", please use ".js", ".mjs", or ".ts"`, ); } @@ -72,7 +72,7 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName * is using a custom image loader. * * @param importStatement The import statement for the original config. - * @param fileExtension The file extension of 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) { @@ -98,7 +98,7 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { } : fahOptimizedConfig(originalConfig); - ${fileExtension === "mjs" ? "export default config;" : "module.exports = config;"} + ${fileExtension === ".mjs" ? "export default config;" : "module.exports = config;"} `; } From a2dc2ebf09691fbcbb915570bbac54e54565fb0e Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Sat, 8 Mar 2025 13:13:16 -0500 Subject: [PATCH 23/28] add validate next config override function --- .../adapter-nextjs/src/bin/build.ts | 21 ++++++++++++++----- .../adapter-nextjs/src/overrides.spec.ts | 10 +++++++++ .../adapter-nextjs/src/overrides.ts | 18 ++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index eb2e525a..d7d155f2 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -8,7 +8,11 @@ import { } from "../utils.js"; import { join } from "path"; import { getBuildOptions, runBuild } from "@apphosting/common"; -import { addRouteOverrides, overrideNextConfig } from "../overrides.js"; +import { + addRouteOverrides, + overrideNextConfig, + validateNextConfigOverrides, +} from "../overrides.js"; const root = process.cwd(); const opts = getBuildOptions(); @@ -21,19 +25,26 @@ if (!process.env.FRAMEWORK_VERSION) { throw new Error("Could not find the nextjs version of the application"); } -const { distDir, configFileName } = await loadConfig(root, opts.projectDirectory); -await overrideNextConfig(root, configFileName); +const originalConfig = await loadConfig(root, opts.projectDirectory); + +await overrideNextConfig(root, originalConfig.configFileName); +await validateNextConfigOverrides(root, opts.projectDirectory); + await runBuild(); const adapterMetadata = getAdapterMetadata(); -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 326d1f2b..96a6c9cf 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -314,6 +314,16 @@ describe("next config overrides", () => { `), ); }); + + 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"); + }); }); // Normalize whitespace for comparison diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index 4c22e65f..ace59438 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -5,9 +5,11 @@ import { loadMiddlewareManifest, exists, writeFile, + loadConfig, } from "./utils.js"; import { join, extname } from "path"; import { rename as renamePromise } from "fs/promises"; +import { NextConfigComplete } from "next/dist/server/config-shared.js"; /** * Overrides the user's Next Config file (next.config.[ts|js|mjs]) to add configs @@ -98,10 +100,26 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { } : fahOptimizedConfig(originalConfig); + const firebaseAppHostingSymbol = Symbol("__createdByFirebaseAppHosting__"); + config[firebaseAppHostingSymbol] = true; + ${fileExtension === ".mjs" ? "export default config;" : "module.exports = config;"} `; } +export async function validateNextConfigOverrides(root: string, projectRoot: string) { + try { + const overriddenConfig = await loadConfig(root, projectRoot); + if (!overriddenConfig.__createdByFirebaseAppHosting__) { + throw new Error("Firebase App Hosting overrides are missing"); + } + } catch (error) { + throw new Error( + `Invalid Next.js config: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + /** * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting * specific overrides (i.e headers). From bd62abe9627acf93af7202e35ca6eda531d68979 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Sat, 8 Mar 2025 14:01:54 -0500 Subject: [PATCH 24/28] just loadconfig again instead of checking anything else --- packages/@apphosting/adapter-nextjs/src/overrides.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index ace59438..b7da8424 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -100,19 +100,13 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { } : fahOptimizedConfig(originalConfig); - const firebaseAppHostingSymbol = Symbol("__createdByFirebaseAppHosting__"); - config[firebaseAppHostingSymbol] = true; - ${fileExtension === ".mjs" ? "export default config;" : "module.exports = config;"} `; } export async function validateNextConfigOverrides(root: string, projectRoot: string) { try { - const overriddenConfig = await loadConfig(root, projectRoot); - if (!overriddenConfig.__createdByFirebaseAppHosting__) { - throw new Error("Firebase App Hosting overrides are missing"); - } + await loadConfig(root, projectRoot); } catch (error) { throw new Error( `Invalid Next.js config: ${error instanceof Error ? error.message : "Unknown error"}`, From 726342e8681a01cf0972b2c63297d50547121222 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Mon, 10 Mar 2025 16:35:34 +0000 Subject: [PATCH 25/28] fix breaking tests --- packages/@apphosting/adapter-nextjs/e2e/app.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts index a95650f6..a50ce219 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts @@ -19,14 +19,14 @@ describe("app", () => { const response = await fetch(host); assert.ok(response.ok); assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8"); - assert.equal(response.headers.get("cache-control"), "s-maxage=31536000, "); + assert.equal(response.headers.get("cache-control"), "s-maxage=31536000,"); }); it("/ssg", async () => { const response = await fetch(posix.join(host, "ssg")); assert.ok(response.ok); assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8"); - assert.equal(response.headers.get("cache-control"), "s-maxage=31536000, "); + assert.equal(response.headers.get("cache-control"), "s-maxage=31536000,"); const text = await response.text(); assert.ok(text.includes("SSG")); assert.ok(text.includes("Generated")); @@ -81,7 +81,7 @@ describe("app", () => { const response = await fetch(posix.join(host, "isr", "demand")); assert.ok(response.ok); assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8"); - assert.equal(response.headers.get("cache-control"), "s-maxage=31536000, "); + assert.equal(response.headers.get("cache-control"), "s-maxage=31536000,"); const text = await response.text(); assert.ok(text.includes("A cached page")); assert.ok(text.includes("Generated")); From d7ce5da2d01a4c06cd7182c03686b9f3af95851b Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Mon, 10 Mar 2025 16:35:57 +0000 Subject: [PATCH 26/28] add validation that override worked --- .../adapter-nextjs/src/bin/build.ts | 25 +++++++---- .../adapter-nextjs/src/overrides.ts | 41 ++++++++++++++++--- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index d7d155f2..eedb6bbb 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -5,14 +5,11 @@ import { generateBuildOutput, validateOutputDirectory, getAdapterMetadata, + exists, } from "../utils.js"; import { join } from "path"; import { getBuildOptions, runBuild } from "@apphosting/common"; -import { - addRouteOverrides, - overrideNextConfig, - validateNextConfigOverrides, -} from "../overrides.js"; +import { addRouteOverrides, overrideNextConfig, validateNextConfigOverride } from "../overrides.js"; const root = process.cwd(); const opts = getBuildOptions(); @@ -27,8 +24,22 @@ if (!process.env.FRAMEWORK_VERSION) { const originalConfig = await loadConfig(root, opts.projectDirectory); -await overrideNextConfig(root, originalConfig.configFileName); -await validateNextConfigOverrides(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(); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index b7da8424..d680e319 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -9,13 +9,13 @@ import { } from "./utils.js"; import { join, extname } from "path"; import { rename as renamePromise } from "fs/promises"; -import { NextConfigComplete } from "next/dist/server/config-shared.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); @@ -87,13 +87,13 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { ...config, images: { ...(config.images || {}), - ...(config.images?.unoptimized === undefined && config.images?.loader === undefined - ? { unoptimized: true } + ...(config.images?.unoptimized === undefined && config.images?.loader === undefined + ? { unoptimized: true } : {}), }, }); - const config = typeof originalConfig === 'function' + const config = typeof originalConfig === 'function' ? async (...args) => { const resolvedConfig = await originalConfig(...args); return fahOptimizedConfig(resolvedConfig); @@ -104,12 +104,41 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { `; } -export async function validateNextConfigOverrides(root: string, projectRoot: string) { +/** + * 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( - `Invalid Next.js config: ${error instanceof Error ? error.message : "Unknown error"}`, + `Resulting Next Config is invalid: ${ + error instanceof Error ? error.message : "Unknown error" + }`, ); } } From ec84af397ccada5b88b192d50a12c0c212e7e3f9 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Mon, 10 Mar 2025 16:55:06 +0000 Subject: [PATCH 27/28] fix tests again --- packages/@apphosting/adapter-nextjs/e2e/app.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts index a50ce219..a95650f6 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts @@ -19,14 +19,14 @@ describe("app", () => { const response = await fetch(host); assert.ok(response.ok); assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8"); - assert.equal(response.headers.get("cache-control"), "s-maxage=31536000,"); + assert.equal(response.headers.get("cache-control"), "s-maxage=31536000, "); }); it("/ssg", async () => { const response = await fetch(posix.join(host, "ssg")); assert.ok(response.ok); assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8"); - assert.equal(response.headers.get("cache-control"), "s-maxage=31536000,"); + assert.equal(response.headers.get("cache-control"), "s-maxage=31536000, "); const text = await response.text(); assert.ok(text.includes("SSG")); assert.ok(text.includes("Generated")); @@ -81,7 +81,7 @@ describe("app", () => { const response = await fetch(posix.join(host, "isr", "demand")); assert.ok(response.ok); assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8"); - assert.equal(response.headers.get("cache-control"), "s-maxage=31536000,"); + assert.equal(response.headers.get("cache-control"), "s-maxage=31536000, "); const text = await response.text(); assert.ok(text.includes("A cached page")); assert.ok(text.includes("Generated")); From 705535081a39c9c94715731e1f89c73b095fae83 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Mon, 10 Mar 2025 14:40:49 -0400 Subject: [PATCH 28/28] add overrides validation unit tests --- .../adapter-nextjs/src/overrides.spec.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index 96a6c9cf..3d478b18 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -326,6 +326,54 @@ describe("next config overrides", () => { }); }); +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();