-
Notifications
You must be signed in to change notification settings - Fork 359
fix: wire ctx.waitUntil for middleware fetch event background tasks
#126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0cbbe57
a8ee44d
f07f226
0f4e82d
bf04872
3a9975e
6fd374b
ced210e
a98855f
a8517db
bd85bbd
8cd310c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct placement — extracting and forwarding promises to |
||
| for (const p of result.waitUntilPromises) { | ||
| ctx.waitUntil(p); | ||
| } | ||
| } | ||
|
Comment on lines
+663
to
+668
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct placement — extracting |
||
|
|
||
| if (!result.continue) { | ||
| if (result.redirectUrl) { | ||
| const redirectHeaders = new Headers({ Location: result.redirectUrl }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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>[]; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -444,16 +446,13 @@ export async function runMiddleware( | |
| response: new Response(message, { | ||
| status: 500, | ||
| }), | ||
| waitUntilPromises: fetchEvent.waitUntilPromises, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — this correctly includes
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — including |
||
| }; | ||
| } | ||
|
|
||
| // 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 }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All the return paths that are touched by this PR correctly include
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — the removal of
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — the removal of Note: the generated entries ( |
||
| } | ||
|
|
||
| // 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — every middleware result path (continue, redirect, rewrite, full response) now returns |
||
| }; | ||
| } | ||
| } | ||
|
|
@@ -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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) { | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good fix from the prior review. This correctly settles promises before the One subtlety: since |
||||||||||||||||||
| void Promise.allSettled(result.waitUntilPromises); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Suggested change
Trivial — not blocking.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!result.continue) { | ||||||||||||||||||
| if (result.redirectUrl) { | ||||||||||||||||||
| const redirectHeaders: Record<string, string | string[]> = { | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -696,6 +696,10 @@ export class NextFetchEvent { | |
| this._waitUntilPromises.push(promise); | ||
| } | ||
|
|
||
| get waitUntilPromises(): Promise<unknown>[] { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
|
|
||
| /** Drain all waitUntil promises. Returns a single promise that settles when all are done. */ | ||
| drainWaitUntil(): Promise<PromiseSettledResult<unknown>[]> { | ||
| return Promise.allSettled(this._waitUntilPromises); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test verifies that Consider adding a unit test that calls
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in a later revision — |
||
| const text = await res.text(); | ||
| expect(text).toBe("Event OK"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("RSC Flight hint fix", () => { | ||
|
|
||
| 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"; | ||
|
|
||
| /** | ||
|
|
@@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — the fixture now properly types the event parameter as |
||
| // 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", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solid test — exercises the exact Clerk auth pattern: One suggestion for a follow-up: it would be valuable to also assert that |
||
| }, | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| }); | ||
|
|
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good test — exercises the exact Clerk pattern:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solid test — exercises the exact Clerk pattern: |
||
| expect(result.waitUntilPromises![0]).toBe(capturedPromise); | ||
| }); | ||
|
Comment on lines
+3292
to
+3318
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good test — calls One thing this doesn't cover: the bubble-up to |
||
|
|
||
| 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. | ||
|
|
||
There was a problem hiding this comment.
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
waitUntilPromisestoctx.waitUntil()before the!result.continuecheck ensures promises survive all middleware result paths (redirect, custom response, continue).