diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 1d2efaca96..c0eb33f844 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -660,6 +660,13 @@ export default { if (typeof runMiddleware === "function") { const result = await runMiddleware(request, ctx); + // 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) { const redirectHeaders = new Headers({ Location: result.redirectUrl }); diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index ca4f13888d..e18ff62f49 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1600,10 +1600,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __mwNextConfig = (__basePath || __i18nConfig) ? { basePath: __basePath, i18n: __i18nConfig ?? undefined } : undefined; const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); - const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); - const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); - const _mwExecCtx = _getRequestExecutionContext(); - if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } + let mwResponse; + try { + mwResponse = await middlewareFn(nextRequest, mwFetchEvent); + } finally { + const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); + const _mwExecCtx = _getRequestExecutionContext(); + if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } + } if (mwResponse) { // Check for x-middleware-next (continue) if (mwResponse.headers.get("x-middleware-next") === "1") { diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 825da4bb66..419b1bd1f9 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -201,6 +201,8 @@ async function _runMiddleware(request) { try { response = await middlewareFn(nextRequest, fetchEvent); } catch (e) { console.error("[vinext] Middleware error:", e); + var _mwCtxErr = _getRequestExecutionContext(); + if (_mwCtxErr && typeof _mwCtxErr.waitUntil === "function") { _mwCtxErr.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; } var _mwCtx = _getRequestExecutionContext(); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 15c6ef9271..6b302c2759 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2467,6 +2467,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { nextConfig?.basePath, ); + // Settle waitUntil promises — no ctx.waitUntil() in dev, but + // promises must still run for parity with prod (session sync, telemetry, etc.) + if (result.waitUntilPromises?.length) { + void Promise.allSettled(result.waitUntilPromises); + } + if (!result.continue) { if (result.redirectUrl) { const redirectHeaders: Record = { diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index 0244f3dd48..30e0d7d8d6 100644 --- a/packages/vinext/src/server/middleware.ts +++ b/packages/vinext/src/server/middleware.ts @@ -358,6 +358,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[]; } /** @@ -444,16 +446,13 @@ export async function runMiddleware( response: new Response(message, { status: 500, }), + waitUntilPromises: fetchEvent.waitUntilPromises, }; } - // Drain waitUntil promises (fire-and-forget: we don't block the response - // on these — matches platform semantics where waitUntil runs after response). - void fetchEvent.drainWaitUntil(); - // No response = continue if (!response) { - return { continue: true }; + return { continue: true, waitUntilPromises: fetchEvent.waitUntilPromises }; } // Check for x-middleware-next header (NextResponse.next()) @@ -466,7 +465,7 @@ export async function runMiddleware( responseHeaders.append(key, value); } } - return { continue: true, responseHeaders }; + return { continue: true, responseHeaders, waitUntilPromises: fetchEvent.waitUntilPromises }; } // Check for redirect (3xx status) @@ -485,6 +484,7 @@ export async function runMiddleware( redirectUrl: location, redirectStatus: response.status, responseHeaders, + waitUntilPromises: fetchEvent.waitUntilPromises, }; } } @@ -512,9 +512,10 @@ export async function runMiddleware( rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders, + waitUntilPromises: fetchEvent.waitUntilPromises, }; } // Middleware returned a full Response (e.g., blocking, custom body) - return { continue: false, response }; + return { continue: false, response, waitUntilPromises: fetchEvent.waitUntilPromises }; } diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 14c62e4686..3db842da41 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -1186,6 +1186,13 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { if (typeof runMiddleware === "function") { const result = await runMiddleware(webRequest, undefined); + // Settle waitUntil promises immediately — in Node.js there's no ctx.waitUntil(). + // Must run BEFORE the !result.continue check so promises survive redirect/response paths + // (e.g. Clerk auth redirecting unauthenticated users). + if (result.waitUntilPromises && result.waitUntilPromises.length > 0) { + void Promise.allSettled(result.waitUntilPromises); + } + if (!result.continue) { if (result.redirectUrl) { const redirectHeaders: Record = { diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 2c22c03541..44ca6507c1 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -696,6 +696,10 @@ export class NextFetchEvent { this._waitUntilPromises.push(promise); } + get waitUntilPromises(): Promise[] { + return this._waitUntilPromises; + } + /** Drain all waitUntil promises. Returns a single promise that settles when all are done. */ drainWaitUntil(): Promise[]> { return Promise.allSettled(this._waitUntilPromises); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index fa1acb0a05..b9f6d783d6 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -12714,10 +12714,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __mwNextConfig = (__basePath || __i18nConfig) ? { basePath: __basePath, i18n: __i18nConfig ?? undefined } : undefined; const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); - const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); - const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); - const _mwExecCtx = _getRequestExecutionContext(); - if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } + let mwResponse; + try { + mwResponse = await middlewareFn(nextRequest, mwFetchEvent); + } finally { + const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); + const _mwExecCtx = _getRequestExecutionContext(); + if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } + } if (mwResponse) { // Check for x-middleware-next (continue) if (mwResponse.headers.get("x-middleware-next") === "1") { @@ -14716,6 +14720,8 @@ async function _runMiddleware(request) { try { response = await middlewareFn(nextRequest, fetchEvent); } catch (e) { console.error("[vinext] Middleware error:", e); + var _mwCtxErr = _getRequestExecutionContext(); + if (_mwCtxErr && typeof _mwCtxErr.waitUntil === "function") { _mwCtxErr.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; } var _mwCtx = _getRequestExecutionContext(); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 27cd3f5102..b1127f63a4 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3201,6 +3201,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("RSC Flight hint fix", () => { diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 818699e56b..ce8c5bea06 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest, NextResponse, NextFetchEvent } from "next/server"; import { recordMiddlewareInvocation } from "./instrumentation-state"; /** @@ -12,7 +12,7 @@ import { recordMiddlewareInvocation } from "./instrumentation-state"; * - Block with 403 * - Search params forwarding */ -export function middleware(request: NextRequest) { +export function middleware(request: NextRequest, event: NextFetchEvent) { // Test NextRequest.nextUrl - this would fail with TypeError if request is plain Request const { pathname } = request.nextUrl; @@ -75,6 +75,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 }); + } + // Inject mw-before-user=1 cookie for beforeFiles rewrite gating test. // In App Router order, beforeFiles rewrites run after middleware, so they // should see this cookie. The /mw-gated-before rule in next.config.ts has: @@ -161,6 +170,7 @@ export const config = { "/middleware-rewrite-status", "/middleware-blocked", "/middleware-throw", + "/middleware-event", "/search-query", "/headers/override-from-middleware", "/header-override-delete", diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 8154fa6dd7..11cad44258 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -3289,6 +3289,60 @@ 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 mockRunner = { + import: 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(mockRunner 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.continue).toBe(false); + expect(result.redirectUrl).toBeDefined(); + expect(result.waitUntilPromises).toBeDefined(); + expect(result.waitUntilPromises!.length).toBe(1); + expect(result.waitUntilPromises![0]).toBe(capturedPromise); + }); + + it("runMiddleware bubbles up waitUntil promises on continue: true path", async () => { + const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js"); + const { NextResponse } = await import("../packages/vinext/src/shims/server.js"); + + let capturedPromise: Promise | null = null; + const mockRunner = { + import: async () => ({ + middleware: (_req: Request, event: { waitUntil: (p: Promise) => void }) => { + const p = Promise.resolve("analytics"); + capturedPromise = p; + event.waitUntil(p); + return NextResponse.next(); + }, + config: { matcher: ["/dashboard"] }, + }), + }; + + const request = new Request("http://localhost/dashboard"); + const result = await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request); + + expect(result.continue).toBe(true); + 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.