Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vinext/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const LIBRARY_SUPPORT: Record<string, { status: Status; detail?: string }> = {
"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)" },
Expand Down
20 changes: 17 additions & 3 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ interface Env {
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };

export default {
async fetch(request: Request, env: Env): Promise<Response> {
async fetch(request: Request, env: Env, ctx: any): Promise<Response> {
const url = new URL(request.url);

// Image optimization via Cloudflare Images binding.
Expand All @@ -432,7 +432,7 @@ export default {
}

// Delegate everything else to vinext
return handler.fetch(request);
return handler.fetch(request, env, ctx);
},
};
`;
Expand Down Expand Up @@ -483,7 +483,7 @@ const imageConfig: ImageConfig | undefined = vinextConfig?.images ? {
} : undefined;

export default {
async fetch(request: Request, env: Env): Promise<Response> {
async fetch(request: Request, env: Env, ctx: any): Promise<Response> {
try {
const url = new URL(request.url);
let pathname = url.pathname;
Expand Down Expand Up @@ -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);
}
}
Comment on lines +557 to +562
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct placement — extracting waitUntilPromises before the !result.continue check ensures promises survive all middleware result paths (redirect, custom response, continue). This is the pattern that prod-server.ts should follow too.


if (!result.continue) {
if (result.redirectUrl) {
return new Response(null, {
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 8 additions & 7 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -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");
Expand All @@ -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 };
}
`
: `
Expand Down
34 changes: 29 additions & 5 deletions packages/vinext/src/server/app-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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 });
Comment on lines 1264 to +1270
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only runs for responses that flow through the normal _handleRequest return path. The early-return cases (redirect on line 1429, custom response on line 1452) now also attach __vinextWaitUntil — good, that was a gap in the initial version of this PR.

}

return response;
})
)
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Comment on lines +1401 to +1405
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the && mwEvent.waitUntilPromises.length > 0 check is unnecessary — spreading an empty array is a no-op and the downstream checks all guard on length. Not worth changing, just noting the redundancy.


if (mwResponse) {
// Check for x-middleware-next (continue)
if (mwResponse.headers.get("x-middleware-next") === "1") {
Expand All @@ -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
Comment on lines 1421 to 1431
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — this covers the most critical early-return path (Clerk auth redirect). The Object.defineProperty with enumerable: false ensures the property doesn't leak into JSON serialization or header iteration.

Expand All @@ -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;
}
}
Expand Down
15 changes: 14 additions & 1 deletion packages/vinext/src/server/app-router-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@
// @ts-expect-error — virtual module resolved by vinext
import rscHandler from "virtual:vinext-rsc-entry";

interface ExecutionContext {
waitUntil(promise: Promise<any>): void;
passThroughOnException(): void;
}

export default {
async fetch(request: Request): Promise<Response> {
async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);

// Normalize backslashes (browsers treat /\ as //) before any other checks.
Expand Down Expand Up @@ -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);
}
}
Comment on lines +55 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard is thorough and correctly placed before the instanceof Response check. The in operator detects non-enumerable properties, so this works with the Object.defineProperty(..., { enumerable: false }) pattern used upstream. Good.


if (result instanceof Response) {
return result;
}
Expand Down
15 changes: 10 additions & 5 deletions packages/vinext/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<unknown>[];
}

/**
Expand Down Expand Up @@ -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 =
Expand All @@ -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())
Expand All @@ -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)
Expand All @@ -327,6 +330,7 @@ export async function runMiddleware(
redirectUrl: location,
redirectStatus: response.status,
responseHeaders,
waitUntilPromises: event.waitUntilPromises,
};
}
}
Expand Down Expand Up @@ -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 };
}
7 changes: 7 additions & 0 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
}
Comment on lines +834 to +839
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: this Promise.allSettled block is placed after the !result.continue early returns (lines 780-803, not in this diff). When middleware returns a redirect or custom response (continue: false), the function returns before reaching this code, so waitUntilPromises are silently dropped.

This is the most common Clerk auth pattern — redirect unauthenticated users. Compare with deploy.ts line 558, which correctly extracts waitUntilPromises before the !result.continue check.

Fix: move this block above the if (!result.continue) guard (around line 779), so promises are settled regardless of the middleware result path:

const result = await runMiddleware(webRequest);

// Settle waitUntil promises immediately — in Node.js there's no ctx.waitUntil().
if (result.waitUntilPromises?.length) {
  Promise.allSettled(result.waitUntilPromises).catch(() => {});
}

if (!result.continue) {
  // ... existing redirect/response handling

}

// Unpack x-middleware-request-* headers into the actual request so that
Expand Down
4 changes: 4 additions & 0 deletions packages/vinext/src/shims/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,10 @@ export class NextFetchEvent {
waitUntil(promise: Promise<unknown>): void {
this._waitUntilPromises.push(promise);
}

get waitUntilPromises(): Promise<unknown>[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing the private array directly means callers can mutate it (push, splice, etc.). This is fine for the current internal-only usage, but worth noting the tradeoff. If this ever becomes part of a public API surface, consider returning a copy ([...this._waitUntilPromises]). Not blocking.

return this._waitUntilPromises;
}
}

// ---------------------------------------------------------------------------
Expand Down
9 changes: 8 additions & 1 deletion tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test verifies that event.waitUntil exists and is callable, which is good for basic coverage. But it doesn't verify the bubble-up behavior — that the promises actually end up in MiddlewareResult.waitUntilPromises and eventually reach ctx.waitUntil().

Consider adding a unit test that calls runMiddleware() from middleware.ts directly and asserts that result.waitUntilPromises contains the registered promises. That would cover the most critical behavior in this PR — the plumbing — without needing an e2e Worker setup.

const text = await res.text();
expect(text).toBe("Event OK");
});
});

describe("SSR entry CSS preload fix", () => {
Expand Down
6 changes: 3 additions & 3 deletions tests/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>");
});

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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<Response>");
});

Expand Down
Loading
Loading