* fix: resolve NEXT_DATA type conflict with next package types
next/dist/client/index.d.ts declares Window.__NEXT_DATA__ as NEXT_DATA,
which conflicts with our inline type in global.d.ts. The vinext-specific
extensions (__vinext, __pageModule, __appModule) aren't in NEXT_DATA,
causing TS2339 errors.
Extract vinext extensions into a VinextNextData interface and cast at
the two usage sites (client/entry.ts, shims/link.tsx) instead of
re-declaring __NEXT_DATA__ with a conflicting type.
* feat(next-intl): auto-detect next-intl config without createNextIntlPlugin
next-intl's createNextIntlPlugin() crashes in vinext because it calls
require('next/package.json') to check the Next.js version, and next
isn't installed. Instead, vinext now detects next-intl in project
dependencies and registers the next-intl/config alias automatically.
Detection probes i18n/request.{ts,tsx,js,jsx} then
src/i18n/request.{ts,tsx,js,jsx}, matching next-intl's own discovery
order. Explicit aliases from webpack/turbopack config take precedence.
Also adds a targeted warning when config loading fails due to
next-intl/plugin, telling users the wrapper is unnecessary.
Closes#202
* fix: address review feedback
- Update check.ts: next-intl status "partial" → "supported"
- Extend VinextNextData from NEXT_DATA in shims/internal/utils.ts
instead of duplicating fields
- Add JSDoc note about monorepo resolution walk-up behavior
* refactor: delete app-dev-server.ts and update all references to entries/ directly
The file was already a pure re-export shim with no logic of its own.
Update every import/dynamic-import site to point straight at the
individual entry modules:
entries/app-rsc-entry.ts ← generateRscEntry, AppRouterConfig
entries/app-ssr-entry.ts ← generateSsrEntry
entries/app-browser-entry.ts ← generateBrowserEntry
Sites updated:
packages/vinext/src/index.ts
tests/entry-templates.test.ts
tests/app-router.test.ts
tests/shims.test.ts
Also update stale comments in:
server/middleware-codegen.ts, server/request-pipeline.ts,
server/instrumentation.ts, shims/metadata.tsx,
tests/rsc-streaming.test.ts, tests/nextjs-compat/rsc-context-lazy-stream.test.ts,
examples/app-router-cloudflare/instrumentation*.ts
* docs: update app-dev-server.ts references in markdown files to entries/
* fix: pass parent promise to generateMetadata() for ancestor metadata access
Implements Next.js's eager-execution-with-serial-resolution pattern for
generateMetadata(): all calls kick off concurrently but each segment's
parent promise resolves serially (root → leaf), so `await parent` inside
a child's generateMetadata correctly gets the accumulated ancestor metadata.
Fixes#375
* regen snaps
* fix: address review comments — remove dead array, pass searchParams to generateMetadata
- Remove unused layoutParentPromises[] array (dead code pointed out in review)
- Pass searchParams to page's resolveModuleMetadata() — the URL query string was
previously always passed as undefined, so generateMetadata() could never read
searchParams from the URL
- Reuse the already-computed spObj for pageProps.searchParams to avoid double iteration
- Add 2 regression tests for searchParams in generateMetadata
* regen snaps
* regen snaps
* fix: pass actual route params to layout generateMetadata() in fallback render
renderHTTPAccessFallbackPage was always passing {} as params to
resolveModuleMetadata(), so layouts with generateMetadata({ params })
would get undefined for all dynamic route segments (e.g. params.slug)
when rendering not-found/forbidden/unauthorized fallback pages.
Fix: use opts?.matchedParams ?? route?.params ?? {} (same expression
already used for the layout component props just below) so that
generateMetadata() in fallback layouts receives the real matched params.
Adds two regression tests to not-found.test.ts: hit an invalid slug that
triggers notFound() and assert the title contains the actual slug value.
Also updates entry-templates snapshots.
* fix: forward params to resolveModuleViewport in renderHTTPAccessFallbackPage
generateViewport({ params }) in a layout wrapping a not-found/error
boundary now receives the actual route params instead of an empty object.
Mirrors the same fix already applied to resolveModuleMetadata.
Adds generateViewport export to the layout-params-notfound fixture and
two regression tests confirming the themeColor includes the real slug.
* perf: parallelise metadata and viewport resolution
* fix: propagate page generateMetadata errors to error boundary
When parallelising metadata resolution, layout errors should be
swallowed (.catch(() => null)) but page-level generateMetadata()
errors must propagate so the caller can route them to the nearest
error.tsx or global-error.tsx boundary.
Fixes the regression introduced by the parallelisation PR where
a throwing generateMetadata() on the page module was silently
swallowed instead of triggering the error boundary.
* test: update snapshots
* fix: log layout generateMetadata/generateViewport errors instead of silently swallowing them
* regen snaps
* refactor: extract shared request handling into config-matchers and request-pipeline
Deduplicate ~250 lines of inline request-handling logic from the generated
App Router RSC entry (app-dev-server.ts) and Pages Router server entry
(index.ts) by importing from the canonical config-matchers.ts and a new
request-pipeline.ts module.
Changes:
- Create server/request-pipeline.ts with shared utilities:
guardProtocolRelativeUrl, stripBasePath, normalizeTrailingSlash,
validateCsrfOrigin, isOriginAllowed, validateImageUrl,
processMiddlewareHeaders
- app-dev-server.ts: replace 14 inline __-prefixed functions with imports
from config-matchers.ts (matchRedirect, matchRewrite, matchHeaders,
isExternalUrl, proxyExternalRequest, sanitizeDestination,
requestContextFromRequest) and request-pipeline.ts (validateCsrfOrigin,
validateImageUrl)
- index.ts: replace inline parseCookies, sanitizeDestinationLocal, and
matchConfigPattern with imports/re-exports from config-matchers.ts;
replace manual RequestContext construction with requestContextFromRequest()
- Update tests to reference imported function names instead of old inline
__-prefixed names
Security fixes:
- matchConfigPattern: eliminates chained .replace() divergence (CodeQL
flagged) in favor of config-matchers single-pass tokenizer
- proxyExternalRequest: uses AbortController+setTimeout (broader runtime
support) instead of AbortSignal.timeout()
- escapeHeaderSource: uses proper config-matchers implementation instead of
simplified chained .replace()
Closes#254
t status
* fix: address bonk review - remove dead applyTo param, dangling comment
* fix: remove dangling comments (parseCookies stub, sanitizeDestination stub)
* fix: remove bare block around __tsRedirect, add multiple trailing slash test
* fix: stricter /api prefix check for trailing slash normalization (matches only /api or /api/), add tests for edge cases
* test: update entry-template snapshots after merging main
---------
Co-authored-by: James <james@eli.cx>
* fix: enforce segment boundary in catch-all pattern matching
`matchConfigPattern('/foobar', '/foo/:path*')` incorrectly matched
because `startsWith('/foo')` doesn't check for a `/` boundary after
the prefix. Add a character check after the prefix to ensure only
exact segment prefixes match.
* test: regression tests for catch-all prefix segment boundary overmatch
---------
Co-authored-by: James <james@eli.cx>
next/font/local variable mode works (className and variable both
supported) — the scanner still said it was broken.
styled-components and @emotion/react work via useServerInsertedHTML —
the scanner still said the hook wasn't implemented.
Updated both entries from partial to supported with accurate details,
and updated corresponding test assertions.
draftMode() reads the __prerender_bypass cookie, which is
request-specific data. Without calling markDynamicUsage(), a Server
Component branching on draftMode().isEnabled could have its output
cached by ISR — serving draft content to regular users or stale
content to draft users.
headers() and cookies() already call markDynamicUsage(); draftMode()
was the only request-reading API that didn't.
* fix: preserve middleware redirect headers (Set-Cookie) in production
middleware.ts correctly collects non-internal headers (e.g. Set-Cookie)
on redirect responses, but three consumers dropped them:
- prod-server.ts: sent only Location header, ignored responseHeaders
- deploy.ts: worker entry sent only Location header
- index.ts: inline runMiddleware codegen didn't collect headers on redirect
(it already did for next() and rewrite(), just not redirect)
The dev server (index.ts:2934) handled this correctly, making this a
dev/prod parity bug where cookies set during middleware redirects worked
in development but were silently dropped in production.
* test: update entry template snapshot for redirect header fix
---------
Co-authored-by: James <james@eli.cx>
`__validateDevRequestOrigin()` is a dev-only cross-origin protection
that was running unconditionally in the generated App Router RSC entry.
When deployed to Cloudflare Workers, it blocked legitimate cross-origin
requests with 403 Forbidden.
Wrap the call site with `process.env.NODE_ENV !== "production"` — the
same pattern already used nearby. Vite statically replaces NODE_ENV at
build time, making the block dead code that gets tree-shaken away.
Closes#361
* fix: fail build when cloudflare() plugin is missing
Upgrade the deploy-time warning to a hard error and add a build-time
check in configResolved that detects a wrangler config without the
cloudflare() plugin registered.
Previously, forgetting to add cloudflare() to vite.config.ts produced
a cryptic "could not resolve virtual:vinext-rsc-entry" error deep in
wrangler esbuild. Now the build fails early with an actionable message
showing exactly what to add.
The validation only fires during builds (not dev) and is skipped when
Nitro is the deployment target.
Closes#325
* refactor: extract shared utilities for cloudflare plugin guard
Address code review feedback on PR #358:
- Extract `hasWranglerConfig(root)` utility from `detectProject()` inline
check, reuse in both `deploy.ts` and `index.ts:configResolved`
- Extract `formatMissingCloudflarePluginError({ isAppRouter, configFile? })`
shared error formatter used by both build-time and deploy-time guards
- Fix hardcoded `vite.config.ts` references: build-time guard now uses
`config.configFile`, deploy-time guard uses generic "your Vite config"
- Add 10 unit tests covering both new utilities
* Update packages/vinext/src/deploy.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>
* test: add snapshot tests for entry template generators
Add 7 snapshot tests covering all 5 virtual entry module generators:
App Router (imported directly — already exported standalone):
- generateRscEntry: minimal routes, with middleware, with config
- generateSsrEntry
- generateBrowserEntry
Pages Router (tested via pluginContainer.load on virtual modules):
- virtual:vinext-server-entry
- virtual:vinext-client-entry
These snapshots lock down the generated code so subsequent extraction
refactors (moving generators into entries/ modules) can be verified
against a known baseline.
Ref #253
* test: update entry template snapshots after merging main
* test: address bonk review comments on entry-template snapshot tests
- Add comment on PAGES_FIXTURE_DIR warning about snapshot coupling
- Add generateRscEntry snapshot test covering instrumentationPath arg
* test: address new bonk review comments on entry-template snapshot tests
- Add dynamic route fixture (/blog/:slug) to minimalAppRoutes to cover
dynamic segment serialization in the generated route table
- Add generateRscEntry snapshot test covering globalErrorPath arg
* test: address final bonk review comments
- Add allowedOrigins and allowedDevOrigins to the 'with config' test
case to cover CSRF validation and dev origin checking code paths
- Replace as any cast with proper type guard for pluginContainer.load()
result
* test: update snapshots after merge with main
Merge upstream/main which includes f1e98b9 (don't clear RSC context
before lazy stream is consumed). Update 5 snapshots to reflect the
replaced setHeadersContext/setNavigationContext(null) calls with
comments explaining lazy stream context lifecycle.
* test: add metadata routes test, enrich route fixture with loading/error/template/notFound
* test: fix snapshot portability - apply stabilize() to all App Router tests, update snapshots after merge
---------
Co-authored-by: James <james@eli.cx>
@mdx-js/rollup strips the query before matching the file extension,
so it would compile 'foo.mdx?raw' as MDX and return compiled JSX
instead of the raw file text. The proxy now returns early when the
id contains '?', matching Vite's own convention for query imports.
Adds two regression tests: one unit test that reproduces the bug
inline (guard vs no-guard with a mock delegate), and one that
asserts the real vinext:mdx plugin returns undefined for query-param ids.
* feat(og): add @next/og support with Cloudflare Workers compatibility
- Add vinext:og-font-fix transform plugin that patches @vercel/og/dist/index.edge.js
at Vite transform time: inlines the fallback font as base64 (avoids fetch() with
import.meta.url which breaks in workerd) and defers WASM init to first use (avoids
WebAssembly.instantiate() being blocked in Node.js module runner)
- Add @vercel/og to optimizeDeps.exclude so Vite's esbuild pre-bundler doesn't cache
the module before the transform hook runs
- Add vinext:og-assets build plugin that copies font/WASM assets to RSC output dir
- Add /app/api/og route to app-router-cloudflare example and app-basic fixture
- Add shared Playwright e2e spec (tests/e2e/og-image.spec.ts) asserting PNG validity,
correct 1200x630 dimensions, param-driven content, and determinism
- Register og-image.spec.ts in app-router, cloudflare-workers, and cloudflare-dev
Playwright projects (18 tests total, all passing)
* refactor(og): generalize fetch+import.meta.url asset inlining, drop WASM lazy-init patch
Replace the @vercel/og-specific vinext:og-font-fix plugin with a general
vinext:og-inline-fetch-assets plugin that:
- Matches ANY fetch(new URL('./asset', import.meta.url)).then(res => res.arrayBuffer())
pattern, not just the hardcoded noto-sans filename
- Resolves each asset relative to the module's on-disk path (via the transform id)
and inlines it as base64 — works for any font version or additional assets
- Applies to any library using this pattern, not just @vercel/og
Also removes the WASM lazy-init patch: now that @vercel/og is in optimizeDeps.exclude,
the module runs through workerd (not the Node.js pre-bundler cache) where
WebAssembly.instantiate() works fine. The lazy-init workaround was only needed
when the pre-bundler cache bypassed the transform hook.
The og-assets build plugin is updated to only copy resvg.wasm (not the font,
which is now inlined and will no longer appear in the bundle by filename).
* fix(og): inline readFileSync assets from @vercel/og node build
The production RSC bundle resolves @vercel/og under the 'import' export
condition, getting dist/index.node.js instead of dist/index.edge.js.
The node build loads font and WASM via fs.readFileSync(fileURLToPath(
new URL('./file', import.meta.url))), which breaks after bundling because
import.meta.url points to the bundle output file, not the dist/ dir.
Extend vinext:og-inline-fetch-assets with a second regex pattern that
matches readFileSync(fileURLToPath(new URL(...))) and replaces it with
Buffer.from('<base64>', 'base64'), inlining both noto-sans font and
resvg.wasm at transform time. This eliminates the ENOENT errors seen
in the production build CI tests.
* refactor(og): address code review feedback
- Remove Fix 1 (font inlining) from vinext:og-font-patch — it was dead
code since vinext:og-inline-fetch-assets (earlier in the plugin array)
already handles the same fetch(new URL(...)) pattern generically. Only
Fix 2 (yoga WASM extraction) remains in og-font-patch.
- Use split().join() instead of String.replace() in both Pattern 1 and
Pattern 2 loops of og-inline-fetch-assets. String.replace(string, ...)
only replaces the first occurrence; split().join() replaces all of them,
which matters for libraries that may use the same pattern multiple times.
* refactor(og): use replaceAll instead of split().join()
* feat: support experimental.serverActions.bodySizeLimit from next.config
* fix: address PR review feedback for parseBodySizeLimit
- Accept bare number strings (e.g. "1048576") as bytes, matching Next.js bytes library behavior
- Add tb and pb unit support
- Throw on zero/negative values to match Next.js validation behavior
- Remove unreachable type guard (value is always string at that point)
- Update tests: add tb/pb/bare-number/validation cases, fix "10tb" assertion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: warn on invalid bodySizeLimit string instead of silently falling back
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Projects that lack "type":"module" in package.json caused Vite to bundle
vite.config.ts through esbuild's CJS path, producing a .vite-temp/*.mjs file
that contains a CJS require() for @cloudflare/vite-plugin. On Node 22 in CI
(GitHub Actions ubuntu-latest), require()-ing an .mjs file is not supported
and the build fails with:
Error: Dynamic require of "…/@cloudflare/vite-plugin/dist/index.mjs" is
not supported
Fix: call ensureESModule(root) + renameCJSConfigs(root) in both the dev() and
buildApp() CLI commands, before Vite is loaded. This mirrors what vinext init
already does, but applies lazily for projects that were set up manually or
before the init step was introduced.
- renameCJSConfigs renames postcss.config.js / tailwind.config.js / .eslintrc.js
etc. to .cjs first, to avoid breakage in those files after ESM mode is enabled
- ensureESModule adds "type":"module" to package.json
- Both are no-ops when the project is already correctly configured
- A warning is emitted when a change is made, pointing users to vinext init
- 9 new unit tests cover the full scenario including multi-file rename
Fixes#184
When a server component, client SSR, or generateMetadata() throws with no
local error.tsx boundary, global-error.tsx renders as the last resort. It
provides its own <html> and <body> (replacing the root layout), matching
Next.js behavior.
Previously, global-error was wrapped inside the root layout in both paths:
1. In-tree ErrorBoundary in buildPageElement — the root layout's <html>
was already serialized in the RSC stream before the ErrorBoundary
activated during SSR, producing double <html>/<body> tags.
2. renderErrorBoundaryPage — explicitly wrapped the error component with
layouts regardless of whether it was a local error.tsx or global-error.
Fix:
- Remove global ErrorBoundary from buildPageElement (it can't work for
SSR because the RSC stream already contains the layout output).
- Add it conditionally for RSC requests only (client-side navigation
needs it for error recovery after hydration).
- In renderErrorBoundaryPage, track whether the resolved component is
global-error and skip layout wrapping when it is.
Strengthen test assertions to match Next.js behavior:
- Assert HTTP 200 (error boundary "handles" the error)
- Assert global-error.tsx content renders (data-testid, error message)
- Assert exactly one <html> and one <body> tag per response
* fix: nav context hydration mismatch
* fix: address bonk review — add __VINEXT_RSC_NAV__ type, serialise searchParams as entry pairs
- Add __VINEXT_RSC_NAV__ global type declaration to global.d.ts with
searchParams typed as [string, string][] (array of entry pairs)
- Change generateSsrEntry() to serialise searchParams via
[...navContext.searchParams.entries()] instead of Object.fromEntries(),
preserving duplicate keys (e.g. ?tag=a&tag=b)
- Update tests to assert the new array-of-pairs shape; new URLSearchParams()
on the client correctly handles both single and duplicate keys
* fix(app-router): don't clear RSC context before lazy stream is consumed
* fix(app-router): remove premature context clears from server action and intercepting route RSC branches
Same bug as fixed for renderHTTPAccessFallbackPage/renderErrorBoundaryPage: context
was cleared immediately after renderToReadableStream() returned, before the stream
was lazily consumed by the client. Async server components running during stream
consumption would see null context and fail.
Two additional instances fixed:
- Server action RSC re-render branch (after action.apply())
- Intercepting route RSC response branch
The getAndClearPendingCookies() / getDraftModeCookieHeader() calls in the server action
branch are intentionally preserved — they read synchronously from context set during
action execution (not during stream rendering) and must run before the response is sent.
Context cleanup is handled naturally by the AsyncLocalStorage scope from
runWithHeadersContext() unwinding after all async continuations complete.
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.
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
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(())
* 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>