Skip to content

Commit aa0c4ed

Browse files
authored
Custom App Hosting Headers for NextJS (#304)
* progress * add an overrides file to explicitely override nextjs defaults for app hosting * move adapterMetadata to common func * check for middleware usage and add header * look at middleware manifest to check if middleware exists * fix path * fix test * fix lint error * bump nextjs adapter * fix next app version test * update package-lock * update patch version instead of minor * replace .eslintrc.json with eslint.config.mjs (preferred for next 15) * move from using .eslintrc.json to eslint.config.mjs (preferred for next 15) * fix e2e test * attempt2 * fix local test * add unit tests and e2e tests to test route overrides * add jsdocs * add jsdoc for interfaces * address comments * refactor e2e scaffolding to be able to setup nextjs apps to test different scenarios * fix e2e tests failing for older node versions
1 parent e5d1b88 commit aa0c4ed

File tree

13 files changed

+707
-113
lines changed

13 files changed

+707
-113
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@apphosting/adapter-nextjs/e2e/app.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import * as assert from "assert";
22
import { posix } from "path";
3+
import fsExtra from "fs-extra";
34

45
export const host = process.env.HOST;
5-
66
if (!host) {
77
throw new Error("HOST environment variable expected");
88
}
99

10+
let adapterVersion: string;
11+
before(() => {
12+
const packageJson = fsExtra.readJSONSync("package.json");
13+
adapterVersion = packageJson.version;
14+
if (!adapterVersion) throw new Error("couldn't parse package.json version");
15+
});
16+
1017
describe("app", () => {
1118
it("/", async () => {
1219
const response = await fetch(host);
@@ -114,4 +121,30 @@ describe("app", () => {
114121
"private, no-cache, no-store, max-age=0, must-revalidate",
115122
);
116123
});
124+
125+
it("should have x-fah-adapter header and no x-fah-middleware header on all routes", async () => {
126+
const routes = [
127+
"/",
128+
"/ssg",
129+
"/ssr",
130+
"/ssr/streaming",
131+
"/isr/time",
132+
"/isr/demand",
133+
"/nonexistent-route",
134+
];
135+
136+
for (const route of routes) {
137+
const response = await fetch(posix.join(host, route));
138+
assert.equal(
139+
response.headers.get("x-fah-adapter"),
140+
`nextjs-${adapterVersion}`,
141+
`Route ${route} missing x-fah-adapter header`,
142+
);
143+
assert.equal(
144+
response.headers.get("x-fah-middleware"),
145+
null,
146+
`Route ${route} should not have x-fah-middleware header`,
147+
);
148+
}
149+
});
117150
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as assert from "assert";
2+
import { posix } from "path";
3+
import fsExtra from "fs-extra";
4+
5+
export const host = process.env.HOST;
6+
if (!host) {
7+
throw new Error("HOST environment variable expected");
8+
}
9+
10+
let adapterVersion: string;
11+
before(() => {
12+
const packageJson = fsExtra.readJSONSync("package.json");
13+
adapterVersion = packageJson.version;
14+
if (!adapterVersion) throw new Error("couldn't parse package.json version");
15+
});
16+
17+
describe("middleware", () => {
18+
it("should have x-fah-adapter header and x-fah-middleware header on all routes", async () => {
19+
const routes = [
20+
"/",
21+
"/ssg",
22+
"/ssr",
23+
"/ssr/streaming",
24+
"/isr/time",
25+
"/isr/demand",
26+
"/nonexistent-route",
27+
];
28+
29+
for (const route of routes) {
30+
const response = await fetch(posix.join(host, route));
31+
assert.equal(
32+
response.headers.get("x-fah-adapter"),
33+
`nextjs-${adapterVersion}`,
34+
`Route ${route} missing x-fah-adapter header`,
35+
);
36+
assert.equal(
37+
response.headers.get("x-fah-middleware"),
38+
"true",
39+
`Route ${route} missing x-fah-middleware header`,
40+
);
41+
}
42+
});
43+
});

packages/@apphosting/adapter-nextjs/e2e/run-local.ts

Lines changed: 159 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -12,100 +12,176 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
1212

1313
const starterTemplateDir = "../../../starters/nextjs/basic";
1414

15+
// Define scenarios to test
16+
interface Scenario {
17+
name: string; // Name of the scenario
18+
setup?: (cwd: string) => Promise<void>; // Optional setup function before building the app
19+
tests?: string[]; // List of test files to run
20+
}
21+
22+
const scenarios: Scenario[] = [
23+
{
24+
name: "basic",
25+
// No setup needed for basic scenario
26+
tests: ["app.spec.ts"],
27+
},
28+
{
29+
name: "with-middleware",
30+
setup: async (cwd: string) => {
31+
// Create a middleware.ts file
32+
const middlewareContent = `
33+
import type { NextRequest } from 'next/server'
34+
35+
export function middleware(request: NextRequest) {
36+
// This is a simple middleware that doesn't modify the request
37+
console.log('Middleware executed', request.nextUrl.pathname);
38+
}
39+
40+
export const config = {
41+
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
42+
};
43+
`;
44+
45+
await fsExtra.writeFile(join(cwd, "src", "middleware.ts"), middlewareContent);
46+
console.log(`Created middleware.ts file`);
47+
},
48+
tests: ["middleware.spec.ts"], // Only run middleware-specific tests
49+
},
50+
];
51+
1552
const errors: any[] = [];
1653

1754
await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined);
1855

19-
console.log("\nBuilding and starting test project...");
20-
21-
const runId = Math.random().toString().split(".")[1];
22-
const cwd = join(__dirname, "runs", runId);
23-
await mkdirp(cwd);
24-
25-
console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`);
26-
await cp(starterTemplateDir, cwd, { recursive: true });
27-
28-
console.log(`[${runId}] > npm ci --silent --no-progress`);
29-
await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], {
30-
cwd,
31-
stdio: "inherit",
32-
shell: true,
33-
});
34-
35-
const buildScript = relative(cwd, join(__dirname, "../dist/bin/build.js"));
36-
console.log(`[${runId}] > node ${buildScript}`);
37-
38-
const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
39-
const frameworkVersion = packageJson.dependencies.next.replace("^", "");
40-
await promiseSpawn("node", [buildScript], {
41-
cwd,
42-
stdio: "inherit",
43-
shell: true,
44-
env: {
45-
...process.env,
46-
FRAMEWORK_VERSION: frameworkVersion,
47-
},
48-
});
49-
50-
const bundleYaml = parseYaml(readFileSync(join(cwd, ".apphosting/bundle.yaml")).toString());
56+
// Run each scenario
57+
for (const scenario of scenarios) {
58+
console.log(`\n\nRunning scenario: ${scenario.name}`);
5159

52-
const runCommand = bundleYaml.runConfig.runCommand;
60+
const runId = `${scenario.name}-${Math.random().toString().split(".")[1]}`;
61+
const cwd = join(__dirname, "runs", runId);
62+
await mkdirp(cwd);
5363

54-
if (typeof runCommand !== "string") {
55-
throw new Error("runCommand must be a string");
56-
}
64+
console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`);
65+
await cp(starterTemplateDir, cwd, { recursive: true });
5766

58-
const [runScript, ...runArgs] = runCommand.split(" ");
59-
let resolveHostname: (it: string) => void;
60-
let rejectHostname: () => void;
61-
const hostnamePromise = new Promise<string>((resolve, reject) => {
62-
resolveHostname = resolve;
63-
rejectHostname = reject;
64-
});
65-
const port = 8080 + Math.floor(Math.random() * 1000);
66-
console.log(`[${runId}] > PORT=${port} ${runCommand}`);
67-
const run = spawn(runScript, runArgs, {
68-
cwd,
69-
shell: true,
70-
env: {
71-
NODE_ENV: "production",
72-
PORT: port.toString(),
73-
PATH: process.env.PATH,
74-
},
75-
});
76-
run.stderr.on("data", (data) => console.error(data.toString()));
77-
run.stdout.on("data", (data) => {
78-
console.log(data.toString());
79-
// Check for the "Ready in" message to determine when the server is fully started
80-
if (data.toString().includes(`Ready in`)) {
81-
// We use 0.0.0.0 instead of localhost to avoid issues when ipv6 is not available (Node 18)
82-
resolveHostname(`http://0.0.0.0:${port}`);
83-
}
84-
});
85-
run.on("close", (code) => {
86-
if (code) {
87-
rejectHostname();
67+
// Run scenario-specific setup if provided
68+
if (scenario.setup) {
69+
console.log(`[${runId}] Running setup for ${scenario.name}`);
70+
await scenario.setup(cwd);
8871
}
89-
});
90-
const host = await hostnamePromise;
9172

92-
console.log("\n\n");
93-
94-
try {
95-
console.log(`> HOST=${host} ts-mocha -p tsconfig.json e2e/*.spec.ts`);
96-
await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", "e2e/*.spec.ts"], {
97-
shell: true,
73+
console.log(`[${runId}] > npm ci --silent --no-progress`);
74+
await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], {
75+
cwd,
9876
stdio: "inherit",
99-
env: {
100-
...process.env,
101-
HOST: host,
102-
},
103-
}).finally(() => {
104-
run.stdin.end();
105-
run.kill("SIGKILL");
77+
shell: true,
10678
});
107-
} catch (e) {
108-
errors.push(e);
79+
80+
const buildScript = relative(cwd, join(__dirname, "../dist/bin/build.js"));
81+
const buildLogPath = join(cwd, "build.log");
82+
console.log(`[${runId}] > node ${buildScript} (output written to ${buildLogPath})`);
83+
84+
const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
85+
const frameworkVersion = packageJson.dependencies.next.replace("^", "");
86+
87+
try {
88+
await promiseSpawn("node", [buildScript], {
89+
cwd,
90+
stdioString: true,
91+
stdio: "pipe",
92+
shell: true,
93+
env: {
94+
...process.env,
95+
FRAMEWORK_VERSION: frameworkVersion,
96+
},
97+
}).then((result) => {
98+
// Write stdout and stderr to the log file
99+
fsExtra.writeFileSync(buildLogPath, result.stdout + result.stderr);
100+
});
101+
102+
const bundleYaml = parseYaml(readFileSync(join(cwd, ".apphosting/bundle.yaml")).toString());
103+
104+
const runCommand = bundleYaml.runConfig.runCommand;
105+
106+
if (typeof runCommand !== "string") {
107+
throw new Error("runCommand must be a string");
108+
}
109+
110+
const [runScript, ...runArgs] = runCommand.split(" ");
111+
let resolveHostname: (it: string) => void;
112+
let rejectHostname: () => void;
113+
const hostnamePromise = new Promise<string>((resolve, reject) => {
114+
resolveHostname = resolve;
115+
rejectHostname = reject;
116+
});
117+
const port = 8080 + Math.floor(Math.random() * 1000);
118+
const runLogPath = join(cwd, "run.log");
119+
console.log(`[${runId}] > PORT=${port} ${runCommand} (output written to ${runLogPath})`);
120+
const runLogStream = fsExtra.createWriteStream(runLogPath);
121+
122+
const run = spawn(runScript, runArgs, {
123+
cwd,
124+
shell: true,
125+
env: {
126+
NODE_ENV: "production",
127+
PORT: port.toString(),
128+
PATH: process.env.PATH,
129+
},
130+
});
131+
132+
run.stderr.on("data", (data) => {
133+
const output = data.toString();
134+
runLogStream.write(output);
135+
});
136+
137+
run.stdout.on("data", (data) => {
138+
const output = data.toString();
139+
runLogStream.write(output);
140+
// Check for the "Ready in" message to determine when the server is fully started
141+
if (output.includes(`Ready in`)) {
142+
// We use 0.0.0.0 instead of localhost to avoid issues when ipv6 is not available (Node 18)
143+
resolveHostname(`http://0.0.0.0:${port}`);
144+
}
145+
});
146+
147+
run.on("close", (code) => {
148+
runLogStream.end();
149+
if (code) {
150+
rejectHostname();
151+
}
152+
});
153+
const host = await hostnamePromise;
154+
155+
console.log("\n\n");
156+
157+
try {
158+
// Determine which test files to run
159+
const testPattern = scenario.tests
160+
? scenario.tests.map((test) => `e2e/${test}`).join(" ")
161+
: "e2e/*.spec.ts";
162+
163+
console.log(
164+
`> HOST=${host} SCENARIO=${scenario.name} ts-mocha -p tsconfig.json ${testPattern}`,
165+
);
166+
await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", ...testPattern.split(" ")], {
167+
shell: true,
168+
stdio: "inherit",
169+
env: {
170+
...process.env,
171+
HOST: host,
172+
SCENARIO: scenario.name,
173+
},
174+
}).finally(() => {
175+
run.stdin.end();
176+
run.kill("SIGKILL");
177+
});
178+
} catch (e) {
179+
errors.push(e);
180+
}
181+
} catch (e) {
182+
console.error(`Error in scenario ${scenario.name}:`, e);
183+
errors.push(e);
184+
}
109185
}
110186

111187
if (errors.length) {

packages/@apphosting/adapter-nextjs/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@apphosting/adapter-nextjs",
3-
"version": "14.0.9",
3+
"version": "14.0.10",
44
"main": "dist/index.js",
55
"description": "Experimental addon to the Firebase CLI to add web framework support",
66
"repository": {
@@ -23,7 +23,7 @@
2323
"scripts": {
2424
"build": "rm -rf dist && tsc && chmod +x ./dist/bin/*",
2525
"test": "npm run test:unit && npm run test:functional",
26-
"test:unit": "ts-mocha -p tsconfig.json src/**/*.spec.ts",
26+
"test:unit": "ts-mocha -p tsconfig.json 'src/**/*.spec.ts' 'src/*.spec.ts'",
2727
"test:functional": "node --loader ts-node/esm ./e2e/run-local.ts",
2828
"localregistry:start": "npx verdaccio --config ../publish-dev/verdaccio-config.yaml",
2929
"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"

0 commit comments

Comments
 (0)