Skip to content

fix: eliminate double middleware execution in hybrid app+pages dev mode#541

Merged
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:fix/double-middleware-execution
Mar 16, 2026
Merged

fix: eliminate double middleware execution in hybrid app+pages dev mode#541
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:fix/double-middleware-execution

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Mar 15, 2026

Summary

  • In hybrid app+pages dev setups, middleware ran twice per App Router request — once in the connect handler (SSR env) and again in the RSC entry (RSC env). This caused double side effects for auth checks, logging, analytics, and any middleware state mutations.
  • The connect handler now forwards middleware results to the RSC entry via an x-vinext-mw-ctx request header. The RSC entry reconstructs _mwCtx from the forwarded data instead of re-executing the middleware function.
  • Pure App Router (no pages/) and pure Pages Router (no app/) setups are unaffected — they already use a single middleware execution path.

Key design decisions

  • Forwarding over skipping: The RSC entry needs _mwCtx.headers for applyMiddlewareRequestHeaders() (so headers() in Server Components sees middleware-modified headers). Simply skipping middleware would break this.
  • Explicit rewrite forwarding: The RSC plugin constructs its Request from the original HTTP request, not from req.url, so middleware rewrites must be forwarded explicitly in the header payload.
  • Response header deferral: When hasAppDir is true, middleware response headers are deferred (not applied to res) to avoid duplicates. They're applied only for Pages routes handled directly by the connect handler.
  • Production guard: The x-vinext-mw-ctx check is guarded by NODE_ENV !== "production" since the forwarding mechanism is dev-only. In production there is no connect handler, so an attacker-supplied header must not be trusted.

Test plan

  • Converted existing test.fixme to live regression test — verifies middlewareInvocationCount === 1 for App Router requests in hybrid fixture
  • All 7 middleware E2E tests pass (redirect, rewrite, rewrite+status, block, execution count)
  • Full app-router E2E suite: 283 passed, 0 failed (baseline: 282 passed + 1 fixme skip)
  • Build, typecheck, lint, format all clean
  • CI: full Vitest suite + all Playwright projects

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 15, 2026

Open in StackBlitz

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

commit: 7c18ace

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
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: fix double middleware execution in hybrid app+pages dev mode

This is a well-designed fix for a real correctness bug. The approach — forwarding middleware results via a request header rather than re-executing — is the right call. The production guard, the comment explaining why, and the regression test converting test.fixme to a live assertion are all appreciated.

I have a few observations, mostly about code hygiene and a minor robustness concern. Nothing blocking.

What looks good

  • Forwarding over skipping is the right design. The RSC entry genuinely needs _mwCtx.headers for applyMiddlewareRequestHeaders(), so simply suppressing the second run would break headers() in Server Components.
  • Production guard (NODE_ENV !== "production") prevents trusting attacker-supplied headers in deployed apps. The connect handler is dev-only, so this is correct.
  • Deferred response headers for hybrid mode avoid double-applying custom headers (once from the connect handler, once from the RSC entry). The six application sites all correctly guard with if (deferredMwResponseHeaders).
  • __mwCtxApplied flag falls back cleanly to re-running middleware if parsing fails.
  • Test update is clean — the test.fixme to test conversion with updated comments accurately describes the new behavior.
  • Header forwarding also correctly covers the "middleware didn't match" case — the connect handler still sets x-vinext-mw-ctx with empty data ({"h":[],"s":null,"r":null}), which causes the RSC entry to skip its own matcher evaluation. Both agree the path doesn't match, so this avoids redundant work.

cleanPathname = __rewriteParsed.pathname;
url.search = __rewriteParsed.search;
}
__mwCtxApplied = true;
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: __mwCtxApplied = true is inside the try block, which means if anything after JSON.parse succeeds but then throws (e.g., new URL(__mwCtxData.r, request.url) with a malformed rewrite URL at line 1600), the flag won't be set and the RSC entry will fall through to re-execute middleware.

This is arguably the correct behavior — if the forwarded context is broken, re-running middleware is a reasonable safety net. But it means a corrupt r value causes both a console.error and a redundant middleware execution, which could be confusing during debugging.

If the "re-execute on any error" fallback is intentional, a one-line comment clarifying that would help future readers. If not, consider setting the flag right after JSON.parse succeeds and handling the rewrite URL separately.

