* fix: stop stripping credential headers from rewrite proxy requests
Rewrite destinations are typically the user's own backend services,
not untrusted third-party origins. Stripping cookie, authorization,
x-api-key, and proxy-authorization headers breaks authentication
for proxied API requests.
Next.js does not strip these headers for rewrites either — it
forwards them as-is to the destination.
* test: update proxy tests to assert credential headers are forwarded
Update tests to match new behavior where credential headers (cookie,
authorization, x-api-key, proxy-authorization) are forwarded through
the rewrite proxy, matching Next.js behavior. x-middleware-* headers
are still stripped.
* fix: suppress Invalid hook call warning during layout pre-render probe
Layout components that call use(promise) trigger React's dev-mode
'Invalid hook call' warning when invoked outside React's render cycle.
The layout pre-render loop calls LayoutComp() directly to probe for
redirect()/notFound() throws — the same pattern the page pre-render
already handles with a console.error suppressor.
Wrap the layout pre-render loop in a try/finally that silences the
expected warning, matching the existing suppression on the page probe.
* refactor: use AsyncLocalStorage to scope hook-warning suppression per-request
Replace the per-request console.error swap/restore pattern with a single
module-level patch gated behind an AsyncLocalStorage store. Each pre-render
probe (layouts and page) now runs inside _suppressHookWarningAls.run(true)
so the suppression is scoped to that specific async execution context.
This eliminates the race window where concurrent requests could have one
request's suppression filter silently drop real errors from another.
* feat: add global.d.ts for window.__VINEXT_* and globalThis.__VINEXT_* globals
Declares all 23 __VINEXT_* runtime globals in a single ambient declaration file
(packages/vinext/src/global.d.ts), eliminating the scattered (window as any) and
(globalThis as any) casts throughout the shims and server code.
* feat: declare window.__NEXT_DATA__ in global.d.ts, remove remaining (window as any) casts
- Add typed Window.__NEXT_DATA__ declaration covering all fields read by
router.ts, link.tsx, and entry.ts (props, page, query, isFallback,
locale/locales/defaultLocale, __vinext, __pageModule, __appModule)
- Remove all (window as any).__NEXT_DATA__ casts in router.ts (×3),
link.tsx (×1), and entry.ts (×1)
- Drop the win = window as any alias in navigateClient() — it was only
needed to write __NEXT_DATA__ which is now typed on Window
- Remove redundant export {} at the bottom of global.d.ts; the
import type { Root } at the top already makes the file a module
Cover Html, Head, Main, NextScript, and default Document with
renderToString assertions for attributes, children, placeholder
content, and correct nesting order.
* chore: add .worktrees/ to gitignore
* feat: support `export const revalidate` on App Router GET route handlers
Read the route segment config `revalidate` from route handler modules
and set `Cache-Control: s-maxage=N, stale-while-revalidate` on GET
responses, matching Next.js behavior. Only applies when the handler
does not set its own Cache-Control header.
Unskip two E2E fixme cases and add vitest coverage.
* fix: guard revalidate=0 and add proper test fixtures
- Skip Cache-Control injection when revalidate is 0 (means "never cache")
- Add /api/custom-cache fixture that sets its own Cache-Control header
- Add /api/no-cache fixture with revalidate=0
- Fix "does not override" test to use a fixture that actually sets Cache-Control
- Add revalidate=0 test case
The sort comparator (routePrecedence diff + localeCompare tiebreaker)
was duplicated in three places — scanPageRoutes, scanApiRoutes, and
appRouter. Extract it as compareRoutes() in routing/utils.ts, which
already exports routePrecedence, and replace all three call sites.
Next.js returns the same router object reference on every call to
useRouter() from next/navigation. Our implementation was creating a new
object literal on each call, which breaks components that rely on
referential equality — useMemo/useEffect dependency arrays and
React.memo bailouts would fire unnecessarily on every render.
Hoist the router object to module level so useRouter() always returns
the same reference. All methods close over module-level state and carry
no per-render data, so this is safe.
Adds two regression tests: one asserting reference equality across
multiple calls, and one verifying the singleton exposes all expected
navigation methods.
* fix: CSS imports from node_modules crash SSR (#270)
When a node_modules package imports a `.css` file, SSR crashes with
"Unknown file extension .css" because Vite externalizes node_modules
by default — causing Node's native ESM loader to handle the import,
which it can't do for non-JS files.
Set `noExternal: true` on all server environments (Pages Router SSR,
App Router RSC, App Router SSR) to force node_modules through Vite's
transform pipeline. This matches Next.js behavior of bundling
everything for SSR. Explicitly listed `external` packages (react,
satori, resvg, yoga-wasm-web) take precedence per Vite's rules.
Skipped for Cloudflare/Nitro targets which already bundle everything.
* fix: externalize better-sqlite3 and fix test hermiticity
- Add better-sqlite3 to ssr.external in better-auth fixture config.
Native addon packages using `bindings` break when Vite transforms them
(stack trace introspection fails). This mirrors Next.js's
serverExternalPackages — users with native addons must externalize them.
- Fix Pages Router CSS test mutating real workspace node_modules.
Previously symlinked tmpDir/node_modules → repo root, then created
fake-css-lib inside the symlink target, leaking into the real workspace.
Now creates a real node_modules dir and symlinks individual packages.
* fix: propagate user ssr.external into RSC and SSR environments
Vite 6+ treats `ssr.*` config as sugar for `environments.ssr.*` only —
it does NOT propagate to custom environments like `rsc`. This means
packages listed in `ssr.external` (e.g. better-sqlite3) were still
being transformed by Vite in the RSC environment, breaking native
addon resolution via the `bindings` package.
Read user's `ssr.external` array and merge it into both
`environments.rsc.resolve.external` and
`environments.ssr.resolve.external` so native addons are properly
externalized from all server environments.
Fixes CI failures in better-auth ecosystem tests on Linux where the
bindings package's stack trace introspection fails when the native
module is transformed by Vite.
* fix: address review comments on node-modules CSS SSR crash fix
- Fix wrong repo URL in test comment (nicepkg/vinext -> cloudflare/vinext)
- Propagate ssr.external: true into RSC and SSR environments correctly
- Move fake test packages from committed node_modules to __test_packages__
with file: protocol dependencies, removing fragile force-tracked files
- Add CSS module (fake-css-module-lib) coverage to Pages Router test
- Narrow Pages Router test symlinks to only required packages
* fix: address bonk review comments
- Update stale comment on top-level ssr block to reflect both React
externalization and noExternal: true behavior; document that user
ssr.noExternal is intentionally superseded by vinext's setting
- Remove now-redundant noExternal: ['better-auth'] from better-auth
fixture (vinext's blanket noExternal: true already covers it)
- Merge Pages Router test's two separate 'it' blocks (both hit same URL)
and add missing fake-css-module-rendered assertion for .module.css
- Guard symlink .catch() to only swallow EEXIST errors, not all failures
- Add comment explaining appDir: tmpDir usage in Pages Router test
---------
Co-authored-by: James <james@eli.cx>
* fix: warn when existing vite.config.ts is missing cloudflare plugin on deploy
Closes#312.
- Add `viteConfigHasCloudflarePlugin()` to deploy.ts: when `vinext deploy`
finds an existing vite.config.ts that doesn't import @cloudflare/vite-plugin,
it now prints an actionable warning with the exact config fix rather than
silently proceeding to a broken build.
- Add README prerequisites section to the Cloudflare Workers deploy docs:
covers wrangler login vs CLOUDFLARE_API_TOKEN (with required permissions)
and how to supply account_id.
- Add 7 unit tests for `viteConfigHasCloudflarePlugin()`.
* fix: pages router warning shows cloudflare() not cloudflare(())
* refactor(ci): extract composite actions and improve workflow hygiene
- Add composite actions: setup-node-pnpm, build-vinext, deploy-example, comment-preview-urls
- Cache Playwright browsers in CI to avoid ~150MB download per E2E run
- Add concurrency group to deploy-examples.yml to prevent deploy pileup
- Replace inline CJS node script in create-next-app job with portable bash
- Convert ecosystem-run.yml inline Node scripts from CJS require() to ESM
- Apply composite actions across all workflows to eliminate repeated boilerplate
* refactor(ci): revert to inline steps, keep only setup-node-pnpm composite action
Remove the build-vinext, deploy-example, and comment-preview-urls composite
actions and inline their steps back into each workflow. Keep setup-node-pnpm
as the one reusable action (pnpm install + node setup). Retain all other
improvements from the previous commit: concurrency groups, Playwright browser
cache, bash-based dev-server check, ESM node scripts in ecosystem-run.
* refactor(ci): use setup-node-pnpm composite action consistently
Every workflow that does pnpm/node setup + install now uses the
setup-node-pnpm composite action instead of the inline 3-step triplet.
Added optional registry-url input to support publish.yml's npm auth.
Exceptions (intentionally kept inline):
- bonk.yml / bigbonk.yml: issue_comment trigger, GHAS constraint
- tip.yml notify-on-failure: node-only, no pnpm install needed
* fix: stub node:async_hooks in client builds via virtual module
Several shims (headers, cache, navigation-state, etc.) import
AsyncLocalStorage from node:async_hooks. Since resolve.alias applies
globally, these shims resolve in every environment including client.
Vite externalizes node:async_hooks to __vite-browser-external in browser
builds — an empty stub with no named exports — causing Rollup errors.
Add a vinext:async-hooks-stub plugin that intercepts node:async_hooks
in the client environment and provides a no-op AsyncLocalStorage via a
virtual module. This is semantically correct: shims already guard with
`_als.getStore() ?? fallback` patterns, so undefined store returns
produce correct client-side behavior.
Unlike the external file approach in #293, this uses a virtual module
inline in the plugin — no additional files, no build artifacts.
Closes#293
* fix: address async-hooks-stub code review feedback
- Convert load hook to object form with handler() for consistency
- Match bare `async_hooks` specifier in addition to `node:async_hooks`
- Add intentionally-minimal comment explaining stub scope
- Add regression tests for resolveId and load hook behavior
* refactor: extract async-hooks-stub plugin to plugins/ and test against real implementation
Move the vinext:async-hooks-stub plugin object from an inline definition in
index.ts to packages/vinext/src/plugins/async-hooks-stub.ts, exporting it as
asyncHooksStubPlugin. Import and use it directly in index.ts.
Update the regression test to import the real plugin via _asyncHooksStubPlugin
and invoke its resolveId/load handlers with a mock context, eliminating the
duplicated logic that the test previously used as a workaround for the plugin
being unexported and inline.
* fix: add filter to load hook, evaluate stub at runtime in test
- Add filter to load hook object form for consistency with resolveId
and to opt into Vite/Rolldown hook optimization path
- Replace overlapping string-assertion test with one that evaluates
the generated source via new Function, testing actual runtime
behavior (getStore returns undefined, run/exit pass through return values)
* fix: remove control-char regex from load filter to fix lint
* fix: suppress no-control-regex for load filter via eslint-disable comment
* refactor: derive load filter regex from ASYNC_HOOKS_STUB_ID constant
* Update build-optimization.test.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* Update index.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* fix: widen run() type signature to accept rest args and typed callback
---------
Co-authored-by: James <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* refactor: extract applyMiddlewareRequestHeaders into config-matchers
Move the x-middleware-request-* unpacking + postMwReqCtx rebuild logic
out of prod-server.ts and deploy.ts and into a shared
applyMiddlewareRequestHeaders() in config-matchers.ts, eliminating ~30
lines of duplicated code from each callsite.
* fix(test): update deploy test to reflect applyMiddlewareRequestHeaders refactor
* fix(deploy): monorepo-aware lock file and node_modules resolution
vinext deploy failed in monorepos in two ways:
1. Wrong package manager detected — detectPackageManager only checked for
bun.lockb (legacy binary format), missing bun.lock (text format, Bun
v1.0+). It also only searched the immediate project directory, so any
monorepo where the lock file lives at the workspace root fell through to
npm, causing a confusing "npm error Invalid Version:" failure.
2. Dependency detection and wrangler binary path hard-coded to the app
directory — hasCloudflarePlugin, hasRscPlugin, hasWrangler, hasMdxRollup
all checked {root}/node_modules only, producing false negatives for
hoisted packages. runWranglerDeploy then crashed with ENOENT because
the wrangler binary was at the workspace root, not the app root.
Changes:
- Add walkUpUntil<T>(start, check) to utils/project.ts — a shared primitive
that walks ancestor directories until check() returns non-null. Both the
lock-file and node_modules walkers are built on top of this.
- Rewrite detectPackageManager / detectPackageManagerName using walkUpUntil;
add bun.lock (text) check alongside existing bun.lockb (binary).
- Add findInNodeModules(start, subPath) using walkUpUntil — walks up to find
node_modules/{subPath}, returning the absolute path or null.
- Use findInNodeModules in detectProject (hasCloudflarePlugin, hasRscPlugin,
hasWrangler), getMissingDeps (hasMdxRollup), and runWranglerDeploy.
Tests (12 new):
- detectPackageManager: pnpm, yarn, bun.lock, bun.lockb, npm fallback,
monorepo root traversal, closest-wins precedence
- findInNodeModules: package lookup, binary lookup, null fallback,
monorepo root traversal, closest-wins precedence
* fix(test): clear npm_config_user_agent in npm fallback test
On CI, pnpm sets process.env.npm_config_user_agent which causes
detectPackageManagerName to return 'pnpm' instead of falling through
to the npm default. Save and restore the env var around the assertion.
---------
Co-authored-by: Steve Faulkner <sfaulkner@cloudflare.com>
* 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
* fix: strip content-encoding and content-length from proxied responses (#263)
Node.js fetch() auto-decompresses responses (gzip, br, etc.), but the
proxy was forwarding the original content-encoding and content-length
headers unchanged. This caused the browser to attempt a second
decompression on the already-decoded body, resulting in
ERR_CONTENT_DECODING_FAILED.
Fix applied in both proxyExternalRequest (config-matchers.ts) and the
inline __proxyExternalRequest (app-dev-server.ts).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: gate proxy header stripping to Node runtime
* fix: guard content-encoding stripping to Node runtime in config-matchers
proxyExternalRequest is shared between Node.js and Workers. Workers
fetch() preserves wire encoding, so unconditionally stripping
content-encoding/content-length would break responses on Workers.
Apply the same __isNodeRuntime guard used in the generated dev server.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Steve Faulkner <sfaulkner@cloudflare.com>
When Nitro's configEnvironment hook runs, Vite hasn't yet applied the
default consumer based on environment name. Without an explicit
consumer: 'client', Nitro incorrectly treats the client environment as
a server-side service, creating a FetchableDevEnvironment that loads
the browser entry (virtual:vinext-app-browser-entry) in a Node.js
worker thread. The browser entry imports @vitejs/plugin-rsc/browser,
which is externalized and loaded natively by Node — but it contains
`import * as clientReferences from 'virtual:vite-rsc/client-references'`,
which fails because Node's ESM loader doesn't support the virtual: scheme.
Fixes#260
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
* 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>
* fix: rebuild reqCtx for afterFiles/fallback rewrites after middleware
afterFiles and fallback rewrites run after middleware in the App Router
execution order. Their has/missing conditions should evaluate against
middleware-modified headers, not the original pre-middleware request.
Introduces postMwReqCtx (prod-server, deploy.ts) and __postMwReqCtx
(app-dev-server) built after x-middleware-request-* headers are unpacked.
headers, redirects, and beforeFiles rewrites keep using the original
pre-middleware reqCtx, matching Next.js execution order.
Adds getHeadersContext() to the headers shim so app-dev-server can read
the ALS-mutated headers context for the post-middleware snapshot.
Test: afterFiles rewrite with has:[cookie:mw-user] is skipped without
middleware injection and matches after middleware injects the cookie.
* fix: also use postMwReqCtx for beforeFiles rewrites in deploy.ts
In App Router execution order, beforeFiles rewrites run after middleware
(step 4 vs step 3). deploy.ts processes them after middleware runs, so
they should use postMwReqCtx like afterFiles and fallback.
prod-server.ts (Pages Router) correctly keeps reqCtx for beforeFiles —
the Pages Router order has no middleware step before beforeFiles.
* fix: resolve merge conflict - keep Vary header test and afterFiles rewrite test
* fix: move beforeFiles rewrites after middleware in app-dev-server; add has/missing regression tests
- Move beforeFiles rewrite block after middleware execution so it sees
middleware-injected cookies/headers (matches Next.js App Router order:
middleware → beforeFiles → filesystem → afterFiles → fallback)
- Fix Map→Record bug in __buildPostMwRequestContext: ctx.cookies is a
Map<string,string> but RequestContext.cookies must be Record<string,string>;
convert with Object.fromEntries() so cookie lookups work correctly
- Update .rsc-suffix stripping test to reflect new code path (cleanPathname
is now used directly by beforeFiles rather than a separate __rewritePathname)
- Add regression tests: beforeFiles and fallback rewrite has/missing conditions
see middleware-injected cookies (app-router); fixtures wire up mw-gated-before
and mw-gated-fallback routes + middleware cookie injection
* Apply suggestion from @james-elicx
* fix: use postMwReqCtx for beforeFiles in prod-server (Pages Router)
Per Next.js docs, the execution order is: headers → redirects → Middleware
→ beforeFiles → filesystem → afterFiles → fallback. This applies to both
App Router and Pages Router. prod-server.ts was incorrectly using the
pre-middleware reqCtx for beforeFiles matching; switch to postMwReqCtx.
Add regression test: beforeFiles has/missing conditions on the production
Pages Router server now correctly see middleware-injected cookies.
* Apply same append logic for multi-value headers in index.ts and deploy.ts
Address review feedback: the Pages Router dev server (applyHeaders in
index.ts) and Cloudflare Worker entry (deploy.ts) had the same overwrite
bug for Vary and Set-Cookie config headers. Apply the same append
semantics used in prod-server.ts and app-dev-server.ts.
* Add append logic for multi-value config headers in prod-server.ts and app-dev-server.ts
The original fixes for these two files were lost during a rebase.
Re-apply: use append() for Vary and Set-Cookie headers instead of
set()/overwrite in both the Pages Router prod server and the App
Router RSC entry config header application.
All four config header sites now use append semantics for multi-value
headers: app-dev-server.ts, prod-server.ts, index.ts, deploy.ts.
* Fix unstable_cache serving stale entries and incorrect revalidate default
Two bugs in unstable_cache:
1. Stale entries served indefinitely: unstable_cache did not check
cacheState when reading from cache, so time-expired entries were
returned without re-fetching. The "use cache" runtime correctly
checks cacheState !== "stale" before returning cached values.
Added the same check to unstable_cache.
2. Incorrect revalidate default: when no revalidate option was provided
(undefined) or false was passed, the stored cache value had
revalidate: 0. The MemoryCacheHandler interprets 0 as "revalidate
at current time" (immediately stale on next set), not "never
revalidate". Changed to store false for indefinite caching, which
correctly leaves revalidateAt as null.
* Update IncrementalCacheValue.revalidate type in next-shims.d.ts
Keep the type declaration in sync with the CachedFetchValue interface
in cache.ts: revalidate is now number | false.
* 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.
* Default API route Content-Type to application/octet-stream
When an API route handler calls res.end() without setting a Content-Type
header, the production server was defaulting to text/html. This could
cause browsers to render response bodies as HTML when the developer
intended to return plain data.
Change the fallback for API routes to application/octet-stream, which
is the safe default for arbitrary binary/text content. The SSR page
rendering path still correctly defaults to text/html.
* Improve test comment for Content-Type assertion
Explain why we use a negative assertion (not text/html) rather than
a positive one: when the handler passes a string to res.end(), the
Response constructor auto-sets text/plain, so the octet-stream
fallback only triggers for non-string bodies.
* Merge middleware response headers on all App Router response paths
Middleware response headers were only merged into RSC streaming
responses and HTML rendering responses, but were dropped on route
handlers, server actions, metadata routes, image optimization, error
pages, and other response paths (~30 out of ~37 return points).
Move middleware header merging from individual response paths to the
handler() wrapper, which runs after _handleRequest() returns. This
ensures all response paths receive middleware headers. The previous
per-path merges in the RSC response and attachMiddlewareContext() are
replaced by a single merge point at the top level.
* Replace module-level middleware state with per-request container
Module-level __pendingMiddlewareHeaders and __pendingMiddlewareRewriteStatus
variables could race between concurrent requests on Cloudflare Workers.
Replace with a per-request _mwCtx object passed from handler() into
_handleRequest(). Each request gets its own container, eliminating the
shared mutable state.
* fix: strip getServerSideProps/getStaticProps from client bundles
Pages Router page modules export getServerSideProps, getStaticProps,
and getStaticPaths for server-side use. These functions often import
server-only modules (database drivers, fs, etc.) that would break or
bloat the client bundle.
Added a new Vite transform plugin (vinext:strip-server-exports) that
replaces these exports with no-op stubs when the module is being
processed for the client environment. The plugin only applies to
files under the pages/ directory and skips API routes, _app,
_document, and _error files.
The transform handles multiple export patterns:
- export (async) function declarations
- export const arrow functions
- export const simple references
- TypeScript type annotations in parameters
Extracted the core logic into a standalone stripServerExports()
function for testability. Added 11 unit tests covering all patterns
including edge cases with nested braces, string literals, TypeScript
types, and multiple server exports in one file.
* Rewrite stripServerExports to use AST instead of regex
Replace the regex-based approach with parseAst (Rollup/acorn) and
MagicString. The AST approach correctly handles all export patterns:
- Function declarations (export function / export async function)
- Variable declarations with any initializer (arrow functions,
function expressions, async named function expressions, simple refs)
- Re-export syntax (export { getServerSideProps })
- Mixed re-exports (export { getServerSideProps, config })
The old regex approach had bugs with function expressions, arrow
functions with TS return type annotations, re-exports, and regex
literals in function bodies. Since this transform runs after Vite's
JSX/TS compilation, parseAst always receives plain JavaScript.
Updated tests to use post-compiled JS (no JSX/TS) matching what the
transform actually receives. Added 6 new test cases for the patterns
that broke the regex approach.
* Rebuild request context after middleware modifies headers
The production server built the RequestContext for has/missing condition
matching before middleware ran. When middleware modified request headers
(e.g. injecting cookies via x-middleware-request-cookie), config rules
for redirects, rewrites, and custom headers evaluated against the
original pre-middleware values instead of the updated ones.
Move the reqCtx construction to after middleware header unpacking so
that config conditions see the current request state.
* Fix request context parity in dev server and deploy entry
Address review feedback: the dev server (index.ts) was not unpacking
x-middleware-request-* headers into req.headers before building reqCtx,
so middleware-injected cookies were invisible to config has/missing
conditions. The Cloudflare Worker entry (deploy.ts) had the same stale
reqCtx as prod-server.ts: built once before middleware, never rebuilt.
Dev server: unpack x-middleware-request-* headers into req.headers and
strip x-middleware-* internal headers from the response.
Deploy entry: change const to let, rebuild reqCtx after middleware
header unpacking.
* fix(config): pass correct Next.js phase to function-form configs
unwrapConfig() hardcoded "phase-development-server" regardless of
whether vinext was running in dev or build mode. Apps that branch on
the phase argument in next.config.ts received wrong config during
production builds.
Map Vite's env.command to the correct Next.js phase constant:
- "serve" → "phase-development-server"
- "build" → "phase-production-build"
* test: add instrumentation module test coverage
The instrumentation module had zero tests. Add 11 tests covering:
- findInstrumentationFile: file discovery across root and src/,
priority ordering, null when absent
- runInstrumentation: register() lifecycle, onRequestError handler
storage, graceful handling of empty modules, error isolation
when ssrLoadModule fails
- reportRequestError: handler invocation with correct args,
no-op without handler, error isolation when handler throws
* fix(test): use shared helper for App Router static export test
The static-export test had its own startFixtureServer that loaded the
fixture's vite.config.ts via configFile. This caused RSC plugin timing
issues on Node 25 / macOS — the App Router dev server returned 404 for
all pages. The Pages Router section (same approach) worked because it
doesn't use the RSC plugin.
Switch the App Router section to use the shared startFixtureServerHelper
from helpers.ts, which uses configFile: false with explicit plugin
registration. This matches how app-router.test.ts (193 passing tests)
creates its dev server and avoids the RSC timing issue.
* fix: address PR review feedback
- Import PHASE_PRODUCTION_BUILD and PHASE_DEVELOPMENT_SERVER constants
from shims/constants.ts instead of hardcoding string literals in
index.ts and config/next-config.ts
- Add afterEach cleanup for temp directories in instrumentation tests
* refactor: use phase constants in tests, simplify pure function test setup
- Replace hardcoded phase strings in next-config.test.ts with
PHASE_PRODUCTION_BUILD and PHASE_DEVELOPMENT_SERVER constants
- Remove unnecessary vi.resetModules() from findInstrumentationFile
tests — it's a pure function with no module-level state to reset
* refactor: use shared startFixtureServer helper in static export tests
Remove the local startFixtureServer function — the shared helper from
tests/helpers.ts does the same thing. The Pages Router fixture's
vite.config.ts is just `vinext()` with no special options, so the
shared helper (which uses configFile: false + vinext plugin) works
identically.
The `closeBundle` hook writes `_headers` to `clientDir` (`dist/client/`)
without creating the directory first. Only `distDir` (`dist/`) is checked
earlier. When build cache is invalidated, `dist/client/` may not exist yet,
causing an ENOENT error on `writeFileSync`.
Add `fs.mkdirSync(clientDir, { recursive: true })` before the write.
`recursive: true` is a no-op if the directory already exists, so this is
safe on warm builds.
The HTML parser treats </script> (and </style>) as the end of the
containing element regardless of JavaScript/CSS string context. When
dangerouslySetInnerHTML content contained these sequences, the SSR
output could produce malformed HTML where the tag boundary was broken.
Add escapeInlineContent() that replaces </script and </style with
<\/script and <\/style (case-insensitive). Apply it in:
- next/script: beforeInteractive SSR path
- next/head: reactElementToHTML for script and style tags
Includes 14 tests covering the escaping helper, Script component SSR,
and Head component SSR.
The .vite/ directory in the client build output contains build manifests
(ssr-manifest.json, manifest.json) that should not be publicly
accessible. The tryServeStatic() function now rejects requests to
.vite/ paths after URL decoding, which also handles encoded variants
like /%2Evite/.
* fix: apply headers, cookies, and status set by getServerSideProps to the response
getServerSideProps receives a res object that supports setHeader(),
statusCode, and end(). Previously, any headers, cookies, or status
codes set on this object were silently discarded in the final HTML
response.
This fixes two code paths:
1. Production (index.ts renderPage): Capture the res object from
createReqRes and merge its accumulated headers, cookies, and
status into the final Response. Also handle the short-circuit
case where gSSP calls res.end() directly.
2. Dev server (dev-server.ts): Preserve res.statusCode set by gSSP
so it flows through to streamPageToResponse. Also detect when
gSSP calls res.end() to avoid writing to an already-ended stream.
Added getHeaders() and headersSent to the mock res object in
createReqRes so callers can read accumulated response metadata.
Test: Added fixture pages (ssr-headers.tsx, ssr-res-end.tsx) and
integration tests verifying custom headers, cookies, status codes,
and res.end() short-circuit behavior.
* refactor: explicitly forward gSSP headers in dev server
Address review feedback:
- Capture headers newly set by gSSP via snapshot/diff and forward them
explicitly through extraHeaders, instead of relying on Node.js
writeHead() implicitly merging setHeader() calls. This prevents
silent header loss if streamPageToResponse is refactored.
- Support array-valued headers (e.g. multiple Set-Cookie) in
streamPageToResponse by using res.setHeader() for arrays.
- Add comments explaining the middleware header behavior when gSSP
calls res.end() (dev includes them via the shared res object,
prod merges them via the worker entry).
The full Vitest suite runs serially (~2 min) due to Vite deps optimizer
cache races between test files. Agents rarely need the full suite during
development. This adds a Running Tests section with a mapping from source
files to relevant test files, so agents run only what they need (seconds
instead of minutes).
The generated Pages Router worker entry was not wrapping the final
redirect destination in sanitizeDestination(), unlike the equivalent
code path in prod-server.ts. This ensures protocol-relative URLs
like //evil.com are normalized to /evil.com before being set as
the Location header.