Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/@apphosting/adapter-nextjs/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
/e2e
1 change: 1 addition & 0 deletions packages/@apphosting/adapter-nextjs/e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
runs
126 changes: 126 additions & 0 deletions packages/@apphosting/adapter-nextjs/e2e/app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as assert from "assert";
import { posix } from "path";

export const host = process.env.HOST;

if (!host) {
throw new Error("HOST environment variable expected");
}

describe("app", () => {
it("/", async () => {
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, stale-while-revalidate",
);
});

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, stale-while-revalidate",
);
const text = await response.text();
assert.ok(text.includes("SSG"));
assert.ok(text.includes("Generated"));
assert.ok(text.includes("UUID"));
});

it("/ssr", async () => {
const response = await fetch(posix.join(host, "ssr"));
assert.ok(response.ok);
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
assert.equal(
response.headers.get("cache-control"),
"private, no-cache, no-store, max-age=0, must-revalidate",
);
const text = await response.text();
assert.ok(text.includes("A server generated page!"));
assert.ok(text.includes("Generated"));
assert.ok(text.includes("UUID"));
});

it("/ssr/streaming", async () => {
const response = await fetch(posix.join(host, "ssr", "streaming"));
assert.ok(response.ok);
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
assert.equal(
response.headers.get("cache-control"),
"private, no-cache, no-store, max-age=0, must-revalidate",
);
const text = await response.text();
assert.ok(text.includes("A server generated page!"));
assert.ok(text.includes("Streaming!"));
assert.ok(text.includes("Generated"));
assert.ok(text.includes("UUID"));
}).timeout(3000); // Increased timeout to 3000ms to allow for the 2000ms component timeout

it("/isr/time", async () => {
const response = await fetch(posix.join(host, "isr", "time"));
assert.ok(response.ok);
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
assert.ok(
response.headers.get("cache-control")?.includes("s-maxage=10"),
"Cache-Control header should include s-maxage=10",
);
const text = await response.text();
assert.ok(text.includes("A cached page"));
assert.ok(text.includes("(should be regenerated every 10 seconds)"));
assert.ok(text.includes("Generated"));
assert.ok(text.includes("UUID"));
});

it("/isr/demand", async () => {
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, stale-while-revalidate",
);
const text = await response.text();
assert.ok(text.includes("A cached page"));
assert.ok(text.includes("Generated"));
assert.ok(text.includes("UUID"));
assert.ok(text.includes("Regenerate page"));
});

it("/isr/demand/revalidate", async () => {
// First, fetch the initial page
const initialResponse = await fetch(posix.join(host, "isr", "demand"));
assert.ok(initialResponse.ok);
const initialText = await initialResponse.text();
const initialUUID = initialText.match(/UUID<\/p>\s*<h2>([^<]+)<\/h2>/)?.[1];

// Trigger revalidation
const revalidateResponse = await fetch(posix.join(host, "isr", "demand", "revalidate"), {
method: "POST",
});
assert.equal(revalidateResponse.status, 200);

// Fetch the page again
const newResponse = await fetch(posix.join(host, "isr", "demand"));
assert.ok(newResponse.ok);
const newText = await newResponse.text();
const newUUID = newText.match(/UUID<\/p>\s*<h2>([^<]+)<\/h2>/)?.[1];

// Check if the UUID has changed, indicating successful revalidation
assert.notEqual(initialUUID, newUUID, "UUID should change after revalidation");
});

it(`404`, async () => {
const response = await fetch(posix.join(host, Math.random().toString()));
assert.equal(response.status, 404);
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
assert.equal(
response.headers.get("cache-control"),
"private, no-cache, no-store, max-age=0, must-revalidate",
);
});
});
115 changes: 115 additions & 0 deletions packages/@apphosting/adapter-nextjs/e2e/run-local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { cp } from "fs/promises";
import promiseSpawn from "@npmcli/promise-spawn";
import { dirname, join, relative } from "path";
import { fileURLToPath } from "url";
import { parse as parseYaml } from "yaml";
import { spawn } from "child_process";
import fsExtra from "fs-extra";

const { readFileSync, mkdirp, rmdir } = fsExtra;

const __dirname = dirname(fileURLToPath(import.meta.url));

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

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());

const runCommand = bundleYaml.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<string>((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(),
},
});
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();
}
});
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,
stdio: "inherit",
env: {
...process.env,
HOST: host,
},
}).finally(() => {
run.stdin.end();
run.kill("SIGKILL");
});
} catch (e) {
errors.push(e);
}

if (errors.length) {
console.error(errors);
process.exit(1);
}

process.exit(0);
8 changes: 7 additions & 1 deletion packages/@apphosting/adapter-nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
"sideEffects": false,
"scripts": {
"build": "rm -rf dist && tsc && chmod +x ./dist/bin/*",
"test": "ts-mocha -p tsconfig.json src/**/*.spec.ts",
"test": "npm run test:unit && npm run test:functional",
"test:unit": "ts-mocha -p tsconfig.json 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"
},
Expand Down Expand Up @@ -55,11 +57,15 @@
},
"devDependencies": {
"@types/fs-extra": "*",
"@types/mocha": "*",
"@types/tmp": "*",
"mocha": "*",
"next": "~14.0.0",
"semver": "*",
"tmp": "*",
"ts-mocha": "*",
"ts-node": "*",
"typescript": "*",
"verdaccio": "^5.30.3"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function RegenerateButton() {
<button
type="button"
className="button regenerate-button"
onClick={() => revalidate()}
onClick={() => void revalidate()}
>
Regenerate page
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { revalidatePath } from "next/cache";

export async function POST() {
export function POST() {
revalidatePath("/isr/demand");

return new Response(null, { status: 200 });
Expand Down
Loading