Skip to content

fix(app-router): align server action routing#2520

Draft
james-elicx wants to merge 19 commits into
mainfrom
codex/fix-action-runtime-takeover
Draft

fix(app-router): align server action routing#2520
james-elicx wants to merge 19 commits into
mainfrom
codex/fix-action-runtime-takeover

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • associate server actions with the App Router routes that consume them
  • route action requests through the owning route while preserving middleware request state
  • align forwarded action and redirect response handling with Next.js behavior
  • keep generated RSC entries thin by delegating forwarding behavior to typed runtime helpers

Validation

  • vp test run tests/action-owner-manifest.test.ts tests/app-action-forwarding.test.ts tests/action-runtime-security.test.ts tests/app-server-action-execution.test.ts tests/entry-templates.test.ts tests/app-router-rsc-plugin.test.ts
  • vp check packages/vinext/src/server/app-action-forwarding.ts packages/vinext/src/build/action-owner-manifest.ts packages/vinext/src/entries/app-rsc-entry.ts packages/vinext/src/index.ts packages/vinext/src/server/app-rsc-handler.ts packages/vinext/src/server/app-server-action-execution.ts tests/app-action-forwarding.test.ts tests/action-owner-manifest.test.ts tests/action-runtime-security.test.ts tests/app-server-action-execution.test.ts tests/entry-templates.test.ts
  • vp run vinext#build
  • git diff --check

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2520
npm i https://pkg.pr.new/create-vinext-app@2520
npm i https://pkg.pr.new/vinext@2520

commit: d596a09

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared d596a09 against base 0862df3 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.3 KB 126.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 120.3 KB 120.4 KB ⚫ +0.0%
Dev server cold start vinext 2.74 s 2.74 s ⚫ -0.1%
Production build time vinext 3.24 s 3.31 s 🔴 +2.0%
RSC entry closure size (gzip) vinext 97.5 KB 97.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 164.0 KB 164.0 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review: server action routing alignment

Reviewed the full diff, ran the targeted suites (app-action-forwarding, action-owner-manifest, action-runtime-security, app-server-action-execution, entry-templates) and vp check on the changed source. Everything passes and the security model is sound: x-action-forwarded is stripped by filterInternalHeaders (it is in INTERNAL_HEADERS) and only re-injected via the trusted ctx.actionForwarded path, so a client cannot spoof it, and the forward loop guard fails closed. The redirect path now re-enters the pipeline via dispatchRedirectRequest so middleware gates the target (the 401 test confirms). Nice work.

A few observations, none blocking:

1. Action-owner injection is gated behind the classification stub filter (fragile coupling)

In renderChunk (packages/vinext/src/index.ts:3646-3714) the action-owner injection is nested inside two guards that exist for the classification manifest:

  • if (!rscClassificationManifest) return null; (:3647)
  • if (!code.includes("__VINEXT_CLASS")) return null; (:3650)

The action-owner stub is only replaced if the chunk also contains __VINEXT_CLASS. Today that holds because generateRscEntry always emits both __VINEXT_CLASS and __VINEXT_ACTION_OWNERS unconditionally into the same RSC entry chunk (entries/app-rsc-entry.ts:524-535, :715). But this is an implicit invariant: if a future codegen change drops/renames __VINEXT_CLASS, or splits the entry, the action-owner stub would silently ship un-replaced. At runtime __VINEXT_ACTION_OWNERS() would then return the literal string "__VINEXT_ACTION_OWNERS_STUB__", which is truthy — so forwardServerActionIfNeeded would treat every action as unowned and 404 it (a hard, silent breakage of all server actions).

The this.error("...failed to inject...") guard only fires when the stub is seen but injection returns null; it does not fire when the whole block is skipped. Consider decoupling the action-owner injection from the __VINEXT_CLASS string filter (e.g. also proceed when rscActionOwnerRoutes && code.includes("__VINEXT_ACTION_OWNERS_STUB__")), and asserting the stub was consumed before the build completes.

2. Dev vs prod ownership divergence through node_modules

buildStaticActionOwnerManifest skips regular import traversal for files under node_modules (action-owner-manifest.ts:394, only re-exports are followed). In production this is only a supplement — the authoritative source is the real Rollup module graph in buildActionOwnerManifest. But in dev, the static manifest is the only source (index.ts:3534, actionOwners: isDevelopmentServe ? actionOwners : undefined). So an action reached through a node_modules intermediary via a normal import (not a re-export) could be considered unowned in dev and get 404'd/mis-forwarded, while working in prod. Worth a code comment or a tracked follow-up, since dev/prod parity is a stated project goal.

3. waitUntil/ExecutionContext not threaded through forwarded dispatch

The forward dispatches with ctx = { actionForwarded: true } (app-rsc-entry.ts:1068). isExecutionContextLike (app-rsc-handler.ts:349) requires a waitUntil method, so this ctx is not treated as an execution context and the handler falls back to getRequestExecutionContext(). That ALS lookup should resolve to the outer request's context since the forward runs synchronously inside runWithRequestContext, so this is likely fine — but it means the CF ExecutionContext is intentionally not forwarded. Confirm after()/waitUntil inside a forwarded action still attaches to the request lifetime on Workers.

4. Minor: redundant matchRoute and dead inline-redirect path

  • handleServerActionRequest calls matchRoute(cleanPathname) twice (app-rsc-entry.ts:1060 and :1079). Small, and off the common request path, but reusable.
  • With the RSC entry always supplying dispatchRedirectRequest, the old inline redirect-render branch (app-server-action-execution.ts:1364+) is dead in production and only exercised by fallback tests. Fine to keep, just added maintenance surface.

5. Minor: cookie-deletion propagation untested

mergeActionForwardCookies (app-action-forwarding.ts:36-44) handles cookie.value === undefined as a delete, but the tests only cover middleware cookie rotation, not deletion. Consider a test asserting a middleware response.cookies.delete(...) removes the cookie from the forwarded request.

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Posted the review to PR #2520. Summary of what I found:

Review outcome

The PR is well-implemented and the security model is sound. I verified:

  • x-action-forwarding cannot be spoofed (stripped by filterInternalHeaders, re-injected only via trusted ctx)
  • The forward loop guard fails closed
  • Redirect targets now re-enter the full pipeline so middleware gates them (confirmed by the 401 test)
  • All targeted suites pass (26 + 51 + 89 tests) and vp check is clean

