Commit Graph

1756 Commits

Author SHA1 Message Date
github-actions[bot] 07cf9e5c58 chore: version packages (beta) (#2726)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@vinext/cloudflare@1.0.0-beta.5 create-vinext-app@1.0.0-beta.2 vinext@1.0.0-beta.5
2026-08-07 23:15:07 +01:00
James Anderson 25a33cefe9 fix(test): lock hybrid i18n fixture workspace (#2838) 2026-08-07 21:25:47 +00:00
James Anderson b50bace7dd fix(test): name hybrid i18n fixture package (#2837) 2026-08-07 21:21:28 +00:00
Norcleeh a01075ad42 fix(build): throw error if there is _next folder inside the public folder (#2814)
* fix(build): throw error if there is `_next` folder inside the public folder

The `_next` folder under the public folder conflicts
with the internal `_next` route, which is not expected

* fix(build): harden public asset conflict validation

---------

Co-authored-by: James <james@eli.cx>
2026-08-07 21:54:55 +01:00
dependabot[bot] 6f656ffc3b chore(deps): bump voidzero-dev/setup-vp from 1.16.1 to 1.17.0 (#2836)
Bumps [voidzero-dev/setup-vp](https://github.com/voidzero-dev/setup-vp) from 1.16.1 to 1.17.0.
- [Release notes](https://github.com/voidzero-dev/setup-vp/releases)
- [Commits](https://github.com/voidzero-dev/setup-vp/compare/143f5f385f39b1b753ffed1a01ad443811855c8b...313600b80b104eadebb9111787d37a2e83e014ca)

---
updated-dependencies:
- dependency-name: voidzero-dev/setup-vp
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 20:38:40 +01:00
James Anderson 884259a898 fix(dev): adopt initial Pages stylesheets in Vite (#2825)
* fix(dev): adopt initial Pages stylesheets in Vite

* fix(dev): deduplicate fallback Pages stylesheets

* fix(dev): serve virtual Pages stylesheets
2026-08-07 11:49:12 +01:00
James Anderson 0bc39ef5bd fix(pages): apply fallback rewrites after API misses (#2827)
* fix(pages): apply fallback rewrites after API misses

* fix(pages): distinguish API normalization from rewrites
2026-08-07 11:33:24 +01:00
James Anderson 4210d1bcfd fix(css): resolve Sass tsconfig path aliases (#2824) 2026-08-07 11:11:47 +01:00
James Anderson 843d3ed921 test: add Vite ecosystem CI entry points (#2833) 2026-08-07 10:12:19 +01:00
Donny/강동윤 86620d5917 feat(client): expose host React to Module Federation remotes (#2828)
* fix(client): bridge React for Module Federation

* fix(ci): register generated React bootstrap entry

---------

Co-authored-by: James <james@eli.cx>
2026-08-06 14:43:11 +00:00
dependabot[bot] 3c5cea4f25 chore(deps): bump voidzero-dev/setup-vp from 1.15.0 to 1.16.1 (#2816)
Bumps [voidzero-dev/setup-vp](https://github.com/voidzero-dev/setup-vp) from 1.15.0 to 1.16.1.
- [Release notes](https://github.com/voidzero-dev/setup-vp/releases)
- [Commits](https://github.com/voidzero-dev/setup-vp/compare/250f29ce396baf5e8f24498e17c0dfdebabc26eb...143f5f385f39b1b753ffed1a01ad443811855c8b)

---
updated-dependencies:
- dependency-name: voidzero-dev/setup-vp
  dependency-version: 1.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 21:53:19 +01:00
James Anderson 255be4e2ab fix(middleware): align encoded path matching (#2802)
* fix(middleware): align encoded path matching

* test(pages): separate matcher and route identity

* fix(middleware): preserve delimiter matcher parity

* fix(middleware): preserve trailing source delimiters

* test(middleware): align trailing source expectations

* fix(middleware): align trailing slash matcher normalization
2026-08-04 15:20:08 +01:00
James Anderson dfc979cc42 fix(app-router): hand off zero-stale prefetches (#2801) 2026-08-03 22:34:21 +01:00
dependabot[bot] 4f7d3fc3f9 chore(deps): bump zizmorcore/zizmor-action from 0.6.0 to 0.6.2 (#2800)
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.0 to 0.6.2.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/6599ee8b7a49aef6a770f63d261d214911a7ce02...3dc1ecc9bcb9e94e9b2c709687979e1298497054)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 20:42:51 +01:00
James Anderson cf689f2e43 fix(pages): run middleware before image endpoint (#2776)
* fix(pages): run middleware before image endpoint

* fix(pages): preserve image rewrite query
2026-08-03 18:33:41 +00:00
James Anderson 2d08de067d fix(pages): preserve raw data URLs for middleware (#2775)
* fix(pages): preserve raw data URLs for middleware

* fix(pages): preserve data route path with skipped normalization
2026-08-03 18:26:09 +00:00
James Anderson 97aea8fc07 fix(ci): pin Next.js tracker OpenCode version (#2799)
* fix(ci): pin Next.js tracker OpenCode version

* fix(ci): align tracker model with Big Bonk
2026-08-03 16:46:19 +00:00
James Anderson 3deb467b21 fix(pages): preserve gSSP headers on redirects (#2771)
* fix(pages): preserve gSSP headers on redirects

* test(pages): cover data redirect headers
2026-08-03 15:43:57 +00:00
James Anderson 81c0b03ef2 fix(pages): preserve not-found response headers (#2773)
* fix(pages): preserve not-found response headers

* fix(pages): preserve not-found headers in dev
2026-08-03 15:59:22 +01:00
James Anderson f9278afafe fix(pages): support bodyless API responses (#2772) 2026-08-03 15:32:50 +01:00
James Anderson 948a14a7e6 fix(examples): declare complex purge secret (#2774) 2026-08-03 15:23:57 +01:00
James Anderson ce90ea286a fix(app-router): pass rewrite validation to source middleware (#2798) 2026-08-03 14:08:17 +00:00
Nathan Nguyen 4108dab89c fix(fetch-cache): honor RequestInit in request dedupe (#2762)
* fix(fetch-cache): honor RequestInit in request dedupe

Request inputs with per-call overrides were deduped using the base Request even though fetch executes the overridden headers and options. Distinct authenticated requests could therefore share one response and persist it under the wrong cache key.

Normalize the effective GET or HEAD request before deriving the dedupe key so request-scoped reuse and persistent cache storage stay partitioned by the options the network sees.

* fix(fetch-cache): preserve effective request semantics

* fix(fetch-cache): key inherited request options

* fix(fetch-cache): serialize bodies with effective headers

* fix(fetch-cache): normalize default request options

* fix(fetch-cache): key normalized request body metadata

* fix(fetch-cache): distinguish normalized body variants

* fix(fetch-cache): hash body bytes without loss

* fix(fetch-cache): bound body key fallback work

* test(fetch-cache): lock effective auth bypass

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 15:07:04 +01:00
Nathan Nguyen 7d786e0350 fix(app-router): keep server metadata out of RSC payloads (#2769)
The App Router payload previously serialized partial render observations and slash-prefixed source page paths into document HTML. Server-only cache tags could leak into the client bootstrap, while crawlers could interpret source page metadata as URLs.\n\nKeep complete render observations at cache finalization, transport source pages as validated segments, and retain legacy source strings only for reading cached payloads during rolling deployments. Next-compatible browser source-page diagnostics remain reconstructible from the segment form.\n\nAdd codec, renderer, fallback, and production HTML regression coverage for the wire contract and the absence of server metadata from documents.

Co-authored-by: James <james@eli.cx>
2026-08-03 14:45:02 +01:00
Nathan Nguyen 7bcbe94a25 fix(script): reject event attributes in hoisted HTML (#2763)
App Router hoisting manually serializes beforeInteractive Script props, bypassing React DOM filtering for string-valued event handlers. Request-influenced on* props could therefore become executable inline attributes in the server response.\n\nReject event-handler names case-insensitively at the raw HTML emission boundary while preserving legitimate attributes such as data-onload. The regression test exercises the actual Script capture and hoisted render path.

Co-authored-by: James <james@eli.cx>
2026-08-03 14:26:42 +01:00
Nathan Nguyen 87220d0bc2 test(app-router): cover consumed prefetch cache handoff (#2758)
* fix(app-router): preserve consumed prefetch during cache handoff

Keep the buffered snapshot discoverable while navigation publishes the committed visited response. This prevents a remounted prefetch-enabled Link from observing a false cache miss and starting a duplicate RSC request.

* test(app-router): verify prefetch handoff delay

* test(app-router): latch prefetch handoff delay

* test(app-router): hold cache latch through remount

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 14:26:18 +01:00
Nathan Nguyen 621610afd2 fix(app-router): stop flooring dynamic prefetch stale times (#2757)
* fix(app-router): stop flooring dynamic prefetch stale times

Next keeps its two client stale-time dimensions on separate rules. The
cacheLife/router bound goes through `getStaleTimeMs`, which floors at 30s
(segment-cache/cache.ts). The dynamic bound goes through
`computeDynamicStaleAt` (segment-cache/bfcache.ts), which applies no floor
at all, so `staleTimes.dynamic: 0` means a dynamic payload is never reused
across a navigation.

`resolvePrefetchedRscResponseExpiresAt` floored the *combined* value, which
`resolveRscResponseStaleTimeSeconds` had already min-combined. Since
`serverStaleTimeSeconds` floors the cacheLife half before the min, the outer
floor's only live effect was raising a dynamic bound the resolver's own
comment says it must never raise.

Every dynamic render reports its bound: `app-page-render.ts` defaults
`dynamicStaleTimeSeconds` to `experimental.staleTimes.dynamic` (0), emitted
as a header when the render is known dynamic up front and in the completion
footer when it turns dynamic mid-stream. Flooring that to 30s let a
credentialed RSC payload be replayed for 30s after a logout, role change, or
permission revocation, with no server round-trip. The consumed expiry then
propagated into the visited response cache, extending the window past the
navigation.

Floor only the unsignalled fallback, mirroring `STATIC_STALETIME_MS =
getStaleTimeMs(config)`. A signalled bound is now authoritative.

This also removes the `minimumTtlMs` plumbing, a partial workaround that
zeroed the floor for routes with a dynamic *pattern* segment. It keyed on
the wrong axis — a statically-patterned route rendering dynamically
(`/dashboard` reading `cookies()`) never matched — and the correct fix
subsumes it. The one test that relied on it now drives the same assertion
through the header a real dynamic render sends.

* fix(app-router): scope the dynamic bound to automatic prefetches

CI caught that the previous commit applied the dynamic stale-time bound to
`prefetch={true}` as well, breaking segment-cache-metadata's rewrite reuse.

Next splits reuse by prefetch kind, not only by stale-time dimension. In
`getPrefetchEntryCacheStatus` a `full` prefetch stays reusable up to
STATIC_STALETIME_MS even for dynamic content; only `auto` degrades past
DYNAMIC_STALETIME_MS. The segment-cache/bfcache.ts comment states it
directly — dynamic prefetches "use STATIC_STALETIME_MS instead of
DYNAMIC_STALETIME_MS" — and the upstream metadata test says so in prose:
"Because the link is prefetched with prefetch={true}, we should be able to
prefetch the title, even though it's dynamic."

So `prefetch={true}` is an explicit opt-in to holding dynamic content for the
static window. Carry that on the policy as `honorDynamicStaleTime`: true for
`resolveAutoAppRoutePrefetch`, false for `resolveFullAppRoutePrefetch`. A
full prefetch resolves its expiry from cacheLife alone, still floored at 30s
per `getStaleTimeMs`; an automatic one additionally honors the dynamic bound.

This is the axis the removed `minimumTtlMs` was groping for — it keyed on
route-pattern dynamism, which is neither the prefetch kind nor the render
kind.

* fix(app-router): keep the prefetch floor for explicit full prefetches

CI showed the previous commit went too far the other way: dropping the
dynamic bound entirely for `prefetch={true}` stretched those windows from 30s
to the full 300s static TTL, and client-cache's parallel-route reuse tests
started re-issuing full prefetches.

Scope the change to the floor rather than to which bounds apply. An automatic
prefetch takes a dynamic render's bound verbatim — including below the 30s
prefetch floor — so a `0` expires immediately, which is the vulnerability.
`prefetch={true}` still min-combines both bounds but keeps Next's ≥30s
prefetch floor, reproducing the previously-green behavior for full prefetches
exactly.

That confines the behavioral change to automatic prefetches, which is where
the finding lives: default `<Link>` prefetching of a dynamically rendered
route.

* ci: retrigger client-cache e2e (suspected flake)

* chore: drop e2e fixture node_modules symlinks committed by mistake

* chore: retrigger CI

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 12:27:09 +01:00
Nathan Nguyen 5f63ae4ae1 fix(app-router): validate external RSC rewrites before proxying (#2754)
* fix(app-router): validate external RSC rewrites before proxying

Out-of-basePath RSC requests claimed by basePath:false rewrites could reach external destinations before their missing or stale _rsc token was canonicalized. This bypassed cache-busting validation in every rewrite phase.

Require every external rewrite call to validate a claimed request before proxy I/O. Regression coverage exercises GET and HEAD in beforeFiles, afterFiles, and fallback and verifies the upstream is never contacted for invalid tokens.

* fix(app-router): validate middleware external RSC rewrites

* fix(app-router): preserve external RSC proxy state

* fix(app-router): preserve canonical RSC redirects

* fix(app-router): restore external Flight headers

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 12:12:42 +01:00
Nathan Nguyen aec4421b2a fix(app-router): let concrete Pages routes win middleware rewrites (#2730)
* fix(app-router): let concrete Pages routes win middleware rewrites

A Pages data request that middleware rewrote returned a synthetic empty
JSON body whenever any App route matched the rewrite target, including a
dynamic or catch-all match. Every other App-vs-Pages ownership decision in
this handler treats a dynamic App match as non-owning, so a concrete Pages
route at the same pathname should render instead. Skipping that arbitration
meant getServerSideProps never ran for the rewrite target, and the client
router, which reuses the middleware probe response when the rewrite target
resolves to a Pages route, accepted the empty body as successful page data.
Redirect and notFound markers the Pages route would have returned were
therefore absent during client-side navigation.

Restrict the shortcut to App matches that own the target outright, so
dynamic matches fall through to the existing static and dynamic Pages
fallback arbitration.

That fallthrough reaches the tail Pages data response, which built its
headers from the not-found response alone and dropped headers the
middleware set on the way. Merge the middleware response headers there so
a rewrite landing on a genuinely App-owned dynamic route still carries its
cookies.

* fix(app-router): preserve middleware headers on Pages fallbacks

* fix(app-router): use Pages response merge semantics

* test(app-router): cover rewritten Pages data ownership

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 11:02:14 +01:00
Nathan Nguyen 9370ea6937 fix(app-router): keep mounted-slot RSC responses no-store (#2728)
* fix(app-router): keep mounted-slot RSC MISS responses no-store

finalizeAppPageRscCacheResponse derived "should I rewrite the client
headers?" from the return value of scheduleAppPageRscCacheWrite. Those are
independent decisions, and #2497 made them disagree: mounted-slot variants
now correctly skip the persistent write (their RSC key is slot-blind), but
the early return took the pending-dynamic finalization with it.

The result is that a fresh ISR-eligible RSC MISS carrying
X-Vinext-Mounted-Slots leaves the origin with its initial
`s-maxage=..., stale-while-revalidate` instead of being rewritten to
`no-store, must-revalidate`. That header is what stops a shared cache from
storing a stream that may still reach cookies()/headers() below a Suspense
boundary after the cache policy was chosen, so a personalized payload can be
stored and replayed for the URL/variant. Apps with named parallel routes send
the header on essentially every client navigation; apps without slots never
enter the path.

Gate the header rewrite on preserveClientResponseHeaders alone, which is
already `cacheState !== "MISS"` at the only production call site. This
restores the client-facing behavior that shipped before #2497 while keeping
its cache-write change, and matches finalizeAppPageHtmlCacheResponse, which
never coupled the two. Doing it structurally rather than adding a
mountedSlotsHeader term means the next early return added to
scheduleAppPageRscCacheWrite cannot silently reintroduce this.

* fix(app-router): keep mounted slots out of edge caches

* fix(cache): clear mounted-slot CDN overrides

* docs(cache): explain mounted-slot no-store scope

* fix(cache): clear pending CDN overrides

* docs(cache): clarify pending header policy

* test(cache): cover dynamic mounted-slot headers

* test(cache): cover pending HTML CDN overrides

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 10:58:41 +01:00
Nathan Nguyen d65ff09985 fix(app-router): authorize the interception source route before rendering it (#2733)
* fix(app-router): authorize the interception source route before rendering it

Interception renders the source route's tree for the request, so one request
reaches two routes: the requested target and the claimed interception source.
Middleware runs once, for the target's cleanPathname, before the source is
known. The source pathname arrives in the `x-vinext-interception-context`
client header, so a crafted RSC request naming a middleware-guarded route
under the intercepting route causes that route to render and returns its
payload, while the guard never sees the path it protects. Applications using
middleware path checks as their authorization boundary lose it for any route
reachable as an interception source.

Run middleware for the claimed source pathname before anything renders from
it, and return its response when it denies. The check is skipped when the
source resolves to the route already matched for this request, which is also
the case where interception does not fire, so ordinary requests and requests
without an interception context keep their single middleware run.

Only a returned response counts as a denial. A rewritten or normalized
pathname means middleware admitted the source and merely routes it elsewhere,
which is a routing concern rather than an authorization one.

This boundary has no upstream counterpart because the situation does not arise
upstream: the generated interception rewrite targets the intercepting route
and the client keeps the segments it already holds, so Next.js never renders
the source route for this request and has no second route to authorize. It is
vinext rendering the source tree that creates the extra boundary.

* fix(app-router): preserve action bodies during source authorization

Source-route middleware authorization rebuilt its request from the Server Action request directly. That transferred the body stream and left action dispatch unable to read it.

Clone the body branch before changing the source URL so middleware and the action retain independent readable streams.

* fix(app-router): isolate interception source authorization

* fix(app-router): fail closed on source request mutations

* fix(app-router): harden interception source authorization

* fix(app-router): align interception source identity

* fix(app-router): close interception authorization gaps

* fix(app-router): preserve matcher and body ownership

* fix(app-router): align interception query authorization

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 00:48:17 +01:00
James Anderson 456ddbef11 fix(actions): run redirect targets through full request pipeline (#2785)
* fix(actions): run middleware for server action redirect targets

A server action that throws `redirect()` renders the target page inline
and returns its Flight payload with the action response, instead of
making the client re-request the target. Middleware had only run for the
action's own path, so the target's middleware never saw the request: an
action reachable on a public path could redirect to a middleware-gated
page and return that page's server-rendered payload, which the browser
client decodes and commits. Next.js has no such hole because the client
re-requests the target through the full pipeline.

Run the target's middleware against the synthetic GET before rendering
it, after the redirect render's headers context is installed so
`NextResponse.next({ request: { headers } })` overrides reach the page.
Middleware response headers merge into the action response.

Only a clean pass-through is rendered inline. A block, redirect, rewrite,
or status override diverts to the header-only 303 that already exists for
non-App-route targets, which the client re-requests through the full
request pipeline. Apps without middleware, and targets no matcher
matches, are unaffected.

* fix(actions): match redirect targets with request route identity

Follow-up to the target-middleware fix, addressing three ways the target
could still be evaluated as something other than the request the client
would have made.

`matchRoute` decodes pathname segments, so an encoded alias like
`/adm%69n` resolved to the `/admin` page while middleware and a real
navigation both saw `/adm%69n` — inline-rendering a route neither
reached. Match redirect targets with request route identity instead.

`cloneActionRedirectHeaders` carried `x-vinext-mw-ctx` onto the target's
request. In hybrid app+pages dev that header holds the Pages handler's
middleware result for the *action* path, which
`applyForwardedMiddlewareContext` replays in place of executing
middleware for the target. Strip it, which also keeps the internal header
out of the redirect render's `headers()`.

Middleware object matchers support `has`/`missing` header predicates, so
a matcher gated on `Accept` took a different branch against the render
request, which drops `Accept` with the action transport headers. Give
middleware a request that keeps it.

* docs(actions): note the redirect-target middleware divert trade-off

* fix(actions): run target middleware before importing redirect-target modules

A middleware-blocked redirect target still executed its route modules'
top-level code: the target was hydrated (dynamically imported) to decide
renderability before the middleware probe ran, so an unauthorized action
redirect could trigger module side effects, or turn a module-eval throw
into a 500 instead of the header-only fallback. Renderability is now
decided from the manifest's lazy thunks (__loadPage/__loadRouteHandler),
and hydration happens only after middleware passes the target through.

The probe leaked two more behaviors a real navigation would not produce:

- Pass-through middleware response headers merged verbatim onto the 303
  action wrapper, so a middleware-set Location gave the wrapper genuine
  HTTP-redirect semantics and fetch followed it before the action client
  could read x-action-redirect. Middleware header merges onto the wrapper
  now strip Location.

- The synthetic target requests were built with bare new Request(), which
  drops the Workers cf property, so target middleware keying off
  request.cf (geo checks) failed open. Both requests now re-attach cf via
  the metadata helper the request clone utilities already used.

* fix(actions): carry middleware cookie mutations onto the redirect target

When action-path middleware rotated or deleted an authentication cookie,
the synthetic redirect-target request was still built from the original
inbound Cookie header, so target middleware evaluated a credential the
response was simultaneously revoking and could render the protected page
inline. The middleware's Set-Cookie mutations now feed the same
request-cookie rebuild as the action's own cookies().set() calls, in
browser order (middleware first, action wins for the same name).

Also aligns two more target-request details with the real pipeline:

- The internal _rsc transport param is stripped from the redirect target
  before matching, middleware, and render, as app-rsc-handler does for
  navigations, so matchers cannot branch on a query no navigation
  carries. The client-facing x-action-redirect header keeps the verbatim
  URL; a diverted re-request still goes through real validation.

- The middleware header merge onto the action wrapper now restores all
  protocol headers (Content-Type, x-action-redirect and friends) rather
  than only stripping Location, so a pass-through middleware can neither
  replace the wrapper's destination nor flip the client into treating
  the Flight body as non-RSC.

* fix(actions): scope redirect-target cookies and framing to browser behavior

Applying every pending Set-Cookie to the redirect target's Cookie header
ignored the cookie's Path attribute, so a mutation scoped to another
path (admin=1; Path=/account) reached a target like /admin that a real
browser navigation would never send it to, and target middleware could
authorize on it. Mutations now apply only when their Path, or the RFC
6265 default path derived from the action URL, path-matches the target.
Cookies from cookies().set() and draftMode() always carry Path=/ and are
unaffected.

Also adds Content-Length to the wrapper's protected headers: a
pass-through middleware value would misframe the freshly generated
Flight stream and let adapters truncate or reject the action response.

Drops the fixture node_modules symlink an e2e run left staged; on
Windows checkouts with core.symlinks=false it materializes as a plain
file that defeats the Playwright server's link-creation guard. The
gitignore rule loses its trailing slash so it covers symlinks and stops
these from getting staged again.

* fix(actions): use redirect target CSP nonce

* docs(actions): clarify redirect cookie projection

* fix(actions): forward middleware headers to redirect targets

* fix(actions): preserve redirect response framing

* fix(actions): preserve middleware request overrides

* fix(actions): run redirect targets through full request pipeline

* fix(actions): match redirect target header semantics

* fix(actions): mirror Next cookie forwarding

* fix(actions): preserve forwarded cookie ordering

* fix(actions): avoid stale forwarded cookies

---------

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
2026-08-03 00:28:04 +01:00
Nathan Nguyen ced0881690 fix(middleware): preserve safe origins for double-slash redirects (#2766)
* fix(middleware): preserve safe origins for double-slash redirects

Same-origin absolute middleware redirects with a pathname beginning in two slashes were relativized into protocol-relative targets. Browsers could then navigate to the first path segment as an external host instead of remaining on the application origin.

Keep open-redirect-shaped paths absolute and retain that invariant while applying trailing-slash policy. Boundary tests cover both HTTP Location and data-request x-nextjs-redirect responses.

* fix(router): retain origin for middleware data redirects

Pages Router converted same-origin absolute data redirects back to app paths before history navigation. For a double-slash pathname, that exposed a protocol-relative target and made replaceState fail as a cross-origin mutation.

Keep the original safe target when origin or basePath removal would expose an authority-shaped app path. The navigation regression test covers history state, the follow-up HTML fetch, and render completion.

---------

Co-authored-by: James <james@eli.cx>
2026-08-01 02:38:59 +01:00
Nathan Nguyen 87fdc98c08 fix(server): prevent bot user-agent regex backtracking (#2765)
* fix(server): prevent bot user-agent regex backtracking

Long non-matching User-Agent values made the default Google crawler suffix branch retry a greedy token scan from every character, causing quadratic request-time CPU work.

Anchor that branch at token boundaries so the broad *-Google matching contract remains intact while each input segment is scanned once. Cover known suffix crawlers and the long non-match path at the public Pages detector boundary.

* ci: retry flaky client-cache e2e

---------

Co-authored-by: James <james@eli.cx>
2026-08-01 02:38:31 +01:00
Nathan Nguyen a9fe796d44 fix(server): evaluate lazy route modules outside the request context (#2740)
* fix(server): evaluate lazy route modules outside the request context

Page and route-handler modules moved from eager RSC-entry evaluation to
lazy `() => import()` thunks resolved on the first request that matches the
route. A dynamic import propagates AsyncLocalStorage into the imported
module's top-level evaluation, and `ensureAppRouteModulesLoaded` is awaited
inside `runWithRequestContext`, so module-scope `headers()`/`cookies()`
bound to whichever request happened to reach the route first. ESM evaluates
a module once per isolate and the namespace is cached on the route with
`__loaded = true`, so that first visitor's session data was then reused for
every later request.

Invoke every lazy thunk through a new `runOutsideRequestContext`
(`AsyncLocalStorage.exit()`, which workerd implements) so module scope sees
no request. This restores the eager-evaluation contract the lazy loader
replaced and matches Next.js, which loads components in `base-server` before
`app-render` enters the request store.

* fix(server): load global-not-found outside the request context

`resolveGlobalNotFoundModule` imports the user's `app/global-not-found.tsx`
on the first route-miss 404 and caches the promise for the worker's
lifetime. That import runs inside the 404-triggering request's context, so
module-scope `headers()`/`cookies()` in global-not-found.tsx bound to that
first visitor and were then served to everyone after them — the same
mechanism as the lazy route-module thunks, on a sibling call path.

Route it through `runOutsideRequestContext` too.

* fix(server): exit every request-scoped ALS before module evaluation

Exiting only the unified request context was not enough. The Cloudflare
entry (`app-router-entry.ts:173`) enters the standalone execution-context
ALS *outside* it, and prerendering enters the work-unit store *inside* it,
so both stayed visible to module scope. Worse, `after()` takes its
`getRequestExecutionContext()` fallback precisely when the unified store is
absent — so the partial exit enabled that path instead of closing it,
letting top-level `after()` attach to the first request's `waitUntil`.

`runOutsideRequestScopes` now exits every ALS handed out by
`getOrCreateAls`, which is future-proof by construction: new shims are
enrolled as they are created, with no enumeration to keep in sync.
`workUnitAsyncStorage` registers itself via `registerAlsForScopeExit`
because it must stay module-local (Sentry resolves it by specifier), and
registering from that side keeps `als-registry` importing nothing but
`node:async_hooks` — it stays evaluable in client bundles where that
resolves to a constructor-less stub.

Reported by Codex on #2740.

* fix(server): isolate intercept page and not-found module loads

The intercepting-route page and not-found modules were imported inline at
their call sites — `resolveAppPageInterceptState` in app-page-request.ts and
the generated `probePage` path — so they missed the isolation every other
route module gets. They are user modules cached on the intercept for the
isolate's lifetime, so module-scope `cookies()`/`headers()` could still
capture the first visitor to an intercepted route.

Move both onto the shared loader as `loadAppInterceptPage` /
`loadAppInterceptNotFound`, beside the `loadAppInterceptLayouts` that already
lives there. Both call sites now route through one guarded implementation
instead of two hand-rolled copies of the same dedup block.

Reported by Codex on #2740.

* fix(app-router): publish concurrent intercept module loads

---------

Co-authored-by: James <james@eli.cx>
2026-07-31 22:24:01 +01:00
Nathan Nguyen 81db6e314e fix(app-router): reject Route Handlers as interception source routes (#2732)
* fix(app-router): reject Route Handlers as interception source routes

An RSC request whose path has no App route match is promoted to an
interception source route selected by the `x-vinext-interception-context`
header. `findIntercept` gates that header against the intercepting route's
pattern with descendants allowed, then resolves the claimed pathname through
the route trie and returns whichever concrete route it lands on. When that
route is a Route Handler, the promoted match reaches the handler dispatch
branch, so a crafted header executes a `route.ts` that merely lives under the
intercepting route, having only run middleware for the requested target path.
Applications that guard route handlers with middleware path checks therefore
lose that boundary: a request to an interception-only target returns the
protected handler's response.

A Route Handler has no page, layouts, or parallel slots, so it can never own
or sit inside an interception source tree. Skip it when resolving the concrete
descendant source route and fall back to the slot owner, which is the fixed
destination Next.js' generated interception rewrite targets. Descendant page
routes still resolve concretely so dynamic source params survive.

The interception source pathname remains unauthenticated, matching Next.js'
`Next-URL` gating. Promoting a descendant *page* route can still render a
middleware-guarded page for a target it does not own; closing that requires
deciding whether descendant promotion should exist at all, and is left
unchanged here.

* fix(app-router): keep slot owner params when rejecting a descendant source

Falling back from a rejected Route Handler resolved the slot owner's params
with an exact pattern match against the claimed source pathname. That match
can never succeed on this path: the source was approved by the
descendants-allowed gate precisely because it carries extra segments beyond
the owner's pattern, so the match returns null and `sourceMatchedParams` ends
up empty. `matchInterceptRoute` derives the promoted owner's params solely
from that object, so a dynamic owner such as `/[locale]/feed` rendered with no
`locale` for a source of `/en/feed/admin`.

Take the owner's params from the prefix the source gate already approved when
the exact match fails. The same recovery covers a descendant source that
resolves to no concrete route at all, which had the identical gap before this
branch existed. The legacy manifest shape, which has no declared
`sourceMatchPattern`, still requires an exact match so its secondary gate keeps
rejecting unrelated sources.

* fix(app-router): reject route handler interception owners

---------

Co-authored-by: James <james@eli.cx>
2026-07-31 14:08:13 +01:00
Nathan Nguyen dc054592ea fix(pages-router): stream piped API responses with backpressure (#2735)
* fix(pages-router): stream piped API responses with backpressure

A handler that pipes into `res` (proxying an upstream, echoing a request
body) could queue the entire stream in memory. The response bridge
acknowledged every write immediately, so `write()` never returned false
and a piped source was never paused, regardless of how slowly the client
consumed the body. The Node production server also read API responses
with `arrayBuffer()`, which held the full body in memory and deferred
delivery until the source closed, so long-lived streams (e.g. proxied
SSE) never flushed.

Hold the write callback while the response body's queue is full and
release it from the stream's pull hook. A held callback makes `write()`
return false, which pauses any piped source until the consumer reads.

Mark responses that are still being written when the handler settles and
send them through the streaming path in the Node production server.
Complete bodies (`res.json`, `res.send`, `res.end(data)`) keep the
buffered path and its Content-Length behaviour.

* fix(pages-router): avoid API stream backpressure deadlocks

* fix(pages-router): preserve streamed API handler lifecycle

* docs(pages-router): clarify API stream backpressure

---------

Co-authored-by: James <james@eli.cx>
2026-07-31 11:57:04 +01:00
Nathan Nguyen fb9be4bb01 fix(router): minimize client rewrite manifests (#2734)
* fix(router): minimize client rewrite data

Browser entries currently serialize resolved rewrite objects verbatim even when the client cannot execute a rule locally. Project rewrites into a client-specific discriminated shape, retaining internal destinations that support SPA routing while handing external and missing-condition rules to the server.

Keep the full matcher lazy on Pages and cover generated manifests and both client navigation ownership paths.

* fix(router): keep server-only rewrite inputs private

Cookie and header rewrite conditions can depend on values that browser JavaScript cannot observe. Evaluating them client-side can both publish those values and skip rewrites that the server would match.

Project only query and host conditions, fail closed for every other condition type, and use the same projection for the public build manifest so all client outputs preserve the same boundary.

---------

Co-authored-by: James <james@eli.cx>
2026-07-31 11:51:54 +01:00
Nathan Nguyen 87a3f37a49 fix(build): exclude filtered require.context modules (#2736)
* fix(build): exclude filtered require.context modules

require.context regexps previously filtered only the runtime map after a broad eager glob had imported every file. This evaluated and bundled excluded modules, including from client components.

Resolve and filter context entries during the transform so only accepted files become static dependencies. Keep context directories watched so create and delete events can update the generated module set.

* test: update require-context unit tests for static-import transform

* fix(build): harden require.context enumeration and bindings

Replace fs.glob (withFileTypes needs Node 22.2, engines allow >=22) with a readdir walk that follows directory symlinks like webpack and guards cycles via realpath, and grow the generated import binding prefix past any identifier already present in the source.

* fix(build): scope symlink cycle guard to the recursion path

A global realpath set deduplicated distinct symlink aliases of the same directory; track realpaths only along the current recursion path so aliases keep their own context keys while cycles still terminate.

* fix(build): stat directory entries with unknown dirent types

Filesystems without dirent type info (NFS, SMB, FUSE) report entries that are neither file nor directory; fall back to stat for any unknown type instead of only symlinks, and skip unresolvable ENOENT/ELOOP entries.

* fix(build): make require.context deterministic and dev-invalidation complete

Assign import binding indices after sorting so readdir order cannot change bundle bytes; invalidate recursive contexts on any membership event since a directory create/delete can change matching descendants without matching the file regexp; and drop watched-context entries for updated modules so importers that lose their last require.context call stop invalidating.

---------

Co-authored-by: James <james@eli.cx>
2026-07-31 11:17:36 +01:00
Nathan Nguyen b3a82e5024 fix(server): transfer request bodies into NextRequest instead of teeing (#2741)
* fix(server): transfer request bodies into NextRequest instead of teeing

`Request.clone()` tees the body stream, and a tee branch that is never read
buffers every chunk the other branch pulls. The NextRequest constructor cloned
any body-bearing input, so wrapping the incoming request left an unread branch
holding the whole body. A route handler that streams a 128 MiB upload to
storage — O(1) memory by design — retained the full 128 MiB instead, letting an
unauthenticated client exhaust the process or Worker isolate by repeating the
request.

Upstream Next.js does `super(input, init)` here, transferring the body rather
than branching it. Match that. Callers that genuinely need two live branches
(middleware vs. downstream routing) already clone explicitly, so nothing loses
the isolation added in #1132.

The clone also defeated the mitigation added in #2026: `executeMiddleware`
cancels the middleware body branch in a `finally`, but the constructor's extra
tee sat between that cancel and the branch actually accumulating chunks, so the
cancel released a branch nobody was filling. With the clone gone the existing
cancel reaches the real branch again.

Drop the same dead tee from the basePath re-prefix path in
`createTrackedAppRouteRequest`, where the source request is replaced outright
and its body is never read again.

Measured with a 128 MiB streamed body, reading through and discarding:

  before: arrayBuffers +128.0 MiB (route handler wrap, and middleware that
          does not touch the body)
  after:  arrayBuffers +0.0 MiB, matching an unwrapped baseline

* fix(server): avoid teeing normalized RSC request bodies

* fix(middleware): release isolated request body branches

---------

Co-authored-by: James <james@eli.cx>
2026-07-31 10:48:02 +01:00
James Anderson 9f1018cd23 fix(middleware): preserve headers for empty override value (#2767)
* fix(middleware): preserve headers for empty override value

* fix(middleware): ignore stray headers for empty override

* fix(middleware): require override list for forwarded headers

* test(middleware): cover stray request header value

* test(middleware): pair valid and stray header overrides

* fix(middleware): preserve unlisted forwarded headers

* fix(middleware): preserve literal forwarded headers
2026-07-31 10:37:36 +01:00
James Anderson 350865c466 fix(create-vinext-app): generate next env on first run (#2768)
* fix(create-vinext-app): generate next env on first run

* fix(types): keep next env renderer internal
2026-07-31 01:00:56 +01:00
Nathan Nguyen 1607104749 fix(middleware): stop restoring credentials middleware deleted before external rewrites (#2739)
`x-middleware-override-headers` carries the complete post-middleware header
set, not a diff: `NextResponse.next`/`rewrite` encode every key of the Headers
object middleware passes, and Next.js deletes any request header missing from
that list. Absence means deleted.

`preserveCredentialHeaders` (#1121) read a short override list as a "partial"
override and copied the base request's `cookie`/`authorization` back in. The
documented deletion pattern — clone `request.headers`, delete the credential,
return `NextResponse.rewrite(externalUrl, { request: { headers } })` — produces
exactly such a list, so the option resurrected the stripped credentials. It was
enabled only for external rewrites, so `proxyExternalRequest` then forwarded
first-party session cookies and bearer tokens to a cross-origin target.

The "partial override" the option guarded against cannot occur:
`encodeMiddlewareRequestHeaders` is the only producer of the override list and
always emits the full key set. Remove the option and restore Next.js-exact
deletion semantics.

Covered end to end (fixture middleware deleting credentials before an external
rewrite), at the proxy boundary, and in the Pages Router pipeline.

Co-authored-by: James <james@eli.cx>
2026-07-31 00:09:51 +01:00
Nathan Nguyen fd35d9d148 fix(cache): bypass shared "use cache" entries in draft mode (#2744)
* fix(cache): bypass shared "use cache" entries in draft mode

`registerCachedFunction` keys shared entries by function id, build seed and
arguments — never by draft-mode state. Since #1489 made `draftMode().isEnabled`
readable inside cache scopes, a cached function that branches on it could have a
preview request seed unpublished content into an entry later served to public
requests, and vice versa.

Skip the shared cache lookup and store when the request is in draft mode,
alongside the existing dev bypass. This mirrors Next.js, which guards its
cache-handler read with `shouldForceRevalidate()` (true when `isDraftMode`) and
its write with an explicit `if (!workStore.isDraftMode)`. `unstable_cache` got
the same guard in #2319; this closes the remaining public "use cache" path.

"use cache: private" is unaffected — it never crosses a request boundary.

* test(app-page): stub isDraftModeEnabled in the headers mock

The element-builder suite replaces `shims/headers.js` wholesale rather than
spreading `importOriginal()`, so the new `isDraftModeEnabled` import in
cache-runtime.ts threw a mocker error through the shared "use cache" path.

* test(cache): preserve public use cache entry in draft mode

* test(cache): prove public draft bypass cache hit

---------

Co-authored-by: James <james@eli.cx>
2026-07-30 23:59:58 +01:00
James Anderson af7bb923ce fix(app-router): preserve page result render ordering (#2760)
* fix(app-router): preserve page result render ordering

* fix(app-router): gate page results on resolved layouts

* fix(app-router): handle exotic layout components

* fix(app-router): republish consumed prefetches promptly

* fix(app-router): normalize consumed prefetch snapshots

* fix(app-router): match Flight forward ref arguments

* fix(app-router): gate exotic page initialization

* fix(app-router): restore segment cache stale windows

* fix(app-router): separate committed stale windows

* fix(app-router): retain cache life through stream completion
2026-07-30 23:57:47 +01:00
Nathan Nguyen ad991a733f fix(pages): refresh next/head tags when regenerating ISR HTML (#2729)
* fix(pages): refresh next/head tags when regenerating ISR HTML

ISR regeneration re-renders the page body but reuses the cached shell
verbatim, so the <head> stays frozen at whatever the first cache-filling
render produced. A page whose title or meta tags derive from
getStaticProps data serves an updated body under permanently stale
metadata, and the two never reconverge for the lifetime of the entry.

next/head tags accumulate as a side effect of rendering into an
AsyncLocalStorage scope that closes when the regeneration pass returns,
so the refreshed head has to be read inside that pass. The render
callback now accepts an onHeadReady hook that runs after the render and
before the scope unwinds.

The collected tags are located in the cached shell through the
data-next-head attribute that getSSRHeadHTML stamps on everything it
emits, which keeps the swap working on entries written before this
change. _document.getInitialProps runs in the same hook because its head
tags share that collector; omitting it would drop them from the
regenerated shell.

_document-rendered head children and CSS-in-JS styles still come from
the cached shell, since regeneration does not re-render _document.

* fix(pages): keep the ISR head refresh off degraded _document paths

Two problems in the regeneration head swap, both raised in review.

The regeneration hook called _document.getInitialProps through the bare
callDocumentGetInitialProps helper, whose context deliberately omits req,
res, pathname, query and asPath and supplies a no-op renderPage. That is
not the contract the initial render uses: enhancePageElement is always
wired in the prod handler, so any user override resolves its head through
runDocumentRenderPage with the real request context instead. A Document
reading ctx.req would therefore throw (swallowed by the helper) or emit
fallback tags during regeneration, and the swap would replace a complete
cached head with that degraded one.

Reproducing the full document pipeline on regeneration is a much larger
change, so those apps now keep the cached head, which is the behaviour
they had before the refresh existed. Everyone else — no _document, or one
that never overrides getInitialProps — is unaffected, and for them the
helper was a no-op anyway, so the hook is now just getSSRHeadHTML.

Locating the closing head with indexOf also read raw-text content as
markup. headChildToHTML escapes </script and </style in inline bodies but
not </head>, so a next/head script may legitimately contain that string.
The scan then ended mid-element, matched nothing, and silently skipped the
refresh — or, with a differently shaped shell, inserted the fresh head
while leaving later stale tags in place. Raw-text elements are blanked
length-preservingly before the boundary lookup so indices still map onto
the original shell.

* test(pages): cover ISR head regeneration end to end

* fix(pages): ignore head text while locating ISR boundary

* test(pages): exercise stale ISR head refresh

---------

Co-authored-by: James <james@eli.cx>
2026-07-29 18:48:11 +01:00
Nathan Nguyen 67e8b2bddc fix(document): HTML-escape NextScript.getInlineScriptSource output (#2727)
* fix(document): HTML-escape NextScript.getInlineScriptSource output

The shim returned raw JSON.stringify(context.__NEXT_DATA__). JSON.stringify
does not escape characters the HTML parser treats as significant, so a string
value containing "</script>" terminates the inline script it is embedded in
and the remainder is parsed as HTML.

Next.js returns htmlEscapeJsonString(data) from every path of this method
(pages/_document.tsx). A custom _document that calls the helper and inlines
the result — the pattern the method exists for — therefore loses escaping it
would keep on Next.js, turning page props or query-derived data into script
injection.

Use safeJsonStringify, the escaper this repo already applies to the
framework-generated __NEXT_DATA__ tag in pages-page-response.ts, so both
paths produce the same output. The default renderer was unaffected.

* test(document): cover complete inline JSON escape set

---------

Co-authored-by: James <james@eli.cx>
2026-07-29 18:41:50 +01:00
James Anderson 9649fb9ec1 fix(app-router): render pages before consuming layouts (#2751)
* fix(app-router): render pages before consuming layouts

* fix(app-router): preserve page loading semantics

* fix(app-router): preserve page initialization and loading

* fix(app-router): preserve loading for initialized pages

* test(app-router): render Suspense payload children

* fix(app-router): wait for initialized pages without loading

* fix(app-router): unify page loading detection
2026-07-29 18:41:15 +01:00
James Anderson 2c434ed893 test(typegen): retry temporary directory cleanup (#2752) 2026-07-29 15:26:09 +00:00
Jerry Zhao 9ae681fc28 fix(deps): pin vitest and coverage-istanbul to 4.1.10 (#2748)
* fix(deps): pin vitest and coverage-istanbul to 4.1.10

* fix(deps): align lockfile vitest specifier with its override
2026-07-29 16:13:48 +01:00