175 Commits

Author SHA1 Message Date
Jared Stowell 16a14c713b fix: Pages prod config headers and redirects after middleware rewrites (#448)
* Fix headers matching original path

* Review redirect ordering parity

* Fix config redirects order

* Fix dev middleware parity

* Fix dev middleware request parity

* address review: worker header/fallback parity, early-return in attachRouteHandlerMiddlewareContext, cache node request headers, add dev redirect test

* address second-round bonk comments: sanitizeDestination in worker redirect, nodeRequestHeaders comment, app-rsc-entry guard comment, E2E redirect test, updated snapshots

---------

Co-authored-by: James <james@eli.cx>
2026-03-11 11:40:07 +00:00
Jared Stowell 67d7c2f90c Add Node execution context handling (#460) 2026-03-11 11:22:49 +00:00
Jared Stowell 3b982066d0 fix: align Pages API body parsing and res.send(Buffer) (#428)
* Fix Buffer handling in Pages API res

* fix: skip reporting handled Pages API parse errors

* address review: blank line nit, content-length parity for Buffer, fix duplicate Content-Length in sendCompressed

* fix: set content-length for Buffer in production res.send() to match dev parity

---------

Co-authored-by: James <james@eli.cx>
2026-03-11 11:16:51 +00:00
Nathan Nguyen 47500b180f fix: return route pattern from router.pathname, not resolved path (#463)
* fix: stabilize instrumentation state and hydration E2E tests

Move Cloudflare instrumentation example state from module-level
variables to globalThis so startup and request paths share one owner
across different module instances.

Add waitForHydration() helper to Pages Router E2E tests to eliminate
flaky button-click assertions that raced against React hydration.

* fix: honor the `as` parameter in router.push/replace

The `as` parameter in push() and replace() was silently ignored
(prefixed as _as) in both the useRouter() hook and the Router
singleton. This broke masked URLs and legacy dynamic-route flows.

Extract resolveNavigationTarget() helper that uses `as` when provided,
falling back to resolveUrl(url) otherwise. Apply to all four call
sites: useRouter().push, useRouter().replace, Router.push, Router.replace.

* fix: address PR review feedback

- Add doc comment to resolveNavigationTarget explaining the url/as
  simplification vs Next.js's two-value approach
- Extract waitForHydration to shared tests/e2e/helpers.ts
- Add pathname assertion to verify router.pathname stays as route
  pattern (/posts/[id]) after as-based navigation

* fix: remove pathname assertion that tests unimplemented behavior

router.pathname currently returns the resolved path after client-side
navigation, not the route pattern. The correct behavior (returning
/posts/[id] instead of /posts/42) requires the two-value url/as
approach tracked in #462.

* fix: return route pattern from router.pathname, not resolved path

In Next.js, router.pathname returns the route pattern (e.g.,
"/posts/[id]"), not the resolved path ("/posts/42"). vinext was
deriving pathname from window.location.pathname which gives the
resolved path.

Fix: use __NEXT_DATA__.page (which holds the route pattern) as
pathname, falling back to location.pathname when __NEXT_DATA__ isn't
available. asPath continues to use the resolved browser path.

Closes #462

* fix: set SSR pathname to route pattern to prevent hydration mismatch

All three SSR entry points (dev-server, pages-server-entry,
static-export) were setting pathname to the resolved URL path (e.g.,
"/posts/42") while the client now derives it from __NEXT_DATA__.page
(the route pattern "/posts/[id]"). This caused hydration mismatches
when components render router.pathname.

Fix: use patternToNextFormat(route.pattern) for the SSR context
pathname, matching what __NEXT_DATA__.page already provides.

---------

Co-authored-by: James <james@eli.cx>
2026-03-11 11:00:36 +00:00
Divanshu Chauhan (divkix) 769a98ac85 perf(kv): local tag cache to reduce KV round-trips on cache hit (#433)
* perf(kv): add local in-memory tag cache to reduce KV round-trips

When KVCacheHandler.get() validates tags, it was issuing one kv.get()
per tag in parallel. For entries with N tags, that's N KV round-trips
per cache hit. Same pattern in revalidateTag() — N parallel PUTs.

Add a local Map<string, { timestamp, fetchedAt }> with a 5-second TTL
that caches tag invalidation timestamps. Within the TTL window, tag
checks are served from memory with zero I/O. After TTL expiry, the
next request re-fetches from KV.

Key behaviors:
- revalidateTag() updates the local cache immediately so invalidations
  are reflected without waiting for TTL expiry
- resetRequestCache() clears the local cache for per-request isolation
- NaN tag timestamps are cached and correctly treated as invalidation
- Only uncached/expired tags trigger KV reads (partial cache hits work)

* fix(test): guard fake timers with try/finally to prevent leaks

Wrap vi.useFakeTimers() in try/finally so vi.useRealTimers() runs even
if an assertion fails mid-test, preventing timer leaks into subsequent
tests.

* fix(kv): address bonk review comments on local tag cache

- Fix early return bug: split KV tag fetch loop into populate-cache pass
  then invalidation-check pass, so already-fetched tag results are always
  cached even when an earlier tag triggers an early return
- Fix unbounded _tagCache growth: delete stale entries when encountered
  during the TTL check (cheap eviction instead of accumulation forever)
- Make tag cache TTL configurable via tagCacheTtlMs constructor option
  (default 5000ms); eliminates fake timers in TTL test
- Clarify resetRequestCache() docstring: it is not called per-request by
  vinext, it is an opt-in escape hatch for callers that need explicit isolation
- Add resetRequestCache() test: verifies tags are re-fetched from KV after
  calling resetRequestCache()

* refactor(kv): reuse cached timestamps in invalidation check loop

The second pass over uncachedTags was re-calling Number(tagTime) and
re-reading tagResults[i]. Now it reads directly from _tagCache (which was
just populated in the first pass), eliminating the duplicate number
conversion and making the intent clearer.

---------

Co-authored-by: James <james@eli.cx>
2026-03-11 10:59:25 +00:00
Jared Stowell f035e1a05e fix: object-form query array serialization in next/link and next/router (#457)
* Fix query param handling

* Fix scalar query serialization
2026-03-11 10:46:57 +00:00
Nathan Nguyen 45b72eade5 fix: honor the as parameter in router.push/replace (#453)
* fix: stabilize instrumentation state and hydration E2E tests

Move Cloudflare instrumentation example state from module-level
variables to globalThis so startup and request paths share one owner
across different module instances.

Add waitForHydration() helper to Pages Router E2E tests to eliminate
flaky button-click assertions that raced against React hydration.

* fix: honor the `as` parameter in router.push/replace

The `as` parameter in push() and replace() was silently ignored
(prefixed as _as) in both the useRouter() hook and the Router
singleton. This broke masked URLs and legacy dynamic-route flows.

Extract resolveNavigationTarget() helper that uses `as` when provided,
falling back to resolveUrl(url) otherwise. Apply to all four call
sites: useRouter().push, useRouter().replace, Router.push, Router.replace.

* fix: address PR review feedback

- Add doc comment to resolveNavigationTarget explaining the url/as
  simplification vs Next.js's two-value approach
- Extract waitForHydration to shared tests/e2e/helpers.ts
- Add pathname assertion to verify router.pathname stays as route
  pattern (/posts/[id]) after as-based navigation

* fix: remove pathname assertion that tests unimplemented behavior

router.pathname currently returns the resolved path after client-side
navigation, not the route pattern. The correct behavior (returning
/posts/[id] instead of /posts/42) requires the two-value url/as
approach tracked in #462.
2026-03-11 09:00:27 +00:00
Nathan Nguyen 15cd5d2881 fix: skip locale prefix for absolute and protocol-relative Link hrefs (#455)
* fix: skip locale prefix for absolute and protocol-relative Link hrefs

applyLocaleToHref() blindly prepended /${locale} to any href string,
producing malformed URLs like /fr/https://example.com/about or
/fr///example.com/about for absolute and protocol-relative hrefs.

Guard against http://, https://, and // URLs before applying the
locale prefix — these are external or same-origin absolute URLs that
should not be rewritten.

* fix: skip locale prefix for absolute and protocol-relative URLs in applyNavigationLocale

applyNavigationLocale() in router.ts (used by Router.push() / Router.replace())
had the same bug as applyLocaleToHref() in link.tsx — it would prepend /{locale}
to absolute and protocol-relative URLs, producing malformed hrefs like
/fr/https://example.com/about or /fr///cdn.example.com/img.png.

Add the same guard that was applied to applyLocaleToHref(), and add
corresponding tests covering https://, http://, and // URL patterns.

---------

Co-authored-by: James <james@eli.cx>
2026-03-11 08:38:48 +00:00
Jared Stowell 1551e4406a fix: App Router ISR invalidation parity for fetch and path tags (#445)
* Fix App Router ISR tag parity

* Fix KV tag persistence

* Fix KV cache tag handling
2026-03-11 08:17:50 +00:00
Divanshu Chauhan (divkix) 29649068f4 fix(fetch-cache): register stale-while-revalidate refetch with waitUntil() (#432)
* fix(fetch-cache): register stale-while-revalidate refetch with waitUntil()

The fetch cache's background refetch on stale entries was fire-and-forget.
On Cloudflare Workers, the isolate terminates after the response is sent,
killing in-flight refetches so stale entries never get refreshed.

Register the refetch promise with ExecutionContext.waitUntil() via the
existing getRequestExecutionContext() ALS accessor, matching the pattern
already used by ISR in isr-cache.ts.

* ci: re-trigger CI
2026-03-11 07:47:39 +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
Jared Stowell c4064ebb23 fix: next/form submitter overrides and query-string GET URLs (#426)
* Add submitter handling to form shim

* Update form shim URL validation

* Fix form submit attribute casing
2026-03-11 00:31:52 +00:00
James Anderson 60ca7192c2 fix(isr): use getRequestExecutionContext() from ALS in background regeneration (#429)
Instead of relying on the caller to pass ctx to triggerBackgroundRegeneration,
read it from AsyncLocalStorage via getRequestExecutionContext(). This ensures
waitUntil() is always registered on the live Workers ExecutionContext, fixing
stale responses that were not triggering revalidation on Cloudflare Workers.
2026-03-10 23:58:24 +00:00
Jared Stowell 0775ce55bb fix: return ReadonlyURLSearchParams from useSearchParams (#424)
* Update navigation search params

* Fix server readonly cache scope
2026-03-10 23:56:11 +00:00
Jared Stowell 6ccdaac78e fix: sitemap XML namespaces, video entries, and alternate-language links (#427) 2026-03-10 23:54:56 +00:00
Jared Stowell 88ed5550d1 fix: honor beforePopState when useRouter is mounted (#423) 2026-03-10 23:11:53 +00:00
James Anderson aa878d55ac fix(isr): KVCacheHandler.set() now awaits KV put to prevent perpetual STALE (#425)
* fix(isr): KVCacheHandler.set() now awaits KV put to prevent perpetual STALE

Background ISR regen was calling ctx.waitUntil(renderFn()) where renderFn()
resolved before the KV put network operation completed. set() returned
Promise.resolve() immediately, so await __isrSet() in the regen resolved early,
the waitUntil expired, and the Workers runtime killed the in-flight KV write —
leaving the cache entry perpetually STALE on every request after the TTL.

Fix: replace the fire-and-forget _putInBackground() with _put() which returns
the actual kv.put() promise. set() now returns that promise, so await __isrSet()
only resolves after the write is fully persisted. ctx.waitUntil() is also still
registered as belt-and-suspenders.

Adds three regression tests covering the STALE → regen → HIT lifecycle with a
delayed KV put mock to prove the set() promise is not an immediately-resolved stub.

* fix: remove unused variable flagged by lint
2026-03-10 23:11:24 +00:00
Jared Stowell a6c5601cc2 fix: preserve repeated route and query params in rewrites and redirects (#422)
* Update redirect matcher replacements

* Fix rewrite param substitution

* fix nits
2026-03-10 22:28:36 +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
Jared Stowell ec1b9da131 fix: preserve Pages Router query arrays and hash in asPath (#421)
* Handle array query params

* Fix router query merge order
2026-03-10 22:17:36 +00:00
Jared Stowell 11b27cd0a8 fix: reject conflicting dynamic route siblings during route discovery (#420)
* Validate route patterns in routers

* Validate intercepting routes
2026-03-10 21:48:36 +00:00
James Anderson 319f9296ff feat: ISR caching for App Router (production-only, stale-while-revalidate) (#405)
* feat: add ISR caching for App Router (production-only, stale-while-revalidate)

- Inline ISR helpers (__isrGet, __isrSet, __triggerBackgroundRegeneration,
  __isrCacheKey, __pendingRegenerations) in the generated RSC entry
- Cache READ fires before buildPageElement for prod pages with revalidate > 0,
  returning cached HTML/headers without loading component modules on HIT/STALE
- Cache WRITE (+ X-Vinext-Cache: MISS header) is guarded by
  process.env.NODE_ENV === 'production' so dev never reads/writes the cache
- Background regenration is deduplicated via __pendingRegenerations Map and
  registered with ctx.waitUntil for Cloudflare Workers
- Thread ExecutionContext (ctx) from app-router-entry.ts through to _handleRequest
- Dev mode still emits correct Cache-Control headers based on export const revalidate
- Add integration tests: Cache-Control in dev, no X-Vinext-Cache in dev, RSC
  requests bypass ISR, pages without revalidate emit no ISR headers
- Add 10 code-generation tests verifying the prod guard, helpers, and structure

* fix: remove unused isrCachePath variable and fix formatting

* fix: cache RSC wire format (rscData) in ISR entries; serve RSC requests from cache

Previously the ISR cache only stored HTML and excluded all RSC requests
from cache reads/writes. This caused two bugs:

1. RSC requests (client-side navigation, link prefetch) always triggered a
   full component tree render even when a fresh cache entry existed, wasting
   CPU on every soft-nav and prefetch.

2. rscData was never captured — the cache entry had rscData: undefined —
   so there was no way to serve RSC requests from cache even if we wanted to.

Fixes:
- Tee the RSC stream before SSR on ISR-eligible HTML requests (prod + revalidate > 0)
  to capture the RSC wire format as an ArrayBuffer stored in rscData
- Background regen also tees its RSC stream to keep rscData fresh
- Remove the !isRscRequest guard from the ISR READ block: both HTML requests
  and RSC requests now hit the cache. HTML requests get cached html; RSC
  requests get cached rscData (falling through to render if rscData is absent)
- HIT and STALE paths branch on isRscRequest: RSC responses get
  Content-Type: text/x-component and the rscData bytes; HTML responses get
  Content-Type: text/html and the html string

Tests updated:
- Replace 'RSC requests return RSC stream without cache headers' test with
  two tests that assert Cache-Control IS emitted for RSC responses on ISR
  pages (dev mode), and that Next-Router-Prefetch requests also get it
- Replace .tee() count test with assertion that >=2 tees exist (RSC + HTML)
- Add rscData storage assertions for WRITE and background regen paths
- Add assertion that ISR READ block has no !isRscRequest guard

* fix(isr): fix RSC cache miss and early-exit performance for App Router ISR

Two bugs fixed:

1. RSC requests (client-side nav / prefetch) were never cached because the RSC
   stream tee that captures rscData was placed AFTER the 'if (isRscRequest)'
   early-return, so the capture branch was never consumed. Move the tee to
   immediately after renderToReadableStream — before the isRscRequest branch —
   and register a waitUntil in the RSC path to write rscData to the cache.

2. The ISR cache read block fired after the generateStaticParams call, meaning
   a cache hit still did the expensive static-params validation. Move the ISR
   read to before generateStaticParams so a HIT skips all that work.

Tests: add two new generateRscEntry ISR assertions:
  - ISR read index < generateStaticParams index in generated code
  - __rscForResponse tee assignment before 'return new Response(__rscForResponse'

* feat(isr): cache RSC prefetch responses immediately as partial entries

RSC prefetch requests (Next-Router-Prefetch and client-side nav) now write
a partial cache entry with rscData + html:'' on MISS. Subsequent RSC requests
serve from cache (HIT) without waiting for an HTML request to populate the
entry first.

The ISR read block treats html:'' as a partial hit: RSC requests get a HIT,
but HTML requests fall through to render and overwrite the entry with a
complete html+rscData entry. This prevents blank HTML responses on first HTML
request after a prefetch-first cold start.

Also adds console.log logging for all ISR cache events (HIT/MISS/STALE/write)
in production to aid debugging on deployed Workers.

* fix(isr): use ALS to thread ExecutionContext into KVCacheHandler without constructor arg

Add runWithExecutionContext/getRequestExecutionContext to next/cache shim
backed by AsyncLocalStorage. The RSC entry handler now wraps each request
in this ALS scope so KVCacheHandler._putInBackground and _deleteInBackground
can call ctx.waitUntil() without needing ctx passed at construction time.

- cache.ts: add ExecutionContextLike interface + ALS helpers
- app-rsc-entry.ts: import runWithExecutionContext, wrap _run in ALS scope
- kv-cache-handler.ts: check ALS ctx first, fall back to constructor ctx
- deploy.ts: simplify generated template to new KVCacheHandler(env.VINEXT_CACHE)
- tests/deploy.test.ts: update expectation to match simplified constructor

* fix(isr): store activeHandler on globalThis to fix cross-environment cache isolation

Cloudflare Workers with Vite RSC loads separate module instances per
environment (worker / RSC / SSR). Previously, activeHandler was a
module-local variable, so setCacheHandler(new KVCacheHandler(...)) in
the worker environment set the worker copy while getCacheHandler() in
the RSC environment still returned a fresh MemoryCacheHandler from
its own copy — causing every ISR read/write to go to in-process
memory that is discarded after each request.

Fix: store the active handler on globalThis via Symbol.for('vinext.cacheHandler'),
the same pattern used by _cacheAls, _ctxAls, and _unstableCacheAls. All
Vite environments share the same globalThis on Cloudflare Workers, so
the KVCacheHandler set in worker/index.ts is now visible to the RSC
entry's getCacheHandler() call, enabling real KV-backed ISR cache
reads and writes.

* fix(isr): allow force-static/error pages to be served from ISR cache, prevent partial RSC write from clobbering complete entry

- Remove !isForceStatic and !isDynamicError from the ISR cache read guard.
  Both modes are compatible with ISR: they control dynamic API behaviour during
  render, not whether results can be cached. The asymmetry between the read guard
  (which excluded them) and the write guard (which did not) caused writes to
  succeed but reads to be skipped entirely, so every request returned MISS.
  Only isForceDynamic should bypass the ISR cache.

- Add a read-before-write in the RSC partial-write path. When an RSC request
  writes a partial cache entry (html: "", rscData set) before the first HTML
  request has come in, a concurrent or later RSC request could race and overwrite
  a complete html+rscData entry that the HTML write had already landed. The fix
  reads the existing entry first and skips the partial write if a fresh, complete
  entry (non-empty html) is already present, so the HTML write always wins.

* fix(isr): split HTML and RSC into separate cache keys, eliminating write races

Previously both HTML and RSC data were stored under a single key with an
html:"" partial-entry sentinel to signal 'RSC written, HTML pending'. This
caused write races on eventually-consistent stores like KV: a concurrent RSC
request could overwrite a complete html+rscData entry with a partial one,
or the read-before-write guard added to prevent that would itself race.

This mirrors how Next.js handles it in FileSystemCache: HTML is stored in
<key>.html and RSC in <key>.rsc as independent files, so each request type
reads and writes only its own key with no coordination needed.

Changes:
- Replace __isrCacheKey() with __isrHtmlKey() / __isrRscKey() which append
  :html and :rsc suffixes respectively
- ISR read block selects the key based on isRscRequest before the get() call
- RSC write path uses __isrRscKey and stores only rscData (html: "")
- HTML write path uses __isrHtmlKey and stores only html (rscData: undefined)
- Background regen writes both keys independently via Promise.all
- Remove the read-before-write hack (no longer needed)
- Remove partial-entry sentinel comments (concept no longer exists)
- HTML write path no longer awaits __isrRscDataPromise

* fix(kv-cache): always use 30-day KV TTL regardless of revalidate frequency

Previously expirationTtl was 10x the revalidate period (clamped to 60s–30d).
For pages with short revalidate windows (e.g. revalidate=5), this meant entries
were evicted from KV after ~50 seconds of no traffic, causing the next request
to block on a fresh render instead of serving stale content.

KV eviction should never be the reason a stale entry disappears — staleness is
tracked by the revalidateAt timestamp in the stored JSON, not by KV TTL. Always
storing for 30 days means stale content is always available to serve while
background regen runs, and entries only disappear after 30 days of zero traffic
or explicit tag invalidation.

* fix(isr): prefix KV cache keys with per-deployment build ID, add ttlSeconds option to KVCacheHandler

- __isrCacheKey now embeds process.env.__VINEXT_BUILD_ID (statically
  replaced by Vite's define at build time) so each deployment gets its
  own effective cache namespace in KV — stale entries from previous
  deploys are never served as hits
- vinextBuildId (crypto.randomUUID() at plugin scope) is passed to
  generateRscEntry and injected as the __VINEXT_BUILD_ID define
- KVCacheHandler accepts an optional ttlSeconds constructor option
  (defaults to 30 days) so users can override KV entry lifetime

* regen snaps

* fmt

* refactor(ctx): migrate to canonical request-context shim for ExecutionContext ALS

- shims/cache.ts: remove duplicated ALS implementation; re-export
  runWithExecutionContext and getRequestExecutionContext from the
  canonical shims/request-context.ts (Symbol.for('vinext.requestContext.als'))
- app-rsc-entry.ts: import runWithExecutionContext from request-context
  via absolute path (consistent with pages-server-entry pattern) rather
  than the bare 'vinext/shims/request-context' specifier which is not
  in the alias map
- deploy.ts: restore missing ${isrSetup} interpolation that was dropped
  during rebase conflict resolution
- Regenerate entry-template snapshots

* fix(isr): address PR review — hash collision, dedup key, regen headers, RSC key from HTML path, Infinity revalidate, Response init, deploy KV hint

* fix(isr): use __hasRsc/__hasHtml guards in cache read block; document 30-day flat KV TTL in test

* fix(isr): gate debug logs behind NEXT_PRIVATE_DEBUG_CACHE, add FNV seed comment, document headers limitation
2026-03-10 21:39:33 +00:00
Jared Stowell cf0786ef34 fix: Reject non-terminal catch-all routes in pages and app routers (#401)
* Fix catch-all route overmatching

* Fix non-terminal slot routes

* Fix malformed slot route discovery

* Add validation for intercept catch-⁤

* Fix intercepting route catch-alls

* bonk

* Fix routing test route literals for typecheck

* Fix app route matcher test fixtures
2026-03-10 20:24:31 +00:00
Divanshu Chauhan (divkix) d38661f809 fix: dedup RSC client references to prevent module duplication in dev (#413)
* fix: dedup RSC client references to prevent module duplication in dev

When the RSC plugin creates client-in-server-package-proxy modules for
"use client" submodules within packages, it re-exports from absolute
file paths. These bypass Vite's pre-bundling in the client environment,
causing the browser to load two separate module instances — one from the
raw ESM path and one from .vite/deps/. This breaks React context
providers/consumers since createContext() runs twice.

Add vinext:client-reference-dedup plugin that intercepts absolute
node_modules path imports originating from proxy virtual modules in the
client environment and redirects them through bare specifier imports,
which Vite's import analysis routes through pre-bundling automatically.

Closes #409

* fix: make dedup test layout a server component to exercise RSC proxy path

Remove "use client" from layout.tsx so ThemeProvider import goes through
RSC → client-in-server-package-proxy (absolute path), while the page's
useTheme goes through client pre-bundling (bare specifier). This ensures
the integration test actually validates the dedup plugin works.

* docs: address PR review feedback for client-reference-dedup plugin

- Document lossy submodule-to-package mapping assumption in resolveId
- Extract load filter regex to named DEDUP_FILTER constant
- Note default export edge case in generated re-export code
2026-03-10 20:22:35 +00:00
Jared Stowell 3a5fabe7ec fix: next/headers readonly semantics and legacy sync compatibility (#402)
* Add headers/cookies legacy tests

* Fix headers sync error handling

* Fix headers sync guards

* bonk

* Update packages/vinext/src/shims/headers.ts

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

* bonk

* Clarify dynamic error comment

---------

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
2026-03-10 20:21:55 +00:00
Jared Stowell cc02fcae72 fix: RequestCookie behavior and getAll(name) (#403)
* Verify cookie header parsing

* Fix empty cookie handling

* Fix empty cookie parsing fallback
2026-03-10 20:06:35 +00:00
Jared Stowell 86fe0e7c7e fix: middleware object matcher semantics and i18n parity (#400)
* Fix middleware matcher request usage

* Handle object middleware matchers

* Fix middleware object matcher

* make ci happy

* Fix default locale middleware match

* Fix default-locale matcher logic

* Restore regexp matcher support

* Fix middleware regexp matcher
2026-03-10 20:03:39 +00:00
Jared Stowell f72440f082 fix: Pages Router prod hydration for inlined page modules (#394)
* Clarify chunk manifest structures

* Fix SSR manifest basePath URLs

* Fix SSR manifest basePath handling

* Fix SSR manifest basePath handling

* make ci happy

* bonk
2026-03-10 20:02:21 +00:00
James Anderson 36f1775122 feat: AsyncLocalStorage for ExecutionContext (ctx) propagation (#410)
Adds request-context.ts shim with runWithExecutionContext() and
getRequestExecutionContext() so Cloudflare Workers ctx is accessible
anywhere in the call stack without manual threading.

- New shims/request-context.ts with ALS keyed on Symbol.for('vinext.requestContext.als')
- app-router-entry.ts wraps each request in runWithExecutionContext(ctx, ...)
- app-rsc-entry.ts generated code imports the helpers for downstream use
- deploy.ts generated worker entry forwards ctx in fetch(request, env, ctx)
- kv-cache-handler.ts uses getRequestExecutionContext() ?? this.ctx
- 8 new tests in tests/request-context.test.ts
2026-03-10 15:28:51 +00:00
Jökull Sólberg Auðunsson 70ff9d5788 fix: handle single object for openGraph.images and twitter.images (#411)
* fix: handle single object for openGraph.images and twitter.images in metadata shim

Next.js allows `openGraph.images` and `twitter.images` to be a single
object (e.g. `{ url, width, height }`) in addition to a string or an
array. The metadata shim only handled string and array cases, causing
"imgList is not iterable" when a single object was passed.

Normalize single objects to arrays before iterating. Also update the
Metadata type to reflect the full range of accepted shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fmt

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-03-10 15:19:54 +00:00
James Anderson 0faf62ac4c feat: support generateBuildId in next.config and inject build ID at runtime (#408)
* feat: support generateBuildId in next.config and inject build ID at runtime

Resolves generateBuildId() from next.config, falling back to a random UUID (ad-blocker-safe) when not configured or when the function returns null. The resolved ID is injected as process.env.__VINEXT_BUILD_ID via Vite define and embedded into __NEXT_DATA__ for Pages Router (dev, prod, and static export). NextRequest gains a buildId accessor for middleware deployment-skew detection.

* regen snaps

* refactor: extract safeUUID helper and drop buildId param from createSSRHandler

Address review nits: deduplicate the UUID+retry loop into a safeUUID() helper,
and read process.env.__VINEXT_BUILD_ID directly in dev-server instead of
threading it as an explicit parameter through createSSRHandler.

* regen snaps

* fix: stabilize entry-templates snapshot by setting generateBuildId in pages-basic fixture

The random UUID from resolveBuildId was embedded in the server entry snapshot,
making it fail on every run. Set generateBuildId to a fixed string in the
pages-basic test fixture so the snapshot is deterministic.

* fix: move buildId to NextURL to match Next.js API surface

Next.js exposes buildId on nextUrl (request.nextUrl.buildId), not directly
on NextRequest. Move the getter to NextURL and add a pass-through on
NextRequest for convenience. Update tests to assert both paths.
2026-03-10 13:59:00 +00:00
Nathan Nguyen c524f150cc feat: support generateSitemaps() for paginated sitemaps (#399)
* test: add products/sitemap.ts fixture with generateSitemaps

Adds a nested sitemap fixture that exports generateSitemaps() returning
two IDs (0, 1). The default export receives { id } and returns entries
scoped to that batch. Used by the integration tests for paginated
sitemap support.

* feat: support generateSitemaps() for paginated sitemaps

When a sitemap.ts module exports generateSitemaps(), the runtime
metadata route handler now:

1. Matches URLs at /{prefix}/sitemap/{id}.xml instead of the base
   /{prefix}/sitemap.xml (which is no longer served)
2. Calls generateSitemaps() to get the list of valid IDs
3. Validates the requested ID against the list (404 on mismatch)
4. Calls the default export with { id: rawId } and serializes the
   result with sitemapToXml()

This matches Next.js behavior: each paginated sitemap is a standalone
<urlset> document. Next.js does not generate a <sitemapindex>.

Closes #397

* test: add integration tests for generateSitemaps and update snapshots

- /products/sitemap/0.xml serves batch-0 entries
- /products/sitemap/1.xml serves batch-1 entries
- /products/sitemap/99.xml returns 404 (invalid ID)
- /products/sitemap.xml returns 404 (base URL not served)
- scanMetadataFiles discovers nested products/sitemap.ts
- Updated entry template snapshots for new generateSitemaps handling

* fix: address Codex review — typed id and single-segment matching

- Pass the original typed id from generateSitemaps() instead of the raw
  URL string, so numeric IDs stay numeric in the handler
- Reject multi-segment paths (e.g. /sitemap/a/b.xml) to avoid stealing
  requests from legitimate nested routes
- Fix shims.test.ts to find root sitemap by servedUrl, not first match

* fix: address ask-bonk review feedback

- Break long condition into multi-line for readability
- Add TODO for makeThenableParams when upgrading to v16 Promise params
- Add string ID ("featured") to fixture alongside numeric IDs
- Add test: missing .xml extension returns 404 (ported from Next.js)
- Add test: string ID ("featured") is served correctly
- Update entry template snapshots
2026-03-10 09:26:55 +00:00
Nathan Nguyen 0b63ad5237 perf: pre-split route patterns and hoist URL split out of match loop (#396)
* perf: pre-split route patterns and hoist URL split out of match loop

Route matching runs on every request and iterates all routes until a
match is found. Previously, matchPattern() re-split both the URL and
the pattern string on every iteration:

  for (const route of routes) {
    // url.split("/") — same URL, re-split every iteration
    // pattern.split("/") — pattern never pre-computed
    matchPattern(normalizedUrl, route.pattern);
  }

For a 50-route app on a 404, that's 50 URL splits + 50 pattern splits
+ 50 Object.create(null) + 100 .filter(Boolean) calls.

Fix:
- Add `patternParts: string[]` to Route and AppRoute interfaces,
  computed once at scan time
- Split the URL once in matchRoute/matchAppRoute before the loop
- Pass pre-split arrays to matchPattern instead of raw strings
- Apply the same optimization to generated production entries
  (pages-server-entry.ts, app-rsc-entry.ts)
- Fix intercepting route lookup to use the new array-based API

* update entry-templates snapshots

---------

Co-authored-by: James <james@eli.cx>
2026-03-10 08:18:30 +00:00
Nathan Nguyen c185978c48 perf: cache compiled regexes in middleware matcher (#395)
Middleware matcher patterns are static (from config.matcher) but
matchPattern() was re-running the full tokeniser + isSafeRegex scan +
new RegExp() on every request for every matcher entry. This is the
same problem that config-matchers.ts solved with _compiledPatternCache
(which eliminated ~2.4s of CPU self-time in profiling).

Add a Map<string, RegExp | null> cache to both the runtime
matchPattern() and the codegen matchMiddlewarePattern() so each
pattern is compiled exactly once per process lifetime.
2026-03-10 08:12:57 +00:00
Nathan Nguyen 490e7405ef fix: preserve middleware request header deletions (#382)
* fix: preserve middleware request header deletions

* test: refresh entry template snapshots

* test: cover mixed-router middleware header overrides

* fix: clone lazy headers by iteration

* test: cover rewrite override serialization
2026-03-10 08:08:05 +00:00
Jared Stowell dea3c3e44f fix: enforce segment boundaries for basePath stripping and redirect prefixing (#393)
* Update deploy basePath stripping

* Fix redirect basePath handling

* Document basePath segment fix

* Fix external redirect basePath
2026-03-10 08:03:20 +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 825a0959b4 perf: O(1) locale-static redirect index (#390)
matchRedirect was running exec() on all 63+ compiled locale-prefix
regexes (e.g. ^/(en|es|fr|...)?/security$) for every request that
matched no redirect — the common case. Profiling showed 2992ms self-time
on compiled.re.exec.

The regex compilation was already cached (previous fix). What remained
was the execution cost: 63 anchored alternation regexes × ~2ms each =
~130ms per no-match request, cumulative 2992ms under load.

Fix: build a per-redirects-array index (WeakMap, computed once) that
classifies each rule:

  localeStatic — source matches /:param(alt1|alt2|...)?/static-suffix
    Indexed in Map<suffix, entry[]> for O(1) lookup. At match time we
    extract the optional locale segment with one indexOf('/') call (no
    regex), look up the suffix in the map, then validate the locale
    against a tiny anchored alternation regex (one per match, not per
    rule).

  linear — all other rules. Unchanged O(n) matchConfigPattern scan.

For the no-match hot path (no redirect fires): 1 map miss + O(25)
linear rules instead of O(88 regex execs). For matching requests,
original ordering is preserved: locale-static entries store their
originalIndex; linear rules before a locale-static match are still
checked first via matchConfigPattern.
2026-03-09 21:09:05 +00:00
James Anderson af3dbb2e0a perf: cache compiled regex patterns in matchConfigPattern (#387)
* perf: cache compiled regex patterns in matchConfigPattern

Redirect/rewrite patterns are static — they come from next.config.js and
never change at runtime. Previously every request re-ran the full tokeniser
walk + isSafeRegex + new RegExp() for every rule in the array. On apps with
locale-prefixed rules (all containing '(' so they enter the regex branch)
profiling showed ~2.4 s of CPU self-time in isSafeRegex per request cycle.

Fix: add a module-level Map that stores the compiled { re, paramNames } on
first encounter of each pattern string. Subsequent requests skip the
tokeniser and isSafeRegex entirely and go straight to re.exec(pathname).
null is cached for patterns rejected by safeRegExp so the ReDoS scanner
also runs at most once per pattern.

Adds two new test cases covering the cache hit path and the cached-null
(ReDoS-rejected) path.

* fmt

* perf: cache compiled regexes in matchHeaders and checkSingleCondition

matchHeaders called escapeHeaderSource() + safeRegExp() on every request for
every header rule. checkSingleCondition called safeRegExp(condition.value) on
every request for every has/missing condition across header/cookie/query/host
branches. Both are pure functions of their input strings, which come from
next.config.js and never change at runtime.

Fix: two new module-level Maps (_compiledHeaderSourceCache,
_compiledConditionCache) mirror the same approach used for matchConfigPattern
in the previous commit. A private _cachedConditionRegex() helper centralises
the cache lookup for the four checkSingleCondition branches.

Adds four new test cases covering the cache-hit paths for both functions.

* fmt
2026-03-09 20:34:46 +00:00
James Anderson 696936c8e3 perf: lazy-init mutable Headers copy in headersContextFromRequest (#388)
* perf: lazy-init mutable Headers copy in headersContextFromRequest

In Workerd, new Headers(request.headers) copies the entire header map
across the V8/C++ boundary on every request, showing up as ~815ms self-
time in production profiles for header-heavy requests.

Replace the eager copy with a Proxy that forwards reads directly to the
immutable request.headers. The mutable copy is only materialised on the
first write (.set/.delete/.append), which only occurs when middleware
calls NextResponse.next({ request: { headers } }). Pure read requests
(the vast majority) now pay zero copy cost.

Cookie parsing is also deferred behind a lazy getter: the cookie string
is not split until cookies() or draftMode() is first called during the
request, rather than unconditionally on every request entry.

* perf: hoist MUTATING_METHODS set to module scope

It was being allocated on every request inside headersContextFromRequest.
2026-03-09 20:19:19 +00:00
Nathan Nguyen 0db5c8d99b fix: avoid shared cache headers for dynamic GET handlers (#381) 2026-03-09 17:41:22 +00:00
Nathan Nguyen 4fc0dfe360 fix: preserve x-middleware-request-* headers in dev-mode middleware runner (#383)
* fix: preserve x-middleware-request-* headers in dev-mode middleware runner

middleware.ts's runMiddleware() stripped ALL x-middleware-* headers,
including x-middleware-request-* headers that the dev server (index.ts)
needs to unpack into actual request headers. This caused
NextResponse.next({ request: { headers } }) to silently lose header
overrides in dev mode while working correctly in production.

The production inline codegen (pages-server-entry.ts) already preserved
these headers. This aligns the dev runner to match.

* fix: let RSC plugin handle fallback rewrites in App Router dev mode

The middleware.ts fix (preserving x-middleware-request-* headers) caused
index.ts to unpack middleware-injected cookies into req.headers. This
made the connect handler's fallback rewrite conditions match for App
Router paths, dispatching to the Pages Router SSR handler instead of
the RSC plugin — resulting in a 404.

Add the same hasAppDir guard that already exists after the fallback
block (line 2167) to the fallback match path itself.
2026-03-09 17:40:08 +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
Nathan Nguyen cb66cc793c feat(next-intl): auto-detect config without createNextIntlPlugin (#366)
* fix: resolve NEXT_DATA type conflict with next package types

next/dist/client/index.d.ts declares Window.__NEXT_DATA__ as NEXT_DATA,
which conflicts with our inline type in global.d.ts. The vinext-specific
extensions (__vinext, __pageModule, __appModule) aren't in NEXT_DATA,
causing TS2339 errors.

Extract vinext extensions into a VinextNextData interface and cast at
the two usage sites (client/entry.ts, shims/link.tsx) instead of
re-declaring __NEXT_DATA__ with a conflicting type.

* feat(next-intl): auto-detect next-intl config without createNextIntlPlugin

next-intl's createNextIntlPlugin() crashes in vinext because it calls
require('next/package.json') to check the Next.js version, and next
isn't installed. Instead, vinext now detects next-intl in project
dependencies and registers the next-intl/config alias automatically.

Detection probes i18n/request.{ts,tsx,js,jsx} then
src/i18n/request.{ts,tsx,js,jsx}, matching next-intl's own discovery
order. Explicit aliases from webpack/turbopack config take precedence.

Also adds a targeted warning when config loading fails due to
next-intl/plugin, telling users the wrapper is unnecessary.

Closes #202

* fix: address review feedback

- Update check.ts: next-intl status "partial" → "supported"
- Extend VinextNextData from NEXT_DATA in shims/internal/utils.ts
  instead of duplicating fields
- Add JSDoc note about monorepo resolution walk-up behavior
2026-03-09 14:24:41 +00:00
James Anderson 23a5cb2ceb refactor: delete app-dev-server.ts, point all callers at entries/ directly (#253 batch 3) (#379)
* refactor: delete app-dev-server.ts and update all references to entries/ directly

The file was already a pure re-export shim with no logic of its own.
Update every import/dynamic-import site to point straight at the
individual entry modules:

  entries/app-rsc-entry.ts    ← generateRscEntry, AppRouterConfig
  entries/app-ssr-entry.ts    ← generateSsrEntry
  entries/app-browser-entry.ts ← generateBrowserEntry

Sites updated:
  packages/vinext/src/index.ts
  tests/entry-templates.test.ts
  tests/app-router.test.ts
  tests/shims.test.ts

Also update stale comments in:
  server/middleware-codegen.ts, server/request-pipeline.ts,
  server/instrumentation.ts, shims/metadata.tsx,
  tests/rsc-streaming.test.ts, tests/nextjs-compat/rsc-context-lazy-stream.test.ts,
  examples/app-router-cloudflare/instrumentation*.ts

* docs: update app-dev-server.ts references in markdown files to entries/
2026-03-09 14:11:07 +00:00
James Anderson 10cc5a2968 fix: pass parent promise to generateMetadata() for ancestor metadata access (#376)
* fix: pass parent promise to generateMetadata() for ancestor metadata access

Implements Next.js's eager-execution-with-serial-resolution pattern for
generateMetadata(): all calls kick off concurrently but each segment's
parent promise resolves serially (root → leaf), so `await parent` inside
a child's generateMetadata correctly gets the accumulated ancestor metadata.

Fixes #375

* regen snaps

* fix: address review comments — remove dead array, pass searchParams to generateMetadata

- Remove unused layoutParentPromises[] array (dead code pointed out in review)
- Pass searchParams to page's resolveModuleMetadata() — the URL query string was
  previously always passed as undefined, so generateMetadata() could never read
  searchParams from the URL
- Reuse the already-computed spObj for pageProps.searchParams to avoid double iteration
- Add 2 regression tests for searchParams in generateMetadata

* regen snaps

* regen snaps

* fix: pass actual route params to layout generateMetadata() in fallback render

renderHTTPAccessFallbackPage was always passing {} as params to
resolveModuleMetadata(), so layouts with generateMetadata({ params })
would get undefined for all dynamic route segments (e.g. params.slug)
when rendering not-found/forbidden/unauthorized fallback pages.

Fix: use opts?.matchedParams ?? route?.params ?? {} (same expression
already used for the layout component props just below) so that
generateMetadata() in fallback layouts receives the real matched params.

Adds two regression tests to not-found.test.ts: hit an invalid slug that
triggers notFound() and assert the title contains the actual slug value.
Also updates entry-templates snapshots.

* fix: forward params to resolveModuleViewport in renderHTTPAccessFallbackPage

generateViewport({ params }) in a layout wrapping a not-found/error
boundary now receives the actual route params instead of an empty object.
Mirrors the same fix already applied to resolveModuleMetadata.

Adds generateViewport export to the layout-params-notfound fixture and
two regression tests confirming the themeColor includes the real slug.
2026-03-09 12:37:36 +00:00
James Anderson 86369f0351 perf: parallelise metadata and viewport resolution (#372)
* perf: parallelise metadata and viewport resolution

* fix: propagate page generateMetadata errors to error boundary

When parallelising metadata resolution, layout errors should be
swallowed (.catch(() => null)) but page-level generateMetadata()
errors must propagate so the caller can route them to the nearest
error.tsx or global-error.tsx boundary.

Fixes the regression introduced by the parallelisation PR where
a throwing generateMetadata() on the page module was silently
swallowed instead of triggering the error boundary.

* test: update snapshots

* fix: log layout generateMetadata/generateViewport errors instead of silently swallowing them

* regen snaps
2026-03-09 11:30:24 +00:00
James Anderson 857f8196a2 perf: load instrumentation without blocking isolate initialization (#373) 2026-03-09 10:59:47 +00:00
MD YUNUS 1bba737dc4 refactor: extract shared request handling from server entry points (#258)
* refactor: extract shared request handling into config-matchers and request-pipeline

Deduplicate ~250 lines of inline request-handling logic from the generated
App Router RSC entry (app-dev-server.ts) and Pages Router server entry
(index.ts) by importing from the canonical config-matchers.ts and a new
request-pipeline.ts module.

Changes:
- Create server/request-pipeline.ts with shared utilities:
  guardProtocolRelativeUrl, stripBasePath, normalizeTrailingSlash,
  validateCsrfOrigin, isOriginAllowed, validateImageUrl,
  processMiddlewareHeaders
- app-dev-server.ts: replace 14 inline __-prefixed functions with imports
  from config-matchers.ts (matchRedirect, matchRewrite, matchHeaders,
  isExternalUrl, proxyExternalRequest, sanitizeDestination,
  requestContextFromRequest) and request-pipeline.ts (validateCsrfOrigin,
  validateImageUrl)
- index.ts: replace inline parseCookies, sanitizeDestinationLocal, and
  matchConfigPattern with imports/re-exports from config-matchers.ts;
  replace manual RequestContext construction with requestContextFromRequest()
- Update tests to reference imported function names instead of old inline
  __-prefixed names

Security fixes:
- matchConfigPattern: eliminates chained .replace() divergence (CodeQL
  flagged) in favor of config-matchers single-pass tokenizer
- proxyExternalRequest: uses AbortController+setTimeout (broader runtime
  support) instead of AbortSignal.timeout()
- escapeHeaderSource: uses proper config-matchers implementation instead of
  simplified chained .replace()

Closes #254

t status

* fix: address bonk review - remove dead applyTo param, dangling comment

* fix: remove dangling comments (parseCookies stub, sanitizeDestination stub)

* fix: remove bare block around __tsRedirect, add multiple trailing slash test

* fix: stricter /api prefix check for trailing slash normalization (matches only /api or /api/), add tests for edge cases

* test: update entry-template snapshots after merging main

---------

Co-authored-by: James <james@eli.cx>
2026-03-09 10:28:10 +00:00
Nathan Nguyen 85425c8d53 fix: enforce segment boundary in catch-all pattern matching (#368)
* fix: enforce segment boundary in catch-all pattern matching

`matchConfigPattern('/foobar', '/foo/:path*')` incorrectly matched
because `startsWith('/foo')` doesn't check for a `/` boundary after
the prefix. Add a character check after the prefix to ensure only
exact segment prefixes match.

* test: regression tests for catch-all prefix segment boundary overmatch

---------

Co-authored-by: James <james@eli.cx>
2026-03-09 10:03:09 +00:00