Skip to content

feat: make vinext fully compatible with clerk middleware#126

Open
nbardy wants to merge 3 commits intocloudflare:mainfrom
nbardy:fix-clerk-middleware
Open

feat: make vinext fully compatible with clerk middleware#126
nbardy wants to merge 3 commits intocloudflare:mainfrom
nbardy:fix-clerk-middleware

Conversation

@nbardy
Copy link

@nbardy nbardy commented Feb 26, 2026

Description

This PR makes vinext fully compatible with @clerk/nextjs by implementing the NextFetchEvent.waitUntil background task API across the middleware and Worker layers.

Dependency: This PR pairs with clerk/javascript#7954, which fixes ESM compatibility in @clerk/nextjs itself (replacing require() calls that crash in Vite/Workers). That PR makes Clerk's package loadable in ESM runtimes. This PR provides the runtime API surface Clerk needs once it loads. Both are required for end-to-end Clerk support on Cloudflare Workers via vinext.

What changed

  • Pass NextFetchEvent to middleware: Construct the event with a waitUntil array and pass it as the second argument to middlewareFn. This is the standard Next.js middleware signature — Clerk uses event.waitUntil() to schedule session sync and telemetry as background tasks.
  • Bubble up promises: Collect waitUntilPromises from the middleware result and attach them to the Response object via a non-enumerable __vinextWaitUntil property so they survive the routing pipeline.
  • Cloudflare Worker integration (App Router & Pages Router): Add ctx: ExecutionContext to the generated Worker fetch signature, collect promises from both runMiddleware and __vinextWaitUntil, and delegate to Cloudflare's native ctx.waitUntil(). This ensures background work survives the serverless response lifecycle.
  • Update checks: vinext check now reports @clerk/nextjs as supported.
  • Tests: Add app-router.test.ts coverage verifying event.waitUntil is injected correctly.

Architectural note: from workaround to native API support

The original framing of this work was "making Clerk not crash." The paired Clerk PR changes that picture: once Clerk's ESM imports are fixed, @clerk/nextjs loads cleanly in Vite/Workers. What vinext needs to do is provide the correct API surface — specifically, a real NextFetchEvent with a functioning waitUntil(). This is not a shim to paper over bugs; it is the standard Next.js middleware contract that Clerk (and any other middleware library) legitimately depends on.


Architectural Decision: The 'Bubble Up' Pattern

Background promises need to travel from the middleware layer up to the Cloudflare Worker's ctx.waitUntil(). This PR uses a hidden property (Object.defineProperty(response, '__vinextWaitUntil', ...)).

Alternatives considered:

  1. AsyncLocalStorage (ALS): Risky in streaming responses. The primary execution thread finishes when the ReadableStream is returned; the ALS scope can tear down before background tasks are safely handed off to Cloudflare.
  2. Structured return types ({ response, tasks }): Changes the core router return type from Response to a tuple — massive blast radius across every caching, proxy, and error boundary layer.
  3. Global WeakMap keyed to Request: Fragile because the framework frequently clones requests when manipulating headers and URLs, which drops the reference.

Attaching promises directly to the Response as a non-enumerable property lets them travel safely up the execution chain, survive streaming, and require zero signature changes across the broader codebase.


Clerk API contact points

API Status
NextFetchEvent + event.waitUntil() ✅ This PR
NextResponse.next({ request: { headers } }) / x-middleware-request-* ✅ Already implemented
req.cookies / res.cookies.set() ✅ Already implemented
req.nextUrl / NextURL ✅ Already implemented
NextResponse.redirect() early return ✅ Already implemented
ESM-safe @clerk/nextjs package clerk/javascript#7954

@nbardy nbardy force-pushed the fix-clerk-middleware branch 4 times, most recently from 04daac0 to 7f5034d Compare February 26, 2026 17:05
@nbardy nbardy changed the title fix: pass NextFetchEvent to middleware and support Clerk waitUntil feat: make vinext fully compatible with clerk middleware Feb 27, 2026
…es to Cloudflare Worker ctx

- Instantiates `NextFetchEvent` with `waitUntil` shim and passes to `middlewareFn`
- Extracts `_waitUntilPromises` from the middleware result and attaches them to the `Response` object via `__vinextWaitUntil`
- Updates the generated Cloudflare Worker `fetch` signature to include `ctx: ExecutionContext`
- Iterates over `__vinextWaitUntil` on the final response and delegates to Cloudflare's native `ctx.waitUntil(promise)`
- Enables background tasks (like telemetry and session sync in `@clerk/nextjs`) to survive the worker response lifecycle
- Updates `vinext check` to explicitly report `@clerk/nextjs` as supported
- Adds `app-router.test.ts` assertion to verify `waitUntil` injection
@nbardy nbardy force-pushed the fix-clerk-middleware branch from 7f5034d to 0cbbe57 Compare March 2, 2026 07:55
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 2, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@126

