diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index 1bb18d0a..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: "unsupported", detail: "deep Next.js middleware integration not compatible" }, + "@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 0683a8dc..61100ab8 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -414,7 +414,7 @@ interface Env { // const imageConfig: ImageConfig = { dangerouslyAllowSVG: true }; 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. @@ -432,7 +432,7 @@ export default { } // Delegate everything else to vinext - return handler.fetch(request); + return handler.fetch(request, env, ctx); }, }; `; @@ -483,7 +483,7 @@ const imageConfig: ImageConfig | undefined = vinextConfig?.images ? { } : undefined; 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; @@ -554,6 +554,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, { @@ -695,6 +702,13 @@ export default { return new Response("404 - Not found", { status: 404 }); } + // Bubble up any background tasks attached by the app-dev-server layer + 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); + } + } + 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 1002bd58..85c065f9 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -671,7 +671,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. @@ -717,14 +717,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(); @@ -737,12 +738,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"); @@ -754,10 +755,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 a6337718..fcd5cec2 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"; @@ -1236,6 +1236,8 @@ function __applyConfigHeaders(pathname, ctx) { } export default async function handler(request) { + const _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 @@ -1248,7 +1250,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. @@ -1262,6 +1264,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; }) ) @@ -1270,7 +1278,7 @@ export default async function handler(request) { ); } -async function _handleRequest(request, __reqCtx) { +async function _handleRequest(request, __reqCtx, _outerWaitUntilPromises) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -1387,7 +1395,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") { @@ -1405,6 +1421,11 @@ async function _handleRequest(request, __reqCtx) { } 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 @@ -1424,7 +1445,10 @@ async function _handleRequest(request, __reqCtx) { } } } 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 9b9f2727..9c081bff 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,14 @@ export default { // Delegate to RSC handler (which decodes + normalizes the pathname itself) const result = await rscHandler(request); + // 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); + } + } + if (result instanceof Response) { return result; } diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index 11d3894c..13b5718f 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()) @@ -308,7 +311,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) @@ -327,6 +330,7 @@ export async function runMiddleware( redirectUrl: location, redirectStatus: response.status, responseHeaders, + waitUntilPromises: event.waitUntilPromises, }; } } @@ -354,9 +358,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/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/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 450004ba..14fa132c 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -394,6 +394,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 3eb67dbb..f3b9bd41 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2308,7 +2308,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) @@ -2530,6 +2530,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 cd94a962..be86fb05 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -339,7 +339,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"); }); @@ -350,7 +350,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", () => { @@ -397,7 +397,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", "/", ], 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.