diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index f0f58b6cf..2977f7584 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -29,7 +29,7 @@ */ import type { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "../shims/cache.js"; -import { getRequestExecutionContext } from "../shims/request-context.js"; +import { getRequestExecutionContext, type ExecutionContextLike } from "../shims/request-context.js"; // Cloudflare KV namespace interface (matches Workers types) interface KVNamespace { @@ -48,19 +48,6 @@ interface KVNamespace { }>; } -/** - * Minimal ExecutionContext interface for Cloudflare Workers. - * Background KV operations (cleanup deletes, cache writes) are registered - * with ctx.waitUntil() so they are not killed when the Response is returned. - * - * The preferred way to supply ctx is via runWithExecutionContext() in the - * worker entry (see vinext/shims/request-context). The constructor option - * is kept as a fallback for callers that set it explicitly. - */ -interface ExecutionContext { - waitUntil(promise: Promise): void; -} - /** Shape stored in KV for each cache entry. */ interface KVCacheEntry { value: IncrementalCacheValue | null; @@ -95,12 +82,12 @@ function validateTag(tag: string): string | null { export class KVCacheHandler implements CacheHandler { private kv: KVNamespace; private prefix: string; - private ctx: ExecutionContext | undefined; + private ctx: ExecutionContextLike | undefined; private ttlSeconds: number; constructor( kvNamespace: KVNamespace, - options?: { appPrefix?: string; ctx?: ExecutionContext; ttlSeconds?: number }, + options?: { appPrefix?: string; ctx?: ExecutionContextLike; ttlSeconds?: number }, ) { this.kv = kvNamespace; this.prefix = options?.appPrefix ? `${options.appPrefix}:` : ""; diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 5d43c20f7..a2aa9e0dc 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1404,7 +1404,7 @@ export default async function handler(request, ctx) { // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const response = await _handleRequest(request, __reqCtx, _mwCtx); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -1440,7 +1440,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -2643,7 +2643,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); } })(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__rscWritePromise); + _getRequestExecutionContext()?.waitUntil(__rscWritePromise); } return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); } @@ -2863,9 +2863,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR cache write error:", __cacheErr); } })(); - // Register with ExecutionContext so the Workers runtime keeps the isolate - // alive until the cache write finishes, even after the response is sent. - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__cachePromise); + // Register with ExecutionContext (from ALS) so the Workers runtime keeps + // the isolate alive until the cache write finishes, even after the response is sent. + _getRequestExecutionContext()?.waitUntil(__cachePromise); return new Response(__streamForClient, { status: __isrResponseProd.status, headers: __isrResponseProd.headers }); } return __isrResponseProd; diff --git a/packages/vinext/src/server/isr-cache.ts b/packages/vinext/src/server/isr-cache.ts index 54bef54d1..bb91c9333 100644 --- a/packages/vinext/src/server/isr-cache.ts +++ b/packages/vinext/src/server/isr-cache.ts @@ -23,14 +23,6 @@ import { import { fnv1a64 } from "../utils/hash.js"; import { getRequestExecutionContext } from "../shims/request-context.js"; -/** - * Minimal ExecutionContext interface for Cloudflare Workers. - * Matches the Workers runtime type; also works with the stub used in tests. - */ -export interface ExecutionContext { - waitUntil(promise: Promise): void; -} - export interface ISRCacheEntry { value: CacheHandlerValue; isStale: boolean; @@ -82,16 +74,11 @@ const pendingRegenerations = new Map>(); * If a regeneration for this key is already in progress, this is a no-op. * The renderFn should produce the new cache value and call isrSet internally. * - * On Cloudflare Workers, pass the `ExecutionContext` as `ctx` so the - * regeneration promise is registered with `ctx.waitUntil()`. Without this, - * the Workers runtime terminates the isolate as soon as the Response is - * returned, silently killing any pending background work. + * On Cloudflare Workers the regeneration promise is registered with + * `ctx.waitUntil()` via the ALS-backed ExecutionContext, keeping the isolate + * alive until the regeneration completes even after the Response is returned. */ -export function triggerBackgroundRegeneration( - key: string, - renderFn: () => Promise, - ctx?: ExecutionContext, -): void { +export function triggerBackgroundRegeneration(key: string, renderFn: () => Promise): void { if (pendingRegenerations.has(key)) return; const promise = renderFn() @@ -104,11 +91,10 @@ export function triggerBackgroundRegeneration( pendingRegenerations.set(key, promise); - // Register with the Workers ExecutionContext so the runtime keeps the - // isolate alive until the regeneration completes, even after the Response - // has already been sent to the client. - const execCtx = ctx ?? getRequestExecutionContext(); - execCtx?.waitUntil(promise); + // Register with the Workers ExecutionContext (retrieved from ALS) so the + // runtime keeps the isolate alive until the regeneration completes, even + // after the Response has already been sent to the client. + getRequestExecutionContext()?.waitUntil(promise); } // --------------------------------------------------------------------------- diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index cb23dad7f..cb30e2b1f 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1650,7 +1650,7 @@ export default async function handler(request, ctx) { // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const response = await _handleRequest(request, __reqCtx, _mwCtx); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -1686,7 +1686,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -2796,7 +2796,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); } })(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__rscWritePromise); + _getRequestExecutionContext()?.waitUntil(__rscWritePromise); } return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); } @@ -3004,9 +3004,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR cache write error:", __cacheErr); } })(); - // Register with ExecutionContext so the Workers runtime keeps the isolate - // alive until the cache write finishes, even after the response is sent. - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__cachePromise); + // Register with ExecutionContext (from ALS) so the Workers runtime keeps + // the isolate alive until the cache write finishes, even after the response is sent. + _getRequestExecutionContext()?.waitUntil(__cachePromise); return new Response(__streamForClient, { status: __isrResponseProd.status, headers: __isrResponseProd.headers }); } return __isrResponseProd; @@ -4362,7 +4362,7 @@ export default async function handler(request, ctx) { // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const response = await _handleRequest(request, __reqCtx, _mwCtx); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -4398,7 +4398,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -5511,7 +5511,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); } })(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__rscWritePromise); + _getRequestExecutionContext()?.waitUntil(__rscWritePromise); } return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); } @@ -5719,9 +5719,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR cache write error:", __cacheErr); } })(); - // Register with ExecutionContext so the Workers runtime keeps the isolate - // alive until the cache write finishes, even after the response is sent. - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__cachePromise); + // Register with ExecutionContext (from ALS) so the Workers runtime keeps + // the isolate alive until the cache write finishes, even after the response is sent. + _getRequestExecutionContext()?.waitUntil(__cachePromise); return new Response(__streamForClient, { status: __isrResponseProd.status, headers: __isrResponseProd.headers }); } return __isrResponseProd; @@ -7107,7 +7107,7 @@ export default async function handler(request, ctx) { // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const response = await _handleRequest(request, __reqCtx, _mwCtx); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -7143,7 +7143,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -8253,7 +8253,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); } })(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__rscWritePromise); + _getRequestExecutionContext()?.waitUntil(__rscWritePromise); } return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); } @@ -8469,9 +8469,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR cache write error:", __cacheErr); } })(); - // Register with ExecutionContext so the Workers runtime keeps the isolate - // alive until the cache write finishes, even after the response is sent. - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__cachePromise); + // Register with ExecutionContext (from ALS) so the Workers runtime keeps + // the isolate alive until the cache write finishes, even after the response is sent. + _getRequestExecutionContext()?.waitUntil(__cachePromise); return new Response(__streamForClient, { status: __isrResponseProd.status, headers: __isrResponseProd.headers }); } return __isrResponseProd; @@ -9859,7 +9859,7 @@ export default async function handler(request, ctx) { // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const response = await _handleRequest(request, __reqCtx, _mwCtx); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -9895,7 +9895,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -11005,7 +11005,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); } })(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__rscWritePromise); + _getRequestExecutionContext()?.waitUntil(__rscWritePromise); } return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); } @@ -11213,9 +11213,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR cache write error:", __cacheErr); } })(); - // Register with ExecutionContext so the Workers runtime keeps the isolate - // alive until the cache write finishes, even after the response is sent. - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__cachePromise); + // Register with ExecutionContext (from ALS) so the Workers runtime keeps + // the isolate alive until the cache write finishes, even after the response is sent. + _getRequestExecutionContext()?.waitUntil(__cachePromise); return new Response(__streamForClient, { status: __isrResponseProd.status, headers: __isrResponseProd.headers }); } return __isrResponseProd; @@ -12578,7 +12578,7 @@ export default async function handler(request, ctx) { // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const response = await _handleRequest(request, __reqCtx, _mwCtx); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -12614,7 +12614,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -13724,7 +13724,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); } })(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__rscWritePromise); + _getRequestExecutionContext()?.waitUntil(__rscWritePromise); } return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); } @@ -13932,9 +13932,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR cache write error:", __cacheErr); } })(); - // Register with ExecutionContext so the Workers runtime keeps the isolate - // alive until the cache write finishes, even after the response is sent. - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__cachePromise); + // Register with ExecutionContext (from ALS) so the Workers runtime keeps + // the isolate alive until the cache write finishes, even after the response is sent. + _getRequestExecutionContext()?.waitUntil(__cachePromise); return new Response(__streamForClient, { status: __isrResponseProd.status, headers: __isrResponseProd.headers }); } return __isrResponseProd; @@ -15486,7 +15486,7 @@ export default async function handler(request, ctx) { // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const response = await _handleRequest(request, __reqCtx, _mwCtx); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -15522,7 +15522,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -16714,7 +16714,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); } })(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__rscWritePromise); + _getRequestExecutionContext()?.waitUntil(__rscWritePromise); } return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); } @@ -16922,9 +16922,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { console.error("[vinext] ISR cache write error:", __cacheErr); } })(); - // Register with ExecutionContext so the Workers runtime keeps the isolate - // alive until the cache write finishes, even after the response is sent. - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(__cachePromise); + // Register with ExecutionContext (from ALS) so the Workers runtime keeps + // the isolate alive until the cache write finishes, even after the response is sent. + _getRequestExecutionContext()?.waitUntil(__cachePromise); return new Response(__streamForClient, { status: __isrResponseProd.status, headers: __isrResponseProd.headers }); } return __isrResponseProd; diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d7180d603..66abc95c0 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2489,7 +2489,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { // Should call dev origin validation inside _handleRequest const callSite = code.indexOf("const __originBlock = __validateDevRequestOrigin(request)"); const handleRequestIdx = code.indexOf( - "async function _handleRequest(request, __reqCtx, _mwCtx, ctx)", + "async function _handleRequest(request, __reqCtx, _mwCtx)", ); expect(callSite).toBeGreaterThan(-1); expect(handleRequestIdx).toBeGreaterThan(-1); diff --git a/tests/isr-cache.test.ts b/tests/isr-cache.test.ts index 5aa4d011c..bf53163f4 100644 --- a/tests/isr-cache.test.ts +++ b/tests/isr-cache.test.ts @@ -17,6 +17,7 @@ import { getRevalidateDuration, triggerBackgroundRegeneration, } from "../packages/vinext/src/server/isr-cache.js"; +import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js"; // ─── isrCacheKey ──────────────────────────────────────────────────────── @@ -237,7 +238,7 @@ describe("triggerBackgroundRegeneration", () => { expect(renderFnB).toHaveBeenCalledOnce(); }); - it("calls ctx.waitUntil with the regen promise when ctx is provided", async () => { + it("calls ctx.waitUntil with the regen promise when ctx is in ALS", async () => { const waitUntil = vi.fn(); const ctx = { waitUntil }; @@ -247,7 +248,9 @@ describe("triggerBackgroundRegeneration", () => { }); const renderFn = vi.fn().mockReturnValue(renderPromise); - triggerBackgroundRegeneration("regen-ctx-1", renderFn, ctx); + await runWithExecutionContext(ctx, async () => { + triggerBackgroundRegeneration("regen-ctx-1", renderFn); + }); expect(waitUntil).toHaveBeenCalledOnce(); expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise));