* 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>
* 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>
* 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>
* 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: 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>
* 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
* 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
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.
* 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
* 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>
* 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
* 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
* fix: tag GitHub @usernames in release notes, publish release directly
* fix: move contributor mentions to end-only Contributors section
* fix: trim SHA to 7 chars in commits.txt sent to AI
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
* 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>
* 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.
* 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
* 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>
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.
* 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
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.
* 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
* 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.
* 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.
* 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
* 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
* 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/
Moves generateSsrEntry (app-ssr-entry.ts) and generateBrowserEntry
(app-browser-entry.ts) out of the 3,215-line app-dev-server.ts into a new
entries/ directory, as described in issue #253.
app-dev-server.ts re-exports both functions so all existing import sites
(including entry-templates.test.ts) continue to work without changes.
All 10 entry-template snapshot tests pass unchanged — generated output is
byte-for-byte identical to before the move.