Commit Graph

8 Commits

Author SHA1 Message Date
James Anderson bacee9ef52 fix(pages): emit canonical __NEXT_DATA__ JSON (#2043)
* fix(pages): emit canonical next data script

* test(pages): expect canonical next data markup

* fix(pages): initialize canonical next data before routing

* test(pages): parse canonical next data markup

* fix(pages): preserve router bootstrap ordering

* test(pages): expect readiness bootstrap import

* fix(pages): guard readiness bootstrap without next data
2026-06-15 23:16:23 +01:00
Nathan Nguyen 0046e0691a fix(app-router): preserve encoded prerender params (#1597)
* fix(app-router): preserve encoded prerender params

App Router prerendered pages with encoded dynamic params could be bypassed in production and rendered again with decoded route params. This diverged from Next.js for generateStaticParams values like "sticks & stones".

The root cause was split across build and startup. The prerender render path only had the encoded URL, so normal route matching decoded the page params. After preserving encoded params for the render, production startup still seeded the ISR cache under the encoded artifact path while live requests lookup cache entries by the runtime-normalized pathname.

Carry trusted prerender params through the prerender-only request path, keep decoded params for dynamicParams validation, and seed prerendered App Router artifacts under the normalized runtime cache key while reading files from their encoded output path.

* chore(app-router): clean up prerender header internals

Reuse the shared record guard for prerender route param parsing and remove the local duplicate.

Keep Next.js INTERNAL_HEADERS as the exact upstream list, with vinext-only internal headers tracked separately but stripped by the same request pipeline. This fixes the CI unit failure while preserving the prerender header trust boundary.

Factor repeated Node-header conversion and forwarded-protocol handling in the production server to avoid local assertions and duplicate loops.

* fix(app-router): scope prerender params to route pattern

Bind the internal prerender route-param header to the route pattern that produced it so encoded render params cannot cross a rewrite boundary into a different dynamic route shape.

The App Router handler now only applies encoded prerender params when the payload routePattern matches the final matched route. Otherwise it falls back to the decoded params from normal route matching.

Tighten the header parser to validate the payload shape with Object.entries/Object.keys and add a regression for a prerendered source route rewritten to a dynamic target route with a different param name.

* fix(app-router): verify prerender params match route

Require trusted prerender route params to prove both the final route pattern and the final decoded matched params before using their encoded render values.

This prevents same-pattern rewrites from carrying stale prerender params into a different concrete route match, while keeping valid prerender renders encoded and static validation decoded.

Add handler regression coverage for same-pattern rewrites and helper coverage for catch-all array comparison.

* docs(app-router): clarify prerender route-param trust boundary

The prerender route-param readers and serializer carried implicit
coupling that read as security verification but actually delegated it.
A reader new to readTrustedPrerenderRouteParams could assume it verifies
the prerender secret, when verification happens once upstream at
prod-server's nodeToWebRequest boundary and every downstream read trusts
that. The double read-then-reattach in createAppRscHandler also depends
on the secret header surviving filterInternalHeaders, with no note of
that coupling.

Document the trust contract: secret is verified at the outer boundary,
downstream reads operate on already-trusted requests gated by
VINEXT_PRERENDER=1, and the secret header must stay out of the internal
strip list for the second read to succeed. Also document the
empty-params to null serialization contract.

No behavior change; comments only.
2026-05-27 10:55:26 +01:00
한만욱 a66004c71e fix: export static App Router metadata files (#1590)
* fix: export static App Router metadata files

* fix: address static metadata export review

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-05-26 15:18:04 +01:00
James Anderson 75eb3c1e8d feat: production prerender pipeline (#553)
* feat: production prerender pipeline (no dev server)

Add prerenderPages() and prerenderApp() that load from production bundles
exclusively — no ViteDevServer dependency. runPrerender() replaces
runPrerenderWithDevServer() (kept as a shim for cli.ts/deploy.ts callers).

Key changes:
- packages/vinext/src/build/prerender.ts — new: prerenderPages/prerenderApp
- packages/vinext/src/build/run-prerender.ts — new: runPrerender + shim
- packages/vinext/src/build/static-export.ts — delegates to prerender layer
- packages/vinext/src/index.ts — adds rscOutDir/ssrOutDir/clientOutDir/disableAppRouter options
- packages/vinext/src/entries/app-rsc-entry.ts — exports generateStaticParamsMap
- packages/vinext/src/entries/pages-server-entry.ts — exports pageRoutes
- packages/vinext/src/shims/cache.ts — adds NoOpCacheHandler
- packages/vinext/src/server/prod-server.ts — serves prerendered .html files
- packages/vinext/src/build/report.ts — integrates PrerenderResult for build report
- tests/helpers.ts — buildPagesFixture/buildAppFixture helpers (isolated outDirs)
- tests/prerender.test.ts — 36 new tests
- tests/static-export.test.ts/app-router.test.ts/pages-router.test.ts — updated

All 487 tests pass. Typecheck, lint, fmt clean.

* fix: build Pages Router SSR bundle for hybrid app+pages projects

For hybrid projects that have both app/ and pages/ directories, the
App Router multi-env build (createBuilder/buildApp) does not produce
a Pages Router SSR bundle. The prerender phase then fails with 'bundle
not found at dist/server/entry.js'.

Fix: after buildApp() completes, detect pages/ presence and run a
separate standalone Vite SSR build with vinext({ disableAppRouter: true })
so the plugin's multi-env environments config does not override the SSR
input/entryFileNames. The build is run with emptyOutDir: false to preserve
RSC artefacts from the App Router build.

* Clean up run-prerender: remove deprecated shim, fix progress arithmetic and typos

* regen snaps

* refactor: clean up prerender pipeline, cache shim, and report utilities

- Extract loadBundle() helper used by both prerenderPages and prerenderApp
- Save/restore CacheHandler around prerender calls (isolation fix)
- Rename isExplicitlyDynamic -> isConfiguredDynamic for clarity
- Export getRscOutputPath and findDir for cross-module reuse
- Import findDir in run-prerender.ts; remove duplicated inline logic
- Fix single-row table corner character in formatBuildReport
- Remove duplicate JSDoc block before revalidateTag in cache shim
- Add SetCtx interface; eliminate (ctx as any) casts in MemoryCacheHandler
- Replace string sentinel in unstable_cache with structural CacheResultWrapper
- Use completedUrls += 1 consistently in Pages Router phase

* fix: remove unused appDir from PrerenderAppOptions and all callers

prerenderApp() never reads appDir — the bundle is loaded via rscBundlePath
and route scanning is done before the call. Remove the field from
PrerenderAppOptions and AppStaticExportOptions, and drop the now-unused
argument from all callers in run-prerender.ts, static-export.ts, and tests.

* refactor: miscellaneous cleanups across prerender, cache, cli, and deploy

* refactor: document gaps, re-detect project after installDeps, fix Vite resolution in deploy.ts

* refactor(prerender): unify onProgress to single call site; document pageCount semantics

Extract renderUrl() inner function from prerenderApp's concurrency loop so
that onProgress is called exactly once per URL at the outer loop level, rather
than being duplicated inside two early-return branches. Eliminates the risk
of accidentally omitting the callback when adding future early exits.

Add a comment in toStaticExportResult clarifying when pageCount and
files.length can diverge (currently they stay in sync).

* build(prerender): write non-export prerender output to dist/server/prerendered-routes/

On Cloudflare Workers, wrangler.jsonc uses not_found_handling: "none" so
every request hits the worker first. Files in dist/client/ are never
auto-served for page requests — they are uploaded but remain inert.
Writing prerendered HTML/RSC to dist/server/prerendered-routes/ keeps
them co-located with server artifacts and away from the static assets
directory.

This also prevents a future issue: when KV pre-population is implemented,
ISR route files must not be in dist/client/ or they would be served as
stale static files forever (bypassing revalidation).

output: 'export' builds are unaffected — static-export.ts passes its
own outDir explicitly, and runPrerender still writes to dist/client/
when mode === 'export'.

* .

* build: suppress IMPORT_IS_UNDEFINED warnings for generateStaticParams

Dynamic route pages that don't export generateStaticParams produce noisy
IMPORT_IS_UNDEFINED warnings because the virtual RSC entry unconditionally
emits mod?.generateStaticParams for every dynamic route. The optional
chaining guards the access safely at runtime; suppress the build-time noise
in the existing onwarn handler alongside the MODULE_LEVEL_DIRECTIVE filter.

* .

* .

* .

* fix conflict regression

* regen snaps

* update cli comment

* add todo for rsc double req

* encode uri components

* encode uri components

* fix: inherit user plugins in hybrid Pages Router secondary build

The secondary `vinext build` step for hybrid (App Router + Pages Router)
projects was constructed with `configFile: false` and only `vinext({ disableAppRouter: true })`,
dropping all user-supplied plugins including `cloudflare()`. This caused a
false-positive "Missing @cloudflare/vite-plugin" error on any hybrid project
with a `wrangler.jsonc` present.

Fix by inheriting the resolved plugin list from the already-completed App
Router builder, filtering out the `vinext:*` sub-plugins and re-injecting
`vinext({ disableAppRouter: true })` in their place. `configFile: false` is
retained to prevent the user's `environments` block from overriding the SSR
input and `entryFileNames`.

* fix: filter vite:react and vite-tsconfig-paths from inherited hybrid build plugins

vinext auto-registers both @vitejs/plugin-react (vite:react*) and
vite-tsconfig-paths when disableAppRouter is false, so inheriting the
resolved plugin list from the App Router builder caused duplicates.
Filter them out alongside the vinext:* sub-plugins.

* fix: filter rsc:* and vite-rsc-* plugins from inherited hybrid build plugins

@vitejs/plugin-rsc registers resolveId for App Router virtual modules
(virtual:vinext-app-ssr-entry etc.) but the corresponding load hooks live
in vinext:* — which we already strip. With rsc:* present but vinext:* absent
the virtual module resolves but can't load, crashing with PLUGIN_ERROR.
Filter rsc:* and vite-rsc-load-module-dev-proxy alongside the other
auto-registered plugin families.

* fix: skip missing-Cloudflare-plugin guard for hybrid Pages Router secondary build

The secondary build uses configFile:false + vinext({disableAppRouter:true})
so it never loads cloudflare() — by design, it's a plain SSR Rollup bundle
with no Workers config. The configResolved guard was incorrectly treating
this as a misconfigured user build.

Revert the inherited-plugins approach (cloudflare() reconstructs the full
multi-env environments block from its own plugin config hook, independent
of vite.config, causing the App SSR virtual entry to be resolved but not
loadable). Instead, gate the guard on !options.disableAppRouter, which is
exclusively set by this internal invocation.

* fix: inherit user transform plugins in hybrid Pages Router secondary build

The secondary SSR build for hybrid projects (app/ + pages/) was introduced
on this branch and always used configFile:false with only
vinext({disableAppRouter:true}), silently dropping any user transform
plugins from vite.config.ts (SVG loaders, CSS-in-JS, etc.).

Fix by loading the raw user config via loadConfigFromFile (before any
plugin config() hooks fire, so cloudflare() hasn't yet injected its
multi-env environments block) and forwarding all plugins except the
families that vinext auto-registers or that would break the plain SSR
build: vinext:*, vite:react*, rsc:*, vite-tsconfig-paths,
vite-rsc-load-module-dev-proxy, and vite-plugin-cloudflare*.

* fix: reset ANSI styling before prerender and route report output

Vite's logger leaves the terminal in a styled state after build output.
Write an ANSI reset before the prerender label and route report so they
always print at full brightness regardless of preceding Vite output.

* fix: skip instrumentation register() during prerender to prevent process hang

Instrumentation modules like @vercel/otel register OpenTelemetry SDK
exporters with background timers that keep the Node process alive
indefinitely. During prerender these side-effects cause the process to
hang after rendering completes, preventing the route report from printing.

Set VINEXT_PRERENDER=1 in prerenderApp/prerenderPages and gate
__ensureInstrumentation() on that env var so instrumentation is skipped
during prerender builds.

* feat: production prerender pipeline for Cloudflare Workers (App Router + Pages Router)

- Add wrangler devDep to packages/vinext; add `@cloudflare/workers-types` to catalog
- Fix prerender.ts: typed wrangler import, Unstable_DevWorker, undici Response casts,
  URL-string extraction for dev.fetch() (no Request object), staticParamsMap Proxy
- Remove process.env.VINEXT_PRERENDER gate from /__vinext/prerender/static-params endpoint
- Add cf-app-basic pages/ fixture (index, about, posts/[slug], api/ping)
- Update buildCloudflareAppFixture to also run buildPagesFixture for Pages Router bundle
- Add CF prerender tests: shared beforeAll, nested App Router + Pages Router describes (46/46)

* .

* feat: CF Workers hybrid build Pages Router prerender support

* regen snaps

* fix(prerender): detect CF Workers build via @cloudflare/vite-plugin in node_modules

Replace wrangler.json file-presence check with node_modules detection,
consistent with how deploy.ts detects CF projects. The old check looked
in dist/server/ which never has a wrangler.json, so isWorkersBuild was
always false for CF projects, causing only the 404 to be prerendered.

The generated dist/server/wrangler.json (from @cloudflare/vite-plugin)
is now used as the config path for unstable_dev, with the project-root
wrangler.jsonc as a fallback. This ensures assets.directory is present,
which wrangler 4+ requires.

* fix(prerender): address bonk review comments

- runWithConcurrency: early return on empty items instead of spawning a
  spurious worker via the '|| 1' fallback
- staticParamsMap Proxy: flip has() trap to return false so the typeof-fn
  check works for routes without generateStaticParams on CF Workers builds;
  also handle null return from the proxy fn (no generateStaticParams) in
  the parent-params expansion path
- loadWrangler(): extract shared helper with a two-candidate fallback
  (wrangler-dist/cli.js → index.js) used by both prerenderApp and
  runPrerender, replacing duplicated path resolution + existence checks
- VINEXT_PRERENDER process.env mutation: add explanatory comments
  documenting why the global mutation is intentional and safe for the
  sequential-call contract

* refactor(prerender): extract findWranglerConfig, pass CF detection through, cache static-params

- Extract findWranglerConfig(serverDir, projectRoot) helper from both prerender.ts and
  run-prerender.ts, eliminating the duplicated 4-candidate wrangler.json search
- Add isWorkersBuild and wranglerConfigPath optional fields to PrerenderAppOptions so
  runPrerender can pass its already-computed values into prerenderApp, avoiding a
  redundant findInNodeModules walk + 4-candidate fs.existsSync loop on every build
- Add a per-build staticParamsCache (Map keyed on pattern+parentParams) inside the CF
  Proxy to dedup repeated /__vinext/prerender/static-params round-trips for deeply
  nested dynamic routes
- Add clarifying comment on renderUrl's runWithHeadersContext wrapper explaining it is
  a no-op for the CF Workers path (rscHandler is an HTTP proxy; ALS context never
  crosses the isolate boundary) but kept for shape-compatibility across both modes

* fix(prerender): address remaining bonk review comments

- Security: gate /__vinext/prerender/* endpoints behind VINEXT_PRERENDER=1 check
  to prevent exposure in normal deployments (process.env works for both Node and
  CF Workers via Miniflare's var injection into process.env)
- Bug: buildUrlFromParams now throws a clear error when a required param is
  missing instead of silently producing 'undefined' in the URL
- Design: add TODO comments for layout-level generateStaticParams limitation in
  both resolveParentParams() and generateStaticParamsMap
- Correctness: prerenderPages() now uses runtime module exports (getServerSideProps/
  getStaticProps) to classify page type on Node builds instead of static file
  analysis; CF builds continue to use classifyPagesRoute() as fallback

* fix(prerender): address latest bonk review comments

- Add shape validation for parentParams on the /__vinext/prerender/static-params
  endpoint: JSON.parse result is guarded to ensure user generateStaticParams always
  receives a plain object, never a primitive, array, or null
- Add .gitignore for tests/fixtures/cf-app-basic/dist/ to prevent accidental commits
  of build output if test cleanup fails (CF fixture builds to source tree, unlike
  other fixtures that use tmpdirs)
- Add comment on nextConfigOverride shallow merge in run-prerender.ts to make the
  limitation explicit for future maintainers

* regen snaps

* fix: build CF fixture in tmpdir instead of source tree

Use createIsolatedFixture in buildCloudflareAppFixture so the CF Vite
build output goes to a tmpdir rather than tests/fixtures/cf-app-basic/dist/.
Adds an optional nodeModulesDir param to createIsolatedFixture so callers
with fixture-scoped deps (like @cloudflare/vite-plugin) can point the
symlink at the fixture's own node_modules instead of the workspace root.
Removes the stopgap .gitignore and the afterAll that deleted dist/ from
the source tree.

* fix: cache-bust prod-server import() to prevent stale module reuse in tests

startProdServer() used a bare file:// URL for its dynamic import() of the RSC
entry bundle. Node's module cache keyed on that URL, so when two test describe
blocks rebuild to the same output path the second invocation always got the
cached module from the first build. The stale module had __instrumentationInitialized
already set to true and globalThis.__VINEXT_onRequestErrorHandler__ pointing at the
first build's instrumentation instance, whose capturedErrors array lived in a
different module instance than the one the production route handler was reading.

Fix: append ?t=<mtime> to the import URL, matching the pattern used by prerender.ts
loadBundle(). Same mtime means same content (cache hit, no-op); new mtime means
a fresh build and gets a fresh module. Applied to both startAppRouterServer and
startPagesRouterServer.

Also removes debug console.log calls from the instrumentation production test.

* fix: use globalThis for instrumentation test state to survive sequential prod builds

In Vitest, the 'App Router Production build' and 'App Router Production
server' describes run in the same process. The first build's preview server
imports dist/server/index.js uncached, setting
globalThis.__VINEXT_onRequestErrorHandler__ to onRequestError_v1 from the
first module instance. The second build is loaded cache-busted (by mtime),
producing a fresh module instance (v2). After v2 sets the handler, v1's
__ensureInstrumentation can re-fire and overwrite it, causing errors from
v2 to be recorded in capturedErrors_v1 (a different array) while the GET
route reads capturedErrors_v2 (empty).

Fix: store capturedErrors and registerCalled on globalThis (same pattern
as the middleware counter) so all module instances write to and read from
the same shared state regardless of which build instance is active.

Also removes debug logging and restores the afterAll dist cleanup that was
commented out during investigation.

* add process.exit(0) at end of build
2026-03-16 19:18:37 +00:00
Stephen Zhou c17d6941be chore: migrate to vite plus (#535)
* chore: migrate to vite plus

* Disable typeAware and typeCheck

* Update CI

* Fix CI

* Fix test

* Clean

* Run test with vp

* Try revert

* react: false In test

* Fix test

* Revert "Try revert"

This reverts commit 009da10473.

* Update

* Update

* Try revert ci changes

* revert

* Run vp migrate

* Disable typeAware and typeCheck for now

* Better resolve for test

* Use vp dev instead of vite

* Update expect

* Fix NormalizeManifestModuleId

* Try increase timeout

* Update to use vp

* Try new check

* Bring back npx vp

* Migrate CI

* Make next-intl resolvable

* Update

* Update

* Update
2026-03-15 10:50:13 +00:00
James Anderson 764a496ce7 add oxfmt formatter (#380)
* add oxfmt formatter: config, scripts, CI, editor setup, docs

* rebuild lockfile

* fix: add Format to required checks list, remove dead ignore pattern

* run fmt

* add format to agents.md again
2026-03-09 14:56:14 +00:00
Nathan Nguyen d056e50467 fix(config): pass correct Next.js phase to function-form configs + instrumentation test coverage (#273)
* 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.
2026-03-06 16:31:16 +00:00
Steve Faulkner 12fea722b6 Initial public release of vinext 2026-02-24 09:29:39 -06:00