From 0cbbe577dfa837fd74a4ec50dcffb01717b8e332 Mon Sep 17 00:00:00 2001 From: Nicholas Bardy Date: Fri, 27 Feb 2026 01:05:10 +0800 Subject: [PATCH 1/2] fix: pass NextFetchEvent to middleware and bubble up waitUntil promises to Cloudflare Worker ctx - Instantiates `NextFetchEvent` with `waitUntil` shim and passes to `middlewareFn` - Extracts `_waitUntilPromises` from the middleware result and attaches them to the `Response` object via `__vinextWaitUntil` - Updates the generated Cloudflare Worker `fetch` signature to include `ctx: ExecutionContext` - Iterates over `__vinextWaitUntil` on the final response and delegates to Cloudflare's native `ctx.waitUntil(promise)` - Enables background tasks (like telemetry and session sync in `@clerk/nextjs`) to survive the worker response lifecycle - Updates `vinext check` to explicitly report `@clerk/nextjs` as supported - Adds `app-router.test.ts` assertion to verify `waitUntil` injection --- packages/vinext/src/check.ts | 2 +- packages/vinext/src/deploy.ts | 21 +++++++++++++--- packages/vinext/src/index.ts | 15 ++++++------ packages/vinext/src/server/app-dev-server.ts | 24 +++++++++++++++---- .../vinext/src/server/app-router-entry.ts | 14 ++++++++++- packages/vinext/src/server/middleware.ts | 15 ++++++++---- packages/vinext/src/shims/server.ts | 4 ++++ tests/app-router.test.ts | 9 ++++++- tests/deploy.test.ts | 6 ++--- tests/fixtures/app-basic/middleware.ts | 12 +++++++++- 10 files changed, 96 insertions(+), 26 deletions(-) diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index f5db48f4..6d353337 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -90,7 +90,7 @@ const LIBRARY_SUPPORT: Record = { "next-view-transitions": { status: "supported" }, "@vercel/analytics": { status: "supported", detail: "analytics script injected client-side" }, "next-intl": { status: "partial", detail: "works with middleware-based setup, some server component features may differ" }, - "@clerk/nextjs": { status: "unsupported", detail: "deep Next.js middleware integration not compatible" }, + "@clerk/nextjs": { status: "supported" }, "@auth/nextjs": { status: "unsupported", detail: "relies on Next.js internal auth handlers; consider migrating to better-auth" }, "next-auth": { status: "unsupported", detail: "relies on Next.js API route internals; consider migrating to better-auth (see https://authjs.dev/getting-started/migrate-to-better-auth)" }, "better-auth": { status: "supported", detail: "uses only public next/* APIs (headers, cookies, NextRequest/NextResponse)" }, diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index d5e55225..28a3a06a 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -406,7 +406,7 @@ interface Env { } export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env, ctx: any): Promise { const url = new URL(request.url); // Image optimization via Cloudflare Images binding. @@ -424,7 +424,7 @@ export default { } // Delegate everything else to vinext - return handler.fetch(request); + return handler.fetch(request, env, ctx); }, }; `; @@ -468,7 +468,7 @@ const configRewrites = vinextConfig?.rewrites ?? { beforeFiles: [], afterFiles: const configHeaders = vinextConfig?.headers ?? []; export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env, ctx: any): Promise { try { const url = new URL(request.url); let pathname = url.pathname; @@ -538,6 +538,13 @@ export default { if (typeof runMiddleware === "function") { const result = await runMiddleware(request); + // Bubble up waitUntil promises (e.g. Clerk telemetry/session sync) + if (result.waitUntilPromises?.length) { + for (const p of result.waitUntilPromises) { + ctx.waitUntil(p); + } + } + if (!result.continue) { if (result.redirectUrl) { return new Response(null, { @@ -671,6 +678,14 @@ export default { return new Response("404 - Not found", { status: 404 }); } + + // Bubble up any background tasks attached by the app-dev-server layer + if ("__vinextWaitUntil" in response) { + for (const p of (response as any).__vinextWaitUntil) { + ctx.waitUntil(p); + } + } + return mergeHeaders(response, middlewareHeaders, middlewareRewriteStatus); } catch (error) { console.error("[vinext] Worker error:", error); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 737f8e05..e6ddd65f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -661,7 +661,7 @@ export default function vinext(options: VinextOptions = {}): Plugin[] { // Generate middleware code if middleware.ts exists const middlewareImportCode = middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))}; -import { NextRequest } from "next/server";` +import { NextRequest, NextFetchEvent } from "next/server";` : ""; // The matcher config is read from the middleware module at import time. @@ -707,14 +707,15 @@ export async function runMiddleware(request) { mwRequest = new Request(mwUrl, request); } var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + var event = new NextFetchEvent({ page: normalizedPathname }); var response; - try { response = await middlewareFn(nextRequest); } + try { response = await middlewareFn(nextRequest, event); } catch (e) { console.error("[vinext] Middleware error:", e); return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; } - if (!response) return { continue: true }; + if (!response) return { continue: true, waitUntilPromises: event.waitUntilPromises }; if (response.headers.get("x-middleware-next") === "1") { var rHeaders = new Headers(); @@ -727,12 +728,12 @@ export async function runMiddleware(request) { key.startsWith("x-middleware-request-") ) rHeaders.append(key, value); } - return { continue: true, responseHeaders: rHeaders }; + return { continue: true, responseHeaders: rHeaders, waitUntilPromises: event.waitUntilPromises }; } if (response.status >= 300 && response.status < 400) { var location = response.headers.get("Location") || response.headers.get("location"); - if (location) return { continue: false, redirectUrl: location, redirectStatus: response.status }; + if (location) return { continue: false, redirectUrl: location, redirectStatus: response.status, waitUntilPromises: event.waitUntilPromises }; } var rewriteUrl = response.headers.get("x-middleware-rewrite"); @@ -744,10 +745,10 @@ export async function runMiddleware(request) { var rewritePath; try { var parsed = new URL(rewriteUrl, request.url); rewritePath = parsed.pathname + parsed.search; } catch { rewritePath = rewriteUrl; } - return { continue: true, rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders: rwHeaders }; + return { continue: true, rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders: rwHeaders, waitUntilPromises: event.waitUntilPromises }; } - return { continue: false, response: response }; + return { continue: false, response: response, waitUntilPromises: event.waitUntilPromises }; } ` : ` diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index 20c7eccc..a93c3980 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -214,7 +214,7 @@ import { import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders } from "next/headers"; -import { NextRequest } from "next/server"; +import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -1180,6 +1180,8 @@ function __applyConfigHeaders(pathname, ctx) { } export default async function handler(request) { + let _waitUntilPromises = []; + // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -1192,7 +1194,7 @@ export default async function handler(request) { _runWithPrivateCache(() => runWithFetchCache(async () => { const __reqCtx = __buildRequestContext(request); - const response = await _handleRequest(request, __reqCtx); + const response = await _handleRequest(request, __reqCtx, _waitUntilPromises); // 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. @@ -1206,6 +1208,12 @@ export default async function handler(request) { response.headers.set(h.key, h.value); } } + + // Expose waitUntil promises to the runtime (like Cloudflare Workers) + if (response && _waitUntilPromises && _waitUntilPromises.length > 0) { + Object.defineProperty(response, "__vinextWaitUntil", { value: _waitUntilPromises, enumerable: false }); + } + return response; }) ) @@ -1214,7 +1222,7 @@ export default async function handler(request) { ); } -async function _handleRequest(request, __reqCtx) { +async function _handleRequest(request, __reqCtx, _outerWaitUntilPromises) { const url = new URL(request.url); // ── Cross-origin request protection ───────────────────────────────── @@ -1319,7 +1327,15 @@ async function _handleRequest(request, __reqCtx) { mwUrl.pathname = cleanPathname; const mwRequest = new Request(mwUrl, request); const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); - const mwResponse = await middlewareFn(nextRequest); + const mwEvent = new NextFetchEvent({ page: cleanPathname }); + const mwResponse = await middlewareFn(nextRequest, mwEvent); + + // If the middleware registered any waitUntil promises, we need to bubble them up + // to the runtime (like Cloudflare Workers) so they aren't cancelled. + if (mwEvent.waitUntilPromises && mwEvent.waitUntilPromises.length > 0) { + _outerWaitUntilPromises.push(...mwEvent.waitUntilPromises); + } + if (mwResponse) { // Check for x-middleware-next (continue) if (mwResponse.headers.get("x-middleware-next") === "1") { diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 9b9f2727..b0378877 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -15,8 +15,13 @@ // @ts-expect-error — virtual module resolved by vinext import rscHandler from "virtual:vinext-rsc-entry"; +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; +} + export default { - async fetch(request: Request): Promise { + async fetch(request: Request, env: any, ctx: ExecutionContext): Promise { const url = new URL(request.url); // Normalize backslashes (browsers treat /\ as //) before any other checks. @@ -47,6 +52,13 @@ export default { // Delegate to RSC handler (which decodes + normalizes the pathname itself) const result = await rscHandler(request); + // If the middleware registered any waitUntil promises, hand them off to the runtime + if (result && typeof result === "object" && "__vinextWaitUntil" in result && Array.isArray(result.__vinextWaitUntil)) { + for (const p of result.__vinextWaitUntil) { + ctx.waitUntil(p); + } + } + if (result instanceof Response) { return result; } diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index 20839370..9d3d7fb1 100644 --- a/packages/vinext/src/server/middleware.ts +++ b/packages/vinext/src/server/middleware.ts @@ -21,7 +21,7 @@ import type { ViteDevServer } from "vite"; import fs from "node:fs"; import path from "node:path"; -import { NextRequest } from "../shims/server.js"; +import { NextRequest, NextFetchEvent } from "../shims/server.js"; import { safeRegExp } from "../config/config-matchers.js"; import { normalizePath } from "./normalize-path.js"; @@ -219,6 +219,8 @@ export interface MiddlewareResult { responseHeaders?: Headers; /** If the middleware returned a full Response, use it directly. */ response?: Response; + /** Promises registered via event.waitUntil() during middleware execution */ + waitUntilPromises?: Promise[]; } /** @@ -273,11 +275,12 @@ export async function runMiddleware( // Wrap in NextRequest so middleware gets .nextUrl, .cookies, .geo, .ip, etc. const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + const event = new NextFetchEvent({ page: normalizedPathname }); // Execute the middleware let response: Response | undefined; try { - response = await middlewareFn(nextRequest); + response = await middlewareFn(nextRequest, event); } catch (e: any) { console.error("[vinext] Middleware error:", e); const message = @@ -294,7 +297,7 @@ export async function runMiddleware( // No response = continue if (!response) { - return { continue: true }; + return { continue: true, waitUntilPromises: event.waitUntilPromises }; } // Check for x-middleware-next header (NextResponse.next()) @@ -309,7 +312,7 @@ export async function runMiddleware( responseHeaders.append(key, value); } } - return { continue: true, responseHeaders }; + return { continue: true, responseHeaders, waitUntilPromises: event.waitUntilPromises }; } // Check for redirect (3xx status) @@ -320,6 +323,7 @@ export async function runMiddleware( continue: false, redirectUrl: location, redirectStatus: response.status, + waitUntilPromises: event.waitUntilPromises, }; } } @@ -347,9 +351,10 @@ export async function runMiddleware( rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders, + waitUntilPromises: event.waitUntilPromises, }; } // Middleware returned a full Response (e.g., blocking, custom body) - return { continue: false, response }; + return { continue: false, response, waitUntilPromises: event.waitUntilPromises }; } diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 2d23b009..b8bda7f6 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -375,6 +375,10 @@ export class NextFetchEvent { waitUntil(promise: Promise): void { this._waitUntilPromises.push(promise); } + + get waitUntilPromises(): Promise[] { + return this._waitUntilPromises; + } } // --------------------------------------------------------------------------- diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index dd48331f..48968112 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2171,7 +2171,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("__safeDevHosts"); // Should call dev origin validation inside _handleRequest const callSite = code.indexOf("const __originBlock = __validateDevRequestOrigin(request)"); - const handleRequestIdx = code.indexOf("async function _handleRequest(request, __reqCtx)"); + const handleRequestIdx = code.indexOf("async function _handleRequest(request, __reqCtx, _outerWaitUntilPromises)"); expect(callSite).toBeGreaterThan(-1); expect(handleRequestIdx).toBeGreaterThan(-1); // The call should be inside the function body (after the function declaration) @@ -2280,6 +2280,13 @@ describe("App Router middleware with NextRequest", () => { expect(key.startsWith("x-middleware-")).toBe(false); } }); + + it("middleware receives event with waitUntil (for Clerk compat)", async () => { + const res = await fetch(`${baseUrl}/middleware-event`); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toBe("Event OK"); + }); }); describe("SSR entry CSS preload fix", () => { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 09a864f8..0d54a35a 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -340,7 +340,7 @@ describe("generateAppRouterWorkerEntry", () => { it("generates valid TypeScript", () => { const content = generateAppRouterWorkerEntry(); expect(content).toContain("export default"); - expect(content).toContain("async fetch(request: Request, env: Env)"); + expect(content).toContain("async fetch(request: Request, env: Env, ctx: any)"); expect(content).toContain("Promise"); }); @@ -351,7 +351,7 @@ describe("generateAppRouterWorkerEntry", () => { it("delegates to handler.fetch", () => { const content = generateAppRouterWorkerEntry(); - expect(content).toContain("handler.fetch(request)"); + expect(content).toContain("handler.fetch(request, env, ctx)"); }); it("includes auto-generated comment", () => { @@ -398,7 +398,7 @@ describe("generatePagesRouterWorkerEntry", () => { it("generates valid TypeScript", () => { const content = generatePagesRouterWorkerEntry(); expect(content).toContain("export default"); - expect(content).toContain("async fetch(request: Request, env: Env)"); + expect(content).toContain("async fetch(request: Request, env: Env, ctx: any)"); expect(content).toContain("Promise"); }); diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 4022d5be..2089862a 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from "next/server"; * - Block with 403 * - Search params forwarding */ -export function middleware(request: NextRequest) { +export function middleware(request: NextRequest, event: any) { // Test NextRequest.nextUrl - this would fail with TypeError if request is plain Request const { pathname } = request.nextUrl; @@ -60,6 +60,15 @@ export function middleware(request: NextRequest) { throw new Error("middleware crash"); } + // Test event and event.waitUntil (needed for Clerk etc) + if (pathname === "/middleware-event") { + if (!event || typeof event.waitUntil !== "function") { + return new Response("Missing event.waitUntil", { status: 500 }); + } + event.waitUntil(Promise.resolve()); + return new Response("Event OK", { status: 200 }); + } + // Forward search params as a header for RSC testing // Ref: opennextjs-cloudflare middleware.ts — search-params header const requestHeaders = new Headers(request.headers); @@ -86,6 +95,7 @@ export const config = { "/middleware-rewrite-status", "/middleware-blocked", "/middleware-throw", + "/middleware-event", "/search-query", "/", ], From f07f226feb1290c978803f59dec59b4639587ad0 Mon Sep 17 00:00:00 2001 From: Nicholas Bardy Date: Sat, 7 Mar 2026 00:52:34 +0700 Subject: [PATCH 2/2] fix: address review feedback for waitUntil/NextFetchEvent support - Fix lost waitUntil promises on redirect/custom response early returns - Settle waitUntil promises in prod-server (Node.js) instead of dropping - Mark @clerk/nextjs as "partial" until ESM fix lands (clerk/javascript#7954) - Align __vinextWaitUntil guard in deploy.ts with app-router-entry.ts - Use const for non-reassigned _waitUntilPromises array - Add comment explaining waitUntil check ordering in app-router-entry - Add unit test verifying runMiddleware bubbles up waitUntil promises Co-Authored-By: Claude Opus 4.6 --- packages/vinext/src/check.ts | 2 +- packages/vinext/src/deploy.ts | 3 +- packages/vinext/src/server/app-dev-server.ts | 12 ++++++-- .../vinext/src/server/app-router-entry.ts | 3 +- packages/vinext/src/server/prod-server.ts | 7 +++++ tests/shims.test.ts | 28 +++++++++++++++++++ 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index a87a635e..febe82a7 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -91,7 +91,7 @@ const LIBRARY_SUPPORT: Record = { "next-view-transitions": { status: "supported" }, "@vercel/analytics": { status: "supported", detail: "analytics script injected client-side" }, "next-intl": { status: "partial", detail: "works with middleware-based setup, some server component features may differ" }, - "@clerk/nextjs": { status: "supported" }, + "@clerk/nextjs": { status: "partial", detail: "waitUntil supported; requires @clerk/nextjs ESM fix (clerk/javascript#7954)" }, "@auth/nextjs": { status: "unsupported", detail: "relies on Next.js internal auth handlers; consider migrating to better-auth" }, "next-auth": { status: "unsupported", detail: "relies on Next.js API route internals; consider migrating to better-auth (see https://authjs.dev/getting-started/migrate-to-better-auth)" }, "better-auth": { status: "supported", detail: "uses only public next/* APIs (headers, cookies, NextRequest/NextResponse)" }, diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index a133f046..61100ab8 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -702,9 +702,8 @@ export default { return new Response("404 - Not found", { status: 404 }); } - // Bubble up any background tasks attached by the app-dev-server layer - if ("__vinextWaitUntil" in response) { + if (response && typeof response === "object" && "__vinextWaitUntil" in response && Array.isArray((response as any).__vinextWaitUntil)) { for (const p of (response as any).__vinextWaitUntil) { ctx.waitUntil(p); } diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index ec1193cb..fcd5cec2 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -1236,7 +1236,7 @@ function __applyConfigHeaders(pathname, ctx) { } export default async function handler(request) { - let _waitUntilPromises = []; + const _waitUntilPromises = []; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an @@ -1421,6 +1421,11 @@ async function _handleRequest(request, __reqCtx, _outerWaitUntilPromises) { } else { // Check for redirect if (mwResponse.status >= 300 && mwResponse.status < 400) { + // Attach waitUntil promises before early return so they reach ctx.waitUntil(). + // This is the most common Clerk auth pattern: redirect unauthenticated users. + if (_outerWaitUntilPromises.length > 0) { + Object.defineProperty(mwResponse, "__vinextWaitUntil", { value: _outerWaitUntilPromises, enumerable: false }); + } return mwResponse; } // Check for rewrite @@ -1440,7 +1445,10 @@ async function _handleRequest(request, __reqCtx, _outerWaitUntilPromises) { } } } else { - // Middleware returned a custom response + // Middleware returned a custom response — attach waitUntil promises before early return + if (_outerWaitUntilPromises.length > 0) { + Object.defineProperty(mwResponse, "__vinextWaitUntil", { value: _outerWaitUntilPromises, enumerable: false }); + } return mwResponse; } } diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index b0378877..9c081bff 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -52,7 +52,8 @@ export default { // Delegate to RSC handler (which decodes + normalizes the pathname itself) const result = await rscHandler(request); - // If the middleware registered any waitUntil promises, hand them off to the runtime + // Extract waitUntil promises BEFORE the instanceof check — the property is + // non-enumerable on the Response and we need to hand it off to ctx before returning. if (result && typeof result === "object" && "__vinextWaitUntil" in result && Array.isArray(result.__vinextWaitUntil)) { for (const p of result.__vinextWaitUntil) { ctx.waitUntil(p); diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 65592260..89c9ad8b 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -830,6 +830,13 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Apply custom status code from middleware rewrite // (e.g. NextResponse.rewrite(url, { status: 403 })) middlewareRewriteStatus = result.rewriteStatus; + + // Await any background promises registered via event.waitUntil() during middleware. + // In prod-server (Node.js) there's no Workers ctx.waitUntil(), so we settle them + // here to avoid silently dropping work (e.g. Clerk session sync). + if (result.waitUntilPromises && result.waitUntilPromises.length > 0) { + Promise.allSettled(result.waitUntilPromises).catch(() => {}); + } } // Unpack x-middleware-request-* headers into the actual request so that diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 39db2623..6873b28b 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2202,6 +2202,34 @@ describe("double-encoded path handling in middleware", () => { expect(result.redirectStatus).toBe(307); }); + it("runMiddleware bubbles up waitUntil promises in result", async () => { + const { runMiddleware } = await import( + "../packages/vinext/src/server/middleware.js" + ); + + let capturedPromise: Promise | null = null; + const mockServer = { + ssrLoadModule: async () => ({ + middleware: (_req: Request, event: { waitUntil: (p: Promise) => void }) => { + const p = Promise.resolve("background-work"); + capturedPromise = p; + event.waitUntil(p); + return Response.redirect("http://localhost/login", 307); + }, + config: { matcher: ["/protected"] }, + }), + }; + + const request = new Request("http://localhost/protected"); + const result = await runMiddleware(mockServer as any, "/tmp/middleware.ts", request); + + // The most critical behavior: waitUntil promises must appear in the result + // so the runtime (e.g. Cloudflare Workers ctx.waitUntil) can keep them alive. + expect(result.waitUntilPromises).toBeDefined(); + expect(result.waitUntilPromises.length).toBe(1); + expect(result.waitUntilPromises[0]).toBe(capturedPromise); + }); + it("app-router-entry.ts does not double-decode (delegates to RSC handler)", async () => { // Verify the Cloudflare Worker entry does not decode the pathname itself, // leaving that responsibility to the RSC handler.