Issues raised (none blocking)

  1. Fragile coupling (highest concern) — The action-owner manifest injection is nested behind the classification-manifest guards in renderChunk, and specifically gated on code.includes("__VINEXT_CLASS"). It only works because codegen currently always emits both stubs in the same chunk. If that invariant ever breaks, the __VINEXT_ACTION_OWNERS_STUB__ string ships un-replaced and silently 404s every server action (the truthy string defeats the !options.actionOwners guard). The existing this.error won't catch the skip case.

  2. Dev/prod parity gap — Dev relies solely on AST-based static ownership analysis, which skips non-reexport imports through node_modules. Prod uses the real module graph, so an action reached via a node_modules intermediary could diverge.

  3. ExecutionContext not forwarded{ actionForwarded: true } isn't execution-context-like, so waitUntil/after() fall back to ALS lookup during forwarding (probably fine, worth confirming on Workers).

  4. Minor — duplicate matchRoute call; dead inline redirect-render path now only used by fallback tests.

  5. Minor — cookie-deletion propagation in mergeActionForwardCookies is untested.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

${
hasServerActions
? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});`
? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});
hasServerActions
? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});`
? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});
const __loadAppActionForwarding = () => import(${JSON.stringify(appActionForwardingPath)});`
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up review — action-owner injection robustness + build cost

Re-reviewed the diff, re-ran tests/app-action-forwarding.test.ts, tests/action-owner-manifest.test.ts, and the full production-build tests/action-runtime-security.test.ts (all green). The security model holds up under scrutiny:

  • The forwarded request carries x-action-forwarded only via the trusted ctx.actionForwarded path in app-rsc-handler.ts:1306-1313 — it is set on filteredHeaders after filterInternalHeaders strips any client-supplied copy, so it cannot be spoofed. On re-entry the loop guard (app-action-forwarding.ts:111) reads it and fails closed to a 404.
  • The dynamic-owner test (action-runtime-security.test.ts:337) confirms a forward to a middleware-gated /admin/* owner is blocked (non-flight → normalized to {}), so the literal-bracket forward pathname never leaks an executed action or garbage params past middleware. That resolves my initial concern about dynamic-route owners.

Two things worth addressing, only the first is more than cosmetic:

1. Reinforce the __VINEXT_ACTION_OWNERS injection guard (agree with prior comment, concrete fix)

The action-owner injection at index.ts:3700 only runs if renderChunk gets past if (!code.includes("__VINEXT_CLASS")) return null; at index.ts:3652. Today both stubs are emitted unconditionally into the same RSC entry chunk (app-rsc-entry.ts:715 + the classification stub), so the coupling holds — but it is an implicit invariant across two independent codegen features. If a future change ever splits the entry or drops __VINEXT_CLASS, the __VINEXT_ACTION_OWNERS_STUB__ string ships un-replaced, and __VINEXT_ACTION_OWNERS() returns the literal stub string. That string is truthy, so forwardServerActionIfNeeded treats every action as unowned (no matching owner pattern) and 404s all server actions — a silent, total breakage.

this.error("...failed to inject...") at index.ts:3712 does not catch this: it only fires when the stub is seen but the regex fails to match, not when the __VINEXT_CLASS pre-filter skips the whole chunk. Recommend a build-completion assertion that fails loudly if rscActionOwnerRoutes is still non-null after the RSC build (i.e., the stub was never consumed). That converts a silent runtime 404 into a hard build error, decoupled from the classification stub.

2. Static owner manifest is computed eagerly on every prod build (build-time cost)

buildStaticActionOwnerManifest runs in the load hook on every build (index.ts:3496), AST-parsing every module reachable from every route. In production this is a supplement — the authoritative source is the real Rollup module graph in buildActionOwnerManifest, and staticOwners is only consulted for the node_modules re-export edge case (action-owner-manifest.ts:312). Traversing and transformWithOxc-parsing all first-party route modules a second time at load time is redundant work that the module graph already covers, and it lines up with the +62.5% production-build-time regression flagged by the benchmark bot. Consider narrowing the static pass to only what the module graph can't see (node_modules re-export chains), or gating it behind the presence of such chains, so the common case doesn't pay for it.

Neither blocks merge. Nice work on the security hardening and the thin-entry delegation.

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Posted the review to PR #2520.

Review summary

I re-reviewed the full diff, re-ran the targeted suites (app-action-forwarding, action-owner-manifest) and the full production-build action-runtime-security test — all pass. The PR is correct and well-tested, and the security model is sound:

  • x-action-forwarded cannot be spoofed — stripped by filterInternalHeaders, re-injected only via the trusted ctx.actionForwarded path (app-rsc-handler.ts:1306-1313); the loop guard fails closed to 404.
  • Dynamic-route owners are safe — a forward to a middleware-gated owner is blocked and normalized to {} before the literal-bracket pathname could leak params (confirmed by action-runtime-security.test.ts:337). This resolved my initial concern.

Issues raised (none blocking)

  1. Fragile action-owner injection coupling (highest concern) — The __VINEXT_ACTION_OWNERS injection is gated behind the classification stub's !code.includes("__VINEXT_CLASS") pre-filter (index.ts:3652). It works only because both codegen features emit into the same chunk today. If that invariant ever breaks, the stub ships un-replaced, __VINEXT_ACTION_OWNERS() returns a truthy literal string, and every server action silently 404s. The existing this.error guard doesn't catch the skip case. Recommended a build-completion assertion that fails loudly if the stub was never consumed.

  2. Eager static manifest costbuildStaticActionOwnerManifest AST-parses every route-reachable module on every prod build, even though the module graph is authoritative in prod and staticOwners is only needed for the node_modules re-export edge case. Lines up with the +62.5% build-time regression flagged by the benchmark bot; suggested narrowing the static pass.

This complements the prior ask-bonk review (which also flagged #1, dev/prod parity, ExecutionContext, and untested cookie-deletion) rather than duplicating it.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: server action routing alignment (follow-up)

Re-reviewed the current head (f698182, which is 4 commits past the first ask-bonk pass) and re-ran the targeted suites — tests/app-action-forwarding.test.ts, tests/action-owner-manifest.test.ts, and the production-build tests/action-runtime-security.test.ts all pass (42 tests green).

The security model still holds up and two prior review items are now resolved:

  • x-action-forwarded cannot be spoofed — it is in INTERNAL_HEADERS (headers.ts:211), so filterInternalHeaders strips any client copy (app-rsc-handler.ts:1316) and it is re-injected only via the trusted ctx.actionForwarded path (app-rsc-handler.ts:1317-1319). The re-entry loop guard fails closed to {} (app-action-forwarding.ts:149-152).
  • Prior review #1 (fragile __VINEXT_CLASS coupling) is fixed. renderChunk now gates the action-owner injection on its own independent hasActionOwnerStub condition (index.ts:3664-3666) rather than piggybacking the classification pre-filter, and generateBundle (index.ts:3744-3750) now fails the build loudly if __VINEXT_ACTION_OWNERS_STUB__ survives un-injected. This converts the prior silent-runtime-404 risk into a hard build error.
  • Prior review #5 (cookie deletion untested) is fixedtests/app-action-forwarding.test.ts:133-183 now covers Max-Age=0, Max-Age=-1, and expired-Expires deletions.

1. CodeQL "improper code sanitization" — the manifest is embedded into generated JS with raw JSON.stringify (should use safeJsonStringify)

The two CodeQL alerts on app-rsc-entry.ts are legitimate and both trace back to embedding the action-owner manifest into generated JavaScript source without HTML/JS-safe escaping:

  • Dev path: app-rsc-entry.ts:708function __VINEXT_ACTION_OWNERS() { return ${JSON.stringify(actionOwners)}; }
  • Prod path: injectActionOwnerManifest at action-owner-manifest.ts:375-378`function ${match[1]}() { return ${JSON.stringify(manifest)}; }`

The manifest keys and values are developer-controlled strings, but they are not constants: in dev, keys are filesystem-derived reference paths (referenceKey/@fs/${id} or /${relative}, action-owner-manifest.ts:90-96) and values are route patterns derived from directory names. JSON.stringify does not escape <, >, &, U+2028, or U+2029 — exactly the characters that break JS/HTML embedding. This repo already has the correct helper for this: safeJsonStringify in server/html.ts, which is used everywhere else data is serialized into emitted JS (app-ssr-stream.ts, dev-server.ts, pages-server-entry.ts, etc.). The classification injector avoided the alert only because it emits an integer switch dispatch, not embedded strings.

Recommend swapping both sites to safeJsonStringify. It produces valid JSON that is also a valid JS literal, so runtime JSON.parse/object-literal semantics are unchanged, and it clears the CodeQL alerts. generateRscEntry can import it the same way the other entry templates do; injectActionOwnerManifest is in build/ and can import from ../server/html.js.

2. Forwarded dispatch drops the query string (forwardUrl.search = "")

forwardServerActionIfNeeded clears the search params on the forward (app-action-forwarding.ts:157). For the action invocation itself this is fine (the action is keyed by header, not query), but the owner page re-render triggered by the forward loses the original request's query string. If an action's owner page reads searchParams during the post-action render, it will see an empty set on the forwarded path but the real params on the non-forwarded (same-route) path — a dev/prod-adjacent behavior split worth a comment or a test. Worth confirming against Next.js' forwarded-action behavior before merge; if intentional, a one-line comment explaining why the query is dropped would prevent a future "bug fix" that re-adds it.

3. (Carry-over, non-blocking) Eager static manifest cost on every prod build

buildStaticActionOwnerManifest still runs unconditionally in the load hook (index.ts:3509-3519) and AST-parses every route-reachable module, even though in production the authoritative source is the Rollup module graph and staticOwners is only consulted for the node_modules re-export edge case (action-owner-manifest.ts:357). This lines up with the +11.8% production-build-time regression the benchmark bot flagged. Narrowing the static pass to only node_modules re-export chains (or gating it on their presence) would keep the common case off that cost. Tracked from the prior review; not a merge blocker.

Nice work on the injection-guard hardening and the thin-entry delegation. Only #1 is more than cosmetic, and it has a clean mechanical fix.

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2520.

Summary

I reviewed the current head (f698182 — 4 commits past the first review pass), read the full diff, and re-ran the targeted suites (app-action-forwarding, action-owner-manifest, production-build action-runtime-security) — 42 tests pass.

Security model confirmed sound, and two prior review items are now resolved:

Issues raised (only #1 is more than cosmetic):

  1. CodeQL alerts are legitimate — the action-owner manifest is embedded into generated JS via raw JSON.stringify at two sites (app-rsc-entry.ts:708 dev path, action-owner-manifest.ts:377 prod injector). Keys/values are filesystem- and route-derived strings that JSON.stringify doesn't escape for <, >, &, U+2028/U+2029. The repo already has safeJsonStringify (server/html.ts) used everywhere else for this exact purpose. Clean mechanical fix: swap both sites.
  2. Forwarded dispatch drops the query string (forwardUrl.search = "") — fine for the action itself, but the owner-page re-render loses searchParams, a potential dev/prod behavior split. Worth a comment/test or Next.js parity confirmation.
  3. (Carry-over) Eager static manifest costbuildStaticActionOwnerManifest runs unconditionally on every prod build, matching the +11.8% build-time regression the bot flagged. Non-blocking.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: server action routing alignment (head 2dcf18a, 5 commits)

Re-reviewed the current head, which is one commit (2dcf18a "escape action owner metadata") past the prior ask-bonk pass. Re-ran the targeted suites and vp check:

  • tests/app-action-forwarding.test.ts + tests/action-owner-manifest.test.ts — 34 pass
  • tests/action-runtime-security.test.ts + tests/app-server-action-execution.test.ts + tests/entry-templates.test.ts — 140 pass
  • tests/app-browser-server-action-client.test.ts — 3 pass
  • vp check on the changed source — clean

All substantive items from prior reviews are now resolved. The latest commit and the earlier hardening close the loop:

  • Prior #1 (fragile __VINEXT_CLASS coupling) — fixed. renderChunk now gates action-owner injection on its own independent hasActionOwnerStub condition (index.ts:3664-3666), and generateBundle (index.ts:3744-3750) fails the build loudly if __VINEXT_ACTION_OWNERS_STUB__ survives un-injected. Converts the silent-runtime-404 risk into a hard build error.
  • CodeQL "improper code sanitization" — fixed. Both injection sites now use safeJsonStringify instead of raw JSON.stringify: the dev path (app-rsc-entry.ts:709) and the prod injector (action-owner-manifest.ts:378). safeJsonStringify escapes <, >, &, U+2028, U+2029 (server/html.ts:21). Covered by a new test (action-owner-manifest.test.ts "escapes action owner manifests embedded in generated JavaScript").
  • Prior #5 (cookie deletion untested) — fixed. Tests now cover Max-Age=0, Max-Age=-1, and expired Expires (app-action-forwarding.test.ts:133-183).
  • Prior #3 (ExecutionContext not forwarded) — now addressed. The forward dispatch threads the real CF context through (app-rsc-entry.ts:1064-1076): when __getRequestExecutionContext() resolves, it passes waitUntil/cache/passThroughOnException alongside actionForwarded: true, so isExecutionContextLike (app-rsc-handler.ts:349) recognizes it and after()/waitUntil inside a forwarded action attach to the request lifetime.
  • Prior #2 (dropped query string) — verified against Next.js and now correct + documented. forwardServerActionIfNeeded clears forwardUrl.search (app-action-forwarding.ts:159), matching Next.js createForwardedActionResponse() which builds new URL(${origin}${basePath}${workerPathname}) with no query. The concern about the owner page losing searchParams on re-render is moot: on the forwarded path shouldSkipPageRendering is true for non-HTTP-fallback actions (app-server-action-execution.ts:1455-1457), so the page isn't re-rendered — only the action returnValue is streamed. This mirrors Next.js skipPageRendering = actionWasForwarded. The added comment documents the parity intent.

Security model re-confirmed sound:

  • x-action-forwarded is unspoofable — it's in INTERNAL_HEADERS, so filterInternalHeaders strips any client copy before handleServerActionRequest sees the request (app-rsc-handler.ts:1316), and it is re-injected only via the trusted ctx.actionForwarded path (:1317-1318). The re-entry loop guard fails closed to {} (app-action-forwarding.ts:149-152).
  • The cookie-merge divergence from Next.js is deliberate and correct: vinext's ResponseCookies.getAll() returns deletions as value: "" (not undefined), so it can't use Next.js's cookie.value === undefined check. The custom deletesCookie() heuristic (Max-Age<=0 / expired Expires) is self-consistent with vinext's own delete() which sets expires: new Date(0).

Remaining (non-blocking, carry-over)

Eager static manifest cost on every prod buildbuildStaticActionOwnerManifest still runs unconditionally in the load hook and AST-parses every route-reachable module, even though in production the Rollup module graph is authoritative and staticOwners is only consulted for the node_modules re-export edge case (action-owner-manifest.ts:358). This lines up with the build-time regression the benchmark bot flagged (+10–12% across rounds). Narrowing the static pass to node_modules re-export chains (or gating it on their presence) would keep the common case off that cost. Not a merge blocker.

Nice work — the injection-guard hardening, the escaping fix, and the ExecutionContext threading all landed cleanly, and the thin-entry delegation is holding up.

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2520.

Summary

I reviewed the current head (2dcf18a, 5 commits — one past the prior ask-bonk pass), read the full diff, re-ran the targeted suites (177 tests across 6 files, all pass) and vp check (clean).

All substantive prior-review items are now resolved:

  1. Fragile __VINEXT_CLASS coupling — fixed with an independent hasActionOwnerStub guard plus a generateBundle build-fail assertion, converting a silent runtime 404 into a hard build error.
  2. CodeQL "improper code sanitization" — fixed; both manifest-injection sites now use safeJsonStringify (escapes <, >, &, U+2028/U+2029), with a new test.
  3. ExecutionContext not forwarded — now threads real waitUntil/cache/passThroughOnException through the forward dispatch.
  4. Dropped query string — verified against Next.js source (createForwardedActionResponse also drops the query) and confirmed moot because forwarded non-fallback actions skip page rendering (shouldSkipPageRendering), matching Next.js skipPageRendering = actionWasForwarded.
  5. Untested cookie deletion — now covered (Max-Age=0, Max-Age=-1, expired Expires).

Security model re-confirmed sound: x-action-forwarded is unspoofable (in INTERNAL_HEADERS, stripped then re-injected only via trusted ctx; loop guard fails closed). I also verified the cookie-merge divergence from Next.js is deliberate and self-consistent given vinext's ResponseCookies model.

Remaining (non-blocking): the eager buildStaticActionOwnerManifest still runs on every prod build, matching the benchmark bot's build-time regression. Suggested narrowing it to node_modules re-export chains.

No blocking issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review: server action routing alignment (head 577afff, 6 commits)

Reviewed the current head, which is one commit (577afff "parse route parameters linearly") past the prior ask-bonk pass. Re-ran the targeted suites and independently verified the security model and the new commit.

  • tests/app-action-forwarding.test.ts + tests/action-owner-manifest.test.ts — 34 pass
  • tests/action-runtime-security.test.ts + tests/entry-templates.test.ts — 51 pass
  • tests/route-sorting.test.ts — 48 pass

All substantive items from prior reviews remain resolved (independent hasActionOwnerStub guard + generateBundle build-fail assertion at index.ts:3664-3666/:3744-3750; safeJsonStringify at both injection sites; ExecutionContext threading; cookie-deletion tests; documented query-string drop). Security model re-confirmed sound: x-action-forwarded is in INTERNAL_HEADERS (headers.ts:211), stripped by filterInternalHeaders (request-pipeline.ts:594) at app-rsc-handler.ts:1316, and re-injected only via the trusted isForwardedActionContext(ctx) path (:1317-1318); the re-entry loop guard fails closed to {} (app-action-forwarding.ts:149-152).

New commit 577afff — ReDoS fix in patternToNextFormat (verified correct)

The regex rewrite to a linear split("/").map(...) (route-validation.ts:150-163) is a genuine ReDoS fix — the old :([^/]+?)\+(?=\/|$) patterns could backtrack on adversarial input, which the new handles adversarial parameter names in linear time test (route-sorting.test.ts:178) guards against. I diffed old vs. new behavior across edge cases and confirmed the only behavioral divergences are unreachable from real route generation:

  • /: → old /:, new /[]; /:+ → old /[+], new /[...]; /:* → old /[*], new /[[...]]; /a:b → old /a[b], new /a:b.
  • The route scanners always emit param segments as a whole :-prefixed segment (app-route-graph.ts:2920/2931/2940, pages-router.ts:132/144/154, route-pattern.ts:7/10/13), never a bare : or a static-prefixed segment like a:b, and never a nameless param.
  • The rest of the routing code already keys param detection on segment.startsWith(":") / patternPart.startsWith(":") (route-pattern.ts:103/121/154/157, app-route-graph.ts:2196/2233/2991), so the new segment-anchored approach is more consistent with the codebase than the old mid-segment-colon regex.
  • All prior parity cases still pass (:c++lang[c++lang], :a*b[a*b], :sign-in+[...sign-in]). The forwarding path (app-action-forwarding.ts:156) feeds it route.pattern, which is always well-formed.

No parity regression. Good hardening.

Non-blocking observations

  1. Eager static manifest cost (carry-over). buildStaticActionOwnerManifest is gated on hasServerActions (index.ts:3509) — good — but for server-action apps it still AST-parses every route-reachable module on every prod build even though the Rollup module graph is authoritative there and staticOwners is only consulted for the node_modules re-export edge case. Lines up with the benchmark bot's build-time regression. Narrowing the static pass to node_modules re-export chains would keep the common case off that cost.

  2. deletesCookie Expires <= now boundary (cosmetic). mergeActionForwardCookies treats a cookie whose Expires equals the captured now as a deletion (app-action-forwarding.ts:51). This is the correct/expected reading and vinext's own delete() uses new Date(0), so it's self-consistent; noting only that the boundary is inclusive.

No blocking issues found. The ReDoS fix, injection-guard hardening, escaping fix, and ExecutionContext threading all landed cleanly.

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2520.

Summary

I reviewed the current head (577afff, 6 commits — one past the prior ask-bonk pass), re-ran the targeted suites (route-sorting, app-action-forwarding, action-owner-manifest, action-runtime-security, entry-templates — all pass), and independently verified the security model and the new commit.

All substantive prior-review items remain resolved, and the security model is sound: x-action-forwarded is unspoofable (in INTERNAL_HEADERS, stripped by filterInternalHeaders, re-injected only via the trusted ctx path; loop guard fails closed).

New commit 577afff rewrites patternToNextFormat from a backtracking-prone regex to a linear split/map parser — a legitimate ReDoS fix. I diffed old-vs-new behavior across edge cases and confirmed the only divergences (/:, /:+, /:*, /a:b) are unreachable from real route generation: the route scanners always emit whole :-prefixed param segments, and the rest of the routing code already keys on segment.startsWith(":"). All parity cases still pass. No regression.

Non-blocking observations:

  1. Eager static manifest cost (carry-over) — gated on hasServerActions but still full AST-parses on every prod build for action apps.
  2. deletesCookie uses an inclusive Expires <= now boundary — correct and self-consistent, noted only.

No blocking issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review: server action routing alignment (head ba44206, 7 commits)

Reviewed the current head, which is one commit past the last ask-bonk pass (577afff). The new commit ba44206 "perf(app-router): collect action owners during RSC scans" is a substantial rewrite that directly addresses the eager-static-manifest build-cost concern flagged in every prior review, so it deserves a fresh look.

Re-ran the targeted suites and vp check:

  • tests/action-owner-manifest.test.ts + tests/action-runtime-security.test.ts — 32 pass
  • tests/app-action-forwarding.test.ts + tests/entry-templates.test.ts + tests/route-sorting.test.ts — 106 pass
  • vp check packages/vinext/src/build/action-owner-manifest.ts packages/vinext/src/index.ts — clean

Security model re-confirmed sound. x-action-forwarded is stripped by filterInternalHeaders (app-rsc-handler.ts:1316) and re-injected only via the trusted isForwardedActionContext(ctx) path (:1317-1318); the loop guard fails closed to {} (app-action-forwarding.ts:149-152). The redirect path re-enters via dispatchRedirectRequest so middleware gates the target. All prior substantive items (fragile __VINEXT_CLASS coupling, safeJsonStringify escaping, ExecutionContext threading, cookie-deletion tests, documented query-string drop) remain resolved.

New commit ba44206 — build-cost concern addressed (verified correct)

The prior "eager static manifest cost" carry-over is now genuinely fixed, not just narrowed:

  • The unconditional buildStaticActionOwnerManifest load-hook pass is now dev-only (index.ts:3510-3521). Production no longer AST-parses every route-reachable module a second time at load time.
  • Production ownership is instead collected during the RSC plugin's existing scan build (steps 1-2 of buildApp, write:false) via a new transform hook (index.ts:6572-6597) gated on isScanBuild, then consumed once in renderChunk during the real rsc build (index.ts:3717-3742). This reuses work the scan build already does rather than adding a redundant traversal.

I verified the collector's correctness against the scan-build lifecycle:

  • buildStart clears the collector only for the rsc environment during scan (index.ts:6565-6570), which runs first (step 1); the ssr scan (step 2) then accumulates on top — no mid-scan wipe.
  • The rsc/ssr same-id concurrency in collect is safe: the critical section (action-owner-manifest.ts:657-659) has no await, so the second writer reads the module the first created and merges its environment rather than clobbering it. The per-environment pendingKey (:623) plus the module?.environments[environment] short-circuit (:622) correctly dedup repeated visits.
  • The analyses cache keyed on sha256(code) (:628) is safe to share across ids (analysis is purely code-derived); resolvedDependencies is correctly computed per-id since it needs the importer.
  • The new test "retains action ownership through client component boundaries" (action-runtime-security.test.ts:304) confirms an action reached through a 'use client' boundary is still owned and forwarded to the middleware-gated owner — the case most likely to regress under the new traversal.

Observations (non-blocking)

  1. Parse/resolve failures in the scan collector fail silently toward a 404. In collect, a parse(code) throw (action-owner-manifest.ts:634) returns without registering the module, and a resolve throw (:649) drops that edge. During buildManifest, a missing module ends the traversal (:587 if (!module || !moduleEnvironment) continue), so any server action reachable only through the dropped module/edge would be treated as unowned and 404'd at runtime — with no build diagnostic. This is much less likely than the old approach's risk (the scan runs on code Vite already transformed successfully), but unlike the __VINEXT_ACTION_OWNERS_STUB__ un-injection case there is no loud failure. Consider at least a debug log on the parse/resolve catch so a silent ownership drop is diagnosable. Not a blocker.

  2. Dev vs prod ownership now use entirely different code paths. Dev = static AST pass over this.resolve-resolved sources (index.ts:3510); prod = scan-build collected, already-transformed modules. Both share collectedSourceDependencies/buildManifestFromModules-style logic, but the inputs differ (raw source vs post-transform, this.resolve vs scan resolver). The node_modules re-export edge case and any transform that rewrites imports could diverge between dev and prod. The shared helpers and the C:/Windows/node_modules narrowing tests mitigate this, but it is worth a tracked note since dev/prod parity is a stated goal.

  3. route-validation.ts patternToNextFormat ReDoS fix — re-confirmed correct and linear (:150-163). The only behavioral divergences from the old regex (/:, /:+, /:*, /a:b) remain unreachable from real route generation, and the forwarding caller feeds it well-formed route.pattern.

No blocking issues found. The build-cost rewrite is a real improvement and lands cleanly; only observation #1 (silent parse/resolve drop) is worth a follow-up.

@ask-bonk

ask-bonk Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2520.

Summary

I reviewed the current head (ba44206, 7 commits — one commit past the last ask-bonk pass). The new commit ba44206 is a substantial rewrite of the manifest-building approach that directly addresses the eager-static-manifest build-cost concern every prior review flagged, so I focused there.

Verification:

  • Targeted suites pass: action-owner-manifest + action-runtime-security (32), app-action-forwarding + entry-templates + route-sorting (106)
  • vp check on changed source — clean
  • Security model re-confirmed sound (x-action-forwarded unspoofable, loop guard fails closed, redirect re-enters through middleware)

New commit ba44206 — verified correct:

  • The unconditional prod static AST pass is now dev-only; prod ownership is collected during the RSC plugin's existing scan build (reusing work rather than duplicating it). This genuinely resolves the carry-over build-cost concern.
  • I checked the scan-build lifecycle: buildStart clears only the rsc scan (runs first), the rsc/ssr same-id concurrency in collect is safe (synchronous critical section merges rather than clobbers), and the sha256(code) analysis cache is safe to share.
  • The new client-component-boundary ownership test covers the case most likely to regress.

Non-blocking observations:

  1. Silent parse/resolve failure toward 404 (highest of the three) — a parse/resolve throw in the scan collector silently drops a module/edge, and any action reachable only through it would be treated as unowned and 404'd at runtime with no build diagnostic. Suggested a debug log on the catch.
  2. Dev and prod now use entirely different ownership code paths (raw source vs post-transform); worth a tracked parity note.
  3. Re-confirmed the patternToNextFormat ReDoS fix is linear and its divergences unreachable.

No blocking issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: server action routing alignment (head e3630a8, 13 commits)

Reviewed the current head, which is 5 commits past the last ask-bonk pass (ba44206). Those commits (6423c6b, c9e6041, 09feead, 13f9f0c, e3630a8) are a substantial rewrite of how the action-owner manifest is built — the prior dev-only static AST pass and the RSC-scan createActionOwnerModuleCollector are gone, replaced by a patched @vitejs/plugin-rsc that exposes scanBuildObservers and hands vinext the es-module-lexer output during the scan build. Because the whole manifest pipeline changed, this needed a fresh look rather than a delta on prior reviews.

Verification on this head:

  • vp install (applies the new patches/@vitejs__plugin-rsc@0.5.27.patch; confirmed scanBuildObservers present in node_modules/@vitejs/plugin-rsc/dist/plugin-BK29Va7z.js)
  • tests/action-owner-manifest.test.ts + tests/app-action-forwarding.test.ts + tests/action-runtime-security.test.ts + tests/app-server-action-execution.test.ts — 125 pass
  • tests/entry-templates.test.ts + tests/route-sorting.test.ts — 91 pass
  • vp check on the changed source — clean

Security model still sound and prior items remain resolved: x-action-forwarded is stripped by filterInternalHeaders and re-injected only via the trusted ctx path; the loop guard fails closed to {} (app-action-forwarding.ts:149-152); safeJsonStringify is used at both injection sites; the ExecutionContext threading (app-rsc-entry.ts:1064-1076) landed; cookie-deletion tests are present. The real prod build in action-runtime-security.test.ts:243-249 exercises the patched plugin end-to-end and confirms owner-based routing (401 for a middleware-gated owner, action executes only through its owner).

But the rewrite introduces a few new concerns, and one is a merge blocker.

1. (Blocking) Server actions become unbuildable for end users — the required plugin-rsc API only exists in this repo's local patch

The new ownership pipeline depends entirely on manager.scanBuildObservers / manager.scanBuildModules, which are added by the new patches/@vitejs__plugin-rsc@0.5.27.patch (this file does not exist on main). That patch is applied only inside this monorepo via pnpm-workspace.yaml patchedDependencies (:104-105).

@vitejs/plugin-rsc is an optional peer dependency of vinext (packages/vinext/package.json:193,203-205), and vinext init adds it as a plain devDependency (src/init.js:108). End users therefore install the unpatched, published @vitejs/plugin-rsc from npm, which has no scanBuildObservers. There is no mechanism that ships this patch to consumers, and the old fallback (buildStaticActionOwnerManifest) was deleted in this PR.

Result: for any end-user App Router app with server actions, renderChunk hits the guard at index.ts:3729-3733:

if (!hasActionOwnerScanObserver && serverReferences.length > 0) {
  this.error("[vinext] The installed @vitejs/plugin-rsc does not expose scan-build observers ... Install a compatible release before building.");
}

…and the build fails hard. The message tells users to "install a compatible release," but no such release exists on npm — the API is a local patch. So this ships as a hard regression: apps with server actions cannot build outside this repo. This needs one of: (a) vendor the observer logic so it doesn't depend on a patched plugin, (b) get the API upstreamed and bump the peer range to a real published version, or (c) restore a fallback path when the observer is absent. Please confirm the intended distribution story before merge.

2. (Regression from prior head) The generateBundle build-fail safety net was removed

ba44206 had a generateBundle hook that failed the build if __VINEXT_ACTION_OWNERS_STUB__ survived in any RSC chunk — every prior ask-bonk review credited this as converting the silent-runtime-404 into a hard build error. That hook is deleted in this PR (confirmed: git show ba44206:.../index.ts has "server action owner manifest was not injected into the RSC entry", e3630a8 does not).

The runtime failure mode it guarded against is unchanged and still severe: if the stub ships un-replaced, __VINEXT_ACTION_OWNERS() returns the literal string "__VINEXT_ACTION_OWNERS_STUB__". That string is truthy, so forwardServerActionIfNeeded's if (!options.actionOwners) return null guard (app-action-forwarding.ts:129) does not short-circuit; actionOwnerPatterns then does Object.hasOwn(<string>, actionId)undefinedownerPath undefined → every server action 404s (:143-148). The only remaining guard is the renderChunk per-chunk logic, which does not catch the case where the stub-bearing chunk is processed while rscActionOwnerRoutes is null (e.g. a future codegen split, or the load hook not re-firing). Recommend restoring the generateBundle assertion — it's cheap insurance against a total-breakage, silent failure mode and was explicitly relied on in prior review sign-offs.

3. Dev now disables action ownership/forwarding entirely (dev/prod parity gap)

In dev (command === "serve"), rscActionOwnerRoutes is forced to null and actionOwners is passed as null (index.ts:3527-3528 and the command === "build" ? undefined : null at the actionOwners: field). At runtime forwardServerActionIfNeeded returns null immediately for a null manifest (app-action-forwarding.ts:129), so in dev every action executes on whatever route it was POSTed to — the ownership gating and middleware re-entry this PR adds are prod-only.

The prior head (ba44206) computed a dev static manifest so ownership worked in dev too. This is a deliberate-looking simplification, but it's a real parity regression for a security-relevant feature (the PR's own framing is "action runtime takeover"). At minimum this deserves a code comment + a tracked follow-up; ideally dev keeps some ownership enforcement. dev/prod parity is a stated project goal.

4. Scan observer silently drops modules on positional-alignment mismatch

createActionOwnerScanObserver maps lexer specifiers to resolved ids positionally: sourceOrder[index] → event.info.importedIds[index] (action-owner-manifest.ts:284-291), gated by if (importedIds.length !== sourceOrder.length) return;. When the counts differ, the entire module's edges are dropped silently, so any action reachable only through that module becomes unowned → runtime 404 with no diagnostic (the same silent-drop class the prior review flagged, now in the observer).

The alignment is fragile:

  • sourceOrder dedups by specifier string (:179-182), while info.importedIds is Rollup's list of resolved ids. Two specifiers resolving to the same id (./a and ./a.js) shrink importedIds below sourceOrder → whole module dropped.
  • Dynamic imports are pushed into sourceOrder before the item.d >= 0 check (:178-186), so a static-string dynamic import is treated positionally as a static import; static-analyzable dynamic imports land in importedIds (the scan-strip plugin rewrites them to static import "x" at plugin :445), but non-static-string dynamic imports change the count and drop the module.

Consider matching by resolved specifier rather than by index, and emitting at least a debug log when a module is dropped so a silent ownership gap is diagnosable. Not a blocker on its own, but it compounds #2 (there's no longer a build-time backstop).

5. Action re-render mode changed PRESERVE_UINAVIGATION via the main merge (confirm intended)

app-server-action-execution.ts:1408 / :1555 now use APP_RSC_RENDER_MODE_NAVIGATION instead of APP_RSC_RENDER_MODE_ACTION_RERENDER_PRESERVE_UI. The PRESERVE_UI constant was removed on main (app-rsc-render-mode.ts no longer defines it), and the merge (206e84c) reconciled these call sites to NAVIGATION. This is a semantic change to post-action re-render behavior that rode in with the merge rather than being an explicit decision in this PR. One of these is the dead inline-redirect fallback path; the other (:1555) is the action-rerender target. Worth confirming the NAVIGATION mode preserves the same UI/state semantics that PRESERVE_UI did, or the post-action re-render could regress.


Summary: the security model and the happy-path build (with the patch) are correct and well-tested. But #1 is a blocker — the patched-plugin dependency means server actions can't build for end users on the published @vitejs/plugin-rsc, and the build fails with an error pointing at a nonexistent "compatible release." #2 (removed generateBundle guard) and #4 (silent module drop) together remove the safety nets that prior reviews relied on for the total-breakage failure mode. #3 is a dev/prod parity regression for a security feature. Please confirm the plugin-rsc distribution story before merge.

@ask-bonk

ask-bonk Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2520.

Summary

The head is now e3630a85 commits past the last ask-bonk review (ba44206). Those commits are a substantial rewrite: the manifest is no longer built from a static AST pass or the old createActionOwnerModuleCollector; instead it relies on a new local patch to @vitejs/plugin-rsc@0.5.27 that exposes scanBuildObservers. I re-verified everything from scratch: applied the patch via vp install, ran the targeted suites (125 + 91 tests, all pass), and vp check clean. The security model holds and prior items remain resolved.

Issues raised

  1. (Blocking) Server actions become unbuildable for end users. The new pipeline requires manager.scanBuildObservers, which exists only in this repo's local patchedDependencies patch (the patch file is new — not on main). @vitejs/plugin-rsc is an optional peer dep and vinext init installs the unpatched published version, which lacks the API. The old fallback was deleted, so renderChunk hits the this.error guard (index.ts:3729) and the build fails hard, pointing users at a "compatible release" that doesn't exist on npm. Needs vendoring, upstreaming, or a fallback.

  2. (Regression) The generateBundle build-fail safety net was removed — the exact hook every prior review credited with turning the silent-404 into a hard build error. The truthy-literal-stub → all-actions-404 failure mode is unchanged.

  3. Dev now disables action ownership/forwarding entirely (actionOwners: null in serve) — a dev/prod parity regression for a security feature the PR is specifically about.

  4. Scan observer silently drops modules on positional lexer↔importedIds mismatch (dedup-by-specifier vs dedup-by-id; dynamic imports), with no diagnostic — compounds Published npm package ships sourcemaps pointing to missing source files #2.

  5. PRESERVE_UINAVIGATION render-mode change rode in via the main merge; worth confirming the post-action re-render semantics are preserved.

Only #1 is a merge blocker; #2/#4 remove safety nets prior reviews relied on.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: server action routing alignment (head 2dc5a3a, 14 commits)

Reviewed the current head, which is one commit past the last ask-bonk pass (e3630a8). The new commit 2dc5a3a "retain imported inline action ownership" fixes a real ownership gap and extends the plugin patch. I re-verified the security model and re-ran the targeted suites with the patch applied (vp installscanBuildObservers present in node_modules/@vitejs/plugin-rsc/dist/plugin-BK29Va7z.js):

  • tests/action-owner-manifest.test.ts + tests/app-action-forwarding.test.ts + tests/action-runtime-security.test.ts39 pass

Security model still sound: x-action-forwarded is stripped by filterInternalHeaders and re-injected only via the trusted ctx path; the loop guard fails closed to {} (app-action-forwarding.ts:149-152); redirects re-enter through middleware; both manifest-injection sites use safeJsonStringify.

New commit 2dc5a3a — verified correct

buildActionOwnerManifest now grants ownership to inline ($$hoist_*) actions declared inside reachable imported Server Components (action-owner-manifest.ts:441-452), using a new inlineExportNames field from the patched serverReferenceMetaMap. The gating on inlineExportNames.has(exportName) correctly avoids inferring provenance for user-named exports (covered by the new "does not infer inline action provenance from user-defined export names" test) while owning compiler-hoisted inline actions reachable through a plain import (the IMPORTED_INLINE_ACTION_EXECUTED prod test at action-runtime-security.test.ts:322). Good fix. Note it adds another patch-only API surface (inlineExportNames), which compounds #1 below.

1. (Blocking, unchanged) Server actions are unbuildable for end users — the required plugin API only exists in this repo's local patch

The ownership pipeline still depends entirely on manager.scanBuildObservers / manager.scanBuildModules / serverReferenceMetaMap.inlineExportNames, all of which are added by patches/@vitejs__plugin-rsc@0.5.27.patch (this file does not exist on main). I confirmed the published plugin lacks the API:

$ npm pack @vitejs/plugin-rsc@0.5.27 && grep -c scanBuildObservers package/dist/*.js
scanBuildObservers NOT in published 0.5.27

@vitejs/plugin-rsc is an optional peer dep with range ^0.5.26 (packages/vinext/package.json:193), and vinext init installs it as a plain devDependency (init.ts:226). End users therefore get the unpatched, published plugin. There is no mechanism to ship this patch to consumers, and no fallback path remains. Result: for any end-user App Router app with server actions, configResolved leaves hasActionOwnerScanObserver = false (index.ts:3202-3205), and renderChunk hits:

if (!hasActionOwnerScanObserver && serverReferences.length > 0) {
  this.error("[vinext] The installed @vitejs/plugin-rsc does not expose scan-build observers ... Install a compatible release before building.");
}

(index.ts:3729-3733) — the build fails hard, pointing users at a "compatible release" that does not exist on npm. This ships as a hard regression. Needs one of: (a) vendor the observer/lexer logic so it doesn't depend on a patched plugin, (b) get the API upstreamed and pin the peer range to a real published version, or (c) restore a fallback when the observer is absent. Please confirm the distribution story before merge.

2. (Regression, unchanged) The generateBundle build-fail safety net is still removed

ba44206 had a generateBundle hook that failed the build if __VINEXT_ACTION_OWNERS_STUB__ survived un-injected in any RSC chunk ("vinext: server action owner manifest was not injected into the RSC entry"). Every prior ask-bonk review credited this with converting the silent-runtime-404 into a hard build error. It is still gone (grep for the message returns nothing in packages/vinext/src).

The failure mode it guarded is unchanged and severe: if the stub ships un-replaced, __VINEXT_ACTION_OWNERS() returns the literal string "__VINEXT_ACTION_OWNERS_STUB__". That string is truthy, so forwardServerActionIfNeeded's if (!options.actionOwners) return null (app-action-forwarding.ts:129) does not short-circuit; actionOwnerPatterns then does Object.hasOwn(<string>, actionId)undefinedownerPath undefined → every server action 404s (:143-148). The only remaining backstop is the per-chunk if (!injected) this.error(...) at index.ts:3742, which fires only when the stub is seen but the regex misses — not when the whole hasActionOwnerStub && rscActionOwnerRoutes block is skipped (e.g. rscActionOwnerRoutes already null, load hook not re-fired, or a future codegen split). Recommend restoring the generateBundle assertion — cheap insurance against a total-breakage, silent failure mode.

3. (Parity gap, unchanged) Dev disables action ownership/forwarding entirely

actionOwners is passed as null in serve (index.ts:3556, command === "build" ? undefined : null) and rscActionOwnerRoutes is null in dev (:3527-3528). At runtime forwardServerActionIfNeeded returns null immediately for a null manifest (app-action-forwarding.ts:129), so in dev every action executes on whatever route it was POSTed to — the ownership gating and middleware re-entry this PR adds are prod-only. For a security-relevant feature ("action runtime takeover") this deserves at least a code comment + tracked follow-up; ideally dev keeps some enforcement. dev/prod parity is a stated project goal.

4. (Silent drop, unchanged) Scan observer drops modules on positional-alignment mismatch

createActionOwnerScanObserver maps lexer specifiers to resolved ids positionally (action-owner-manifest.ts:288-292), gated by if (event.info.importedIds.length !== pending.sourceOrder.length) return; (:285-287). When counts differ, the entire module's edges are dropped silently → any action reachable only through it becomes unowned → runtime 404, no diagnostic. The alignment is fragile: sourceOrder dedups by specifier string (:180-183) while info.importedIds is Rollup's list of resolved ids, so two specifiers resolving to the same id (./a and ./a.js) shrink importedIds below sourceOrder and drop the whole module. Consider matching by resolved specifier rather than by index, and emitting a debug log on drop. Compounds #2 (no build-time backstop remains).

5. (Confirm) Action re-render mode is NAVIGATION (was PRESERVE_UI before the main merge)

app-server-action-execution.ts:1408/:1555 now use APP_RSC_RENDER_MODE_NAVIGATION. PRESERVE_UI was removed on main; the merge reconciled these sites to NAVIGATION. This is a semantic change to post-action re-render that rode in with the merge rather than being an explicit decision here. Worth confirming NAVIGATION preserves the same UI/state semantics PRESERVE_UI did.


Summary: the security model and the patched happy-path build are correct and well-tested, and 2dc5a3a closes a genuine inline-ownership gap. But #1 is a merge blocker — server actions can't build for end users on the published @vitejs/plugin-rsc, and the build fails pointing at a nonexistent release; 2dc5a3a adds yet another patch-only API. #2 (removed generateBundle guard) and #4 (silent module drop) together remove the safety nets prior reviews relied on for the total-breakage failure mode. #3 is a dev/prod parity regression for a security feature. Please confirm the plugin-rsc distribution story before merge.

@ask-bonk

ask-bonk Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2520.

Summary

The head is now 2dc5a3aone commit past the last ask-bonk review (e3630a8). I read the full diff, applied the plugin patch via vp install, ran the targeted suites (39 tests pass), and independently verified the blocking claims from prior reviews.

New commit 2dc5a3a — verified correct: It closes a real gap by granting ownership to compiler-hoisted inline ($$hoist_*) actions declared in reachable imported Server Components, while correctly avoiding provenance inference for user-named exports. But it does so via a new inlineExportNames field that is another patch-only API — deepening the core blocker.

Issues raised

  1. (Blocking, unchanged) Server actions are unbuildable for end users. I definitively confirmed the required scanBuildObservers/scanBuildModules/inlineExportNames API exists only in this repo's local patch — npm pack @vitejs/plugin-rsc@0.5.27 (the latest release, matched by the ^0.5.26 peer range) does not contain it. vinext init installs the unpatched published plugin, no fallback remains, and the build hard-fails pointing users at a nonexistent "compatible release."
  2. (Regression, unchanged) The generateBundle build-fail safety net (which prior reviews credited for turning the silent all-actions-404 into a hard build error) is still removed. Verified the truthy-literal-stub → 404 failure mode is intact.
  3. (Parity gap, unchanged) Dev disables action ownership/forwarding entirely (actionOwners: null in serve) — a dev/prod parity regression for a security feature.
  4. (Silent drop, unchanged) The scan observer positionally matches specifiers to resolved ids and silently drops whole modules on count mismatch.
  5. (Confirm) Action re-render mode changed PRESERVE_UINAVIGATION via the main merge.

Only #1 is a merge blocker; #2/#4 remove safety nets prior reviews relied on.

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.

2 participants