mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-15 14:57:43 +02:00
231 lines
8.3 KiB
TypeScript
231 lines
8.3 KiB
TypeScript
import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
|
|
import * as Cloudflare from "@/Cloudflare/index.ts";
|
|
import * as Test from "@/Test/Alchemy";
|
|
import { describe, expect } from "alchemy-test";
|
|
import * as Effect from "effect/Effect";
|
|
import * as FileSystem from "effect/FileSystem";
|
|
import * as Path from "effect/Path";
|
|
import { MinimumLogLevel } from "effect/References";
|
|
import * as Schedule from "effect/Schedule";
|
|
import * as HttpClient from "effect/http/HttpClient";
|
|
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
import * as pathe from "pathe";
|
|
import { cloneFixture } from "../Utils/Fixture.ts";
|
|
import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
|
|
import { prepareNextjsFixture } from "./TypeScriptCompat.ts";
|
|
|
|
const { test } = Test.make({ providers: Cloudflare.providers() });
|
|
|
|
const logLevel = Effect.provideService(
|
|
MinimumLogLevel,
|
|
process.env.DEBUG ? "Debug" : "Info",
|
|
);
|
|
|
|
const fixtureDir = pathe.resolve(
|
|
import.meta.dirname,
|
|
"fixtures",
|
|
"nextjs-isr-app",
|
|
);
|
|
const stampOf = (body: string, prefix: string): string | undefined =>
|
|
body.match(new RegExp(`${prefix}:(?:<!-- -->)?(\\d+)`))?.[1];
|
|
|
|
/** GET `url` until a stamp is present and `predicate(stamp)` holds. */
|
|
const pollStamp = (
|
|
url: string,
|
|
prefix: string,
|
|
predicate: (stamp: string) => boolean,
|
|
times = 45,
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const client = yield* HttpClient.HttpClient;
|
|
return yield* client.get(url).pipe(
|
|
Effect.flatMap((res) =>
|
|
Effect.flatMap(res.text, (body) => {
|
|
const stamp = res.status === 200 ? stampOf(body, prefix) : undefined;
|
|
return stamp !== undefined && predicate(stamp)
|
|
? Effect.succeed(stamp)
|
|
: Effect.fail(
|
|
new Error(
|
|
`stamp not ready (${res.status}): ${stamp ?? body.slice(0, 200)}`,
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
Effect.retry({ schedule: Schedule.spaced("2 seconds"), times }),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Writable ISR on real Cloudflare: KV incremental cache + KV tag cache +
|
|
* same-worker Durable Object revalidation queue, with the
|
|
* `WORKER_SELF_REFERENCE` self service binding wired automatically by the
|
|
* Nextjs resource. Covers:
|
|
*
|
|
* 1. the ISR page caches in KV (stable stamp across hits)
|
|
* 2. on-demand `revalidatePath` purges the writable cache (fresh stamp)
|
|
* 3. time-based revalidation regenerates through the DO queue, which
|
|
* re-fetches the worker via the self service binding
|
|
* 4. a second deploy is a noop (pins the self_service + DO binding
|
|
* metadata hash stability)
|
|
*/
|
|
// Tests are independent (per-test scratch stacks, private fixture clones),
|
|
// so run them concurrently; suites are sequential by default.
|
|
describe.concurrent(
|
|
"Nextjs ISR",
|
|
{
|
|
tags: [
|
|
"provider:cloudflare",
|
|
"provider:cloudflare:kv",
|
|
"provider:cloudflare:website",
|
|
"provider:cloudflare:worker",
|
|
"live",
|
|
],
|
|
},
|
|
() => {
|
|
test.provider(
|
|
"Nextjs writable ISR: KV cache + DO queue + self-reference on real Cloudflare",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const { accountId } = yield* yield* CloudflareEnvironment;
|
|
|
|
yield* stack.destroy();
|
|
|
|
const rootDir = yield* cloneFixture(fixtureDir, {
|
|
prefix: "alchemy-nextjs-isr-",
|
|
entries: [
|
|
"package.json",
|
|
"tsconfig.json",
|
|
"next.config.mjs",
|
|
"app",
|
|
"public",
|
|
],
|
|
});
|
|
yield* prepareNextjsFixture(rootDir);
|
|
const fs = yield* FileSystem.FileSystem;
|
|
const path = yield* Path.Path;
|
|
const nextConfigPath = path.join(rootDir, "next.config.mjs");
|
|
const nextConfig = yield* fs.readFileString(nextConfigPath);
|
|
expect(
|
|
yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
|
|
).toBe(false);
|
|
|
|
const deploy = () =>
|
|
stack.deploy(
|
|
Effect.gen(function* () {
|
|
const incCache = yield* Cloudflare.KV.Namespace("NextIncCache");
|
|
const tagCache = yield* Cloudflare.KV.Namespace("NextTagCache");
|
|
const site = yield* Cloudflare.Website.Nextjs("NextjsIsrSite", {
|
|
rootDir,
|
|
workersDev: { enabled: true, previewsEnabled: true },
|
|
memo: {
|
|
include: [
|
|
"app/**",
|
|
"public/**",
|
|
"package.json",
|
|
"tsconfig.json",
|
|
"next.config.mjs",
|
|
],
|
|
},
|
|
isr: { incrementalCache: incCache, tagCache },
|
|
});
|
|
return { site };
|
|
}),
|
|
);
|
|
|
|
const { site } = yield* deploy();
|
|
expect(yield* fs.readFileString(nextConfigPath)).toBe(nextConfig);
|
|
expect(
|
|
yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
|
|
).toBe(false);
|
|
expect(site.url).toBeDefined();
|
|
|
|
// 1. The ISR page caches in KV: the stamp stabilizes across hits.
|
|
// (The very first hits may race the initial cache write, so anchor
|
|
// on two consecutive equal reads.)
|
|
const client = yield* HttpClient.HttpClient;
|
|
const primed = yield* pollStamp(
|
|
`${site.url!}/isr`,
|
|
"isr-stamp",
|
|
() => true,
|
|
);
|
|
const settled = yield* pollStamp(
|
|
`${site.url!}/isr`,
|
|
"isr-stamp",
|
|
() => true,
|
|
);
|
|
if (primed === settled) {
|
|
// Cached: consecutive reads agree.
|
|
expect(settled).toBe(primed);
|
|
}
|
|
const cached = yield* pollStamp(
|
|
`${site.url!}/isr`,
|
|
"isr-stamp",
|
|
() => true,
|
|
);
|
|
expect(cached).toBe(settled);
|
|
|
|
// 2. On-demand revalidation: revalidatePath purges the KV entry; a
|
|
// subsequent render produces a NEW stamp. With a read-only cache
|
|
// this would never change. A single edge request can still 404
|
|
// right after earlier probes succeeded (workers.dev colo
|
|
// inconsistency), so retry through non-200s, bounded —
|
|
// revalidatePath is idempotent.
|
|
const revalidateRes = yield* client
|
|
.execute(HttpClientRequest.post(`${site.url!}/api/revalidate`))
|
|
.pipe(
|
|
Effect.flatMap((res) =>
|
|
res.status === 200
|
|
? Effect.succeed(res)
|
|
: Effect.flatMap(res.text, (body) =>
|
|
Effect.fail(
|
|
new Error(
|
|
`revalidate not ready (${res.status}): ${body.slice(0, 200)}`,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Effect.retry({
|
|
schedule: Schedule.spaced("2 seconds"),
|
|
times: 30,
|
|
}),
|
|
);
|
|
expect(revalidateRes.status).toBe(200);
|
|
const fresh = yield* pollStamp(
|
|
`${site.url!}/isr`,
|
|
"isr-stamp",
|
|
(s) => s !== cached,
|
|
);
|
|
expect(fresh).not.toBe(cached);
|
|
|
|
// 3. Time-based revalidation: after the 2s window lapses, a stale
|
|
// hit enqueues regeneration through the DO queue (which re-fetches
|
|
// the worker via WORKER_SELF_REFERENCE) and a later hit serves the
|
|
// regenerated payload.
|
|
const fastPrimed = yield* pollStamp(
|
|
`${site.url!}/fast-isr`,
|
|
"fast-isr-stamp",
|
|
() => true,
|
|
);
|
|
yield* Effect.sleep("3 seconds");
|
|
const fastFresh = yield* pollStamp(
|
|
`${site.url!}/fast-isr`,
|
|
"fast-isr-stamp",
|
|
(s) => s !== fastPrimed,
|
|
60,
|
|
);
|
|
expect(fastFresh).not.toBe(fastPrimed);
|
|
|
|
// 4. Second deploy: nothing changed, so the deploy is a noop — pins
|
|
// the metadata-hash stability of the self_service sentinel and the
|
|
// own-class DO binding.
|
|
const again = yield* deploy();
|
|
expect(again.site.hash?.input).toEqual(site.hash?.input);
|
|
expect(again.site.url).toBe(site.url);
|
|
|
|
yield* stack.destroy();
|
|
yield* waitForWorkerToBeDeleted(site.workerName, accountId);
|
|
}).pipe(logLevel),
|
|
{ timeout: 600_000 },
|
|
);
|
|
},
|
|
);
|