const __mwCtxHeader = request.headers.get("x-vinext-mw-ctx");
if (__mwCtxHeader) {
try {
const __mwCtxData = JSON.parse(__mwCtxHeader);
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.

Minor: The header value is parsed via JSON.parse with no size guard. In dev, the connect handler controls this value so it's safe. But if someone's middleware sets many/large headers (e.g., big Set-Cookie values), the JSON-serialized header could approach Node.js's default ~16KB per-header limit (--max-http-header-size), causing silent truncation and a parse error here.

Unlikely in practice and dev-only, so not blocking — but maybe worth a note.

Comment thread packages/vinext/src/index.ts Outdated
Comment on lines +2364 to +2368

// External rewrite from beforeFiles — proxy to external URL
if (isExternalUrl(resolvedUrl)) {
if (deferredMwResponseHeaders) {
for (const [key, value] of deferredMwResponseHeaders) {
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 deferredMwResponseHeaders application pattern (check + loop + appendHeader) is duplicated 6 times across the connect handler. Consider extracting a helper at the top of the handler scope:

const applyDeferredMwHeaders = () => {
  if (deferredMwResponseHeaders) {
    for (const [key, value] of deferredMwResponseHeaders) {
      res.appendHeader(key, value);
    }
  }
};

This would reduce ~30 lines of repetition and make it easier to modify the behavior later (e.g., if you need to null out deferredMwResponseHeaders after first application to prevent double-append on some edge path). Not blocking.

}
}

// Build request context once for has/missing condition checks
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.

Confirmed: x-vinext-mw-ctx is always set when hasAppDir && middlewarePath, even when runMiddleware returned { continue: true } with no responseHeaders (matcher didn't match). The payload {"h":[],"s":null,"r":null} correctly causes the RSC entry to set __mwCtxApplied = true and skip re-running middleware. This is the right behavior — the connect handler already evaluated the matcher.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 15, 2026

The review was submitted successfully. Here's a summary of what I posted:

Overall assessment: This is a well-designed fix. The "forwarding over skipping" approach is correct — the RSC entry needs middleware context for applyMiddlewareRequestHeaders(), so simply suppressing the second execution would break headers() in Server Components.

Inline comments posted (4):

  1. app-rsc-entry.ts:1604__mwCtxApplied = true placement inside the try block means a malformed rewrite URL causes both a console.error and a fallback to re-executing middleware. This is arguably correct but worth documenting.

  2. app-rsc-entry.ts:1583 — Minor note about JSON payload size. Node.js defaults to ~16KB per header value; header-heavy middleware could theoretically hit this limit.

  3. index.ts:2364-2368 — The deferredMwResponseHeaders application pattern is duplicated 6 times. Suggested extracting a helper function.

  4. index.ts:2340 — Confirmed the "middleware didn't match" case works correctly — the connect handler still sets the forwarding header with empty data, preventing redundant matcher evaluation in the RSC entry.

github run

In hybrid app+pages dev setups, middleware ran twice per App Router
request — once in the connect handler (SSR env) and again in the RSC
entry (RSC env). This caused double side effects for auth checks,
logging, analytics, and any middleware state mutations.

The connect handler now forwards its middleware results to the RSC entry
via an x-vinext-mw-ctx request header containing the response headers,
rewrite URL, and rewrite status. The RSC entry reconstructs _mwCtx from
the forwarded data instead of re-executing the middleware function.

Key details:
- Response headers are deferred (not applied to res) in the connect
  handler when hasAppDir is true, avoiding duplicates. They're applied
  to res only for Pages routes handled directly by the connect handler.
- The RSC plugin constructs its Request from the original HTTP request,
  not from req.url, so middleware rewrites must be forwarded explicitly
  (the connect handler's req.url rewrite is invisible to the RSC entry).
- Pure App Router (no pages/) and pure Pages Router (no app/) setups
  are unaffected — they already use a single middleware execution path.
- Guard x-vinext-mw-ctx check with NODE_ENV !== "production" so the
  forwarding header cannot be exploited in production builds
- Replace bare catch {} with error logging and fallback to running
  middleware normally when the forwarded context is malformed
- Filter x-middleware-next and x-middleware-rewrite from forwarded
  header entries to match the RSC entry's inline filtering behavior
- Apply deferred middleware response headers before all three
  proxyExternalRewriteNode call sites (beforeFiles, afterFiles,
  fallback external rewrites)
- Extract applyDeferredMwHeaders() helper to deduplicate 6 identical
  inline blocks in the connect handler
- Add comment clarifying that __mwCtxApplied flag placement inside try
  block is intentional — falls back to re-running middleware on error
@NathanDrake2406 NathanDrake2406 force-pushed the fix/double-middleware-execution branch from 9f60eda to 7c18ace Compare March 15, 2026 14:47
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review

Copy link
Copy Markdown
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: fix double middleware execution in hybrid app+pages dev mode

This is a correct and well-structured fix for a real bug. The approach — forwarding middleware results via x-vinext-mw-ctx rather than re-executing or skipping — is the right design choice. The RSC entry needs the full middleware context (_mwCtx.headers for applyMiddlewareRequestHeaders(), rewrite URL for routing, custom status), so a forwarding mechanism is the only option that preserves correctness.

I verified:

  • Early-return paths are correct. Redirects and custom responses (!result.continue) return before the forwarding header is set — the connect handler handles those fully, RSC entry is never invoked.
  • next() paths correctly skip applyDeferredMwHeaders(). All three return next() calls (lines 2476, 2543, 2556) hand off to the RSC entry, which handles response headers from the forwarded context. Deferred headers are only flushed for Pages-handled responses.
  • Header filtering is consistent and intentional. deferredMwResponseHeaders strips all x-middleware-* (correct for HTTP response), while mwCtxEntries only strips x-middleware-next and x-middleware-rewrite — the RSC entry needs x-middleware-request-* for applyMiddlewareRequestHeaders().
  • Production guard is correct. process.env.NODE_ENV !== "production" ensures the header is only trusted in dev where the connect handler is the sole producer.
  • applyDeferredMwHeaders helper correctly extracted by the author (matching the earlier review suggestion), avoiding repetition across 6 call sites.
  • Test conversion is clean. The test.fixmetest change with updated comments accurately describes the fix.

Three minor observations below, none blocking. Approving.

Comment on lines +1583 to +1609
const __mwCtxData = JSON.parse(__mwCtxHeader);
if (__mwCtxData.h && __mwCtxData.h.length > 0) {
// Note: h may include x-middleware-request-* internal headers so
// applyMiddlewareRequestHeaders() can unpack them below.
// processMiddlewareHeaders() strips them before any response.
_mwCtx.headers = new Headers();
for (const [key, value] of __mwCtxData.h) {
_mwCtx.headers.append(key, value);
}
}
if (__mwCtxData.s != null) {
_mwCtx.status = __mwCtxData.s;
}
// Apply forwarded middleware rewrite so routing uses the rewritten path.
// The RSC plugin constructs its Request from the original HTTP request,
// not from req.url, so the connect handler's req.url rewrite is invisible.
if (__mwCtxData.r) {
const __rewriteParsed = new URL(__mwCtxData.r, request.url);
cleanPathname = __rewriteParsed.pathname;
url.search = __rewriteParsed.search;
}
// Flag set after full context application — if any step fails (e.g. malformed
// rewrite URL), we fall back to re-running middleware as a safety net.
__mwCtxApplied = true;
} catch (e) {
console.error("[vinext] Failed to parse forwarded middleware context:", e);
}
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: __mwCtxApplied = true at line 1606 is inside the try block after all context application steps. This means new URL(__mwCtxData.r, request.url) throwing on a malformed rewrite URL causes both a console.error and a fallback to re-executing middleware — which could be confusing during debugging since the JSON was valid (the connect handler did run middleware).

Consider setting the flag right after JSON.parse succeeds, and wrapping the rewrite URL application in its own try/catch. That way: valid JSON = trust it, bad rewrite URL = log clearly but don't re-execute:

Suggested change
const __mwCtxData = JSON.parse(__mwCtxHeader);
if (__mwCtxData.h && __mwCtxData.h.length > 0) {
// Note: h may include x-middleware-request-* internal headers so
// applyMiddlewareRequestHeaders() can unpack them below.
// processMiddlewareHeaders() strips them before any response.
_mwCtx.headers = new Headers();
for (const [key, value] of __mwCtxData.h) {
_mwCtx.headers.append(key, value);
}
}
if (__mwCtxData.s != null) {
_mwCtx.status = __mwCtxData.s;
}
// Apply forwarded middleware rewrite so routing uses the rewritten path.
// The RSC plugin constructs its Request from the original HTTP request,
// not from req.url, so the connect handler's req.url rewrite is invisible.
if (__mwCtxData.r) {
const __rewriteParsed = new URL(__mwCtxData.r, request.url);
cleanPathname = __rewriteParsed.pathname;
url.search = __rewriteParsed.search;
}
// Flag set after full context application — if any step fails (e.g. malformed
// rewrite URL), we fall back to re-running middleware as a safety net.
__mwCtxApplied = true;
} catch (e) {
console.error("[vinext] Failed to parse forwarded middleware context:", e);
}
const __mwCtxData = JSON.parse(__mwCtxHeader);
__mwCtxApplied = true;
if (__mwCtxData.h && __mwCtxData.h.length > 0) {
// Note: h may include x-middleware-request-* internal headers so
// applyMiddlewareRequestHeaders() can unpack them below.
// processMiddlewareHeaders() strips them before any response.
_mwCtx.headers = new Headers();
for (const [key, value] of __mwCtxData.h) {
_mwCtx.headers.append(key, value);
}
}
if (__mwCtxData.s != null) {
_mwCtx.status = __mwCtxData.s;
}
// Apply forwarded middleware rewrite so routing uses the rewritten path.
// The RSC plugin constructs its Request from the original HTTP request,
// not from req.url, so the connect handler's req.url rewrite is invisible.
if (__mwCtxData.r) {
try {
const __rewriteParsed = new URL(__mwCtxData.r, request.url);
cleanPathname = __rewriteParsed.pathname;
url.search = __rewriteParsed.search;
} catch (e) {
console.error("[vinext] Failed to apply forwarded middleware rewrite URL:", e);
}
}

Not blocking — the current behavior is safe (falls back to re-running), just potentially confusing in debug scenarios.

}
}
}
req.headers["x-vinext-mw-ctx"] = JSON.stringify({
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.

Minor defensive thought: if the serialized JSON for x-vinext-mw-ctx ever gets large (many middleware-set headers, large cookie values), it could approach Node's default --max-http-header-size limit (16KB). In dev this is unlikely to be a practical issue, but if it ever surfaces, the symptom would be a silent parse error in the RSC entry followed by a redundant middleware re-execution — which at least fails safe. No action needed, just flagging for awareness.

for (const [key, value] of result.responseHeaders) {
if (!key.startsWith("x-middleware-")) {
res.appendHeader(key, value);
if (hasAppDir) {
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.

Observation: When hasAppDir is true and a request ends up hitting a Pages route (e.g., API route at line 2466), applyDeferredMwHeaders() applies the response headers to res, and applyRequestHeadersToNodeRequest() applies the request headers to req.headers. The forwarded x-vinext-mw-ctx header is also on req.headers but is never consumed by the Pages handler — it just passes through harmlessly.

This is correct behavior, just noting for future readers: in the hybrid case, Pages routes get their response headers from deferredMwResponseHeaders, while App Router routes get them from x-vinext-mw-ctx via the RSC entry. Two paths, same result.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 16, 2026

Review submitted successfully. Here's a summary of what I posted:

Verdict: Approved

This is a correct and well-designed fix for a real double-execution bug in hybrid app+pages dev mode. The "forwarding over skipping" approach is the right call — the RSC entry genuinely needs the middleware context for applyMiddlewareRequestHeaders().

Key things I verified:

  • Early-return paths (redirects, custom responses) correctly skip the forwarding header since the connect handler handles those fully
  • next() paths correctly skip applyDeferredMwHeaders() — deferred headers are only flushed for Pages-handled responses
  • Header filtering is consistent: deferredMwResponseHeaders strips all x-middleware-* for HTTP response, while mwCtxEntries preserves x-middleware-request-* for the RSC entry
  • Production guard prevents trusting attacker-supplied headers in deployed apps

3 inline comments posted (none blocking):

  1. app-rsc-entry.ts lines 1583-1609 — Suggested moving __mwCtxApplied = true right after JSON.parse succeeds and wrapping the rewrite URL in its own try/catch, to avoid a confusing double-run when the JSON is valid but the rewrite URL is malformed
  2. index.ts line 2416 — Noted the theoretical Node.js --max-http-header-size limit (16KB) for the serialized header, though unlikely in practice
  3. index.ts line 2366 — Documented the two-path design: Pages routes use deferredMwResponseHeaders, App Router routes use x-vinext-mw-ctx via the RSC entry

github run

@james-elicx james-elicx merged commit abb1aa6 into cloudflare:main Mar 16, 2026
21 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/double-middleware-execution branch March 18, 2026 09:50
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.

2 participants