Vinext now automatically detects installed packages with broken ESM
resolution and adds them to resolve.noExternal for both the rsc and
ssr environments. This fixes 'Cannot find module' errors when using
packages like validator that have extensionless internal ESM imports.
Root cause: Vite externalizes dependencies for SSR by default, but
some packages use extensionless imports (e.g. ./util/assertString)
that fail under Node.js strict ESM resolution. Additionally, Vite 7's
Environment API means ssr.noExternal only affects the ssr environment,
not the rsc environment where server components run.
Changes:
- Add KNOWN_PROBLEMATIC_ESM_PACKAGES list (validator, date-fns)
- Add collectNoExternalPackages() to merge user config with auto-detected packages
- Propagate noExternal to both rsc and ssr environments
- Add validator ecosystem test fixture and integration tests
- Add troubleshooting documentation
Fixes#189
* fix: pass NextFetchEvent as second argument to middleware
All 3 middleware invocation sites were calling `middlewareFn(nextRequest)`
with only one argument. The `NextMiddleware` type expects `(request, event)`
but `NextFetchEvent` was never instantiated or passed. Any middleware using
`event.waitUntil()` would throw at runtime because `event` is `undefined`.
Fix: instantiate `NextFetchEvent({ page })` at each call site, pass it as
the second argument, and drain `_waitUntilPromises` via
`Promise.allSettled()` (fire-and-forget) after middleware returns.
Call sites fixed:
- `packages/vinext/src/server/middleware.ts` (shared runner, used by prod)
- `packages/vinext/src/index.ts` (Pages Router dev codegen)
- `packages/vinext/src/server/app-dev-server.ts` (App Router dev codegen)
* address review: add drainWaitUntil(), wire ctx.waitUntil() in Workers entry, fix async test
* fix deploy tests: update assertions for ctx-aware fetch and runMiddleware signatures
* address review: fix misleading comment, update JSDoc, use queueMicrotask in test
* pass undefined explicitly to runMiddleware in prod-server (no ctx on Node.js)
---------
Co-authored-by: James <james@eli.cx>
The previous depth-based approach sliced the pathname by a numeric URL
depth, which broke when route groups were present — they don't appear
in URLs but Next.js includes them in the segments array. This also
meant dynamic params were returned as raw URL strings rather than
resolved values matching the route tree.
Replace `layoutSegmentDepths: number[]` with two new fields:
- `routeSegments: string[]` — filesystem segments from app/ root
- `layoutTreePositions: number[]` — directory depth per layout
Each layout now receives pre-computed child segments through React
context (including route groups, with dynamic params resolved to
actual values and catch-all segments joined with "/").
Additional fixes found during code review:
- Pass matched params to error/fallback rendering paths so dynamic
segments resolve correctly during error page rendering
- Skip optional catch-all segments when param is empty/undefined
- Extend routeSegments for parallel slot sub-routes
- Update stale type declaration in next-shims.d.ts
Ported from Next.js: test/e2e/app-dir/hooks/hooks.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts
* fix: normalize same-origin absolute URLs for client-side navigation
When <Link href="https://example.com/path"> is used and example.com
matches the current origin, vinext now does SPA navigation instead
of a full page reload. This matches Next.js behavior.
Adds toSameOriginPath() utility that extracts the local path from a
same-origin absolute URL. Applied consistently across:
- link.tsx handleClick() and prefetch paths
- navigation.ts navigateImpl() (App Router)
- router.ts push()/replace() (Pages Router hook + singleton)
Closes#335
* fix: handle protocol-relative URLs in toSameOriginPath()
Use window.location.origin as base for `//` URLs so they resolve
correctly for same-origin detection, while keeping `new URL(url)`
(no base) for http/https URLs to preserve existing behavior.
Addresses Copilot review comment on PR #336.
* Update tests/link.test.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: James Anderson <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* 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.