Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Correct placement — extracting and forwarding waitUntilPromises to ctx.waitUntil() before the !result.continue check ensures promises survive all middleware result paths (redirect, custom response, continue).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Correct placement — extracting and forwarding promises to ctx.waitUntil() before the !result.continue check ensures promises survive all middleware result paths. This is the pattern that prod-server.ts also follows.

for (const p of result.waitUntilPromises) {
ctx.waitUntil(p);
}
}
Comment on lines +663 to +668

Copy link
Copy Markdown
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) {
const redirectHeaders = new Headers({ Location: result.redirectUrl });
Expand Down
12 changes: 8 additions & 4 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[]> = {
Expand Down
15 changes: 8 additions & 7 deletions packages/vinext/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>[];
}

/**
Expand Down Expand Up @@ -444,16 +446,13 @@ export async function runMiddleware(
response: new Response(message, {
status: 500,
}),
waitUntilPromises: fetchEvent.waitUntilPromises,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good — this correctly includes waitUntilPromises in the error path. The latest Bonk review flagged this as missing, but it's present here. Looks like the review was reading a stale diff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good — including waitUntilPromises in the error path is correct. If middleware calls event.waitUntil(p) and then throws, the caller still gets the promises and can settle them properly.

};
}

// 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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All the return paths that are touched by this PR correctly include waitUntilPromises — good. But note the two untouched issues above this line:

  1. Line 454 (void fetchEvent.drainWaitUntil()) — this pre-existing call now double-settles every promise, since callers also settle them via ctx.waitUntil() or Promise.allSettled(). Should be removed now that the bubble-up pattern handles draining.

  2. Line 444-449 (catch block) — the error return doesn't include waitUntilPromises. If middleware schedules background work via event.waitUntil() then throws, those promises are silently lost.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good — the removal of void fetchEvent.drainWaitUntil() (which was between the catch block and this line) is correct. The old code settled promises locally and then discarded them. Now callers own draining via the returned waitUntilPromises, eliminating the double-settlement issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good — the removal of void fetchEvent.drainWaitUntil() that was previously here is correct. Callers now own draining through the returned waitUntilPromises, eliminating the double-settlement issue.

Note: the generated entries (pages-server-entry.ts:207, app-rsc-entry.ts:1604) still use drainWaitUntil() in their own inline middleware logic. That's fine — they're a separate code path that doesn't flow through this function. Just flagging for awareness that these are two parallel middleware execution implementations.

}

// Check for x-middleware-next header (NextResponse.next())
Expand All @@ -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)
Expand All @@ -485,6 +484,7 @@ export async function runMiddleware(
redirectUrl: location,
redirectStatus: response.status,
responseHeaders,
waitUntilPromises: fetchEvent.waitUntilPromises,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good — every middleware result path (continue, redirect, rewrite, full response) now returns waitUntilPromises. This is thorough.

};
}
}
Expand Down Expand Up @@ -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 };
}
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 @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good fix from the prior review. This correctly settles promises before the !result.continue check, so redirect/response early returns don't drop them.

One subtlety: since drainWaitUntil() in middleware.ts:454 also settles the same promises, they're currently being double-settled here too. Once the drainWaitUntil() call is removed (per the review comment on middleware.ts), this becomes the sole settler — which is the correct design.

void Promise.allSettled(result.waitUntilPromises);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Promise.allSettled never rejects by spec — it always resolves with an array of settled results. The void is fine for the fire-and-forget intent, but the code reads as if it could reject. Consider dropping the promise on the floor more explicitly, or just remove void since the return value is already unused:

Suggested change
void Promise.allSettled(result.waitUntilPromises);
Promise.allSettled(result.waitUntilPromises);

Trivial — not blocking.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Promise.allSettled never rejects by spec — it always resolves with an array of settled results. The void operator is unnecessary here. Not blocking, just slightly misleading since it implies the promise might reject.

Suggested change
void Promise.allSettled(result.waitUntilPromises);
Promise.allSettled(result.waitUntilPromises);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Promise.allSettled never rejects by spec — it always resolves with an array of settled results. The void operator is unnecessary and slightly misleading (implies the promise might reject). Harmless, but could be:

Suggested change
void Promise.allSettled(result.waitUntilPromises);
Promise.allSettled(result.waitUntilPromises);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Promise.allSettled never rejects by spec — it always resolves with an array of settled results. The void operator here implies the expression might reject and we're intentionally ignoring it, which is slightly misleading. The fire-and-forget intent is clear either way, so this is cosmetic.

Suggested change
void Promise.allSettled(result.waitUntilPromises);
Promise.allSettled(result.waitUntilPromises);

}

if (!result.continue) {
if (result.redirectUrl) {
const redirectHeaders: Record<string, string | string[]> = {
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 @@ -696,6 +696,10 @@ export class NextFetchEvent {
this._waitUntilPromises.push(promise);
}

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

Copy link
Copy Markdown
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — keeping the direct exposure for now since it's internal-only. Will revisit if this becomes public API.

return this._waitUntilPromises;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this exposes the internal array by reference, so callers could mutate it (push, splice, etc.). Fine for current internal-only usage. If this ever becomes part of a public API surface, consider returning a snapshot ([...this._waitUntilPromises]).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The getter exposes the backing array by reference, so callers can mutate it (e.g. fetchEvent.waitUntilPromises.push(...) or .length = 0). This is fine for internal use within middleware.ts, but if this class ever becomes part of the public API surface, consider returning a snapshot ([...this._waitUntilPromises]). Not blocking — just flagging for future awareness.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This exposes the internal backing array by reference. Currently fine for internal use (only middleware.ts reads it), but if NextFetchEvent ever becomes part of a public API surface, this should return a snapshot ([...this._waitUntilPromises]) to prevent callers from mutating the internal state. Not blocking.

}

/** Drain all waitUntil promises. Returns a single promise that settles when all are done. */
drainWaitUntil(): Promise<PromiseSettledResult<unknown>[]> {
return Promise.allSettled(this._waitUntilPromises);
Expand Down
14 changes: 10 additions & 4 deletions tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in a later revision — shims.test.ts now has a test that calls runMiddleware() directly and asserts result.waitUntilPromises contains the registered promises through a redirect path.

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

describe("RSC Flight hint fix", () => {
Expand Down
14 changes: 12 additions & 2 deletions tests/fixtures/app-basic/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest, NextResponse, NextFetchEvent } from "next/server";
import { recordMiddlewareInvocation } from "./instrumentation-state";

/**
Expand All @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good — the fixture now properly types the event parameter as NextFetchEvent and imports it. This addresses the nit from the latest Bonk review about using any.

// Test NextRequest.nextUrl - this would fail with TypeError if request is plain Request
const { pathname } = request.nextUrl;

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> | null = null;
const mockRunner = {
import: async () => ({
middleware: (_req: Request, event: { waitUntil: (p: Promise<unknown>) => void }) => {
const p = Promise.resolve("background-work");
capturedPromise = p;
event.waitUntil(p);
return Response.redirect("http://localhost/login", 307);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Solid test — exercises the exact Clerk auth pattern: waitUntil() + redirect, then asserts the promise surfaces in result.waitUntilPromises. This is the most critical plumbing behavior.

One suggestion for a follow-up: it would be valuable to also assert that result.continue === false and result.redirectUrl is set, to fully verify the redirect+waitUntil combo works as expected (not just that waitUntilPromises is populated).

},
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Solid test — exercises the exact Clerk auth pattern (waitUntil + redirect) and asserts promise identity. Consider also adding a test for the continue: true path (middleware calls waitUntil then returns NextResponse.next()) to cover the most common non-auth middleware pattern.

});

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<unknown> | null = null;
const mockRunner = {
import: async () => ({
middleware: (_req: Request, event: { waitUntil: (p: Promise<unknown>) => 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good test — exercises the exact Clerk pattern: waitUntil + redirect, then asserts the promise surfaces in result.waitUntilPromises. This covers the critical plumbing path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Solid test — exercises the exact Clerk pattern: waitUntil() + redirect, then asserts the promise surfaces in result.waitUntilPromises. Consider also asserting result.continue === false and result.redirectUrl to fully verify the redirect+waitUntil combo.

expect(result.waitUntilPromises![0]).toBe(capturedPromise);
});
Comment on lines +3292 to +3318

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good test — calls runMiddleware() directly, has the middleware call event.waitUntil(p) then return a redirect, and asserts the promise appears in result.waitUntilPromises. This covers the exact Clerk auth redirect pattern end-to-end through the middleware.ts plumbing.

One thing this doesn't cover: the bubble-up to __vinextWaitUntil on the Response object (the app-dev-server.ts layer). That would require a more involved integration test, but the current coverage is reasonable for this PR.


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.
Expand Down
Loading