Commit Graph

18 Commits

Author SHA1 Message Date
Steve Faulkner c4e64c908b fix: make fnv1a64 output fixed-width (#1706) 2026-05-31 20:09:20 -05:00
Nathan Nguyen bdbd1f833d fix(app-router): preserve source identity for intercepted renders (#1589)
* fix(app-router): preserve source identity for intercepted renders

Intercepted App Router renders used the visible target pathname as the payload route identity. That let a modal navigation such as /feed to /photos/42 replace the retained source tree with target-scoped route and page ids, so refreshes, server actions, chained modals, and history traversal could lose source page state.

The violated invariant is that an intercepted render has separate display and matched-route identities. This commits a fenced render identity helper, keeps source route and page ids stable for retained source-route payloads, leaves the target pathname in interception proof, and includes source context in RSC cache variants.

Regression coverage asserts source payload keys, cache variants, modal refresh/action/history flows, and source client/search state preservation.

* fix(app-router): normalize intercepted rsc cache source

* fix(app-router): harden intercepted cache regeneration

* refactor(app-router): share intercepted path normalization

* fixup! fix(app-router): preserve source identity for intercepted renders

fixup! fix(app-router): preserve source identity for intercepted renders
2026-05-28 12:42:01 +01:00
James Anderson 03e658f221 fix(security): bound x-vinext-mounted-slots cache key cardinality (F-PROD-1) (#1619)
* fix(security): bound x-vinext-mounted-slots cache-key cardinality (F-PROD-1)

Vinext invents two internal headers (X-Vinext-Mounted-Slots,
X-Vinext-Interception-Context) that are legitimately set by the browser's
RSC client and flow into the ISR RSC cache key (appIsrRscKey via
fnv1a64) and optimistic-routing template/source keys.

Because these headers were accepted verbatim from external requests, an
attacker who controls the browser could fan out unbounded distinct values:

  for i in $(seq 1 100000); do
    curl -s "https://target.example/blog.rsc?_rsc=0" \\
      -H "X-Vinext-Mounted-Slots: attacker-$i" \\
      -H "RSC: 1" > /dev/null &
  done

Each unique header value mapped to a unique cache-key hash, producing one
KV write per request (Cloudflare KV bills per write), cache fragmentation,
and content-split cache pollution.

We cannot strip the headers wholesale because the legitimate vinext
navigation client does set them on the user's browser-to-server RSC
fetch. Instead, this commit validates the input shape and bounds
cardinality at the boundary, before the value reaches the cache key.

Approach: input-shape validation + bounds. Specifically:

x-vinext-mounted-slots (normalizeMountedSlotsHeader):
  - Raw header value capped at 4096 bytes (oversized → null).
  - Each token must match the legitimate slot:<name>:<treePath> wire
    format produced by encodeSlotId — name is non-empty and contains no
    ':', treePath starts with '/'. Anything else (e.g. arbitrary
    'attacker-N' bytes) is dropped silently.
  - Each token capped at 256 bytes.
  - Token count capped at 16 after dedup + sort.

x-vinext-interception-context (new normalizeInterceptionContextHeader):
  - Null bytes stripped (header-injection defense, preserved from prior
    behavior).
  - Value capped at 1024 bytes.
  - Must start with '/' (legitimate values are same-origin pathnames
    like '/feed', '/photos/42' — see resolveManifestNavigationInterceptionContext).
  - Raw whitespace is rejected.

Anything that fails validation is treated as if the header were absent,
so the request is still served (just without interception or with the
no-slots cache key).

Why not strip the headers entirely (F-PROD-1 audit option (a))? The
RSC fetch IS the user's browser-to-server request — there is no
internal hop where these headers are added on the server side. Stripping
would break legitimate parallel-route and intercepting-route navigation.

Why not hash the input before using it (audit option (b))? The value
already passes through fnv1a64 in appIsrRscKey. Hashing does not fix
unbounded cardinality — it just relocates it.

Follow-up: the audit hint also suggests validating slot IDs against the
route's declared parallel-route slots and rejecting unknown slot IDs
(matching Next.js's Next-Router-State-Tree approach). That requires
route-manifest access at the cache-key construction point and is a
larger structural change tracked separately.

Tests added in tests/isr-cache.test.ts and
tests/app-rsc-request-normalization.test.ts cover:
  - 1000 distinct attacker-supplied header values collapse to a single
    cache key.
  - Tokens that do not match slot:<name>:<treePath> are rejected.
  - Token count cap (16) is enforced.
  - Raw header length cap (4096 bytes) is enforced.
  - Interception-context values that do not start with '/' are rejected.
  - Legitimate browser-emitted values continue to round-trip.

Identified in internal security audit — see SECURITY-AUDIT-2026-05.md
finding F-PROD-1.

* fix(security): keep slot-id shape validation inside app-elements codec boundary

The architectural invariant test (`app-elements.test.ts > keeps raw
AppElements wire-key construction inside the codec boundary`) failed
because the new mounted-slots header validator in
`app-mounted-slots-header.ts` reimplemented the slot-id wire-format
check (`token.startsWith("slot:")` + body parsing) outside the
codec module.

Delegate shape validation to `AppElementsWire.isSlotId` (already
exported from `app-elements-wire.ts`) so the wire-format definition
lives in exactly one place. The header normalizer retains its
security-relevant bounds (token byte cap, total header byte cap, token
count cap, sort + dedup) — only the shape check moves.

Security property is preserved: slot-id cardinality is still bounded
by MAX_SLOT_TOKENS, MAX_TOKEN_LENGTH, and MAX_RAW_HEADER_LENGTH, and
the same legitimate-shape filter (`slot:<name>:<treePath>` with an
absolute tree path) still gates which tokens contribute to the
normalized header value used in the ISR RSC cache key.
2026-05-27 15:58:54 +01:00
Nathan Nguyen 82b6388ece fix(app-router): render optimistic loading shells for dynamic navigation (#1421)
* fix(app-router): render optimistic loading shells for dynamic navigation

Dynamic App Router links currently skip automatic prefetch for route patterns with params, so a navigation to a sibling dynamic URL waits for fresh RSC data before committing anything. That diverges from Next.js, where automatic prefetch learns the loading shell for dynamic routes and the router can commit the shell immediately while the full payload is fetched.

The client prefetch path now requests a loading-shell render for dynamic app routes, keeps that entry out of the navigation cache, learns reusable optimistic route templates from settled shell prefetches, and commits a detached optimistic payload for matching dynamic navigations. The server emits shell-only RSC payloads under a separate cache variant so normal navigation data is not polluted.

Tests cover matching semantics, shell-only page wiring, cache key isolation, prefetch cache consumption, Link prefetch behavior, and the upstream Next.js optimistic-routing deploy-suite case.

* chore(app-router): address optimistic routing review
2026-05-22 10:07:26 +01:00
Nathan Nguyen ac75d43e7e fix(app-router): preserve UI during action refresh (#1147)
* fix(app-router): preserve UI during action refresh

Server action responses currently always render a page payload, and router.refresh can put BrowserRoot into a pending state that mounts loading.tsx while the same route refresh resolves. That diverges from Next.js, where non-revalidating actions can skip page rendering and refresh/revalidation should preserve the current page UI.

The server action path now omits the root payload when no data was invalidated, suppresses loading boundaries for action rerenders, and carries the same suppress-loading variant through RSC request, cache-busting, and ISR cache keys. The browser action callback now resolves no-root action payloads directly and avoids pending router state for refresh.

Regression coverage includes the no-rerender action payload, loading-boundary suppression, cache key variants, cache-busting compatibility, request normalization, and an app-router Playwright case that catches route loading mounts after action plus router.refresh.

* fix(app-router): carry semantic RSC render modes

Replace the cross-layer suppress-loading boolean with AppRscRenderMode so browser requests, request normalization, rendering, cache-busting, Vary, and ISR keys all carry the same semantic payload variant.

Decode server action responses before clearing client navigation caches so no-root return-only action results keep visited response and prefetch caches intact while redirects, root payloads, and legacy bare payloads still invalidate.

Add coverage for refresh() inside server actions and for normal versus preserve-current-UI ISR RSC cache keys.

* fix(app-router): default helper render mode to navigation

* docs: add review-requested comments for structural discriminator and ISR key format

* fix: add explicit null case to parseAppRscRenderMode switch for exhaustiveness check

---------

Co-authored-by: James <james@eli.cx>
2026-05-12 15:09:45 +01:00
Nathan Nguyen f45fce00d5 fix(isr): honor route expire ceilings (#961)
* fix(isr): honor route expire ceilings

Track expireAt alongside revalidateAt in the memory and KV cache handlers so ISR entries past their expire ceiling become blocking misses instead of stale responses.

Plumb expireTime and request cacheLife expire values through App Router, Pages Router, prerender seeding, and cache writes while keeping generated entries as thin app-shape wiring over normal server modules.

Match Next.js cache-control semantics for finite stale-while-revalidate windows when an expire value is known.

* fix(isr): address cache life review follow-ups

* chore: address latest review follow-ups

* ci: pin vite-plus setup version

* Revert "ci: pin vite-plus setup version"

This reverts commit ee1d1d4e8db7b7406734414c53fa1b79f955a4d1.

* fix: avoid blocking ISR page streams on cache metadata

* fix: preserve headers for speculative cacheLife probes

* fix: preserve prerender cacheLife metadata

* fix: preserve legacy ISR cache metadata behavior

* fix: preserve prerender seed revalidate context

* fix: harden app page cache policy metadata

* fix: resolve app router prerender conflict
2026-05-02 19:54:25 +01:00
Nathan Nguyen 7b1d810d89 refactor(app-router): extract RSC runtime primitives (#964)
* refactor(app-router): extract RSC runtime primitives

The App Router RSC entry template currently owns low-level ISR keying, server-action body reads, and RSC error digest behavior. That makes generated code harder to inspect and leaves runtime contracts covered only through template snapshots.

Move those primitives into typed server modules and keep app-rsc-entry.ts focused on app-shape imports, route tables, and request-specific wiring. Add focused helper tests for slot normalization, streamed action body limits, production digest behavior, and generated-entry delegation.

* fix(app-router): address RSC primitive review feedback
2026-04-30 10:08:58 +01:00
Divanshu Chauhan (divkix) 0846931bcb fix: report stale ISR background revalidation errors via onRequestError (#947)
* fix: report stale ISR background revalidation errors via onRequestError (#775)

Next.js 16 now reports errors during background ISR revalidation of stale
pages via routeModule.onRequestError(). Previously these errors were silently
swallowed (only logged to console). This ensures instrumentation hooks
(Sentry, OpenTelemetry) observe revalidation failures.

- Add errorContext parameter to triggerBackgroundRegeneration() in isr-cache.ts
- Report errors via reportRequestError() with revalidateReason: "stale"
- Update App Router entry template to pass route context to regen callbacks
- Update Pages Router entry template to forward error context through wrappers
- Update dev-server.ts and pages-page-data.ts to pass route context
- Update snapshots and test assertions for generated code changes

* fix: remove dead ISRRegenErrorContext export flagged by knip
2026-04-29 18:23:31 +01:00
Nathan Nguyen 37e4e6951d fix(app-router): tag cached pages by route pattern (#923)
Cached App Router pages only carried implicit revalidatePath tags derived from the resolved URL. That left dynamic routes tagged with concrete values, so pattern-scoped invalidation like revalidatePath("/blog/[slug]", "layout") could miss entries rendered at /blog/hello.

The generated RSC entry now delegates implicit tag construction to a typed helper that combines exact pathname tags with route-segment-derived layout, page, and route handler tags. Focused tests cover dynamic route patterns, route groups, root aliases, and route handler leaves.

Refs cloudflare/vinext#921
2026-04-29 10:37:33 +01:00
Nathan Nguyen ea73e5b97d fix: implement prefix-based invalidation for revalidatePath layout type (#544)
* fix: implement prefix-based invalidation for revalidatePath with type layout

revalidatePath(path, "layout") now invalidates the layout path AND all
child pages beneath it, matching Next.js behavior. Previously the type
parameter was declared but completely ignored.

Adds collectTagsByPathPrefix to the CacheHandler interface (optional) and
implements it on MemoryCacheHandler. The prefix match is path-segment-aware
so /dashboard matches /dashboard/settings but not /dashboard-admin.

* fix: address review feedback for revalidatePath layout type

- Update next-shims.d.ts with collectTagsByPathPrefix on CacheHandler and MemoryCacheHandler
- Add console.warn when layout invalidation degrades on handlers without prefix support
- Normalize trailing slashes in revalidatePath (public API safety)

* refactor: replace collectTagsByPathPrefix with revalidateByPathPrefix

The caller (revalidatePath) should declare WHAT it wants — "invalidate
everything under this prefix" — not HOW to do it by collecting tags and
passing them back. revalidateByPathPrefix lets each backend handle
prefix invalidation optimally (e.g., KV can do bulk prefix operations
natively instead of O(n) iteration in the caller).

* fix: address PR review feedback — remove dead code, harden tag matching, add tests

- Remove unreachable `prefix === "_N_T_/"` branch from `_isPathChildOf`;
  `revalidateByPathPrefix` always passes plain normalized paths, never
  `_N_T_`-prefixed ones. Add explanatory comment that the `/` root case
  works because custom tags never start with `/`.
- Guard against non-path custom tags in `revalidateByPathPrefix`: after
  stripping `_N_T_` prefix, skip entries where rawPath doesn't start
  with `/` to avoid false matches on user-defined tags.
- Add JSDoc to `CacheHandler.revalidateByPathPrefix` clarifying that when
  present it fully replaces the `revalidateTag` call path — implementors
  are solely responsible for handling all stored path-based tags.
- Add trailing-slash normalization tests: `revalidatePath("/dashboard/", "layout")`
  and `revalidatePath("/about/", "page")` must behave identically to
  their non-trailing-slash equivalents (normalization already present in
  `revalidatePath`; tests confirm the invariant).

* refactor: replace O(n) prefix scanning with O(1) layout tag invalidation

Rearchitect revalidatePath(path, "layout") to match Next.js's
getDerivedTags / getImplicitTags approach:

- __pageCacheTags now generates layout hierarchy tags at render time
  (e.g., /dashboard/settings carries _N_T_/layout, _N_T_/dashboard/layout,
  _N_T_/dashboard/settings/layout)
- revalidatePath("/dashboard", "layout") invalidates a single tag
  (_N_T_/dashboard/layout) instead of scanning all cache entries
- Remove revalidateByPathPrefix from CacheHandler interface — no new
  interface methods needed, works with existing revalidateTag
- Remove _isPathChildOf helper and MemoryCacheHandler.revalidateByPathPrefix

This makes layout invalidation O(1) instead of O(n), works with all
cache backends (KV, Redis, etc.) without special support, and matches
how Next.js achieves this internally.

* fix: implement distinct page-type invalidation for revalidatePath

Address PR review feedback:
- revalidatePath(path, "page") now invalidates _N_T_<path>/page (leaf
  page tag), distinct from omitting type which invalidates the broader
  path tags. Matches Next.js getDerivedTags behavior.
- __pageCacheTags now generates _N_T_<path>/page leaf tag at render time.
- Simplify trailing-slash ternary to explicit root-path check.
- Add test proving "page" type and no-type take distinct code paths.
- Add "keep in sync" comment on test helper.

* fix: double-slash in root page cache tag breaks revalidatePath("/", "page")

__pageCacheTags("/") generated "_N_T_//page" (double-slash) while
revalidatePath("/", "page") targeted "_N_T_/page" (single-slash),
causing silent invalidation failure for root page entries.

Add root-path guard in __pageCacheTags and the test helper
deriveImplicitTags. Add test for revalidatePath("/", "page").
Rename misleading test name and add Pages Router doc comment.

* refactor: simplify revalidatePath and reduce comment noise

- Collapse layout/page branches into one: tagBase + "/" + type
- Extract tagBase computation to eliminate repeated root-path ternary
- Trim redundant inline comments — JSDoc already explains the approach
- Trim verbose test helper docstring
- Update snapshots

* refactor: simplify tag construction — reuse stem, eliminate tagBase ternary

- revalidatePath: 3 lines of logic, single tag expression, no branching
- __pageCacheTags: reuse `built` variable for page tag (same stem invariant)
- deriveImplicitTags test helper: keep in sync with source
- Update snapshots
2026-03-17 10:52:10 +00:00
Stephen Zhou 25f6e0fc87 chore: enable typeAware and typeCheck, use vp check (#551)
* chore: enable typeAware and typeCheck, use vp check

* Build

* Try cache false

* Revert "Try cache false"

This reverts commit 5f76ed02f1.

* Update

* workaround for vp check

* Try no workaround

* Fix check

* Update to 0.1.12

* denyWarnings
2026-03-16 11:00:47 -05:00
Stephen Zhou c17d6941be chore: migrate to vite plus (#535)
* chore: migrate to vite plus

* Disable typeAware and typeCheck

* Update CI

* Fix CI

* Fix test

* Clean

* Run test with vp

* Try revert

* react: false In test

* Fix test

* Revert "Try revert"

This reverts commit 009da10473.

* Update

* Update

* Try revert ci changes

* revert

* Run vp migrate

* Disable typeAware and typeCheck for now

* Better resolve for test

* Use vp dev instead of vite

* Update expect

* Fix NormalizeManifestModuleId

* Try increase timeout

* Update to use vp

* Try new check

* Bring back npx vp

* Migrate CI

* Make next-intl resolvable

* Update

* Update

* Update
2026-03-15 10:50:13 +00:00
Divanshu Chauhan (divkix) e6439ffce8 refactor: phase 1/2 unify per-request ALS into a shared request context (#450)
* refactor: consolidate 5 nested ALS scopes into unified request context

Every App Router request previously ran through 5-6 nested
AsyncLocalStorage.run() calls (headers, navigation, cache-state,
private-cache, fetch-cache, plus optional execution-context). Each
ALS scope push/pop has measurable cost on Workers isolates.

Introduce a single UnifiedRequestContext that holds all per-request
state in one flat object, backed by one ALS instance. Each shim module
checks isInsideUnifiedScope() first and reads its sub-fields from the
unified store, falling back to its own standalone ALS when outside
(SSR environment, Pages Router, tests).

- Create unified-request-context.ts with createRequestContext(),
  runWithRequestContext(), getRequestContext(), isInsideUnifiedScope()
- Rewire _getState() in headers, navigation-state, cache, cache-runtime,
  fetch-cache, and request-context to dual-path (unified first, own ALS
  fallback)
- Make each runWith*() a no-op inside unified scope
- Replace 5-deep nesting in app-rsc-entry.ts (main handler + ISR regen)
  with single _runWithUnifiedCtx() call
- Export ensureFetchPatch() from fetch-cache for standalone patch install
- Add 17 unit tests for unified context (isolation, nesting, concurrency)

* test: update entry-templates snapshots for unified ALS context

Snapshots were stale after the unified request context refactor changed
the generated entry code (new imports, removed 5-deep nesting).

* test: update app router waitUntil assertion

* refactor: unify request ALS across router flows

* fix: address unified request context review feedback

* fix: address unified ALS review feedback

* fix: address remaining unified ALS review feedback

* fix: wrap Pages Router ISR regeneration in unified context

Ensures patched fetch and ALS-based context are available during
background regeneration, matching App Router behavior.

- Creates fresh unified context with execution context for regen
- Calls ensureFetchPatch() to enable cache tagging
- Adds test verifying _runWithUnifiedCtx and ensureFetchPatch presence
- Updates snapshot

Part of Phase 1.5: minimal Pages Router fix for ISR parity.

* Fix dev ISR unified context parity

* fix: address unified ALS review followups

* Fix pages router unified execution context seeding

* Fix Pages Router ISR rerender state isolation

* fix: address unified ALS review feedback
2026-03-13 07:19:13 +00:00
James Anderson a08a2b5d92 refactor: make ExecutionContext ALS the single source of truth (#431)
* refactor: make ExecutionContext ALS the single source of truth

Remove ctx as an explicit parameter from internal functions so that all
downstream code consistently retrieves the Workers ExecutionContext via
getRequestExecutionContext() from the ALS. Changes:

- isr-cache.ts: drop ctx? param from triggerBackgroundRegeneration();
  remove duplicate ExecutionContext interface (use ALS directly)
- app-rsc-entry.ts: drop ctx 4th param from generated _handleRequest;
  replace ctx.waitUntil() calls with _getRequestExecutionContext()?.waitUntil()
- kv-cache-handler.ts: remove duplicate ExecutionContext interface;
  import ExecutionContextLike from request-context instead
- isr-cache.test.ts: update ctx.waitUntil test to supply ctx via
  runWithExecutionContext() (ALS) instead of as a direct argument
- Update entry-templates.test.ts snapshots

* fix(test): update _handleRequest signature assertion after ctx param removal
2026-03-11 07:40:52 +00:00
Divanshu Chauhan (divkix) b12eeebfce fix(isr): namespace cache keys by buildId (#419)
* fix(isr): namespace cache keys by buildId

Include buildId in ISR cache key generation to prevent stale cache
reuse across deploys. Key format changes from `<router>:<pathname>` to
`<router>:<buildId>:<pathname>` when buildId is available. Without
buildId the format is unchanged for backward compatibility.

Updated all callers: dev-server.ts (dev), pages-server-entry.ts (prod).

Closes #415

* test: update pages-server-entry snapshot for buildId cache key

The entry template changed to include buildId in ISR cache keys.
Update the snapshot to match the new generated code.

* fix(isr): inline isrCacheKey with fnv1a64 hashing in pages server entry

The generated pages server entry was constructing ISR cache keys inline
without the >200-char fnv1a64 hashing that exists in isr-cache.ts. With
buildId making keys longer, this risks exceeding KV key-length limits.

Inline fnv1a64 and isrCacheKey helpers into the generated entry (same
pattern as isrGet/isrSet/triggerBackgroundRegeneration) and replace both
inline cache key constructions with calls to isrCacheKey. Also rename
isrCacheKeyPrefix to _cachePrefix for consistent variable naming.

* address review: clarify __VINEXT_BUILD_ID is undefined in dev by design

* address review: align app-rsc-entry ISR key format with pages router, add comments

* address review: clarify keep-in-sync comment scope (prefix/hashing, not full key format)

---------

Co-authored-by: James <james@eli.cx>
2026-03-10 22:18:49 +00:00
James Anderson 7ba1ab5ccf fix: register background KV ops and ISR regen with ctx.waitUntil on Workers (#391)
* fix: register background KV ops and ISR regen with ctx.waitUntil on Workers

On Cloudflare Workers, any async work started after a Response is returned
is killed immediately. This caused three silent failures:

1. ISR background regeneration (stale-while-revalidate) was never
   registered with ctx.waitUntil, so every revalidation on Workers was
   silently dropped.

2. KV deletes for corrupt/invalid/tag-invalidated cache entries were
   awaited on the critical path, blocking the response for pure cleanup work.

3. KV writes in KVCacheHandler.set() were awaited on the critical path;
   the client already has the rendered content so the write can happen
   after the response is sent.

Fixes:
- Thread ExecutionContext through triggerBackgroundRegeneration and call
  ctx.waitUntil(regenPromise) when ctx is present (isr-cache.ts and the
  inlined copy in pages-server-entry.ts).
- Add _deleteInBackground / _putInBackground helpers to KVCacheHandler
  that use ctx.waitUntil when ctx is present and fall back to
  fire-and-forget on Node.js.
- Convert all three delete callsites (corrupt JSON, invalid shape,
  tag-invalidation) and the set() KV write to use the new helpers.
- All changes are backward-compatible: ctx is optional everywhere;
  Node.js dev/prod behaviour is unchanged.

* regen snaps
2026-03-09 23:25:57 +00:00
James Anderson 764a496ce7 add oxfmt formatter (#380)
* add oxfmt formatter: config, scripts, CI, editor setup, docs

* rebuild lockfile

* fix: add Format to required checks list, remove dead ignore pattern

* run fmt

* add format to agents.md again
2026-03-09 14:56:14 +00:00
Steve Faulkner 12fea722b6 Initial public release of vinext 2026-02-24 09:29:39 -06:00