diff --git a/packages/vinext/src/cache/cache-adapters-virtual.ts b/packages/vinext/src/cache/cache-adapters-virtual.ts index 565eaa9440..1a3859815f 100644 --- a/packages/vinext/src/cache/cache-adapters-virtual.ts +++ b/packages/vinext/src/cache/cache-adapters-virtual.ts @@ -41,6 +41,14 @@ export type CdnCacheAdapterCapabilities = { * guarantee is present. URL-only caches retain the contextual `_rsc` digest. */ responseVary?: "verbatim"; + /** + * Rewrites and other request routing run before the shared response stage, + * and the resolved response-stage invocation participates in cache identity. + * + * Warm planners may therefore request rewrite source paths: conditional and + * default route resolutions cannot reuse one another's cached response. + */ + requestRouting?: "uncached-stage"; /** * Cacheable App Page responses require a build-bound probe manifest before * the adapter may emit public shared-cache policy. The adapter's deployment @@ -48,6 +56,16 @@ export type CdnCacheAdapterCapabilities = { * that serves the corresponding application build. */ routeCacheability?: "probe-manifest"; + /** + * Response headers whose values can opt a response into or out of the + * adapter's shared cache, in addition to the framework-owned Cache-Control + * header. + * + * Prerender discovery uses these names to avoid collapsing a dynamic route + * pattern when next.config assigns different cache policy to its concrete + * pathnames. + */ + responsePolicyHeaderNames?: readonly string[]; }; export type CacheAdapterBuildOutput = { @@ -79,6 +97,10 @@ export function hasVerbatimResponseVary(cache?: VinextCacheConfig | null): boole return cache?.cdn?.capabilities?.responseVary === "verbatim"; } +export function hasUncachedRequestRouting(cache?: VinextCacheConfig | null): boolean { + return cache?.cdn?.capabilities?.requestRouting === "uncached-stage"; +} + export function hasBuildIdentityResponseHeader(cache?: VinextCacheConfig | null): boolean { return cache?.cdn?.capabilities?.buildIdentity === "response-header"; } @@ -87,6 +109,20 @@ export function requiresRouteCacheabilityProbeManifest(cache?: VinextCacheConfig return cache?.cdn?.capabilities?.routeCacheability === "probe-manifest"; } +/** Lowercase response-policy names owned by core and the configured adapter. */ +export function getConfiguredCdnResponsePolicyHeaderNames( + cache?: VinextCacheConfig | null, +): readonly string[] { + return [ + ...new Set([ + "cache-control", + ...(cache?.cdn?.capabilities?.responsePolicyHeaderNames ?? []) + .map((name) => name.trim().toLowerCase()) + .filter(Boolean), + ]), + ]; +} + /** * The `cache` option of the vinext() plugin: declaratively register cache * handlers instead of calling `setDataCacheHandler()` / `setCdnCacheAdapter()` @@ -101,6 +137,8 @@ export type VinextCacheConfig = { /** Public virtual module id imported by the server entries. */ export const VIRTUAL_CACHE_ADAPTERS = "virtual:vinext-cache-adapters"; +/** Request-stage module that cannot retain the data-cache adapter graph. */ +export const VIRTUAL_CDN_CACHE_ADAPTER = "virtual:vinext-cdn-cache-adapter"; // Custom metadata key attached to vinext's config plugin so deploy commands can // inspect the normalized cache descriptors after loading the user's Vite config. @@ -180,11 +218,11 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { if (data?.adapter) { lines.push(`import __vinextDataAdapterFactory from ${JSON.stringify(data.adapter)};`); - lines.push(`import { setDataCacheHandler } from "vinext/shims/cache-handler";`); + lines.push(`import { registerDataCacheHandler } from "vinext/shims/cache-handler";`); } if (cdn?.adapter) { lines.push(`import __vinextCdnAdapterFactory from ${JSON.stringify(cdn.adapter)};`); - lines.push(`import { setCdnCacheAdapter } from "vinext/shims/cdn-cache";`); + lines.push(`import { registerCdnCacheAdapter } from "vinext/shims/cdn-cache-state";`); } lines.push( @@ -210,7 +248,7 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { if (data?.adapter) { lines.push( " try {", - ` setDataCacheHandler(__vinextDataAdapterFactory({ env, options: ${inlineOptions( + ` registerDataCacheHandler(() => __vinextDataAdapterFactory({ env, options: ${inlineOptions( data.adapter, data.options, )} }));`, @@ -223,7 +261,7 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { if (cdn?.adapter) { lines.push( " try {", - ` setCdnCacheAdapter(__vinextCdnAdapterFactory({ env, options: ${inlineOptions( + ` registerCdnCacheAdapter(() => __vinextCdnAdapterFactory({ env, options: ${inlineOptions( cdn.adapter, cdn.options, )} }));`, @@ -237,3 +275,8 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { return lines.join("\n"); } + +/** Generate request-stage registration without importing a configured data adapter. */ +export function generateCdnCacheAdapterModule(cache?: VinextCacheConfig): string { + return `${generateCacheAdaptersModule(cache?.cdn ? { cdn: cache.cdn } : undefined)}export const hasConfiguredDataCache = ${Boolean(cache?.data?.adapter)};\n`; +} diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 4fd94918a3..87619bcca0 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -510,6 +510,11 @@ declare module "virtual:vinext-cache-adapters" { export function registerConfiguredCacheAdapters(env?: Record): void; } +declare module "virtual:vinext-cdn-cache-adapter" { + export const hasConfiguredDataCache: boolean; + export function registerConfiguredCacheAdapters(env?: Record): void; +} + declare module "virtual:vinext-pages-client-assets" { import type { PagesClientAssets } from "vinext/server/pages-client-assets"; const assets: PagesClientAssets; diff --git a/packages/vinext/src/shims/cache-handler.ts b/packages/vinext/src/shims/cache-handler.ts index 56195a12cb..6ca9811330 100644 --- a/packages/vinext/src/shims/cache-handler.ts +++ b/packages/vinext/src/shims/cache-handler.ts @@ -373,8 +373,55 @@ export class MemoryCacheHandler implements CacheHandler { } const HANDLER_KEY = Symbol.for("vinext.cacheHandler"); +const CONFIGURED_HANDLER_KEY = Symbol.for("vinext.configuredCacheHandler"); +const EXPLICIT_HANDLER_KEY = Symbol.for("vinext.explicitCacheHandler"); +const LAZY_HANDLER_KEY = Symbol.for("vinext.lazyCacheHandler"); const globalHandlers = globalThis as unknown as Record; +class LazyDataCacheHandler implements CacheHandler { + private resolved: Promise | undefined; + private loadedHandler: CacheHandler | undefined; + + constructor(private readonly load: () => Promise) {} + + private resolve(): Promise { + return (this.resolved ??= this.load().then(() => { + const registered = globalHandlers[HANDLER_KEY]; + if (registered && registered !== this) { + this.loadedHandler = registered; + return registered; + } + + const fallback = new MemoryCacheHandler(); + globalHandlers[HANDLER_KEY] = fallback; + globalHandlers[CONFIGURED_HANDLER_KEY] = fallback; + delete globalHandlers[LAZY_HANDLER_KEY]; + this.loadedHandler = fallback; + return fallback; + })); + } + + async get(key: string, ctx?: Record): Promise { + return (await this.resolve()).get(key, ctx); + } + + async set( + key: string, + data: IncrementalCacheValue | null, + ctx?: Record, + ): Promise { + await (await this.resolve()).set(key, data, ctx); + } + + async revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise { + await (await this.resolve()).revalidateTag(tags, durations); + } + + resetRequestCache(): void { + this.loadedHandler?.resetRequestCache?.(); + } +} + function getActiveHandler(): CacheHandler { return globalHandlers[HANDLER_KEY] ?? (globalHandlers[HANDLER_KEY] = new MemoryCacheHandler()); } @@ -387,6 +434,45 @@ export function configureMemoryCacheHandler(options?: MemoryCacheHandlerOptions) export function setDataCacheHandler(handler: CacheHandler): void { globalHandlers[HANDLER_KEY] = handler; + globalHandlers[EXPLICIT_HANDLER_KEY] = handler; + delete globalHandlers[LAZY_HANDLER_KEY]; +} + +/** + * Lazily keep the first declaratively configured handler shared across + * duplicated stage modules. Imperative setters remain able to replace it. + */ +export function registerDataCacheHandler(factory: () => CacheHandler): void { + if ( + globalHandlers[EXPLICIT_HANDLER_KEY] !== undefined || + globalHandlers[CONFIGURED_HANDLER_KEY] !== undefined + ) { + return; + } + const handler = factory(); + if ( + globalHandlers[EXPLICIT_HANDLER_KEY] !== undefined || + globalHandlers[CONFIGURED_HANDLER_KEY] !== undefined + ) { + return; + } + globalHandlers[HANDLER_KEY] = handler; + globalHandlers[CONFIGURED_HANDLER_KEY] = handler; + delete globalHandlers[LAZY_HANDLER_KEY]; +} + +/** Register a lightweight proxy that imports the configured handler on first use. */ +export function registerLazyDataCacheHandler(load: () => Promise): void { + if ( + globalHandlers[EXPLICIT_HANDLER_KEY] !== undefined || + globalHandlers[CONFIGURED_HANDLER_KEY] !== undefined || + globalHandlers[LAZY_HANDLER_KEY] !== undefined + ) { + return; + } + const handler = new LazyDataCacheHandler(load); + globalHandlers[HANDLER_KEY] = handler; + globalHandlers[LAZY_HANDLER_KEY] = handler; } export function getDataCacheHandler(): CacheHandler { diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 566a04c30e..9d8cd4926b 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -15,8 +15,7 @@ * vinext({ cache: { data: kvDataAdapter({ binding: 'VINEXT_KV_CACHE' }) } }) * * The imperative `setCacheHandler` / `setDataCacheHandler` setters are - * deprecated for consumers and retained only as the internal registration - * target used by the generated cache-adapter module. + * deprecated for consumers and retained for backwards compatibility. */ import { diff --git a/packages/vinext/src/shims/cdn-cache-state.ts b/packages/vinext/src/shims/cdn-cache-state.ts new file mode 100644 index 0000000000..1a5e320f33 --- /dev/null +++ b/packages/vinext/src/shims/cdn-cache-state.ts @@ -0,0 +1,28 @@ +import type { CdnCacheAdapter } from "./cdn-cache.js"; + +const CDN_CACHE_ADAPTER_KEY = Symbol.for("vinext.cdnCacheAdapter"); +const globals = globalThis as unknown as Record; + +/** Register an adapter without loading the origin cache implementation. */ +export function setCdnCacheAdapter(adapter: CdnCacheAdapter): void { + globals[CDN_CACHE_ADAPTER_KEY] = adapter; +} + +/** + * Lazily keep the first declaratively configured adapter shared across + * duplicated stage modules. Failed factories remain retryable so a later + * entrypoint with the required runtime bindings can register successfully. + */ +export function registerCdnCacheAdapter(factory: () => CdnCacheAdapter): void { + if (globals[CDN_CACHE_ADAPTER_KEY] !== undefined) return; + const adapter = factory(); + // Preserve an imperative adapter installed re-entrantly by the factory. + if (globals[CDN_CACHE_ADAPTER_KEY] === undefined) { + globals[CDN_CACHE_ADAPTER_KEY] = adapter; + } +} + +/** Read only an explicitly registered adapter; defaults belong to cdn-cache. */ +export function getExplicitCdnCacheAdapter(): CdnCacheAdapter | null { + return (globals[CDN_CACHE_ADAPTER_KEY] as CdnCacheAdapter | undefined) ?? null; +} diff --git a/packages/vinext/src/shims/cdn-cache.ts b/packages/vinext/src/shims/cdn-cache.ts index a06c00ec69..6dd4e8e472 100644 --- a/packages/vinext/src/shims/cdn-cache.ts +++ b/packages/vinext/src/shims/cdn-cache.ts @@ -25,11 +25,9 @@ * pre-split implementation. */ -import { - getDataCacheHandler, - type CacheHandlerValue, - type IncrementalCacheValue, -} from "./cache-handler.js"; +import type { CacheHandlerValue, IncrementalCacheValue } from "./cache-handler.js"; +import { getExplicitCdnCacheAdapter } from "./cdn-cache-state.js"; +export { setCdnCacheAdapter } from "./cdn-cache-state.js"; /** A map of response header name -> value the adapter wants applied or removed. */ export type CdnResponseHeaders = Record; @@ -106,6 +104,9 @@ export type CdnCacheAdapter = { */ readonly responseVary?: "verbatim"; + /** Provider-specific response headers whose values control shared caching. */ + readonly responsePolicyHeaderNames?: readonly string[]; + /** * Fresh App Page responses must reach clean EOF before this adapter may emit * shared-cache headers. Used by edge adapters whose cache sits in front of @@ -203,6 +204,7 @@ export class DefaultCdnCacheAdapter implements CdnCacheAdapter { readonly ownsBackgroundRevalidation = true; async get(key: string, ctx?: Record): Promise { + const { getDataCacheHandler } = await import("./cache-handler.js"); return getDataCacheHandler().get(key, ctx); } @@ -211,6 +213,7 @@ export class DefaultCdnCacheAdapter implements CdnCacheAdapter { data: IncrementalCacheValue | null, ctx?: Record, ): Promise { + const { getDataCacheHandler } = await import("./cache-handler.js"); await getDataCacheHandler().set(key, data, ctx); } @@ -241,9 +244,6 @@ export class DefaultCdnCacheAdapter implements CdnCacheAdapter { // 2. Otherwise, the origin-managed DefaultCdnCacheAdapter. // --------------------------------------------------------------------------- -const _CDN_KEY = Symbol.for("vinext.cdnCacheAdapter"); -const _gCdn = globalThis as unknown as Record; - let _defaultAdapter: DefaultCdnCacheAdapter | null = null; /** @@ -265,20 +265,15 @@ let _defaultAdapter: DefaultCdnCacheAdapter | null = null; * ``` * * The plugin registers the adapter across every runtime/router entry, so you - * don't have to call this from a worker entry. This setter remains as the - * internal registration target and for backwards compatibility, but is not the - * recommended consumer API. + * don't have to call this from a worker entry. This setter remains for + * backwards compatibility, but is not the recommended consumer API. */ -export function setCdnCacheAdapter(adapter: CdnCacheAdapter): void { - _gCdn[_CDN_KEY] = adapter; -} - /** * Get the active CDN cache adapter. An explicitly configured adapter wins; * otherwise the origin-managed {@link DefaultCdnCacheAdapter} is used. */ export function getCdnCacheAdapter(): CdnCacheAdapter { - const active = _gCdn[_CDN_KEY] as CdnCacheAdapter | undefined; + const active = getExplicitCdnCacheAdapter(); if (active) return active; return (_defaultAdapter ??= new DefaultCdnCacheAdapter()); diff --git a/tests/cache-adapters-config.test.ts b/tests/cache-adapters-config.test.ts index ff39b0ee65..a5d76ce6c6 100644 --- a/tests/cache-adapters-config.test.ts +++ b/tests/cache-adapters-config.test.ts @@ -14,6 +14,7 @@ import path from "node:path"; import { describe, it, expect } from "vite-plus/test"; import { findVinextCacheConfigInPlugins, + generateCdnCacheAdapterModule, loadVinextCacheConfigFromViteConfig, generateCacheAdaptersModule, hasBuildIdentityResponseHeader, @@ -45,31 +46,35 @@ describe("generateCacheAdaptersModule", () => { const code = generateCacheAdaptersModule(cache); expect(code).toContain("export function registerConfiguredCacheAdapters() {}"); expect(code).not.toContain("import"); - expect(code).not.toContain("setDataCacheHandler"); - expect(code).not.toContain("setCdnCacheAdapter"); + expect(code).not.toContain("registerDataCacheHandler"); + expect(code).not.toContain("registerCdnCacheAdapter"); } }); it("wires only the data adapter when only data is configured", () => { const code = generateCacheAdaptersModule({ data: { adapter: "my-data-adapter" } }); expect(code).toContain(`import __vinextDataAdapterFactory from "my-data-adapter";`); - expect(code).toContain(`import { setDataCacheHandler } from "vinext/shims/cache-handler";`); expect(code).toContain( - "setDataCacheHandler(__vinextDataAdapterFactory({ env, options: undefined }));", + `import { registerDataCacheHandler } from "vinext/shims/cache-handler";`, + ); + expect(code).toContain( + "registerDataCacheHandler(() => __vinextDataAdapterFactory({ env, options: undefined }));", ); expect(code).not.toContain("__vinextCdnAdapterFactory"); - expect(code).not.toContain("setCdnCacheAdapter"); + expect(code).not.toContain("registerCdnCacheAdapter"); }); it("wires only the cdn adapter when only cdn is configured", () => { const code = generateCacheAdaptersModule({ cdn: { adapter: "my-cdn-adapter" } }); expect(code).toContain(`import __vinextCdnAdapterFactory from "my-cdn-adapter";`); - expect(code).toContain(`import { setCdnCacheAdapter } from "vinext/shims/cdn-cache";`); expect(code).toContain( - "setCdnCacheAdapter(__vinextCdnAdapterFactory({ env, options: undefined }));", + `import { registerCdnCacheAdapter } from "vinext/shims/cdn-cache-state";`, + ); + expect(code).toContain( + "registerCdnCacheAdapter(() => __vinextCdnAdapterFactory({ env, options: undefined }));", ); expect(code).not.toContain("__vinextDataAdapterFactory"); - expect(code).not.toContain("setDataCacheHandler"); + expect(code).not.toContain("registerDataCacheHandler"); }); it("inlines descriptor options and forwards them to the factory", () => { @@ -77,7 +82,7 @@ describe("generateCacheAdaptersModule", () => { data: { adapter: "@vinext/cloudflare/cache/kv-data-adapter", options: { binding: "MY_KV" } }, }); expect(code).toContain( - `setDataCacheHandler(__vinextDataAdapterFactory({ env, options: {"binding":"MY_KV"} }));`, + `registerDataCacheHandler(() => __vinextDataAdapterFactory({ env, options: {"binding":"MY_KV"} }));`, ); }); @@ -88,8 +93,8 @@ describe("generateCacheAdaptersModule", () => { }); expect(code).toContain(`from "@vinext/cloudflare/cache/cdn-adapter";`); expect(code).toContain(`from "@vinext/cloudflare/cache/kv-data-adapter";`); - expect(code).toContain("setDataCacheHandler(__vinextDataAdapterFactory("); - expect(code).toContain("setCdnCacheAdapter(__vinextCdnAdapterFactory("); + expect(code).toContain("registerDataCacheHandler(() => __vinextDataAdapterFactory("); + expect(code).toContain("registerCdnCacheAdapter(() => __vinextCdnAdapterFactory("); expect(code).toContain( "if (typeof process !== 'undefined' && process.env?.__VINEXT_PRERENDER_PATH_DISCOVERY === '1') return;", ); @@ -97,6 +102,17 @@ describe("generateCacheAdaptersModule", () => { expect(code).toContain("__vinextCacheAdaptersRegistered = true;"); }); + it("advertises data-cache availability without importing it into the request stage", () => { + const code = generateCdnCacheAdapterModule({ + cdn: { adapter: "my-cdn-adapter" }, + data: { adapter: "my-data-adapter" }, + }); + + expect(code).toContain("export const hasConfiguredDataCache = true;"); + expect(code).toContain('from "my-cdn-adapter"'); + expect(code).not.toContain("my-data-adapter"); + }); + it("logs registration failures without printing raw Error stack traces", () => { const code = generateCacheAdaptersModule({ cdn: { adapter: "@vinext/cloudflare/cache/cdn-adapter" }, diff --git a/tests/cdn-cache.test.ts b/tests/cdn-cache.test.ts index 44a4db8939..1d90bcf359 100644 --- a/tests/cdn-cache.test.ts +++ b/tests/cdn-cache.test.ts @@ -20,8 +20,11 @@ import { type CdnCacheableHeaderInput, type CdnResponseHeaders, } from "../packages/vinext/src/shims/cdn-cache.js"; +import { registerCdnCacheAdapter } from "../packages/vinext/src/shims/cdn-cache-state.js"; import { MemoryCacheHandler, + registerDataCacheHandler, + registerLazyDataCacheHandler, setDataCacheHandler, setCacheHandler, getDataCacheHandler, @@ -62,11 +65,177 @@ describe("data cache handler aliases", () => { setDataCacheHandler(handler); expect(getCacheHandler()).toBe(handler); }); + + it("creates a declarative handler once while keeping failed factories retryable", () => { + const handlerKey = Symbol.for("vinext.cacheHandler"); + const configuredKey = Symbol.for("vinext.configuredCacheHandler"); + const explicitKey = Symbol.for("vinext.explicitCacheHandler"); + const globals = globalThis as unknown as Record; + const previousHandler = globals[handlerKey]; + const previousConfigured = globals[configuredKey]; + const previousExplicit = globals[explicitKey]; + delete globals[handlerKey]; + delete globals[configuredKey]; + delete globals[explicitKey]; + + try { + const failedFactory = vi.fn((): CacheHandler => { + throw new Error("missing binding"); + }); + expect(() => registerDataCacheHandler(failedFactory)).toThrow("missing binding"); + + const first = new MemoryCacheHandler(); + const duplicateFactory = vi.fn(() => new MemoryCacheHandler()); + registerDataCacheHandler(() => first); + registerDataCacheHandler(duplicateFactory); + expect(getDataCacheHandler()).toBe(first); + expect(duplicateFactory).not.toHaveBeenCalled(); + + const explicit = new MemoryCacheHandler(); + setDataCacheHandler(explicit); + expect(getDataCacheHandler()).toBe(explicit); + expect(failedFactory).toHaveBeenCalledOnce(); + } finally { + if (previousHandler === undefined) delete globals[handlerKey]; + else globals[handlerKey] = previousHandler; + if (previousConfigured === undefined) delete globals[configuredKey]; + else globals[configuredKey] = previousConfigured; + if (previousExplicit === undefined) delete globals[explicitKey]; + else globals[explicitKey] = previousExplicit; + } + }); + + it("does not evaluate a declarative factory after an imperative registration", () => { + const explicit = new MemoryCacheHandler(); + const factory = vi.fn(() => new MemoryCacheHandler()); + setDataCacheHandler(explicit); + + registerDataCacheHandler(factory); + + expect(factory).not.toHaveBeenCalled(); + expect(getDataCacheHandler()).toBe(explicit); + }); + + it("loads a request-stage data adapter only on first cache use", async () => { + const handlerKey = Symbol.for("vinext.cacheHandler"); + const configuredKey = Symbol.for("vinext.configuredCacheHandler"); + const explicitKey = Symbol.for("vinext.explicitCacheHandler"); + const lazyKey = Symbol.for("vinext.lazyCacheHandler"); + const globals = globalThis as unknown as Record; + const previous = new Map( + [handlerKey, configuredKey, explicitKey, lazyKey].map((key) => [key, globals[key]]), + ); + for (const key of previous.keys()) delete globals[key]; + + try { + const configured = new MemoryCacheHandler(); + const load = vi.fn(async () => registerDataCacheHandler(() => configured)); + registerLazyDataCacheHandler(load); + registerLazyDataCacheHandler(vi.fn()); + + expect(load).not.toHaveBeenCalled(); + const proxy = getDataCacheHandler(); + await Promise.all([proxy.get("a"), proxy.get("b")]); + + expect(load).toHaveBeenCalledOnce(); + expect(getDataCacheHandler()).toBe(configured); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete globals[key]; + else globals[key] = value; + } + } + }); + + it("preserves an imperative handler installed by a declarative factory", () => { + const handlerKey = Symbol.for("vinext.cacheHandler"); + const configuredKey = Symbol.for("vinext.configuredCacheHandler"); + const explicitKey = Symbol.for("vinext.explicitCacheHandler"); + const globals = globalThis as unknown as Record; + const previousHandler = globals[handlerKey]; + const previousConfigured = globals[configuredKey]; + const previousExplicit = globals[explicitKey]; + delete globals[handlerKey]; + delete globals[configuredKey]; + delete globals[explicitKey]; + + try { + const explicit = new MemoryCacheHandler(); + registerDataCacheHandler(() => { + setDataCacheHandler(explicit); + return new MemoryCacheHandler(); + }); + expect(getDataCacheHandler()).toBe(explicit); + } finally { + if (previousHandler === undefined) delete globals[handlerKey]; + else globals[handlerKey] = previousHandler; + if (previousConfigured === undefined) delete globals[configuredKey]; + else globals[configuredKey] = previousConfigured; + if (previousExplicit === undefined) delete globals[explicitKey]; + else globals[explicitKey] = previousExplicit; + } + }); }); // ─── DefaultCdnCacheAdapter ────────────────────────────────────────────── describe("DefaultCdnCacheAdapter", () => { + it("keeps the first declarative registration while allowing an explicit override", () => { + const adapterKey = Symbol.for("vinext.cdnCacheAdapter"); + const globals = globalThis as unknown as Record; + const previous = globals[adapterKey]; + delete globals[adapterKey]; + + try { + const first = new DefaultCdnCacheAdapter(); + const duplicate = new DefaultCdnCacheAdapter(); + const duplicateFactory = vi.fn(() => duplicate); + registerCdnCacheAdapter(() => first); + registerCdnCacheAdapter(duplicateFactory); + expect(getCdnCacheAdapter()).toBe(first); + expect(duplicateFactory).not.toHaveBeenCalled(); + + setCdnCacheAdapter(duplicate); + expect(getCdnCacheAdapter()).toBe(duplicate); + } finally { + if (previous === undefined) delete globals[adapterKey]; + else globals[adapterKey] = previous; + } + }); + + it("does not evaluate declarative factories after an imperative registration", () => { + const explicit = new DefaultCdnCacheAdapter(); + const factory = vi.fn(() => new DefaultCdnCacheAdapter()); + setCdnCacheAdapter(explicit); + + registerCdnCacheAdapter(factory); + + expect(factory).not.toHaveBeenCalled(); + expect(getCdnCacheAdapter()).toBe(explicit); + }); + + it("retries declarative registration after a factory failure", () => { + const adapterKey = Symbol.for("vinext.cdnCacheAdapter"); + const globals = globalThis as unknown as Record; + const previous = globals[adapterKey]; + delete globals[adapterKey]; + + try { + expect(() => + registerCdnCacheAdapter(() => { + throw new Error("missing binding"); + }), + ).toThrow("missing binding"); + + const retry = new DefaultCdnCacheAdapter(); + registerCdnCacheAdapter(() => retry); + expect(getCdnCacheAdapter()).toBe(retry); + } finally { + if (previous === undefined) delete globals[adapterKey]; + else globals[adapterKey] = previous; + } + }); + it("owns background revalidation (origin-managed ISR)", () => { expect(new DefaultCdnCacheAdapter().ownsBackgroundRevalidation).toBe(true); });