mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
03e658f221
* fix(security): bound x-vinext-mounted-slots cache-key cardinality (F-PROD-1)
Vinext invents two internal headers (X-Vinext-Mounted-Slots,
X-Vinext-Interception-Context) that are legitimately set by the browser's
RSC client and flow into the ISR RSC cache key (appIsrRscKey via
fnv1a64) and optimistic-routing template/source keys.
Because these headers were accepted verbatim from external requests, an
attacker who controls the browser could fan out unbounded distinct values:
for i in $(seq 1 100000); do
curl -s "https://target.example/blog.rsc?_rsc=0" \\
-H "X-Vinext-Mounted-Slots: attacker-$i" \\
-H "RSC: 1" > /dev/null &
done
Each unique header value mapped to a unique cache-key hash, producing one
KV write per request (Cloudflare KV bills per write), cache fragmentation,
and content-split cache pollution.
We cannot strip the headers wholesale because the legitimate vinext
navigation client does set them on the user's browser-to-server RSC
fetch. Instead, this commit validates the input shape and bounds
cardinality at the boundary, before the value reaches the cache key.
Approach: input-shape validation + bounds. Specifically:
x-vinext-mounted-slots (normalizeMountedSlotsHeader):
- Raw header value capped at 4096 bytes (oversized → null).
- Each token must match the legitimate slot:<name>:<treePath> wire
format produced by encodeSlotId — name is non-empty and contains no
':', treePath starts with '/'. Anything else (e.g. arbitrary
'attacker-N' bytes) is dropped silently.
- Each token capped at 256 bytes.
- Token count capped at 16 after dedup + sort.
x-vinext-interception-context (new normalizeInterceptionContextHeader):
- Null bytes stripped (header-injection defense, preserved from prior
behavior).
- Value capped at 1024 bytes.
- Must start with '/' (legitimate values are same-origin pathnames
like '/feed', '/photos/42' — see resolveManifestNavigationInterceptionContext).
- Raw whitespace is rejected.
Anything that fails validation is treated as if the header were absent,
so the request is still served (just without interception or with the
no-slots cache key).
Why not strip the headers entirely (F-PROD-1 audit option (a))? The
RSC fetch IS the user's browser-to-server request — there is no
internal hop where these headers are added on the server side. Stripping
would break legitimate parallel-route and intercepting-route navigation.
Why not hash the input before using it (audit option (b))? The value
already passes through fnv1a64 in appIsrRscKey. Hashing does not fix
unbounded cardinality — it just relocates it.
Follow-up: the audit hint also suggests validating slot IDs against the
route's declared parallel-route slots and rejecting unknown slot IDs
(matching Next.js's Next-Router-State-Tree approach). That requires
route-manifest access at the cache-key construction point and is a
larger structural change tracked separately.
Tests added in tests/isr-cache.test.ts and
tests/app-rsc-request-normalization.test.ts cover:
- 1000 distinct attacker-supplied header values collapse to a single
cache key.
- Tokens that do not match slot:<name>:<treePath> are rejected.
- Token count cap (16) is enforced.
- Raw header length cap (4096 bytes) is enforced.
- Interception-context values that do not start with '/' are rejected.
- Legitimate browser-emitted values continue to round-trip.
Identified in internal security audit — see SECURITY-AUDIT-2026-05.md
finding F-PROD-1.
* fix(security): keep slot-id shape validation inside app-elements codec boundary
The architectural invariant test (`app-elements.test.ts > keeps raw
AppElements wire-key construction inside the codec boundary`) failed
because the new mounted-slots header validator in
`app-mounted-slots-header.ts` reimplemented the slot-id wire-format
check (`token.startsWith("slot:")` + body parsing) outside the
codec module.
Delegate shape validation to `AppElementsWire.isSlotId` (already
exported from `app-elements-wire.ts`) so the wire-format definition
lives in exactly one place. The header normalizer retains its
security-relevant bounds (token byte cap, total header byte cap, token
count cap, sort + dedup) — only the shape check moves.
Security property is preserved: slot-id cardinality is still bounded
by MAX_SLOT_TOKENS, MAX_TOKEN_LENGTH, and MAX_RAW_HEADER_LENGTH, and
the same legitimate-shape filter (`slot:<name>:<treePath>` with an
absolute tree path) still gates which tokens contribute to the
normalized header value used in the ISR RSC cache key.
814 lines
30 KiB
TypeScript
814 lines
30 KiB
TypeScript
/**
|
|
* ISR cache unit tests.
|
|
*
|
|
* Tests cache key generation, normalization, hash truncation,
|
|
* revalidate duration tracking with LRU eviction, background
|
|
* regeneration deduplication, and cache value builders.
|
|
*
|
|
* These complement the integration-level ISR tests in features.test.ts
|
|
* by testing the ISR cache layer in isolation.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
|
|
import {
|
|
isrCacheKey,
|
|
appIsrHtmlKey,
|
|
appIsrRscKey,
|
|
appIsrRouteKey,
|
|
isrGet,
|
|
isrSet,
|
|
buildPagesCacheValue,
|
|
buildAppPageCacheValue,
|
|
normalizeMountedSlotsHeader,
|
|
setRevalidateDuration,
|
|
getRevalidateDuration,
|
|
triggerBackgroundRegeneration,
|
|
} from "../packages/vinext/src/server/isr-cache.js";
|
|
import {
|
|
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
|
|
APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
|
|
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
|
|
import { buildPageCacheTags } from "../packages/vinext/src/server/implicit-tags.js";
|
|
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
|
|
import {
|
|
createRequestContext,
|
|
getRequestContext,
|
|
isInsideUnifiedScope,
|
|
runWithRequestContext,
|
|
} from "../packages/vinext/src/shims/unified-request-context.js";
|
|
import {
|
|
MemoryCacheHandler,
|
|
setCacheHandler,
|
|
revalidatePath,
|
|
type CachedFetchValue,
|
|
} from "../packages/vinext/src/shims/cache.js";
|
|
|
|
// ─── isrCacheKey ────────────────────────────────────────────────────────
|
|
|
|
describe("isrCacheKey", () => {
|
|
it("generates pages: prefix for Pages Router", () => {
|
|
expect(isrCacheKey("pages", "/about")).toBe("pages:/about");
|
|
});
|
|
|
|
it("generates app: prefix for App Router", () => {
|
|
expect(isrCacheKey("app", "/dashboard")).toBe("app:/dashboard");
|
|
});
|
|
|
|
it("preserves root / without stripping", () => {
|
|
expect(isrCacheKey("pages", "/")).toBe("pages:/");
|
|
});
|
|
|
|
it("strips trailing slash from non-root paths", () => {
|
|
expect(isrCacheKey("pages", "/about/")).toBe("pages:/about");
|
|
});
|
|
|
|
it("does not strip trailing slash from root", () => {
|
|
expect(isrCacheKey("pages", "/")).toBe("pages:/");
|
|
});
|
|
|
|
it("handles deeply nested paths", () => {
|
|
expect(isrCacheKey("app", "/blog/2024/01/my-post")).toBe("app:/blog/2024/01/my-post");
|
|
});
|
|
|
|
it("hashes very long paths (> 200 chars)", () => {
|
|
const longPath = "/" + "a".repeat(250);
|
|
const key = isrCacheKey("pages", longPath);
|
|
expect(key).toMatch(/^pages:__hash:/);
|
|
// Hash should be deterministic
|
|
const key2 = isrCacheKey("pages", longPath);
|
|
expect(key).toBe(key2);
|
|
});
|
|
|
|
it("does not hash paths that produce keys <= 200 chars", () => {
|
|
const shortPath = "/about";
|
|
const key = isrCacheKey("pages", shortPath);
|
|
expect(key).toBe("pages:/about");
|
|
expect(key).not.toContain("__hash:");
|
|
});
|
|
|
|
it("different long paths produce different hashes", () => {
|
|
const path1 = "/" + "a".repeat(250);
|
|
const path2 = "/" + "b".repeat(250);
|
|
expect(isrCacheKey("pages", path1)).not.toBe(isrCacheKey("pages", path2));
|
|
});
|
|
|
|
it("includes buildId in key when provided", () => {
|
|
expect(isrCacheKey("pages", "/about", "abc123")).toBe("pages:abc123:/about");
|
|
});
|
|
|
|
it("includes buildId in app router key", () => {
|
|
expect(isrCacheKey("app", "/dashboard", "build-42")).toBe("app:build-42:/dashboard");
|
|
});
|
|
|
|
it("preserves root with buildId", () => {
|
|
expect(isrCacheKey("pages", "/", "v1")).toBe("pages:v1:/");
|
|
});
|
|
|
|
it("strips trailing slash with buildId", () => {
|
|
expect(isrCacheKey("pages", "/about/", "v1")).toBe("pages:v1:/about");
|
|
});
|
|
|
|
it("hashes long paths with buildId", () => {
|
|
const longPath = "/" + "a".repeat(250);
|
|
const key = isrCacheKey("pages", longPath, "build-99");
|
|
expect(key).toMatch(/^pages:build-99:__hash:/);
|
|
});
|
|
|
|
it("without buildId format is unchanged (backward compat)", () => {
|
|
expect(isrCacheKey("pages", "/about")).toBe("pages:/about");
|
|
expect(isrCacheKey("app", "/dashboard")).toBe("app:/dashboard");
|
|
});
|
|
});
|
|
|
|
describe("App Router ISR cache key primitives", () => {
|
|
const originalBuildId = process.env.__VINEXT_BUILD_ID;
|
|
|
|
afterEach(() => {
|
|
if (originalBuildId === undefined) {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
return;
|
|
}
|
|
|
|
process.env.__VINEXT_BUILD_ID = originalBuildId;
|
|
});
|
|
|
|
it("builds separate html, rsc, and route keys from the normalized pathname", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
expect(appIsrHtmlKey("/about/")).toBe("app:/about:html");
|
|
expect(appIsrRscKey("/about/")).toBe("app:/about:rsc");
|
|
expect(appIsrRouteKey("/api/feed/")).toBe("app:/api/feed:route");
|
|
});
|
|
|
|
it("includes the build id when present", () => {
|
|
process.env.__VINEXT_BUILD_ID = "build-42";
|
|
|
|
expect(appIsrHtmlKey("/dashboard")).toBe("app:build-42:/dashboard:html");
|
|
});
|
|
|
|
it("hashes long pathname keys while preserving the cache entry suffix", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const key = appIsrRscKey("/" + "a".repeat(250));
|
|
|
|
expect(key).toMatch(/^app:__hash:[a-z0-9]+:rsc$/);
|
|
});
|
|
|
|
it("keys mounted-slot RSC variants by normalized mounted-slot header", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const first = appIsrRscKey("/feed", "slot:modal:/ slot:sidebar:/");
|
|
const second = appIsrRscKey("/feed", "slot:sidebar:/ slot:modal:/");
|
|
|
|
expect(first).toBe(second);
|
|
expect(first).toMatch(/^app:\/feed:rsc:slots:[a-z0-9]+$/);
|
|
});
|
|
|
|
it("bounds RSC cache-key cardinality against attacker-supplied mounted-slot values", () => {
|
|
// SECURITY-AUDIT-2026-05 F-PROD-1: an attacker who forges
|
|
// X-Vinext-Mounted-Slots: <unique-value> must not be able to fan out
|
|
// unbounded distinct cache keys (per-write KV billing / wallet attack).
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const keys = new Set<string>();
|
|
for (let i = 0; i < 1000; i++) {
|
|
// Each of these violates the legitimate slot:<name>:<treePath> wire
|
|
// shape and must be dropped by normalization.
|
|
keys.add(appIsrRscKey("/feed", `attacker-${i}`));
|
|
}
|
|
// All 1000 distinct attacker values collapse to the same "no slots" key.
|
|
expect(keys.size).toBe(1);
|
|
expect(keys.values().next().value).toBe("app:/feed:rsc");
|
|
});
|
|
|
|
it("caps cache-key cardinality when an attacker pads the mounted-slots header", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
// Even when every token has the legitimate wire shape, the raw header
|
|
// length is capped (4096 bytes). When attacker payloads exceed the cap
|
|
// they are dropped to null, collapsing into the no-slots cache key.
|
|
const keys = new Set<string>();
|
|
for (let batch = 0; batch < 100; batch++) {
|
|
// 1000 legitimate tokens per batch easily exceeds the 4 KiB length cap.
|
|
const tokens = Array.from({ length: 1000 }, (_, i) => `slot:b${batch}_s${i}:/`).join(" ");
|
|
keys.add(appIsrRscKey("/feed", tokens));
|
|
}
|
|
expect(keys.size).toBe(1);
|
|
expect(keys.values().next().value).toBe("app:/feed:rsc");
|
|
});
|
|
|
|
it("keys RSC refresh variants separately from normal navigation variants", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
expect(appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI)).toBe(
|
|
"app:/feed:rsc:preserve-ui",
|
|
);
|
|
expect(appIsrRscKey("/feed", "slot:modal:/", APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI)).toMatch(
|
|
/^app:\/feed:rsc:slots:[a-z0-9]+:preserve-ui$/,
|
|
);
|
|
});
|
|
|
|
it("keys RSC loading-shell prefetch variants separately from normal navigation variants", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
expect(appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL)).toBe(
|
|
"app:/feed:rsc:prefetch-loading-shell",
|
|
);
|
|
expect(
|
|
appIsrRscKey("/feed", "slot:modal:/", APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL),
|
|
).toMatch(/^app:\/feed:rsc:slots:[a-z0-9]+:prefetch-loading-shell$/);
|
|
});
|
|
|
|
it("round-trips normal and preserve-current-UI RSC payloads under separate keys", async () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
|
|
const normalKey = appIsrRscKey("/feed");
|
|
const preserveUiKey = appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI);
|
|
const normalPayload = new TextEncoder().encode("normal-rsc").buffer;
|
|
const preserveUiPayload = new TextEncoder().encode("preserve-ui-rsc").buffer;
|
|
|
|
await isrSet(normalKey, buildAppPageCacheValue("", normalPayload), 60, []);
|
|
await isrSet(preserveUiKey, buildAppPageCacheValue("", preserveUiPayload), 60, []);
|
|
|
|
const normalEntry = await isrGet(normalKey);
|
|
const preserveUiEntry = await isrGet(preserveUiKey);
|
|
|
|
expect(normalEntry?.value.value?.kind).toBe("APP_PAGE");
|
|
expect(preserveUiEntry?.value.value?.kind).toBe("APP_PAGE");
|
|
if (
|
|
normalEntry?.value.value?.kind !== "APP_PAGE" ||
|
|
preserveUiEntry?.value.value?.kind !== "APP_PAGE"
|
|
) {
|
|
throw new Error("Expected App Router page cache entries");
|
|
}
|
|
|
|
expect(new TextDecoder().decode(normalEntry.value.value.rscData)).toBe("normal-rsc");
|
|
expect(new TextDecoder().decode(preserveUiEntry.value.value.rscData)).toBe("preserve-ui-rsc");
|
|
});
|
|
});
|
|
|
|
describe("normalizeMountedSlotsHeader", () => {
|
|
it("returns null for missing or blank mounted-slot headers", () => {
|
|
expect(normalizeMountedSlotsHeader(null)).toBeNull();
|
|
expect(normalizeMountedSlotsHeader(" \t\n ")).toBeNull();
|
|
});
|
|
|
|
it("deduplicates and sorts whitespace-separated slot ids", () => {
|
|
expect(
|
|
normalizeMountedSlotsHeader(" slot:sidebar:/ slot:modal:/ slot:sidebar:/\tslot:cart:/ "),
|
|
).toBe("slot:cart:/ slot:modal:/ slot:sidebar:/");
|
|
});
|
|
});
|
|
|
|
// ─── buildPagesCacheValue ───────────────────────────────────────────────
|
|
|
|
describe("buildPagesCacheValue", () => {
|
|
it("builds correct structure", () => {
|
|
const value = buildPagesCacheValue("<html>test</html>", { title: "Test" });
|
|
expect(value.kind).toBe("PAGES");
|
|
expect(value.html).toBe("<html>test</html>");
|
|
expect(value.pageData).toEqual({ title: "Test" });
|
|
expect(value.headers).toBeUndefined();
|
|
expect(value.status).toBeUndefined();
|
|
});
|
|
|
|
it("includes status when provided", () => {
|
|
const value = buildPagesCacheValue("<html>404</html>", {}, 404);
|
|
expect(value.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ─── buildAppPageCacheValue ─────────────────────────────────────────────
|
|
|
|
describe("buildAppPageCacheValue", () => {
|
|
it("builds correct structure", () => {
|
|
const value = buildAppPageCacheValue("<html>app</html>");
|
|
expect(value.kind).toBe("APP_PAGE");
|
|
expect(value.html).toBe("<html>app</html>");
|
|
expect(value.rscData).toBeUndefined();
|
|
expect(value.headers).toBeUndefined();
|
|
expect(value.postponed).toBeUndefined();
|
|
expect(value.status).toBeUndefined();
|
|
});
|
|
|
|
it("includes rscData when provided", () => {
|
|
const rscData = new ArrayBuffer(8);
|
|
const value = buildAppPageCacheValue("<html>app</html>", rscData);
|
|
expect(value.rscData).toBe(rscData);
|
|
});
|
|
|
|
it("includes status when provided", () => {
|
|
const value = buildAppPageCacheValue("<html>app</html>", undefined, 200);
|
|
expect(value.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─── Revalidate duration tracking ───────────────────────────────────────
|
|
|
|
describe("setRevalidateDuration / getRevalidateDuration", () => {
|
|
it("stores and retrieves a duration", () => {
|
|
setRevalidateDuration("test-key-1", 60);
|
|
expect(getRevalidateDuration("test-key-1")).toBe(60);
|
|
});
|
|
|
|
it("returns undefined for unknown keys", () => {
|
|
expect(getRevalidateDuration("nonexistent-key-xyz")).toBeUndefined();
|
|
});
|
|
|
|
it("overwrites previous values", () => {
|
|
setRevalidateDuration("test-key-2", 60);
|
|
setRevalidateDuration("test-key-2", 120);
|
|
expect(getRevalidateDuration("test-key-2")).toBe(120);
|
|
});
|
|
|
|
it("handles zero duration", () => {
|
|
setRevalidateDuration("test-key-3", 0);
|
|
expect(getRevalidateDuration("test-key-3")).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Expire ceiling handling ────────────────────────────────────────────
|
|
|
|
describe("ISR expire ceiling", () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
});
|
|
|
|
it("serves stale within expire and treats entries beyond expire as hard misses", async () => {
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.setSystemTime(1_000);
|
|
|
|
await isrSet("expire-test", buildPagesCacheValue("<html>cached</html>", {}), 1, [], 3);
|
|
|
|
vi.setSystemTime(2_500);
|
|
const stale = await isrGet("expire-test");
|
|
expect(stale?.isStale).toBe(true);
|
|
expect(stale?.value.value?.kind).toBe("PAGES");
|
|
|
|
vi.setSystemTime(4_500);
|
|
await expect(isrGet("expire-test")).resolves.toBeNull();
|
|
});
|
|
|
|
it("preserves legacy revalidate context while writing cache-control metadata", async () => {
|
|
let setContext: Record<string, unknown> | undefined;
|
|
setCacheHandler({
|
|
async get() {
|
|
return null;
|
|
},
|
|
async set(_key, _data, ctx) {
|
|
setContext = ctx;
|
|
},
|
|
async revalidateTag() {},
|
|
});
|
|
|
|
await isrSet("compat-test", buildPagesCacheValue("<html>cached</html>", {}), 60, ["tag"], 300);
|
|
|
|
expect(setContext).toEqual({
|
|
cacheControl: { revalidate: 60, expire: 300 },
|
|
revalidate: 60,
|
|
tags: ["tag"],
|
|
});
|
|
});
|
|
|
|
it("treats cache handlers that report expired entries as hard misses", async () => {
|
|
setCacheHandler({
|
|
async get() {
|
|
return {
|
|
lastModified: Date.now() - 10_000,
|
|
cacheState: "expired",
|
|
value: buildPagesCacheValue("<html>expired</html>", {}),
|
|
};
|
|
},
|
|
async set() {},
|
|
async revalidateTag() {},
|
|
});
|
|
|
|
await expect(isrGet("expired-handler-entry")).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── triggerBackgroundRegeneration ───────────────────────────────────────
|
|
|
|
describe("triggerBackgroundRegeneration", () => {
|
|
it("calls the render function", async () => {
|
|
const renderFn = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-1", renderFn);
|
|
// Wait for the async operation
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("deduplicates concurrent regeneration for same key", async () => {
|
|
let resolveFirst: () => void;
|
|
const firstPromise = new Promise<void>((r) => {
|
|
resolveFirst = r;
|
|
});
|
|
const renderFn1 = vi.fn().mockReturnValue(firstPromise);
|
|
const renderFn2 = vi.fn().mockResolvedValue(undefined);
|
|
|
|
triggerBackgroundRegeneration("regen-test-2", renderFn1);
|
|
triggerBackgroundRegeneration("regen-test-2", renderFn2);
|
|
|
|
// Only the first should have been called
|
|
expect(renderFn1).toHaveBeenCalledOnce();
|
|
expect(renderFn2).not.toHaveBeenCalled();
|
|
|
|
// Complete the first
|
|
resolveFirst!();
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
});
|
|
|
|
it("allows regeneration after previous completes", async () => {
|
|
const renderFn1 = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-3", renderFn1);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn1).toHaveBeenCalledOnce();
|
|
|
|
// After completion, a new regeneration should be allowed
|
|
const renderFn2 = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-3", renderFn2);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn2).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("handles render function errors gracefully", async () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const renderFn = vi.fn().mockRejectedValue(new Error("render failed"));
|
|
|
|
triggerBackgroundRegeneration("regen-test-4", renderFn);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(renderFn).toHaveBeenCalledOnce();
|
|
expect(consoleError).toHaveBeenCalled();
|
|
|
|
// After error, key should be cleared so new regeneration is possible
|
|
const renderFn2 = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-4", renderFn2);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn2).toHaveBeenCalledOnce();
|
|
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("reports error via onRequestError handler when errorContext is provided", async () => {
|
|
const handler = vi.fn();
|
|
globalThis.__VINEXT_onRequestErrorHandler__ = handler;
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
try {
|
|
const renderFn = vi.fn().mockRejectedValue(new Error("regen failed"));
|
|
triggerBackgroundRegeneration("regen-report-error", renderFn, {
|
|
routerKind: "App Router",
|
|
routePath: "/blog/[slug]",
|
|
routeType: "render",
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
const [error, request, context] = handler.mock.calls[0];
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error.message).toBe("regen failed");
|
|
expect(request).toEqual({ path: "regen-report-error", method: "GET", headers: {} });
|
|
expect(context).toEqual({
|
|
routerKind: "App Router",
|
|
routePath: "/blog/[slug]",
|
|
routeType: "render",
|
|
revalidateReason: "stale",
|
|
});
|
|
} finally {
|
|
delete globalThis.__VINEXT_onRequestErrorHandler__;
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does NOT call onRequestError handler when errorContext is omitted", async () => {
|
|
const handler = vi.fn();
|
|
globalThis.__VINEXT_onRequestErrorHandler__ = handler;
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
try {
|
|
const renderFn = vi.fn().mockRejectedValue(new Error("regen failed"));
|
|
triggerBackgroundRegeneration("regen-no-ctx", renderFn);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(consoleError).toHaveBeenCalled();
|
|
expect(handler).not.toHaveBeenCalled();
|
|
} finally {
|
|
delete globalThis.__VINEXT_onRequestErrorHandler__;
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("wraps non-Error throw values in Error before reporting", async () => {
|
|
const handler = vi.fn();
|
|
globalThis.__VINEXT_onRequestErrorHandler__ = handler;
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
try {
|
|
const renderFn = vi.fn().mockRejectedValue("string error");
|
|
triggerBackgroundRegeneration("regen-string-error", renderFn, {
|
|
routerKind: "Pages Router",
|
|
routePath: "/about",
|
|
routeType: "render",
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
const [error] = handler.mock.calls[0];
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error.message).toBe("string error");
|
|
} finally {
|
|
delete globalThis.__VINEXT_onRequestErrorHandler__;
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("different keys run independently", async () => {
|
|
const renderFnA = vi.fn().mockResolvedValue(undefined);
|
|
const renderFnB = vi.fn().mockResolvedValue(undefined);
|
|
|
|
triggerBackgroundRegeneration("regen-test-5a", renderFnA);
|
|
triggerBackgroundRegeneration("regen-test-5b", renderFnB);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFnA).toHaveBeenCalledOnce();
|
|
expect(renderFnB).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("calls ctx.waitUntil with the regen promise when ctx is in ALS", async () => {
|
|
const waitUntil = vi.fn();
|
|
const ctx = { waitUntil };
|
|
|
|
let resolveRender: () => void;
|
|
const renderPromise = new Promise<void>((r) => {
|
|
resolveRender = r;
|
|
});
|
|
const renderFn = vi.fn().mockReturnValue(renderPromise);
|
|
|
|
await runWithExecutionContext(ctx, async () => {
|
|
triggerBackgroundRegeneration("regen-ctx-1", renderFn);
|
|
});
|
|
|
|
expect(waitUntil).toHaveBeenCalledOnce();
|
|
expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise));
|
|
|
|
resolveRender!();
|
|
await renderPromise;
|
|
});
|
|
|
|
it("preserves unified request context for async work started by regeneration", async () => {
|
|
let releaseRender!: () => void;
|
|
const resumeRender = new Promise<void>((resolve) => {
|
|
releaseRender = resolve;
|
|
});
|
|
|
|
let regenPromise: Promise<unknown> | null = null;
|
|
const executionContext = {
|
|
waitUntil(promise: Promise<unknown>) {
|
|
regenPromise = promise;
|
|
},
|
|
};
|
|
|
|
let sawUnifiedScope = false;
|
|
let collectedTags: string[] = [];
|
|
|
|
await runWithExecutionContext(executionContext, async () => {
|
|
await runWithRequestContext(
|
|
createRequestContext({ currentRequestTags: ["outer-tag"] }),
|
|
async () => {
|
|
triggerBackgroundRegeneration("regen-unified-scope", async () => {
|
|
await resumeRender;
|
|
sawUnifiedScope = isInsideUnifiedScope();
|
|
collectedTags = [...getRequestContext().currentRequestTags];
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
expect(isInsideUnifiedScope()).toBe(false);
|
|
if (!regenPromise) {
|
|
throw new Error("expected triggerBackgroundRegeneration to register waitUntil");
|
|
}
|
|
const pendingRegen = regenPromise;
|
|
|
|
releaseRender();
|
|
await Promise.resolve(pendingRegen);
|
|
|
|
expect(sawUnifiedScope).toBe(true);
|
|
expect(collectedTags).toEqual(["outer-tag"]);
|
|
});
|
|
|
|
it("does not require ctx — works without it", async () => {
|
|
const renderFn = vi.fn().mockResolvedValue(undefined);
|
|
// No ctx passed — should not throw
|
|
triggerBackgroundRegeneration("regen-no-ctx", renderFn);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
// ─── revalidatePath with type parameter ──────────────────────────────────
|
|
|
|
describe("revalidatePath type parameter", () => {
|
|
let handler: MemoryCacheHandler;
|
|
|
|
function staticRouteSegments(pathname: string): string[] {
|
|
return pathname.split("/").filter(Boolean);
|
|
}
|
|
|
|
/** Helper: store a FETCH cache entry with path + implicit hierarchy tags. */
|
|
async function seedEntry(
|
|
path: string,
|
|
body: string,
|
|
routeSegments = staticRouteSegments(path),
|
|
): Promise<void> {
|
|
const tags = buildPageCacheTags(path, [], routeSegments, "page");
|
|
const value: CachedFetchValue = {
|
|
kind: "FETCH",
|
|
data: { headers: {}, body, url: path },
|
|
tags,
|
|
revalidate: false,
|
|
};
|
|
await handler.set(`entry:${path}`, value, { tags });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
handler = new MemoryCacheHandler();
|
|
setCacheHandler(handler);
|
|
});
|
|
|
|
it("invalidates the layout path AND all child paths when type is 'layout'", async () => {
|
|
await seedEntry("/dashboard", "dashboard-root");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
await seedEntry("/dashboard/profile", "profile");
|
|
await seedEntry("/about", "about-page");
|
|
|
|
// All four entries should be present before revalidation
|
|
expect(await handler.get("entry:/dashboard")).not.toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).not.toBeNull();
|
|
expect(await handler.get("entry:/dashboard/profile")).not.toBeNull();
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
|
|
await revalidatePath("/dashboard", "layout");
|
|
|
|
// All three dashboard entries should be invalidated
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/profile")).toBeNull();
|
|
|
|
// /about should NOT be invalidated
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
});
|
|
|
|
it("invalidates only the exact path when type is 'page'", async () => {
|
|
await seedEntry("/about", "about-page");
|
|
await seedEntry("/about/team", "about-team");
|
|
|
|
await revalidatePath("/about", "page");
|
|
|
|
// Only /about should be invalidated
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
// /about/team should remain
|
|
expect(await handler.get("entry:/about/team")).not.toBeNull();
|
|
});
|
|
|
|
it("invalidates the exact path when no type is specified", async () => {
|
|
await seedEntry("/about", "about-page");
|
|
await seedEntry("/about/team", "about-team");
|
|
|
|
await revalidatePath("/about");
|
|
|
|
// Only /about should be invalidated
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
// /about/team should remain
|
|
expect(await handler.get("entry:/about/team")).not.toBeNull();
|
|
});
|
|
|
|
it("uses route pattern tags for typed dynamic route invalidation", async () => {
|
|
await seedEntry("/blog/hello", "hello", ["blog", "[slug]"]);
|
|
|
|
await revalidatePath("/blog/hello", "layout");
|
|
expect(await handler.get("entry:/blog/hello")).not.toBeNull();
|
|
|
|
await revalidatePath("/blog/[slug]", "layout");
|
|
expect(await handler.get("entry:/blog/hello")).toBeNull();
|
|
|
|
await seedEntry("/blog/hello", "hello", ["blog", "[slug]"]);
|
|
|
|
await revalidatePath("/blog/hello");
|
|
expect(await handler.get("entry:/blog/hello")).toBeNull();
|
|
});
|
|
|
|
it("handles deeply nested children under a layout prefix", async () => {
|
|
await seedEntry("/app", "app-root");
|
|
await seedEntry("/app/blog", "blog");
|
|
await seedEntry("/app/blog/2024", "blog-2024");
|
|
await seedEntry("/app/blog/2024/01/post", "blog-post");
|
|
|
|
await revalidatePath("/app", "layout");
|
|
|
|
// All entries under /app should be invalidated
|
|
expect(await handler.get("entry:/app")).toBeNull();
|
|
expect(await handler.get("entry:/app/blog")).toBeNull();
|
|
expect(await handler.get("entry:/app/blog/2024")).toBeNull();
|
|
expect(await handler.get("entry:/app/blog/2024/01/post")).toBeNull();
|
|
});
|
|
|
|
it("does not invalidate paths that merely share a string prefix", async () => {
|
|
// /dashboard-admin starts with "/dashboard" as a string, but it's NOT
|
|
// a child route of /dashboard — it's a sibling. The prefix match must
|
|
// be path-segment-aware (match "/dashboard/" or exact "/dashboard").
|
|
await seedEntry("/dashboard", "dashboard");
|
|
await seedEntry("/dashboard-admin", "dashboard-admin");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
|
|
await revalidatePath("/dashboard", "layout");
|
|
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
// /dashboard-admin should NOT be invalidated — different route
|
|
expect(await handler.get("entry:/dashboard-admin")).not.toBeNull();
|
|
});
|
|
|
|
it("handles root path '/' with layout type — invalidates everything", async () => {
|
|
await seedEntry("/", "home");
|
|
await seedEntry("/about", "about");
|
|
await seedEntry("/dashboard", "dashboard");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
|
|
await revalidatePath("/", "layout");
|
|
|
|
// Root layout covers all routes
|
|
expect(await handler.get("entry:/")).toBeNull();
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
});
|
|
|
|
it("handles root path '/' with page type — invalidates only the root page", async () => {
|
|
await seedEntry("/", "home");
|
|
await seedEntry("/about", "about");
|
|
|
|
await revalidatePath("/", "page");
|
|
|
|
// Root page should be invalidated
|
|
expect(await handler.get("entry:/")).toBeNull();
|
|
// Other pages should remain — "page" type targets only the exact route
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
});
|
|
|
|
it("trailing slash on layout path is normalized — same as without trailing slash", async () => {
|
|
await seedEntry("/dashboard", "dashboard-root");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
await seedEntry("/about", "about-page");
|
|
|
|
// revalidatePath("/dashboard/", "layout") must behave like ("/dashboard", "layout")
|
|
await revalidatePath("/dashboard/", "layout");
|
|
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
// /about should NOT be invalidated
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
});
|
|
|
|
it("type 'page' invalidates via /page tag, not the bare path tag", async () => {
|
|
// Seed two synthetic entries to prove the tag paths are distinct:
|
|
// Entry A: only the /page leaf tag — only revalidatePath(path, "page") should hit it
|
|
// Entry B: only the bare _N_T_ path tag — only revalidatePath(path) should hit it
|
|
const pageOnlyValue: CachedFetchValue = {
|
|
kind: "FETCH",
|
|
data: { headers: {}, body: "page-only", url: "/about" },
|
|
tags: ["_N_T_/about/page"],
|
|
revalidate: false,
|
|
};
|
|
const barePathValue: CachedFetchValue = {
|
|
kind: "FETCH",
|
|
data: { headers: {}, body: "bare-path", url: "/about" },
|
|
tags: ["/about", "_N_T_/about"],
|
|
revalidate: false,
|
|
};
|
|
await handler.set("entry:page-only", pageOnlyValue, { tags: ["_N_T_/about/page"] });
|
|
await handler.set("entry:bare-path", barePathValue, { tags: ["/about", "_N_T_/about"] });
|
|
|
|
await revalidatePath("/about", "page");
|
|
|
|
// "page" type targets the /page leaf tag only
|
|
expect(await handler.get("entry:page-only")).toBeNull();
|
|
// The bare path entry should NOT be touched
|
|
expect(await handler.get("entry:bare-path")).not.toBeNull();
|
|
});
|
|
|
|
it("trailing slash on page path is normalized — same as without trailing slash", async () => {
|
|
await seedEntry("/about", "about-page");
|
|
await seedEntry("/about/team", "about-team");
|
|
|
|
// revalidatePath("/about/", "page") must be equivalent to ("/about", "page")
|
|
await revalidatePath("/about/", "page");
|
|
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
// /about/team should remain — only the exact path was invalidated
|
|
expect(await handler.get("entry:/about/team")).not.toBeNull();
|
|
});
|
|
});
|