mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
47b38a91f3
* fix(plugin): resolve vinext/shims/* package subpaths to local shim files Runtime helper modules embedded into generated entries import vinext's own shims by package subpath (e.g. `vinext/shims/headers`), while source checkouts alias userland `next/*` imports to the local shim files. The two specifiers resolved to different module instances, so request-scoped singleton state (navigation context, headers) split between the source shim copy and the package export copy. The violated invariant is that every shim module must be a per-request singleton regardless of import specifier. Resolve `vinext/shims/*` through the same plugin path as the `next/*` aliases so both forms land on the local shim files. Exercised by the SSR shell-error recovery browser spec, whose fixture imports vinext from the source checkout and depends on shared navigation state across both import forms. * fix(app-router): recover SSR shell render errors via __next_error__ document When the HTML (Fizz) render rejects during SSR, vinext re-rendered a server-side global-error page whose flight payload encodes the error tree. For an app without a custom global-error.tsx that meant the default error card with no path back to the real page: an SSR-phase-only throw (e.g. a client component using the "throw to opt out of server rendering" pattern) left the browser stuck on the card even though the client render would succeed. The violated expectation is Next.js's shell-error semantics: a failed HTML shell is served as the default `__next_error__` error document carrying the ORIGINAL flight payload and the bootstrap module, and the browser re-renders the real tree from that payload with createRoot instead of hydrating. Local error.tsx boundaries still win — they ship inside the flight payload and catch the re-thrown error client-side. handleSsr now resolves to that recovery document instead of rejecting, but only when both hold: - the error did not originate in the RSC render (no string `digest`), so flight errors, redirect()/notFound(), and server-component throws keep driving the existing rejection-based boundary machinery, and - the app has no custom global-error.tsx (the generated entry knows at build time and threads hasCustomGlobalError through dispatch/render options); apps with one keep the server-rendered boundary re-render. The browser entry switches from hydrateRoot to createRoot when the document root carries id="__next_error__", dropping the error-shell styles first. Covered by the new ssr-error-shell-recovery browser spec (recovery to real content, local error.tsx for SSR-only and unconditional client throws) and the existing tests/nextjs-compat/global-error.test.ts boundary-semantics suite. * refactor(review): address PR 1908 non-blocking notes - Add static prerender no-boundary recovery regression test to ssr-error-shell-recovery.browser.spec.ts - Document broad __next_error__ browser marker in app-browser-entry.ts - Extract stripJsExtension to utils/path.ts and wire into all shim resolution sites (vinext/shims/* + react-server shims) Non-functional: targeted regression coverage + code documentation + minor resolver hardening per reviewer feedback. * refactor(review): clarify shell recovery assumptions * fix(ssr): cancel abandoned prerender streams * refactor(ssr): clarify error shell root options * fix(isr): recover shell errors during regeneration * fix(ssr): scope client recovery to marked shells * fix(plugin): explicitly filter vinext shim subpaths * fix(plugin): retain null-prefixed shim resolution * test(nextjs-compat): cover no-boundary shell recovery fallback An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again. * fix(app-router): preserve shell recovery error semantics * test(app-router): harden shell recovery cache semantics * fix(app-router): mark global error responses uncacheable * test(app-router): align error response status expectations * fix(build): preserve null-prefixed og resolution * fix(cache): delegate cache header cleanup to adapters --------- Co-authored-by: James <james@eli.cx>
189 lines
7.5 KiB
TypeScript
189 lines
7.5 KiB
TypeScript
/**
|
|
* CloudflareCdnCacheAdapter + auto-detection tests.
|
|
*
|
|
* Covers the edge-managed adapter backed by the Workers Cache (ctx.cache):
|
|
* - get null / set no-op / ownsBackgroundRevalidation false
|
|
* - buildResponseHeaders emits a cacheable Cache-Control + Cache-Tag
|
|
* - revalidateTag purges via ctx.cache.purge({ tags })
|
|
* - getCdnCacheAdapter() auto-switches to the Cloudflare adapter when the
|
|
* VINEXT_CDN_CACHE_AUTO_DETECT flag is set and ctx.cache exists.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
|
|
import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
|
|
import {
|
|
getCdnCacheAdapter,
|
|
setCdnCacheAdapter,
|
|
DefaultCdnCacheAdapter,
|
|
} from "../packages/vinext/src/shims/cdn-cache.js";
|
|
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
|
|
|
|
const CDN_KEY = Symbol.for("vinext.cdnCacheAdapter");
|
|
const AUTO_DETECT_ENV = "VINEXT_CDN_CACHE_AUTO_DETECT";
|
|
|
|
function resetActiveAdapter(): void {
|
|
delete (globalThis as Record<PropertyKey, unknown>)[CDN_KEY];
|
|
}
|
|
|
|
beforeEach(resetActiveAdapter);
|
|
afterEach(resetActiveAdapter);
|
|
|
|
// ─── Adapter behavior ────────────────────────────────────────────────────
|
|
|
|
describe("CloudflareCdnCacheAdapter", () => {
|
|
const adapter = new CloudflareCdnCacheAdapter();
|
|
|
|
it("does not own background revalidation (the edge re-requests origin)", () => {
|
|
expect(adapter.ownsBackgroundRevalidation).toBe(false);
|
|
});
|
|
|
|
it("get returns null so the origin always renders fresh", async () => {
|
|
expect(await adapter.get()).toBeNull();
|
|
});
|
|
|
|
it("set is a no-op (platform caches the response, not an origin store)", async () => {
|
|
await expect(adapter.set("k", null)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("carries SWR on CDN-Cache-Control (public + max-age) and revalidates the browser", () => {
|
|
// A value-less `stale-while-revalidate` is normalized to an explicit window
|
|
// (Cloudflare ignores the bare directive — RFC 5861 requires a value).
|
|
expect(
|
|
adapter.buildResponseHeaders({ cacheControl: "s-maxage=60, stale-while-revalidate" }),
|
|
).toEqual({
|
|
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
"CDN-Cache-Control": "public, max-age=60, stale-while-revalidate=31536000",
|
|
});
|
|
});
|
|
|
|
it("uses max-age (not s-maxage) and public on the edge directive, even pending-dynamic", () => {
|
|
const headers = adapter.buildResponseHeaders({
|
|
cacheControl: "s-maxage=60, stale-while-revalidate=540",
|
|
pendingDynamicCheck: true,
|
|
});
|
|
// Edge caches + SWRs via CDN-Cache-Control; the browser always revalidates.
|
|
// An already-valued stale-while-revalidate is passed through unchanged.
|
|
expect(headers["CDN-Cache-Control"]).toBe("public, max-age=60, stale-while-revalidate=540");
|
|
expect(headers["Cache-Control"]).toBe("public, max-age=0, must-revalidate");
|
|
});
|
|
|
|
it("adds a Cache-Tag header from the page tags", () => {
|
|
const headers = adapter.buildResponseHeaders({
|
|
cacheControl: "s-maxage=60",
|
|
tags: ["/blog", "_N_T_/blog", "posts"],
|
|
});
|
|
expect(headers["Cache-Tag"]).toBe("/blog,_N_T_/blog,posts");
|
|
expect(headers["Cache-Control"]).toBe("public, max-age=0, must-revalidate");
|
|
expect(headers["CDN-Cache-Control"]).toBe("public, max-age=60");
|
|
});
|
|
|
|
it("skips tags containing the comma separator or that are too long", () => {
|
|
const headers = adapter.buildResponseHeaders({
|
|
cacheControl: "s-maxage=60",
|
|
tags: ["a,b", "x".repeat(2000), "ok"],
|
|
});
|
|
expect(headers["Cache-Tag"]).toBe("ok");
|
|
});
|
|
|
|
it("returns only no-store (no CDN-Cache-Control) when there is no cacheable policy", () => {
|
|
expect(adapter.buildResponseHeaders({ cacheControl: "" })).toEqual({
|
|
"Cache-Control": "no-store",
|
|
});
|
|
});
|
|
|
|
it("passes a non-cacheable policy through without promoting it to the edge", () => {
|
|
// revalidate=0 / gssp paths produce no-store / private — must never become
|
|
// a CDN-Cache-Control directive (which would cache an uncacheable response).
|
|
for (const cc of [
|
|
"no-store, must-revalidate",
|
|
"private, no-cache, no-store, max-age=0, must-revalidate",
|
|
]) {
|
|
const headers = adapter.buildResponseHeaders({ cacheControl: cc, tags: ["x"] });
|
|
expect(headers).toEqual({
|
|
"Cache-Control": cc,
|
|
"CDN-Cache-Control": null,
|
|
"Cloudflare-CDN-Cache-Control": null,
|
|
"Cache-Tag": null,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("revalidateTag purges the Workers Cache by tag via ctx.cache.purge", async () => {
|
|
const purge = vi.fn(async () => {});
|
|
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
|
|
await adapter.revalidateTag(["posts", "_N_T_/blog"]);
|
|
});
|
|
expect(purge).toHaveBeenCalledWith({ tags: ["posts", "_N_T_/blog"] });
|
|
});
|
|
|
|
it("revalidateTag normalizes a single tag to an array", async () => {
|
|
const purge = vi.fn(async () => {});
|
|
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
|
|
await adapter.revalidateTag("posts");
|
|
});
|
|
expect(purge).toHaveBeenCalledWith({ tags: ["posts"] });
|
|
});
|
|
|
|
it("revalidateTag is a no-op when the Workers Cache is absent (e.g. Node dev)", async () => {
|
|
// No runWithExecutionContext scope → getRequestExecutionContext() is null.
|
|
await expect(adapter.revalidateTag("posts")).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("revalidateTag does not purge for an empty tag set", async () => {
|
|
const purge = vi.fn(async () => {});
|
|
await runWithExecutionContext({ waitUntil() {}, cache: { purge } }, async () => {
|
|
await adapter.revalidateTag([]);
|
|
});
|
|
expect(purge).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── Auto-detection (flag-gated) ───────────────────────────────────────────
|
|
|
|
describe("auto-switch to the Cloudflare adapter when ctx.cache exists", () => {
|
|
afterEach(() => {
|
|
delete process.env[AUTO_DETECT_ENV];
|
|
});
|
|
|
|
it("selects the Cloudflare adapter when the flag is on and ctx.cache exists", async () => {
|
|
process.env[AUTO_DETECT_ENV] = "1";
|
|
resetActiveAdapter();
|
|
|
|
const adapter = await runWithExecutionContext(
|
|
{ waitUntil() {}, cache: { async purge() {} } },
|
|
async () => getCdnCacheAdapter(),
|
|
);
|
|
expect(adapter).toBeInstanceOf(CloudflareCdnCacheAdapter);
|
|
});
|
|
|
|
it("does NOT auto-detect when the flag is off, even with ctx.cache present", async () => {
|
|
delete process.env[AUTO_DETECT_ENV];
|
|
resetActiveAdapter();
|
|
|
|
const adapter = await runWithExecutionContext(
|
|
{ waitUntil() {}, cache: { async purge() {} } },
|
|
async () => getCdnCacheAdapter(),
|
|
);
|
|
expect(adapter).toBeInstanceOf(DefaultCdnCacheAdapter);
|
|
});
|
|
|
|
it("falls back to the default adapter when ctx.cache is absent (flag on)", async () => {
|
|
process.env[AUTO_DETECT_ENV] = "1";
|
|
resetActiveAdapter();
|
|
// No request context / no ctx.cache → no auto-detection.
|
|
expect(getCdnCacheAdapter()).toBeInstanceOf(DefaultCdnCacheAdapter);
|
|
});
|
|
|
|
it("an explicitly set adapter wins over auto-detection", async () => {
|
|
process.env[AUTO_DETECT_ENV] = "1";
|
|
resetActiveAdapter();
|
|
const explicit = new DefaultCdnCacheAdapter();
|
|
setCdnCacheAdapter(explicit);
|
|
|
|
const adapter = await runWithExecutionContext(
|
|
{ waitUntil() {}, cache: { async purge() {} } },
|
|
async () => getCdnCacheAdapter(),
|
|
);
|
|
expect(adapter).toBe(explicit);
|
|
});
|
|
});
|