16 Commits

Author SHA1 Message Date
James Anderson 2ef52ad66b fix: use page extensions for middleware files (#595)
* fix: use page extensions for middleware files

* same thing for instrumentation
2026-03-20 08:45:26 +00: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
Webdesign29 85f8c493b7 fix: handle URL objects in metadata resolveUrl (#465)
* fix: handle URL objects in metadata resolveUrl

The Next.js Metadata API types URL fields as `string | URL` (e.g.
`metadataBase`, `openGraph.url`, icon/image URLs). The internal
`resolveUrl()` function in `MetadataHead` only accepted `string`,
so passing a `URL` object (which is valid per Next.js types) crashed
with `url.startsWith is not a function`.

Add type coercion at the top of `resolveUrl()` to convert `URL`
objects to strings before processing.

Reproduction:
1. Set `metadataBase: new URL("https://example.com")` in a layout
2. Set `openGraph: { url: "/page" }` in a page's metadata
3. `resolveUrl` receives the URL object, calls `.startsWith()` on it
4. Crash: `url.startsWith is not a function`

With this fix, `URL` objects are coerced to strings via `.toString()`.

* fix: update Metadata types and rendering to accept string | URL fields

Address bonk review feedback:
- Add test covering URL object input for alternates.canonical and
  openGraph.url to lock in the fix against regressions
- Update Metadata interface to type URL fields as string | URL,
  matching Next.js types (openGraph.url/images/videos/audio,
  twitter.images, icons, manifest, alternates.canonical/languages/
  media/types)
- Add overload to resolveUrl() so it returns string when called with
  a non-undefined argument, eliminating string | URL fallback issues
- Fix image/icon array normalisation to handle bare URL objects
  alongside strings and descriptor objects

* fmt

* fix: remove dead-code ?? fallbacks and unnecessary test casts

Address bonk re-review feedback:
- Remove 'as unknown as string' casts in the URL-object test since the
  Metadata interface now types those fields as string | URL
- Remove all 17 remaining 'resolveUrl(x) ?? x' fallbacks; resolveUrl
  is overloaded to return string for any truthy input so these never
  fired, and with string | URL fields the fallback value would have
  passed a URL object directly to content=/href= attributes

* fmt

---------

Co-authored-by: James <james@eli.cx>
2026-03-12 19:32:31 +00:00
Nathan Nguyen 8d10bc64fd fix: MemoryCacheHandler revalidate:0 creates immediately-stale entries (#503)
* fix: MemoryCacheHandler revalidate:0 creates immediately-stale entries

MemoryCacheHandler.set had an inconsistent guard on data.revalidate:
the ctx path checked `revalidate > 0` but the data path only checked
`typeof data.revalidate === "number"`, allowing revalidate:0 to set
revalidateAt = Date.now() — making entries immediately stale.

This was inconsistent with KVCacheHandler which already had the > 0
guard on both paths. The two handlers would produce different staleness
behavior for the same input: Memory → immediately stale, KV → no TTL.

Add the missing `> 0` guard to align both code paths and both handlers.

* fix: revalidate:0 should skip cache storage entirely

Both MemoryCacheHandler and KVCacheHandler had inconsistent handling of
revalidate:0. In Next.js, revalidate:0 means "don't cache." But the
handlers would still store entries — Memory made them immediately stale
(data path lacked > 0 guard), KV cached them forever (both paths
rejected 0, so revalidateAt=null meant no expiry).

Resolve effective revalidate from both ctx and data paths (data
overrides ctx), and early-return without storing when it's 0. Both
handlers now use identical logic.

Fix existing test data that incidentally used data.revalidate:0 in
tests meant to exercise stale-while-revalidate and tag invalidation.
2026-03-12 17:34:12 +00:00
Nathan Nguyen 38dc409a43 feat: Add Pages Router i18n domain routing (#471)
* Add Pages Router domain locale routing

* Add coverage for Pages Router domain locale behavior

* Format Pages Router i18n domain routing changes

* Handle basePath in Pages Router i18n domains

* Refactor Pages Router i18n domain fixtures

* Update Pages Router entry snapshot after rebase

* Align domain locale redirects with Next.js

* Clarify Pages Router domain locale helpers

* Fix case-insensitive locale prefixes

* Simplify same-domain locale URL handling

* Cover same-domain locale alias redirects

* Document Pages Router i18n invariants

* Guard preferred locale in domain redirects
2026-03-12 13:49:59 +00:00
Nathan Nguyen d093c3317b fix: Pages Router ISR background regeneration re-renders HTML (#487)
* fix: Pages Router ISR background regeneration re-renders HTML

Background regeneration only re-ran getStaticProps but cached the old
HTML, so server-rendered content was permanently stale after the first
revalidation cycle. The pageData JSON was updated but never used to
re-render the page.

Now the regeneration callback:
- Re-renders the page component with fresh props
- Rebuilds __NEXT_DATA__ with fresh props
- Updates the revalidate duration map (dev server)
- Passes full locale context to getStaticProps

Fixed in both dev server and production server entry.

* ci: retrigger checks

* fix: preserve document shell during prod ISR regeneration

The prod entry's regeneration was hardcoding a simple HTML suffix,
losing custom _document content between <Main /> and <NextScript />.

Now splits the cached HTML at known markers (<div id="__next"> and
__NEXT_DATA__ script) to preserve the full document structure: head
section, gap content, and tail are carried forward from the original
cached entry.

Also updates the entry-templates snapshot.

* ci: retrigger checks

* ci: retrigger checks
2026-03-12 08:28:15 +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 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 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
James Anderson 4e0294fc4f fix: middleware crash in dev mode with cloudflare plugin (#344)
* test: middleware with cloudflare plugin and routing bypass

* fix: use module runner for pages middleware

* add pages router dev test
2026-03-08 14:04:11 +00:00
Divanshu Chauhan (divkix) dc8e953f07 fix(instrumentation): defer ssrLoadModule to post-middleware hook (#289)
* fix(instrumentation): defer ssrLoadModule call to post-middleware hook

Vite 7's SSRCompatModuleRunner requires the SSR environment's transport
channel to be initialized before ssrLoadModule() can be called. During
configureServer(), the channel is not yet ready, causing a TypeError
when instrumentation.ts exists.

Move the runInstrumentation() call from the configureServer body into
the returned post-middleware function, where environments are fully
initialized.

Fixes #167

* fix(instrumentation): address review comments on deferred ssrLoadModule

- Remove unreachable .catch() on runInstrumentation() since internal
  try/catch already handles errors and the function never rejects
- Mock console.error in transport error test to suppress noisy output
  and assert the expected error message
- Rename misleading test to accurately describe what it verifies

* test: address instrumentation review feedback
2026-03-06 19:33:12 -06:00
Nathan Nguyen af0a839a67 fix: preserve multiple Set-Cookie headers in response merging (#297)
* fix: preserve multiple Set-Cookie headers in prod-server and worker entry

The response header merging in prod-server.ts and the generated Cloudflare
worker entry used Record<string, string> which flattened multiple Set-Cookie
headers — the last value won, or cookies with Expires dates got corrupted
by comma-joining.

Extract mergeResponseHeaders() helper that uses getSetCookie() to preserve
array-valued Set-Cookie headers. Apply the same fix to the worker entry
template in deploy.ts using the Headers API.

Fixes #295

* fix: address review feedback on Set-Cookie header preservation

- Add `as string` cast in deploy.ts worker entry for x-middleware-request-*
  header unpacking (matches prod-server.ts)
- Normalize Vary header with Array.isArray check in sendCompressed to handle
  the widened type correctly
- Add edge-case test for middleware cookie as plain string (not array)

* fix: apply Set-Cookie fix to example workers, remove redundant toLowerCase

- Fix the same Set-Cookie flattening bug in hand-written example worker
  entries (pages-router-cloudflare, realworld-api-rest)
- Remove 4 redundant .toLowerCase() calls — Headers.forEach() always
  yields lowercase keys

* fix: add missing as string cast in deploy.ts middleware header collection

* refactor: extract mergeHeaders to shared vinext/server/worker-utils

The mergeHeaders function was duplicated in both example worker entries
(pages-router-cloudflare, realworld-api-rest) and the deploy.ts template.
Extract it to a shared module that example workers import. The deploy.ts
template still inlines it (code generation constraint).

Reduces 3 copies to 2 (shared module + generated template).

* fix: array-accumulate Set-Cookie in config headers, fix JSDoc, add behavioral test

- deploy.ts template: config headers section was comma-joining Set-Cookie values
  using += which corrupts cookies with Expires dates and loses all but the last
  cookie when multiple config rules match. Matches the array-accumulation pattern
  already used in prod-server.ts (lines 899-907) and the middleware section above.
- worker-utils.ts + deploy.ts template JSDoc: 'Response headers take precedence'
  was misleading — Set-Cookie is additive, not overriding. Clarify both docs.
- tests/deploy.test.ts: add behavioral test for mergeHeaders via worker-utils import
  (same function inlined in the generated template), replacing reliance on
  string-contains assertions alone.

* fix: indentation in deploy.ts template, array-accumulate Set-Cookie in example workers

- deploy.ts: fix extra leading space in config headers block (lines 636-659)
  and JSDoc lines 740-743 introduced in previous commit
- pages-router-cloudflare, realworld-api-rest: config headers section was
  doing a plain assignment (middlewareHeaders[lk] = h.value) which overwrites
  array-valued Set-Cookie built up by the middleware section above; use the
  same array-accumulation pattern as prod-server.ts and the deploy.ts template

* fix: remove as any casts in prod-server.ts, add Vary handling to example workers

- prod-server.ts lines 904/906: middlewareHeaders is now Record<string, string | string[]>
  so the as any casts are unnecessary; replace with as string (consistent with the
  middleware collection section above at line 848)
- pages-router-cloudflare, realworld-api-rest: add Vary comma-joining to config
  headers section to match prod-server.ts (line 908) and deploy.ts template (line 653)

---------

Co-authored-by: James <james@eli.cx>
2026-03-06 23:53:23 +00:00
Divanshu Chauhan (divkix) 04d0086731 fix: inject default viewport meta (width=device-width, initial-scale=1) (#298)
* fix: inject default viewport meta (width=device-width, initial-scale=1)

mergeViewport() started from an empty object, so when a user exported
a viewport config with only themeColor (no width/initialScale), the
merged result was truthy but missing viewport dimensions. ViewportHead
then skipped the <meta name="viewport"> tag entirely, breaking mobile
rendering.

Seed mergeViewport with DEFAULT_VIEWPORT matching Next.js's
createDefaultViewport() defaults: { width: "device-width", initialScale: 1 }.
User-provided values still override these defaults.

* refactor: export DEFAULT_VIEWPORT and reuse at call sites

Export DEFAULT_VIEWPORT from metadata shim and replace the duplicated
inline literals in app-dev-server.ts to keep the default in one place.

* refactor: simplify mergeViewport call sites to always apply defaults

Since mergeViewport() always seeds from DEFAULT_VIEWPORT (even for
empty lists), the `viewportList.length > 0` guards and `?? DEFAULT_VIEWPORT`
fallbacks at both call sites were redundant. Remove them so the default
viewport is applied unconditionally via mergeViewport itself.

Addresses review feedback from james-elicx on PR #298.
2026-03-06 20:24:45 +00:00
Steve Faulkner 9b01dfec65 fix: load .env files into process.env before config evaluation (#234)
Closes #228. vinext was not loading .env files, so environment variables
defined in .env were unavailable to server-side code (getServerSideProps,
API routes, server components) and NEXT_PUBLIC_* vars were missing from
the client bundle defines.

Uses Vite's loadEnv() in the config hook with an empty prefix to load
all .env vars (not just VITE_-prefixed) into process.env before
loadNextConfig() runs, matching Next.js behavior.
2026-03-03 09:19:45 -06:00
Sunil Pai 8a618af9f6 fix: handle malformed percent-encoded URLs gracefully (#107)
Wrap all decodeURIComponent calls on user-controlled URL paths in
try/catch to return 400 Bad Request instead of throwing an uncaught
URIError that crashes the Node process.

A single request with a malformed percent-encoded path (e.g. /%E0%A4%A)
could terminate the entire server process. This affected all server
entry points: prod server (App + Pages Router), dev server middleware,
Cloudflare Worker entry, and generated RSC/middleware handlers.

Fixes:
- prod-server.ts: App Router and Pages Router request handlers
- app-router-entry.ts: Cloudflare Worker entry
- app-dev-server.ts: Generated RSC entry handler
- index.ts: Dev server connect middleware + generated middleware runner
  + generated NEXT_LOCALE cookie parser
- middleware.ts: Pages Router dev middleware runner

Includes 11 regression tests across app-router, pages-router, and
features test suites.
2026-02-26 10:52:57 +00:00
Steve Faulkner 12fea722b6 Initial public release of vinext 2026-02-24 09:29:39 -06:00