From 7d4572c2192d711e117771885da3c6d2b9ef623f Mon Sep 17 00:00:00 2001
From: Steve Faulkner
Date: Mon, 27 Apr 2026 14:26:16 -0500
Subject: [PATCH 01/18] Add first-pass Pages Router deploy suite parity
---
.../nextjs-pages-router-deploy-suite.yml | 102 ++
.gitignore | 1 +
packages/vinext/package.json | 2 +
packages/vinext/src/build/prerender.ts | 37 +-
packages/vinext/src/cli.ts | 11 +-
.../vinext/src/client/validate-module-path.ts | 15 +-
packages/vinext/src/config/config-matchers.ts | 115 +-
packages/vinext/src/config/next-config.ts | 101 +-
packages/vinext/src/deploy.ts | 115 +-
packages/vinext/src/entries/app-rsc-entry.ts | 83 +-
.../vinext/src/entries/pages-client-entry.ts | 38 +-
.../vinext/src/entries/pages-server-entry.ts | 625 ++++++-
packages/vinext/src/global.d.ts | 40 +-
packages/vinext/src/index.ts | 773 ++++++--
packages/vinext/src/plugins/css-data-url.ts | 63 +
.../vinext/src/plugins/edge-blob-assets.ts | 90 +
.../vinext/src/plugins/import-meta-url.ts | 153 ++
packages/vinext/src/plugins/og-assets.ts | 4 +-
packages/vinext/src/plugins/postcss.ts | 41 +-
.../src/plugins/strip-server-exports.ts | 78 +-
packages/vinext/src/plugins/styled-jsx.ts | 7 +
packages/vinext/src/plugins/swc-helpers.ts | 32 +
packages/vinext/src/plugins/wasm-module.ts | 87 +
packages/vinext/src/routing/pages-router.ts | 45 +-
.../vinext/src/server/app-browser-entry.ts | 184 +-
packages/vinext/src/server/app-page-render.ts | 16 +-
.../src/server/app-page-route-wiring.tsx | 26 +
.../vinext/src/server/app-router-entry.ts | 37 +-
packages/vinext/src/server/dev-server.ts | 6 +-
.../vinext/src/server/edge-runtime-globals.ts | 12 +
packages/vinext/src/server/middleware.ts | 30 +-
.../vinext/src/server/next-static-compat.ts | 51 +
packages/vinext/src/server/pages-api-route.ts | 103 +-
packages/vinext/src/server/pages-i18n.ts | 7 +-
.../vinext/src/server/pages-node-compat.ts | 181 +-
packages/vinext/src/server/pages-page-data.ts | 260 ++-
.../vinext/src/server/pages-page-response.ts | 333 +++-
packages/vinext/src/server/prod-server.ts | 685 ++++++-
.../vinext/src/server/request-pipeline.ts | 12 +
packages/vinext/src/server/trace-metadata.ts | 134 ++
packages/vinext/src/shims/app.ts | 26 +-
packages/vinext/src/shims/document.tsx | 66 +-
packages/vinext/src/shims/form.tsx | 193 +-
packages/vinext/src/shims/head.ts | 11 +-
packages/vinext/src/shims/link.tsx | 549 +++++-
packages/vinext/src/shims/navigation.ts | 13 +
packages/vinext/src/shims/router.ts | 1630 +++++++++++++++--
packages/vinext/src/shims/server.ts | 65 +-
packages/vinext/src/shims/url-utils.ts | 42 +
packages/vinext/src/utils/project.ts | 23 +
packages/vinext/src/utils/query.ts | 3 +-
packages/vinext/src/utils/server-externals.ts | 190 ++
pnpm-lock.yaml | 14 +
pnpm-workspace.yaml | 1 +
scripts/nextjs-deploy-suite-cleanup.sh | 29 +
scripts/nextjs-deploy-suite-deploy.sh | 376 ++++
scripts/nextjs-deploy-suite-logs.sh | 14 +
.../nextjs-pages-router-deploy-manifest.mjs | 70 +
scripts/run-nextjs-deploy-suite.sh | 98 +
tests/app-browser-entry.test.ts | 33 +
tests/app-page-route-wiring.test.ts | 24 +
tests/app-router.test.ts | 37 +
tests/build-optimization.test.ts | 109 +-
tests/css-data-url.test.ts | 30 +
tests/deploy.test.ts | 17 +-
.../app-router/instrumentation-client.spec.ts | 14 +-
.../instrumentation-client.spec.ts | 14 +-
tests/edge-blob-assets.test.ts | 22 +
tests/features.test.ts | 33 +
tests/form.test.ts | 128 +-
tests/import-meta-url.test.ts | 45 +
tests/link.test.ts | 204 ++-
tests/middleware.test.ts | 97 +
tests/next-config.test.ts | 108 ++
tests/next-static-compat.test.ts | 59 +
tests/og-inline.test.ts | 19 +
tests/pages-api-route.test.ts | 286 ++-
tests/pages-i18n.test.ts | 21 +
tests/pages-page-data.test.ts | 382 +++-
tests/pages-page-response.test.ts | 276 ++-
tests/pages-prerender-paths.test.ts | 20 +
tests/pages-router.test.ts | 492 +++++
tests/postcss-resolve.test.ts | 6 +-
tests/query.test.ts | 10 +-
tests/request-pipeline.test.ts | 7 +
tests/router-scroll-restoration.test.ts | 216 +++
tests/routing.test.ts | 24 +-
tests/server-externals.test.ts | 74 +
tests/shims.test.ts | 810 +++++++-
tests/startup-cache.test.ts | 90 +
tests/strip-server-exports.test.ts | 35 +
tests/styled-jsx.test.ts | 29 +
tests/swc-helpers.test.ts | 54 +
tests/trace-metadata.test.ts | 61 +
tests/tsconfig-paths-vite8.test.ts | 49 +
tests/wasm-module.test.ts | 42 +
96 files changed, 11403 insertions(+), 722 deletions(-)
create mode 100644 .github/workflows/nextjs-pages-router-deploy-suite.yml
create mode 100644 packages/vinext/src/plugins/css-data-url.ts
create mode 100644 packages/vinext/src/plugins/edge-blob-assets.ts
create mode 100644 packages/vinext/src/plugins/import-meta-url.ts
create mode 100644 packages/vinext/src/plugins/styled-jsx.ts
create mode 100644 packages/vinext/src/plugins/swc-helpers.ts
create mode 100644 packages/vinext/src/plugins/wasm-module.ts
create mode 100644 packages/vinext/src/server/edge-runtime-globals.ts
create mode 100644 packages/vinext/src/server/next-static-compat.ts
create mode 100644 packages/vinext/src/server/trace-metadata.ts
create mode 100644 packages/vinext/src/utils/server-externals.ts
create mode 100755 scripts/nextjs-deploy-suite-cleanup.sh
create mode 100755 scripts/nextjs-deploy-suite-deploy.sh
create mode 100755 scripts/nextjs-deploy-suite-logs.sh
create mode 100644 scripts/nextjs-pages-router-deploy-manifest.mjs
create mode 100755 scripts/run-nextjs-deploy-suite.sh
create mode 100644 tests/css-data-url.test.ts
create mode 100644 tests/edge-blob-assets.test.ts
create mode 100644 tests/import-meta-url.test.ts
create mode 100644 tests/middleware.test.ts
create mode 100644 tests/next-static-compat.test.ts
create mode 100644 tests/pages-prerender-paths.test.ts
create mode 100644 tests/router-scroll-restoration.test.ts
create mode 100644 tests/server-externals.test.ts
create mode 100644 tests/strip-server-exports.test.ts
create mode 100644 tests/styled-jsx.test.ts
create mode 100644 tests/swc-helpers.test.ts
create mode 100644 tests/trace-metadata.test.ts
create mode 100644 tests/wasm-module.test.ts
diff --git a/.github/workflows/nextjs-pages-router-deploy-suite.yml b/.github/workflows/nextjs-pages-router-deploy-suite.yml
new file mode 100644
index 000000000..fa30789f2
--- /dev/null
+++ b/.github/workflows/nextjs-pages-router-deploy-suite.yml
@@ -0,0 +1,102 @@
+name: Next.js Pages Router Deploy Suite
+
+on:
+ # Manual-only by design. This is a first-pass Pages Router adapter parity suite
+ # and is intentionally not part of the required PR checks.
+ workflow_dispatch:
+ inputs:
+ next-ref:
+ description: Next.js ref to test against
+ required: true
+ default: canary
+ test-concurrency:
+ description: Per-shard Next.js test concurrency
+ required: true
+ default: "2"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nextjs-pages-router-deploy-suite-${{ github.ref }}-${{ inputs.next-ref }}
+ cancel-in-progress: false
+
+jobs:
+ pages-router-deploy-suite:
+ name: Pages Router deploy suite (${{ matrix.shard }}/16)
+ runs-on: ubuntu-latest
+ timeout-minutes: 75
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
+
+ steps:
+ - name: Checkout vinext
+ uses: actions/checkout@v6
+
+ - uses: ./.github/actions/setup
+ with:
+ node-version: "22"
+
+ - name: Checkout Next.js
+ uses: actions/checkout@v6
+ with:
+ repository: vercel/next.js
+ ref: ${{ inputs.next-ref }}
+ path: next.js
+
+ - name: Locate pnpm store
+ id: pnpm-store
+ run: echo "path=$(corepack pnpm store path --silent)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Next.js pnpm store
+ uses: actions/cache@v5
+ with:
+ path: ${{ steps.pnpm-store.outputs.path }}
+ key: nextjs-pages-router-pnpm-${{ runner.os }}-${{ hashFiles('next.js/pnpm-lock.yaml') }}
+ restore-keys: |
+ nextjs-pages-router-pnpm-${{ runner.os }}-
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: nextjs-pages-router-playwright-${{ runner.os }}-${{ hashFiles('next.js/pnpm-lock.yaml') }}
+ restore-keys: |
+ nextjs-pages-router-playwright-${{ runner.os }}-
+
+ - name: Build vinext
+ run: vp run vinext#build
+
+ - name: Prepare Next.js checkout
+ run: bash scripts/run-nextjs-deploy-suite.sh "$GITHUB_WORKSPACE/next.js"
+ env:
+ CI: true
+ VINEXT_BUILD: "0"
+ NEXTJS_PREPARE: "1"
+ NEXTJS_PREPARE_ONLY: "1"
+
+ - name: Generate Pages Router deploy manifest
+ run: node scripts/nextjs-pages-router-deploy-manifest.mjs "$GITHUB_WORKSPACE/next.js" "$RUNNER_TEMP/nextjs-pages-router-deploy-manifest.json"
+
+ - name: Run Pages Router deploy shard
+ run: bash scripts/run-nextjs-deploy-suite.sh "$GITHUB_WORKSPACE/next.js"
+ env:
+ CI: true
+ VINEXT_BUILD: "0"
+ NEXT_TEST_JOB: ${{ matrix.shard }}
+ NEXT_TEST_GROUP: ${{ matrix.shard }}/16
+ NEXT_TEST_CONCURRENCY: ${{ inputs.test-concurrency }}
+ NEXT_EXTERNAL_TESTS_FILTERS: ${{ runner.temp }}/nextjs-pages-router-deploy-manifest.json
+
+ - name: Upload deploy debug artifacts
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: nextjs-pages-router-debug-${{ matrix.shard }}
+ path: |
+ reports/nextjs-deploy-debug/
+ next.js/test/**/*.results.json
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index a215f73ae..fcf36114e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
coverage/
+reports/
# Worktrees
.worktrees/
diff --git a/packages/vinext/package.json b/packages/vinext/package.json
index af522a995..17909781e 100644
--- a/packages/vinext/package.json
+++ b/packages/vinext/package.json
@@ -62,7 +62,9 @@
"dependencies": {
"@unpic/react": "catalog:",
"@vercel/og": "catalog:",
+ "jiti": "catalog:",
"magic-string": "catalog:",
+ "urlpattern-polyfill": "^10.1.0",
"vite-plugin-commonjs": "catalog:",
"vite-tsconfig-paths": "catalog:"
},
diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts
index afe161b4c..6cce3edeb 100644
--- a/packages/vinext/src/build/prerender.ts
+++ b/packages/vinext/src/build/prerender.ts
@@ -231,6 +231,32 @@ function buildUrlFromParams(pattern: string, params: Record;
+ locale?: string;
+ };
+
+export function normalizePagesStaticPathEntry(
+ pattern: string,
+ pathEntry: PagesStaticPathEntry,
+): { urlPath: string; params: Record } {
+ if (typeof pathEntry === "string") {
+ const pathname = pathEntry.split("?")[0] || "/";
+ return {
+ urlPath: pathname.startsWith("/") ? pathname : `/${pathname}`,
+ params: {},
+ };
+ }
+
+ const params = pathEntry.params ?? {};
+ return {
+ urlPath: buildUrlFromParams(pattern, params),
+ params,
+ };
+}
+
/**
* Determine the HTML output file path for a URL.
* Respects trailingSlash config.
@@ -419,7 +445,7 @@ export async function prerenderPages({
params: Record;
module: {
getStaticPaths?: (opts: { locales: string[]; defaultLocale: string }) => Promise<{
- paths: Array<{ params: Record }>;
+ paths: PagesStaticPathEntry[];
fallback: unknown;
}>;
getStaticProps?: unknown;
@@ -458,7 +484,7 @@ export async function prerenderPages({
}
if (text === "null") return { paths: [], fallback: false };
return JSON.parse(text) as {
- paths: Array<{ params: Record }>;
+ paths: PagesStaticPathEntry[];
fallback: unknown;
};
}
@@ -539,10 +565,9 @@ export async function prerenderPages({
continue;
}
- const paths: Array<{ params: Record }> =
- pathsResult?.paths ?? [];
- for (const { params } of paths) {
- const urlPath = buildUrlFromParams(route.pattern, params);
+ const paths: PagesStaticPathEntry[] = pathsResult?.paths ?? [];
+ for (const pathEntry of paths) {
+ const { urlPath, params } = normalizePagesStaticPathEntry(route.pattern, pathEntry);
pagesToRender.push({ route, urlPath, params, revalidate });
}
} else {
diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts
index adbee0884..8f9293edd 100644
--- a/packages/vinext/src/cli.ts
+++ b/packages/vinext/src/cli.ts
@@ -21,7 +21,11 @@ import fs from "node:fs";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
-import { detectPackageManager, ensureViteConfigCompatibility } from "./utils/project.js";
+import {
+ detectPackageManager,
+ ensureViteConfigCompatibility,
+ execPackageManagerCommand,
+} from "./utils/project.js";
import { deploy as runDeploy, parseDeployArgs } from "./deploy.js";
import { runCheck, formatReport } from "./check.js";
import { init as runInit, getReactUpgradeDeps } from "./init.js";
@@ -407,7 +411,10 @@ async function buildApp() {
const installCmd = detectPackageManager(process.cwd()).replace(/ -D$/, "");
const [pm, ...pmArgs] = installCmd.split(" ");
console.log(" Upgrading React for RSC compatibility...");
- execFileSync(pm, [...pmArgs, ...reactUpgrade], { cwd: process.cwd(), stdio: "inherit" });
+ execPackageManagerCommand(pm, [...pmArgs, ...reactUpgrade], {
+ cwd: process.cwd(),
+ stdio: "inherit",
+ });
}
}
diff --git a/packages/vinext/src/client/validate-module-path.ts b/packages/vinext/src/client/validate-module-path.ts
index 43dc8f6bc..96d9cb0d4 100644
--- a/packages/vinext/src/client/validate-module-path.ts
+++ b/packages/vinext/src/client/validate-module-path.ts
@@ -19,7 +19,18 @@ export function isValidModulePath(p: unknown): p is string {
if (p.startsWith("//")) return false;
// Must not contain protocol (prevents importing from external URLs)
if (p.includes("://")) return false;
- // Must not traverse directories
- if (p.includes("..")) return false;
+ // Must not traverse directories. Check path segments instead of any ".."
+ // substring so valid catch-all route chunks like "__...slug__-hash.js" work.
+ const pathOnly = p.split(/[?#]/, 1)[0];
+ const segments = pathOnly.split(/[\\/]/);
+ for (const segment of segments) {
+ let decoded = segment;
+ try {
+ decoded = decodeURIComponent(segment);
+ } catch {
+ // Malformed escapes are not traversal segments by themselves.
+ }
+ if (segment === ".." || decoded.split(/[\\/]/).includes("..")) return false;
+ }
return true;
}
diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts
index 78fc08d58..f0df49e04 100644
--- a/packages/vinext/src/config/config-matchers.ts
+++ b/packages/vinext/src/config/config-matchers.ts
@@ -665,6 +665,7 @@ export function matchConfigPattern(
// where the param name is followed by a dot — the simple matcher would treat
// "slug.md" as the param name and match any single segment regardless of suffix.
if (
+ pattern.includes(":") ||
pattern.includes("(") ||
pattern.includes("\\") ||
/:[\w-]+[*+][^/]/.test(pattern) ||
@@ -713,7 +714,7 @@ export function matchConfigPattern(
regexStr += tok[0];
}
}
- const re = safeRegExp("^" + regexStr + "$");
+ const re = safeRegExp("^" + regexStr + "$", "i");
// Store null for rejected patterns so we don't re-run isSafeRegex.
compiled = re ? { re, paramNames } : null;
_compiledPatternCache.set(pattern, compiled);
@@ -740,7 +741,9 @@ export function matchConfigPattern(
const isPlus = catchAllMatch[2] === "+";
const prefixNoSlash = prefix.replace(/\/$/, "");
- if (!pathname.startsWith(prefixNoSlash)) return null;
+ const lowerPathname = pathname.toLowerCase();
+ const lowerPrefixNoSlash = prefixNoSlash.toLowerCase();
+ if (!lowerPathname.startsWith(lowerPrefixNoSlash)) return null;
const charAfter = pathname[prefixNoSlash.length];
if (charAfter !== undefined && charAfter !== "/") return null;
@@ -762,13 +765,38 @@ export function matchConfigPattern(
for (let i = 0; i < parts.length; i++) {
if (parts[i].startsWith(":")) {
params[parts[i].slice(1)] = pathParts[i];
- } else if (parts[i] !== pathParts[i]) {
+ } else if (parts[i].toLowerCase() !== pathParts[i].toLowerCase()) {
return null;
}
}
return params;
}
+function matchLocaleFalseConfigPattern(
+ pathname: string,
+ rule: { source: string; locale?: false },
+): Record | null {
+ const params = matchConfigPattern(pathname, rule.source);
+ if (params || rule.locale !== false) return params;
+
+ const leadingLocaleParam = /^\/:([\w-]+)(?=\/)/.exec(rule.source);
+ if (!leadingLocaleParam) return null;
+
+ const suffixPattern = rule.source.slice(leadingLocaleParam[0].length) || "/";
+ const suffixParams = matchConfigPattern(pathname, suffixPattern);
+ if (!suffixParams) return null;
+
+ return {
+ [leadingLocaleParam[1]]: "",
+ ...suffixParams,
+ };
+}
+
+function getLocaleFalseLeadingParamName(rule: { source: string; locale?: false }): string | null {
+ if (rule.locale !== false) return null;
+ return /^\/:([\w-]+)(?=\/)/.exec(rule.source)?.[1] ?? null;
+}
+
/**
* Apply redirect rules from next.config.js.
* Returns the redirect info if a redirect was matched, or null.
@@ -895,7 +923,7 @@ export function matchRedirect(
// the locale-static match wins. Stop scanning.
break;
}
- const params = matchConfigPattern(pathname, redirect.source);
+ const params = matchLocaleFalseConfigPattern(pathname, redirect);
if (params) {
const conditionParams =
redirect.has || redirect.missing
@@ -928,27 +956,98 @@ export function matchRewrite(
pathname: string,
rewrites: NextRewrite[],
ctx: RequestContext,
+ currentUrl?: string,
): string | null {
for (const rewrite of rewrites) {
- const params = matchConfigPattern(pathname, rewrite.source);
+ const params = matchLocaleFalseConfigPattern(pathname, rewrite);
if (params) {
const conditionParams =
rewrite.has || rewrite.missing
? collectConditionParams(rewrite.has, rewrite.missing, ctx)
: _emptyParams();
if (!conditionParams) continue;
- let dest = substituteDestinationParams(rewrite.destination, {
+ const rewriteParams = {
...params,
...conditionParams,
- });
+ };
+ let dest = substituteDestinationParams(rewrite.destination, rewriteParams);
+ const appendParams = { ...params };
+ const localeParamName = getLocaleFalseLeadingParamName(rewrite);
+ if (localeParamName) {
+ delete appendParams[localeParamName];
+ }
+ dest = appendUnusedRewriteParams(dest, rewrite.destination, appendParams);
// Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params).
dest = sanitizeDestination(dest);
+ if (currentUrl) {
+ dest = mergeRewriteSourceQuery(dest, currentUrl);
+ }
return dest;
}
}
return null;
}
+function mergeRewriteSourceQuery(destination: string, sourceUrl: string): string {
+ if (!sourceUrl.includes("?")) return destination;
+
+ const base = "http://vinext.local";
+ const source = new URL(sourceUrl, base);
+ if (source.searchParams.size === 0) return destination;
+
+ const isProtocolRelative = destination.startsWith("//");
+ const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(destination);
+ const target = new URL(isProtocolRelative ? `http:${destination}` : destination, base);
+
+ for (const key of new Set(source.searchParams.keys())) {
+ if (target.searchParams.has(key)) continue;
+ for (const value of source.searchParams.getAll(key)) {
+ target.searchParams.append(key, value);
+ }
+ }
+
+ if (isProtocolRelative) {
+ return `//${target.host}${target.pathname}${target.search}${target.hash}`;
+ }
+ if (isAbsolute) {
+ return target.toString();
+ }
+ return `${target.pathname}${target.search}${target.hash}`;
+}
+
+function appendUnusedRewriteParams(
+ destination: string,
+ originalDestination: string,
+ params: Record,
+): string {
+ const unusedParams = Object.entries(params).filter(
+ ([key]) =>
+ !new RegExp(`:${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([+*])?(?![A-Za-z0-9_])`).test(
+ originalDestination,
+ ),
+ );
+ if (unusedParams.length === 0) return destination;
+
+ const base = "http://vinext.local";
+ const isProtocolRelative = destination.startsWith("//");
+ const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(destination);
+ const target = new URL(isProtocolRelative ? `http:${destination}` : destination, base);
+
+ for (const [key, value] of unusedParams) {
+ if (!target.searchParams.has(key)) {
+ target.searchParams.append(key, value);
+ }
+ }
+
+ if (isProtocolRelative) {
+ return `//${target.host}${target.pathname}${target.search}${target.hash}`;
+ }
+ if (isAbsolute) {
+ return target.toString();
+ }
+ return `${target.pathname}${target.search}${target.hash}`;
+}
+
/**
* Substitute all matched route params into a redirect/rewrite destination.
*
@@ -1139,7 +1238,7 @@ export function matchHeaders(
let sourceRegex = _compiledHeaderSourceCache.get(rule.source);
if (sourceRegex === undefined) {
const escaped = escapeHeaderSource(rule.source);
- sourceRegex = safeRegExp("^" + escaped + "$");
+ sourceRegex = safeRegExp("^" + escaped + "$", "i");
_compiledHeaderSourceCache.set(rule.source, sourceRegex);
}
if (sourceRegex && sourceRegex.test(pathname)) {
diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts
index a2012a067..03dfe2019 100644
--- a/packages/vinext/src/config/next-config.ts
+++ b/packages/vinext/src/config/next-config.ts
@@ -72,6 +72,8 @@ export type NextRedirect = {
source: string;
destination: string;
permanent: boolean;
+ basePath?: false;
+ locale?: false;
has?: HasCondition[];
missing?: HasCondition[];
};
@@ -79,12 +81,16 @@ export type NextRedirect = {
export type NextRewrite = {
source: string;
destination: string;
+ basePath?: false;
+ locale?: false;
has?: HasCondition[];
missing?: HasCondition[];
};
export type NextHeader = {
source: string;
+ basePath?: false;
+ locale?: false;
has?: HasCondition[];
missing?: HasCondition[];
headers: Array<{ key: string; value: string }>;
@@ -127,6 +133,8 @@ export type NextConfig = {
env?: Record;
/** Base URL path prefix */
basePath?: string;
+ /** URL or path prefix for generated static assets */
+ assetPrefix?: string;
/** Whether to add trailing slashes */
trailingSlash?: boolean;
/** Internationalization routing config */
@@ -179,6 +187,10 @@ export type NextConfig = {
pageExtensions?: string[];
/** Extra origins allowed to access the dev server. */
allowedDevOrigins?: string[];
+ /** TypeScript configuration options. */
+ typescript?: {
+ tsconfigPath?: string;
+ };
/**
* Enable Cache Components (Next.js 16).
* When true, enables the "use cache" directive for pages, components, and functions.
@@ -195,6 +207,14 @@ export type NextConfig = {
serverExternalPackages?: string[];
/** Webpack config (ignored — we use Vite) */
webpack?: unknown;
+ /**
+ * Compiler-level constants replaced during compilation.
+ * Matches Next.js `compiler.define` and `compiler.defineServer`.
+ */
+ compiler?: {
+ define?: Record;
+ defineServer?: Record;
+ };
/**
* Custom build ID generator. If provided, called once at build/dev start.
* Must return a non-empty string, or null to use the default random ID.
@@ -217,6 +237,7 @@ export type NextConfigInput = NextConfig | NextConfigFactory;
export type ResolvedNextConfig = {
env: Record;
basePath: string;
+ assetPrefix: string;
trailingSlash: boolean;
output: "" | "export" | "standalone";
pageExtensions: string[];
@@ -230,6 +251,7 @@ export type ResolvedNextConfig = {
headers: NextHeader[];
images: NextConfig["images"];
i18n: NextI18nConfig | null;
+ crossOrigin: string | null;
/** MDX remark/rehype/recma plugins extracted from @next/mdx config */
mdx: MdxOptions | null;
/** Explicit module aliases preserved from wrapped next.config plugins. */
@@ -238,16 +260,26 @@ export type ResolvedNextConfig = {
allowedDevOrigins: string[];
/** Extra allowed origins for server action CSRF validation (from experimental.serverActions.allowedOrigins). */
serverActionsAllowedOrigins: string[];
+ /** User-specified TypeScript config path from `typescript.tsconfigPath`. */
+ typescriptTsconfigPath: string | null;
/** Packages whose barrel imports should be optimized (from experimental.optimizePackageImports). */
optimizePackageImports: string[];
/** Parsed body size limit for server actions in bytes (from experimental.serverActions.bodySizeLimit). Defaults to 1MB. */
serverActionsBodySizeLimit: number;
+ /** Whether experimental.scrollRestoration is enabled. */
+ experimentalScrollRestoration: boolean;
+ /** Allowed OpenTelemetry propagation keys to expose as client trace metadata. */
+ clientTraceMetadata: string[] | undefined;
/**
* Packages that should be treated as server-external (not bundled by Vite).
* Sourced from `serverExternalPackages` or the legacy
* `experimental.serverComponentsExternalPackages` in next.config.
*/
serverExternalPackages: string[];
+ compiler: {
+ define: Record;
+ defineServer: Record;
+ };
/** Resolved build ID (from generateBuildId, or a random UUID if not provided). */
buildId: string;
};
@@ -372,7 +404,9 @@ export async function loadNextConfig(
logLevel: "error",
clearScreen: false,
});
- return await unwrapConfig(mod, phase);
+ const config = await unwrapConfig(mod, phase);
+ emitDeprecatedConfigWarnings(config);
+ return config;
} catch (e) {
// If the error indicates a CJS file loaded in ESM context, retry with
// createRequire which provides a proper CommonJS environment.
@@ -380,7 +414,9 @@ export async function loadNextConfig(
try {
const require = createRequire(path.join(root, "package.json"));
const mod = require(configPath);
- return await unwrapConfig(mod, phase);
+ const config = await unwrapConfig(mod, phase);
+ emitDeprecatedConfigWarnings(config);
+ return config;
} catch (e2) {
warnConfigLoadFailure(filename, e2 as Error);
throw e2;
@@ -392,6 +428,40 @@ export async function loadNextConfig(
}
}
+export function emitDeprecatedConfigWarnings(config: NextConfig): void {
+ const experimental = config.experimental as Record | undefined;
+
+ if (experimental && Object.hasOwn(experimental, "instrumentationHook")) {
+ console.warn(
+ "[vinext] next.config experimental.instrumentationHook is no longer needed and can be removed.",
+ );
+ }
+
+ if (experimental && Object.hasOwn(experimental, "middlewarePrefetch")) {
+ console.warn(
+ "[vinext] next.config experimental.middlewarePrefetch has been renamed. Please use `experimental.proxyPrefetch` instead.",
+ );
+ }
+
+ if (experimental && Object.hasOwn(experimental, "middlewareClientMaxBodySize")) {
+ console.warn(
+ "[vinext] next.config experimental.middlewareClientMaxBodySize has been renamed. Please use `experimental.proxyClientMaxBodySize` instead.",
+ );
+ }
+
+ if (experimental && Object.hasOwn(experimental, "externalMiddlewareRewritesResolve")) {
+ console.warn(
+ "[vinext] next.config experimental.externalMiddlewareRewritesResolve has been renamed. Please use `experimental.externalProxyRewritesResolve` instead.",
+ );
+ }
+
+ if (Object.hasOwn(config, "skipMiddlewareUrlNormalize")) {
+ console.warn(
+ "[vinext] next.config skipMiddlewareUrlNormalize has been renamed. Please use `skipProxyUrlNormalize` instead.",
+ );
+ }
+}
+
/**
* Generate a UUID that doesn't contain "ad" to avoid false-positive ad-blocker hits.
* Mirrors Next.js's own nanoid retry loop.
@@ -447,6 +517,7 @@ export async function resolveNextConfig(
const resolved: ResolvedNextConfig = {
env: {},
basePath: "",
+ assetPrefix: "",
trailingSlash: false,
output: "",
pageExtensions: normalizePageExtensions(),
@@ -456,13 +527,21 @@ export async function resolveNextConfig(
headers: [],
images: undefined,
i18n: null,
+ crossOrigin: null,
mdx: null,
aliases: {},
allowedDevOrigins: [],
serverActionsAllowedOrigins: [],
+ typescriptTsconfigPath: null,
optimizePackageImports: [],
serverActionsBodySizeLimit: 1 * 1024 * 1024,
+ experimentalScrollRestoration: false,
+ clientTraceMetadata: undefined,
serverExternalPackages: [],
+ compiler: {
+ define: {},
+ defineServer: {},
+ },
buildId,
};
detectNextIntlConfig(root, resolved);
@@ -531,6 +610,8 @@ export async function resolveNextConfig(
};
const allowedDevOrigins = Array.isArray(config.allowedDevOrigins) ? config.allowedDevOrigins : [];
+ const typescriptTsconfigPath =
+ typeof config.typescript?.tsconfigPath === "string" ? config.typescript.tsconfigPath : null;
// Resolve serverActions.allowedOrigins and bodySizeLimit from experimental config
const experimental = config.experimental as Record | undefined;
@@ -541,6 +622,10 @@ export async function resolveNextConfig(
const serverActionsBodySizeLimit = parseBodySizeLimit(
serverActionsConfig?.bodySizeLimit as string | number | undefined,
);
+ const experimentalScrollRestoration = experimental?.scrollRestoration === true;
+ const clientTraceMetadata = Array.isArray(experimental?.clientTraceMetadata)
+ ? experimental.clientTraceMetadata.filter((value): value is string => typeof value === "string")
+ : undefined;
// Resolve optimizePackageImports from experimental config
const rawOptimize = experimental?.optimizePackageImports;
@@ -598,6 +683,7 @@ export async function resolveNextConfig(
const resolved: ResolvedNextConfig = {
env: config.env ?? {},
basePath: config.basePath ?? "",
+ assetPrefix: typeof config.assetPrefix === "string" ? config.assetPrefix : "",
trailingSlash: config.trailingSlash ?? false,
output: output === "export" || output === "standalone" ? output : "",
pageExtensions,
@@ -607,13 +693,21 @@ export async function resolveNextConfig(
headers,
images: config.images,
i18n,
+ crossOrigin: typeof config.crossOrigin === "string" ? config.crossOrigin : null,
mdx,
aliases,
allowedDevOrigins,
serverActionsAllowedOrigins,
+ typescriptTsconfigPath,
optimizePackageImports,
serverActionsBodySizeLimit,
+ experimentalScrollRestoration,
+ clientTraceMetadata,
serverExternalPackages,
+ compiler: {
+ define: config.compiler?.define ?? {},
+ defineServer: config.compiler?.defineServer ?? {},
+ },
buildId,
};
@@ -633,7 +727,8 @@ function normalizeAliasEntries(
const normalized: Record = {};
for (const [key, value] of Object.entries(aliases)) {
if (typeof value !== "string") continue;
- normalized[key] = path.isAbsolute(value) ? value : path.resolve(root, value);
+ normalized[key] =
+ path.isAbsolute(value) || value.startsWith(".") ? path.resolve(root, value) : value;
}
return normalized;
}
diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts
index d29827f3a..09ecf3514 100644
--- a/packages/vinext/src/deploy.ts
+++ b/packages/vinext/src/deploy.ts
@@ -24,6 +24,7 @@ import {
ensureESModule as _ensureESModule,
renameCJSConfigs as _renameCJSConfigs,
detectPackageManager as _detectPackageManager,
+ execPackageManagerCommand,
findInNodeModules as _findInNodeModules,
} from "./utils/project.js";
import { getReactUpgradeDeps } from "./init.js";
@@ -544,6 +545,7 @@ interface ExecutionContext {
// Extract config values (embedded at build time in the server entry)
const basePath: string = vinextConfig?.basePath ?? "";
+const assetPrefix: string = vinextConfig?.assetPrefix ?? "";
const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false;
const configRedirects = vinextConfig?.redirects ?? [];
const configRewrites = vinextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
@@ -564,6 +566,35 @@ function stripBasePath(pathname: string, basePath: string): string {
return pathname.slice(basePath.length) || "/";
}
+function isNextStaticAssetPath(pathname: string): boolean {
+ return pathname === "/_next/static" || pathname.startsWith("/_next/static/");
+}
+
+function normalizeAssetPrefixPath(assetPrefix?: string | null): string {
+ if (!assetPrefix) return "";
+
+ let pathname = assetPrefix;
+ try {
+ pathname = new URL(assetPrefix).pathname;
+ } catch {
+ // Path-style asset prefixes are already pathnames.
+ }
+
+ if (!pathname.startsWith("/")) return "";
+ return pathname.replace(//+$/, "");
+}
+
+function getNextStaticAssetLookupPath(pathname: string, assetPrefix?: string | null): string {
+ if (isNextStaticAssetPath(pathname)) return pathname;
+
+ const prefixPath = normalizeAssetPrefixPath(assetPrefix);
+ if (!prefixPath || prefixPath === "/") return pathname;
+ if (pathname !== prefixPath && !pathname.startsWith(prefixPath + "/")) return pathname;
+
+ const stripped = pathname.slice(prefixPath.length) || "/";
+ return isNextStaticAssetPath(stripped) ? stripped : pathname;
+}
+
// Mirror of isOpenRedirectShaped in server/request-pipeline.ts. Inlined here
// because this worker runs in the Cloudflare Workers environment and can't
// import from our local source at build time. Keep in sync.
@@ -584,6 +615,7 @@ export default {
const url = new URL(request.url);
let pathname = url.pathname;
let urlWithQuery = pathname + url.search;
+ const requestHadBasePath = !basePath || hasBasePath(pathname, basePath);
// Block protocol-relative URL open redirects in all shapes:
// literal //evil.com, /\\\\evil.com
@@ -617,18 +649,60 @@ export default {
}, allowedWidths, imageConfig);
}
+ // Vite build output is emitted under /assets/. When basePath is configured,
+ // HTML points at //assets/*; after stripping basePath above, serve
+ // the normalized /assets/* file directly before middleware.
+ if (pathname.startsWith("/assets/")) {
+ const assetResponse = await env.ASSETS.fetch(
+ new Request(new URL(pathname + url.search, request.url), request),
+ );
+ if (assetResponse.status !== 404) {
+ return assetResponse;
+ }
+ return new Response("Not Found", {
+ status: 404,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+
+ // Next static assets should behave like immutable filesystem assets:
+ // serve valid files directly and return a plain 404 for misses instead
+ // of falling through to the custom Pages 404 route.
+ const nextStaticLookupPath = getNextStaticAssetLookupPath(pathname, assetPrefix);
+ if (isNextStaticAssetPath(nextStaticLookupPath)) {
+ const assetResponse = await env.ASSETS.fetch(
+ new Request(new URL(nextStaticLookupPath + url.search, request.url), request),
+ );
+ if (assetResponse.status !== 404) {
+ return assetResponse;
+ }
+ return new Response("Not Found", {
+ status: 404,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+
// ── 2. Trailing slash normalization ────────────────────────────
if (pathname !== "/" && pathname !== "/api" && !pathname.startsWith("/api/")) {
const hasTrailing = pathname.endsWith("/");
- if (trailingSlash && !hasTrailing) {
+ const pathWithoutTrailing = pathname.replace(//+$/, "");
+ const lastSegment = pathWithoutTrailing.slice(pathWithoutTrailing.lastIndexOf("/") + 1);
+ const isFileLike = lastSegment.includes(".");
+ if (isFileLike && hasTrailing) {
+ return new Response(null, {
+ status: 308,
+ headers: { Location: basePath + pathWithoutTrailing + url.search },
+ });
+ }
+ if (trailingSlash && !hasTrailing && !isFileLike) {
return new Response(null, {
status: 308,
headers: { Location: basePath + pathname + "/" + url.search },
});
- } else if (!trailingSlash && hasTrailing) {
+ } else if (!trailingSlash && hasTrailing && !isFileLike) {
return new Response(null, {
status: 308,
- headers: { Location: basePath + pathname.replace(/\\/+$/, "") + url.search },
+ headers: { Location: basePath + pathname.replace(//+$/, "") + url.search },
});
}
}
@@ -640,7 +714,14 @@ export default {
if (basePath) {
const strippedUrl = new URL(request.url);
strippedUrl.pathname = pathname;
- request = new Request(strippedUrl, request);
+ const strippedHeaders = new Headers(request.headers);
+ strippedHeaders.set("x-vinext-request-had-base-path", requestHadBasePath ? "1" : "0");
+ request = new Request(strippedUrl, {
+ method: request.method,
+ headers: strippedHeaders,
+ body: request.body,
+ redirect: request.redirect,
+ });
}
// Build request context for pre-middleware config matching. Redirects
@@ -674,7 +755,7 @@ export default {
let resolvedUrl = urlWithQuery;
const middlewareHeaders: Record = {};
let middlewareRewriteStatus: number | undefined;
- if (typeof runMiddleware === "function") {
+ if (typeof runMiddleware === "function" && (!basePath || requestHadBasePath)) {
const result = await runMiddleware(request, ctx);
// Bubble up waitUntil promises (e.g. Clerk telemetry/session sync)
@@ -791,6 +872,22 @@ export default {
return mergeHeaders(response, middlewareHeaders, middlewareRewriteStatus);
}
+ // ── 7b. Public/static asset routes after beforeFiles rewrites ──
+ // Cloudflare Assets serves public/ files outside the Pages server entry.
+ // When beforeFiles rewrites target a public file, the Worker must probe
+ // the asset binding after applying the rewrite before falling through to
+ // Pages rendering.
+ if (
+ resolvedPathname !== "/" &&
+ !resolvedPathname.startsWith("/assets/") &&
+ request.method !== "POST"
+ ) {
+ const assetResponse = await env.ASSETS.fetch(new Request(new URL(resolvedUrl, request.url), request));
+ if (assetResponse.status !== 404) {
+ return mergeHeaders(assetResponse, middlewareHeaders, middlewareRewriteStatus);
+ }
+ }
+
// ── 8. Apply afterFiles rewrites from next.config.js ──────────
if (configRewrites.afterFiles?.length) {
const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx);
@@ -803,6 +900,10 @@ export default {
}
}
+ if (basePath && !requestHadBasePath && resolvedUrl === urlWithQuery) {
+ return new Response("404 - Not found", { status: 404 });
+ }
+
// ── 9. Page routes ────────────────────────────────────────────
let response: Response | undefined;
if (typeof renderPage === "function") {
@@ -1073,7 +1174,7 @@ function installDeps(root: string, deps: MissingDep[]): void {
const [pm, ...pmArgs] = installCmd.split(" ");
console.log(` Installing: ${deps.map((d) => d.name).join(", ")}`);
- execFileSync(pm, [...pmArgs, ...depSpecs], {
+ execPackageManagerCommand(pm, [...pmArgs, ...depSpecs], {
cwd: root,
stdio: "inherit",
});
@@ -1292,7 +1393,7 @@ export async function deploy(options: DeployOptions): Promise {
console.log(
` Upgrading ${reactUpgrade.map((d) => d.replace(/@latest$/, "")).join(", ")}...`,
);
- execFileSync(pm, [...pmArgs, ...reactUpgrade], { cwd: root, stdio: "inherit" });
+ execPackageManagerCommand(pm, [...pmArgs, ...reactUpgrade], { cwd: root, stdio: "inherit" });
}
}
const missingDeps = getMissingDeps(info);
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 44f2673ef..99c340b3e 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -96,6 +96,8 @@ export type AppRouterConfig = {
bodySizeLimit?: number;
/** Internationalization routing config for middleware matcher locale handling. */
i18n?: NextI18nConfig | null;
+ /** Path or URL prefix for immutable Next/Vite assets. */
+ assetPrefix?: string;
/**
* When true, the project has a `pages/` directory alongside the App Router.
* The generated RSC entry exposes `/__vinext/prerender/pages-static-paths`
@@ -107,6 +109,8 @@ export type AppRouterConfig = {
hasPagesDir?: boolean;
/** Exact public/ file routes, using normalized leading-slash pathnames. */
publicFiles?: string[];
+ /** OpenTelemetry propagation keys exposed as client trace metadata. */
+ clientTraceMetadata?: string[];
};
/**
@@ -135,8 +139,10 @@ export function generateRscEntry(
const allowedOrigins = config?.allowedOrigins ?? [];
const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024;
const i18nConfig = config?.i18n ?? null;
+ const assetPrefix = config?.assetPrefix ?? "";
const hasPagesDir = config?.hasPagesDir ?? false;
const publicFiles = config?.publicFiles ?? [];
+ const clientTraceMetadata = config?.clientTraceMetadata ?? [];
// Build import map for all page and layout files
const imports: string[] = [];
const importMap: Map = new Map();
@@ -404,6 +410,7 @@ import {
} from ${JSON.stringify(appElementsPath)};
import {
buildAppPageElements as __buildAppPageElements,
+ buildAppPageLoadingElements as __buildAppPageLoadingElements,
createAppPageTreePath as __createAppPageTreePath,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from ${JSON.stringify(appPageRouteWiringPath)};
@@ -744,7 +751,17 @@ async function __ensureInstrumentation() {
if (__instrumentationInitPromise) return __instrumentationInitPromise;
__instrumentationInitPromise = (async () => {
if (typeof _instrumentation.register === "function") {
- await _instrumentation.register();
+ const __previousNextRuntime = process.env.NEXT_RUNTIME;
+ process.env.NEXT_RUNTIME = "nodejs";
+ try {
+ await _instrumentation.register();
+ } finally {
+ if (__previousNextRuntime === undefined) {
+ delete process.env.NEXT_RUNTIME;
+ } else {
+ process.env.NEXT_RUNTIME = __previousNextRuntime;
+ }
+ }
}
// Store the onRequestError handler on globalThis so it is visible to
// reportRequestError() (imported as _reportRequestError above) regardless
@@ -1154,6 +1171,8 @@ ${middlewarePath ? generateMiddlewareMatcherCode("modern") : ""}
const __basePath = ${JSON.stringify(bp)};
const __trailingSlash = ${JSON.stringify(ts)};
+const __assetPrefix = ${JSON.stringify(assetPrefix)};
+export const vinextConfig = { basePath: __basePath, assetPrefix: __assetPrefix };
const __i18nConfig = ${JSON.stringify(i18nConfig)};
const __configRedirects = ${JSON.stringify(redirects)};
const __configRewrites = ${JSON.stringify(rewrites)};
@@ -1565,14 +1584,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
throw new Error("The " + _fileType + " file must export a function named \`" + _expectedExport + "\` or a \`default\` function.");
}
const middlewareMatcher = middlewareModule.config?.matcher;
- if (matchesMiddleware(cleanPathname, middlewareMatcher, request, __i18nConfig)) {
+ const __skipBasePathScopedMiddleware =
+ __basePath &&
+ middlewareMatcher !== undefined &&
+ request.headers.get("x-vinext-request-had-base-path") === "0";
+ if (!__skipBasePathScopedMiddleware && matchesMiddleware(cleanPathname, middlewareMatcher, request, __i18nConfig)) {
try {
// Wrap in NextRequest so middleware gets .nextUrl, .cookies, .geo, .ip, etc.
// Always construct a new Request with the fully decoded + normalized pathname
- // so middleware and the router see the same canonical path.
+ // so middleware and the router see the same canonical path.
const mwUrl = new URL(request.url);
mwUrl.pathname = cleanPathname;
- const mwRequest = new Request(mwUrl, request);
+ const mwRequest = new Request(mwUrl, request.clone());
const __mwNextConfig = (__basePath || __i18nConfig) ? { basePath: __basePath, i18n: __i18nConfig ?? undefined } : undefined;
const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined);
const mwFetchEvent = new NextFetchEvent({ page: cleanPathname });
@@ -2074,6 +2097,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (__pagesRes.status !== 404) {
setHeadersContext(null);
setNavigationContext(null);
+ if (_mwCtx.headers) {
+ const __headers = new Headers(__pagesRes.headers);
+ for (const [key, value] of _mwCtx.headers) {
+ if (key.toLowerCase() === "set-cookie") {
+ __headers.append(key, value);
+ } else {
+ __headers.set(key, value);
+ }
+ }
+ return new Response(__pagesRes.body, {
+ status: __pagesRes.status,
+ statusText: __pagesRes.statusText,
+ headers: __headers,
+ });
+ }
return __pagesRes;
}
}
@@ -2081,14 +2119,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
`
: ""
}
- // Render custom not-found page if available, otherwise plain 404
+ // Render custom not-found page if available, otherwise Next-compatible default 404 HTML.
const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request, undefined, _scriptNonce, _mwCtx);
if (notFoundResponse) return notFoundResponse;
setHeadersContext(null);
setNavigationContext(null);
- const notFoundHeaders = new Headers();
+ const notFoundHeaders = new Headers({ "Content-Type": "text/html; charset=utf-8" });
__mergeMiddlewareResponseHeaders(notFoundHeaders, _mwCtx.headers);
- return new Response("Not Found", { status: 404, headers: notFoundHeaders });
+ return new Response("404 - Page not found
This page could not be found.
", {
+ status: 404,
+ headers: notFoundHeaders,
+ });
}
const { route, params } = match;
@@ -2284,6 +2325,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// force-dynamic: set no-store Cache-Control
const isForceDynamic = dynamicConfig === "force-dynamic";
+ if (isRscRequest && request.headers.get("x-vinext-loading-payload") === "1") {
+ const __loadingElement = __buildAppPageLoadingElements({
+ interceptionContext: interceptionContextHeader,
+ route,
+ routePath: cleanPathname,
+ });
+ if (__loadingElement) {
+ const __loadingOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __loadingStream = renderToReadableStream(__loadingElement, {
+ onError: __loadingOnError,
+ });
+ const __loadingHeaders = new Headers({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ });
+ __mergeMiddlewareResponseHeaders(__loadingHeaders, _mwCtx.headers);
+ return new Response(__loadingStream, {
+ status: _mwCtx.status ?? 200,
+ headers: __loadingHeaders,
+ });
+ }
+ return new Response(null, { status: 204 });
+ }
+
// ── ISR cache read (production only) ─────────────────────────────────────
// Read from cache BEFORE generateStaticParams and all rendering work.
// This is the critical performance optimization: on a cache hit we skip
@@ -2368,6 +2433,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
+ if (isRscRequest && route.loading && route.loading.default) {
+ __cachedPageResponse.headers.set("X-Vinext-Route-Loading", "1");
+ }
return __cachedPageResponse;
}
}
@@ -2514,6 +2582,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const _asyncLayoutParams = makeThenableParams(params);
return __renderAppPageLifecycle({
cleanPathname,
+ clientTraceMetadata: ${JSON.stringify(clientTraceMetadata)},
clearRequestContext() {
setHeadersContext(null);
setNavigationContext(null);
diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts
index 5c3bfff66..64ba68360 100644
--- a/packages/vinext/src/entries/pages-client-entry.ts
+++ b/packages/vinext/src/entries/pages-client-entry.ts
@@ -14,6 +14,8 @@ import {
patternToNextFormat as pagesPatternToNextFormat,
type Route,
} from "../routing/pages-router.js";
+import fs from "node:fs/promises";
+import { hasNamedExport } from "../build/report.js";
import { createValidFileMatcher } from "../routing/file-matcher.js";
import { type ResolvedNextConfig } from "../config/next-config.js";
import { findFileWithExts } from "./pages-entry-helpers.js";
@@ -40,6 +42,19 @@ export async function generateClientEntry(
return ` ${JSON.stringify(nextFormatPattern)}: () => import(${JSON.stringify(absPath)})`;
});
+ const ssgRoutes: string[] = [];
+ for (const route of pageRoutes) {
+ try {
+ const source = await fs.readFile(route.filePath, "utf-8");
+ if (hasNamedExport(source, "getStaticProps")) {
+ ssgRoutes.push(pagesPatternToNextFormat(route.pattern));
+ }
+ } catch {
+ // If source analysis fails, skip data prefetch for this route. Navigation
+ // still loads data on demand, which is safer than executing GSSP early.
+ }
+ }
+
const appFileBase = appFilePath?.replace(/\\/g, "/");
return `
@@ -55,6 +70,8 @@ const pageLoaders = {
${loaderEntries.join(",\n")}
};
+window.__VINEXT_PAGES_SSG_ROUTES__ = new Set(${JSON.stringify(ssgRoutes)});
+
async function hydrate() {
const nextData = window.__NEXT_DATA__;
if (!nextData) {
@@ -62,7 +79,7 @@ async function hydrate() {
return;
}
- const { pageProps } = nextData.props;
+ const { pageProps, ...appProps } = nextData.props;
const loader = pageLoaders[nextData.page];
if (!loader) {
console.error("[vinext] No page loader for route:", nextData.page);
@@ -84,7 +101,7 @@ async function hydrate() {
const appModule = await import(${JSON.stringify(appFileBase!)});
const AppComponent = appModule.default;
window.__VINEXT_APP__ = AppComponent;
- element = React.createElement(AppComponent, { Component: PageComponent, pageProps });
+ element = React.createElement(AppComponent, { Component: PageComponent, pageProps, ...appProps });
} catch {
element = React.createElement(PageComponent, pageProps);
}
@@ -107,6 +124,23 @@ async function hydrate() {
const root = hydrateRoot(container, element);
window.__VINEXT_ROOT__ = root;
window.__VINEXT_HYDRATED_AT = performance.now();
+ window.__NEXT_HYDRATED = true;
+ window.__NEXT_HYDRATED_AT = window.__VINEXT_HYDRATED_AT;
+ if (typeof window.__NEXT_HYDRATED_CB === "function") {
+ window.__NEXT_HYDRATED_CB();
+ }
+
+ if (nextData.isFallback === true) {
+ const Router = (await import("next/router")).default;
+ window.__VINEXT_SUPPRESS_DATA_NAVIGATION_FAILURE = true;
+ Router.replace(window.location.pathname + window.location.search)
+ .catch((error) => {
+ console.error("[vinext] Failed to resolve fallback page data", error);
+ })
+ .finally(() => {
+ window.__VINEXT_SUPPRESS_DATA_NAVIGATION_FAILURE = false;
+ });
+ }
}
hydrate();
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index c062484f5..0bf9559c7 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -32,6 +32,10 @@ const _pagesNodeCompatPath = resolveEntryPath("../server/pages-node-compat.js",
const _pagesApiRoutePath = resolveEntryPath("../server/pages-api-route.js", import.meta.url);
const _isrCachePath = resolveEntryPath("../server/isr-cache.js", import.meta.url);
const _cspPath = resolveEntryPath("../server/csp.js", import.meta.url);
+const _edgeRuntimeGlobalsPath = resolveEntryPath(
+ "../server/edge-runtime-globals.js",
+ import.meta.url,
+);
/**
* Generate the virtual SSR server entry module.
@@ -43,9 +47,12 @@ export async function generateServerEntry(
fileMatcher: ReturnType,
middlewarePath: string | null,
instrumentationPath: string | null,
+ includeApiRoutes = true,
): Promise {
const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
- const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
+ const apiRoutes = includeApiRoutes
+ ? await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)
+ : [];
// Generate import statements using absolute paths since virtual
// modules don't have a real file location for relative resolution.
@@ -70,9 +77,10 @@ export async function generateServerEntry(
` { pattern: ${JSON.stringify(r.pattern)}, patternParts: ${JSON.stringify(r.patternParts)}, isDynamic: ${r.isDynamic}, params: ${JSON.stringify(r.params)}, module: api_${i} }`,
);
- // Check for _app and _document
+ // Check for special Pages Router files.
const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher);
const docFilePath = findFileWithExts(pagesDir, "_document", fileMatcher);
+ const errorFilePath = findFileWithExts(pagesDir, "_error", fileMatcher);
const appImportCode =
appFilePath !== null
? `import { default as AppComponent } from ${JSON.stringify(appFilePath.replace(/\\/g, "/"))};`
@@ -83,6 +91,11 @@ export async function generateServerEntry(
? `import { default as DocumentComponent } from ${JSON.stringify(docFilePath.replace(/\\/g, "/"))};`
: `const DocumentComponent = null;`;
+ const errorImportCode =
+ errorFilePath !== null
+ ? `import { default as ErrorComponent } from ${JSON.stringify(errorFilePath.replace(/\\/g, "/"))};`
+ : `const ErrorComponent = null;`;
+
// Serialize i18n config for embedding in the server entry
const i18nConfigJson = nextConfig?.i18n
? JSON.stringify({
@@ -100,11 +113,15 @@ export async function generateServerEntry(
// This embeds redirects, rewrites, headers, basePath, trailingSlash
// so prod-server.ts can apply them without loading next.config.js at runtime.
const vinextConfigJson = JSON.stringify({
+ buildId: nextConfig?.buildId ?? null,
basePath: nextConfig?.basePath ?? "",
+ assetPrefix: nextConfig?.assetPrefix ?? "",
trailingSlash: nextConfig?.trailingSlash ?? false,
redirects: nextConfig?.redirects ?? [],
rewrites: nextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] },
headers: nextConfig?.headers ?? [],
+ crossOrigin: nextConfig?.crossOrigin ?? null,
+ clientTraceMetadata: nextConfig?.clientTraceMetadata,
i18n: nextConfig?.i18n ?? null,
images: {
deviceSizes: nextConfig?.images?.deviceSizes,
@@ -133,7 +150,17 @@ export async function generateServerEntry(
// requests are handled. Matches Next.js semantics: register() is called once
// on startup in the process that handles requests.
if (typeof _instrumentation.register === "function") {
- await _instrumentation.register();
+ const __previousNextRuntime = process.env.NEXT_RUNTIME;
+ process.env.NEXT_RUNTIME = "nodejs";
+ try {
+ await _instrumentation.register();
+ } finally {
+ if (__previousNextRuntime === undefined) {
+ delete process.env.NEXT_RUNTIME;
+ } else {
+ process.env.NEXT_RUNTIME = __previousNextRuntime;
+ }
+ }
}
// Store the onRequestError handler on globalThis so it is visible to all
// code within the Worker (same global scope).
@@ -178,6 +205,10 @@ async function _runMiddleware(request) {
var matcher = config && config.matcher;
var url = new URL(request.url);
+ if (vinextConfig.basePath && matcher !== undefined && request.headers.get("x-vinext-request-had-base-path") === "0") {
+ return { continue: true };
+ }
+
// Normalize pathname before matching to prevent path-confusion bypasses
// (percent-encoding like /%61dmin, double slashes like /dashboard//settings).
var decodedPathname;
@@ -194,10 +225,18 @@ async function _runMiddleware(request) {
if (normalizedPathname !== url.pathname) {
var mwUrl = new URL(url);
mwUrl.pathname = normalizedPathname;
- mwRequest = new Request(mwUrl, request);
+ mwRequest = new Request(mwUrl, request.clone());
}
- var __mwNextConfig = (vinextConfig.basePath || i18nConfig) ? { basePath: vinextConfig.basePath, i18n: i18nConfig || undefined } : undefined;
+ var __mwBasePath = vinextConfig.basePath && request.headers.get("x-vinext-request-had-base-path") !== "0" ? vinextConfig.basePath : "";
+ var __mwNextConfig = (__mwBasePath || i18nConfig) ? { basePath: __mwBasePath, i18n: i18nConfig || undefined } : undefined;
var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined);
+ if (mwRequest.headers.get("x-vinext-data-request") === "1") {
+ Object.defineProperty(nextRequest, "__isData", {
+ value: true,
+ enumerable: false,
+ configurable: true,
+ });
+ }
var fetchEvent = new NextFetchEvent({ page: normalizedPathname });
var response;
try { response = await middlewareFn(nextRequest, fetchEvent); }
@@ -245,7 +284,19 @@ async function _runMiddleware(request) {
if (!k.startsWith("x-middleware-") || k === "x-middleware-override-headers" || k.startsWith("x-middleware-request-")) rwHeaders.append(k, v);
}
var rewritePath;
- try { var parsed = new URL(rewriteUrl, request.url); rewritePath = parsed.pathname + parsed.search; }
+ try {
+ var parsed = new URL(rewriteUrl, request.url);
+ var current = new URL(request.url);
+ if (parsed.origin !== current.origin) {
+ rewritePath = parsed.toString();
+ } else {
+ var parsedPathname = parsed.pathname;
+ if (__mwBasePath && (parsedPathname === __mwBasePath || parsedPathname.startsWith(__mwBasePath + "/"))) {
+ parsedPathname = parsedPathname.slice(__mwBasePath.length) || "/";
+ }
+ rewritePath = parsedPathname + parsed.search;
+ }
+ }
catch { rewritePath = rewriteUrl; }
return { continue: true, rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders: rwHeaders };
}
@@ -260,6 +311,7 @@ export async function runMiddleware() { return { continue: true }; }
// The server entry is a self-contained module that uses Web-standard APIs
// (Request/Response, renderToReadableStream) so it runs on Cloudflare Workers.
return `
+import ${JSON.stringify(_edgeRuntimeGlobalsPath)};
import React from "react";
import { renderToReadableStream } from "react-dom/server.edge";
import { resetSSRHead, getSSRHeadHTML } from "next/head";
@@ -292,7 +344,7 @@ import {
} from ${JSON.stringify(_isrCachePath)};
import { getScriptNonceFromHeaderSources as __getScriptNonceFromHeaderSources } from ${JSON.stringify(_cspPath)};
import { resolvePagesPageData as __resolvePagesPageData } from ${JSON.stringify(_pagesPageDataPath)};
-import { renderPagesPageResponse as __renderPagesPageResponse } from ${JSON.stringify(_pagesPageResponsePath)};
+import { buildPagesIsrCacheControl as __buildPagesIsrCacheControl, isPagesHtmlBotUserAgent as __isPagesHtmlBotUserAgent, renderPagesPageResponse as __renderPagesPageResponse } from ${JSON.stringify(_pagesPageResponsePath)};
${instrumentationImportCode}
${middlewareImportCode}
@@ -302,7 +354,9 @@ ${instrumentationInitCode}
const i18nConfig = ${i18nConfigJson};
// Build ID (embedded at build time)
-const buildId = ${buildIdJson};
+const buildId = process.env.__VINEXT_BUILD_ID || ${buildIdJson};
+const __generatedFallbackPaths = new Set();
+const __onDemandRevalidatePaths = new Set();
// Full resolved config for production server (embedded at build time)
export const vinextConfig = ${vinextConfigJson};
@@ -320,6 +374,17 @@ function isrCacheKey(router, pathname) {
return __sharedIsrCacheKey(router, pathname, buildId || undefined);
}
+function normalizeRevalidatePath(urlPath) {
+ try {
+ const parsed = new URL(urlPath, "http://vinext.local");
+ const pathname = parsed.pathname || "/";
+ return pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
+ } catch (_error) {
+ const pathname = String(urlPath || "/").split("?")[0] || "/";
+ return pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
+ }
+}
+
async function renderToStringAsync(element) {
const stream = await renderToReadableStream(element);
await stream.allReady;
@@ -346,6 +411,10 @@ ${apiImports.join("\n")}
${appImportCode}
${docImportCode}
+${errorImportCode}
+
+const appModuleFilePath = ${JSON.stringify(appFilePath?.replace(/\\/g, "/") ?? null)};
+const errorModuleFilePath = ${JSON.stringify(errorFilePath?.replace(/\\/g, "/") ?? null)};
export const pageRoutes = [
${pageRouteEntries.join(",\n")}
@@ -368,7 +437,8 @@ function matchRoute(url, routes) {
}
function parseQuery(url) {
- const qs = url.split("?")[1];
+ const queryIndex = url.indexOf("?");
+ const qs = queryIndex === -1 ? "" : url.slice(queryIndex + 1);
if (!qs) return {};
const p = new URLSearchParams(qs);
const q = {};
@@ -382,6 +452,15 @@ function parseQuery(url) {
return q;
}
+function mergeRouteQuery(params, url) {
+ return { ...parseQuery(url), ...params };
+}
+
+function requestPathAndSearch(request) {
+ const url = new URL(request.url);
+ return url.pathname + url.search;
+}
+
function patternToNextFormat(pattern) {
return pattern
.replace(/:([\\w]+)\\*/g, "[[...$1]]")
@@ -389,6 +468,31 @@ function patternToNextFormat(pattern) {
.replace(/:([\\w]+)/g, "[$1]");
}
+function findModuleJsAsset(manifest, moduleId) {
+ const m = (manifest && Object.keys(manifest).length > 0)
+ ? manifest
+ : (typeof globalThis !== "undefined" && globalThis.__VINEXT_SSR_MANIFEST__) || null;
+ if (!m || !moduleId) return undefined;
+
+ var files = m[moduleId];
+ if (!files) {
+ for (var mk in m) {
+ if (moduleId.endsWith("/" + mk) || moduleId === mk) {
+ files = m[mk];
+ break;
+ }
+ }
+ }
+ if (!files) return undefined;
+ for (var i = 0; i < files.length; i++) {
+ var file = files[i];
+ if (!file || !file.endsWith(".js")) continue;
+ if (file.charAt(0) !== "/") file = "/" + file;
+ return file;
+ }
+ return undefined;
+}
+
function collectAssetTags(manifest, moduleIds, scriptNonce) {
// Fall back to embedded manifest (set by vinext:cloudflare-build for Workers)
const m = (manifest && Object.keys(manifest).length > 0)
@@ -397,6 +501,9 @@ function collectAssetTags(manifest, moduleIds, scriptNonce) {
const tags = [];
const seen = new Set();
const nonceAttr = __createNonceAttribute(scriptNonce);
+ const crossOriginAttr = vinextConfig.crossOrigin
+ ? ' crossorigin="' + vinextConfig.crossOrigin + '"'
+ : " crossorigin";
// Load the set of lazy chunk filenames (only reachable via dynamic imports).
// These should NOT get or ');
+ tags.push('');
}
if (m) {
// Always inject shared chunks (framework, vinext runtime, entry) and
@@ -491,7 +598,7 @@ function collectAssetTags(manifest, moduleIds, scriptNonce) {
// (React.lazy, next/dynamic) and should only be fetched on demand.
if (lazySet && lazySet.has(tf)) continue;
tags.push('');
- tags.push('');
+ tags.push('');
}
}
}
@@ -545,12 +652,211 @@ function parseCookieLocaleFromHeader(cookieHeader) {
return null;
}
-export async function renderPage(request, url, manifest, ctx, middlewareHeaders) {
- if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest, middlewareHeaders));
- return _renderPage(request, url, manifest, middlewareHeaders);
+export async function renderPage(request, url, manifest, ctx, middlewareHeaders, isDataRequest) {
+ if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest, middlewareHeaders, isDataRequest));
+ return _renderPage(request, url, manifest, middlewareHeaders, isDataRequest);
+}
+
+async function renderStatusPage(options) {
+ const statusCode = options.statusCode;
+ const statusRoutePattern = statusCode === 404 ? "/404" : "/500";
+ const statusRoute = pageRoutes.find(function(route) {
+ return route.pattern === statusRoutePattern;
+ });
+ const route = statusRoute || null;
+ const PageComponent = route && route.module && route.module.default
+ ? route.module.default
+ : ErrorComponent;
+
+ if (!PageComponent) return null;
+
+ const routePattern = route ? patternToNextFormat(route.pattern) : "/_error";
+ const routeUrl = route ? route.pattern : "/_error";
+ const requestUrl = requestPathAndSearch(options.request);
+ let pageProps = { statusCode };
+ const query = {};
+ const scriptNonce = __getScriptNonceFromHeaderSources(options.request.headers, options.middlewareHeaders);
+ const shouldBufferResponse = true;
+
+ if (typeof setSSRContext === "function") {
+ setSSRContext({
+ pathname: routePattern,
+ query,
+ asPath: requestUrl,
+ isFallback: false,
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ domainLocales: options.domainLocales,
+ });
+ }
+
+ if (i18nConfig) {
+ setI18nContext({
+ locale: options.locale,
+ locales: i18nConfig.locales,
+ defaultLocale: options.defaultLocale,
+ domainLocales: options.domainLocales,
+ hostname: new URL(options.request.url).hostname,
+ });
+ }
+
+ if (PageComponent && typeof PageComponent.getInitialProps === "function") {
+ const errorReqRes = __createPagesReqRes({
+ body: undefined,
+ query,
+ request: options.request,
+ url: requestUrl,
+ });
+ errorReqRes.res.statusCode = statusCode;
+ const nextErrorProps = await PageComponent.getInitialProps({
+ pathname: routePattern,
+ query,
+ asPath: requestUrl,
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ req: errorReqRes.req,
+ res: errorReqRes.res,
+ err: options.err,
+ });
+ if (errorReqRes.res.headersSent) {
+ return await errorReqRes.responsePromise;
+ }
+ if (nextErrorProps && typeof nextErrorProps === "object") {
+ pageProps = { ...pageProps, ...nextErrorProps };
+ }
+ }
+
+ function createPageElement(currentPageProps) {
+ var currentElement = AppComponent
+ ? React.createElement(AppComponent, { Component: PageComponent, pageProps: currentPageProps })
+ : React.createElement(PageComponent, currentPageProps);
+ return wrapWithRouterContext(currentElement);
+ }
+
+ let documentInitialProps = {};
+ if (DocumentComponent && typeof DocumentComponent.getInitialProps === "function") {
+ const documentReqRes = __createPagesReqRes({
+ body: undefined,
+ query,
+ request: options.request,
+ url: requestUrl,
+ });
+ const documentCtx = {
+ pathname: routePattern,
+ query,
+ asPath: requestUrl,
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ req: documentReqRes.req,
+ res: documentReqRes.res,
+ renderPage: async function renderDocumentPage() {
+ const html = await renderToStringAsync(createPageElement(pageProps));
+ return { html, head: [], styles: [] };
+ },
+ };
+ const nextDocumentProps = await DocumentComponent.getInitialProps(documentCtx);
+ if (documentReqRes.res.headersSent) {
+ return await documentReqRes.responsePromise;
+ }
+ if (nextDocumentProps && typeof nextDocumentProps === "object") {
+ documentInitialProps = nextDocumentProps;
+ }
+ }
+
+ var _fontLinkHeader = "";
+ var _allFp = [];
+ try {
+ var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : [];
+ var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : [];
+ _allFp = _fpGoogle.concat(_fpLocal);
+ if (_allFp.length > 0) {
+ _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", ");
+ }
+ } catch (e) { /* font preloads not available */ }
+
+ const pageModuleFilePath = route ? route.filePath : errorModuleFilePath;
+ const pageModuleUrl = pageModuleFilePath ? findModuleJsAsset(options.manifest, pageModuleFilePath) : null;
+ const appModuleUrl = findModuleJsAsset(options.manifest, appModuleFilePath);
+ const pageModuleIds = pageModuleFilePath ? [pageModuleFilePath] : [];
+ const assetTags = collectAssetTags(options.manifest, pageModuleIds, scriptNonce);
+ const response = await __renderPagesPageResponse({
+ assetTags,
+ appProps: {},
+ buildId,
+ clientTraceMetadata: vinextConfig.clientTraceMetadata,
+ clearSsrContext() {
+ if (typeof setSSRContext === "function") setSSRContext(null);
+ },
+ createPageElement,
+ crossOrigin: vinextConfig.crossOrigin,
+ DocumentComponent,
+ documentProps: documentInitialProps,
+ documentRenderPageOptions: null,
+ flushPreloads: typeof flushPreloads === "function" ? flushPreloads : undefined,
+ fontLinkHeader: _fontLinkHeader,
+ fontPreloads: _allFp,
+ getFontLinks() {
+ try {
+ return typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : [];
+ } catch (e) {
+ return [];
+ }
+ },
+ getFontStyles() {
+ try {
+ var allFontStyles = [];
+ if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle());
+ if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal());
+ return allFontStyles;
+ } catch (e) {
+ return [];
+ }
+ },
+ getSSRHeadHTML: typeof getSSRHeadHTML === "function" ? getSSRHeadHTML : undefined,
+ gsspRes: null,
+ isFallback: false,
+ isGsp: false,
+ isrCacheKey,
+ isrRevalidateSeconds: null,
+ isrSet,
+ i18n: {
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ domainLocales: options.domainLocales,
+ },
+ pageProps,
+ pageModuleUrl,
+ appModuleUrl,
+ params: query,
+ renderDocumentToString(element) {
+ return renderToStringAsync(element);
+ },
+ renderHeadPrepassToStringAsync(element) {
+ return renderToStringAsync(element);
+ },
+ renderIsrPassToStringAsync,
+ renderToReadableStream(element) {
+ return renderToReadableStream(element);
+ },
+ resetSSRHead: typeof resetSSRHead === "function" ? resetSSRHead : undefined,
+ routePattern,
+ routeUrl,
+ safeJsonStringify,
+ scriptNonce,
+ shouldBufferResponse,
+ });
+
+ return new Response(response.body, {
+ status: statusCode,
+ headers: response.headers,
+ });
}
-async function _renderPage(request, url, manifest, middlewareHeaders) {
+async function _renderPage(request, url, manifest, middlewareHeaders, isDataRequest) {
const localeInfo = i18nConfig
? resolvePagesI18nRequest(
url,
@@ -559,6 +865,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
new URL(request.url).hostname,
vinextConfig.basePath,
vinextConfig.trailingSlash,
+ { skipLocaleRedirect: isDataRequest },
)
: { locale: undefined, url, hadPrefix: false, domainLocale: undefined, redirectUrl: undefined };
const locale = localeInfo.locale;
@@ -574,7 +881,17 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
const match = matchRoute(routeUrl, pageRoutes);
if (!match) {
- return new Response("404 - Page not found
",
+ const notFoundResponse = await renderStatusPage({
+ request,
+ manifest,
+ middlewareHeaders,
+ statusCode: 404,
+ locale,
+ defaultLocale: currentDefaultLocale,
+ domainLocales,
+ });
+ if (notFoundResponse) return notFoundResponse;
+ return new Response("404 - Page not found
This page could not be found.
",
{ status: 404, headers: { "Content-Type": "text/html" } });
}
@@ -586,11 +903,45 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
ensureFetchPatch();
try {
const routePattern = patternToNextFormat(route.pattern);
+ const requestUrl = request.headers.get("x-vinext-original-url") || requestPathAndSearch(request);
+ const asPath = isDataRequest ? routeUrl : requestUrl;
+ const routeUrlObject = new URL(routeUrl, "http://vinext.local");
+ const requestUrlObject = new URL(requestUrl, "http://vinext.local");
+ const routePathname = normalizeRevalidatePath(routeUrlObject.pathname);
+ const hasOnDemandRevalidate = __onDemandRevalidatePaths.delete(routePathname);
+ const revalidateReason = hasOnDemandRevalidate
+ ? "on-demand"
+ : process.env.VINEXT_PRERENDER === "1"
+ ? "build"
+ : undefined;
+ const resolvedUrl = isDataRequest
+ ? routeUrl
+ : routeUrlObject.pathname + requestUrlObject.search;
+ const pageModule = route.module;
+ const isGsspPage = typeof pageModule.getServerSideProps === "function";
+ const isGspPage = typeof pageModule.getStaticProps === "function";
+ const routeSearchWasRewritten =
+ routeUrlObject.search !== "" && routeUrlObject.search !== requestUrlObject.search;
+ const query = isGsspPage
+ ? mergeRouteQuery(params, routeUrl)
+ : isGspPage
+ ? routeSearchWasRewritten
+ ? mergeRouteQuery(params, routeUrl)
+ : { ...params }
+ : mergeRouteQuery(params, requestUrl);
+ if (!isGsspPage && request.method !== "GET" && request.method !== "HEAD") {
+ return new Response("Method Not Allowed", {
+ status: 405,
+ headers: { Allow: "GET, HEAD" },
+ });
+ }
+
if (typeof setSSRContext === "function") {
setSSRContext({
pathname: routePattern,
- query: { ...params, ...parseQuery(routeUrl) },
- asPath: routeUrl,
+ query,
+ asPath,
+ isFallback: false,
locale: locale,
locales: i18nConfig ? i18nConfig.locales : undefined,
defaultLocale: currentDefaultLocale,
@@ -608,12 +959,13 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
});
}
- const pageModule = route.module;
const PageComponent = pageModule.default;
if (!PageComponent) {
return new Response("Page has no default export", { status: 500 });
}
const scriptNonce = __getScriptNonceFromHeaderSources(request.headers, middlewareHeaders);
+ const shouldBufferResponse = true;
+ const isCrawlerRequest = __isPagesHtmlBotUserAgent(request.headers.get("user-agent") || "");
// Build font Link header early so it's available for ISR cached responses too.
// Font preloads are module-level state populated at import time and persist across requests.
var _fontLinkHeader = "";
@@ -626,14 +978,14 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
_fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", ");
}
} catch (e) { /* font preloads not available */ }
- const query = parseQuery(routeUrl);
const pageDataResult = await __resolvePagesPageData({
applyRequestContexts() {
if (typeof setSSRContext === "function") {
setSSRContext({
pathname: routePattern,
- query: { ...params, ...query },
- asPath: routeUrl,
+ query,
+ asPath,
+ isFallback: false,
locale: locale,
locales: i18nConfig ? i18nConfig.locales : undefined,
defaultLocale: currentDefaultLocale,
@@ -652,7 +1004,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
},
buildId,
createGsspReqRes() {
- return __createPagesReqRes({ body: undefined, query, request, url: routeUrl });
+ return __createPagesReqRes({ body: undefined, query, request, url: requestUrl });
},
createPageElement(currentPageProps) {
var currentElement = AppComponent
@@ -673,6 +1025,11 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
pageModule,
params,
query,
+ hasGeneratedFallbackPath: __generatedFallbackPaths.has(routeUrlObject.pathname),
+ isCrawlerRequest,
+ isDataRequest,
+ revalidateReason,
+ resolvedUrl,
renderIsrPassToStringAsync,
route: {
isDynamic: route.isDynamic,
@@ -696,29 +1053,208 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
if (pageDataResult.kind === "response") {
return pageDataResult.response;
}
+ if (pageDataResult.kind === "notFound") {
+ const notFoundResponse = await renderStatusPage({
+ request,
+ manifest,
+ middlewareHeaders,
+ statusCode: 404,
+ locale,
+ defaultLocale: currentDefaultLocale,
+ domainLocales,
+ });
+ if (notFoundResponse) return notFoundResponse;
+ return new Response("404 - Page not found
This page could not be found.
",
+ { status: 404, headers: { "Content-Type": "text/html" } });
+ }
let pageProps = pageDataResult.pageProps;
var gsspRes = pageDataResult.gsspRes;
let isrRevalidateSeconds = pageDataResult.isrRevalidateSeconds;
+ const isFallback = pageDataResult.isFallback === true;
+ if (typeof setSSRContext === "function") {
+ setSSRContext({
+ pathname: routePattern,
+ query,
+ asPath,
+ isFallback,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: currentDefaultLocale,
+ domainLocales: domainLocales,
+ });
+ }
+ let appInitialProps = {};
+ const documentQuery = query;
+ if (AppComponent && typeof AppComponent.getInitialProps === "function") {
+ const appReqRes = __createPagesReqRes({ body: undefined, query: documentQuery, request, url: requestUrl });
+ const appCtx = {
+ Component: PageComponent,
+ router: {
+ pathname: routePattern,
+ route: routePattern,
+ query: documentQuery,
+ asPath,
+ },
+ ctx: {
+ req: appReqRes.req,
+ res: appReqRes.res,
+ pathname: routePattern,
+ query: documentQuery,
+ asPath,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: currentDefaultLocale,
+ },
+ };
+ const nextAppProps = await AppComponent.getInitialProps(appCtx);
+ if (appReqRes.res.headersSent) {
+ return await appReqRes.responsePromise;
+ }
+ if (nextAppProps && typeof nextAppProps === "object") {
+ const { pageProps: appPageProps, ...restAppProps } = nextAppProps;
+ appInitialProps = restAppProps;
+ if (appPageProps && typeof appPageProps === "object") {
+ pageProps = { ...appPageProps, ...pageProps };
+ }
+ }
+ }
+
+ function normalizeDocumentRenderPageOptions(renderOptions) {
+ if (!renderOptions) return null;
+ if (typeof renderOptions === "function") {
+ return { enhanceComponent: renderOptions };
+ }
+ return renderOptions;
+ }
+
+ function createPageElementWithOptions(currentPageProps, renderOptions) {
+ var normalizedRenderOptions = normalizeDocumentRenderPageOptions(renderOptions);
+ var RenderPageComponent = PageComponent;
+ var RenderAppComponent = AppComponent;
+ if (normalizedRenderOptions && typeof normalizedRenderOptions.enhanceComponent === "function") {
+ RenderPageComponent = normalizedRenderOptions.enhanceComponent(RenderPageComponent);
+ }
+
+ var currentElement;
+ if (RenderAppComponent) {
+ if (normalizedRenderOptions && typeof normalizedRenderOptions.enhanceApp === "function") {
+ RenderAppComponent = normalizedRenderOptions.enhanceApp(RenderAppComponent);
+ }
+ currentElement = React.createElement(RenderAppComponent, { Component: RenderPageComponent, pageProps: currentPageProps, ...appInitialProps });
+ } else if (normalizedRenderOptions && typeof normalizedRenderOptions.enhanceApp === "function") {
+ var DefaultApp = function DefaultApp(props) {
+ return React.createElement(props.Component, props.pageProps);
+ };
+ RenderAppComponent = normalizedRenderOptions.enhanceApp(DefaultApp);
+ currentElement = React.createElement(RenderAppComponent, { Component: RenderPageComponent, pageProps: currentPageProps });
+ } else {
+ currentElement = React.createElement(RenderPageComponent, currentPageProps);
+ }
+ return wrapWithRouterContext(currentElement);
+ }
+
+ let documentInitialProps = {};
+ let documentRenderPageOptions = null;
+ if (DocumentComponent && typeof DocumentComponent.getInitialProps === "function") {
+ const documentReqRes = __createPagesReqRes({ body: undefined, query: documentQuery, request, url: requestUrl });
+ const documentCtx = {
+ pathname: routePattern,
+ query: documentQuery,
+ asPath,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: currentDefaultLocale,
+ req: documentReqRes.req,
+ res: documentReqRes.res,
+ renderPage: async function renderDocumentPage(renderOptions) {
+ documentRenderPageOptions = normalizeDocumentRenderPageOptions(renderOptions);
+ const html = await renderToStringAsync(
+ createPageElementWithOptions(pageProps, documentRenderPageOptions),
+ );
+ return { html, head: [], styles: [] };
+ },
+ };
+ const nextDocumentProps = await DocumentComponent.getInitialProps(documentCtx);
+ if (documentReqRes.res.headersSent) {
+ return await documentReqRes.responsePromise;
+ }
+ if (nextDocumentProps && typeof nextDocumentProps === "object") {
+ documentInitialProps = nextDocumentProps;
+ }
+ }
+
+ const pageModuleUrl = findModuleJsAsset(manifest, route.filePath);
+ const appModuleUrl = findModuleJsAsset(manifest, appModuleFilePath);
+
+ if (isDataRequest) {
+ var dataHeaders = new Headers({ "Content-Type": "application/json" });
+ if (process.env.NEXT_DEPLOYMENT_ID) {
+ dataHeaders.set("x-nextjs-deployment-id", process.env.NEXT_DEPLOYMENT_ID);
+ }
+ if (gsspRes && typeof gsspRes.getHeaders === "function") {
+ var gsspHeaders = gsspRes.getHeaders();
+ for (var hk in gsspHeaders) {
+ var hv = gsspHeaders[hk];
+ if (Array.isArray(hv)) {
+ for (var hi = 0; hi < hv.length; hi++) dataHeaders.append(hk, String(hv[hi]));
+ } else if (hv !== undefined) {
+ dataHeaders.set(hk, String(hv));
+ }
+ }
+ dataHeaders.set("Content-Type", "application/json");
+ }
+ if (gsspRes && !dataHeaders.has("Cache-Control")) {
+ dataHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
+ }
+ if (isGspPage && !dataHeaders.has("Cache-Control")) {
+ dataHeaders.set("Cache-Control", __buildPagesIsrCacheControl(isrRevalidateSeconds || undefined, "MISS"));
+ }
+ if (isGspPage) {
+ __generatedFallbackPaths.add(routeUrlObject.pathname);
+ }
+ return new Response(safeJsonStringify({
+ ...appInitialProps,
+ pageProps,
+ page: routePattern,
+ query,
+ buildId,
+ isFallback,
+ ...(i18nConfig ? {
+ locale,
+ locales: i18nConfig.locales,
+ defaultLocale: currentDefaultLocale,
+ domainLocales,
+ } : {}),
+ ...(isGspPage ? { gsp: true } : {}),
+ ...(gsspRes ? { gssp: true } : {}),
+ __vinext: {
+ ...(pageModuleUrl ? { pageModuleUrl } : {}),
+ ...(appModuleUrl ? { appModuleUrl } : {}),
+ },
+ }), {
+ status: gsspRes ? gsspRes.statusCode : 200,
+ headers: dataHeaders,
+ });
+ }
const pageModuleIds = route.filePath ? [route.filePath] : [];
const assetTags = collectAssetTags(manifest, pageModuleIds, scriptNonce);
- return __renderPagesPageResponse({
+ return await __renderPagesPageResponse({
assetTags,
+ appProps: appInitialProps,
buildId,
+ clientTraceMetadata: vinextConfig.clientTraceMetadata,
clearSsrContext() {
if (typeof setSSRContext === "function") setSSRContext(null);
},
createPageElement(currentPageProps) {
- var currentElement;
- if (AppComponent) {
- currentElement = React.createElement(AppComponent, { Component: PageComponent, pageProps: currentPageProps });
- } else {
- currentElement = React.createElement(PageComponent, currentPageProps);
- }
- return wrapWithRouterContext(currentElement);
+ return createPageElementWithOptions(currentPageProps, documentRenderPageOptions);
},
+ crossOrigin: vinextConfig.crossOrigin,
DocumentComponent,
+ documentProps: documentInitialProps,
+ documentRenderPageOptions,
flushPreloads: typeof flushPreloads === "function" ? flushPreloads : undefined,
fontLinkHeader: _fontLinkHeader,
fontPreloads: _allFp,
@@ -741,6 +1277,8 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
},
getSSRHeadHTML: typeof getSSRHeadHTML === "function" ? getSSRHeadHTML : undefined,
gsspRes,
+ isFallback,
+ isGsp: isGspPage,
isrCacheKey,
isrRevalidateSeconds,
isrSet,
@@ -751,10 +1289,15 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
domainLocales: domainLocales,
},
pageProps,
- params,
+ pageModuleUrl,
+ appModuleUrl,
+ params: query,
renderDocumentToString(element) {
return renderToStringAsync(element);
},
+ renderHeadPrepassToStringAsync(element) {
+ return renderToStringAsync(element);
+ },
renderIsrPassToStringAsync,
renderToReadableStream(element) {
return renderToReadableStream(element);
@@ -764,6 +1307,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
routeUrl,
safeJsonStringify,
scriptNonce,
+ shouldBufferResponse,
});
} catch (e) {
console.error("[vinext] SSR error:", e);
@@ -772,6 +1316,20 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
{ path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) },
{ routerKind: "Pages Router", routePath: route.pattern, routeType: "render" },
).catch(() => { /* ignore reporting errors */ });
+ try {
+ const errorResponse = await renderStatusPage({
+ request,
+ manifest,
+ middlewareHeaders,
+ statusCode: 500,
+ locale,
+ defaultLocale: currentDefaultLocale,
+ domainLocales,
+ });
+ if (errorResponse) return errorResponse;
+ } catch (_render500Error) {
+ // Fall through to the generic 500 below.
+ }
return new Response("Internal Server Error", { status: 500 });
}
});
@@ -781,6 +1339,9 @@ export async function handleApiRoute(request, url) {
const match = matchRoute(url, apiRoutes);
return __handlePagesApiRoute({
match,
+ onRevalidate(urlPath) {
+ __onDemandRevalidatePaths.add(normalizeRevalidatePath(urlPath));
+ },
request,
url,
reportRequestError(error, routePattern) {
diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts
index 289792e82..86c3246ea 100644
--- a/packages/vinext/src/global.d.ts
+++ b/packages/vinext/src/global.d.ts
@@ -17,8 +17,10 @@
*/
import type { Root } from "react-dom/client";
+import type { VinextNextData } from "./client/vinext-next-data";
import type { OnRequestErrorHandler } from "./server/instrumentation";
import type { CachedRscResponse, PrefetchCacheEntry } from "./shims/navigation";
+import type { NextRouterSingleton } from "./shims/router";
// ---------------------------------------------------------------------------
// Window globals — browser-side state shared across module boundaries
@@ -42,6 +44,14 @@ declare global {
*/
__VINEXT_HYDRATED_AT: number | undefined;
+ /**
+ * Next.js conventional hydration markers used by upstream test helpers and
+ * compatibility suites.
+ */
+ __NEXT_HYDRATED: boolean | undefined;
+ __NEXT_HYDRATED_AT: number | undefined;
+ __NEXT_HYDRATED_CB: (() => void) | undefined;
+
/**
* The cached `_app` component for Pages Router.
* Written and read by `shims/router.ts` to avoid re-importing on every
@@ -69,6 +79,27 @@ declare global {
*/
__VINEXT_DEFAULT_LOCALE__: string | undefined;
+ /**
+ * Legacy Next.js browser global used by parts of the upstream Pages Router
+ * test suite and by older integrations that reach for the Router singleton
+ * outside the module system.
+ */
+ next: { router?: NextRouterSingleton } | undefined;
+
+ /**
+ * Suppresses hard navigations when a fallback shell's automatic data fetch
+ * fails. Next keeps the fallback page mounted for failed fallback data
+ * loads instead of blowing away page globals with a reload.
+ */
+ __VINEXT_SUPPRESS_DATA_NAVIGATION_FAILURE: boolean | undefined;
+
+ /**
+ * Pages Router route patterns that are known to use getStaticProps.
+ * The generated client entry sets this so Link prefetching can avoid
+ * executing request-specific getServerSideProps routes in the background.
+ */
+ __VINEXT_PAGES_SSG_ROUTES__: Set | undefined;
+
// ── App Router ──────────────────────────────────────────────────────────
/**
@@ -96,9 +127,12 @@ declare global {
historyUpdateMode?: "push" | "replace",
previousNextUrlOverride?: string | null,
programmaticTransition?: boolean,
+ deferredLoadingTransition?: boolean,
) => Promise)
| undefined;
+ __VINEXT_RSC_PREFETCH_LOADING__: ((href: string) => Promise) | undefined;
+
/**
* A Promise that resolves when the current in-flight popstate RSC navigation
* finishes rendering.
@@ -124,11 +158,7 @@ declare global {
// ── Next.js conventional globals ────────────────────────────────────────
//
- // `__NEXT_DATA__` is already declared by `next/dist/client/index.d.ts` as
- // `NEXT_DATA` from `next/dist/shared/lib/utils`. We intentionally do NOT
- // re-declare it here to avoid type conflicts. vinext-specific extensions
- // (__vinext) are accessed via the `VinextNextData` type in
- // `client/vinext-next-data.ts`.
+ __NEXT_DATA__: VinextNextData | undefined;
}
// ── self globals used inside server-injected inline scripts ───────────────
diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts
index e23ecd3e4..ea7f9b23f 100644
--- a/packages/vinext/src/index.ts
+++ b/packages/vinext/src/index.ts
@@ -1,5 +1,5 @@
-import type { Plugin, PluginOption, UserConfig, ViteDevServer } from "vite";
-import { loadEnv, parseAst } from "vite";
+import type { Plugin, PluginOption, TransformResult, UserConfig, ViteDevServer } from "vite";
+import { loadEnv, parseAst, transformWithOxc } from "vite";
import {
pagesRouter,
apiRouter,
@@ -73,6 +73,8 @@ import { manifestFileWithBase, manifestFilesWithBase } from "./utils/manifest-pa
import { hasBasePath } from "./utils/base-path.js";
import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js";
import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js";
+import { createEdgeBlobAssetsPlugin } from "./plugins/edge-blob-assets.js";
+import { createImportMetaUrlPlugin } from "./plugins/import-meta-url.js";
import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js";
import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js";
import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js";
@@ -88,6 +90,7 @@ import {
import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js";
import { computeLazyChunks } from "./utils/lazy-chunks.js";
import { resolvePostcssStringPlugins } from "./plugins/postcss.js";
+import { createWasmModulePlugin } from "./plugins/wasm-module.js";
import {
createClientManualChunks,
createClientOutputConfig,
@@ -103,8 +106,15 @@ import {
type BundleBackfillChunk,
} from "./build/ssr-manifest.js";
import { stripServerExports } from "./plugins/strip-server-exports.js";
+import { createCssDataUrlPlugin } from "./plugins/css-data-url.js";
+import { createSwcHelpersResolverPlugin } from "./plugins/swc-helpers.js";
import { hasMdxFiles } from "./utils/mdx-scan.js";
import { scanPublicFileRoutes } from "./utils/public-routes.js";
+import { writeNextStaticCompatAssets } from "./server/next-static-compat.js";
+import {
+ mergeServerExternalPackages,
+ resolveServerExternalPackageImport,
+} from "./utils/server-externals.js";
import tsconfigPaths from "vite-tsconfig-paths";
import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react";
import MagicString from "magic-string";
@@ -127,6 +137,61 @@ type ASTNode = ReturnType["body"][number]["parent"];
const __dirname = import.meta.dirname;
type VitePluginReactModule = typeof import("@vitejs/plugin-react");
+type BabelTransformResult = {
+ code?: string | null;
+ map?: unknown;
+};
+type BabelCoreModule = {
+ default?: {
+ transformAsync?: (
+ code: string,
+ options: Record,
+ ) => Promise;
+ };
+ transformAsync?: (
+ code: string,
+ options: Record,
+ ) => Promise;
+};
+
+const BABEL_CONFIG_FILES = [
+ ".babelrc",
+ ".babelrc.json",
+ ".babelrc.js",
+ ".babelrc.mjs",
+ ".babelrc.cjs",
+ "babel.config.js",
+ "babel.config.mjs",
+ "babel.config.cjs",
+ "babel.config.json",
+];
+
+const projectBabelConfigCache = new Map();
+
+function hasProjectBabelConfig(projectRoot: string): boolean {
+ const cached = projectBabelConfigCache.get(projectRoot);
+ if (cached !== undefined) return cached;
+
+ for (const fileName of BABEL_CONFIG_FILES) {
+ if (fs.existsSync(path.join(projectRoot, fileName))) {
+ projectBabelConfigCache.set(projectRoot, true);
+ return true;
+ }
+ }
+
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")) as {
+ babel?: unknown;
+ };
+ if (pkg.babel && typeof pkg.babel === "object") {
+ projectBabelConfigCache.set(projectRoot, true);
+ return true;
+ }
+ } catch {}
+
+ projectBabelConfigCache.set(projectRoot, false);
+ return false;
+}
function resolveOptionalDependency(projectRoot: string, specifier: string): string | null {
try {
@@ -142,6 +207,189 @@ function resolveOptionalDependency(projectRoot: string, specifier: string): stri
return null;
}
+async function transformWithProjectBabel(
+ code: string,
+ filepath: string,
+ projectRoot: string,
+): Promise {
+ const babelCorePath = resolveOptionalDependency(projectRoot, "@babel/core");
+ if (!babelCorePath) {
+ throw new Error(
+ "[vinext] A Babel config was found, but @babel/core could not be resolved from the project.",
+ );
+ }
+
+ const babel = (await import(pathToFileURL(babelCorePath).href)) as BabelCoreModule;
+ const transformAsync = babel.transformAsync ?? babel.default?.transformAsync;
+ if (!transformAsync) {
+ throw new Error("[vinext] @babel/core does not expose transformAsync().");
+ }
+
+ return transformAsync(code, {
+ babelrc: true,
+ caller: {
+ name: "next-babel-turbo-loader",
+ supportsStaticESM: true,
+ supportsDynamicImport: true,
+ supportsTopLevelAwait: true,
+ isDev: false,
+ isServer: false,
+ target: "web",
+ },
+ configFile: true,
+ cwd: projectRoot,
+ filename: filepath,
+ root: projectRoot,
+ sourceFileName: path.relative(projectRoot, filepath),
+ sourceMaps: true,
+ });
+}
+
+type PackageExportsValue =
+ | string
+ | string[]
+ | {
+ [conditionOrSubpath: string]: PackageExportsValue | undefined;
+ };
+
+function parseBarePackageSpecifier(
+ specifier: string,
+): { packageName: string; exportKey: string } | null {
+ if (
+ !specifier ||
+ specifier.startsWith(".") ||
+ specifier.startsWith("/") ||
+ specifier.startsWith("\\") ||
+ specifier.startsWith("\0") ||
+ specifier.includes(":")
+ ) {
+ return null;
+ }
+
+ const parts = specifier.split("/");
+ const packageName = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
+ if (!packageName || (specifier.startsWith("@") && parts.length < 2)) return null;
+
+ const subpath = specifier.slice(packageName.length);
+ return {
+ packageName,
+ exportKey: subpath ? `.${subpath}` : ".",
+ };
+}
+
+function findPackageJsonPath(
+ projectRoot: string,
+ packageName: string,
+ fromSpecifier: string,
+): string | null {
+ const projectRequire = createRequire(path.join(projectRoot, "package.json"));
+
+ try {
+ return projectRequire.resolve(`${packageName}/package.json`);
+ } catch {}
+
+ try {
+ let dir = path.dirname(projectRequire.resolve(fromSpecifier));
+ for (;;) {
+ const packageJsonPath = path.join(dir, "package.json");
+ if (fs.existsSync(packageJsonPath)) {
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as {
+ name?: string;
+ };
+ if (packageJson.name === packageName) return packageJsonPath;
+ }
+
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+ } catch {
+ return null;
+ }
+}
+
+function pickConditionalExportTarget(
+ value: PackageExportsValue | undefined,
+ activeConditions: Set,
+): string | null {
+ if (!value) return null;
+ if (typeof value === "string") return value;
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const resolved = pickConditionalExportTarget(item, activeConditions);
+ if (resolved) return resolved;
+ }
+ return null;
+ }
+
+ for (const [condition, target] of Object.entries(value)) {
+ if (condition === "types") continue;
+ if (condition === "default" || activeConditions.has(condition)) {
+ const resolved = pickConditionalExportTarget(target, activeConditions);
+ if (resolved) return resolved;
+ }
+ }
+
+ return null;
+}
+
+function resolveConditionalPackageExport(
+ projectRoot: string,
+ specifier: string,
+ conditions: string[],
+): string | null {
+ const parsed = parseBarePackageSpecifier(specifier);
+ if (!parsed) return null;
+ if (
+ (parsed.packageName === "react" || parsed.packageName === "react-dom") &&
+ !conditions.includes("react-server")
+ ) {
+ return null;
+ }
+
+ try {
+ const packageJsonPath = findPackageJsonPath(projectRoot, parsed.packageName, specifier);
+ if (!packageJsonPath) return null;
+
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as {
+ exports?: PackageExportsValue;
+ };
+ const exportsValue = packageJson.exports;
+ if (!exportsValue) return null;
+
+ const activeConditions = new Set(conditions);
+ const exportValue =
+ typeof exportsValue === "object" &&
+ !Array.isArray(exportsValue) &&
+ Object.keys(exportsValue).some((key) => key.startsWith("."))
+ ? exportsValue[parsed.exportKey]
+ : parsed.exportKey === "."
+ ? exportsValue
+ : undefined;
+ const target = pickConditionalExportTarget(exportValue, activeConditions);
+ if (!target || !target.startsWith(".")) return null;
+
+ return path.resolve(path.dirname(packageJsonPath), target);
+ } catch {
+ return null;
+ }
+}
+
+function fileDeclaresEdgeRuntime(filePath: string): boolean {
+ try {
+ if (!/\.[cm]?[jt]sx?$/.test(filePath) || filePath.includes("/node_modules/")) return false;
+ const source = fs.readFileSync(filePath, "utf-8");
+ return (
+ /\bexport\s+const\s+runtime\s*=\s*["']edge["']/.test(source) ||
+ /\bexport\s+const\s+config\s*=\s*\{[\s\S]*?\bruntime\s*:\s*["'](?:edge|experimental-edge)["']/.test(
+ source,
+ )
+ );
+ } catch {
+ return false;
+ }
+}
+
function resolveShimModulePath(shimsDir: string, moduleName: string): string {
// Source checkouts only ship TypeScript shims, while built packages only ship
// JavaScript. Check .ts first to avoid an extra stat in development.
@@ -337,20 +585,33 @@ type UserResolveConfigWithTsconfigPaths = NonNullable & {
// transforms can see them via resolve.alias without re-reading config files per env.
const _tsconfigAliasCache = new Map>();
-function resolveTsconfigAliases(projectRoot: string): Record {
- if (_tsconfigAliasCache.has(projectRoot)) {
- return _tsconfigAliasCache.get(projectRoot)!;
+function resolveTsconfigAliases(
+ projectRoot: string,
+ tsconfigPath?: string | null,
+): Record {
+ const cacheKey = `${projectRoot}\0${tsconfigPath ?? ""}`;
+ if (_tsconfigAliasCache.has(cacheKey)) {
+ return _tsconfigAliasCache.get(cacheKey)!;
}
let aliases: Record = {};
- for (const name of TSCONFIG_FILES) {
- const candidate = path.join(projectRoot, name);
- if (!fs.existsSync(candidate)) continue;
- aliases = loadTsconfigPathAliases(candidate, projectRoot);
- break;
+ if (tsconfigPath) {
+ const candidate = path.isAbsolute(tsconfigPath)
+ ? tsconfigPath
+ : path.resolve(projectRoot, tsconfigPath);
+ if (fs.existsSync(candidate)) {
+ aliases = loadTsconfigPathAliases(candidate, projectRoot);
+ }
+ } else {
+ for (const name of TSCONFIG_FILES) {
+ const candidate = path.join(projectRoot, name);
+ if (!fs.existsSync(candidate)) continue;
+ aliases = loadTsconfigPathAliases(candidate, projectRoot);
+ break;
+ }
}
- _tsconfigAliasCache.set(projectRoot, aliases);
+ _tsconfigAliasCache.set(cacheKey, aliases);
return aliases;
}
@@ -403,6 +664,28 @@ function getClientOutputConfigForVite(viteMajorVersion: number) {
return viteMajorVersion >= 8 ? { codeSplitting: clientCodeSplittingConfig } : clientOutputConfig;
}
+const optionalNodeModuleDependencyPlugin: Plugin = {
+ name: "vinext:optional-node-module-dependency",
+ enforce: "pre",
+ resolveId(id, importer) {
+ if (!importer || !importer.includes(`${path.sep}node_modules${path.sep}`)) {
+ return null;
+ }
+ if (!parseBarePackageSpecifier(id)) {
+ return null;
+ }
+ try {
+ createRequire(importer).resolve(id);
+ return null;
+ } catch {
+ return { id, external: true };
+ }
+ },
+};
+
+const DYNAMIC_CSS_URL_IMPORT_RE =
+ /\bimport\(\s*new\s+URL\(\s*(["'])(\.\/[^"']+\.css)\1\s*,\s*import\.meta\.url\s*\)\.href\s*\)/g;
+
export type VinextOptions = {
/**
* Base directory containing the app/ and pages/ directories.
@@ -531,6 +814,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// module graph for. The shim files exist in the vinext package before plugin
// init, so realpath is safe to evaluate eagerly.
const canonicalize = (p: string): string => tryRealpathSync(p) ?? p;
+ const moduleIdKey = (id: string): string => {
+ const cleanId = id.startsWith("\0") ? id.slice(1) : id;
+ const queryIndex = cleanId.search(/[?#]/);
+ const pathOnly = queryIndex === -1 ? cleanId : cleanId.slice(0, queryIndex);
+ return path.isAbsolute(pathOnly) ? canonicalize(pathOnly) : pathOnly;
+ };
const dynamicShimPaths: ReadonlySet = new Set(
[
resolveShimModulePath(shimsDir, "headers"),
@@ -541,18 +830,28 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Shim alias map — populated in config(), used by resolveId() for .js variants
let nextShimMap: Record = {};
+ let serverExternalPackages: string[] | true = [];
+ const middlewareLayerModuleIds = new Set();
+ const edgeRuntimeModuleIds = new Set();
/**
* Generate the virtual SSR server entry module.
* This is the entry point for `vite build --ssr`.
*/
async function generateServerEntry(): Promise {
+ // In hybrid App + Pages builds, the App SSR environment imports this
+ // virtual module only to access Pages render metadata/fallback rendering.
+ // Middleware already runs from the App RSC entry for those requests; keeping
+ // it out of this SSR graph prevents server-only middleware imports from
+ // being validated as client/SSR imports by @vitejs/plugin-rsc. The separate
+ // Pages Router server build (disableAppRouter: true) still includes it.
return _generateServerEntry(
pagesDir,
nextConfig,
fileMatcher,
- middlewarePath,
+ hasAppDir ? null : middlewarePath,
instrumentationPath,
+ !hasAppDir,
);
}
@@ -720,15 +1019,52 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
const plugins: PluginOption[] = [
+ optionalNodeModuleDependencyPlugin,
// Resolve tsconfig paths/baseUrl aliases so real-world Next.js repos
// that use @/*, #/*, or baseUrl imports work out of the box.
// Vite 8+ supports this natively via resolve.tsconfigPaths.
...(viteMajorVersion >= 8 ? [] : [tsconfigPaths()]),
// React Fast Refresh + JSX transform for client components.
- reactPluginPromise,
+ reactPluginPromise as unknown as PluginOption,
+ // Next.js treats page and app `.js` files as JSX-capable. Vite's parser
+ // still keys JSX mode off the file extension, so compile these before the
+ // builtin transform sees them.
+ {
+ name: "vinext:jsx-in-js",
+ enforce: "pre",
+ async transform(code, id) {
+ const [filepath] = id.split("?");
+ if (!filepath.endsWith(".js")) return null;
+ if (filepath.includes("/node_modules/")) return null;
+ if (!code.includes("<")) return null;
+
+ if (hasProjectBabelConfig(root)) {
+ const result = await transformWithProjectBabel(code, filepath, root);
+ if (!result?.code) return null;
+ return {
+ code: result.code,
+ map: (result.map ?? null) as TransformResult["map"],
+ };
+ }
+
+ const result = await transformWithOxc(code, id, {
+ lang: "jsx",
+ jsx: { runtime: "automatic", development: false },
+ });
+
+ return {
+ code: result.code,
+ map: result.map,
+ };
+ },
+ },
+ createCssDataUrlPlugin(),
+ createSwcHelpersResolverPlugin(() => root),
+ createImportMetaUrlPlugin(() => root),
+ createWasmModulePlugin(() => root),
// Transform CJS require()/module.exports to ESM before other plugins
// analyze imports (RSC directive scanning, shim resolution, etc.)
- commonjs(),
+ commonjs() as PluginOption,
{
name: "vinext:config",
enforce: "pre",
@@ -738,7 +1074,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
const userResolve = config.resolve as UserResolveConfigWithTsconfigPaths | undefined;
const shouldEnableNativeTsconfigPaths =
viteMajorVersion >= 8 && userResolve?.tsconfigPaths === undefined;
- const tsconfigPathAliases = resolveTsconfigAliases(root);
// Load .env files into process.env before anything else.
// Next.js loads .env files before evaluating next.config.js, so
@@ -824,10 +1159,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
nextConfig = await resolveNextConfig(rawConfig, root);
}
+ const tsconfigPathAliases = resolveTsconfigAliases(root, nextConfig.typescriptTsconfigPath);
fileMatcher = createValidFileMatcher(nextConfig.pageExtensions);
instrumentationPath = findInstrumentationFile(root, fileMatcher);
instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher);
middlewarePath = findMiddlewareFile(root, fileMatcher);
+ middlewareLayerModuleIds.clear();
+ edgeRuntimeModuleIds.clear();
+ if (middlewarePath) {
+ middlewareLayerModuleIds.add(moduleIdKey(middlewarePath));
+ }
// Merge env from next.config.js with NEXT_PUBLIC_* env vars
const defines = getNextPublicEnvDefines();
@@ -846,6 +1187,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
// Expose basePath to client-side code
defines["process.env.__NEXT_ROUTER_BASEPATH"] = JSON.stringify(nextConfig.basePath);
+ defines["process.env.__NEXT_ROUTER_TRAILING_SLASH"] = JSON.stringify(
+ String(nextConfig.trailingSlash),
+ );
+ defines["process.env.__NEXT_SCROLL_RESTORATION"] = JSON.stringify(
+ String(nextConfig.experimentalScrollRestoration),
+ );
// Expose image remote patterns for validation in next/image shim
defines["process.env.__VINEXT_IMAGE_REMOTE_PATTERNS"] = JSON.stringify(
JSON.stringify(nextConfig.images?.remotePatterns ?? []),
@@ -880,6 +1227,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Also used to namespace ISR cache keys so old cached entries from a
// previous deploy are never served by the new one.
defines["process.env.__VINEXT_BUILD_ID"] = JSON.stringify(nextConfig.buildId);
+ defines["process.browser"] = "true";
+ applyCompilerDefines(defines, nextConfig.compiler?.define ?? {}, "compiler.define");
+ const serverDefines = { ...defines };
+ serverDefines["process.browser"] = "false";
+ applyCompilerDefines(
+ serverDefines,
+ nextConfig.compiler?.defineServer ?? {},
+ "compiler.defineServer",
+ );
// Build the shim alias map. Exact `.js` variants are included for the
// public Next entrypoints that are file-backed in `next/package.json`.
@@ -1029,9 +1385,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// inject via css.postcss so Vite uses the resolved plugins.
// Only do this if the user hasn't already set css.postcss inline.
// oxlint-disable-next-line typescript/no-explicit-any
- let postcssOverride: { plugins: any[] } | undefined;
+ let postcssOverride: { plugins: unknown[] } | undefined;
if (!config.css?.postcss || typeof config.css.postcss === "string") {
- postcssOverride = await resolvePostcssStringPlugins(root);
+ postcssOverride = (await resolvePostcssStringPlugins(root)) as
+ | { plugins: unknown[] }
+ | undefined;
}
// Auto-inject @mdx-js/rollup when MDX files exist and no MDX plugin is
@@ -1060,8 +1418,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// (on the client env), not globally — otherwise it leaks into RSC/SSR
// environments where it can cause asset resolution issues.
const isMultiEnv = hasAppDir || hasCloudflarePlugin || hasNitroPlugin;
-
- const viteConfig: UserConfig = {
+ const viteConfig = {
// Disable Vite's default HTML serving - we handle all routing
appType: "custom",
build: {
@@ -1110,6 +1467,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
) {
return;
}
+ if (
+ warning.code === "UNRESOLVED_IMPORT" &&
+ warning.message?.includes("/node_modules/")
+ ) {
+ return;
+ }
if (userOnwarn) {
userOnwarn(warning, defaultHandler);
} else {
@@ -1154,8 +1517,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
},
// Configure SSR transform behaviour for Node targets.
- // - `external`: React packages are loaded natively by Node (CJS)
- // rather than through Vite's ESM evaluator.
+ // - `external`: Pages-only apps can load React packages natively by
+ // Node (CJS). App Router builds must not externalize React here:
+ // the RSC environment needs to bundle React with the react-server
+ // export condition.
// - `noExternal: true`: force everything else through Vite's
// transform pipeline so non-JS imports (CSS, images) from
// node_modules don't hit Node's native ESM loader.
@@ -1168,7 +1533,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
? {}
: {
ssr: {
- external: ["react", "react-dom", "react-dom/server"],
+ external: hasAppDir ? [] : ["react", "react-dom", "react-dom/server"],
noExternal: true,
},
}),
@@ -1187,10 +1552,20 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// NOTE: top-level optimizeDeps is now set below (after capturing
// incoming values from earlier plugins) so both Pages Router and
// App Router builds merge correctly.
- // Enable JSX in .tsx/.jsx files
- // Vite 7 uses `esbuild` for transforms, Vite 8+ uses `oxc`
+ // Enable JSX transforms for Next.js route files. Next.js allows JSX in
+ // plain .js pages, while Vite 8's default OXC include skips them.
...(viteMajorVersion >= 8
- ? { oxc: { jsx: { runtime: "automatic" } } }
+ ? {
+ oxc: {
+ ...(typeof config.oxc === "object" && config.oxc ? config.oxc : {}),
+ include: reactOptions?.include ?? /\.(js|jsx|ts|tsx|mjs|mts|cjs|cts)$/,
+ exclude: reactOptions?.exclude ?? /\/node_modules\//,
+ jsx:
+ typeof config.oxc === "object" && config.oxc && "jsx" in config.oxc
+ ? config.oxc.jsx
+ : { runtime: "automatic" },
+ },
+ }
: { esbuild: { jsx: "automatic" } }),
// Define env vars for client bundle
define: defines,
@@ -1198,7 +1573,19 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
...(nextConfig.basePath ? { base: nextConfig.basePath + "/" } : {}),
// Inject resolved PostCSS plugins if string names were found
...(postcssOverride ? { css: { postcss: postcssOverride } } : {}),
- };
+ } as UserConfig;
+ const oxcConfig =
+ viteMajorVersion >= 8
+ ? {
+ ...(typeof config.oxc === "object" && config.oxc ? config.oxc : {}),
+ include: reactOptions?.include ?? /\.(js|jsx|ts|tsx|mjs|mts|cjs|cts)$/,
+ exclude: reactOptions?.exclude ?? /\/node_modules\//,
+ jsx:
+ typeof config.oxc === "object" && config.oxc && "jsx" in config.oxc
+ ? config.oxc.jsx
+ : { runtime: "automatic" },
+ }
+ : null;
// Collect user-provided ssr.external so we can propagate it into
// both the RSC and SSR environment configs. Vite's `ssr.*` config
@@ -1216,11 +1603,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Without externalizing them, Vite's optimizer picks the wrong export
// condition and the build fails with MISSING_EXPORT errors.
const nextServerExternal: string[] = nextConfig?.serverExternalPackages ?? [];
- const userSsrExternal: string[] | true = Array.isArray(config.ssr?.external)
- ? [...config.ssr.external, ...nextServerExternal]
- : config.ssr?.external === true
- ? true
- : nextServerExternal;
+ const userSsrExternal: string[] | true = mergeServerExternalPackages(
+ config.ssr?.external,
+ nextServerExternal,
+ );
+ serverExternalPackages = userSsrExternal;
// Capture top-level optimizeDeps populated by earlier plugins
// (e.g. @lingui/vite-plugin) so we merge rather than overwrite.
@@ -1282,6 +1669,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
viteConfig.environments = {
rsc: {
+ define: serverDefines,
+ ...(oxcConfig ? { oxc: oxcConfig } : {}),
...(hasCloudflarePlugin || hasNitroPlugin
? {}
: {
@@ -1317,6 +1706,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
},
ssr: {
+ define: serverDefines,
+ ...(oxcConfig ? { oxc: oxcConfig } : {}),
...(hasCloudflarePlugin || hasNitroPlugin
? {}
: {
@@ -1338,10 +1729,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
outDir: options.ssrOutDir ?? "dist/server/ssr",
...withBuildBundlerOptions(viteMajorVersion, {
input: { index: VIRTUAL_APP_SSR_ENTRY },
+ output: {
+ entryFileNames: "index.js",
+ },
}),
},
},
client: {
+ define: defines,
+ ...(oxcConfig ? { oxc: oxcConfig } : {}),
// Explicitly mark as client consumer so other plugins (e.g. Nitro)
// can detect this during configEnvironment hooks — before Vite
// applies the default consumer based on environment name.
@@ -1386,9 +1782,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// the worker entry. Without this, all chunks are modulepreloaded
// on every page — defeating code-splitting for React.lazy() and
// next/dynamic boundaries.
- ...(hasCloudflarePlugin ? { manifest: true } : {}),
+ ...(hasCloudflarePlugin || hasPagesDir ? { manifest: true } : {}),
+ ...(hasPagesDir ? { ssrManifest: true } : {}),
...withBuildBundlerOptions(viteMajorVersion, {
- input: { index: VIRTUAL_APP_BROWSER_ENTRY },
+ input: hasPagesDir
+ ? { index: VIRTUAL_APP_BROWSER_ENTRY, pages: VIRTUAL_CLIENT_ENTRY }
+ : { index: VIRTUAL_APP_BROWSER_ENTRY },
output: getClientOutputConfigForVite(viteMajorVersion),
treeshake: getClientTreeshakeConfigForVite(viteMajorVersion),
}),
@@ -1402,6 +1801,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// and there's no client-side hydration.
viteConfig.environments = {
client: {
+ define: defines,
+ ...(oxcConfig ? { oxc: oxcConfig } : {}),
consumer: "client",
optimizeDeps:
pagesOptimizeEntries.length > 0 ? { entries: pagesOptimizeEntries } : undefined,
@@ -1427,6 +1828,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// alongside an explicit build input conflicts with the caller's intent.
viteConfig.environments = {
client: {
+ define: defines,
+ ...(oxcConfig ? { oxc: oxcConfig } : {}),
consumer: "client",
optimizeDeps:
pagesOptimizeEntries.length > 0 ? { entries: pagesOptimizeEntries } : undefined,
@@ -1442,8 +1845,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
},
ssr: {
+ define: serverDefines,
+ ...(oxcConfig ? { oxc: oxcConfig } : {}),
resolve: {
- external: ["react", "react-dom", "react-dom/server"],
+ external:
+ userSsrExternal === true
+ ? true
+ : ["react", "react-dom", "react-dom/server", ...userSsrExternal],
noExternal: true as const,
},
build: {
@@ -1541,84 +1949,171 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
},
- resolveId: {
- // Hook filter: only invoke JS for next/* imports and virtual:vinext-* modules.
- // Matches "next/navigation", "next/router.js", "virtual:vinext-rsc-entry",
- // and \0-prefixed re-imports from @vitejs/plugin-rsc.
- filter: {
- id: /(?:next\/|virtual:vinext-)/,
- },
- handler(id) {
- // Strip \0 prefix if present — @vitejs/plugin-rsc's generated
- // browser entry imports our virtual module using the already-resolved
- // ID (with \0 prefix). We need to re-resolve it so the client
- // environment's import-analysis can find it.
- const cleanId = id.startsWith("\0") ? id.slice(1) : id;
-
- // Pages Router virtual modules
- if (cleanId === VIRTUAL_SERVER_ENTRY) return RESOLVED_SERVER_ENTRY;
- if (cleanId === VIRTUAL_CLIENT_ENTRY) return RESOLVED_CLIENT_ENTRY;
- if (
- cleanId.endsWith("/" + VIRTUAL_SERVER_ENTRY) ||
- cleanId.endsWith("\\" + VIRTUAL_SERVER_ENTRY)
- ) {
- return RESOLVED_SERVER_ENTRY;
- }
- if (
- cleanId.endsWith("/" + VIRTUAL_CLIENT_ENTRY) ||
- cleanId.endsWith("\\" + VIRTUAL_CLIENT_ENTRY)
- ) {
- return RESOLVED_CLIENT_ENTRY;
- }
- // App Router virtual modules
- if (cleanId === VIRTUAL_RSC_ENTRY) return RESOLVED_RSC_ENTRY;
- if (cleanId === VIRTUAL_APP_SSR_ENTRY) return RESOLVED_APP_SSR_ENTRY;
- if (cleanId === VIRTUAL_APP_BROWSER_ENTRY) return RESOLVED_APP_BROWSER_ENTRY;
- if (cleanId.startsWith(VIRTUAL_GOOGLE_FONTS + "?")) {
- return RESOLVED_VIRTUAL_GOOGLE_FONTS + cleanId.slice(VIRTUAL_GOOGLE_FONTS.length);
- }
- if (
- cleanId.endsWith("/" + VIRTUAL_RSC_ENTRY) ||
- cleanId.endsWith("\\" + VIRTUAL_RSC_ENTRY)
- ) {
- return RESOLVED_RSC_ENTRY;
+ async resolveId(id, importer) {
+ // Strip \0 prefix if present — @vitejs/plugin-rsc's generated
+ // browser entry imports our virtual module using the already-resolved
+ // ID (with \0 prefix). We need to re-resolve it so the client
+ // environment's import-analysis can find it.
+ const cleanId = id.startsWith("\0") ? id.slice(1) : id;
+
+ // Pages Router virtual modules
+ if (cleanId === VIRTUAL_SERVER_ENTRY) return RESOLVED_SERVER_ENTRY;
+ if (cleanId === VIRTUAL_CLIENT_ENTRY) return RESOLVED_CLIENT_ENTRY;
+ if (
+ cleanId.endsWith("/" + VIRTUAL_SERVER_ENTRY) ||
+ cleanId.endsWith("\\" + VIRTUAL_SERVER_ENTRY)
+ ) {
+ return RESOLVED_SERVER_ENTRY;
+ }
+ if (
+ cleanId.endsWith("/" + VIRTUAL_CLIENT_ENTRY) ||
+ cleanId.endsWith("\\" + VIRTUAL_CLIENT_ENTRY)
+ ) {
+ return RESOLVED_CLIENT_ENTRY;
+ }
+ // App Router virtual modules
+ if (cleanId === VIRTUAL_RSC_ENTRY) return RESOLVED_RSC_ENTRY;
+ if (cleanId === VIRTUAL_APP_SSR_ENTRY) return RESOLVED_APP_SSR_ENTRY;
+ if (cleanId === VIRTUAL_APP_BROWSER_ENTRY) return RESOLVED_APP_BROWSER_ENTRY;
+ if (cleanId.startsWith(VIRTUAL_GOOGLE_FONTS + "?")) {
+ return RESOLVED_VIRTUAL_GOOGLE_FONTS + cleanId.slice(VIRTUAL_GOOGLE_FONTS.length);
+ }
+ if (
+ cleanId.endsWith("/" + VIRTUAL_RSC_ENTRY) ||
+ cleanId.endsWith("\\" + VIRTUAL_RSC_ENTRY)
+ ) {
+ return RESOLVED_RSC_ENTRY;
+ }
+ if (
+ cleanId.endsWith("/" + VIRTUAL_APP_SSR_ENTRY) ||
+ cleanId.endsWith("\\" + VIRTUAL_APP_SSR_ENTRY)
+ ) {
+ return RESOLVED_APP_SSR_ENTRY;
+ }
+ if (
+ cleanId.endsWith("/" + VIRTUAL_APP_BROWSER_ENTRY) ||
+ cleanId.endsWith("\\" + VIRTUAL_APP_BROWSER_ENTRY)
+ ) {
+ return RESOLVED_APP_BROWSER_ENTRY;
+ }
+ if (
+ cleanId.includes("/" + VIRTUAL_GOOGLE_FONTS + "?") ||
+ cleanId.includes("\\" + VIRTUAL_GOOGLE_FONTS + "?")
+ ) {
+ const queryIndex = cleanId.indexOf(VIRTUAL_GOOGLE_FONTS + "?");
+ return (
+ RESOLVED_VIRTUAL_GOOGLE_FONTS + cleanId.slice(queryIndex + VIRTUAL_GOOGLE_FONTS.length)
+ );
+ }
+
+ if (
+ this.environment?.name !== "client" &&
+ serverExternalPackages !== true &&
+ serverExternalPackages.length > 0
+ ) {
+ const resolvedExternal = resolveServerExternalPackageImport(
+ cleanId,
+ importer,
+ serverExternalPackages,
+ root,
+ );
+ if (resolvedExternal) {
+ return { id: resolvedExternal, external: true };
}
- if (
- cleanId.endsWith("/" + VIRTUAL_APP_SSR_ENTRY) ||
- cleanId.endsWith("\\" + VIRTUAL_APP_SSR_ENTRY)
- ) {
- return RESOLVED_APP_SSR_ENTRY;
+ }
+
+ // Middleware/proxy runs in an edge-like server layer. Its whole import
+ // graph should resolve with Next's middleware conditions, independent
+ // of the page/API/runtime that the middleware eventually hands off to.
+ if (importer && middlewareLayerModuleIds.has(moduleIdKey(importer))) {
+ const isBareImport =
+ !cleanId.startsWith(".") &&
+ !path.isAbsolute(cleanId) &&
+ !cleanId.startsWith("/") &&
+ !cleanId.startsWith("\0") &&
+ !cleanId.includes(":");
+ if (isBareImport) {
+ const parsedBareImport = parseBarePackageSpecifier(cleanId);
+ const isNonRscReactJsxRuntime =
+ this.environment?.name !== "rsc" &&
+ parsedBareImport?.packageName === "react" &&
+ (parsedBareImport.exportKey === "./jsx-runtime" ||
+ parsedBareImport.exportKey === "./jsx-dev-runtime");
+ if (!isNonRscReactJsxRuntime) {
+ const middlewareExport = resolveConditionalPackageExport(root, cleanId, [
+ "react-server",
+ "browser",
+ "edge-light",
+ "import",
+ "module",
+ "default",
+ ]);
+ if (middlewareExport) {
+ return middlewareExport;
+ }
+ }
}
+
+ return this.resolve(id, importer, { skipSelf: true });
+ }
+
+ // App Router RSC bundles must consistently use React's react-server
+ // export, including transitive JSX runtime imports from route handlers
+ // and server-only dependencies such as next/og.
+ if (this.environment?.name === "rsc") {
+ const parsedBareImport = parseBarePackageSpecifier(cleanId);
if (
- cleanId.endsWith("/" + VIRTUAL_APP_BROWSER_ENTRY) ||
- cleanId.endsWith("\\" + VIRTUAL_APP_BROWSER_ENTRY)
+ parsedBareImport &&
+ (parsedBareImport.packageName === "react" ||
+ parsedBareImport.packageName === "react-dom")
) {
- return RESOLVED_APP_BROWSER_ENTRY;
+ const reactServerExport = resolveConditionalPackageExport(root, cleanId, [
+ "react-server",
+ "import",
+ "module",
+ "default",
+ ]);
+ if (reactServerExport) {
+ return reactServerExport;
+ }
}
+ }
+
+ if (importer) {
+ const importerKey = moduleIdKey(importer);
if (
- cleanId.includes("/" + VIRTUAL_GOOGLE_FONTS + "?") ||
- cleanId.includes("\\" + VIRTUAL_GOOGLE_FONTS + "?")
+ this.environment?.name !== "client" &&
+ (edgeRuntimeModuleIds.has(importerKey) || fileDeclaresEdgeRuntime(importerKey))
) {
- const queryIndex = cleanId.indexOf(VIRTUAL_GOOGLE_FONTS + "?");
- return (
- RESOLVED_VIRTUAL_GOOGLE_FONTS +
- cleanId.slice(queryIndex + VIRTUAL_GOOGLE_FONTS.length)
- );
- }
-
- // Shims with react-server variants — resolve per-environment.
- // These are NOT in resolve.alias (Vite's alias plugin runs
- // before enforce:"pre" plugins and can't be overridden).
- // See https://github.com/cloudflare/vinext/issues/834
- const reactServerShim = _reactServerShims.get(cleanId);
- if (reactServerShim !== undefined) {
- const shimName =
+ edgeRuntimeModuleIds.add(importerKey);
+ const edgeConditions =
this.environment?.name === "rsc"
- ? `${reactServerShim}.react-server`
- : reactServerShim;
- return resolveShimModulePath(_shimsDir, shimName);
+ ? ["react-server", "browser", "edge-light", "import", "module", "default"]
+ : ["browser", "edge-light", "import", "module", "default"];
+ const edgeExport = resolveConditionalPackageExport(root, cleanId, edgeConditions);
+ if (edgeExport) {
+ edgeRuntimeModuleIds.add(moduleIdKey(edgeExport));
+ return edgeExport;
+ }
+
+ const resolved = await this.resolve(id, importer, { skipSelf: true });
+ if (resolved && !resolved.external) {
+ edgeRuntimeModuleIds.add(moduleIdKey(resolved.id));
+ }
+ return resolved;
}
- },
+ }
+
+ // Shims with react-server variants — resolve per-environment.
+ // These are NOT in resolve.alias (Vite's alias plugin runs
+ // before enforce:"pre" plugins and can't be overridden).
+ // See https://github.com/cloudflare/vinext/issues/834
+ const reactServerShim = _reactServerShims.get(cleanId);
+ if (reactServerShim !== undefined) {
+ const shimName =
+ this.environment?.name === "rsc" ? `${reactServerShim}.react-server` : reactServerShim;
+ return resolveShimModulePath(_shimsDir, shimName);
+ }
},
async load(id) {
@@ -1659,8 +2154,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
allowedDevOrigins: nextConfig?.allowedDevOrigins,
bodySizeLimit: nextConfig?.serverActionsBodySizeLimit,
i18n: nextConfig?.i18n,
+ assetPrefix: nextConfig?.assetPrefix,
hasPagesDir,
publicFiles: scanPublicFileRoutes(root),
+ clientTraceMetadata: nextConfig?.clientTraceMetadata,
},
instrumentationPath,
);
@@ -2348,7 +2845,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// strip it (for robustness) but don't reject paths that don't start
// with basePath — Vite has already done the filtering.
const bp = nextConfig?.basePath ?? "";
- if (bp && pathname.startsWith(bp)) {
+ const requestHadBasePath = !bp || hasBasePath(pathname, bp);
+ if (bp && hasBasePath(pathname, bp)) {
const stripped = pathname.slice(bp.length) || "/";
const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
url = stripped + qs;
@@ -2364,14 +2862,26 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
!pathname.startsWith("/api/")
) {
const hasTrailing = pathname.endsWith("/");
- if (nextConfig.trailingSlash && !hasTrailing) {
+ const pathWithoutTrailing = pathname.replace(/\/+$/, "");
+ const lastSegment = pathWithoutTrailing.slice(
+ pathWithoutTrailing.lastIndexOf("/") + 1,
+ );
+ const isFileLike = lastSegment.includes(".");
+ if (isFileLike && hasTrailing) {
+ const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
+ const dest = bp + pathWithoutTrailing + qs;
+ res.writeHead(308, { Location: dest });
+ res.end();
+ return;
+ }
+ if (nextConfig.trailingSlash && !hasTrailing && !isFileLike) {
// trailingSlash: true — redirect /about → /about/
const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
const dest = bp + pathname + "/" + qs;
res.writeHead(308, { Location: dest });
res.end();
return;
- } else if (!nextConfig.trailingSlash && hasTrailing) {
+ } else if (!nextConfig.trailingSlash && hasTrailing && !isFileLike) {
// trailingSlash: false (default) — redirect /about/ → /about
const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
const dest = bp + pathname.replace(/\/+$/, "") + qs;
@@ -2461,7 +2971,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
middlewarePath,
middlewareRequest,
nextConfig?.i18n,
- nextConfig?.basePath,
+ requestHadBasePath ? nextConfig?.basePath : "",
);
// Settle waitUntil promises — no ctx.waitUntil() in dev, but
@@ -2762,6 +3272,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
},
},
+ {
+ name: "vinext:server-css-url-imports",
+ transform: {
+ filter: { id: /\.(tsx?|jsx?|mjs)$/ },
+ handler(code) {
+ if (this.environment?.name === "client") return null;
+ if (!code.includes("new URL") || !code.includes(".css")) return null;
+ const transformed = code.replace(
+ DYNAMIC_CSS_URL_IMPORT_RE,
+ 'Promise.resolve({ default: "" })',
+ );
+ if (transformed === code) return null;
+ return { code: transformed, map: null };
+ },
+ },
+ },
// Local image import transform:
// When a source file imports a local image (e.g., `import hero from './hero.jpg'`),
// this plugin transforms the default import to a StaticImageData object with
@@ -3099,6 +3625,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Inline binary assets fetched via `fetch(new URL("./asset", import.meta.url))` —
// see src/plugins/og-assets.ts
createOgInlineFetchAssetsPlugin(),
+ // Match Next edge compiler behavior for `fetch(new URL(asset, import.meta.url))`
+ // by making local asset URLs fetchable in the best-effort Pages edge runtime.
+ createEdgeBlobAssetsPlugin(),
// Copy @vercel/og binary assets to the RSC output directory — see src/plugins/og-assets.ts
ogAssetsPlugin,
// Collect SSR/RSC bundle externals and write dist/server/vinext-externals.json.
@@ -3160,6 +3689,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
};
})(),
+ {
+ name: "vinext:next-static-compat-assets",
+ apply: "build" as const,
+ enforce: "post" as const,
+ writeBundle: {
+ sequential: true,
+ order: "post" as const,
+ handler(outputOptions: { dir?: string }) {
+ if (this.environment?.name !== "client") return;
+ const outDir = outputOptions.dir;
+ if (!outDir) return;
+ writeNextStaticCompatAssets(outDir, nextConfig!.buildId);
+ },
+ },
+ },
// Write vinext-server.json to dist/server/ with a per-build prerender secret.
// The prerender secret is used by prod-server.ts to authenticate requests to
// the internal /__vinext/prerender/* endpoints, which are only reachable during
@@ -3650,6 +4194,21 @@ function getNextPublicEnvDefines(): Record {
return defines;
}
+function applyCompilerDefines(
+ defines: Record,
+ userDefines: Record,
+ optionName: "compiler.define" | "compiler.defineServer",
+) {
+ for (const [key, value] of Object.entries(userDefines)) {
+ if (Object.hasOwn(defines, key)) {
+ throw new Error(
+ `The \`${optionName}\` option is configured to replace the \`${key}\` variable. This variable is either part of a Next.js built-in or is already configured.`,
+ );
+ }
+ defines[key] = JSON.stringify(value);
+ }
+}
+
// matchConfigPattern is imported from config-matchers.ts and re-exported
// for tests and other consumers that import it from vinext's main entry.
// The duplicate local implementation and its extractConstraint helper
diff --git a/packages/vinext/src/plugins/css-data-url.ts b/packages/vinext/src/plugins/css-data-url.ts
new file mode 100644
index 000000000..b10112c91
--- /dev/null
+++ b/packages/vinext/src/plugins/css-data-url.ts
@@ -0,0 +1,63 @@
+import { createHash } from "node:crypto";
+import type { Plugin } from "vite";
+
+const RESOLVED_PREFIX = "\0vinext:css-data-url:";
+
+export function decodeCssDataUrl(id: string): string | null {
+ if (!id.startsWith("data:text/css")) return null;
+
+ const commaIndex = id.indexOf(",");
+ if (commaIndex === -1) return null;
+
+ const metadata = id.slice(0, commaIndex).toLowerCase();
+ const payload = id.slice(commaIndex + 1);
+
+ if (metadata.includes(";base64")) {
+ return Buffer.from(payload, "base64").toString("utf8");
+ }
+
+ try {
+ return decodeURIComponent(payload);
+ } catch {
+ return payload;
+ }
+}
+
+function styleIdForCss(css: string): string {
+ return `vinext-css-data-url-${createHash("sha256").update(css).digest("hex").slice(0, 16)}`;
+}
+
+export function createCssDataUrlPlugin(): Plugin {
+ const styles = new Map();
+
+ return {
+ name: "vinext:css-data-url",
+ enforce: "pre",
+
+ resolveId(source) {
+ const css = decodeCssDataUrl(source);
+ if (css === null) return null;
+
+ const id = `${RESOLVED_PREFIX}${styleIdForCss(css)}`;
+ styles.set(id, css);
+ return id;
+ },
+
+ load(id) {
+ const css = styles.get(id);
+ if (css === undefined) return null;
+
+ return [
+ `const css = ${JSON.stringify(css)};`,
+ `const id = ${JSON.stringify(styleIdForCss(css))};`,
+ `if (typeof document !== "undefined" && !document.getElementById(id)) {`,
+ ` const style = document.createElement("style");`,
+ ` style.id = id;`,
+ ` style.textContent = css;`,
+ ` document.head.appendChild(style);`,
+ `}`,
+ `export default css;`,
+ ].join("\n");
+ },
+ };
+}
diff --git a/packages/vinext/src/plugins/edge-blob-assets.ts b/packages/vinext/src/plugins/edge-blob-assets.ts
new file mode 100644
index 000000000..a41a9a62f
--- /dev/null
+++ b/packages/vinext/src/plugins/edge-blob-assets.ts
@@ -0,0 +1,90 @@
+import fs from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import type { Plugin } from "vite";
+
+const ASSET_MIME_TYPES: Record = {
+ ".gif": "image/gif",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".json": "application/json",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".txt": "text/plain",
+ ".webp": "image/webp",
+};
+
+function isExternalSpecifier(specifier: string): boolean {
+ try {
+ return new URL(specifier).protocol !== "";
+ } catch {
+ return false;
+ }
+}
+
+function shouldInlineSpecifier(specifier: string): boolean {
+ if (isExternalSpecifier(specifier)) return false;
+ return Object.hasOwn(ASSET_MIME_TYPES, path.extname(specifier).toLowerCase());
+}
+
+function resolveAssetPath(specifier: string, importer: string): string | null {
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
+ return path.resolve(path.dirname(importer), specifier);
+ }
+
+ try {
+ return createRequire(importer).resolve(specifier);
+ } catch {
+ return null;
+ }
+}
+
+export async function transformEdgeBlobAssetUrls(
+ code: string,
+ id: string,
+ readFile: (filePath: string) => Promise = fs.promises.readFile,
+): Promise {
+ if (!code.includes("import.meta.url")) return null;
+
+ const pattern = /new\s+URL\(\s*(["'])([^"']+)\1\s*,\s*import\.meta\.url\s*\)/g;
+
+ let output = code;
+ let didReplace = false;
+
+ for (const match of code.matchAll(pattern)) {
+ const fullMatch = match[0];
+ const specifier = match[2];
+ if (!shouldInlineSpecifier(specifier)) continue;
+
+ const assetPath = resolveAssetPath(specifier, id);
+ if (!assetPath) continue;
+
+ let asset: Buffer;
+ try {
+ asset = await readFile(assetPath);
+ } catch {
+ continue;
+ }
+
+ const mimeType =
+ ASSET_MIME_TYPES[path.extname(specifier).toLowerCase()] ?? "application/octet-stream";
+ const dataUrl = `data:${mimeType};base64,${asset.toString("base64")}`;
+
+ output = output.replaceAll(fullMatch, `new URL(${JSON.stringify(dataUrl)})`);
+ didReplace = true;
+ }
+
+ return didReplace ? output : null;
+}
+
+export function createEdgeBlobAssetsPlugin(): Plugin {
+ return {
+ name: "vinext:edge-blob-assets",
+ enforce: "pre",
+ async transform(code, id) {
+ if (this.environment?.name === "client") return null;
+ const transformed = await transformEdgeBlobAssetUrls(code, id);
+ return transformed ? { code: transformed, map: null } : null;
+ },
+ };
+}
diff --git a/packages/vinext/src/plugins/import-meta-url.ts b/packages/vinext/src/plugins/import-meta-url.ts
new file mode 100644
index 000000000..a4b5aeb8b
--- /dev/null
+++ b/packages/vinext/src/plugins/import-meta-url.ts
@@ -0,0 +1,153 @@
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import MagicString from "magic-string";
+import type { Plugin, TransformResult } from "vite";
+
+type TransformOptions = {
+ environmentName?: string;
+ root: string;
+ turbopackRootPlaceholder?: boolean;
+};
+
+function cleanModuleId(id: string): string {
+ const cleanId = id.startsWith("\0") ? id.slice(1) : id;
+ const queryIndex = cleanId.search(/[?#]/);
+ return queryIndex === -1 ? cleanId : cleanId.slice(0, queryIndex);
+}
+
+function isIdentifierChar(char: string | undefined): boolean {
+ return char !== undefined && /[A-Za-z0-9_$]/.test(char);
+}
+
+function findEnclosingParen(code: string, index: number): number {
+ let depth = 0;
+ for (let i = index - 1; i >= 0; i--) {
+ const char = code[i];
+ if (char === ")") {
+ depth++;
+ } else if (char === "(") {
+ if (depth === 0) return i;
+ depth--;
+ }
+ }
+ return -1;
+}
+
+function isNewUrlImportMetaBase(code: string, index: number): boolean {
+ const parenIndex = findEnclosingParen(code, index);
+ if (parenIndex === -1) return false;
+
+ const beforeParen = code.slice(Math.max(0, parenIndex - 32), parenIndex).trimEnd();
+ if (!/(^|[^\w$])new\s+URL$/.test(beforeParen)) return false;
+
+ return code.slice(parenIndex + 1, index).includes(",");
+}
+
+function findImportMetaUrlRanges(code: string): Array<[number, number]> {
+ const ranges: Array<[number, number]> = [];
+ const needle = "import.meta.url";
+ let i = 0;
+
+ while (i < code.length) {
+ const char = code[i];
+ const next = code[i + 1];
+
+ if (char === "/" && next === "/") {
+ i = code.indexOf("\n", i + 2);
+ if (i === -1) break;
+ continue;
+ }
+
+ if (char === "/" && next === "*") {
+ const end = code.indexOf("*/", i + 2);
+ i = end === -1 ? code.length : end + 2;
+ continue;
+ }
+
+ if (char === '"' || char === "'" || char === "`") {
+ const quote = char;
+ i++;
+ while (i < code.length) {
+ if (code[i] === "\\") {
+ i += 2;
+ continue;
+ }
+ if (code[i] === quote) {
+ i++;
+ break;
+ }
+ i++;
+ }
+ continue;
+ }
+
+ if (
+ code.startsWith(needle, i) &&
+ !isIdentifierChar(code[i - 1]) &&
+ !isIdentifierChar(code[i + needle.length]) &&
+ !isNewUrlImportMetaBase(code, i)
+ ) {
+ ranges.push([i, i + needle.length]);
+ i += needle.length;
+ continue;
+ }
+
+ i++;
+ }
+
+ return ranges;
+}
+
+function sourceUrlForModule(id: string, options: TransformOptions): string {
+ const filepath = cleanModuleId(id);
+ const normalizedFilepath = filepath.replace(/\\/g, "/");
+ const normalizedRoot = options.root.replace(/\\/g, "/");
+
+ if (options.environmentName === "client" && options.turbopackRootPlaceholder === true) {
+ const relativePath = path.posix.relative(normalizedRoot, normalizedFilepath);
+ if (!relativePath.startsWith("../") && relativePath !== "..") {
+ return `file:///ROOT/${relativePath}`;
+ }
+ }
+
+ return pathToFileURL(filepath).href;
+}
+
+export function transformNextImportMetaUrl(
+ code: string,
+ id: string,
+ options: TransformOptions,
+): TransformResult | null {
+ if (!code.includes("import.meta.url")) return null;
+ if (id.includes("node_modules")) return null;
+ if (id.startsWith("\0")) return null;
+ if (!/\.(tsx?|jsx?|mjs)$/.test(cleanModuleId(id))) return null;
+
+ const ranges = findImportMetaUrlRanges(code);
+ if (ranges.length === 0) return null;
+
+ const replacement = JSON.stringify(sourceUrlForModule(id, options));
+ const output = new MagicString(code);
+ for (const [start, end] of ranges) {
+ output.overwrite(start, end, replacement);
+ }
+
+ return {
+ code: output.toString(),
+ map: output.generateMap({ hires: "boundary" }) as TransformResult["map"],
+ };
+}
+
+export function createImportMetaUrlPlugin(getRoot: () => string): Plugin {
+ return {
+ name: "vinext:import-meta-url",
+ enforce: "pre",
+ transform(code, id) {
+ return transformNextImportMetaUrl(code, id, {
+ environmentName: this.environment?.name,
+ root: getRoot(),
+ turbopackRootPlaceholder: process.env.IS_TURBOPACK_TEST === "1",
+ });
+ },
+ };
+}
diff --git a/packages/vinext/src/plugins/og-assets.ts b/packages/vinext/src/plugins/og-assets.ts
index 365ff6ba3..a25e398b1 100644
--- a/packages/vinext/src/plugins/og-assets.ts
+++ b/packages/vinext/src/plugins/og-assets.ts
@@ -85,7 +85,7 @@ export function createOgInlineFetchAssetsPlugin(): Plugin {
// Replace with an inline IIFE that decodes the asset as base64 and returns Promise.
if (code.includes("fetch(")) {
const fetchPattern =
- /fetch\(\s*new URL\(\s*(["'])(\.\/[^"']+)\1\s*,\s*import\.meta\.url\s*\)\s*\)(?:\.then\(\s*(?:function\s*\([^)]*\)|\([^)]*\)\s*=>)\s*\{?\s*return\s+[^.]+\.arrayBuffer\(\)\s*\}?\s*\)|\.then\(\s*\([^)]*\)\s*=>\s*[^.]+\.arrayBuffer\(\)\s*\))/g;
+ /fetch\(\s*new URL\(\s*(["'])((?:\.{1,2}\/)[^"']+)\1\s*,\s*import\.meta\.url\s*\)\s*\)(?:\.then\(\s*(?:function\s*\([^)]*\)|\([^)]*\)\s*=>)\s*\{?\s*return\s+[^.]+\.arrayBuffer\(\)\s*\}?\s*\)|\.then\(\s*\([^)]*\)\s*=>\s*[^.]+\.arrayBuffer\(\)\s*\))/g;
for (const match of code.matchAll(fetchPattern)) {
const fullMatch = match[0];
@@ -127,7 +127,7 @@ export function createOgInlineFetchAssetsPlugin(): Plugin {
// both font data passed to satori and WASM bytes passed to initWasm).
if (code.includes("readFileSync(")) {
const readFilePattern =
- /[a-zA-Z_$][a-zA-Z0-9_$]*\.readFileSync\(\s*(?:[a-zA-Z_$][a-zA-Z0-9_$]*\.)?fileURLToPath\(\s*new URL\(\s*(["'])(\.\/[^"']+)\1\s*,\s*import\.meta\.url\s*\)\s*\)\s*\)/g;
+ /[a-zA-Z_$][a-zA-Z0-9_$]*\.readFileSync\(\s*(?:[a-zA-Z_$][a-zA-Z0-9_$]*\.)?fileURLToPath\(\s*new URL\(\s*(["'])((?:\.{1,2}\/)[^"']+)\1\s*,\s*import\.meta\.url\s*\)\s*\)\s*\)/g;
for (const match of newCode.matchAll(readFilePattern)) {
const fullMatch = match[0];
diff --git a/packages/vinext/src/plugins/postcss.ts b/packages/vinext/src/plugins/postcss.ts
index e18567a82..4b61b48e3 100644
--- a/packages/vinext/src/plugins/postcss.ts
+++ b/packages/vinext/src/plugins/postcss.ts
@@ -2,6 +2,12 @@ import path from "node:path";
import fs from "node:fs";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
+import { createJiti } from "jiti";
+
+type PostCSSConfig = {
+ plugins?: unknown[] | Record;
+ [key: string]: unknown;
+};
/**
* PostCSS config file names to search for, in priority order.
@@ -31,7 +37,7 @@ const POSTCSS_CONFIG_FILES = [
* Stores the Promise itself so concurrent calls (RSC/SSR/Client config() hooks firing in
* parallel) all await the same in-flight scan rather than each starting their own.
*/
-export const postcssCache = new Map>();
+export const postcssCache = new Map>();
/**
* Resolve PostCSS string plugin names in a project's PostCSS config.
@@ -47,7 +53,7 @@ export const postcssCache = new Map {
+): Promise {
if (postcssCache.has(projectRoot)) return postcssCache.get(projectRoot)!;
const promise = resolvePostcssStringPluginsUncached(projectRoot);
@@ -57,7 +63,7 @@ export function resolvePostcssStringPlugins(
async function resolvePostcssStringPluginsUncached(
projectRoot: string,
-): Promise<{ plugins: unknown[] } | undefined> {
+): Promise {
// Find the PostCSS config file
let configPath: string | null = null;
for (const name of POSTCSS_CONFIG_FILES) {
@@ -91,22 +97,29 @@ async function resolvePostcssStringPluginsUncached(
return undefined;
}
}
- const mod = await import(pathToFileURL(configPath).href);
- config = mod.default ?? mod;
+ config = await loadPostcssConfig(configPath);
} catch {
// If we can't load the config, let Vite/postcss-load-config handle it
return undefined;
}
- // Only process array-form plugins that contain string entries
+ // Process array-form plugins that contain string entries
// (either bare strings or tuple form ["plugin-name", { options }])
if (!config || !Array.isArray(config.plugins)) {
+ // Vite needs tsx or jiti installed in the app to load TypeScript PostCSS
+ // configs. vinext ships jiti, so pass loaded object configs through directly.
+ if (isTypeScriptConfig(configPath) && config && typeof config === "object") {
+ return config;
+ }
return undefined;
}
const hasStringPlugins = config.plugins.some(
(p: unknown) => typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"),
);
if (!hasStringPlugins) {
+ if (isTypeScriptConfig(configPath)) {
+ return config;
+ }
return undefined;
}
@@ -134,5 +147,19 @@ async function resolvePostcssStringPluginsUncached(
}),
);
- return { plugins: resolved };
+ return { ...config, plugins: resolved };
+}
+
+async function loadPostcssConfig(configPath: string): Promise {
+ if (isTypeScriptConfig(configPath)) {
+ const jiti = createJiti(configPath);
+ return await jiti.import(configPath, { default: true });
+ }
+
+ const mod = await import(pathToFileURL(configPath).href);
+ return mod.default ?? mod;
+}
+
+function isTypeScriptConfig(configPath: string): boolean {
+ return /\.(?:c|m)?ts$/.test(configPath);
}
diff --git a/packages/vinext/src/plugins/strip-server-exports.ts b/packages/vinext/src/plugins/strip-server-exports.ts
index fbcad33bf..c4a936be8 100644
--- a/packages/vinext/src/plugins/strip-server-exports.ts
+++ b/packages/vinext/src/plugins/strip-server-exports.ts
@@ -26,6 +26,31 @@ export function stripServerExports(code: string): string | null {
const s = new MagicString(code);
let changed = false;
+ const localBindings = new Set();
+ const strippedRanges: Array<{ start: number; end: number }> = [];
+
+ for (const node of ast.body) {
+ if (node.type === "FunctionDeclaration" && node.id) {
+ localBindings.add(node.id.name);
+ } else if (node.type === "VariableDeclaration") {
+ for (const declarator of node.declarations) {
+ if (declarator.id?.type === "Identifier") {
+ localBindings.add(declarator.id.name);
+ }
+ }
+ } else if (node.type === "ExportNamedDeclaration" && node.declaration) {
+ const decl = node.declaration;
+ if (decl.type === "FunctionDeclaration" && decl.id) {
+ localBindings.add(decl.id.name);
+ } else if (decl.type === "VariableDeclaration") {
+ for (const declarator of decl.declarations) {
+ if (declarator.id?.type === "Identifier") {
+ localBindings.add(declarator.id.name);
+ }
+ }
+ }
+ }
+ }
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration") continue;
@@ -40,11 +65,13 @@ export function stripServerExports(code: string): string | null {
node.end,
`export function ${decl.id.name}() { return { props: {} }; }`,
);
+ strippedRanges.push({ start: node.start, end: node.end });
changed = true;
} else if (decl.type === "VariableDeclaration") {
for (const declarator of decl.declarations) {
if (declarator.id?.type === "Identifier" && SERVER_EXPORTS.has(declarator.id.name)) {
s.overwrite(node.start, node.end, `export const ${declarator.id.name} = undefined;`);
+ strippedRanges.push({ start: node.start, end: node.end });
changed = true;
}
}
@@ -55,13 +82,15 @@ export function stripServerExports(code: string): string | null {
// Case 3: export { getServerSideProps } or export { getServerSideProps as gSSP }
if (node.specifiers && node.specifiers.length > 0 && !node.source) {
const kept: Extract[] = [];
- const stripped: string[] = [];
+ const stripped: Array<{ exportedName: string; localName: string }> = [];
for (const spec of node.specifiers) {
// spec.local.name is the binding name, spec.exported.name is the export name
// oxlint-disable-next-line typescript/no-explicit-any
const exportedName = (spec.exported as any)?.name ?? (spec.exported as any)?.value;
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const localName = (spec.local as any)?.name ?? (spec.local as any)?.value ?? exportedName;
if (SERVER_EXPORTS.has(exportedName)) {
- stripped.push(exportedName);
+ stripped.push({ exportedName, localName });
} else {
kept.push(spec);
}
@@ -80,15 +109,56 @@ export function stripServerExports(code: string): string | null {
.join(", ");
parts.push(`export { ${keptStr} };`);
}
- for (const name of stripped) {
- parts.push(`export const ${name} = undefined;`);
+ for (const { exportedName, localName } of stripped) {
+ // `const getServerSideProps = ...; export { getServerSideProps }`
+ // already has a local binding. Emitting `export const ...` would
+ // redeclare that binding and make the client build fail before tree
+ // shaking. Removing the export is enough to hide it from the client.
+ if (localName === exportedName && localBindings.has(localName)) {
+ continue;
+ }
+ parts.push(`export const ${exportedName} = undefined;`);
}
s.overwrite(node.start, node.end, parts.join("\n"));
+ strippedRanges.push({ start: node.start, end: node.end });
changed = true;
}
}
}
if (!changed) return null;
+
+ let analysisCode = code;
+ for (const { start, end } of strippedRanges) {
+ analysisCode = analysisCode.slice(0, start) + " ".repeat(end - start) + analysisCode.slice(end);
+ }
+ for (const node of ast.body) {
+ if (node.type === "ImportDeclaration") {
+ analysisCode =
+ analysisCode.slice(0, node.start) +
+ " ".repeat(node.end - node.start) +
+ analysisCode.slice(node.end);
+ }
+ }
+
+ for (const node of ast.body) {
+ if (node.type !== "ImportDeclaration") continue;
+ const localNames = node.specifiers
+ .map((specifier) => specifier.local?.name)
+ .filter((name): name is string => Boolean(name));
+ if (localNames.length === 0) continue;
+
+ const isUsedOutsideStrippedServerExports = localNames.some((name) =>
+ new RegExp(`\\b${escapeRegExp(name)}\\b`).test(analysisCode),
+ );
+ if (!isUsedOutsideStrippedServerExports) {
+ s.remove(node.start, node.end);
+ }
+ }
+
return s.toString();
}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
diff --git a/packages/vinext/src/plugins/styled-jsx.ts b/packages/vinext/src/plugins/styled-jsx.ts
new file mode 100644
index 000000000..485d71ff6
--- /dev/null
+++ b/packages/vinext/src/plugins/styled-jsx.ts
@@ -0,0 +1,7 @@
+export function minifyStyledJsxCss(css: string): string {
+ return css
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\s+/g, " ")
+ .replace(/\s*([{}:;,>+~])\s*/g, "$1")
+ .trim();
+}
diff --git a/packages/vinext/src/plugins/swc-helpers.ts b/packages/vinext/src/plugins/swc-helpers.ts
new file mode 100644
index 000000000..f58dd3a06
--- /dev/null
+++ b/packages/vinext/src/plugins/swc-helpers.ts
@@ -0,0 +1,32 @@
+import { createRequire } from "node:module";
+import path from "node:path";
+import type { Plugin } from "vite";
+
+export function resolveSwcHelperFromNext(projectRoot: string, specifier: string): string | null {
+ if (!specifier.startsWith("@swc/helpers/")) return null;
+
+ const projectRequire = createRequire(path.join(projectRoot, "package.json"));
+
+ try {
+ const nextPackageJson = projectRequire.resolve("next/package.json");
+ const nextRequire = createRequire(nextPackageJson);
+ return nextRequire.resolve(specifier);
+ } catch {}
+
+ try {
+ return projectRequire.resolve(specifier);
+ } catch {
+ return null;
+ }
+}
+
+export function createSwcHelpersResolverPlugin(getRoot: () => string): Plugin {
+ return {
+ name: "vinext:swc-helpers-resolver",
+ enforce: "pre",
+
+ resolveId(source) {
+ return resolveSwcHelperFromNext(getRoot(), source);
+ },
+ };
+}
diff --git a/packages/vinext/src/plugins/wasm-module.ts b/packages/vinext/src/plugins/wasm-module.ts
new file mode 100644
index 000000000..f22ca8813
--- /dev/null
+++ b/packages/vinext/src/plugins/wasm-module.ts
@@ -0,0 +1,87 @@
+import fs from "node:fs";
+import path from "node:path";
+import type { Plugin, ResolvedConfig } from "vite";
+
+const WASM_MODULE_PREFIX = "\0vinext-wasm-module:";
+const WASM_MODULE_QUERY_RE = /\.wasm\?module(?:$|[])/;
+
+export function isWasmModuleRequest(id: string): boolean {
+ return WASM_MODULE_QUERY_RE.test(id);
+}
+
+export function stripWasmModuleQuery(id: string): string {
+ return id.replace(/\?module(?:$|[].*)/, "");
+}
+
+export function resolveWasmModuleFile(
+ source: string,
+ importer: string | undefined,
+ root: string,
+): string {
+ const cleanSource = stripWasmModuleQuery(source.startsWith("\0") ? source.slice(1) : source);
+ if (path.isAbsolute(cleanSource)) return path.resolve(cleanSource);
+
+ if (cleanSource.startsWith(".") && importer) {
+ const cleanImporter = stripWasmModuleQuery(
+ importer.startsWith("\0") ? importer.slice(1) : importer,
+ );
+ return path.resolve(path.dirname(cleanImporter), cleanSource);
+ }
+
+ return path.resolve(root, cleanSource);
+}
+
+export function renderWasmModuleCode(bytes: Uint8Array): string {
+ const base64 = Buffer.from(bytes).toString("base64");
+ return `
+const __vinextWasmBase64 = ${JSON.stringify(base64)};
+function __vinextDecodeBase64(value) {
+ if (typeof atob === "function") {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+ }
+ if (typeof Buffer !== "undefined") {
+ return Uint8Array.from(Buffer.from(value, "base64"));
+ }
+ throw new Error("Unable to decode WASM module bytes");
+}
+export default new WebAssembly.Module(__vinextDecodeBase64(__vinextWasmBase64));
+`;
+}
+
+export function createWasmModulePlugin(getRoot: () => string): Plugin {
+ let config: ResolvedConfig | undefined;
+
+ return {
+ name: "vinext:wasm-module",
+ enforce: "pre",
+ configResolved(resolvedConfig) {
+ config = resolvedConfig;
+ },
+ resolveId(source, importer) {
+ if (!isWasmModuleRequest(source)) return null;
+
+ // Cloudflare's Vite plugin understands `?module` WASM imports for Workers.
+ // Only provide the fallback for plain vinext builds where Vite/Rolldown
+ // would otherwise try to load a literal `*.wasm?module` file.
+ if (
+ config?.plugins.some(
+ (plugin) =>
+ plugin.name === "vite-plugin-cloudflare" ||
+ plugin.name.startsWith("vite-plugin-cloudflare:"),
+ )
+ ) {
+ return null;
+ }
+
+ return WASM_MODULE_PREFIX + resolveWasmModuleFile(source, importer, getRoot());
+ },
+ load(id) {
+ if (!id.startsWith(WASM_MODULE_PREFIX)) return null;
+ const file = id.slice(WASM_MODULE_PREFIX.length);
+ return renderWasmModuleCode(fs.readFileSync(file));
+ },
+ };
+}
diff --git a/packages/vinext/src/routing/pages-router.ts b/packages/vinext/src/routing/pages-router.ts
index 739827535..24a4adc95 100644
--- a/packages/vinext/src/routing/pages-router.ts
+++ b/packages/vinext/src/routing/pages-router.ts
@@ -1,4 +1,5 @@
import path from "node:path";
+import { readFile } from "node:fs/promises";
import { compareRoutes, decodeRouteSegment, normalizePathnameForRouteMatch } from "./utils.js";
import {
createValidFileMatcher,
@@ -23,6 +24,7 @@ export type Route = {
// Route cache — invalidated when pages directory changes
const routeCache = new Map }>();
+const warnedInvalidStaticLinkHrefs = new Set();
/**
* Invalidate cached routes for a given pages directory.
@@ -76,7 +78,10 @@ async function scanPageRoutes(pagesDir: string, matcher: ValidFileMatcher): Prom
(name: string) => name === "api" || name.startsWith("_"),
)) {
const route = fileToRoute(file, pagesDir, matcher);
- if (route) routes.push(route);
+ if (route) {
+ routes.push(route);
+ await warnInvalidStaticLinkHrefs(route);
+ }
}
validateRoutePatterns(routes.map((route) => route.pattern));
@@ -87,6 +92,44 @@ async function scanPageRoutes(pagesDir: string, matcher: ValidFileMatcher): Prom
return routes;
}
+function hasRepeatedForwardSlashOrBackslash(href: string): boolean {
+ if (href.includes("\\")) return true;
+
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("//")) {
+ try {
+ return new URL(href, "http://vinext.local").pathname.includes("//");
+ } catch {
+ return false;
+ }
+ }
+
+ const pathname = href.split(/[?#]/, 1)[0] ?? "";
+ return pathname.includes("//");
+}
+
+async function warnInvalidStaticLinkHrefs(route: Route): Promise {
+ let source: string;
+ try {
+ source = await readFile(route.filePath, "utf8");
+ } catch {
+ return;
+ }
+
+ const linkHrefPattern = /]*\bhref=(["'])(.*?)\1/g;
+ for (const match of source.matchAll(linkHrefPattern)) {
+ const href = match[2] ?? "";
+ if (!hasRepeatedForwardSlashOrBackslash(href)) continue;
+
+ const page = patternToNextFormat(route.pattern);
+ const warningKey = `${route.filePath}:${href}`;
+ if (warnedInvalidStaticLinkHrefs.has(warningKey)) continue;
+ warnedInvalidStaticLinkHrefs.add(warningKey);
+ console.error(
+ `Invalid href '${href}' passed to next/router in page: '${page}'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.`,
+ );
+ }
+}
+
/**
* Convert a file path relative to pages/ into a Route.
*/
diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts
index 6408088df..ba9a9b2f4 100644
--- a/packages/vinext/src/server/app-browser-entry.ts
+++ b/packages/vinext/src/server/app-browser-entry.ts
@@ -53,6 +53,9 @@ import {
getVinextBrowserGlobal,
} from "./app-browser-stream.js";
import {
+ APP_INTERCEPTION_CONTEXT_KEY,
+ APP_ROOT_LAYOUT_KEY,
+ APP_ROUTE_KEY,
createAppPayloadCacheKey,
getMountedSlotIdsHeader,
normalizeAppElements,
@@ -107,6 +110,7 @@ type VisitedResponseCacheEntry = {
const MAX_VISITED_RESPONSE_CACHE_SIZE = 50;
const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000;
const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000;
+const loadingPayloadCache = new Map();
// These are plain module-level variables, unlike ClientNavigationState in
// navigation.ts which uses Symbol.for to survive multiple Vite module instances.
@@ -436,13 +440,29 @@ function getRequestState(
}
function createRscRequestHeaders(interceptionContext: string | null): Headers {
- const headers = new Headers({ Accept: "text/x-component" });
+ const headers = new Headers({ Accept: "text/x-component", RSC: "1" });
if (interceptionContext !== null) {
headers.set("X-Vinext-Interception-Context", interceptionContext);
}
return headers;
}
+function createRscLoadingRequestHeaders(interceptionContext: string | null): Headers {
+ const headers = new Headers({
+ Accept: "text/x-component",
+ "X-Vinext-Loading-Payload": "1",
+ });
+ if (interceptionContext !== null) {
+ headers.set("X-Vinext-Interception-Context", interceptionContext);
+ }
+ return headers;
+}
+
+function createLoadingPayloadCacheKey(href: string): string {
+ const url = new URL(href, window.location.origin);
+ return url.pathname;
+}
+
/**
* Resolve all pending navigation commits with renderId <= the committed renderId.
* Note: Map iteration handles concurrent deletion safely — entries are visited in
@@ -997,16 +1017,16 @@ function registerServerActionCallback(): void {
// Fall through to hard redirect below if URL parsing fails.
}
- // Use hard redirect for all action redirects because vinext's server
- // currently returns an empty body for redirect responses. RSC navigation
- // requires a valid RSC payload. This is a known parity gap with Next.js,
- // which pre-renders the redirect target's RSC payload.
const redirectType = fetchResponse.headers.get("x-action-redirect-type") ?? "replace";
- if (redirectType === "push") {
- window.location.assign(actionRedirect);
- } else {
- window.location.replace(actionRedirect);
- }
+ window.dispatchEvent(new Event("vinext:link-status-navigation"));
+ await window.__VINEXT_RSC_NAVIGATE__?.(
+ actionRedirect,
+ 0,
+ "navigate",
+ redirectType === "push" ? "push" : "replace",
+ undefined,
+ true,
+ );
return undefined;
}
@@ -1068,6 +1088,47 @@ function bootstrapHydration(rscStream: ReadableStream): void {
import.meta.env.DEV ? { onCaughtError: devOnCaughtError } : undefined,
);
window.__VINEXT_HYDRATED_AT = performance.now();
+ window.__NEXT_HYDRATED = true;
+ window.__NEXT_HYDRATED_AT = window.__VINEXT_HYDRATED_AT;
+ if (typeof window.__NEXT_HYDRATED_CB === "function") {
+ window.__NEXT_HYDRATED_CB();
+ }
+
+ window.__VINEXT_RSC_PREFETCH_LOADING__ = async function prefetchLoadingPayload(
+ href: string,
+ ): Promise {
+ const url = new URL(href, window.location.origin);
+ const cacheKey = createLoadingPayloadCacheKey(url.href);
+ if (loadingPayloadCache.has(cacheKey)) {
+ return;
+ }
+
+ const loadingHeaders = createRscLoadingRequestHeaders(getCurrentInterceptionContext());
+ let mountedSlotsHeader: string | null = null;
+ try {
+ mountedSlotsHeader = getMountedSlotIdsHeader(getBrowserRouterState().elements);
+ } catch {
+ mountedSlotsHeader = null;
+ }
+ if (mountedSlotsHeader) {
+ loadingHeaders.set("X-Vinext-Mounted-Slots", mountedSlotsHeader);
+ }
+
+ try {
+ const response = await fetch(url.pathname + url.search, {
+ headers: loadingHeaders,
+ credentials: "include",
+ priority: "low" as RequestInit["priority"],
+ });
+ const contentType = response.headers.get("content-type") ?? "";
+ if (!response.ok || !contentType.startsWith("text/x-component") || !response.body) {
+ return;
+ }
+ loadingPayloadCache.set(cacheKey, await snapshotRscResponse(response));
+ } catch {
+ // Loading prefetch is opportunistic; navigation can still fall back to the full payload.
+ }
+ };
window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc(
href: string,
@@ -1076,6 +1137,7 @@ function bootstrapHydration(rscStream: ReadableStream): void {
historyUpdateMode?: HistoryUpdateMode,
previousNextUrlOverride?: string | null,
programmaticTransition = false,
+ deferredLoadingTransition = false,
): Promise {
let _snapshotPending = false;
let pendingRouterState: PendingBrowserRouterState | null = null;
@@ -1106,6 +1168,8 @@ function bootstrapHydration(rscStream: ReadableStream): void {
const url = new URL(currentHref, window.location.origin);
const rscUrl = toRscUrl(url.pathname + url.search);
+ const rscFetchUrl = url.pathname + url.search;
+ let renderedLoadingPayload = false;
const requestState = getRequestState(navigationKind, currentPrevNextUrl);
const requestInterceptionContext = requestState.interceptionContext;
const requestPreviousNextUrl = requestState.previousNextUrl;
@@ -1184,6 +1248,61 @@ function bootstrapHydration(rscStream: ReadableStream): void {
let navResponse: Response | undefined;
let navResponseUrl: string | null = null;
+
+ const renderLoadingPayload = async (
+ loadingResponseSnapshot: CachedRscResponse,
+ historyMode: HistoryUpdateMode | undefined,
+ pendingState: PendingBrowserRouterState | null,
+ ): Promise => {
+ const loadingResponse = restoreRscResponse(loadingResponseSnapshot, false);
+ const loadingParamsHeader = loadingResponse.headers.get("X-Vinext-Params");
+ let loadingParams: Record = {};
+ if (loadingParamsHeader) {
+ try {
+ loadingParams = JSON.parse(decodeURIComponent(loadingParamsHeader)) as Record<
+ string,
+ string | string[]
+ >;
+ } catch {
+ loadingParams = {};
+ }
+ }
+ const loadingSnapshot = createClientNavigationRenderSnapshot(currentHref, loadingParams);
+ const loadingPayload = normalizeAppElementsPromise(
+ createFromFetch(Promise.resolve(loadingResponse)),
+ );
+ await renderNavigationPayload(
+ loadingPayload,
+ loadingSnapshot,
+ currentHref,
+ navId,
+ historyMode,
+ loadingParams,
+ requestPreviousNextUrl,
+ pendingState,
+ isSameRoute,
+ toActionType(navigationKind),
+ );
+ return true;
+ };
+
+ if (programmaticTransition && navigationKind === "navigate") {
+ const loadingResponseSnapshot = loadingPayloadCache.get(
+ createLoadingPayloadCacheKey(currentHref),
+ );
+ if (loadingResponseSnapshot) {
+ renderedLoadingPayload = await renderLoadingPayload(
+ loadingResponseSnapshot,
+ currentHistoryMode,
+ pendingRouterState,
+ );
+ if (renderedLoadingPayload) {
+ pendingRouterState = null;
+ currentHistoryMode = undefined;
+ }
+ }
+ }
+
if (navigationKind !== "refresh") {
const prefetchedResponse = consumePrefetchResponse(
rscUrl,
@@ -1201,7 +1320,7 @@ function bootstrapHydration(rscStream: ReadableStream): void {
if (mountedSlotsHeader) {
requestHeaders.set("X-Vinext-Mounted-Slots", mountedSlotsHeader);
}
- navResponse = await fetch(rscUrl, {
+ navResponse = await fetch(rscFetchUrl, {
headers: requestHeaders,
credentials: "include",
});
@@ -1259,9 +1378,11 @@ function bootstrapHydration(rscStream: ReadableStream): void {
}
const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin);
- const requestedUrl = new URL(rscUrl, window.location.origin);
+ const requestedUrl = new URL(rscFetchUrl, window.location.origin);
+ const finalPathname = finalUrl.pathname.replace(/\.rsc$/, "");
+ const requestedPathname = requestedUrl.pathname.replace(/\.rsc$/, "");
- if (finalUrl.pathname !== requestedUrl.pathname) {
+ if (finalPathname !== requestedPathname) {
// Server-side redirect: update the URL in history and loop to fetch
// the destination without settling pendingRouterState. This keeps
// isPending true across all redirect hops instead of flashing false.
@@ -1305,6 +1426,41 @@ function bootstrapHydration(rscStream: ReadableStream): void {
if (navId !== activeNavigationId) return;
+ let resolvedElementsForCache: AppElements | null = null;
+ if (
+ !renderedLoadingPayload &&
+ !programmaticTransition &&
+ deferredLoadingTransition &&
+ navigationKind === "navigate" &&
+ navResponse.body
+ ) {
+ resolvedElementsForCache = await rscPayload;
+ const metadata = readAppElementsMetadata(resolvedElementsForCache);
+ const genericLoadingElements = normalizeAppElements({
+ [APP_ROUTE_KEY]: metadata.routeId,
+ [APP_INTERCEPTION_CONTEXT_KEY]: metadata.interceptionContext,
+ [APP_ROOT_LAYOUT_KEY]: metadata.rootLayoutTreePath,
+ [metadata.routeId]: createElement("div", { id: "loading" }, "Loading..."),
+ });
+ await renderNavigationPayload(
+ Promise.resolve(genericLoadingElements),
+ navigationSnapshot,
+ currentHref,
+ navId,
+ currentHistoryMode,
+ navParams,
+ requestPreviousNextUrl,
+ pendingRouterState,
+ isSameRoute,
+ toActionType(navigationKind),
+ );
+ renderedLoadingPayload = true;
+ currentHistoryMode = undefined;
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+
+ if (navId !== activeNavigationId) return;
+
_snapshotPending = true; // Set before renderNavigationPayload
try {
await renderNavigationPayload(
@@ -1334,7 +1490,7 @@ function bootstrapHydration(rscStream: ReadableStream): void {
// If we stored it before and renderNavigationPayload threw, a future
// back/forward navigation could replay a snapshot from a navigation that
// never actually rendered successfully.
- const resolvedElements = await rscPayload;
+ const resolvedElements = resolvedElementsForCache ?? (await rscPayload);
const metadata = readAppElementsMetadata(resolvedElements);
storeVisitedResponseSnapshot(
rscUrl,
diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts
index 9173421c3..c48048692 100644
--- a/packages/vinext/src/server/app-page-render.ts
+++ b/packages/vinext/src/server/app-page-render.ts
@@ -31,6 +31,7 @@ import {
shouldRerenderAppPageWithGlobalError,
type AppPageSsrHandler,
} from "./app-page-stream.js";
+import { getClientTraceMetadataHtml, injectHtmlBeforeHeadClose } from "./trace-metadata.js";
type AppPageBoundaryOnError = (
error: unknown,
@@ -50,6 +51,7 @@ type AppPageRequestCacheLife = {
};
type RenderAppPageLifecycleOptions = {
+ clientTraceMetadata?: readonly string[];
cleanPathname: string;
clearRequestContext: () => void;
consumeDynamicUsage: () => boolean;
@@ -307,9 +309,19 @@ export async function renderAppPageLifecycle(
renderEnd,
responseKind: "html",
});
+ const shouldInjectTraceMetadata =
+ options.isProduction &&
+ !htmlResponsePolicy.shouldWriteToCache &&
+ (options.isForceDynamic || dynamicUsedDuringRender || revalidateSeconds === 0);
+ const traceMetadataHtml = shouldInjectTraceMetadata
+ ? await getClientTraceMetadataHtml(options.clientTraceMetadata)
+ : "";
+ const responseHtmlStream = traceMetadataHtml
+ ? injectHtmlBeforeHeadClose(safeHtmlStream, traceMetadataHtml)
+ : safeHtmlStream;
if (htmlResponsePolicy.shouldWriteToCache) {
- const isrResponse = buildAppPageHtmlResponse(safeHtmlStream, {
+ const isrResponse = buildAppPageHtmlResponse(responseHtmlStream, {
draftCookie,
fontLinkHeader,
middlewareContext: options.middlewareContext,
@@ -333,7 +345,7 @@ export async function renderAppPageLifecycle(
});
}
- return buildAppPageHtmlResponse(safeHtmlStream, {
+ return buildAppPageHtmlResponse(responseHtmlStream, {
draftCookie,
fontLinkHeader,
middlewareContext: options.middlewareContext,
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
index e08ce2ce1..9e171cd93 100644
--- a/packages/vinext/src/server/app-page-route-wiring.tsx
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -667,3 +667,29 @@ export function buildAppPageElements<
return elements;
}
+
+export function buildAppPageLoadingElements<
+ TModule extends AppPageModule,
+ TErrorModule extends AppPageErrorModule,
+>(
+ options: Pick<
+ BuildAppPageElementsOptions,
+ "interceptionContext" | "route" | "routePath"
+ >,
+): AppElements | null {
+ const LoadingComponent = getDefaultExport(options.route.loading);
+ if (!LoadingComponent) {
+ return null;
+ }
+
+ const interceptionContext = options.interceptionContext ?? null;
+ const routeId = createAppPayloadRouteId(options.routePath, interceptionContext);
+ const rootLayoutTreePath = createAppPageLayoutEntries(options.route)[0]?.treePath ?? null;
+
+ return {
+ [APP_ROUTE_KEY]: routeId,
+ [APP_INTERCEPTION_CONTEXT_KEY]: interceptionContext,
+ [APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath,
+ [routeId]: ,
+ };
+}
diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts
index b8f0462e8..122d5f101 100644
--- a/packages/vinext/src/server/app-router-entry.ts
+++ b/packages/vinext/src/server/app-router-entry.ts
@@ -13,10 +13,12 @@
*/
// @ts-expect-error — virtual module resolved by vinext
-import rscHandler from "virtual:vinext-rsc-entry";
+import rscHandler, { vinextConfig } from "virtual:vinext-rsc-entry";
import { runWithExecutionContext, type ExecutionContextLike } from "../shims/request-context.js";
import { resolveStaticAssetSignal } from "./worker-utils.js";
import { isOpenRedirectShaped } from "./request-pipeline.js";
+import { stripBasePath } from "../utils/base-path.js";
+import { getNextStaticAssetLookupPath, isNextStaticAssetPath } from "./next-static-compat.js";
type WorkerAssetEnv = {
ASSETS?: {
@@ -56,6 +58,39 @@ export default {
// AND in the handler would double-decode, causing inconsistent path
// matching between middleware and routing.
+ if (env?.ASSETS) {
+ const basePath =
+ typeof vinextConfig?.basePath === "string" ? (vinextConfig.basePath as string) : "";
+ const assetPrefix =
+ typeof vinextConfig?.assetPrefix === "string" ? (vinextConfig.assetPrefix as string) : "";
+ const assetPathname = stripBasePath(url.pathname, basePath);
+ if (assetPathname.startsWith("/assets/")) {
+ const assetResponse = await env.ASSETS.fetch(
+ new Request(new URL(assetPathname + url.search, request.url), request),
+ );
+ if (assetResponse.status !== 404) {
+ return assetResponse;
+ }
+ return new Response("Not Found", {
+ status: 404,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+ const nextStaticLookupPath = getNextStaticAssetLookupPath(assetPathname, assetPrefix);
+ if (isNextStaticAssetPath(nextStaticLookupPath)) {
+ const assetResponse = await env.ASSETS.fetch(
+ new Request(new URL(nextStaticLookupPath + url.search, request.url), request),
+ );
+ if (assetResponse.status !== 404) {
+ return assetResponse;
+ }
+ return new Response("Not Found", {
+ status: 404,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+ }
+
// Delegate to RSC handler (which decodes + normalizes the pathname itself),
// wrapping in the ExecutionContext ALS scope so downstream code can reach
// ctx.waitUntil() without having ctx threaded through every call site.
diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts
index 1985ffbad..163a6cdfa 100644
--- a/packages/vinext/src/server/dev-server.ts
+++ b/packages/vinext/src/server/dev-server.ts
@@ -40,6 +40,7 @@ import {
parseCookieLocaleFromHeader,
resolvePagesI18nRequest,
} from "./pages-i18n.js";
+import "./edge-runtime-globals.js";
/**
* Render a React element to a string using renderToReadableStream.
@@ -901,7 +902,10 @@ hydrate();
`window.__NEXT_DATA__ = ${safeJsonStringify({
props: { pageProps },
page: patternToNextFormat(route.pattern),
- query: params,
+ query:
+ typeof pageModule.getStaticProps === "function"
+ ? params
+ : { ...params, ...parseQuery(url) },
buildId: process.env.__VINEXT_BUILD_ID,
isFallback: false,
locale: locale ?? currentDefaultLocale,
diff --git a/packages/vinext/src/server/edge-runtime-globals.ts b/packages/vinext/src/server/edge-runtime-globals.ts
new file mode 100644
index 000000000..175104403
--- /dev/null
+++ b/packages/vinext/src/server/edge-runtime-globals.ts
@@ -0,0 +1,12 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+
+type EdgeRuntimeGlobal = typeof globalThis & {
+ AsyncLocalStorage?: typeof AsyncLocalStorage;
+};
+
+export function installEdgeRuntimeGlobals(target: typeof globalThis = globalThis): void {
+ const edgeGlobal = target as EdgeRuntimeGlobal;
+ edgeGlobal.AsyncLocalStorage ??= AsyncLocalStorage;
+}
+
+installEdgeRuntimeGlobals();
diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts
index 48517be1f..c0c7d5c05 100644
--- a/packages/vinext/src/server/middleware.ts
+++ b/packages/vinext/src/server/middleware.ts
@@ -33,6 +33,7 @@ import { normalizePath } from "./normalize-path.js";
import { shouldKeepMiddlewareHeader } from "./middleware-request-headers.js";
import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js";
import { ValidFileMatcher } from "../routing/file-matcher.js";
+import { hasBasePath, stripBasePath } from "../utils/base-path.js";
/**
* Determine whether a middleware/proxy file path refers to a proxy file.
@@ -398,6 +399,14 @@ export async function runMiddleware(
const matcher = config?.matcher;
const url = new URL(request.url);
+ if (
+ basePath &&
+ matcher !== undefined &&
+ request.headers.get("x-vinext-request-had-base-path") === "0"
+ ) {
+ return { continue: true };
+ }
+
// Normalize the pathname before middleware matching to prevent bypasses
// via percent-encoding (/%61dmin → /admin) or double slashes (/dashboard//settings).
let decodedPathname: string;
@@ -419,7 +428,7 @@ export async function runMiddleware(
if (normalizedPathname !== url.pathname) {
const mwUrl = new URL(url);
mwUrl.pathname = normalizedPathname;
- mwRequest = new Request(mwUrl, request);
+ mwRequest = new Request(mwUrl, request.clone());
}
// Wrap in NextRequest so middleware gets .nextUrl, .cookies, .geo, .ip, etc.
@@ -431,6 +440,17 @@ export async function runMiddleware(
mwRequest instanceof NextRequest
? mwRequest
: new NextRequest(mwRequest, nextConfig ? { nextConfig } : undefined);
+ if (mwRequest.headers.get("x-vinext-data-request") === "1") {
+ Object.defineProperty(nextRequest, "__isData", {
+ value: true,
+ enumerable: false,
+ configurable: true,
+ });
+ const dataLocale = mwRequest.headers.get("x-vinext-data-locale");
+ if (dataLocale) {
+ nextRequest.nextUrl.locale = dataLocale;
+ }
+ }
const fetchEvent = new NextFetchEvent({ page: normalizedPathname });
// Execute the middleware
@@ -505,7 +525,13 @@ export async function runMiddleware(
let rewritePath: string;
try {
const rewriteParsed = new URL(rewriteUrl, request.url);
- rewritePath = rewriteParsed.pathname + rewriteParsed.search;
+ const requestUrl = new URL(request.url);
+ rewritePath =
+ rewriteParsed.origin !== requestUrl.origin
+ ? rewriteParsed.toString()
+ : (basePath && hasBasePath(rewriteParsed.pathname, basePath)
+ ? stripBasePath(rewriteParsed.pathname, basePath)
+ : rewriteParsed.pathname) + rewriteParsed.search;
} catch {
rewritePath = rewriteUrl;
}
diff --git a/packages/vinext/src/server/next-static-compat.ts b/packages/vinext/src/server/next-static-compat.ts
new file mode 100644
index 000000000..498832ee8
--- /dev/null
+++ b/packages/vinext/src/server/next-static-compat.ts
@@ -0,0 +1,51 @@
+import fs from "node:fs";
+import path from "node:path";
+
+const NEXT_STATIC_PREFIX = "/_next/static/";
+
+export function isNextStaticAssetPath(pathname: string): boolean {
+ return pathname === "/_next/static" || pathname.startsWith(NEXT_STATIC_PREFIX);
+}
+
+function normalizeAssetPrefixPath(assetPrefix?: string | null): string {
+ if (!assetPrefix) return "";
+
+ let pathname = assetPrefix;
+ try {
+ pathname = new URL(assetPrefix).pathname;
+ } catch {
+ // Path-style asset prefixes are already pathnames.
+ }
+
+ if (!pathname.startsWith("/")) return "";
+ return pathname.replace(/\/+$/, "");
+}
+
+export function getNextStaticAssetLookupPath(
+ pathname: string,
+ assetPrefix?: string | null,
+): string {
+ if (isNextStaticAssetPath(pathname)) return pathname;
+
+ const prefixPath = normalizeAssetPrefixPath(assetPrefix);
+ if (!prefixPath || prefixPath === "/") return pathname;
+ if (pathname !== prefixPath && !pathname.startsWith(`${prefixPath}/`)) return pathname;
+
+ const stripped = pathname.slice(prefixPath.length) || "/";
+ return isNextStaticAssetPath(stripped) ? stripped : pathname;
+}
+
+export function writeNextStaticCompatAssets(clientDir: string, buildId: string): void {
+ if (!buildId) return;
+
+ const staticDir = path.join(clientDir, "_next", "static", buildId);
+ fs.mkdirSync(staticDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(staticDir, "_buildManifest.js"),
+ [
+ "self.__BUILD_MANIFEST = {};",
+ "self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB();",
+ "",
+ ].join("\n"),
+ );
+}
diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts
index 47e6c36bc..ecc509712 100644
--- a/packages/vinext/src/server/pages-api-route.ts
+++ b/packages/vinext/src/server/pages-api-route.ts
@@ -1,7 +1,9 @@
import type { Route } from "../routing/pages-router.js";
+import { NextRequest } from "../shims/server.js";
import { addQueryParam } from "../utils/query.js";
import {
createPagesReqRes,
+ parsePagesBodySizeLimit,
parsePagesApiBody,
type PagesRequestQuery,
type PagesReqResRequest,
@@ -10,7 +12,14 @@ import {
} from "./pages-node-compat.js";
type PagesApiRouteModule = {
- default?: (req: PagesReqResRequest, res: PagesReqResResponse) => void | Promise;
+ config?: {
+ api?: {
+ bodyParser?: false | { sizeLimit?: number | string };
+ };
+ runtime?: string;
+ };
+ runtime?: string;
+ default?: unknown;
};
export type PagesApiRouteMatch = {
@@ -22,11 +31,36 @@ export type PagesApiRouteMatch = {
type HandlePagesApiRouteOptions = {
match: PagesApiRouteMatch | null;
+ onRevalidate?: (
+ urlPath: string,
+ options?: { unstable_onlyGenerated?: boolean },
+ ) => Promise | void;
reportRequestError?: (error: Error, routePattern: string) => void | Promise;
request: Request;
url: string;
};
+const warnedEdgeRuntimeRoutes = new Set();
+
+function normalizeEdgeRuntimeResponse(response: Response): Response {
+ if (!response.headers.has("content-encoding") && !response.headers.has("content-length")) {
+ return response;
+ }
+
+ const headers = new Headers(response.headers);
+ // Node's fetch decodes compressed upstream bodies but keeps the original
+ // encoding headers. The deploy harness runs Pages edge routes in Node, so
+ // strip body metadata before the production server optionally recompresses.
+ headers.delete("content-encoding");
+ headers.delete("content-length");
+
+ return new Response(response.body, {
+ headers,
+ status: response.status,
+ statusText: response.statusText,
+ });
+}
+
function buildPagesApiQuery(url: string, params: PagesRequestQuery): PagesRequestQuery {
const query: PagesRequestQuery = { ...params };
const search = url.split("?")[1];
@@ -41,6 +75,28 @@ function buildPagesApiQuery(url: string, params: PagesRequestQuery): PagesReques
return query;
}
+function appendRouteParams(searchParams: URLSearchParams, params: PagesRequestQuery): void {
+ for (const [key, value] of Object.entries(params)) {
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ searchParams.append(key, item);
+ }
+ } else {
+ searchParams.append(key, value);
+ }
+ }
+}
+
+function requestWithResolvedUrl(
+ request: Request,
+ url: string,
+ params: PagesRequestQuery = {},
+): Request {
+ const resolvedUrl = new URL(url, request.url);
+ appendRouteParams(resolvedUrl.searchParams, params);
+ return new Request(resolvedUrl, request);
+}
+
export async function handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promise {
if (!options.match) {
return new Response("404 - API route not found", { status: 404 });
@@ -53,17 +109,54 @@ export async function handlePagesApiRoute(options: HandlePagesApiRouteOptions):
}
try {
+ const runtime = route.module.config?.runtime ?? route.module.runtime;
+ if (runtime === "edge" || runtime === "experimental-edge") {
+ if (!warnedEdgeRuntimeRoutes.has(route.pattern)) {
+ warnedEdgeRuntimeRoutes.add(route.pattern);
+ console.warn(
+ `[vinext] Pages API route ${route.pattern} exports config.runtime = "edge". ` +
+ "vinext does not implement Next.js Edge Runtime isolation; this route will run " +
+ "as a best-effort Web Request/Response handler on the normal vinext runtime. " +
+ "Prefer the default Node.js Pages API runtime, or migrate request-boundary logic " +
+ "to proxy.ts. See https://nextjs.org/blog/next-16#proxyts-formerly-middlewarets",
+ );
+ }
+ const resolvedRequest = requestWithResolvedUrl(options.request, options.url, params);
+ const edgeRequest =
+ resolvedRequest instanceof NextRequest ? resolvedRequest : new NextRequest(resolvedRequest);
+ const edgeHandler = handler as (req: NextRequest) => unknown | Promise;
+ const result = await edgeHandler(edgeRequest);
+ if (result instanceof Response) {
+ return normalizeEdgeRuntimeResponse(result);
+ }
+ return new Response(null, { status: 204 });
+ }
+
const query = buildPagesApiQuery(options.url, params);
- const body = await parsePagesApiBody(options.request);
- const { req, res, responsePromise } = createPagesReqRes({
+ const apiConfig = route.module.config?.api;
+ const shouldParseBody = apiConfig?.bodyParser !== false;
+ const sizeLimit =
+ shouldParseBody && typeof apiConfig?.bodyParser === "object"
+ ? parsePagesBodySizeLimit(apiConfig.bodyParser.sizeLimit)
+ : undefined;
+ const body = shouldParseBody ? await parsePagesApiBody(options.request, sizeLimit) : undefined;
+ const { isResponsePiped, req, res, responsePromise } = createPagesReqRes({
body,
+ onRevalidate: options.onRevalidate,
+ preserveRequestBodyStream: !shouldParseBody,
query,
request: options.request,
url: options.url,
});
- await handler(req, res);
- res.end();
+ const nodeHandler = handler as (
+ req: PagesReqResRequest,
+ res: PagesReqResResponse,
+ ) => unknown | Promise;
+ await nodeHandler(req, res);
+ if (!res.headersSent && !isResponsePiped()) {
+ res.end();
+ }
return await responsePromise;
} catch (error) {
if (error instanceof PagesApiBodyParseError) {
diff --git a/packages/vinext/src/server/pages-i18n.ts b/packages/vinext/src/server/pages-i18n.ts
index a59c626bd..1ecd6fec1 100644
--- a/packages/vinext/src/server/pages-i18n.ts
+++ b/packages/vinext/src/server/pages-i18n.ts
@@ -31,6 +31,10 @@ type PagesI18nRequestInfo = {
redirectUrl?: string;
};
+type ResolvePagesI18nRequestOptions = {
+ skipLocaleRedirect?: boolean;
+};
+
function readHeader(headers: HeaderBag, name: string): string | undefined {
if (!headers) return undefined;
if (headers instanceof Headers) {
@@ -201,13 +205,14 @@ export function resolvePagesI18nRequest(
hostname?: string | null,
basePath = "",
trailingSlash = false,
+ options: ResolvePagesI18nRequestOptions = {},
): PagesI18nRequestInfo {
const domainLocale = detectDomainLocale(i18nConfig.domains, hostname ?? undefined);
const defaultLocale = domainLocale?.defaultLocale || i18nConfig.defaultLocale;
const localeInfo = extractLocaleFromUrl(url, i18nConfig, defaultLocale);
let redirectUrl: string | undefined;
- if (!localeInfo.hadPrefix) {
+ if (!localeInfo.hadPrefix && !options.skipLocaleRedirect) {
redirectUrl = getLocaleRedirect({
headers,
nextConfig: {
diff --git a/packages/vinext/src/server/pages-node-compat.ts b/packages/vinext/src/server/pages-node-compat.ts
index 4fa5f8058..368c37425 100644
--- a/packages/vinext/src/server/pages-node-compat.ts
+++ b/packages/vinext/src/server/pages-node-compat.ts
@@ -1,4 +1,5 @@
import { decode as decodeQueryString } from "node:querystring";
+import { Readable, Writable } from "node:stream";
import { parseCookies } from "../config/config-matchers.js";
import { PagesBodyParseError, getMediaType, isJsonMediaType } from "./pages-media-type.js";
@@ -19,7 +20,8 @@ export type PagesReqResRequest = {
query: PagesRequestQuery;
body: unknown;
cookies: Record;
-};
+ [Symbol.asyncIterator]: () => AsyncIterableIterator;
+} & Readable;
export type PagesReqResHeaders = {
[key: string]: string | number | boolean | string[];
@@ -31,22 +33,32 @@ export type PagesReqResResponse = {
writeHead: (code: number, headers?: PagesReqResHeaders) => PagesReqResResponse;
setHeader: (name: string, value: string | number | boolean | string[]) => PagesReqResResponse;
getHeader: (name: string) => string | number | boolean | string[] | undefined;
+ write: (chunk: string | Uint8Array | Buffer) => boolean;
end: (data?: BodyInit | null) => void;
status: (code: number) => PagesReqResResponse;
json: (data: unknown) => void;
send: (data: unknown) => void;
redirect: (statusOrUrl: number | string, url?: string) => void;
+ setPreviewData: (data: unknown) => PagesReqResResponse;
+ clearPreviewData: () => PagesReqResResponse;
+ revalidate: (urlPath: string, options?: { unstable_onlyGenerated?: boolean }) => Promise;
getHeaders: () => PagesReqResHeaders;
-};
+} & Writable;
type CreatePagesReqResOptions = {
body: unknown;
+ onRevalidate?: (
+ urlPath: string,
+ options?: { unstable_onlyGenerated?: boolean },
+ ) => Promise | void;
+ preserveRequestBodyStream?: boolean;
query: PagesRequestQuery;
request: Request;
url: string;
};
type CreatePagesReqResResult = {
+ isResponsePiped: () => boolean;
req: PagesReqResRequest;
res: PagesReqResResponse;
responsePromise: Promise;
@@ -81,6 +93,34 @@ async function readPagesRequestBodyWithLimit(request: Request, maxBytes: number)
return chunks.join("");
}
+export function parsePagesBodySizeLimit(
+ sizeLimit: number | string | undefined,
+ fallback = MAX_PAGES_API_BODY_SIZE,
+): number {
+ if (typeof sizeLimit === "number" && Number.isFinite(sizeLimit) && sizeLimit >= 0) {
+ return sizeLimit;
+ }
+
+ if (typeof sizeLimit !== "string") {
+ return fallback;
+ }
+
+ const match = sizeLimit
+ .trim()
+ .toLowerCase()
+ .match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/);
+ if (!match) {
+ return fallback;
+ }
+
+ const value = Number.parseFloat(match[1]);
+ const unit = match[2] ?? "b";
+ const multiplier =
+ unit === "gb" ? 1024 * 1024 * 1024 : unit === "mb" ? 1024 * 1024 : unit === "kb" ? 1024 : 1;
+
+ return Math.floor(value * multiplier);
+}
+
export async function parsePagesApiBody(
request: Request,
maxBytes = MAX_PAGES_API_BODY_SIZE,
@@ -130,36 +170,89 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage
headersObj[key.toLowerCase()] = value;
}
- const req: PagesReqResRequest = {
- method: options.request.method,
- url: options.url,
- headers: headersObj,
- query: options.query,
- body: options.body,
- cookies: parseCookies(options.request.headers.get("cookie")),
- };
+ const reqStream =
+ options.preserveRequestBodyStream && options.request.body
+ ? Readable.fromWeb(
+ options.request.body as import("node:stream/web").ReadableStream,
+ )
+ : Readable.from([]);
+ const req = reqStream as PagesReqResRequest;
+ req.method = options.request.method;
+ req.url = options.url;
+ req.headers = headersObj;
+ req.query = options.query;
+ req.body = options.body;
+ req.cookies = parseCookies(options.request.headers.get("cookie"));
let resStatusCode = 200;
const resHeaders: Record = {};
const setCookieHeaders: string[] = [];
- let resBody: BodyInit | null = null;
+ const resBodyChunks: Buffer[] = [];
+ let responsePiped = false;
let ended = false;
let resolveResponse!: (value: Response) => void;
const responsePromise = new Promise((resolve) => {
resolveResponse = resolve;
});
- const res: PagesReqResResponse = {
- get statusCode() {
- return resStatusCode;
+ function normalizeResponseChunk(data: BodyInit): Buffer {
+ if (Buffer.isBuffer(data)) return data;
+ if (data instanceof Uint8Array) return Buffer.from(data);
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
+ return Buffer.from(String(data));
+ }
+
+ function resolveOnce(): void {
+ if (ended) {
+ return;
+ }
+ ended = true;
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(resHeaders)) {
+ headers.set(key, String(value));
+ }
+ for (const cookie of setCookieHeaders) {
+ headers.append("set-cookie", cookie);
+ }
+ const body = resBodyChunks.length > 0 ? Buffer.concat(resBodyChunks) : null;
+ resolveResponse(new Response(body, { status: resStatusCode, headers }));
+ }
+
+ const resStream = new Writable({
+ write(chunk, _encoding, callback) {
+ if (chunk !== undefined && chunk !== null) {
+ resBodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ callback();
},
- set statusCode(code) {
- resStatusCode = code;
+ });
+ resStream.on("pipe", () => {
+ responsePiped = true;
+ });
+ resStream.on("finish", resolveOnce);
+
+ const streamWrite = resStream.write.bind(resStream);
+ const streamEnd = resStream.end.bind(resStream);
+ const res = resStream as PagesReqResResponse;
+
+ Object.defineProperties(res, {
+ statusCode: {
+ get() {
+ return resStatusCode;
+ },
+ set(code: number) {
+ resStatusCode = code;
+ },
},
- get headersSent() {
- return ended;
+ headersSent: {
+ get() {
+ return ended || res.writableEnded;
+ },
},
- writeHead(code, headers) {
+ });
+
+ Object.assign(res, {
+ writeHead(code: number, headers?: PagesReqResHeaders) {
resStatusCode = code;
if (headers) {
for (const [key, value] of Object.entries(headers)) {
@@ -176,7 +269,10 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage
}
return res;
},
- setHeader(name, value) {
+ write(chunk: string | Uint8Array | Buffer) {
+ return streamWrite(chunk);
+ },
+ setHeader(name: string, value: string | number | boolean | string[]) {
if (name.toLowerCase() === "set-cookie") {
// Node.js res.setHeader() replaces the existing value entirely.
setCookieHeaders.length = 0;
@@ -190,38 +286,31 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage
}
return res;
},
- getHeader(name) {
+ getHeader(name: string) {
if (name.toLowerCase() === "set-cookie") {
return setCookieHeaders.length > 0 ? setCookieHeaders : undefined;
}
return resHeaders[name.toLowerCase()];
},
- end(data) {
- if (ended) {
+ end(data?: BodyInit | null) {
+ if (ended || res.writableEnded) {
return;
}
- ended = true;
if (data !== undefined && data !== null) {
- resBody = data;
+ resBodyChunks.push(normalizeResponseChunk(data));
}
- const headers = new Headers();
- for (const [key, value] of Object.entries(resHeaders)) {
- headers.set(key, String(value));
- }
- for (const cookie of setCookieHeaders) {
- headers.append("set-cookie", cookie);
- }
- resolveResponse(new Response(resBody, { status: resStatusCode, headers }));
+ streamEnd();
+ resolveOnce();
},
- status(code) {
+ status(code: number) {
resStatusCode = code;
return res;
},
- json(data) {
+ json(data: unknown) {
resHeaders["content-type"] = "application/json";
res.end(JSON.stringify(data));
},
- send(data) {
+ send(data: unknown) {
if (Buffer.isBuffer(data)) {
if (!resHeaders["content-type"]) {
resHeaders["content-type"] = "application/octet-stream";
@@ -242,7 +331,7 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage
}
res.end(String(data));
},
- redirect(statusOrUrl, url) {
+ redirect(statusOrUrl: number | string, url?: string) {
if (typeof statusOrUrl === "string") {
res.writeHead(307, { Location: statusOrUrl });
} else {
@@ -250,6 +339,20 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage
}
res.end();
},
+ setPreviewData(data: unknown) {
+ const encoded = Buffer.from(JSON.stringify(data)).toString("base64url");
+ setCookieHeaders.push(`__prerender_bypass=vinext-preview; Path=/; SameSite=Lax`);
+ setCookieHeaders.push(`__next_preview_data=${encoded}; Path=/; SameSite=Lax; HttpOnly`);
+ return res;
+ },
+ clearPreviewData() {
+ setCookieHeaders.push(`__prerender_bypass=; Path=/; Max-Age=0; SameSite=Lax`);
+ setCookieHeaders.push(`__next_preview_data=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly`);
+ return res;
+ },
+ async revalidate(urlPath: string, revalidateOptions?: { unstable_onlyGenerated?: boolean }) {
+ await options.onRevalidate?.(urlPath, revalidateOptions);
+ },
getHeaders() {
const headers: PagesReqResHeaders = { ...resHeaders };
if (setCookieHeaders.length > 0) {
@@ -257,7 +360,7 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage
}
return headers;
},
- };
+ });
- return { req, res, responsePromise };
+ return { isResponsePiped: () => responsePiped, req, res, responsePromise };
}
diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts
index ce8a2c6f8..da7fb83e6 100644
--- a/packages/vinext/src/server/pages-page-data.ts
+++ b/packages/vinext/src/server/pages-page-data.ts
@@ -3,7 +3,9 @@ import type { Route } from "../routing/pages-router.js";
import type { CachedPagesValue } from "../shims/cache.js";
import { buildPagesCacheValue, type ISRCacheEntry } from "./isr-cache.js";
import {
+ buildPagesIsrCacheControl,
buildPagesNextDataScript,
+ PAGES_INDEFINITE_REVALIDATE_SECONDS,
type PagesGsspResponse,
type PagesI18nRenderContext,
} from "./pages-page-response.js";
@@ -14,9 +16,11 @@ type PagesRedirectResult = {
statusCode?: number;
};
-type PagesStaticPathsEntry = {
- params: Record;
-};
+type PagesStaticPathsEntry =
+ | {
+ params: Record;
+ }
+ | string;
type PagesStaticPathsResult = {
fallback?: boolean | "blocking";
@@ -24,12 +28,14 @@ type PagesStaticPathsResult = {
};
type PagesPagePropsResult = {
- props?: Record;
+ props?: Record | Promise>;
redirect?: PagesRedirectResult;
notFound?: boolean;
- revalidate?: number;
+ revalidate?: number | false;
};
+export type PagesRevalidateReason = "on-demand" | "build" | "stale";
+
export type PagesMutableGsspResponse = {
headersSent: boolean;
} & PagesGsspResponse;
@@ -47,7 +53,7 @@ export type PagesPageModule = {
defaultLocale: string;
}) => Promise | PagesStaticPathsResult;
getServerSideProps?: (context: {
- params: Record;
+ params?: Record;
req: unknown;
res: PagesMutableGsspResponse;
query: Record;
@@ -61,9 +67,23 @@ export type PagesPageModule = {
locale?: string;
locales?: string[];
defaultLocale?: string;
+ revalidateReason?: PagesRevalidateReason;
}) => Promise | PagesPagePropsResult;
};
+type PagesComponentWithInitialProps = {
+ getInitialProps?: (context: {
+ pathname: string;
+ query: Record;
+ asPath: string;
+ req: unknown;
+ res: PagesMutableGsspResponse;
+ locale?: string;
+ locales?: string[];
+ defaultLocale?: string;
+ }) => Promise | undefined> | Record | undefined;
+};
+
type RenderPagesIsrHtmlOptions = {
buildId: string | null;
cachedHtml: string;
@@ -94,13 +114,18 @@ export type ResolvePagesPageDataOptions = {
pageModule: PagesPageModule;
params: Record;
query: Record;
+ resolvedUrl: string;
route: Pick;
routePattern: string;
routeUrl: string;
+ hasGeneratedFallbackPath?: boolean;
+ isCrawlerRequest?: boolean;
runInFreshUnifiedContext: (callback: () => Promise) => Promise;
safeJsonStringify: (value: unknown) => string;
sanitizeDestination: (destination: string) => string;
scriptNonce?: string;
+ isDataRequest?: boolean;
+ revalidateReason?: PagesRevalidateReason;
triggerBackgroundRegeneration: (key: string, renderFn: () => Promise) => void;
renderIsrPassToStringAsync: (element: ReactNode) => Promise;
};
@@ -108,6 +133,7 @@ export type ResolvePagesPageDataOptions = {
type ResolvePagesPageDataRenderResult = {
kind: "render";
gsspRes: PagesGsspResponse | null;
+ isFallback?: boolean;
isrRevalidateSeconds: number | null;
pageProps: Record;
};
@@ -117,19 +143,27 @@ type ResolvePagesPageDataResponseResult = {
response: Response;
};
+type ResolvePagesPageDataNotFoundResult = {
+ kind: "notFound";
+};
+
type ResolvePagesPageDataResult =
| ResolvePagesPageDataRenderResult
- | ResolvePagesPageDataResponseResult;
+ | ResolvePagesPageDataResponseResult
+ | ResolvePagesPageDataNotFoundResult;
function buildPagesNotFoundResponse(): Response {
- return new Response("404 - Page not found
", {
- status: 404,
- headers: { "Content-Type": "text/html" },
- });
+ return new Response(
+ "404 - Page not found
This page could not be found.
",
+ {
+ status: 404,
+ headers: { "Content-Type": "text/html" },
+ },
+ );
}
function buildPagesDataNotFoundResponse(): Response {
- return new Response("404", { status: 404 });
+ return buildPagesNotFoundResponse();
}
function resolvePagesRedirectStatus(redirect: PagesRedirectResult): number {
@@ -139,7 +173,37 @@ function resolvePagesRedirectStatus(redirect: PagesRedirectResult): number {
function matchesPagesStaticPath(
pathEntry: PagesStaticPathsEntry,
params: Record,
+ routePattern: string,
+ routeUrl: string,
): boolean {
+ const normalizePath = (value: string) => {
+ const pathname = value.split("?")[0] || "/";
+ return pathname === "/" ? pathname : pathname.replace(/\/$/, "");
+ };
+
+ if (typeof pathEntry === "string") {
+ if (normalizePath(pathEntry) === normalizePath(routeUrl)) {
+ return true;
+ }
+
+ const replaceParam = (_match: string, key: string, modifier?: string) => {
+ const value = params[key];
+ if (Array.isArray(value)) {
+ return value.map((part) => encodeURIComponent(String(part))).join("/");
+ }
+ if (value == null && modifier === "*") {
+ return "";
+ }
+ return encodeURIComponent(String(value ?? ""));
+ };
+ const expectedPath = routePattern
+ .replace(/\[\[\.\.\.([\w-]+)\]\]/g, replaceParam)
+ .replace(/\[\.\.\.([\w-]+)\]/g, replaceParam)
+ .replace(/\[([\w-]+)\]/g, replaceParam)
+ .replace(/:([\w-]+)([+*])?/g, replaceParam);
+ return normalizePath(pathEntry) === normalizePath(expectedPath);
+ }
+
return Object.entries(pathEntry.params).every(([key, value]) => {
const actual = params[key];
if (Array.isArray(value)) {
@@ -158,10 +222,7 @@ function buildPagesCacheResponse(
const headers: Record = {
"Content-Type": "text/html",
"X-Vinext-Cache": cacheState,
- "Cache-Control":
- cacheState === "HIT"
- ? `s-maxage=${revalidateSeconds ?? 60}, stale-while-revalidate`
- : "s-maxage=0, stale-while-revalidate",
+ "Cache-Control": buildPagesIsrCacheControl(revalidateSeconds, cacheState),
};
if (fontLinkHeader) {
@@ -182,17 +243,27 @@ function rewritePagesCachedHtml(
const bodyMarker = '';
const bodyStart = cachedHtml.indexOf(bodyMarker);
const contentStart = bodyStart >= 0 ? bodyStart + bodyMarker.length : -1;
- // This intentionally looks for the bare inline __NEXT_DATA__ marker.
// Pages responses with scriptNonce are excluded from ISR writes, so cached
// HTML should never contain nonce-prefixed __NEXT_DATA__ scripts here.
- const nextDataMarker = "", nextDataStart) + 9;
+ let nextDataEnd = cachedHtml.indexOf("", nextDataStart) + 9;
+ const afterFirstScript = cachedHtml.slice(nextDataEnd);
+ const assignmentMatch = afterFirstScript.match(/^\s*", assignmentStart);
+ if (assignmentEnd >= 0) {
+ nextDataEnd = assignmentEnd + 9;
+ }
+ }
const tail = cachedHtml.slice(nextDataEnd);
return cachedHtml.slice(0, contentStart) + freshBody + "
" + gap + nextDataScript + tail;
@@ -214,6 +285,7 @@ export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Pr
const nextDataScript = buildPagesNextDataScript({
buildId: options.buildId,
i18n: options.i18n,
+ isGsp: true,
pageProps: options.pageProps,
params: options.params,
routePattern: options.routePattern,
@@ -223,9 +295,25 @@ export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Pr
return rewritePagesCachedHtml(options.cachedHtml, freshBody, nextDataScript);
}
+function resolvePagesRevalidateSeconds(
+ revalidate: PagesPagePropsResult["revalidate"],
+): number | null {
+ if (revalidate === false) {
+ return PAGES_INDEFINITE_REVALIDATE_SECONDS;
+ }
+
+ if (typeof revalidate === "number" && revalidate > 0) {
+ return revalidate;
+ }
+
+ return null;
+}
+
export async function resolvePagesPageData(
options: ResolvePagesPageDataOptions,
): Promise {
+ let shouldRenderFallbackShell = false;
+
if (typeof options.pageModule.getStaticPaths === "function" && options.route.isDynamic) {
const pathsResult = await options.pageModule.getStaticPaths({
locales: options.i18n.locales ?? [],
@@ -236,7 +324,7 @@ export async function resolvePagesPageData(
if (fallback === false) {
const paths = pathsResult?.paths ?? [];
const isValidPath = paths.some((pathEntry) =>
- matchesPagesStaticPath(pathEntry, options.params),
+ matchesPagesStaticPath(pathEntry, options.params, options.routePattern, options.routeUrl),
);
if (!isValidPath) {
@@ -245,6 +333,16 @@ export async function resolvePagesPageData(
response: buildPagesNotFoundResponse(),
};
}
+ } else if (fallback === true) {
+ const paths = pathsResult?.paths ?? [];
+ const isValidPath = paths.some((pathEntry) =>
+ matchesPagesStaticPath(pathEntry, options.params, options.routePattern, options.routeUrl),
+ );
+ shouldRenderFallbackShell =
+ !isValidPath &&
+ !options.isDataRequest &&
+ !options.hasGeneratedFallbackPath &&
+ !options.isCrawlerRequest;
}
}
@@ -254,11 +352,11 @@ export async function resolvePagesPageData(
if (typeof options.pageModule.getServerSideProps === "function") {
const { req, res, responsePromise } = options.createGsspReqRes();
const result = await options.pageModule.getServerSideProps({
- params: options.params,
+ params: options.route.isDynamic ? options.params : undefined,
req,
res,
query: options.query,
- resolvedUrl: options.routeUrl,
+ resolvedUrl: options.resolvedUrl,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
@@ -272,7 +370,7 @@ export async function resolvePagesPageData(
}
if (result?.props) {
- pageProps = result.props;
+ pageProps = await result.props;
}
if (result?.redirect) {
@@ -286,6 +384,11 @@ export async function resolvePagesPageData(
}
if (result?.notFound) {
+ if (!options.isDataRequest) {
+ return {
+ kind: "notFound",
+ };
+ }
return {
kind: "response",
response: buildPagesDataNotFoundResponse(),
@@ -302,8 +405,33 @@ export async function resolvePagesPageData(
const cacheKey = options.isrCacheKey("pages", pathname);
const cached = await options.isrGet(cacheKey);
const cachedValue = cached?.value.value;
+ const isOnDemandRevalidate = options.revalidateReason === "on-demand";
+
+ if (
+ !isOnDemandRevalidate &&
+ cachedValue?.kind === "PAGES" &&
+ cached &&
+ !cached.isStale &&
+ !options.scriptNonce
+ ) {
+ if (options.isDataRequest) {
+ return {
+ kind: "render",
+ gsspRes: null,
+ isrRevalidateSeconds,
+ pageProps: cachedValue.pageData as Record,
+ };
+ }
+
+ if (options.route.isDynamic && options.routeUrl.includes("?")) {
+ return {
+ kind: "render",
+ gsspRes: null,
+ isrRevalidateSeconds,
+ pageProps: cachedValue.pageData as Record,
+ };
+ }
- if (cachedValue?.kind === "PAGES" && cached && !cached.isStale && !options.scriptNonce) {
return {
kind: "response",
response: buildPagesCacheResponse(
@@ -315,7 +443,13 @@ export async function resolvePagesPageData(
};
}
- if (cachedValue?.kind === "PAGES" && cached && cached.isStale && !options.scriptNonce) {
+ if (
+ !isOnDemandRevalidate &&
+ cachedValue?.kind === "PAGES" &&
+ cached &&
+ cached.isStale &&
+ !options.scriptNonce
+ ) {
options.triggerBackgroundRegeneration(cacheKey, async function () {
return options.runInFreshUnifiedContext(async () => {
const freshResult = await options.pageModule.getStaticProps?.({
@@ -323,20 +457,19 @@ export async function resolvePagesPageData(
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
+ revalidateReason: "stale",
});
- if (
- freshResult?.props &&
- typeof freshResult.revalidate === "number" &&
- freshResult.revalidate > 0
- ) {
+ const freshRevalidateSeconds = resolvePagesRevalidateSeconds(freshResult?.revalidate);
+ if (freshResult?.props && freshRevalidateSeconds !== null) {
+ const freshPageProps = await freshResult.props;
options.applyRequestContexts();
const freshHtml = await renderPagesIsrHtml({
buildId: options.buildId,
cachedHtml: cachedValue.html,
createPageElement: options.createPageElement,
i18n: options.i18n,
- pageProps: freshResult.props,
+ pageProps: freshPageProps,
params: options.params,
renderIsrPassToStringAsync: options.renderIsrPassToStringAsync,
routePattern: options.routePattern,
@@ -345,28 +478,48 @@ export async function resolvePagesPageData(
await options.isrSet(
cacheKey,
- buildPagesCacheValue(freshHtml, freshResult.props),
- freshResult.revalidate,
+ buildPagesCacheValue(freshHtml, freshPageProps),
+ freshRevalidateSeconds,
);
}
});
});
+ if (options.isDataRequest) {
+ return {
+ kind: "render",
+ gsspRes: null,
+ isrRevalidateSeconds,
+ pageProps: cachedValue.pageData as Record,
+ };
+ }
+
return {
kind: "response",
response: buildPagesCacheResponse(cachedValue.html, "STALE", options.fontLinkHeader),
};
}
+ if (shouldRenderFallbackShell) {
+ return {
+ kind: "render",
+ gsspRes: null,
+ isFallback: true,
+ isrRevalidateSeconds: null,
+ pageProps: {},
+ };
+ }
+
const result = await options.pageModule.getStaticProps({
params: options.params,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
+ revalidateReason: options.revalidateReason,
});
if (result?.props) {
- pageProps = result.props;
+ pageProps = await result.props;
}
if (result?.redirect) {
@@ -380,14 +533,47 @@ export async function resolvePagesPageData(
}
if (result?.notFound) {
+ if (!options.isDataRequest) {
+ return {
+ kind: "notFound",
+ };
+ }
return {
kind: "response",
response: buildPagesDataNotFoundResponse(),
};
}
- if (typeof result?.revalidate === "number" && result.revalidate > 0) {
- isrRevalidateSeconds = result.revalidate;
+ isrRevalidateSeconds = resolvePagesRevalidateSeconds(result?.revalidate);
+ }
+
+ const PageComponent = options.pageModule.default as PagesComponentWithInitialProps | undefined;
+ if (
+ typeof options.pageModule.getServerSideProps !== "function" &&
+ typeof options.pageModule.getStaticProps !== "function" &&
+ typeof PageComponent?.getInitialProps === "function"
+ ) {
+ const { req, res, responsePromise } = options.createGsspReqRes();
+ const result = await PageComponent.getInitialProps({
+ pathname: options.routePattern,
+ query: options.query,
+ asPath: options.resolvedUrl,
+ req,
+ res,
+ locale: options.i18n.locale,
+ locales: options.i18n.locales,
+ defaultLocale: options.i18n.defaultLocale,
+ });
+
+ if (res.headersSent) {
+ return {
+ kind: "response",
+ response: await responsePromise,
+ };
+ }
+
+ if (result && typeof result === "object") {
+ pageProps = result;
}
}
diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts
index 8313dc961..e21ccdb50 100644
--- a/packages/vinext/src/server/pages-page-response.ts
+++ b/packages/vinext/src/server/pages-page-response.ts
@@ -1,12 +1,58 @@
import React, { type ComponentType, type ReactNode } from "react";
+import { minifyStyledJsxCss } from "../plugins/styled-jsx.js";
+import { renderHeadNodesToHTML } from "../shims/head.js";
import { withScriptNonce } from "../shims/script-nonce-context.js";
-import { createInlineScriptTag, createNonceAttribute, escapeHtmlAttr } from "./html.js";
+import { createNonceAttribute, escapeHtmlAttr } from "./html.js";
+import { getClientTraceMetadataHtml } from "./trace-metadata.js";
+
+export const PAGES_INDEFINITE_REVALIDATE_SECONDS = 31536000;
+export const PAGES_NEXT_DEPLOY_CACHE_CONTROL = "public, max-age=0, must-revalidate";
+const PAGES_HTML_BOT_UA_RE =
+ /Googlebot(?!-)|Googlebot$|[\w-]+-Google|Google-[\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight/i;
+
+export function usesPagesNextDeployCacheControl(): boolean {
+ return process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL === "1";
+}
+
+export function isPagesHtmlBotUserAgent(userAgent: string): boolean {
+ return PAGES_HTML_BOT_UA_RE.test(userAgent);
+}
+
+export function buildPagesIsrCacheControl(
+ revalidateSeconds: number | undefined,
+ cacheState: "HIT" | "MISS" | "STALE",
+): string {
+ if (usesPagesNextDeployCacheControl()) {
+ return PAGES_NEXT_DEPLOY_CACHE_CONTROL;
+ }
+
+ if (cacheState === "STALE") {
+ return "s-maxage=0, stale-while-revalidate";
+ }
+
+ return `s-maxage=${revalidateSeconds ?? 60}, stale-while-revalidate`;
+}
type PagesFontPreload = {
href: string;
type: string;
};
+type PagesDocumentComponent = ComponentType> & {
+ getInitialProps?: (ctx: unknown) => Promise> | Record;
+};
+
+type PagesRenderPageOptions =
+ | ((Component: ComponentType>) => ComponentType>)
+ | {
+ enhanceApp?: (
+ App: ComponentType>,
+ ) => ComponentType>;
+ enhanceComponent?: (
+ Component: ComponentType>,
+ ) => ComponentType>;
+ };
+
export type PagesI18nRenderContext = {
locale?: string;
locales?: string[];
@@ -23,12 +69,31 @@ type PagesStreamedHtmlResponse = {
__vinextStreamedHtmlResponse?: boolean;
} & Response;
+type PagesReactReadableStream = ReadableStream & {
+ allReady?: Promise;
+};
+
+type PagesNextDataPayload = Record & {
+ props: Record;
+ page: string;
+ query: Record;
+ buildId: string | null;
+};
+
type RenderPagesPageResponseOptions = {
+ appProps?: Record;
assetTags: string;
buildId: string | null;
+ clientTraceMetadata?: readonly string[];
clearSsrContext: () => void;
- createPageElement: (pageProps: Record) => ReactNode;
- DocumentComponent: ComponentType | null;
+ createPageElement: (
+ pageProps: Record,
+ renderOptions?: PagesRenderPageOptions | null,
+ ) => ReactNode;
+ crossOrigin?: string | null;
+ DocumentComponent: PagesDocumentComponent | null;
+ documentProps?: Record | undefined;
+ documentRenderPageOptions?: PagesRenderPageOptions | null;
flushPreloads?: (() => Promise | void) | undefined;
fontLinkHeader: string;
fontPreloads: PagesFontPreload[];
@@ -36,6 +101,9 @@ type RenderPagesPageResponseOptions = {
getFontStyles: () => string[];
getSSRHeadHTML?: (() => string) | undefined;
gsspRes: PagesGsspResponse | null;
+ isFallback?: boolean;
+ isGssp?: boolean;
+ isGsp?: boolean;
isrCacheKey: (router: string, pathname: string) => string;
isrRevalidateSeconds: number | null;
isrSet: (
@@ -50,16 +118,20 @@ type RenderPagesPageResponseOptions = {
revalidateSeconds: number,
) => Promise;
i18n: PagesI18nRenderContext;
+ pageModuleUrl?: string;
pageProps: Record;
+ appModuleUrl?: string;
params: Record;
renderDocumentToString: (element: ReactNode) => Promise;
+ renderHeadPrepassToStringAsync?: ((element: ReactNode) => Promise) | undefined;
renderIsrPassToStringAsync: (element: ReactNode) => Promise;
- renderToReadableStream: (element: ReactNode) => Promise>;
+ renderToReadableStream: (element: ReactNode) => Promise;
resetSSRHead?: (() => void) | undefined;
routePattern: string;
routeUrl: string;
safeJsonStringify: (value: unknown) => string;
scriptNonce?: string;
+ shouldBufferResponse?: boolean;
};
function buildPagesFontHeadHtml(
@@ -67,16 +139,18 @@ function buildPagesFontHeadHtml(
fontPreloads: PagesFontPreload[],
fontStyles: string[],
scriptNonce?: string,
+ crossOrigin?: string | null,
): string {
let html = "";
const nonceAttr = createNonceAttribute(scriptNonce);
+ const crossOriginAttr = createCrossOriginAttribute(crossOrigin);
for (const link of fontLinks) {
html += `\n `;
}
for (const preload of fontPreloads) {
- html += `\n `;
+ html += `\n `;
}
if (fontStyles.length > 0) {
@@ -86,24 +160,75 @@ function buildPagesFontHeadHtml(
return html;
}
-export function buildPagesNextDataScript(
+function getDocumentHeadHTML(documentProps?: Record): string {
+ const head = documentProps?.head;
+ if (!Array.isArray(head)) return "";
+ return renderHeadNodesToHTML(head);
+}
+
+function createCrossOriginAttribute(crossOrigin?: string | null): string {
+ if (!crossOrigin) {
+ return " crossorigin";
+ }
+ return ` crossorigin="${escapeHtmlAttr(crossOrigin)}"`;
+}
+
+function createOptionalCrossOriginAttribute(crossOrigin?: string | null): string {
+ if (!crossOrigin) {
+ return "";
+ }
+ return ` crossorigin="${escapeHtmlAttr(crossOrigin)}"`;
+}
+
+function getHtmlAttr(attrs: string, name: string): string | undefined {
+ const pattern = new RegExp(`${name}=(?:"([^"]*)"|'([^']*)')`, "i");
+ const match = attrs.match(pattern);
+ return match?.[1] ?? match?.[2];
+}
+
+function addMissingAttrToScriptsAndPreloads(html: string, name: string, value: string): string {
+ const escapedAttr = ` ${name}="${escapeHtmlAttr(value)}"`;
+ return html.replace(/<(script|link)\b([^>]*)>/gi, (match, tagName: string, attrs: string) => {
+ if (new RegExp(`\\s${name}=`, "i").test(attrs)) {
+ return match;
+ }
+ if (
+ tagName.toLowerCase() === "link" &&
+ !/\srel=(?:"(?:modulepreload|preload)"|'(?:modulepreload|preload)'|(?:modulepreload|preload)(?:\s|>|$))/i.test(
+ attrs,
+ )
+ ) {
+ return match;
+ }
+ return `<${tagName}${attrs}${escapedAttr}>`;
+ });
+}
+
+function buildPagesNextDataPayload(
options: Pick<
RenderPagesPageResponseOptions,
| "buildId"
| "i18n"
+ | "isFallback"
+ | "isGsp"
+ | "isGssp"
+ | "pageModuleUrl"
| "pageProps"
+ | "appModuleUrl"
+ | "appProps"
+ | "crossOrigin"
| "params"
| "routePattern"
| "safeJsonStringify"
| "scriptNonce"
>,
-): string {
- const nextDataPayload: Record = {
- props: { pageProps: options.pageProps },
+): PagesNextDataPayload {
+ const nextDataPayload: PagesNextDataPayload = {
+ props: { ...options.appProps, pageProps: options.pageProps },
page: options.routePattern,
query: options.params,
buildId: options.buildId,
- isFallback: false,
+ isFallback: options.isFallback === true,
};
if (options.i18n.locales) {
@@ -113,15 +238,55 @@ export function buildPagesNextDataScript(
nextDataPayload.domainLocales = options.i18n.domainLocales;
}
+ if (options.isGssp) {
+ nextDataPayload.gssp = true;
+ }
+ if (options.isGsp) {
+ nextDataPayload.gsp = true;
+ }
+
+ if (options.pageModuleUrl || options.appModuleUrl) {
+ nextDataPayload.__vinext = {
+ ...(options.pageModuleUrl ? { pageModuleUrl: options.pageModuleUrl } : {}),
+ ...(options.appModuleUrl ? { appModuleUrl: options.appModuleUrl } : {}),
+ };
+ }
+
+ return nextDataPayload;
+}
+
+export function buildPagesNextDataScript(
+ options: Pick<
+ RenderPagesPageResponseOptions,
+ | "buildId"
+ | "i18n"
+ | "isFallback"
+ | "isGsp"
+ | "isGssp"
+ | "pageModuleUrl"
+ | "pageProps"
+ | "appModuleUrl"
+ | "appProps"
+ | "crossOrigin"
+ | "params"
+ | "routePattern"
+ | "safeJsonStringify"
+ | "scriptNonce"
+ >,
+): string {
+ const nextDataPayload = buildPagesNextDataPayload(options);
const localeGlobals = options.i18n.locales
? `;window.__VINEXT_LOCALE__=${options.safeJsonStringify(options.i18n.locale)}` +
`;window.__VINEXT_LOCALES__=${options.safeJsonStringify(options.i18n.locales)}` +
`;window.__VINEXT_DEFAULT_LOCALE__=${options.safeJsonStringify(options.i18n.defaultLocale)}`
: "";
- return createInlineScriptTag(
- `window.__NEXT_DATA__ = ${options.safeJsonStringify(nextDataPayload)}${localeGlobals}`,
- options.scriptNonce,
+ const nextDataJson = options.safeJsonStringify(nextDataPayload);
+ const nonceAttr = createNonceAttribute(options.scriptNonce);
+ const crossOriginAttr = createOptionalCrossOriginAttribute(options.crossOrigin);
+ return (
+ `` +
+ ``
);
}
@@ -133,29 +298,55 @@ async function buildPagesShellHtml(
RenderPagesPageResponseOptions,
"assetTags" | "DocumentComponent" | "renderDocumentToString"
> & {
+ crossOrigin?: string | null;
+ documentProps?: Record | undefined;
ssrHeadHTML: string;
},
): Promise {
if (options.DocumentComponent) {
- let html = await options.renderDocumentToString(React.createElement(options.DocumentComponent));
+ let html = await options.renderDocumentToString(
+ React.createElement(options.DocumentComponent, options.documentProps ?? {}),
+ );
+ let documentScriptNonce: string | undefined;
+ let documentScriptCrossOrigin: string | undefined;
+ html = html.replace(
+ /]*)>__NEXT_SCRIPTS__<\/vinext-next-scripts>/i,
+ (_match, attrs: string) => {
+ documentScriptNonce = getHtmlAttr(attrs, "data-vinext-next-script-nonce");
+ documentScriptCrossOrigin = getHtmlAttr(attrs, "data-vinext-next-script-crossorigin");
+ return nextDataScript;
+ },
+ );
html = html.replace("__NEXT_MAIN__", bodyMarker);
if (options.ssrHeadHTML || options.assetTags || fontHeadHTML) {
+ const suffixBeforeAssets = options.ssrHeadHTML ? "\n " : "";
html = html.replace(
"",
- ` ${fontHeadHTML}${options.ssrHeadHTML}\n ${options.assetTags}\n`,
+ `${fontHeadHTML}${options.ssrHeadHTML}${suffixBeforeAssets}${options.assetTags}\n`,
);
}
html = html.replace("", nextDataScript);
+ html = html.replace("__NEXT_SCRIPTS__", nextDataScript);
if (!html.includes("__NEXT_DATA__")) {
html = html.replace("
\n" +
@@ -220,14 +411,50 @@ function applyGsspHeaders(headers: Headers, gsspRes: PagesGsspResponse | null):
return gsspRes.statusCode;
}
+function buildWeakHtmlEtag(html: string): string {
+ let hash = 2166136261;
+ for (let i = 0; i < html.length; i++) {
+ hash ^= html.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+ return `W/"${html.length.toString(16)}-${(hash >>> 0).toString(16)}"`;
+}
+
+export function normalizePagesInlineStyleTags(html: string): string {
+ const bodyIndex = html.search(/]*)?>([\s\S]*?)<\/style>/gi, (match, attrs = "", css) => {
+ if (!css.includes(":") && !css.includes("{")) return match;
+ return ``;
+ })
+ );
+}
+
export async function renderPagesPageResponse(
options: RenderPagesPageResponseOptions,
): Promise {
- const pageElement = withScriptNonce(
- React.createElement(React.Fragment, null, options.createPageElement(options.pageProps)),
- options.scriptNonce,
- );
+ const createPageElement = () =>
+ withScriptNonce(
+ React.createElement(
+ React.Fragment,
+ null,
+ options.createPageElement(options.pageProps, options.documentRenderPageOptions ?? null),
+ ),
+ options.scriptNonce,
+ );
+ const pageElement = createPageElement();
+ options.resetSSRHead?.();
+ if (options.renderHeadPrepassToStringAsync) {
+ await options.renderHeadPrepassToStringAsync(createPageElement());
+ }
+ const pageHeadHTML = options.getSSRHeadHTML?.() ?? "";
+ const documentHeadHTML = getDocumentHeadHTML(options.documentProps);
options.resetSSRHead?.();
await options.flushPreloads?.();
@@ -236,22 +463,53 @@ export async function renderPagesPageResponse(
options.fontPreloads,
options.getFontStyles(),
options.scriptNonce,
+ options.crossOrigin,
);
+ const traceMetadataHTML =
+ options.gsspRes !== null ? await getClientTraceMetadataHtml(options.clientTraceMetadata) : "";
+ const nextDataPayload = buildPagesNextDataPayload({
+ appProps: options.appProps,
+ buildId: options.buildId,
+ i18n: options.i18n,
+ isFallback: options.isFallback,
+ isGsp: options.isGsp,
+ isGssp: options.gsspRes !== null,
+ pageModuleUrl: options.pageModuleUrl,
+ pageProps: options.pageProps,
+ appModuleUrl: options.appModuleUrl,
+ crossOrigin: options.crossOrigin,
+ params: options.params,
+ routePattern: options.routePattern,
+ safeJsonStringify: options.safeJsonStringify,
+ scriptNonce: options.scriptNonce,
+ });
const nextDataScript = buildPagesNextDataScript({
+ appProps: options.appProps,
buildId: options.buildId,
i18n: options.i18n,
+ isFallback: options.isFallback,
+ isGsp: options.isGsp,
+ isGssp: options.gsspRes !== null,
+ pageModuleUrl: options.pageModuleUrl,
pageProps: options.pageProps,
+ appModuleUrl: options.appModuleUrl,
+ crossOrigin: options.crossOrigin,
params: options.params,
routePattern: options.routePattern,
safeJsonStringify: options.safeJsonStringify,
scriptNonce: options.scriptNonce,
});
+ const documentProps = options.DocumentComponent
+ ? { ...options.documentProps, __NEXT_DATA__: nextDataPayload }
+ : options.documentProps;
const bodyMarker = "";
const shellHtml = await buildPagesShellHtml(bodyMarker, fontHeadHTML, nextDataScript, {
assetTags: options.assetTags,
+ crossOrigin: options.crossOrigin,
DocumentComponent: options.DocumentComponent,
+ documentProps,
renderDocumentToString: options.renderDocumentToString,
- ssrHeadHTML: options.getSSRHeadHTML?.() ?? "",
+ ssrHeadHTML: [pageHeadHTML, documentHeadHTML, traceMetadataHTML].filter(Boolean).join("\n "),
});
options.clearSsrContext();
@@ -260,7 +518,7 @@ export async function renderPagesPageResponse(
const shellPrefix = shellHtml.slice(0, markerIndex);
const shellSuffix = shellHtml.slice(markerIndex + bodyMarker.length);
const bodyStream = await options.renderToReadableStream(pageElement);
- const compositeStream = await buildPagesCompositeStream(bodyStream, shellPrefix, shellSuffix);
+ await bodyStream.allReady;
if (
// Keep nonce-bearing pages out of ISR writes: rewritePagesCachedHtml()
@@ -272,10 +530,10 @@ export async function renderPagesPageResponse(
const isrElement = React.createElement(
React.Fragment,
null,
- options.createPageElement(options.pageProps),
+ options.createPageElement(options.pageProps, options.documentRenderPageOptions ?? null),
);
const isrHtml = await options.renderIsrPassToStringAsync(isrElement);
- const fullHtml = shellPrefix + isrHtml + shellSuffix;
+ const fullHtml = normalizePagesInlineStyleTags(shellPrefix + isrHtml + shellSuffix);
const isrPathname = options.routeUrl.split("?")[0];
const cacheKey = options.isrCacheKey("pages", isrPathname);
await options.isrSet(
@@ -296,17 +554,40 @@ export async function renderPagesPageResponse(
if (options.scriptNonce) {
responseHeaders.set("Cache-Control", "no-store, must-revalidate");
+ } else if (options.isFallback) {
+ responseHeaders.set(
+ "Cache-Control",
+ usesPagesNextDeployCacheControl()
+ ? PAGES_NEXT_DEPLOY_CACHE_CONTROL
+ : "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
} else if (options.isrRevalidateSeconds) {
responseHeaders.set(
"Cache-Control",
- `s-maxage=${options.isrRevalidateSeconds}, stale-while-revalidate`,
+ buildPagesIsrCacheControl(options.isrRevalidateSeconds, "MISS"),
);
responseHeaders.set("X-Vinext-Cache", "MISS");
+ } else if (options.gsspRes && !responseHeaders.has("Cache-Control")) {
+ responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
}
if (options.fontLinkHeader) {
responseHeaders.set("Link", options.fontLinkHeader);
}
+ if (options.shouldBufferResponse) {
+ const bodyHtml = await new Response(bodyStream).text();
+ const fullHtml = normalizePagesInlineStyleTags(shellPrefix + bodyHtml + shellSuffix);
+ if (!responseHeaders.has("ETag")) {
+ responseHeaders.set("ETag", buildWeakHtmlEtag(fullHtml));
+ }
+ return new Response(fullHtml, {
+ status: finalStatus,
+ headers: responseHeaders,
+ });
+ }
+
+ const compositeStream = await buildPagesCompositeStream(bodyStream, shellPrefix, shellSuffix);
+
const response = new Response(compositeStream, {
status: finalStatus,
headers: responseHeaders,
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index bb333e4a8..031f062b2 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -45,6 +45,7 @@ import {
DEFAULT_IMAGE_SIZES,
type ImageConfig,
} from "./image-optimization.js";
+import type { NextHeader, NextRedirect, NextRewrite } from "../config/next-config.js";
import { normalizePath } from "./normalize-path.js";
import { isOpenRedirectShaped } from "./request-pipeline.js";
import { hasBasePath, stripBasePath } from "../utils/base-path.js";
@@ -55,6 +56,8 @@ import type { ExecutionContextLike } from "../shims/request-context.js";
import { readPrerenderSecret } from "../build/server-manifest.js";
import { seedMemoryCacheFromPrerender } from "./seed-cache.js";
import { installSocketErrorBackstop } from "./socket-error-backstop.js";
+import { getNextStaticAssetLookupPath, isNextStaticAssetPath } from "./next-static-compat.js";
+import "./edge-runtime-globals.js";
/** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */
function readNodeStream(req: IncomingMessage): ReadableStream {
@@ -834,12 +837,19 @@ export async function startProdServer(options: ProdServerOptions = {}) {
const resolvedOutDir = path.resolve(outDir);
const clientDir = path.join(resolvedOutDir, "client");
- // Detect build type
- const rscEntryPath = path.join(resolvedOutDir, "server", "index.js");
- const serverEntryPath = path.join(resolvedOutDir, "server", "entry.js");
- const isAppRouter = fs.existsSync(rscEntryPath);
-
- if (!isAppRouter && !fs.existsSync(serverEntryPath)) {
+ // Detect build type. Vite/Rolldown can emit either .js or .mjs depending on
+ // the project package type and output format.
+ const rscEntryPath = [
+ path.join(resolvedOutDir, "server", "index.js"),
+ path.join(resolvedOutDir, "server", "index.mjs"),
+ ].find((candidate) => fs.existsSync(candidate));
+ const serverEntryPath = [
+ path.join(resolvedOutDir, "server", "entry.js"),
+ path.join(resolvedOutDir, "server", "entry.mjs"),
+ ].find((candidate) => fs.existsSync(candidate));
+ const isAppRouter = !!rscEntryPath;
+
+ if (!isAppRouter && !serverEntryPath) {
console.error(`[vinext] No build output found in ${outDir}`);
console.error("Run `vinext build` first.");
process.exit(1);
@@ -849,7 +859,13 @@ export async function startProdServer(options: ProdServerOptions = {}) {
return startAppRouterServer({ port, host, clientDir, rscEntryPath, compress });
}
- return startPagesRouterServer({ port, host, clientDir, serverEntryPath, compress });
+ return startPagesRouterServer({
+ port,
+ host,
+ clientDir,
+ serverEntryPath: serverEntryPath!,
+ compress,
+ });
}
// ─── App Router Production Server ─────────────────────────────────────────────
@@ -940,6 +956,78 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
const rscMtime = fs.statSync(rscEntryPath).mtimeMs;
const rscModule = await import(`${pathToFileURL(rscEntryPath).href}?t=${rscMtime}`);
const rscHandler = resolveAppRouterHandler(rscModule.default);
+ const appBasePath: string =
+ typeof rscModule.vinextConfig?.basePath === "string" ? rscModule.vinextConfig.basePath : "";
+ const appAssetPrefix: string =
+ typeof rscModule.vinextConfig?.assetPrefix === "string"
+ ? rscModule.vinextConfig.assetPrefix
+ : "";
+
+ const pagesEntryPath = [
+ path.join(path.dirname(rscEntryPath), "entry.js"),
+ path.join(path.dirname(rscEntryPath), "entry.mjs"),
+ ].find((candidate) => fs.existsSync(candidate));
+ const pagesServerEntry = pagesEntryPath
+ ? ((await import(
+ `${pathToFileURL(pagesEntryPath).href}?t=${fs.statSync(pagesEntryPath).mtimeMs}`
+ )) as {
+ handleApiRoute?: (request: Request, url: string) => Promise | Response;
+ runMiddleware?: (
+ request: Request,
+ ctx?: unknown,
+ ) => Promise<{
+ continue: boolean;
+ redirectUrl?: string;
+ redirectStatus?: number;
+ response?: Response;
+ responseHeaders?: Headers;
+ rewriteUrl?: string;
+ rewriteStatus?: number;
+ waitUntilPromises?: Promise[];
+ }>;
+ })
+ : null;
+
+ const ssrManifestPath = path.join(clientDir, ".vite", "ssr-manifest.json");
+ if (fs.existsSync(ssrManifestPath)) {
+ try {
+ (
+ globalThis as typeof globalThis & {
+ __VINEXT_SSR_MANIFEST__?: Record;
+ }
+ ).__VINEXT_SSR_MANIFEST__ = JSON.parse(fs.readFileSync(ssrManifestPath, "utf-8")) as Record<
+ string,
+ string[]
+ >;
+ } catch {
+ /* ignore parse errors */
+ }
+ }
+ const buildManifestPath = path.join(clientDir, ".vite", "manifest.json");
+ if (fs.existsSync(buildManifestPath)) {
+ try {
+ const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8"));
+ for (const [, value] of Object.entries(buildManifest)) {
+ const entry = value as { isEntry?: boolean; file?: unknown };
+ if (
+ entry.isEntry &&
+ typeof entry.file === "string" &&
+ /(?:^|\/)pages[-.]/.test(entry.file)
+ ) {
+ globalThis.__VINEXT_CLIENT_ENTRY__ = manifestFileWithBase(entry.file, "/");
+ break;
+ }
+ }
+ const lazyChunks = computeLazyChunks(buildManifest).map((file: string) =>
+ manifestFileWithBase(file, "/"),
+ );
+ if (lazyChunks.length > 0) {
+ globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks;
+ }
+ } catch {
+ /* ignore parse errors */
+ }
+ }
// Seed the memory cache with pre-rendered routes so the first request to
// any pre-rendered page is a cache HIT instead of a full re-render.
@@ -1004,14 +1092,26 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
}
// Serve hashed build assets (Vite output in /assets/) directly.
+ // With basePath configured, generated HTML points at //assets/*;
+ // strip that prefix before looking in dist/client.
// Public directory files fall through to the RSC handler, which runs
// middleware before serving them.
+ const appStaticLookupPath = stripBasePath(pathname, appBasePath);
if (
- pathname.startsWith("/assets/") &&
- (await tryServeStatic(req, res, clientDir, pathname, compress, staticCache))
+ appStaticLookupPath.startsWith("/assets/") &&
+ (await tryServeStatic(req, res, clientDir, appStaticLookupPath, compress, staticCache))
) {
return;
}
+ const nextStaticLookupPath = getNextStaticAssetLookupPath(appStaticLookupPath, appAssetPrefix);
+ if (isNextStaticAssetPath(nextStaticLookupPath)) {
+ if (await tryServeStatic(req, res, clientDir, nextStaticLookupPath, compress, staticCache)) {
+ return;
+ }
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ res.end("Not Found");
+ return;
+ }
// Image optimization passthrough (Node.js prod server has no Images binding;
// serves the original file with cache headers and security headers)
@@ -1068,7 +1168,83 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
// Convert Node.js request to Web Request and call the RSC handler
const request = nodeToWebRequest(req, normalizedUrl);
- const response = await rscHandler(request);
+ let response = await rscHandler(request);
+
+ // Hybrid App + Pages builds produce a separate Pages server entry for
+ // Pages API routes. If the App handler did not match an /api route,
+ // delegate to that entry so App Router production serving does not hide
+ // the Pages Router API surface.
+ if (
+ response.status === 404 &&
+ (pathname.startsWith("/api/") || pathname === "/api") &&
+ typeof pagesServerEntry?.handleApiRoute === "function"
+ ) {
+ const fallbackHeaders = omitHeadersCaseInsensitive(mergeResponseHeaders({}, response), [
+ "content-encoding",
+ "content-length",
+ "content-type",
+ "transfer-encoding",
+ ]);
+ cancelResponseBody(response);
+ let pagesApiRequest = request;
+ let pagesApiUrl = normalizedUrl;
+ if (typeof pagesServerEntry.runMiddleware === "function") {
+ const middlewareResult = await pagesServerEntry.runMiddleware(request, undefined);
+ if (middlewareResult.waitUntilPromises?.length) {
+ void Promise.allSettled(middlewareResult.waitUntilPromises);
+ }
+ if (!middlewareResult.continue) {
+ if (middlewareResult.redirectUrl) {
+ response = new Response(null, {
+ status: middlewareResult.redirectStatus ?? 307,
+ headers: {
+ ...Object.fromEntries(toWebHeaders(fallbackHeaders)),
+ Location: normalizeLocalRedirectLocation(
+ middlewareResult.redirectUrl,
+ request.url,
+ false,
+ ),
+ },
+ });
+ await sendWebResponse(response, req, res, compress);
+ return;
+ }
+ if (middlewareResult.response) {
+ response = mergeWebResponse(fallbackHeaders, middlewareResult.response);
+ await sendWebResponse(response, req, res, compress);
+ return;
+ }
+ }
+ if (middlewareResult.responseHeaders) {
+ for (const [key, value] of middlewareResult.responseHeaders) {
+ if (key === "set-cookie") {
+ const existing = fallbackHeaders[key];
+ if (Array.isArray(existing)) {
+ existing.push(value);
+ } else if (existing) {
+ fallbackHeaders[key] = [existing as string, value];
+ } else {
+ fallbackHeaders[key] = [value];
+ }
+ } else {
+ fallbackHeaders[key] = value;
+ }
+ }
+ pagesApiRequest = applyMiddlewareRequestHeaders(
+ fallbackHeaders,
+ pagesApiRequest,
+ ).request;
+ }
+ if (middlewareResult.rewriteUrl && !isExternalUrl(middlewareResult.rewriteUrl)) {
+ const rewriteUrl = new URL(middlewareResult.rewriteUrl, request.url);
+ pagesApiUrl = rewriteUrl.pathname + rewriteUrl.search;
+ }
+ }
+ response = mergeWebResponse(
+ fallbackHeaders,
+ await pagesServerEntry.handleApiRoute(pagesApiRequest, pagesApiUrl),
+ );
+ }
const staticFileSignal = response.headers.get("x-vinext-static-file");
if (staticFileSignal) {
@@ -1145,6 +1321,206 @@ type PagesRouterServerOptions = {
compress: boolean;
};
+type PagesDataRequestInfo = {
+ localePrefix: string;
+ pageUrl: string;
+};
+
+function isFileLikePathname(pathname: string): boolean {
+ const pathWithoutTrailing = pathname.replace(/\/+$/, "");
+ const lastSegment = pathWithoutTrailing.slice(pathWithoutTrailing.lastIndexOf("/") + 1);
+ return lastSegment.includes(".");
+}
+
+function normalizeTrailingSlashPathAndSearch(url: string, trailingSlash: boolean): string {
+ const parsed = new URL(url, "http://vinext.local");
+ let pathname = parsed.pathname;
+ if (pathname === "/" || pathname === "/api" || pathname.startsWith("/api/")) {
+ return pathname + parsed.search;
+ }
+
+ if (isFileLikePathname(pathname)) {
+ pathname = pathname.replace(/\/+$/, "") || "/";
+ } else if (trailingSlash) {
+ pathname = pathname.endsWith("/") ? pathname : `${pathname}/`;
+ } else {
+ pathname = pathname.replace(/\/+$/, "") || "/";
+ }
+
+ return pathname + parsed.search;
+}
+
+function pathnameForConfigMatch(pathname: string, trailingSlash: boolean): string {
+ if (!trailingSlash || pathname === "/") return pathname;
+ return pathname.replace(/\/+$/, "") || "/";
+}
+
+function rulesForRequestBasePath(
+ rules: readonly T[],
+ basePath: string,
+ requestHadBasePath: boolean,
+): T[] {
+ if (!basePath) return [...rules];
+ return rules.filter((rule) =>
+ requestHadBasePath ? rule.basePath !== false : rule.basePath === false,
+ );
+}
+
+function normalizeLocalRedirectLocation(
+ location: string,
+ requestUrl: string,
+ trailingSlash: boolean,
+ i18nConfig?: { locales?: string[] } | null,
+): string {
+ try {
+ const parsed = new URL(location, requestUrl);
+ const requestOrigin = new URL(requestUrl).origin;
+ if (parsed.origin !== requestOrigin) return location;
+ parsed.pathname = stripLocalePrefixFromApiPath(parsed.pathname, i18nConfig);
+ const normalized = normalizeTrailingSlashPathAndSearch(
+ parsed.pathname + parsed.search,
+ trailingSlash,
+ );
+ parsed.pathname = normalized.split("?")[0] || "/";
+ parsed.search = normalized.includes("?") ? normalized.slice(normalized.indexOf("?")) : "";
+ return parsed.pathname + parsed.search + parsed.hash;
+ } catch {
+ return location;
+ }
+}
+
+function stripLocalePrefixFromApiPath(
+ pathname: string,
+ i18nConfig?: { locales?: string[] } | null,
+): string {
+ const segments = pathname.split("/").filter(Boolean);
+ if (segments.length < 2 || segments[1] !== "api") return pathname;
+ const locale = i18nConfig?.locales?.find(
+ (candidate) => candidate.toLowerCase() === segments[0]?.toLowerCase(),
+ );
+ return locale ? `/${segments.slice(1).join("/")}` : pathname;
+}
+
+function buildPagesDataRedirectLocation(
+ location: string,
+ requestUrl: string,
+ trailingSlash: boolean,
+ localePrefix: string,
+): string {
+ try {
+ const parsed = new URL(location, requestUrl);
+ const requestOrigin = new URL(requestUrl).origin;
+ if (parsed.origin !== requestOrigin) return location;
+
+ const normalized = normalizeTrailingSlashPathAndSearch(
+ parsed.pathname + parsed.search,
+ trailingSlash,
+ );
+ const normalizedPathname = normalized.split("?")[0] || "/";
+ const pathname =
+ localePrefix &&
+ (normalizedPathname === `${localePrefix}/api` ||
+ normalizedPathname.startsWith(`${localePrefix}/api/`))
+ ? normalizedPathname.slice(localePrefix.length) || "/"
+ : normalizedPathname;
+ const search = normalized.includes("?") ? normalized.slice(normalized.indexOf("?")) : "";
+ const localizedPathname =
+ localePrefix &&
+ pathname !== "/" &&
+ !pathname.startsWith(`${localePrefix}/`) &&
+ pathname !== localePrefix &&
+ !pathname.startsWith("/api/")
+ ? `${localePrefix}${pathname}`
+ : pathname;
+ return localizedPathname + search + parsed.hash;
+ } catch {
+ return location;
+ }
+}
+
+function publicPagesDataRedirectLocalePrefix(
+ dataRequestInfo: PagesDataRequestInfo,
+ i18nConfig?: { defaultLocale?: string } | null,
+): string {
+ return dataRequestInfo.localePrefix === `/${i18nConfig?.defaultLocale}`
+ ? ""
+ : dataRequestInfo.localePrefix;
+}
+
+function parsePagesDataRequest(
+ pathname: string,
+ search: string,
+ buildId?: string | null,
+ i18nConfig?: { locales?: string[]; defaultLocale?: string } | null,
+): PagesDataRequestInfo | null {
+ const prefix = "/_next/data/";
+ if (!pathname.startsWith(prefix) || !pathname.endsWith(".json")) return null;
+
+ const rest = pathname.slice(prefix.length);
+ const firstSlash = rest.indexOf("/");
+ if (firstSlash < 0) return null;
+
+ const requestBuildId = rest.slice(0, firstSlash);
+ if (buildId && requestBuildId !== buildId) return null;
+
+ const rawPagePath = rest.slice(firstSlash).slice(0, -".json".length);
+ const pagePath = rawPagePath === "/index" ? "/" : rawPagePath;
+ const segments = pagePath.split("/").filter(Boolean);
+ const firstSegment = segments[0];
+ const locales = i18nConfig?.locales ?? [];
+ const locale = locales.find(
+ (candidate) => candidate.toLowerCase() === firstSegment?.toLowerCase(),
+ );
+ const localePrefix = locale ? `/${locale}` : "";
+ const pathWithoutLocale = locale ? "/" + segments.slice(1).join("/") : pagePath;
+ const normalizedPath =
+ pathWithoutLocale === "/" || pathWithoutLocale === "" ? "/" : pathWithoutLocale;
+
+ return {
+ localePrefix,
+ pageUrl: normalizedPath + search,
+ };
+}
+
+function normalizePagesDataRequestPageUrl(
+ dataRequestInfo: PagesDataRequestInfo,
+ trailingSlash: boolean,
+): string {
+ return normalizeTrailingSlashPathAndSearch(
+ `${dataRequestInfo.localePrefix}${dataRequestInfo.pageUrl}`,
+ trailingSlash,
+ );
+}
+
+function normalizePagesDataRequestMiddlewareUrl(
+ dataRequestInfo: PagesDataRequestInfo,
+ trailingSlash: boolean,
+ i18nConfig?: { defaultLocale?: string } | null,
+): string {
+ const localePrefix = publicPagesDataRedirectLocalePrefix(dataRequestInfo, i18nConfig);
+ return normalizeTrailingSlashPathAndSearch(
+ `${localePrefix}${dataRequestInfo.pageUrl}`,
+ trailingSlash,
+ );
+}
+
+function applyPagesDataLocalePrefixToResolvedUrl(
+ resolvedUrl: string,
+ dataRequestInfo: PagesDataRequestInfo | null,
+): string {
+ if (!dataRequestInfo?.localePrefix || isExternalUrl(resolvedUrl)) return resolvedUrl;
+
+ const parsed = new URL(resolvedUrl, "http://vinext.local");
+ if (
+ parsed.pathname === dataRequestInfo.localePrefix ||
+ parsed.pathname.startsWith(`${dataRequestInfo.localePrefix}/`)
+ ) {
+ return resolvedUrl;
+ }
+
+ return `${dataRequestInfo.localePrefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
+}
+
/**
* Start the Pages Router production server.
*
@@ -1170,7 +1546,10 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// Extract config values (embedded at build time in the server entry)
const basePath: string = vinextConfig?.basePath ?? "";
+ const assetPrefix: string = vinextConfig?.assetPrefix ?? "";
+ const buildId: string | null = vinextConfig?.buildId ?? process.env.__VINEXT_BUILD_ID ?? null;
const assetBase = basePath ? `${basePath}/` : "/";
+ const i18nConfig = vinextConfig?.i18n ?? null;
const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false;
const configRedirects = vinextConfig?.redirects ?? [];
const configRewrites = vinextConfig?.rewrites ?? {
@@ -1200,13 +1579,20 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
ssrManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
}
- // Load the build manifest to compute lazy chunks — chunks only reachable via
- // dynamic imports (React.lazy, next/dynamic). These should not be
- // modulepreloaded since they are fetched on demand.
+ // Load the build manifest to find the Pages client entry and compute lazy
+ // chunks. The generated Pages server entry reads these globals while rendering
+ // asset tags, mirroring the Cloudflare Worker build-time injection path.
const buildManifestPath = path.join(clientDir, ".vite", "manifest.json");
if (fs.existsSync(buildManifestPath)) {
try {
const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8"));
+ for (const [, value] of Object.entries(buildManifest)) {
+ const entry = value as { isEntry?: boolean; file?: unknown };
+ if (entry.isEntry && typeof entry.file === "string") {
+ globalThis.__VINEXT_CLIENT_ENTRY__ = manifestFileWithBase(entry.file, assetBase);
+ break;
+ }
+ }
const lazyChunks = computeLazyChunks(buildManifest).map((file: string) =>
manifestFileWithBase(file, assetBase),
);
@@ -1300,13 +1686,35 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// middleware. These are always public and don't need protection.
// Public directory files (e.g. /favicon.ico, /robots.txt) are served
// after middleware (step 5b) so middleware can intercept them.
- const staticLookupPath = stripBasePath(pathname, basePath);
+ const staticLookupPath = getNextStaticAssetLookupPath(
+ stripBasePath(pathname, basePath),
+ assetPrefix,
+ );
if (
staticLookupPath.startsWith("/assets/") &&
(await tryServeStatic(req, res, clientDir, staticLookupPath, compress, staticCache))
) {
return;
}
+ if (isNextStaticAssetPath(staticLookupPath)) {
+ if (await tryServeStatic(req, res, clientDir, staticLookupPath, compress, staticCache)) {
+ return;
+ }
+ if (buildId && staticLookupPath === `/_next/static/${buildId}/_buildManifest.js`) {
+ const body =
+ "self.__BUILD_MANIFEST = {};\nself.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB();\n";
+ res.writeHead(200, {
+ "Content-Type": "application/javascript; charset=utf-8",
+ "Content-Length": String(Buffer.byteLength(body)),
+ "Cache-Control": "public, max-age=31536000, immutable",
+ });
+ res.end(body);
+ return;
+ }
+ // Missing Next static assets still flow through middleware in Next.js.
+ // This allows middleware to rewrite e.g. missing chunk URLs and allows
+ // NextResponse.next({ headers }) to decorate the eventual static 404.
+ }
// ── Image optimization passthrough ──────────────────────────────
if (pathname === IMAGE_OPTIMIZATION_PATH || staticLookupPath === IMAGE_OPTIMIZATION_PATH) {
@@ -1352,6 +1760,8 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
try {
+ const requestHadBasePath = !basePath || hasBasePath(pathname, basePath);
+
// ── 2. Strip basePath ─────────────────────────────────────────
{
const stripped = stripBasePath(pathname, basePath);
@@ -1362,15 +1772,45 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
}
+ const originalPagesRequestUrl = url;
+ const dataRequestInfo = parsePagesDataRequest(
+ pathname,
+ rawQs,
+ vinextConfig?.buildId,
+ i18nConfig,
+ );
+ const isPagesDataRequest = dataRequestInfo !== null;
+ if (dataRequestInfo) {
+ url = normalizePagesDataRequestPageUrl(dataRequestInfo, trailingSlash);
+ pathname = url.split("?")[0];
+ }
+ const middlewareRequestUrl = dataRequestInfo
+ ? normalizePagesDataRequestMiddlewareUrl(dataRequestInfo, trailingSlash, i18nConfig)
+ : url;
+
// ── 3. Trailing slash normalization ───────────────────────────
- if (pathname !== "/" && pathname !== "/api" && !pathname.startsWith("/api/")) {
+ if (
+ !isPagesDataRequest &&
+ pathname !== "/" &&
+ pathname !== "/api" &&
+ !pathname.startsWith("/api/")
+ ) {
const hasTrailing = pathname.endsWith("/");
- if (trailingSlash && !hasTrailing) {
+ const pathWithoutTrailing = pathname.replace(/\/+$/, "");
+ const lastSegment = pathWithoutTrailing.slice(pathWithoutTrailing.lastIndexOf("/") + 1);
+ const isFileLike = lastSegment.includes(".");
+ if (isFileLike && hasTrailing) {
+ const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
+ res.writeHead(308, { Location: basePath + pathWithoutTrailing + qs });
+ res.end();
+ return;
+ }
+ if (trailingSlash && !hasTrailing && !isFileLike) {
const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
res.writeHead(308, { Location: basePath + pathname + "/" + qs });
res.end();
return;
- } else if (!trailingSlash && hasTrailing) {
+ } else if (!trailingSlash && hasTrailing && !isFileLike) {
const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
res.writeHead(308, { Location: basePath + pathname.replace(/\/+$/, "") + qs });
res.end();
@@ -1388,9 +1828,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
if (v) h.set(k, Array.isArray(v) ? v.join(", ") : v);
return h;
}, new Headers());
+ if (isPagesDataRequest) {
+ reqHeaders.set("x-vinext-data-request", "1");
+ reqHeaders.set("x-vinext-original-url", originalPagesRequestUrl);
+ if (dataRequestInfo.localePrefix) {
+ reqHeaders.set("x-vinext-data-locale", dataRequestInfo.localePrefix.slice(1));
+ }
+ }
+ if (basePath) {
+ reqHeaders.set("x-vinext-request-had-base-path", requestHadBasePath ? "1" : "0");
+ }
const method = req.method ?? "GET";
const hasBody = method !== "GET" && method !== "HEAD";
- let webRequest = new Request(`${protocol}://${hostHeader}${url}`, {
+ let webRequest = new Request(`${protocol}://${hostHeader}${middlewareRequestUrl}`, {
method,
headers: reqHeaders,
body: hasBody ? readNodeStream(req) : undefined,
@@ -1405,21 +1855,68 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// beforeFiles, afterFiles, and fallback all run after middleware per the
// Next.js execution order, so they use postMwReqCtx below.
const reqCtx: RequestContext = requestContextFromRequest(webRequest);
+ const requestRedirects: NextRedirect[] = rulesForRequestBasePath(
+ configRedirects,
+ basePath,
+ requestHadBasePath,
+ );
+ const requestHeaders: NextHeader[] = rulesForRequestBasePath(
+ configHeaders,
+ basePath,
+ requestHadBasePath,
+ );
+ const requestRewrites = {
+ beforeFiles: rulesForRequestBasePath(
+ configRewrites.beforeFiles ?? [],
+ basePath,
+ requestHadBasePath,
+ ),
+ afterFiles: rulesForRequestBasePath(
+ configRewrites.afterFiles ?? [],
+ basePath,
+ requestHadBasePath,
+ ),
+ fallback: rulesForRequestBasePath(
+ configRewrites.fallback ?? [],
+ basePath,
+ requestHadBasePath,
+ ),
+ };
// ── 4. Apply redirects from next.config.js ────────────────────
- if (configRedirects.length) {
- const redirect = matchRedirect(pathname, configRedirects, reqCtx);
+ if (requestRedirects.length) {
+ const redirectMatchPathname = dataRequestInfo
+ ? dataRequestInfo.pageUrl.split("?")[0] || "/"
+ : middlewareRequestUrl.split("?")[0] || "/";
+ const redirect = matchRedirect(
+ pathnameForConfigMatch(redirectMatchPathname, trailingSlash),
+ requestRedirects,
+ reqCtx,
+ );
if (redirect) {
// Guard against double-prefixing: only add basePath if destination
// doesn't already start with it.
// Sanitize the final destination to prevent protocol-relative URL open redirects.
const dest = sanitizeDestination(
basePath &&
+ requestHadBasePath &&
!isExternalUrl(redirect.destination) &&
!hasBasePath(redirect.destination, basePath)
? basePath + redirect.destination
: redirect.destination,
);
+ if (isPagesDataRequest) {
+ res.writeHead(redirect.permanent ? 308 : 307, {
+ "x-nextjs-redirect": buildPagesDataRedirectLocation(
+ dest,
+ webRequest.url,
+ trailingSlash,
+ publicPagesDataRedirectLocalePrefix(dataRequestInfo, i18nConfig),
+ ),
+ });
+ res.end();
+ return;
+ }
res.writeHead(redirect.permanent ? 308 : 307, { Location: dest });
res.end();
return;
@@ -1428,6 +1925,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// ── 5. Run middleware ─────────────────────────────────────────
let resolvedUrl = url;
+ let resolvedConfigUrl = middlewareRequestUrl;
const middlewareHeaders: Record = {};
let middlewareRewriteStatus: number | undefined;
if (typeof runMiddleware === "function") {
@@ -1442,8 +1940,38 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
if (!result.continue) {
if (result.redirectUrl) {
+ if (isPagesDataRequest) {
+ const redirectHeaders: Record = {
+ "x-nextjs-redirect": buildPagesDataRedirectLocation(
+ result.redirectUrl,
+ webRequest.url,
+ trailingSlash,
+ publicPagesDataRedirectLocalePrefix(dataRequestInfo, i18nConfig),
+ ),
+ };
+ if (result.responseHeaders) {
+ for (const [key, value] of result.responseHeaders) {
+ const existing = redirectHeaders[key];
+ if (existing === undefined) {
+ redirectHeaders[key] = value;
+ } else if (Array.isArray(existing)) {
+ existing.push(value);
+ } else {
+ redirectHeaders[key] = [existing, value];
+ }
+ }
+ }
+ res.writeHead(result.redirectStatus ?? 307, redirectHeaders);
+ res.end();
+ return;
+ }
const redirectHeaders: Record = {
- Location: result.redirectUrl,
+ Location: normalizeLocalRedirectLocation(
+ result.redirectUrl,
+ webRequest.url,
+ trailingSlash,
+ i18nConfig,
+ ),
};
if (result.responseHeaders) {
for (const [key, value] of result.responseHeaders) {
@@ -1504,7 +2032,36 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// Apply middleware rewrite
if (result.rewriteUrl) {
+ if (isExternalUrl(result.rewriteUrl)) {
+ const { request: externalRewriteRequest } = applyMiddlewareRequestHeaders(
+ middlewareHeaders,
+ webRequest,
+ );
+ const proxyResponse = await proxyExternalRequest(
+ externalRewriteRequest,
+ result.rewriteUrl,
+ );
+ const mergedResponse = mergeWebResponse(
+ middlewareHeaders,
+ proxyResponse,
+ result.rewriteStatus,
+ );
+ await sendWebResponse(mergedResponse, req, res, compress);
+ return;
+ }
+ if (dataRequestInfo) {
+ const rewrittenPathname = result.rewriteUrl.split("?")[0] || "/";
+ middlewareHeaders["x-nextjs-matched-path"] =
+ dataRequestInfo.localePrefix + rewrittenPathname;
+ }
resolvedUrl = result.rewriteUrl;
+ resolvedConfigUrl = result.rewriteUrl;
+ } else {
+ const matchedPath = middlewareHeaders["x-matched-path"];
+ if (typeof matchedPath === "string" && matchedPath.startsWith("/")) {
+ resolvedUrl = matchedPath;
+ resolvedConfigUrl = matchedPath;
+ }
}
// Apply custom status code from middleware rewrite
@@ -1525,6 +2082,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// Config header matching must keep using the original normalized pathname
// even if middleware rewrites the downstream route/render target.
let resolvedPathname = resolvedUrl.split("?")[0];
+ let resolvedConfigPathname = resolvedConfigUrl.split("?")[0];
// ── 6. Apply custom headers from next.config.js ───────────────
// Config headers are additive for multi-value headers (Vary,
@@ -1534,8 +2092,8 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// by middleware so middleware always wins for the same key.
// This runs before step 5b so config headers are included in static
// public directory file responses (matching Next.js behavior).
- if (configHeaders.length) {
- const matched = matchHeaders(pathname, configHeaders, reqCtx);
+ if (requestHeaders.length) {
+ const matched = matchHeaders(pathname, requestHeaders, reqCtx);
for (const h of matched) {
const lk = h.key.toLowerCase();
if (lk === "set-cookie") {
@@ -1557,6 +2115,17 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
}
+ if (isNextStaticAssetPath(staticLookupPath)) {
+ const body = "Not Found";
+ res.writeHead(404, {
+ ...omitHeadersCaseInsensitive(middlewareHeaders, ["content-type", "content-length"]),
+ "Content-Type": "text/plain; charset=utf-8",
+ "Content-Length": String(Buffer.byteLength(body)),
+ });
+ res.end(body);
+ return;
+ }
+
// ── 5b. Serve public directory static files ────────────────────
// Public directory files (non-build-asset static files) are served
// after middleware so middleware can intercept or redirect them.
@@ -1582,16 +2151,23 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
// ── 7. Apply beforeFiles rewrites from next.config.js ─────────
- if (configRewrites.beforeFiles?.length) {
- const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx);
+ if (requestRewrites.beforeFiles.length) {
+ const rewritten = matchRewrite(
+ pathnameForConfigMatch(resolvedConfigPathname, trailingSlash),
+ requestRewrites.beforeFiles,
+ postMwReqCtx,
+ resolvedConfigUrl,
+ );
if (rewritten) {
if (isExternalUrl(rewritten)) {
const proxyResponse = await proxyExternalRequest(webRequest, rewritten);
await sendWebResponse(proxyResponse, req, res, compress);
return;
}
- resolvedUrl = rewritten;
+ resolvedConfigUrl = rewritten;
+ resolvedUrl = applyPagesDataLocalePrefixToResolvedUrl(rewritten, dataRequestInfo);
resolvedPathname = rewritten.split("?")[0];
+ resolvedConfigPathname = resolvedConfigUrl.split("?")[0];
}
}
@@ -1636,20 +2212,54 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
return;
}
+ // ── 8b. Public/static asset routes after beforeFiles rewrites ──
+ // beforeFiles rewrites run before filesystem routes in Next.js, so a
+ // rewrite can target public/file.txt and should be served as a static
+ // file before afterFiles rewrites or page rendering.
+ if (
+ resolvedPathname !== "/" &&
+ !resolvedPathname.startsWith("/api/") &&
+ !resolvedPathname.startsWith("/assets/") &&
+ (await tryServeStatic(
+ req,
+ res,
+ clientDir,
+ resolvedPathname,
+ compress,
+ staticCache,
+ middlewareHeaders,
+ ))
+ ) {
+ return;
+ }
+
// ── 9. Apply afterFiles rewrites from next.config.js ──────────
- if (configRewrites.afterFiles?.length) {
- const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx);
+ if (requestRewrites.afterFiles.length) {
+ const rewritten = matchRewrite(
+ pathnameForConfigMatch(resolvedConfigPathname, trailingSlash),
+ requestRewrites.afterFiles,
+ postMwReqCtx,
+ resolvedConfigUrl,
+ );
if (rewritten) {
if (isExternalUrl(rewritten)) {
const proxyResponse = await proxyExternalRequest(webRequest, rewritten);
await sendWebResponse(proxyResponse, req, res, compress);
return;
}
- resolvedUrl = rewritten;
+ resolvedConfigUrl = rewritten;
+ resolvedUrl = applyPagesDataLocalePrefixToResolvedUrl(rewritten, dataRequestInfo);
resolvedPathname = rewritten.split("?")[0];
+ resolvedConfigPathname = resolvedConfigUrl.split("?")[0];
}
}
+ if (basePath && !requestHadBasePath && resolvedUrl === url) {
+ res.writeHead(404);
+ res.end("404 - Not found");
+ return;
+ }
+
// ── 10. SSR page rendering ────────────────────────────────────
let response: Response | undefined;
if (typeof renderPage === "function") {
@@ -1660,14 +2270,16 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
ssrManifest,
undefined,
middlewareResponseHeaders,
+ isPagesDataRequest,
);
// ── 11. Fallback rewrites (if SSR returned 404) ─────────────
- if (response && response.status === 404 && configRewrites.fallback?.length) {
+ if (response && response.status === 404 && requestRewrites.fallback.length) {
const fallbackRewrite = matchRewrite(
- resolvedPathname,
- configRewrites.fallback,
+ pathnameForConfigMatch(resolvedConfigPathname, trailingSlash),
+ requestRewrites.fallback,
postMwReqCtx,
+ resolvedConfigUrl,
);
if (fallbackRewrite) {
if (isExternalUrl(fallbackRewrite)) {
@@ -1675,12 +2287,17 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
await sendWebResponse(proxyResponse, req, res, compress);
return;
}
+ const localizedFallbackRewrite = applyPagesDataLocalePrefixToResolvedUrl(
+ fallbackRewrite,
+ dataRequestInfo,
+ );
response = await renderPage(
webRequest,
- fallbackRewrite,
+ localizedFallbackRewrite,
ssrManifest,
undefined,
middlewareResponseHeaders,
+ isPagesDataRequest,
);
}
}
@@ -1753,4 +2370,6 @@ export {
mergeResponseHeaders,
mergeWebResponse,
tryServeStatic,
+ parsePagesDataRequest,
+ normalizePagesDataRequestPageUrl,
};
diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts
index 2fdb5a799..e503533f5 100644
--- a/packages/vinext/src/server/request-pipeline.ts
+++ b/packages/vinext/src/server/request-pipeline.ts
@@ -130,6 +130,18 @@ export function normalizeTrailingSlash(
return new Response("404 Not Found", { status: 404 });
}
const hasTrailing = pathname.endsWith("/");
+ const pathWithoutTrailing = pathname.replace(/\/+$/, "");
+ const lastSegment = pathWithoutTrailing.slice(pathWithoutTrailing.lastIndexOf("/") + 1);
+ const isFileLike = lastSegment.includes(".");
+ if (isFileLike && hasTrailing) {
+ return new Response(null, {
+ status: 308,
+ headers: { Location: basePath + pathWithoutTrailing + search },
+ });
+ }
+ if (isFileLike) {
+ return null;
+ }
// RSC (client-side navigation) requests arrive as /path.rsc — don't
// redirect those to /path.rsc/ when trailingSlash is enabled.
if (trailingSlash && !hasTrailing && !pathname.endsWith(".rsc")) {
diff --git a/packages/vinext/src/server/trace-metadata.ts b/packages/vinext/src/server/trace-metadata.ts
new file mode 100644
index 000000000..3239bc6fc
--- /dev/null
+++ b/packages/vinext/src/server/trace-metadata.ts
@@ -0,0 +1,134 @@
+import { escapeHtmlAttr } from "./html.js";
+
+type ClientTraceEntry = {
+ key: string;
+ value: string;
+};
+
+type OpenTelemetryApi = {
+ context: {
+ active(): unknown;
+ with(context: unknown, fn: () => T): T;
+ };
+ propagation: {
+ inject(context: unknown, carrier: ClientTraceEntry[], setter: unknown): void;
+ };
+ trace: {
+ setSpan(context: unknown, span: unknown): unknown;
+ wrapSpanContext(context: unknown): unknown;
+ };
+ TraceFlags?: {
+ SAMPLED?: number;
+ };
+};
+
+type OpenTelemetryLoader = () => Promise;
+
+const traceDataSetter = {
+ set(carrier: ClientTraceEntry[], key: string, value: unknown) {
+ carrier.push({ key, value: String(value) });
+ },
+};
+
+function randomHex(bytes: number): string {
+ const values = new Uint8Array(bytes);
+ crypto.getRandomValues(values);
+ return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
+}
+
+function isValidMetadataKey(key: string): boolean {
+ return /^[A-Za-z0-9_.:-]+$/.test(key);
+}
+
+async function loadOpenTelemetryApi(): Promise {
+ // Keep @opentelemetry/api optional. Next apps that configure tracing install
+ // it themselves; apps without it should simply render no trace metadata.
+ const specifier = "@opentelemetry/api";
+ return import(/* @vite-ignore */ specifier) as Promise;
+}
+
+export async function getClientTraceMetadataHtml(
+ clientTraceMetadata: readonly string[] | undefined,
+ loadApi: OpenTelemetryLoader = loadOpenTelemetryApi,
+): Promise {
+ if (!clientTraceMetadata?.length) return "";
+
+ let api: OpenTelemetryApi;
+ try {
+ api = await loadApi();
+ } catch {
+ return "";
+ }
+
+ const allowedKeys = new Set(clientTraceMetadata);
+ const entries: ClientTraceEntry[] = [];
+ const spanContext = {
+ traceId: randomHex(16),
+ spanId: randomHex(8),
+ traceFlags: api.TraceFlags?.SAMPLED ?? 1,
+ };
+ const activeContext = api.trace.setSpan(
+ api.context.active(),
+ api.trace.wrapSpanContext(spanContext),
+ );
+
+ api.context.with(activeContext, () => {
+ api.propagation.inject(activeContext, entries, traceDataSetter);
+ });
+
+ return entries
+ .filter(({ key }) => allowedKeys.has(key) && isValidMetadataKey(key))
+ .map(
+ ({ key, value }) => ``,
+ )
+ .join("");
+}
+
+export function injectHtmlBeforeHeadClose(
+ stream: ReadableStream,
+ html: string,
+): ReadableStream {
+ if (!html) return stream;
+
+ const decoder = new TextDecoder();
+ const encoder = new TextEncoder();
+ let injected = false;
+ let buffered = "";
+
+ const flushBuffered = (controller: TransformStreamDefaultController): void => {
+ if (!buffered) return;
+ if (!injected) {
+ const index = buffered.indexOf("");
+ if (index !== -1) {
+ buffered = buffered.slice(0, index) + html + buffered.slice(index);
+ injected = true;
+ }
+ }
+ controller.enqueue(encoder.encode(buffered));
+ buffered = "";
+ };
+
+ return stream.pipeThrough(
+ new TransformStream({
+ transform(chunk, controller) {
+ buffered += decoder.decode(chunk, { stream: true });
+ if (injected || buffered.includes("") || buffered.length > 8192) {
+ flushBuffered(controller);
+ }
+ },
+ flush(controller) {
+ buffered += decoder.decode();
+ if (!injected) {
+ const index = buffered.indexOf("");
+ if (index !== -1) {
+ buffered = buffered.slice(0, index) + html + buffered.slice(index);
+ injected = true;
+ } else {
+ buffered += html;
+ }
+ }
+ flushBuffered(controller);
+ },
+ }),
+ );
+}
diff --git a/packages/vinext/src/shims/app.ts b/packages/vinext/src/shims/app.ts
index 53ec6849d..580a3f252 100644
--- a/packages/vinext/src/shims/app.ts
+++ b/packages/vinext/src/shims/app.ts
@@ -3,12 +3,32 @@
*
* Provides the AppProps type and default App component for _app.tsx.
*/
-import type { ComponentType } from "react";
+import React, { type ComponentType } from "react";
export type AppProps> = {
Component: ComponentType
;
pageProps: P;
};
-// Re-export for named import compatibility
-export type { AppProps as default };
+type AppContext = {
+ Component: ComponentType & {
+ getInitialProps?: (ctx: unknown) => unknown | Promise;
+ };
+ ctx: unknown;
+};
+
+export default class App> extends React.Component> {
+ static async getInitialProps({ Component, ctx }: AppContext): Promise<{ pageProps: unknown }> {
+ const pageProps =
+ typeof Component.getInitialProps === "function" ? await Component.getInitialProps(ctx) : {};
+ return { pageProps };
+ }
+
+ render(): React.ReactNode {
+ const { Component, pageProps } = this.props;
+ return React.createElement(
+ Component as ComponentType>,
+ pageProps as Record,
+ );
+ }
+}
diff --git a/packages/vinext/src/shims/document.tsx b/packages/vinext/src/shims/document.tsx
index a66ea97fb..0b356c5af 100644
--- a/packages/vinext/src/shims/document.tsx
+++ b/packages/vinext/src/shims/document.tsx
@@ -7,6 +7,23 @@
*/
import React from "react";
+export type DocumentInitialProps = {
+ html: string;
+ head?: React.ReactNode[];
+ styles?: React.ReactNode;
+};
+
+export type DocumentContext = {
+ renderPage: (options?: DocumentRenderPageOptions) => Promise;
+};
+
+export type DocumentRenderPageOptions =
+ | ((Component: React.ComponentType) => React.ComponentType)
+ | {
+ enhanceApp?: (App: React.ComponentType) => React.ComponentType;
+ enhanceComponent?: (Component: React.ComponentType) => React.ComponentType;
+ };
+
export function Html({
children,
lang,
@@ -23,11 +40,11 @@ export function Html({
* Document Head - renders with children.
* The dev server injects meta tags, styles, etc.
*/
-export function Head({ children }: { children?: React.ReactNode }) {
+export function Head({ children }: { children?: React.ReactNode; nonce?: string }) {
return (
-
-
+
+
{children}
);
@@ -45,21 +62,40 @@ export function Main() {
* actual hydration scripts (__NEXT_DATA__ + entry module).
* Uses dangerouslySetInnerHTML so the HTML comment survives renderToString.
*/
-export function NextScript() {
- return " }} />;
+export function NextScript({ crossOrigin, nonce }: { crossOrigin?: string; nonce?: string }) {
+ return React.createElement("vinext-next-scripts", {
+ "data-vinext-next-script-crossorigin": crossOrigin,
+ "data-vinext-next-script-nonce": nonce,
+ dangerouslySetInnerHTML: { __html: "__NEXT_SCRIPTS__" },
+ });
}
+NextScript.getInlineScriptSource = function getInlineScriptSource(props?: {
+ __NEXT_DATA__?: unknown;
+}) {
+ // vinext exposes `window.__NEXT_DATA__` for the Pages Router client entry.
+ // Hash-based CSP code calls this helper to authorize the inline payload, so
+ // it must match the inline script that vinext injects at render time.
+ return `window.__NEXT_DATA__ = ${JSON.stringify(props?.__NEXT_DATA__ ?? {})}`;
+};
+
/**
* Default Document component - used when no custom _document.tsx exists.
*/
-export default function Document() {
- return (
-
-
-
-
-
-
-
- );
+export default class Document> extends React.Component
{
+ static async getInitialProps(ctx: DocumentContext): Promise {
+ return ctx.renderPage();
+ }
+
+ render(): React.ReactNode {
+ return (
+
+
+
+
+
+
+
+ );
+ }
}
diff --git a/packages/vinext/src/shims/form.tsx b/packages/vinext/src/shims/form.tsx
index d69a7e2f9..f50cf93c0 100644
--- a/packages/vinext/src/shims/form.tsx
+++ b/packages/vinext/src/shims/form.tsx
@@ -21,16 +21,29 @@
import { forwardRef, useActionState, type FormHTMLAttributes, type ForwardedRef } from "react";
import { navigateClientSide } from "./navigation.js";
import { isDangerousScheme } from "./url-safety.js";
+import { hasBasePath } from "../utils/base-path.js";
import { toSameOriginPath } from "./url-utils.js";
// Re-export useActionState from React 19 to match Next.js's next/form module
export { useActionState };
-type FormSubmitter = HTMLButtonElement | HTMLInputElement;
+type FormSubmitter = (HTMLButtonElement | HTMLInputElement) & {
+ disabled?: boolean;
+ formEnctype?: string;
+ formMethod?: string;
+ formTarget?: string;
+ name?: string;
+ value?: string;
+ getAttribute(name: string): string | null;
+};
const SUPPORTED_FORM_ENCTYPE = "application/x-www-form-urlencoded";
const SUPPORTED_FORM_METHOD = "GET";
const SUPPORTED_FORM_TARGET = "_self";
+function getBasePath(): string {
+ return process.env.__NEXT_ROUTER_BASEPATH ?? "";
+}
+
function isSafeAction(action: string): boolean {
// Block dangerous URI schemes
if (isDangerousScheme(action)) return false;
@@ -52,18 +65,37 @@ function isSafeAction(action: string): boolean {
return true;
}
-function getSubmitter(nativeEvent: unknown): FormSubmitter | null {
+function isFormSubmitter(value: unknown): value is FormSubmitter {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ "getAttribute" in value &&
+ typeof value.getAttribute === "function"
+ );
+}
+
+function getSubmitter(
+ nativeEvent: unknown,
+ form: HTMLFormElement,
+ fallbackSubmitter: FormSubmitter | null,
+): FormSubmitter | null {
const submitter =
nativeEvent &&
typeof nativeEvent === "object" &&
"submitter" in nativeEvent &&
- nativeEvent.submitter instanceof Element
+ isFormSubmitter(nativeEvent.submitter)
? nativeEvent.submitter
: null;
- if (submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) {
- return submitter;
+ if (submitter) return submitter;
+
+ const activeElement = typeof document !== "undefined" ? document.activeElement : null;
+ if (isFormSubmitter(activeElement) && form.contains(activeElement as Node)) {
+ return activeElement;
}
+
+ if (fallbackSubmitter) return fallbackSubmitter;
+
return null;
}
@@ -79,6 +111,21 @@ function getEffectiveAction(submitter: FormSubmitter | null, formAction: string)
return submitter?.getAttribute("formaction") ?? formAction;
}
+function addBasePathToFormAction(action: string): string {
+ const basePath = getBasePath();
+ if (!basePath || !action.startsWith("/") || action.startsWith("//")) return action;
+
+ try {
+ const url = new URL(action, "http://vinext.local");
+ if (url.origin !== "http://vinext.local" || hasBasePath(url.pathname, basePath)) {
+ return action;
+ }
+ return `${basePath}${url.pathname}${url.search}${url.hash}`;
+ } catch {
+ return action;
+ }
+}
+
function checkFormActionUrl(action: string, source: "action" | "formAction"): void {
const aPropName = source === "action" ? "an `action`" : "a `formAction`";
@@ -98,8 +145,20 @@ function checkFormActionUrl(action: string, source: "action" | "formAction"): vo
}
}
+function getSubmitterAttribute(
+ submitter: FormSubmitter,
+ lowerName: string,
+ reactName: string,
+): string | null {
+ return submitter.getAttribute(lowerName) ?? submitter.getAttribute(reactName);
+}
+
function hasUnsupportedSubmitterAttributes(submitter: FormSubmitter): boolean {
- const formEncType = submitter.getAttribute("formenctype");
+ const formEncType =
+ getSubmitterAttribute(submitter, "formenctype", "formEncType") ??
+ (submitter.formEnctype && submitter.formEnctype !== SUPPORTED_FORM_ENCTYPE
+ ? submitter.formEnctype
+ : null);
if (formEncType !== null && formEncType !== SUPPORTED_FORM_ENCTYPE) {
console.error(
`
", ` ${nextDataScript}\n`);
}
+ if (documentScriptNonce) {
+ html = addMissingAttrToScriptsAndPreloads(html, "nonce", documentScriptNonce);
+ }
+ if (documentScriptCrossOrigin || options.crossOrigin) {
+ html = addMissingAttrToScriptsAndPreloads(
+ html,
+ "crossorigin",
+ documentScriptCrossOrigin ?? options.crossOrigin ?? "",
+ );
+ }
return html;
}
return (
"\n\n
+ // Inject scripts: replace the current NextScript marker, with the legacy
+ // comment marker kept for compatibility with older rendered output.
+ docHtml = docHtml.replace(
+ /]*>__NEXT_SCRIPTS__<\/vinext-next-scripts>/i,
+ scripts,
+ );
docHtml = docHtml.replace("", scripts);
if (!docHtml.includes("__NEXT_DATA__")) {
docHtml = docHtml.replace("", ` ${scripts}\n`);
@@ -278,6 +327,17 @@ export function createSSRHandler(
const _reqStart = now();
let _compileEnd: number | undefined;
let _renderEnd: number | undefined;
+ const originalUrl = url;
+ const dataRequestInfo = parsePagesDevDataRequest(
+ url,
+ basePath,
+ process.env.__VINEXT_BUILD_ID,
+ i18nConfig,
+ );
+ const isDataRequest = dataRequestInfo !== null;
+ if (dataRequestInfo) {
+ url = dataRequestInfo.pageUrl;
+ }
res.on("finish", () => {
const totalMs = now() - _reqStart;
@@ -290,7 +350,7 @@ export function createSSRHandler(
: undefined;
logRequest({
method: req.method ?? "GET",
- url,
+ url: originalUrl,
status: res.statusCode,
totalMs,
compileMs,
@@ -312,6 +372,7 @@ export function createSSRHandler(
req.headers.host,
basePath,
trailingSlash,
+ { skipLocaleRedirect: isDataRequest },
);
locale = resolved.locale;
localeStrippedUrl = resolved.url;
@@ -936,6 +997,49 @@ hydrate();
const allScripts = `${nextDataScript}\n ${hydrationScript}`;
+ if (isDataRequest) {
+ const dataHeaders: Record = {
+ ...gsspExtraHeaders,
+ "Content-Type": "application/json",
+ };
+ if (
+ typeof pageModule.getServerSideProps === "function" &&
+ !dataHeaders["Cache-Control"]
+ ) {
+ dataHeaders["Cache-Control"] =
+ "private, no-cache, no-store, max-age=0, must-revalidate";
+ }
+ if (isrRevalidateSeconds && !dataHeaders["Cache-Control"]) {
+ dataHeaders["Cache-Control"] =
+ `s-maxage=${isrRevalidateSeconds}, stale-while-revalidate`;
+ }
+
+ res.writeHead(statusCode ?? 200, dataHeaders);
+ res.end(
+ safeJsonStringify({
+ pageProps,
+ page: patternToNextFormat(route.pattern),
+ query:
+ typeof pageModule.getStaticProps === "function"
+ ? params
+ : { ...params, ...parseQuery(url) },
+ buildId: process.env.__VINEXT_BUILD_ID,
+ isFallback: false,
+ locale: locale ?? currentDefaultLocale,
+ locales: i18nConfig?.locales,
+ defaultLocale: currentDefaultLocale,
+ domainLocales,
+ ...(typeof pageModule.getStaticProps === "function" ? { gsp: true } : {}),
+ ...(typeof pageModule.getServerSideProps === "function" ? { gssp: true } : {}),
+ __vinext: {
+ pageModuleUrl,
+ appModuleUrl,
+ },
+ }),
+ );
+ return;
+ }
+
// Build response headers: start with gSSP headers, then layer on
// ISR and font preload headers (which take precedence).
const extraHeaders: Record = {
@@ -1147,6 +1251,10 @@ async function renderErrorPage(
const docElement = createElement(DocumentComponent);
let docHtml = await renderToStringAsync(docElement);
docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml);
+ docHtml = docHtml.replace(
+ /]*>__NEXT_SCRIPTS__<\/vinext-next-scripts>/i,
+ "",
+ );
docHtml = docHtml.replace("", "");
html = docHtml;
} else {
diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts
index 5c70295d3..7ed507f05 100644
--- a/packages/vinext/src/shims/router.ts
+++ b/packages/vinext/src/shims/router.ts
@@ -1068,7 +1068,7 @@ function syncI18nGlobalsFromNextData(nextData: VinextNextData): void {
*/
async function navigateClient(
url: string,
- options: { allowErrorPageData?: boolean } = {},
+ options: { allowErrorPageData?: boolean; beforeRender?: () => void } = {},
): Promise {
if (typeof window === "undefined") return;
@@ -1129,6 +1129,7 @@ async function navigateClient(
}
if (dataResult.kind === "not-found") {
+ options.beforeRender?.();
renderPagesNotFound(root);
return;
}
@@ -1202,6 +1203,7 @@ async function navigateClient(
window.__NEXT_DATA__ = nextData;
syncI18nGlobalsFromNextData(nextData);
element = wrapWithRouterContext(element);
+ options.beforeRender?.();
await renderPagesRoot(root, element);
return;
}
@@ -1268,6 +1270,7 @@ async function navigateClient(
);
if (!match) {
if (res.status === 404) {
+ options.beforeRender?.();
renderPagesNotFound(root);
return;
}
@@ -1364,6 +1367,7 @@ async function navigateClient(
// Wrap with RouterContext.Provider so next/compat/router works
element = wrapWithRouterContext(element);
+ options.beforeRender?.();
await renderPagesRoot(root, element);
})();
_activeNavigationPromise = navigationPromise;
@@ -1395,7 +1399,7 @@ async function navigateClient(
async function runNavigateClient(
fullUrl: string,
resolvedUrl: string,
- options: { allowErrorPageData?: boolean } = {},
+ options: { allowErrorPageData?: boolean; beforeRender?: () => void } = {},
): Promise<"completed" | "cancelled" | "failed"> {
try {
await navigateClient(fullUrl, options);
@@ -1555,29 +1559,47 @@ export function useRouter(): NextRouter {
routerEvents.emit("routeChangeStart", eventUrl, { shallow: options?.shallow ?? false });
if (options?.shallow) {
commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
+ setState(getPathnameAndQuery());
+ routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: true });
+ routerEvents.emit("routeChangeComplete", eventUrl, { shallow: true });
} else {
const committedBeforeFetch = shouldCommitQueryNavigationBeforeFetch(full);
if (committedBeforeFetch) {
commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
}
const previousBrowserUrl = getCurrentBrowserPathSearchHash();
+ let completedBeforeRender = false;
+ const completeBeforeRender = () => {
+ if (completedBeforeRender) return;
+ completedBeforeRender = true;
+ if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
+ commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
+ } else {
+ syncHistoryTrackingFromCurrent();
+ }
+ preserveTargetSearchIfRewriteDroppedIt(full);
+ setState(getPathnameAndQuery());
+ routerEvents.emit("beforeHistoryChange", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ routerEvents.emit("routeChangeComplete", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ };
_pendingNavigationBrowserUrl = full;
- const result = await runNavigateClient(routeFull, eventUrl, { allowErrorPageData });
+ const result = await runNavigateClient(routeFull, eventUrl, {
+ allowErrorPageData,
+ beforeRender: completeBeforeRender,
+ });
if (_pendingNavigationBrowserUrl === full) {
_pendingNavigationBrowserUrl = null;
}
if (result === "cancelled") return true;
if (result === "failed") return false;
- if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
- commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
- } else {
- syncHistoryTrackingFromCurrent();
+ if (!completedBeforeRender) {
+ completeBeforeRender();
}
- preserveTargetSearchIfRewriteDroppedIt(full);
}
- setState(getPathnameAndQuery());
- routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: options?.shallow ?? false });
- routerEvents.emit("routeChangeComplete", eventUrl, { shallow: options?.shallow ?? false });
// Scroll: handle hash target, else scroll to top unless scroll:false
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
@@ -1645,29 +1667,47 @@ export function useRouter(): NextRouter {
routerEvents.emit("routeChangeStart", eventUrl, { shallow: options?.shallow ?? false });
if (options?.shallow) {
commitReplaceNavigationHistory(historyState, full);
+ setState(getPathnameAndQuery());
+ routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: true });
+ routerEvents.emit("routeChangeComplete", eventUrl, { shallow: true });
} else {
const committedBeforeFetch = shouldCommitQueryNavigationBeforeFetch(full);
if (committedBeforeFetch) {
commitReplaceNavigationHistory(historyState, full);
}
const previousBrowserUrl = getCurrentBrowserPathSearchHash();
+ let completedBeforeRender = false;
+ const completeBeforeRender = () => {
+ if (completedBeforeRender) return;
+ completedBeforeRender = true;
+ if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
+ commitReplaceNavigationHistory(historyState, full);
+ } else {
+ syncHistoryTrackingFromCurrent();
+ }
+ preserveTargetSearchIfRewriteDroppedIt(full);
+ setState(getPathnameAndQuery());
+ routerEvents.emit("beforeHistoryChange", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ routerEvents.emit("routeChangeComplete", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ };
_pendingNavigationBrowserUrl = full;
- const result = await runNavigateClient(routeFull, eventUrl, { allowErrorPageData });
+ const result = await runNavigateClient(routeFull, eventUrl, {
+ allowErrorPageData,
+ beforeRender: completeBeforeRender,
+ });
if (_pendingNavigationBrowserUrl === full) {
_pendingNavigationBrowserUrl = null;
}
if (result === "cancelled") return true;
if (result === "failed") return false;
- if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
- commitReplaceNavigationHistory(historyState, full);
- } else {
- syncHistoryTrackingFromCurrent();
+ if (!completedBeforeRender) {
+ completeBeforeRender();
}
- preserveTargetSearchIfRewriteDroppedIt(full);
}
- setState(getPathnameAndQuery());
- routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: options?.shallow ?? false });
- routerEvents.emit("routeChangeComplete", eventUrl, { shallow: options?.shallow ?? false });
// Scroll: handle hash target, else scroll to top unless scroll:false
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
@@ -1755,7 +1795,6 @@ let _beforePopStateCb: BeforePopStateCallback | undefined;
// compare the previous value with the (already-changed) window.location.
let _lastPathnameAndSearch =
typeof window !== "undefined" ? window.location.pathname + window.location.search : "";
-let _isFirstPopStateEvent = true;
type NextHistoryState = {
url?: string;
@@ -1868,8 +1907,6 @@ if (typeof window !== "undefined") {
window.addEventListener("popstate", (e: PopStateEvent) => {
const state = e.state as NextHistoryState | null;
- const isFirstPopStateEvent = _isFirstPopStateEvent;
- _isFirstPopStateEvent = false;
if (state?.__NA) {
window.location.reload();
@@ -1880,17 +1917,6 @@ if (typeof window !== "undefined") {
const stateUrl = state.url ?? window.location.pathname + window.location.search;
const stateAs = state.as ?? stateUrl;
const stateOptions = state.options ?? {};
- const previousAsPath = stripLocaleFromAppPathAndSearch(
- stripBasePath(_lastPathnameAndSearch, __basePath),
- );
- if (
- isFirstPopStateEvent &&
- window.__VINEXT_LOCALE__ === stateOptions.locale &&
- stateAs === previousAsPath
- ) {
- return;
- }
-
let forcedScroll: { x: number; y: number } | null = null;
if (manualScrollRestoration && typeof state.key === "string" && _historyKey !== state.key) {
saveScrollPositionToSession(_historyKey);
@@ -1910,6 +1936,17 @@ if (typeof window !== "undefined") {
}
const fullStateUrl = toBrowserNavigationHref(stateUrl, window.location.href, __basePath);
+ const browserPathAndSearch = window.location.pathname + window.location.search;
+ if (browserPathAndSearch === _lastPathnameAndSearch) {
+ const hashUrl = stripBasePath(browserPathAndSearch, __basePath) + window.location.hash;
+ routerEvents.emit("hashChangeStart", hashUrl, { shallow: false });
+ scrollToHash(window.location.hash);
+ routerEvents.emit("hashChangeComplete", hashUrl, { shallow: false });
+ restoreScrollPosition(e.state, forcedScroll);
+ dispatchVinextNavigate();
+ return;
+ }
+ _lastPathnameAndSearch = browserPathAndSearch;
routerEvents.emit("routeChangeStart", stateAs, { shallow: stateOptions.shallow ?? false });
routerEvents.emit("beforeHistoryChange", stateAs, {
shallow: stateOptions.shallow ?? false,
@@ -2108,28 +2145,45 @@ const Router: NextRouterSingleton = {
routerEvents.emit("routeChangeStart", eventUrl, { shallow: options?.shallow ?? false });
if (options?.shallow) {
commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
+ routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: true });
+ routerEvents.emit("routeChangeComplete", eventUrl, { shallow: true });
} else {
const committedBeforeFetch = shouldCommitQueryNavigationBeforeFetch(full);
if (committedBeforeFetch) {
commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
}
const previousBrowserUrl = getCurrentBrowserPathSearchHash();
+ let completedBeforeRender = false;
+ const completeBeforeRender = () => {
+ if (completedBeforeRender) return;
+ completedBeforeRender = true;
+ if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
+ commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
+ } else {
+ syncHistoryTrackingFromCurrent();
+ }
+ preserveTargetSearchIfRewriteDroppedIt(full);
+ routerEvents.emit("beforeHistoryChange", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ routerEvents.emit("routeChangeComplete", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ };
_pendingNavigationBrowserUrl = full;
- const result = await runNavigateClient(routeFull, eventUrl, { allowErrorPageData });
+ const result = await runNavigateClient(routeFull, eventUrl, {
+ allowErrorPageData,
+ beforeRender: completeBeforeRender,
+ });
if (_pendingNavigationBrowserUrl === full) {
_pendingNavigationBrowserUrl = null;
}
if (result === "cancelled") return true;
if (result === "failed") return false;
- if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
- commitPushNavigationHistory(historyState, full, routeFull, as !== undefined);
- } else {
- syncHistoryTrackingFromCurrent();
+ if (!completedBeforeRender) {
+ completeBeforeRender();
}
- preserveTargetSearchIfRewriteDroppedIt(full);
}
- routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: options?.shallow ?? false });
- routerEvents.emit("routeChangeComplete", eventUrl, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
if (hash) {
@@ -2194,28 +2248,45 @@ const Router: NextRouterSingleton = {
routerEvents.emit("routeChangeStart", eventUrl, { shallow: options?.shallow ?? false });
if (options?.shallow) {
commitReplaceNavigationHistory(historyState, full);
+ routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: true });
+ routerEvents.emit("routeChangeComplete", eventUrl, { shallow: true });
} else {
const committedBeforeFetch = shouldCommitQueryNavigationBeforeFetch(full);
if (committedBeforeFetch) {
commitReplaceNavigationHistory(historyState, full);
}
const previousBrowserUrl = getCurrentBrowserPathSearchHash();
+ let completedBeforeRender = false;
+ const completeBeforeRender = () => {
+ if (completedBeforeRender) return;
+ completedBeforeRender = true;
+ if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
+ commitReplaceNavigationHistory(historyState, full);
+ } else {
+ syncHistoryTrackingFromCurrent();
+ }
+ preserveTargetSearchIfRewriteDroppedIt(full);
+ routerEvents.emit("beforeHistoryChange", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ routerEvents.emit("routeChangeComplete", eventUrl, {
+ shallow: options?.shallow ?? false,
+ });
+ };
_pendingNavigationBrowserUrl = full;
- const result = await runNavigateClient(routeFull, eventUrl, { allowErrorPageData });
+ const result = await runNavigateClient(routeFull, eventUrl, {
+ allowErrorPageData,
+ beforeRender: completeBeforeRender,
+ });
if (_pendingNavigationBrowserUrl === full) {
_pendingNavigationBrowserUrl = null;
}
if (result === "cancelled") return true;
if (result === "failed") return false;
- if (!committedBeforeFetch && getCurrentBrowserPathSearchHash() === previousBrowserUrl) {
- commitReplaceNavigationHistory(historyState, full);
- } else {
- syncHistoryTrackingFromCurrent();
+ if (!completedBeforeRender) {
+ completeBeforeRender();
}
- preserveTargetSearchIfRewriteDroppedIt(full);
}
- routerEvents.emit("beforeHistoryChange", eventUrl, { shallow: options?.shallow ?? false });
- routerEvents.emit("routeChangeComplete", eventUrl, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
if (hash) {
diff --git a/tests/document.test.ts b/tests/document.test.ts
index 888855a0c..fa621c660 100644
--- a/tests/document.test.ts
+++ b/tests/document.test.ts
@@ -24,10 +24,11 @@ describe("Main", () => {
});
describe("NextScript", () => {
- it("renders the __NEXT_SCRIPTS__ comment that dev-server replaces with hydration scripts", () => {
+ it("renders the __NEXT_SCRIPTS__ marker that the server replaces with hydration scripts", () => {
const html = render(React.createElement(NextScript));
- // Dev-server replaces this HTML comment with __NEXT_DATA__ + module script tags
- expect(html).toContain("");
+ // The server replaces this marker with __NEXT_DATA__ + module script tags.
+ expect(html).toContain(" {
it("injects default charset and viewport meta tags", () => {
const html = render(React.createElement(Head));
expect(html).toContain('charSet="utf-8"');
- expect(html).toContain('content="width=device-width, initial-scale=1"');
+ expect(html).toContain('content="width=device-width"');
+ expect(html).toContain('data-next-head=""');
});
it("preserves custom children alongside defaults", () => {
diff --git a/tests/e2e/cloudflare-pages-router/hydration.spec.ts b/tests/e2e/cloudflare-pages-router/hydration.spec.ts
index cd3c77776..e1f3dc51b 100644
--- a/tests/e2e/cloudflare-pages-router/hydration.spec.ts
+++ b/tests/e2e/cloudflare-pages-router/hydration.spec.ts
@@ -10,7 +10,7 @@ test.describe("Pages Router client hydration on Cloudflare Workers", () => {
const response = await page.goto(BASE + "/");
const html = await response!.text();
// The HTML should contain a script tag for the client entry
- expect(html).toMatch(/script\s+type="module"\s+src="\/assets\/[^"]+\.js"/);
+ expect(html).toMatch(/');
+ tags.push('');
}
if (m) {
// Always inject shared chunks (framework, vinext runtime, entry) and
@@ -15068,7 +15352,7 @@ function collectAssetTags(manifest, moduleIds, scriptNonce) {
// (React.lazy, next/dynamic) and should only be fetched on demand.
if (lazySet && lazySet.has(tf)) continue;
tags.push('');
- tags.push('');
+ tags.push('');
}
}
}
@@ -15122,12 +15406,211 @@ function parseCookieLocaleFromHeader(cookieHeader) {
return null;
}
-export async function renderPage(request, url, manifest, ctx, middlewareHeaders) {
- if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest, middlewareHeaders));
- return _renderPage(request, url, manifest, middlewareHeaders);
+export async function renderPage(request, url, manifest, ctx, middlewareHeaders, isDataRequest) {
+ if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest, middlewareHeaders, isDataRequest));
+ return _renderPage(request, url, manifest, middlewareHeaders, isDataRequest);
}
-async function _renderPage(request, url, manifest, middlewareHeaders) {
+async function renderStatusPage(options) {
+ const statusCode = options.statusCode;
+ const statusRoutePattern = statusCode === 404 ? "/404" : "/500";
+ const statusRoute = pageRoutes.find(function(route) {
+ return route.pattern === statusRoutePattern;
+ });
+ const route = statusRoute || null;
+ const PageComponent = route && route.module && route.module.default
+ ? route.module.default
+ : ErrorComponent;
+
+ if (!PageComponent) return null;
+
+ const routePattern = route ? patternToNextFormat(route.pattern) : "/_error";
+ const routeUrl = route ? route.pattern : "/_error";
+ const requestUrl = requestPathAndSearch(options.request);
+ let pageProps = { statusCode };
+ const query = {};
+ const scriptNonce = __getScriptNonceFromHeaderSources(options.request.headers, options.middlewareHeaders);
+ const shouldBufferResponse = true;
+
+ if (typeof setSSRContext === "function") {
+ setSSRContext({
+ pathname: routePattern,
+ query,
+ asPath: requestUrl,
+ isFallback: false,
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ domainLocales: options.domainLocales,
+ });
+ }
+
+ if (i18nConfig) {
+ setI18nContext({
+ locale: options.locale,
+ locales: i18nConfig.locales,
+ defaultLocale: options.defaultLocale,
+ domainLocales: options.domainLocales,
+ hostname: new URL(options.request.url).hostname,
+ });
+ }
+
+ if (PageComponent && typeof PageComponent.getInitialProps === "function") {
+ const errorReqRes = __createPagesReqRes({
+ body: undefined,
+ query,
+ request: options.request,
+ url: requestUrl,
+ });
+ errorReqRes.res.statusCode = statusCode;
+ const nextErrorProps = await PageComponent.getInitialProps({
+ pathname: routePattern,
+ query,
+ asPath: requestUrl,
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ req: errorReqRes.req,
+ res: errorReqRes.res,
+ err: options.err,
+ });
+ if (errorReqRes.res.headersSent) {
+ return await errorReqRes.responsePromise;
+ }
+ if (nextErrorProps && typeof nextErrorProps === "object") {
+ pageProps = { ...pageProps, ...nextErrorProps };
+ }
+ }
+
+ function createPageElement(currentPageProps) {
+ var currentElement = AppComponent
+ ? React.createElement(AppComponent, { Component: PageComponent, pageProps: currentPageProps })
+ : React.createElement(PageComponent, currentPageProps);
+ return wrapWithRouterContext(currentElement);
+ }
+
+ let documentInitialProps = {};
+ if (DocumentComponent && typeof DocumentComponent.getInitialProps === "function") {
+ const documentReqRes = __createPagesReqRes({
+ body: undefined,
+ query,
+ request: options.request,
+ url: requestUrl,
+ });
+ const documentCtx = {
+ pathname: routePattern,
+ query,
+ asPath: requestUrl,
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ req: documentReqRes.req,
+ res: documentReqRes.res,
+ renderPage: async function renderDocumentPage() {
+ const html = await renderToStringAsync(createPageElement(pageProps));
+ return { html, head: [], styles: [] };
+ },
+ };
+ const nextDocumentProps = await DocumentComponent.getInitialProps(documentCtx);
+ if (documentReqRes.res.headersSent) {
+ return await documentReqRes.responsePromise;
+ }
+ if (nextDocumentProps && typeof nextDocumentProps === "object") {
+ documentInitialProps = nextDocumentProps;
+ }
+ }
+
+ var _fontLinkHeader = "";
+ var _allFp = [];
+ try {
+ var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : [];
+ var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : [];
+ _allFp = _fpGoogle.concat(_fpLocal);
+ if (_allFp.length > 0) {
+ _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", ");
+ }
+ } catch (e) { /* font preloads not available */ }
+
+ const pageModuleFilePath = route ? route.filePath : errorModuleFilePath;
+ const pageModuleUrl = pageModuleFilePath ? findModuleJsAsset(options.manifest, pageModuleFilePath) : null;
+ const appModuleUrl = findModuleJsAsset(options.manifest, appModuleFilePath);
+ const pageModuleIds = pageModuleFilePath ? [pageModuleFilePath] : [];
+ const assetTags = collectAssetTags(options.manifest, pageModuleIds, scriptNonce);
+ const response = await __renderPagesPageResponse({
+ assetTags,
+ appProps: {},
+ buildId,
+ clientTraceMetadata: vinextConfig.clientTraceMetadata,
+ clearSsrContext() {
+ if (typeof setSSRContext === "function") setSSRContext(null);
+ },
+ createPageElement,
+ crossOrigin: vinextConfig.crossOrigin,
+ DocumentComponent,
+ documentProps: documentInitialProps,
+ documentRenderPageOptions: null,
+ flushPreloads: typeof flushPreloads === "function" ? flushPreloads : undefined,
+ fontLinkHeader: _fontLinkHeader,
+ fontPreloads: _allFp,
+ getFontLinks() {
+ try {
+ return typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : [];
+ } catch (e) {
+ return [];
+ }
+ },
+ getFontStyles() {
+ try {
+ var allFontStyles = [];
+ if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle());
+ if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal());
+ return allFontStyles;
+ } catch (e) {
+ return [];
+ }
+ },
+ getSSRHeadHTML: typeof getSSRHeadHTML === "function" ? getSSRHeadHTML : undefined,
+ gsspRes: null,
+ isFallback: false,
+ isGsp: false,
+ isrCacheKey,
+ isrRevalidateSeconds: null,
+ isrSet,
+ i18n: {
+ locale: options.locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: options.defaultLocale,
+ domainLocales: options.domainLocales,
+ },
+ pageProps,
+ pageModuleUrl,
+ appModuleUrl,
+ params: query,
+ renderDocumentToString(element) {
+ return renderToStringAsync(element);
+ },
+ renderHeadPrepassToStringAsync(element) {
+ return renderToStringAsync(element);
+ },
+ renderIsrPassToStringAsync,
+ renderToReadableStream(element) {
+ return renderToReadableStream(element);
+ },
+ resetSSRHead: typeof resetSSRHead === "function" ? resetSSRHead : undefined,
+ routePattern,
+ routeUrl,
+ safeJsonStringify,
+ scriptNonce,
+ shouldBufferResponse,
+ });
+
+ return new Response(response.body, {
+ status: statusCode,
+ headers: response.headers,
+ });
+}
+
+async function _renderPage(request, url, manifest, middlewareHeaders, isDataRequest) {
const localeInfo = i18nConfig
? resolvePagesI18nRequest(
url,
@@ -15136,6 +15619,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
new URL(request.url).hostname,
vinextConfig.basePath,
vinextConfig.trailingSlash,
+ { skipLocaleRedirect: isDataRequest },
)
: { locale: undefined, url, hadPrefix: false, domainLocale: undefined, redirectUrl: undefined };
const locale = localeInfo.locale;
@@ -15151,7 +15635,17 @@ async function _renderPage(request, url, manifest, middlewareHeaders) {
const match = matchRoute(routeUrl, pageRoutes);
if (!match) {
- return new Response("404 - Page not found