diff --git a/package-lock.json b/package-lock.json index 5a5e04d8..18c0844c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25520,7 +25520,7 @@ } }, "packages/@apphosting/adapter-angular": { - "version": "17.2.11", + "version": "17.2.12", "license": "Apache-2.0", "dependencies": { "@apphosting/common": "*", @@ -25618,7 +25618,7 @@ } }, "packages/@apphosting/adapter-nextjs": { - "version": "14.0.9", + "version": "14.0.10", "license": "Apache-2.0", "dependencies": { "@apphosting/common": "*", diff --git a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts index 32647fba..a95650f6 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/app.spec.ts @@ -1,12 +1,19 @@ import * as assert from "assert"; import { posix } from "path"; +import fsExtra from "fs-extra"; export const host = process.env.HOST; - if (!host) { throw new Error("HOST environment variable expected"); } +let adapterVersion: string; +before(() => { + const packageJson = fsExtra.readJSONSync("package.json"); + adapterVersion = packageJson.version; + if (!adapterVersion) throw new Error("couldn't parse package.json version"); +}); + describe("app", () => { it("/", async () => { const response = await fetch(host); @@ -114,4 +121,30 @@ describe("app", () => { "private, no-cache, no-store, max-age=0, must-revalidate", ); }); + + it("should have x-fah-adapter header and no x-fah-middleware header on all routes", async () => { + const routes = [ + "/", + "/ssg", + "/ssr", + "/ssr/streaming", + "/isr/time", + "/isr/demand", + "/nonexistent-route", + ]; + + for (const route of routes) { + const response = await fetch(posix.join(host, route)); + assert.equal( + response.headers.get("x-fah-adapter"), + `nextjs-${adapterVersion}`, + `Route ${route} missing x-fah-adapter header`, + ); + assert.equal( + response.headers.get("x-fah-middleware"), + null, + `Route ${route} should not have x-fah-middleware header`, + ); + } + }); }); diff --git a/packages/@apphosting/adapter-nextjs/e2e/middleware.spec.ts b/packages/@apphosting/adapter-nextjs/e2e/middleware.spec.ts new file mode 100644 index 00000000..74477f53 --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/e2e/middleware.spec.ts @@ -0,0 +1,43 @@ +import * as assert from "assert"; +import { posix } from "path"; +import fsExtra from "fs-extra"; + +export const host = process.env.HOST; +if (!host) { + throw new Error("HOST environment variable expected"); +} + +let adapterVersion: string; +before(() => { + const packageJson = fsExtra.readJSONSync("package.json"); + adapterVersion = packageJson.version; + if (!adapterVersion) throw new Error("couldn't parse package.json version"); +}); + +describe("middleware", () => { + it("should have x-fah-adapter header and x-fah-middleware header on all routes", async () => { + const routes = [ + "/", + "/ssg", + "/ssr", + "/ssr/streaming", + "/isr/time", + "/isr/demand", + "/nonexistent-route", + ]; + + for (const route of routes) { + const response = await fetch(posix.join(host, route)); + assert.equal( + response.headers.get("x-fah-adapter"), + `nextjs-${adapterVersion}`, + `Route ${route} missing x-fah-adapter header`, + ); + assert.equal( + response.headers.get("x-fah-middleware"), + "true", + `Route ${route} missing x-fah-middleware header`, + ); + } + }); +}); diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index 2b4ae74b..abc44bd4 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -12,100 +12,176 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const starterTemplateDir = "../../../starters/nextjs/basic"; +// Define scenarios to test +interface Scenario { + name: string; // Name of the scenario + setup?: (cwd: string) => Promise; // Optional setup function before building the app + tests?: string[]; // List of test files to run +} + +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 + }, +]; + const errors: any[] = []; await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined); -console.log("\nBuilding and starting test project..."); - -const runId = Math.random().toString().split(".")[1]; -const cwd = join(__dirname, "runs", runId); -await mkdirp(cwd); - -console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`); -await cp(starterTemplateDir, cwd, { recursive: true }); - -console.log(`[${runId}] > npm ci --silent --no-progress`); -await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], { - cwd, - stdio: "inherit", - shell: true, -}); - -const buildScript = relative(cwd, join(__dirname, "../dist/bin/build.js")); -console.log(`[${runId}] > node ${buildScript}`); - -const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")); -const frameworkVersion = packageJson.dependencies.next.replace("^", ""); -await promiseSpawn("node", [buildScript], { - cwd, - stdio: "inherit", - shell: true, - env: { - ...process.env, - FRAMEWORK_VERSION: frameworkVersion, - }, -}); - -const bundleYaml = parseYaml(readFileSync(join(cwd, ".apphosting/bundle.yaml")).toString()); +// Run each scenario +for (const scenario of scenarios) { + console.log(`\n\nRunning scenario: ${scenario.name}`); -const runCommand = bundleYaml.runConfig.runCommand; + const runId = `${scenario.name}-${Math.random().toString().split(".")[1]}`; + const cwd = join(__dirname, "runs", runId); + await mkdirp(cwd); -if (typeof runCommand !== "string") { - throw new Error("runCommand must be a string"); -} + console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`); + await cp(starterTemplateDir, cwd, { recursive: true }); -const [runScript, ...runArgs] = runCommand.split(" "); -let resolveHostname: (it: string) => void; -let rejectHostname: () => void; -const hostnamePromise = new Promise((resolve, reject) => { - resolveHostname = resolve; - rejectHostname = reject; -}); -const port = 8080 + Math.floor(Math.random() * 1000); -console.log(`[${runId}] > PORT=${port} ${runCommand}`); -const run = spawn(runScript, runArgs, { - cwd, - shell: true, - env: { - NODE_ENV: "production", - PORT: port.toString(), - PATH: process.env.PATH, - }, -}); -run.stderr.on("data", (data) => console.error(data.toString())); -run.stdout.on("data", (data) => { - console.log(data.toString()); - // Check for the "Ready in" message to determine when the server is fully started - if (data.toString().includes(`Ready in`)) { - // We use 0.0.0.0 instead of localhost to avoid issues when ipv6 is not available (Node 18) - resolveHostname(`http://0.0.0.0:${port}`); - } -}); -run.on("close", (code) => { - if (code) { - rejectHostname(); + // Run scenario-specific setup if provided + if (scenario.setup) { + console.log(`[${runId}] Running setup for ${scenario.name}`); + await scenario.setup(cwd); } -}); -const host = await hostnamePromise; -console.log("\n\n"); - -try { - console.log(`> HOST=${host} ts-mocha -p tsconfig.json e2e/*.spec.ts`); - await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", "e2e/*.spec.ts"], { - shell: true, + console.log(`[${runId}] > npm ci --silent --no-progress`); + await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], { + cwd, stdio: "inherit", - env: { - ...process.env, - HOST: host, - }, - }).finally(() => { - run.stdin.end(); - run.kill("SIGKILL"); + shell: true, }); -} catch (e) { - errors.push(e); + + const buildScript = relative(cwd, join(__dirname, "../dist/bin/build.js")); + const buildLogPath = join(cwd, "build.log"); + console.log(`[${runId}] > node ${buildScript} (output written to ${buildLogPath})`); + + const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")); + const frameworkVersion = packageJson.dependencies.next.replace("^", ""); + + try { + await promiseSpawn("node", [buildScript], { + cwd, + stdioString: true, + stdio: "pipe", + shell: true, + env: { + ...process.env, + FRAMEWORK_VERSION: frameworkVersion, + }, + }).then((result) => { + // Write stdout and stderr to the log file + fsExtra.writeFileSync(buildLogPath, result.stdout + result.stderr); + }); + + const bundleYaml = parseYaml(readFileSync(join(cwd, ".apphosting/bundle.yaml")).toString()); + + const runCommand = bundleYaml.runConfig.runCommand; + + if (typeof runCommand !== "string") { + throw new Error("runCommand must be a string"); + } + + const [runScript, ...runArgs] = runCommand.split(" "); + let resolveHostname: (it: string) => void; + let rejectHostname: () => void; + const hostnamePromise = new Promise((resolve, reject) => { + resolveHostname = resolve; + rejectHostname = reject; + }); + const port = 8080 + Math.floor(Math.random() * 1000); + const runLogPath = join(cwd, "run.log"); + console.log(`[${runId}] > PORT=${port} ${runCommand} (output written to ${runLogPath})`); + const runLogStream = fsExtra.createWriteStream(runLogPath); + + const run = spawn(runScript, runArgs, { + cwd, + shell: true, + env: { + NODE_ENV: "production", + PORT: port.toString(), + PATH: process.env.PATH, + }, + }); + + run.stderr.on("data", (data) => { + const output = data.toString(); + runLogStream.write(output); + }); + + run.stdout.on("data", (data) => { + const output = data.toString(); + runLogStream.write(output); + // Check for the "Ready in" message to determine when the server is fully started + if (output.includes(`Ready in`)) { + // We use 0.0.0.0 instead of localhost to avoid issues when ipv6 is not available (Node 18) + resolveHostname(`http://0.0.0.0:${port}`); + } + }); + + run.on("close", (code) => { + runLogStream.end(); + if (code) { + rejectHostname(); + } + }); + const host = await hostnamePromise; + + console.log("\n\n"); + + try { + // Determine which test files to run + const testPattern = scenario.tests + ? scenario.tests.map((test) => `e2e/${test}`).join(" ") + : "e2e/*.spec.ts"; + + console.log( + `> HOST=${host} SCENARIO=${scenario.name} ts-mocha -p tsconfig.json ${testPattern}`, + ); + await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", ...testPattern.split(" ")], { + shell: true, + stdio: "inherit", + env: { + ...process.env, + HOST: host, + SCENARIO: scenario.name, + }, + }).finally(() => { + run.stdin.end(); + run.kill("SIGKILL"); + }); + } catch (e) { + errors.push(e); + } + } catch (e) { + console.error(`Error in scenario ${scenario.name}:`, e); + errors.push(e); + } } if (errors.length) { diff --git a/packages/@apphosting/adapter-nextjs/package.json b/packages/@apphosting/adapter-nextjs/package.json index e57b5cc7..d7c0246d 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.9", + "version": "14.0.10", "main": "dist/index.js", "description": "Experimental addon to the Firebase CLI to add web framework support", "repository": { @@ -23,7 +23,7 @@ "scripts": { "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", + "test:unit": "ts-mocha -p tsconfig.json 'src/**/*.spec.ts' 'src/*.spec.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" diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts index 89f44ed8..bc969a8e 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts @@ -4,12 +4,14 @@ import fs from "fs"; import yaml from "yaml"; import path from "path"; import os from "os"; -import { OutputBundleOptions } from "../interfaces.js"; +import { OutputBundleOptions, AdapterMetadata } from "../interfaces.js"; describe("build commands", () => { let tmpDir: string; let outputBundleOptions: OutputBundleOptions; let defaultNextVersion: string; + let adapterMetadata: AdapterMetadata; + beforeEach(() => { tmpDir = generateTmpDir(); outputBundleOptions = { @@ -21,20 +23,23 @@ describe("build commands", () => { serverFilePath: path.join(tmpDir, ".next", "standalone", "server.js"), }; defaultNextVersion = "14.0.3"; + adapterMetadata = { + adapterPackageName: "@apphosting/adapter-nextjs", + adapterVersion: "14.0.1", + }; }); it("expects all output bundle files to be generated", async () => { - const { generateBuildOutput, validateOutputDirectory, createMetadata } = await importUtils; + const { generateBuildOutput, validateOutputDirectory } = await importUtils; const files = { ".next/standalone/server.js": "", ".next/static/staticfile": "", ".next/routes-manifest.json": `{ - "headers":[], - "rewrites":[], + "headers":[], + "rewrites":[], "redirects":[] }`, }; - const packageVersion = createMetadata(defaultNextVersion).adapterVersion; generateTestFiles(tmpDir, files); await generateBuildOutput( tmpDir, @@ -42,6 +47,7 @@ describe("build commands", () => { outputBundleOptions, path.join(tmpDir, ".next"), defaultNextVersion, + adapterMetadata, ); await validateOutputDirectory(outputBundleOptions, path.join(tmpDir, ".next")); @@ -53,7 +59,7 @@ runConfig: runCommand: node .next/standalone/server.js metadata: adapterPackageName: "@apphosting/adapter-nextjs" - adapterVersion: ${packageVersion} + adapterVersion: ${adapterMetadata.adapterVersion} framework: nextjs frameworkVersion: ${defaultNextVersion} `, @@ -97,6 +103,7 @@ metadata: }, path.join(tmpDir, ".next"), defaultNextVersion, + adapterMetadata, ); const expectedFiles = { @@ -117,8 +124,8 @@ metadata: ".next/standalone/notserver.js": "", ".next/static/staticfile": "", ".next/routes-manifest.json": `{ - "headers":[{"source":"source", "headers":["header1"]}], - "rewrites":[{"source":"source", "destination":"destination"}], + "headers":[{"source":"source", "headers":["header1"]}], + "rewrites":[{"source":"source", "destination":"destination"}], "redirects":[{"source":"source", "destination":"destination"}] }`, }; @@ -129,6 +136,10 @@ metadata: outputBundleOptions, path.join(tmpDir, ".next"), defaultNextVersion, + { + adapterPackageName: "@apphosting/adapter-nextjs", + adapterVersion: "14.0.1", + }, ); assert.rejects( async () => await validateOutputDirectory(outputBundleOptions, path.join(tmpDir, ".next")), @@ -142,8 +153,8 @@ metadata: "public/publicfile": "", extrafile: "", ".next/routes-manifest.json": `{ - "headers":[], - "rewrites":[], + "headers":[], + "rewrites":[], "redirects":[] }`, }; @@ -154,6 +165,10 @@ metadata: outputBundleOptions, path.join(tmpDir, ".next"), defaultNextVersion, + { + adapterPackageName: "@apphosting/adapter-nextjs", + adapterVersion: "14.0.1", + }, ); await validateOutputDirectory(outputBundleOptions, path.join(tmpDir, ".next")); @@ -180,6 +195,7 @@ metadata: expectedOutputBundleOptions, ); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index 5d4e9f1b..a2241174 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -4,9 +4,11 @@ import { populateOutputBundleOptions, generateBuildOutput, validateOutputDirectory, + getAdapterMetadata, } from "../utils.js"; import { join } from "path"; import { getBuildOptions, runBuild } from "@apphosting/common"; +import { addRouteOverrides } from "../overrides.js"; const root = process.cwd(); const opts = getBuildOptions(); @@ -20,20 +22,24 @@ if (!process.env.FRAMEWORK_VERSION) { } await runBuild(); +const adapterMetadata = getAdapterMetadata(); + const { distDir } = await loadConfig(root, opts.projectDirectory); const nextBuildDirectory = join(opts.projectDirectory, distDir); - const outputBundleOptions = populateOutputBundleOptions( root, opts.projectDirectory, nextBuildDirectory, ); +await addRouteOverrides(outputBundleOptions.outputDirectoryAppPath, distDir, adapterMetadata); + await generateBuildOutput( root, opts.projectDirectory, outputBundleOptions, nextBuildDirectory, process.env.FRAMEWORK_VERSION, + adapterMetadata, ); await validateOutputDirectory(outputBundleOptions, nextBuildDirectory); diff --git a/packages/@apphosting/adapter-nextjs/src/constants.ts b/packages/@apphosting/adapter-nextjs/src/constants.ts index dfd282ca..79a16933 100644 --- a/packages/@apphosting/adapter-nextjs/src/constants.ts +++ b/packages/@apphosting/adapter-nextjs/src/constants.ts @@ -1,9 +1,11 @@ import type { PHASE_PRODUCTION_BUILD as NEXT_PHASE_PRODUCTION_BUILD, ROUTES_MANIFEST as NEXT_ROUTES_MANIFEST, + MIDDLEWARE_MANIFEST as NEXT_MIDDLEWARE_MANIFEST, } from "next/constants.js"; // export next/constants ourselves so we don't have to dynamically import them // also this gives us a better heads up if the NextJS API changes export const PHASE_PRODUCTION_BUILD: typeof NEXT_PHASE_PRODUCTION_BUILD = "phase-production-build"; export const ROUTES_MANIFEST: typeof NEXT_ROUTES_MANIFEST = "routes-manifest.json"; +export const MIDDLEWARE_MANIFEST: typeof NEXT_MIDDLEWARE_MANIFEST = "middleware-manifest.json"; diff --git a/packages/@apphosting/adapter-nextjs/src/interfaces.ts b/packages/@apphosting/adapter-nextjs/src/interfaces.ts index 62eaf6cc..1a197f1d 100644 --- a/packages/@apphosting/adapter-nextjs/src/interfaces.ts +++ b/packages/@apphosting/adapter-nextjs/src/interfaces.ts @@ -1,4 +1,6 @@ import type { RouteHas } from "next/dist/lib/load-custom-routes.js"; +import type { AssetBinding } from "next/dist/build/webpack/loaders/get-module-build-info.js"; +import type { MiddlewareMatcher } from "next/dist/build//analysis/get-page-static-info.js"; export interface RoutesManifestRewriteObject { beforeFiles?: RoutesManifestRewrite[]; @@ -71,7 +73,6 @@ export interface RoutesManifest { localeDetection?: false; }; } - // The output bundle options are specified here export interface OutputBundleOptions { /** @@ -102,10 +103,40 @@ export interface OutputBundleOptions { outputStaticDirectoryPath: string; } -// Metadata schema for bundle.yaml outputted by next.js adapter -export interface Metadata { +// Metadata schema for adapter metadata +export interface AdapterMetadata { adapterPackageName: string; adapterVersion: string; +} + +// Metadata schema for bundle.yaml outputted by next.js adapter +export interface Metadata extends AdapterMetadata { framework: string; frameworkVersion: string; } + +/* + Next.js exposed internal interface for middleware manifest (middleware-manifest.json) + https://github.com/vercel/next.js/blob/v15.2.0-canary.76/packages/next/src/build/webpack/plugins/middleware-plugin.ts#L54 +*/ +export interface MiddlewareManifest { + version: 3; + sortedMiddleware: string[]; + middleware: { + [page: string]: EdgeFunctionDefinition; + }; + functions: { + [page: string]: EdgeFunctionDefinition; + }; +} + +// Next.js exposed internal interface for edge function definitions +interface EdgeFunctionDefinition { + files: string[]; + name: string; + page: string; + matchers: MiddlewareMatcher[]; + wasm?: AssetBinding[]; + assets?: AssetBinding[]; + regions?: string[] | string; +} diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts new file mode 100644 index 00000000..b608717c --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -0,0 +1,165 @@ +import assert from "assert"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { RoutesManifest, MiddlewareManifest } from "./interfaces.js"; +const importOverrides = import("@apphosting/adapter-nextjs/dist/overrides.js"); + +describe("route overrides", () => { + let tmpDir: string; + let routesManifestPath: string; + let middlewareManifestPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-manifests-")); + routesManifestPath = path.join(tmpDir, ".next", "routes-manifest.json"); + middlewareManifestPath = path.join(tmpDir, ".next", "server", "middleware-manifest.json"); + + fs.mkdirSync(path.dirname(routesManifestPath), { recursive: true }); + fs.mkdirSync(path.dirname(middlewareManifestPath), { recursive: true }); + }); + + it("should add default fah headers to routes manifest", async () => { + const { addRouteOverrides } = await importOverrides; + const initialManifest: RoutesManifest = { + version: 3, + basePath: "", + pages404: true, + staticRoutes: [], + dynamicRoutes: [], + dataRoutes: [], + headers: [ + { + source: "/existing", + headers: [{ key: "X-Custom", value: "test" }], + regex: "^/existing$", + }, + ], + rewrites: [], + redirects: [], + }; + + fs.writeFileSync(routesManifestPath, JSON.stringify(initialManifest)); + fs.writeFileSync( + middlewareManifestPath, + JSON.stringify({ version: 1, sortedMiddleware: [], middleware: {}, functions: {} }), + ); + + await addRouteOverrides(tmpDir, ".next", { + adapterPackageName: "@apphosting/adapter-nextjs", + adapterVersion: "1.0.0", + }); + + const updatedManifest = JSON.parse( + fs.readFileSync(routesManifestPath, "utf-8"), + ) as RoutesManifest; + + const expectedManifest: RoutesManifest = { + version: 3, + basePath: "", + pages404: true, + staticRoutes: [], + dynamicRoutes: [], + dataRoutes: [], + redirects: [], + rewrites: [], + headers: [ + { + source: "/existing", + headers: [{ key: "X-Custom", value: "test" }], + regex: "^/existing$", + }, + { + source: "/:path*", + regex: "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$", + headers: [ + { + key: "x-fah-adapter", + value: "nextjs-1.0.0", + }, + ], + }, + ], + }; + + assert.deepStrictEqual(updatedManifest, expectedManifest); + }); + + it("should add middleware header when middleware exists", async () => { + const { addRouteOverrides } = await importOverrides; + const initialManifest: RoutesManifest = { + version: 3, + basePath: "", + pages404: true, + staticRoutes: [], + dynamicRoutes: [], + dataRoutes: [], + headers: [], + rewrites: [], + redirects: [], + }; + + const middlewareManifest: MiddlewareManifest = { + version: 3, + sortedMiddleware: ["/"], + middleware: { + "/": { + files: ["middleware.ts"], + name: "middleware", + page: "/", + matchers: [ + { + regexp: "^/.*$", + originalSource: "/:path*", + }, + ], + }, + }, + functions: {}, + }; + + fs.writeFileSync(routesManifestPath, JSON.stringify(initialManifest)); + fs.writeFileSync(middlewareManifestPath, JSON.stringify(middlewareManifest)); + + await addRouteOverrides(tmpDir, ".next", { + adapterPackageName: "@apphosting/adapter-nextjs", + adapterVersion: "1.0.0", + }); + + const updatedManifest = JSON.parse( + fs.readFileSync(routesManifestPath, "utf-8"), + ) as RoutesManifest; + + assert.strictEqual(updatedManifest.headers.length, 1); + + const expectedManifest: RoutesManifest = { + version: 3, + basePath: "", + pages404: true, + staticRoutes: [], + dynamicRoutes: [], + dataRoutes: [], + rewrites: [], + redirects: [], + headers: [ + { + source: "/:path*", + regex: "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$", + headers: [ + { + key: "x-fah-adapter", + value: "nextjs-1.0.0", + }, + { key: "x-fah-middleware", value: "true" }, + ], + }, + ], + }; + + assert.deepStrictEqual(updatedManifest, expectedManifest); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts new file mode 100644 index 00000000..d4672638 --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -0,0 +1,55 @@ +import { AdapterMetadata, MiddlewareManifest } from "./interfaces.js"; +import { loadRouteManifest, writeRouteManifest, loadMiddlewareManifest } from "./utils.js"; + +/** + * Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting + * specific overrides (i.e headers). + * + * This function adds the following headers to all routes: + * - x-fah-adapter: The Firebase App Hosting adapter version used to build the app. + * - x-fah-middleware: When middleware is enabled. + * + * @param appPath The path to the app directory. + * @param distDir The path to the dist directory. + * @param adapterMetadata The adapter metadata. + */ +export async function addRouteOverrides( + appPath: string, + distDir: string, + adapterMetadata: AdapterMetadata, +) { + const middlewareManifest = loadMiddlewareManifest(appPath, distDir); + const routeManifest = loadRouteManifest(appPath, distDir); + routeManifest.headers.push({ + source: "/:path*", + headers: [ + { + key: "x-fah-adapter", + value: `nextjs-${adapterMetadata.adapterVersion}`, + }, + ...(middlewareExists(middlewareManifest) + ? [ + { + key: "x-fah-middleware", + value: "true", + }, + ] + : []), + ], + /* + 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. + + This regex was generated by building a sample NextJs app with the source string `/:path*` and then inspecting the + routes-manifest.json file. + */ + regex: "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$", + }); + + await writeRouteManifest(appPath, distDir, routeManifest); +} + +function middlewareExists(middlewareManifest: MiddlewareManifest) { + return Object.keys(middlewareManifest.middleware).length > 0; +} diff --git a/packages/@apphosting/adapter-nextjs/src/utils.spec.ts b/packages/@apphosting/adapter-nextjs/src/utils.spec.ts new file mode 100644 index 00000000..5ae25a08 --- /dev/null +++ b/packages/@apphosting/adapter-nextjs/src/utils.spec.ts @@ -0,0 +1,121 @@ +const importUtils = import("@apphosting/adapter-nextjs/dist/utils.js"); +import { describe, it, beforeEach, afterEach } from "mocha"; +import assert from "assert"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { RoutesManifest, MiddlewareManifest } from "../src/interfaces.js"; + +describe("manifest utils", () => { + let tmpDir: string; + let distDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-manifests-")); + distDir = ".next"; + }); + + it("should load routes manifest", async () => { + const mockRoutesManifest: RoutesManifest = { + version: 3, + basePath: "", + pages404: true, + staticRoutes: [], + dynamicRoutes: [], + dataRoutes: [], + headers: [], + rewrites: [], + redirects: [], + }; + + const manifestPath = path.join(tmpDir, distDir, "routes-manifest.json"); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify(mockRoutesManifest)); + + const { loadRouteManifest } = await importUtils; + const result = loadRouteManifest(tmpDir, distDir); + + assert.deepStrictEqual(result, mockRoutesManifest); + }); + + it("should load middleware manifest", async () => { + const mockMiddleware: MiddlewareManifest = { + version: 1, + sortedMiddleware: ["/"], + functions: {}, + middleware: { + "/": { + files: ["middleware.js"], + name: "middleware", + page: "/", + matchers: [ + { + regexp: "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/api\\/([^/.]+)(?:\\/(.*))?", + originalSource: "/api/*", + }, + ], + }, + }, + }; + + const manifestPath = path.join(tmpDir, distDir, "server/middleware-manifest.json"); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify(mockMiddleware)); + + const { loadMiddlewareManifest } = await importUtils; + const result = loadMiddlewareManifest(tmpDir, distDir); + + assert.deepStrictEqual(result, mockMiddleware); + }); + + it("should write route manifest", async () => { + const mockManifest: RoutesManifest = { + version: 3, + basePath: "", + pages404: true, + staticRoutes: [], + dynamicRoutes: [], + dataRoutes: [], + headers: [ + { + source: "/api/*", + headers: [{ key: "X-Custom", value: "value" }], + regex: "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/api\\/([^/.]+)(?:\\/(.*))?", + }, + ], + rewrites: [], + redirects: [], + }; + + const manifestDir = path.join(tmpDir, distDir); + fs.mkdirSync(manifestDir, { recursive: true }); + + const { writeRouteManifest } = await importUtils; + await writeRouteManifest(tmpDir, distDir, mockManifest); + + const manifestPath = path.join(tmpDir, distDir, "routes-manifest.json"); + const written = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + + assert.deepStrictEqual(written, mockManifest); + }); + + it("should throw when loading non-existent route manifest", async () => { + const { loadRouteManifest } = await importUtils; + + assert.throws(() => { + loadRouteManifest(tmpDir, distDir); + }); + }); + + it("should throw when loading non-existent middleware manifest", async () => { + const { loadMiddlewareManifest } = await importUtils; + + assert.throws(() => { + loadMiddlewareManifest(tmpDir, distDir); + }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index 08f8a946..fd7f8784 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -4,10 +4,12 @@ import { join, dirname, relative, normalize } from "path"; import { fileURLToPath } from "url"; import { stringify as yamlStringify } from "yaml"; -import { PHASE_PRODUCTION_BUILD } from "./constants.js"; -import { OutputBundleOptions } from "./interfaces.js"; +import { PHASE_PRODUCTION_BUILD, ROUTES_MANIFEST } from "./constants.js"; +import { OutputBundleOptions, RoutesManifest } from "./interfaces.js"; import { NextConfigComplete } from "next/dist/server/config-shared.js"; -import { OutputBundleConfig, Metadata } from "@apphosting/common"; +import { OutputBundleConfig } from "@apphosting/common"; +import { AdapterMetadata, MiddlewareManifest } from "./interfaces.js"; +import { MIDDLEWARE_MANIFEST } from "next/constants.js"; // fs-extra is CJS, readJson can't be imported using shorthand export const { move, exists, writeFile, readJson, readdir, readFileSync, existsSync, mkdir } = @@ -33,6 +35,48 @@ export async function loadConfig(root: string, projectRoot: string): Promise { + const manifestPath = join(standalonePath, distDir, ROUTES_MANIFEST); + await writeFile(manifestPath, JSON.stringify(customManifest)); +} + export const isMain = (meta: ImportMeta): boolean => { if (!meta) return false; if (!process.argv[1]) return false; @@ -80,12 +124,13 @@ export async function generateBuildOutput( opts: OutputBundleOptions, nextBuildDirectory: string, nextVersion: string, + adapterMetadata: AdapterMetadata, ): Promise { const staticDirectory = join(nextBuildDirectory, "static"); await Promise.all([ move(staticDirectory, opts.outputStaticDirectoryPath, { overwrite: true }), moveResources(appDir, opts.outputDirectoryAppPath, opts.bundleYamlPath), - generateBundleYaml(opts, rootDir, nextVersion), + generateBundleYaml(opts, rootDir, nextVersion, adapterMetadata), ]); return; } @@ -110,21 +155,17 @@ async function moveResources( return; } -/** - * Create metadata needed for outputting adapter and framework metrics in bundle.yaml. - */ -export function createMetadata(nextVersion: string): Metadata { +export function getAdapterMetadata(): AdapterMetadata { const directoryName = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = `${directoryName}/../package.json`; if (!existsSync(packageJsonPath)) { throw new Error(`Next.js adapter package.json file does not exist at ${packageJsonPath}`); } const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + return { adapterPackageName: packageJson.name, adapterVersion: packageJson.version, - framework: "nextjs", - frameworkVersion: nextVersion, }; } @@ -133,6 +174,7 @@ async function generateBundleYaml( opts: OutputBundleOptions, cwd: string, nextVersion: string, + adapterMetadata: AdapterMetadata, ): Promise { await mkdir(opts.outputDirectoryBasePath); const outputBundle: OutputBundleConfig = { @@ -140,7 +182,11 @@ async function generateBundleYaml( runConfig: { runCommand: `node ${normalize(relative(cwd, opts.serverFilePath))}`, }, - metadata: createMetadata(nextVersion), + metadata: { + ...adapterMetadata, + framework: "nextjs", + frameworkVersion: nextVersion, + }, }; await writeFile(opts.bundleYamlPath, yamlStringify(outputBundle)); return;