commit: 902cdac

@southpolesteve
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: waitUntil / NextFetchEvent support for Clerk middleware

The overall approach is sound — NextFetchEvent already existed in the codebase, and this PR wires it up to actually be instantiated, passed to middleware, and bubbled up to ctx.waitUntil(). The hidden-property pattern on Response is a reasonable pragmatic choice given the alternatives discussed in the PR description.

However, there are several issues that need to be addressed, most notably a dev/prod parity gap and lost waitUntil promises on early-return paths.


1. Dev/prod parity: prod-server.ts ignores waitUntilPromises

The Pages Router production server (prod-server.ts:750-751) calls runMiddleware() which now returns waitUntilPromises, but the result is silently discarded. Per AGENTS.md: "When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as 'follow-ups' — fix them in the same PR."

The prod server runs in Node.js (not Workers), so there's no ctx.waitUntil(), but the promises should still be awaited (or Promise.allSettled'd) rather than silently swallowed. If Clerk schedules session sync via waitUntil, dropping those promises in prod mode means sessions won't sync during local production testing.

2. Early returns in app-dev-server.ts lose waitUntil promises

When middleware returns a redirect (line 1356) or a custom response (line 1376), the function returns mwResponse directly. The _waitUntilPromises array was populated on line 1336, but the Object.defineProperty that attaches them to the response only runs in the outer handler() function (line 1213). These early returns bypass that, so the promises are collected into _outerWaitUntilPromises but never attached to the response and never reach ctx.waitUntil().

Fix: attach __vinextWaitUntil to mwResponse before each early return:

if (_outerWaitUntilPromises.length > 0) {
  Object.defineProperty(mwResponse, '__vinextWaitUntil', { value: _outerWaitUntilPromises, enumerable: false });
}
return mwResponse;

This is a real bug — Clerk calls event.waitUntil() and then returns a redirect (the most common auth pattern: redirect unauthenticated users). The promises from that redirect path are lost.

"@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: "supported" },
Copy link
Contributor

Choose a reason for hiding this comment

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

This is premature. The PR description explicitly states a dependency on an unmerged Clerk PR (clerk/javascript#7954) for ESM compatibility. Until that ships, @clerk/nextjs won't load in Vite/Workers. Marking it as "supported" will mislead users running vinext check.

Consider "partial" with a detail string, or keep this change gated until the Clerk side lands:

Suggested change
"@clerk/nextjs": { status: "supported" },
"@clerk/nextjs": { status: "partial", detail: "waitUntil supported; requires @clerk/nextjs ESM fix (clerk/javascript#7954)" },

}

export default async function handler(request) {
let _waitUntilPromises = [];
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: the binding itself is never reassigned — only the array contents are mutated. const is more appropriate.

Suggested change
let _waitUntilPromises = [];
const _waitUntilPromises = [];

// Delegate to RSC handler (which decodes + normalizes the pathname itself)
const result = await rscHandler(request);

// If the middleware registered any waitUntil promises, hand them off to the runtime
Copy link
Contributor

Choose a reason for hiding this comment

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

This check runs before the instanceof Response check on line 62. If result is a Response (the common case), this works — the in operator finds the non-enumerable property, promises are handed off, then the Response is returned below.

A brief comment explaining why this precedes the instanceof check would help future readers:

Suggested change
// If the middleware registered any waitUntil promises, hand them off to the runtime
// 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)) {



// Bubble up any background tasks attached by the app-dev-server layer
if ("__vinextWaitUntil" in response) {
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 here is less robust than the equivalent in app-router-entry.ts (line 56), which does result && typeof result === 'object' && '__vinextWaitUntil' in result && Array.isArray(...). Consider aligning:

Suggested change
if ("__vinextWaitUntil" in response) {
if (response && typeof response === "object" && "__vinextWaitUntil" in response && Array.isArray((response as any).__vinextWaitUntil)) {

Also, nit: there's an extra blank line above this comment (line 681).

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.


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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 4, 2026

fatal: could not read Username for 'https://github.com': No such device or address

github run

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 4, 2026

@southpolesteve Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

nbardy and others added 2 commits March 7, 2026 00:50
# Conflicts:
#	packages/vinext/src/server/app-dev-server.ts
#	packages/vinext/src/server/middleware.ts
- Fix lost waitUntil promises on redirect/custom response early returns
- Settle waitUntil promises in prod-server (Node.js) instead of dropping
- Mark @clerk/nextjs as "partial" until ESM fix lands (clerk/javascript#7954)
- Align __vinextWaitUntil guard in deploy.ts with app-router-entry.ts
- Use const for non-reassigned _waitUntilPromises array
- Add comment explaining waitUntil check ordering in app-router-entry
- Add unit test verifying runMiddleware bubbles up waitUntil promises

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nbardy
Copy link
Author

nbardy commented Mar 6, 2026

/bigbonk review

@nbardy
Copy link
Author

nbardy commented Mar 6, 2026

@ask-bok I fixed it

@nbardy
Copy link
Author

nbardy commented Mar 6, 2026

Note on Clerk dependency

This PR on its own provides general waitUntil / NextFetchEvent plumbing that benefits any middleware. However, for the specific Clerk use case that motivated this work, both PRs are required:

  1. This PR (vinext feat: make vinext fully compatible with clerk middleware #126) — provides the runtime API surface (NextFetchEvent.waitUntil + promise bubble-up to ctx.waitUntil)
  2. clerk/javascript#7954 — fixes @clerk/nextjs ESM compatibility so the package can even load in Vite/Workers (currently crashes on require('server-only'))

Without the Clerk PR, @clerk/nextjs fails on import before any of our waitUntil plumbing runs. That's why vinext check reports @clerk/nextjs as "partial" — our side is ready, theirs is pending.

The waitUntil infrastructure is still independently useful for custom middleware and other libraries that use event.waitUntil().

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: waitUntil / NextFetchEvent plumbing for Clerk middleware

The overall architecture is sound. The hidden-property bubble-up pattern is a pragmatic choice well-justified by the alternatives analysis in the PR description. The implementation is thorough across the App Router dev server, the generated virtual middleware module, and the Cloudflare Worker entries.

Good improvements since the previous review: the early-return paths in app-dev-server.ts now correctly attach __vinextWaitUntil before returning, and check.ts accurately reports "partial" status. The new shims.test.ts test that calls runMiddleware() directly and asserts on result.waitUntilPromises is exactly the right kind of test.


Bug: prod-server.ts early returns drop waitUntilPromises

The Promise.allSettled call (line 837) only runs in the result.continue === true path. When middleware returns continue: false — i.e., a redirect (line 780-786) or a custom response (line 788-803) — the function returns early before reaching the Promise.allSettled call. This means Clerk's session sync promises are silently dropped in the most common auth pattern: redirecting unauthenticated users.

Compare with deploy.ts line 558, which correctly places the waitUntilPromises extraction before the !result.continue check. The prod-server should follow the same pattern — move the Promise.allSettled block above the if (!result.continue) check.

Minor: error catch path in app-dev-server.ts loses promises

If middleware calls event.waitUntil(p) and then throws, the catch block (line 1456-1458) returns a bare new Response("Internal Server Error", { status: 500 }) without attaching __vinextWaitUntil. Edge case and not blocking, but noted for completeness.

Positive notes

  • The guard in app-router-entry.ts (result && typeof result === 'object' && '__vinextWaitUntil' in result && Array.isArray(...)) is thorough. The in operator correctly detects non-enumerable properties.
  • The Pages Router worker entry in deploy.ts correctly places waitUntilPromises extraction before early returns (line 558), so both redirect and custom response paths are covered.
  • The check.ts change to "partial" with the dependency note accurately reflects the current state.

Comment on lines +834 to +839
// 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(() => {});
}
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

Comment on lines +557 to +562
// Bubble up waitUntil promises (e.g. Clerk telemetry/session sync)
if (result.waitUntilPromises?.length) {
for (const p of result.waitUntilPromises) {
ctx.waitUntil(p);
}
}
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.

Comment on lines 1264 to +1270
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 });
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.

Comment on lines 1421 to 1431
} 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
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.

Comment on lines +55 to +61
// 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);
}
}
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.

Comment on lines +1401 to +1405
// 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);
}
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.

Comment on lines +2205 to +2231
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 mockServer = {
ssrLoadModule: 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);
},
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);
});
Copy link
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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 6, 2026

The review was posted successfully. Here's a summary of the review:

Review Summary for PR #126

Architecture: Sound. The hidden-property (__vinextWaitUntil) bubble-up pattern is well-justified and pragmatic.

One Bug Found

prod-server.ts early returns drop waitUntilPromises — The Promise.allSettled call on line 837 only runs when result.continue === true. When middleware returns a redirect or custom response (continue: false), the function returns early before settling the promises. This is the most common Clerk auth pattern (redirect unauthenticated users). The fix is to move the Promise.allSettled block above the if (!result.continue) check, matching the pattern already used correctly in deploy.ts line 558.

Minor Issue

Error catch path in app-dev-server.ts loses waitUntil promises if middleware throws after calling waitUntil(). Edge case, not blocking.

Positive Notes

  • Early-return paths in app-dev-server.ts now correctly attach __vinextWaitUntil (redirect + custom response)
  • app-router-entry.ts guard is thorough — in operator correctly detects non-enumerable properties
  • deploy.ts Pages Router entry correctly places promise extraction before early returns
  • check.ts accurately reports "partial" with dependency note
  • Test coverage is reasonable — shims.test.ts covers the redirect+waitUntil plumbing end-to-end

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants