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..baa431aa1 --- /dev/null +++ b/.github/workflows/nextjs-pages-router-deploy-suite.yml @@ -0,0 +1,107 @@ +name: Next.js Pages Router Deploy Suite + +on: + # Temporary PR trigger while validating this first-pass Pages Router adapter + # parity suite. Remove the pull_request trigger before merging. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + inputs: + next-ref: + description: Next.js ref to test against + required: true + default: v16.2.4 + 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 || 'v16.2.4' }} + 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: Enable pnpm shim for Next.js scripts + run: corepack enable pnpm + + - name: Checkout Next.js + uses: actions/checkout@v6 + with: + repository: vercel/next.js + ref: ${{ inputs.next-ref || 'v16.2.4' }} + 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 || '2' }} + 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/benchmarks/vinext/tsconfig.json b/benchmarks/vinext/tsconfig.json index d899beda7..39af51225 100644 --- a/benchmarks/vinext/tsconfig.json +++ b/benchmarks/vinext/tsconfig.json @@ -12,7 +12,8 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "vinext": ["../../packages/vinext/src/index.ts"] } }, "include": ["**/*.ts", "**/*.tsx"], 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..65178dd71 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..898ae273e 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) || @@ -696,7 +697,12 @@ export function matchConfigPattern( tokenRe.lastIndex += 1; const constraint = extractConstraint(pattern, tokenRe); paramNames.push(name); - if (constraint !== null) { + const hasLiteralSuffix = + tokenRe.lastIndex < pattern.length && pattern[tokenRe.lastIndex] !== "/"; + if (quantifier === "*" && regexStr.endsWith("/") && !hasLiteralSuffix) { + const capture = constraint !== null ? constraint : ".*"; + regexStr = `${regexStr.slice(0, -1)}(?:/(${capture}))?`; + } else if (constraint !== null) { regexStr += `(${constraint})`; } else { regexStr += quantifier === "*" ? "(.*)" : "(.+)"; @@ -713,7 +719,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 +746,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 +770,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 +928,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 +961,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 +1243,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..2bf760573 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( } } +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..26a1ac121 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,12 +374,37 @@ 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; return new Response(stream).text(); } +async function renderHeadPrepassAsync(element) { + const stream = await renderToReadableStream(element, { + onError() { + // The prepass is intentionally aborted after synchronous next/head tags render. + }, + }); + try { + await stream.cancel(); + } catch (_error) { + // The prepass only exists to collect synchronously rendered next/head tags. + } + return ""; +} + async function renderIsrPassToStringAsync(element) { // The cache-fill render is a second render pass for the same request. // Reset render-scoped state so it cannot leak from the streamed response @@ -346,6 +425,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 +451,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 +466,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 +482,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 +515,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 +612,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 +666,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 renderHeadPrepassAsync(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 +879,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 +895,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 +917,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 +973,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 isCrawlerRequest = __isPagesHtmlBotUserAgent(request.headers.get("user-agent") || ""); + const shouldBufferResponse = isCrawlerRequest; // 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 +992,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 +1018,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 +1039,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 +1067,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 +1291,8 @@ async function _renderPage(request, url, manifest, middlewareHeaders) { }, getSSRHeadHTML: typeof getSSRHeadHTML === "function" ? getSSRHeadHTML : undefined, gsspRes, + isFallback, + isGsp: isGspPage, isrCacheKey, isrRevalidateSeconds, isrSet, @@ -751,10 +1303,15 @@ async function _renderPage(request, url, manifest, middlewareHeaders) { domainLocales: domainLocales, }, pageProps, - params, + pageModuleUrl, + appModuleUrl, + params: query, renderDocumentToString(element) { return renderToStringAsync(element); }, + renderHeadPrepassToStringAsync(element) { + return renderHeadPrepassAsync(element); + }, renderIsrPassToStringAsync, renderToReadableStream(element) { return renderToReadableStream(element); @@ -764,6 +1321,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders) { routeUrl, safeJsonStringify, scriptNonce, + shouldBufferResponse, }); } catch (e) { console.error("[vinext] SSR error:", e); @@ -772,6 +1330,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 +1353,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..9d32179c9 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,11 +1706,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, ssr: { + define: serverDefines, + ...(oxcConfig ? { oxc: oxcConfig } : {}), ...(hasCloudflarePlugin || hasNitroPlugin ? {} : { resolve: { - external: userSsrExternal === true ? true : [...userSsrExternal], + external: + userSsrExternal === true + ? true + : [ + "react", + "react-dom", + "react-dom/server", + "react-dom/server.edge", + ...userSsrExternal, + ], // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native // ESM loader. Matches Next.js behavior of bundling everything. @@ -1331,17 +1731,31 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }), optimizeDeps: { - exclude: [...new Set([...incomingExclude, "vinext", "@vercel/og"])], + exclude: [ + ...new Set([ + ...incomingExclude, + "vinext", + "@vercel/og", + ...(hasCloudflarePlugin || hasNitroPlugin + ? [] + : ["react", "react/jsx-runtime", "react/jsx-dev-runtime"]), + ]), + ], entries: optimizeEntries, }, build: { 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 +1800,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 +1819,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 +1846,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 +1863,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 +1967,149 @@ 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; - } - if ( - cleanId.endsWith("/" + VIRTUAL_APP_SSR_ENTRY) || - cleanId.endsWith("\\" + VIRTUAL_APP_SSR_ENTRY) - ) { - return RESOLVED_APP_SSR_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_BROWSER_ENTRY) || - cleanId.endsWith("\\" + VIRTUAL_APP_BROWSER_ENTRY) - ) { - return RESOLVED_APP_BROWSER_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 }); + } + + 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 +2150,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, ); @@ -2310,7 +2803,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Skip requests for files with extensions (static assets) let pathname = url.split("?")[0]; - if (pathname.includes(".") && !pathname.endsWith(".html")) { + const isPagesDataRequest = + pathname.startsWith("/_next/data/") || + (nextConfig?.basePath + ? pathname.startsWith(`${nextConfig.basePath}/_next/data/`) + : false); + if (pathname.includes(".") && !pathname.endsWith(".html") && !isPagesDataRequest) { return next(); } @@ -2348,7 +2846,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 +2863,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 +2972,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 +3273,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 +3626,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 +3690,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 +4195,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..8c111ae03 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,9 @@ 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(); +const VINEXT_APP_ROUTER_STATE_HISTORY_STATE_KEY = "__vinext_appRouterStateKey"; +const MAX_HISTORY_ROUTER_STATE_SNAPSHOTS = 100; // These are plain module-level variables, unlike ClientNavigationState in // navigation.ts which uses Symbol.for to survive multiple Vite module instances. @@ -140,6 +146,8 @@ let browserRouterStateRef: { current: AppRouterState } | null = null; let activePendingBrowserRouterState: PendingBrowserRouterState | null = null; let latestClientParams: Record = {}; const visitedResponseCache = new Map(); +const historyRouterStateSnapshots = new Map(); +let nextHistoryRouterStateSnapshotId = 0; function isServerActionResult(value: unknown): value is ServerActionResult { return !!value && typeof value === "object" && "root" in value; @@ -242,6 +250,62 @@ function clearClientNavigationCaches(): void { clearPrefetchState(); } +function evictHistoryRouterStateSnapshotsIfNeeded(): void { + while (historyRouterStateSnapshots.size >= MAX_HISTORY_ROUTER_STATE_SNAPSHOTS) { + const oldest = historyRouterStateSnapshots.keys().next().value; + if (oldest === undefined) { + return; + } + historyRouterStateSnapshots.delete(oldest); + } +} + +function cloneHistoryStateRecord(state: unknown): Record { + if (!state || typeof state !== "object") { + return {}; + } + + const nextState: Record = {}; + for (const [key, value] of Object.entries(state)) { + nextState[key] = value; + } + return nextState; +} + +function storeHistoryRouterStateSnapshot(state: AppRouterState): string { + evictHistoryRouterStateSnapshotsIfNeeded(); + const key = String(++nextHistoryRouterStateSnapshotId); + historyRouterStateSnapshots.set(key, state); + return key; +} + +function persistHistoryRouterStateSnapshot(state: AppRouterState): void { + const key = storeHistoryRouterStateSnapshot(state); + const historyState = cloneHistoryStateRecord( + createHistoryStateWithPreviousNextUrl(window.history.state, state.previousNextUrl), + ); + historyState[VINEXT_APP_ROUTER_STATE_HISTORY_STATE_KEY] = key; + replaceHistoryStateWithoutNotify(historyState, "", window.location.href); +} + +function readHistoryRouterStateSnapshot(state: unknown): AppRouterState | null { + const key = cloneHistoryStateRecord(state)[VINEXT_APP_ROUTER_STATE_HISTORY_STATE_KEY]; + if (typeof key !== "string") { + return null; + } + return historyRouterStateSnapshots.get(key) ?? null; +} + +function restoreHistoryRouterStateSnapshot(state: AppRouterState): void { + const navId = ++activeNavigationId; + settlePendingBrowserRouterState(activePendingBrowserRouterState); + setPendingPathname(window.location.pathname, navId); + applyClientParams(state.navigationSnapshot.params); + commitClientNavigationState(navId); + getBrowserRouterStateSetter()(state); + window.__VINEXT_RSC_PENDING__ = null; +} + function queuePrePaintNavigationEffect(renderId: number, effect: (() => void) | null): void { if (!effect) { return; @@ -436,13 +500,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 @@ -588,15 +668,6 @@ function BrowserRoot({ useLayoutEffect(() => { setBrowserRouterState = setTreeStateValue; browserRouterStateRef = stateRef; - return () => { - if (setBrowserRouterState === setTreeStateValue) { - setBrowserRouterState = null; - } - if (browserRouterStateRef === stateRef) { - browserRouterStateRef = null; - } - setMountedSlotsHeader(null); - }; }, [setTreeStateValue]); useLayoutEffect(() => { @@ -604,16 +675,8 @@ function BrowserRoot({ }, [treeState.elements]); useLayoutEffect(() => { - if (treeState.renderId !== 0) { - return; - } - - replaceHistoryStateWithoutNotify( - createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl), - "", - window.location.href, - ); - }, [treeState.previousNextUrl, treeState.renderId]); + persistHistoryRouterStateSnapshot(treeState); + }, [treeState]); const committedTree = createElement( NavigationCommitSignal, @@ -997,16 +1060,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 +1131,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 +1180,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 +1211,8 @@ function bootstrapHydration(rscStream: ReadableStream): void { const url = new URL(currentHref, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); + const rscFetchUrl = rscUrl; + let renderedLoadingPayload = false; const requestState = getRequestState(navigationKind, currentPrevNextUrl); const requestInterceptionContext = requestState.interceptionContext; const requestPreviousNextUrl = requestState.previousNextUrl; @@ -1184,6 +1291,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 +1363,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 +1421,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 +1469,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 +1533,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, @@ -1391,6 +1590,14 @@ function bootstrapHydration(rscStream: ReadableStream): void { // microtask-based deferral for compatibility with non-RSC navigation. // See: https://github.com/vercel/next.js/discussions/41934#discussioncomment-4602607 window.addEventListener("popstate", (event) => { + const snapshot = readHistoryRouterStateSnapshot(event.state); + if (snapshot) { + notifyAppRouterTransitionStart(window.location.href, "traverse"); + restoreHistoryRouterStateSnapshot(snapshot); + restorePopstateScrollPosition(event.state); + return; + } + notifyAppRouterTransitionStart(window.location.href, "traverse"); const pendingNavigation = window.__VINEXT_RSC_NAVIGATE__?.(window.location.href, 0, "traverse") ?? Promise.resolve(); 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..afdb92256 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. @@ -72,6 +73,50 @@ async function renderIsrPassToStringAsync(element: React.ReactElement): Promise< /** Body placeholder used to split the document shell for streaming. */ const STREAM_BODY_MARKER = ""; +type PagesDevDataRequestInfo = { + pageUrl: string; +}; + +function parsePagesDevDataRequest( + url: string, + basePath: string, + buildId: string | undefined, + i18nConfig?: Pick | null, +): PagesDevDataRequestInfo | null { + const parsed = new URL(url, "http://vinext.local"); + let pathname = parsed.pathname; + if (basePath && (pathname === basePath || pathname.startsWith(`${basePath}/`))) { + pathname = pathname.slice(basePath.length) || "/"; + } + + 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 { + pageUrl: `${localePrefix}${normalizedPath}${parsed.search}`, + }; +} + /** * Stream a Pages Router page response using progressive SSR. * @@ -131,7 +176,12 @@ async function streamPageToResponse( if (headHTML || fontHeadHTML) { docHtml = docHtml.replace("", ` ${fontHeadHTML}${headHTML}\n`); } - // Inject scripts: replace placeholder or append before + // 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`); @@ -277,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; @@ -289,7 +350,7 @@ export function createSSRHandler( : undefined; logRequest({ method: req.method ?? "GET", - url, + url: originalUrl, status: res.statusCode, totalMs, compileMs, @@ -311,6 +372,7 @@ export function createSSRHandler( req.headers.host, basePath, trailingSlash, + { skipLocaleRedirect: isDataRequest }, ); locale = resolved.locale; localeStrippedUrl = resolved.url; @@ -901,7 +963,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, @@ -932,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 = { @@ -1143,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/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..abeb5ebc9 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -1,12 +1,62 @@ import React, { type ComponentType, type ReactNode } from "react"; +import { minifyStyledJsxCss } from "../plugins/styled-jsx.js"; +import { _runWithCacheState } from "../shims/cache.js"; +import { runWithPrivateCache } from "../shims/cache-runtime.js"; +import { runWithFetchCache } from "../shims/fetch-cache.js"; +import { renderHeadNodesToHTML } from "../shims/head.js"; +import { runWithServerInsertedHTMLState } from "../shims/navigation-state.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; +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; + +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 +73,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 +105,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 +122,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 +143,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 +164,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 +242,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 ( + `` + + `window.__NEXT_DATA__ = ${nextDataJson}${localeGlobals}` ); } @@ -133,29 +302,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("", ` ${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\n" + - ' \n' + - ' \n' + + ' \n' + + ' \n' + ` ${fontHeadHTML}${options.ssrHeadHTML}\n` + ` ${options.assetTags}\n` + "\n\n" + @@ -171,10 +366,19 @@ async function buildPagesCompositeStream( shellSuffix: string, ): Promise> { const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const styleNormalizer = createPagesInlineStyleStreamNormalizer(); return new ReadableStream({ async start(controller) { - controller.enqueue(encoder.encode(shellPrefix)); + const enqueueText = (text: string, flush = false) => { + const normalized = styleNormalizer(text, flush); + if (normalized) { + controller.enqueue(encoder.encode(normalized)); + } + }; + + let pendingPrefix = shellPrefix; const reader = bodyStream.getReader(); try { for (;;) { @@ -182,12 +386,14 @@ async function buildPagesCompositeStream( if (chunk.done) { break; } - controller.enqueue(chunk.value); + enqueueText(pendingPrefix + decoder.decode(chunk.value, { stream: true })); + pendingPrefix = ""; } } finally { reader.releaseLock(); } - controller.enqueue(encoder.encode(shellSuffix)); + enqueueText(pendingPrefix + decoder.decode()); + enqueueText(shellSuffix, true); controller.close(); }, }); @@ -220,14 +426,132 @@ 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)}"`; +} + +function runWithFreshPagesRenderState(fn: () => Promise): Promise { + return runWithServerInsertedHTMLState(() => + _runWithCacheState(() => runWithPrivateCache(() => runWithFetchCache(fn))), + ); +} + +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 `${minifyStyledJsxCss(css)}`; + }); +} + +function createPagesInlineStyleStreamNormalizer(): (text: string, flush?: boolean) => string { + let sawBody = false; + let pending = ""; + + return (text, flush = false) => { + pending += text; + let output = ""; + + if (!sawBody) { + const bodyMatch = /]*>/i.exec(pending); + if (!bodyMatch) { + if (flush) { + output = pending; + pending = ""; + } else if (pending.length > 4096) { + output = pending.slice(0, -512); + pending = pending.slice(-512); + } + return output; + } + + const bodyEnd = bodyMatch.index + bodyMatch[0].length; + output += pending.slice(0, bodyEnd); + pending = pending.slice(bodyEnd); + sawBody = true; + } + + for (;;) { + const lowerPending = pending.toLowerCase(); + const styleStart = lowerPending.indexOf(" 0) { + output += pending.slice(0, emitLength); + pending = pending.slice(emitLength); + } + break; + } + + if (styleStart > 0) { + output += pending.slice(0, styleStart); + pending = pending.slice(styleStart); + continue; + } + + const styleEnd = lowerPending.indexOf(""); + if (styleEnd === -1) { + if (flush) { + output += pending; + pending = ""; + } + break; + } + + const completeStyleEnd = styleEnd + "".length; + output += normalizeInlineStyleTagsFragment(pending.slice(0, completeStyleEnd)); + pending = pending.slice(completeStyleEnd); + } + + return output; + }; +} + 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 runWithFreshPagesRenderState(() => + options.renderHeadPrepassToStringAsync!(createPageElement()), + ); + } + const pageHeadHTML = options.getSSRHeadHTML?.() ?? ""; + const documentHeadHTML = getDocumentHeadHTML(options.documentProps); options.resetSSRHead?.(); await options.flushPreloads?.(); @@ -236,22 +560,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(); @@ -259,8 +614,9 @@ export async function renderPagesPageResponse( const markerIndex = shellHtml.indexOf(bodyMarker); 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); + const bodyStream = await runWithFreshPagesRenderState(() => + options.renderToReadableStream(pageElement), + ); if ( // Keep nonce-bearing pages out of ISR writes: rewritePagesCachedHtml() @@ -272,10 +628,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 +652,41 @@ 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) { + await bodyStream.allReady; + 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..212138dc4 --- /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"].join("/"); + 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( `
's \`encType\` was set to an unsupported value via \`formEncType="${formEncType}"\`. ` + @@ -108,7 +167,11 @@ function hasUnsupportedSubmitterAttributes(submitter: FormSubmitter): boolean { return true; } - const formMethod = submitter.getAttribute("formmethod"); + const formMethod = + getSubmitterAttribute(submitter, "formmethod", "formMethod") ?? + (submitter.formMethod && submitter.formMethod.toUpperCase() !== SUPPORTED_FORM_METHOD + ? submitter.formMethod + : null); if (formMethod !== null && formMethod.toUpperCase() !== SUPPORTED_FORM_METHOD) { console.error( `'s \`method\` was set to an unsupported value via \`formMethod="${formMethod}"\`. ` + @@ -117,7 +180,11 @@ function hasUnsupportedSubmitterAttributes(submitter: FormSubmitter): boolean { return true; } - const formTarget = submitter.getAttribute("formtarget"); + const formTarget = + getSubmitterAttribute(submitter, "formtarget", "formTarget") ?? + (submitter.formTarget && submitter.formTarget !== SUPPORTED_FORM_TARGET + ? submitter.formTarget + : null); if (formTarget !== null && formTarget !== SUPPORTED_FORM_TARGET) { console.error( `'s \`target\` was set to an unsupported value via \`formTarget="${formTarget}"\`. ` + @@ -129,6 +196,22 @@ function hasUnsupportedSubmitterAttributes(submitter: FormSubmitter): boolean { return false; } +function hasUnsupportedSubmitterFallback(form: HTMLFormElement): boolean { + if (typeof form.querySelectorAll !== "function") return false; + + for (const element of form.querySelectorAll("button,input")) { + if (isFormSubmitter(element) && hasUnsupportedSubmitterAttributes(element)) { + return true; + } + } + return false; +} + +function hasReactClientActionAttributes(submitter: FormSubmitter): boolean { + const action = submitter.getAttribute("formaction") ?? submitter.getAttribute("formAction"); + return action !== null && /^\s*javascript:/i.test(action); +} + function createFormSubmitDestinationUrl( action: string, form: HTMLFormElement, @@ -144,6 +227,15 @@ function createFormSubmitDestinationUrl( targetUrl.searchParams.append(name, typeof value === "string" ? value : value.name); } + // When a basePath-prefixed action/formAction resolves to a browser URL, + // hand the absolute URL to navigateClientSide so it can normalize back to + // an app-relative route before re-applying basePath. Returning "/base/..." + // here would be treated as app-relative and become "/base/base/...". + const basePath = getBasePath(); + if (basePath && hasBasePath(targetUrl.pathname, basePath)) { + return targetUrl.href; + } + return toSameOriginPath(targetUrl.href) ?? targetUrl.href; } @@ -164,6 +256,8 @@ function buildFormData(form: HTMLFormElement, submitter: FormSubmitter | null): type FormProps = { /** Target URL for GET forms, or server action for POST forms */ action: string | ((formData: FormData) => void | Promise); + /** Disable automatic loading-state prefetch behavior */ + prefetch?: false | null; /** Replace instead of push in history (default: false) */ replace?: boolean; /** Scroll to top after navigation (default: true) */ @@ -171,11 +265,50 @@ type FormProps = { } & FormHTMLAttributes; const Form = forwardRef(function Form(props: FormProps, ref: ForwardedRef) { - const { action, replace = false, scroll = true, onSubmit, ...rest } = props; + const { + action, + prefetch = null, + replace = false, + scroll = true, + onSubmit, + onClickCapture, + ...rest + } = props; + let capturedSubmitter: FormSubmitter | null = null; + let didPrefetchLoading = false; + + function setFormRef(element: HTMLFormElement | null): void { + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + + if ( + element && + !didPrefetchLoading && + typeof action === "string" && + prefetch !== false && + isSafeAction(action) && + typeof window !== "undefined" && + typeof window.__VINEXT_RSC_PREFETCH_LOADING__ === "function" + ) { + didPrefetchLoading = true; + void window.__VINEXT_RSC_PREFETCH_LOADING__(action); + } + } // If action is a function (server action), pass it directly to React if (typeof action === "function") { - return ; + return ( + + ); } // Block dangerous action URLs. Render without action attribute @@ -188,9 +321,11 @@ const Form = forwardRef(function Form(props: FormProps, ref: ForwardedRef blocked unsafe action: ${action}`); } - return ; + return ; } + const actionHref = addBasePathToFormAction(action); + async function handleSubmit(e: React.SubmitEvent) { // Call user's onSubmit first if (onSubmit) { @@ -198,16 +333,22 @@ const Form = forwardRef(function Form(props: FormProps, ref: ForwardedRef; + function handleClickCapture(e: React.MouseEvent) { + const target = e.target; + if (isFormSubmitter(target) && e.currentTarget.contains(target)) { + capturedSubmitter = target; + } + onClickCapture?.(e); + } + + return ( + + ); }); export default Form; diff --git a/packages/vinext/src/shims/head.ts b/packages/vinext/src/shims/head.ts index 0c9d4492d..5d898957a 100644 --- a/packages/vinext/src/shims/head.ts +++ b/packages/vinext/src/shims/head.ts @@ -44,7 +44,12 @@ export function resetSSRHead(): void { /** Get collected head HTML. Call after render. */ export function getSSRHeadHTML(): string { - return reduceHeadChildren(_getSSRHeadChildren()) + return renderHeadNodesToHTML(_getSSRHeadChildren()); +} + +/** Serialize React head nodes using the same reduction rules as next/head. */ +export function renderHeadNodesToHTML(headChildren: React.ReactNode[]): string { + return reduceHeadChildren(headChildren) .map((child) => headChildToHTML(child.type as string, child.props as Record)) .filter(Boolean) .join("\n "); @@ -237,7 +242,7 @@ function headChildToHTML(tag: string, props: Record): string { const attrStr = attrs.length ? " " + attrs.join(" ") : ""; if (SELF_CLOSING_HEAD_TAGS.has(tag)) { - return `<${tag}${attrStr} data-vinext-head="true" />`; + return `<${tag}${attrStr} data-next-head="" />`; } // For raw-content tags (script, style), escape closing-tag sequences so the @@ -246,7 +251,7 @@ function headChildToHTML(tag: string, props: Record): string { innerHTML = escapeInlineContent(innerHTML, tag); } - return `<${tag}${attrStr} data-vinext-head="true">${innerHTML}`; + return `<${tag}${attrStr} data-next-head="">${innerHTML}`; } function escapeHTML(s: string): string { @@ -280,7 +285,18 @@ export function escapeInlineContent(content: string, tag: string): string { } function syncClientHead(): void { - document.querySelectorAll("[data-vinext-head]").forEach((el) => el.remove()); + document.querySelectorAll("[data-vinext-head], [data-next-head]").forEach((el) => { + // Preserve the document defaults from next/document; page-level head tags + // are re-projected from the currently mounted instances below. + if (el.matches("meta[charset], meta[name='viewport']")) return; + el.remove(); + }); + + const defaultHeadTags = Array.from( + document.head.querySelectorAll("meta[charset], meta[name='viewport']"), + ); + const lastDefaultHeadTag = defaultHeadTags[defaultHeadTags.length - 1]; + const insertionAnchor = lastDefaultHeadTag?.nextSibling ?? document.head.firstChild; for (const child of reduceHeadChildren([..._clientHeadChildren.values()])) { if (typeof child.type !== "string") continue; @@ -304,8 +320,8 @@ function syncClientHead(): void { } } - domEl.setAttribute("data-vinext-head", "true"); - document.head.appendChild(domEl); + domEl.setAttribute("data-next-head", ""); + document.head.insertBefore(domEl, insertionAnchor); } } diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 50c03cf36..fb79dad67 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -30,12 +30,15 @@ import { } from "./navigation.js"; import { createAppPayloadCacheKey } from "../server/app-elements.js"; import { isDangerousScheme } from "./url-safety.js"; +import Router from "./router.js"; +import { RouterContext } from "./internal/router-context.js"; import { - resolveRelativeHref, + normalizeLocalTrailingSlashHref, toBrowserNavigationHref, toSameOriginAppPath, withBasePath, } from "./url-utils.js"; +import { stripBasePath } from "../utils/base-path.js"; import { appendSearchParamsToUrl, type UrlQuery, urlQueryToSearchParams } from "../utils/query.js"; import { addLocalePrefix, getDomainLocaleUrl, type DomainLocale } from "../utils/domain-locale.js"; import { getI18nContext } from "./i18n-context.js"; @@ -59,8 +62,12 @@ type LinkProps = { prefetch?: boolean; /** Whether to pass the href to the child element */ passHref?: boolean; + /** Preserve the pre-Next 13 behavior where Link clones/wraps its child. */ + legacyBehavior?: boolean; /** Scroll to top on navigation (default: true) */ scroll?: boolean; + /** Update URL/query without refetching Pages Router data */ + shallow?: boolean; /** Locale for i18n (used for locale-prefixed URLs) */ locale?: string | false; /** Called before navigation happens (Next.js 16). Return value is ignored. */ @@ -77,6 +84,92 @@ type LinkStatusContextValue = { }; const LinkStatusContext = createContext({ pending: false }); +const LINK_STATUS_NAVIGATION_EVENT = "vinext:link-status-navigation"; +const _LINK_STATUS_STATE_KEY = Symbol.for("vinext.linkStatusState"); + +type LinkStatusListener = () => void; +type LinkStatusState = { + activeLinkId: number | null; + nextLinkId: number; + listeners: Set; + historyPatched: boolean; + originalPushState: History["pushState"] | null; + originalReplaceState: History["replaceState"] | null; +}; + +type LinkStatusGlobal = typeof globalThis & { + [_LINK_STATUS_STATE_KEY]?: LinkStatusState; +}; + +function getLinkStatusState(): LinkStatusState { + const globalState = globalThis as LinkStatusGlobal; + globalState[_LINK_STATUS_STATE_KEY] ??= { + activeLinkId: null, + nextLinkId: 1, + listeners: new Set(), + historyPatched: false, + originalPushState: null, + originalReplaceState: null, + }; + return globalState[_LINK_STATUS_STATE_KEY]!; +} + +function notifyLinkStatusListeners(): void { + const state = getLinkStatusState(); + for (const listener of state.listeners) listener(); +} + +function beginLinkPending(linkId: number): void { + const state = getLinkStatusState(); + state.activeLinkId = linkId; + notifyLinkStatusListeners(); +} + +function clearLinkPending(linkId?: number): void { + const state = getLinkStatusState(); + if (linkId !== undefined && state.activeLinkId !== linkId) return; + if (state.activeLinkId === null) return; + state.activeLinkId = null; + notifyLinkStatusListeners(); +} + +function subscribeLinkStatus(listener: LinkStatusListener): () => void { + const state = getLinkStatusState(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +function installLinkStatusNavigationListeners(): void { + if (typeof window === "undefined") return; + const state = getLinkStatusState(); + if (state.historyPatched) return; + state.historyPatched = true; + state.originalPushState = window.history.pushState.bind(window.history); + state.originalReplaceState = window.history.replaceState.bind(window.history); + + window.addEventListener(LINK_STATUS_NAVIGATION_EVENT, () => clearLinkPending()); + window.addEventListener("popstate", () => clearLinkPending()); + + window.history.pushState = function patchedLinkStatusPushState( + data: unknown, + unused: string, + url?: string | URL | null, + ): void { + state.originalPushState!.call(window.history, data, unused, url); + clearLinkPending(); + }; + + window.history.replaceState = function patchedLinkStatusReplaceState( + data: unknown, + unused: string, + url?: string | URL | null, + ): void { + state.originalReplaceState!.call(window.history, data, unused, url); + clearLinkPending(); + }; +} /** * useLinkStatus returns the pending state of the enclosing . @@ -89,10 +182,34 @@ export function useLinkStatus(): LinkStatusContextValue { /** basePath from next.config.js, injected by the plugin at build time */ const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? ""; +const __trailingSlash = process.env.__NEXT_ROUTER_TRAILING_SLASH === "true"; + +function pathnameFromAsPath(asPath: string | undefined): string { + if (!asPath) return "/"; + const pathname = asPath.split(/[?#]/, 1)[0]; + return pathname || "/"; +} -function resolveHref(href: LinkProps["href"]): string { +function getCurrentLinkPathname(routerContext: { asPath?: string } | null): string { + if (routerContext?.asPath) return pathnameFromAsPath(routerContext.asPath); + if (typeof window !== "undefined") { + if (typeof window.location.pathname === "string") { + return stripBasePath(window.location.pathname, __basePath); + } + if (typeof window.location.href === "string") { + try { + return stripBasePath(new URL(window.location.href).pathname, __basePath); + } catch { + return "/"; + } + } + } + return "/"; +} + +function resolveHref(href: LinkProps["href"], currentPathname = "/"): string { if (typeof href === "string") return href; - let url = href.pathname ?? "/"; + let url = href.pathname ?? currentPathname; if (href.query) { const params = urlQueryToSearchParams(href.query); url = appendSearchParamsToUrl(url, params); @@ -100,6 +217,141 @@ function resolveHref(href: LinkProps["href"]): string { return url; } +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("//"); +} + +function warnInvalidNavigationHref(href: string, page: string): void { + console.error( + `Invalid href '${href}' passed to next/router in page: '${page}'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.`, + ); +} + +function getPagesDataPathParts(pathname: string): { localePrefix: string; pagePathname: string } { + const locales = window.__VINEXT_LOCALES__ ?? []; + const defaultLocale = window.__VINEXT_DEFAULT_LOCALE__; + const targetLocale = pathname.split("/").filter(Boolean)[0]; + if (targetLocale && locales.includes(targetLocale)) { + const pagePathname = pathname.slice(targetLocale.length + 1) || "/"; + return { localePrefix: `/${targetLocale}`, pagePathname }; + } + + if (defaultLocale && locales.includes(defaultLocale)) { + return { localePrefix: `/${defaultLocale}`, pagePathname: pathname }; + } + + // Non-i18n prefetch data URLs do not carry a locale prefix. + return { localePrefix: "", pagePathname: pathname }; +} + +function buildPagesPrefetchDataUrl(href: string): string | null { + if (typeof window === "undefined") return null; + + const buildId = window.__NEXT_DATA__?.buildId ?? process.env.__VINEXT_BUILD_ID; + if (!buildId) return null; + + const url = new URL(href, window.location.href); + let pathname = url.pathname; + + for (const [key, value] of url.searchParams) { + const encoded = encodeURIComponent(value); + const before = pathname; + pathname = pathname + .replace(new RegExp(`\\[\\.\\.\\.${key}\\]`, "g"), encoded) + .replace(new RegExp(`\\[${key}\\]`, "g"), encoded); + if (pathname !== before) { + url.searchParams.delete(key); + } + } + + if (pathname.includes("[")) { + return null; + } + + const dataPathParts = getPagesDataPathParts(pathname); + const pagePathname = + dataPathParts.pagePathname === "/" + ? dataPathParts.localePrefix + ? "" + : "/index" + : dataPathParts.pagePathname.replace(/\/$/, ""); + const query = url.searchParams.toString(); + return `${window.location.origin}${__basePath}/_next/data/${buildId}${dataPathParts.localePrefix}${pagePathname}.json${query ? `?${query}` : ""}`; +} + +function pagesRoutePatternToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); + const regex = escaped + .replace(/\\\[\\\[\\.\\.\\.[^\]]+\\\]\\\]/g, "(?:/.*)?") + .replace(/\\\[\\.\\.\\.[^\]]+\\\]/g, "/.+") + .replace(/\\\[[^\]]+\\\]/g, "[^/]+"); + return new RegExp(`^${regex === "/" ? "/" : regex.replace(/\/$/, "")}/?$`); +} + +function isPagesSsgPrefetchTarget(href: string): boolean { + if (typeof window === "undefined") return false; + const ssgRoutes = window.__VINEXT_PAGES_SSG_ROUTES__; + if (!ssgRoutes || ssgRoutes.size === 0) return false; + + const url = new URL(href, window.location.href); + const { pagePathname } = getPagesDataPathParts(url.pathname); + const pathname = pagePathname === "" ? "/" : pagePathname; + + for (const route of ssgRoutes) { + if (route === pathname) return true; + if (route.includes("[") && pagesRoutePatternToRegex(route).test(pathname)) { + return true; + } + } + return false; +} + +function hasDynamicRouteSegment(href: string): boolean { + try { + return new URL(href, window.location.href).pathname.includes("["); + } catch { + return href.split(/[?#]/, 1)[0]?.includes("[") === true; + } +} + +function isPagesDynamicSsgPrefetchTarget(href: string): boolean { + if (typeof window === "undefined") return false; + const ssgRoutes = window.__VINEXT_PAGES_SSG_ROUTES__; + if (!ssgRoutes || ssgRoutes.size === 0) return false; + + const url = new URL(href, window.location.href); + const { pagePathname } = getPagesDataPathParts(url.pathname); + const pathname = pagePathname === "" ? "/" : pagePathname; + + for (const route of ssgRoutes) { + if (route.includes("[") && pagesRoutePatternToRegex(route).test(pathname)) { + return true; + } + } + return false; +} + +function withVinextPrefetchMarker(href: string): string { + try { + const url = new URL(href, window.location.href); + url.searchParams.set("__vinext_prefetch", "1"); + return url.pathname + url.search + url.hash; + } catch { + return href.includes("?") ? `${href}&__vinext_prefetch=1` : `${href}?__vinext_prefetch=1`; + } +} + // --------------------------------------------------------------------------- // Prefetching infrastructure // --------------------------------------------------------------------------- @@ -114,7 +366,7 @@ function resolveHref(href: LinkProps["href"]): string { * Uses `requestIdleCallback` (or `setTimeout` fallback) to avoid blocking * the main thread during initial page load. */ -function prefetchUrl(href: string): void { +function prefetchUrl(href: string, dataHref = href, options: { immediate?: boolean } = {}): void { if (typeof window === "undefined") return; // Normalize same-origin absolute URLs to local paths before prefetching @@ -127,19 +379,21 @@ function prefetchUrl(href: string): void { const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath); - // Distinguish the same visible URL when it is prefetched from different - // interception sources such as /feed vs /gallery. - const rscUrl = toRscUrl(fullHref); - const interceptionContext = getCurrentInterceptionContext(); - const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); - const prefetched = getPrefetchedUrls(); - if (prefetched.has(cacheKey)) return; - prefetched.add(cacheKey); - - const schedule = window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100)); + const schedule = options.immediate + ? (fn: () => void) => fn() + : (window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100))); schedule(() => { if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { + // Distinguish the same visible URL when it is prefetched from different + // interception sources such as /feed vs /gallery. + const rscUrl = toRscUrl(fullHref); + const interceptionContext = getCurrentInterceptionContext(); + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); + const prefetched = getPrefetchedUrls(); + if (prefetched.has(cacheKey)) return; + prefetched.add(cacheKey); + const mountedSlotsHeader = getMountedSlotsHeader(); const headers = new Headers({ Accept: "text/x-component" }); if (mountedSlotsHeader) { @@ -160,16 +414,57 @@ function prefetchUrl(href: string): void { interceptionContext, mountedSlotsHeader, ); - } else if ((window.__NEXT_DATA__ as VinextNextData | undefined)?.__vinext?.pageModuleUrl) { - // Pages Router: inject a prefetch link for the target page module - // We can't easily resolve the target page's module URL from the Link, - // so we create a for the HTML page which helps - // the browser's preload scanner. - const link = document.createElement("link"); - link.rel = "prefetch"; - link.href = fullHref; - link.as = "document"; - document.head.appendChild(link); + } else { + // Pages Router data can be request-specific (notably getServerSideProps), + // so only prefetch JSON for routes we know are getStaticProps pages. + const dataUrl = buildPagesPrefetchDataUrl(dataHref); + if (!dataUrl) return; + if (!isPagesSsgPrefetchTarget(dataHref)) return; + + if (options.immediate) { + const prefetchMarkerHref = withVinextPrefetchMarker(fullHref); + void fetch(prefetchMarkerHref, { + headers: { purpose: "prefetch" }, + credentials: "include", + cache: "no-store", + priority: "low" as const, + // @ts-expect-error — purpose is a valid fetch option in some browsers + purpose: "prefetch", + }).catch(() => { + // Best-effort parity signal; navigation still fetches authoritative data. + }); + return; + } + + if (isPagesDynamicSsgPrefetchTarget(dataHref) && !hasDynamicRouteSegment(dataHref)) return; + + window.next ??= {}; + window.next.router ??= { sdc: {} } as NonNullable["router"]; + const router = window.next.router!; + router.sdc ??= {}; + if (!options.immediate && router.sdc[dataUrl]) return; + + fetch(dataUrl, { + headers: { Purpose: "prefetch" }, + credentials: "include", + cache: "no-store", + priority: "low" as const, + // @ts-expect-error — purpose is a valid fetch option in some browsers + purpose: "prefetch", + }) + .then(async (response) => { + if (!response.ok) return; + if (response.headers.get("x-middleware-cache")?.toLowerCase() === "no-cache") return; + const contentType = response.headers.get("Content-Type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) return; + + const nextData = (await response.json()) as VinextNextData; + if (nextData.gsp !== true) return; + window.next!.router!.sdc[dataUrl] = nextData; + }) + .catch(() => { + // Prefetch failures are non-fatal and should not affect navigation. + }); } }); } @@ -216,6 +511,20 @@ function getDefaultLocale(): string | undefined { return getI18nContext()?.defaultLocale; } +function getCurrentLocale(): string | undefined { + if (typeof window !== "undefined") { + return window.__VINEXT_LOCALE__; + } + return getI18nContext()?.locale; +} + +function getConfiguredLocales(): readonly string[] | undefined { + if (typeof window !== "undefined") { + return window.__VINEXT_LOCALES__; + } + return getI18nContext()?.locales; +} + function getDomainLocales(): readonly DomainLocale[] | undefined { if (typeof window !== "undefined") { return (window.__NEXT_DATA__ as VinextNextData | undefined)?.domainLocales; @@ -238,35 +547,48 @@ function getDomainLocaleHref(href: string, locale: string): string | undefined { }); } +function hasLocalePrefix(pathname: string): boolean { + const locales = getConfiguredLocales(); + if (!locales?.length) return false; + + const firstSegment = pathname.split("/").filter(Boolean)[0]; + return firstSegment !== undefined && locales.includes(firstSegment); +} + /** * Apply locale prefix to a URL path based on the locale prop. * - locale="fr" → prepend /fr (unless it already has a locale prefix) * - locale={false} → use the href as-is (no locale prefix, link to default) - * - locale=undefined → use current locale (href as-is in most cases) + * - locale=undefined → use the active i18n locale when the current URL is locale-prefixed */ -function applyLocaleToHref(href: string, locale: string | false | undefined): string { +function applyLocaleToHref( + href: string, + locale: string | false | undefined, + options: { useImplicitActiveLocale?: boolean } = {}, +): string { if (locale === false) { // Explicit false: no locale prefix return href; } - if (locale === undefined) { - // No locale prop: keep current behavior (href as-is) - return href; - } - // Absolute and protocol-relative URLs must not be prefixed — locale // only applies to local paths. if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { return href; } - const domainLocaleHref = getDomainLocaleHref(href, locale); + const effectiveLocale = + locale ?? (options.useImplicitActiveLocale === false ? getDefaultLocale() : getCurrentLocale()); + if (effectiveLocale === undefined) { + return href; + } + + const domainLocaleHref = getDomainLocaleHref(href, effectiveLocale); if (domainLocaleHref) { return domainLocaleHref; } - return addLocalePrefix(href, locale, getDefaultLocale() ?? ""); + return addLocalePrefix(href, effectiveLocale, getDefaultLocale() ?? ""); } const Link = forwardRef(function Link( @@ -276,42 +598,76 @@ const Link = forwardRef(function Link( replace = false, prefetch: prefetchProp, scroll = true, + shallow = false, children, onClick, + onMouseEnter, onNavigate, ...rest }, forwardedRef, ) { + const routerContext = useContext(RouterContext); + const currentPathname = getCurrentLinkPathname(routerContext); + // Extract locale from rest props const { locale, ...restWithoutLocale } = rest; // If `as` is provided, use it as the actual URL (legacy Next.js pattern // where href is a route pattern like "/user/[id]" and as is "/user/1") - const resolvedHref = as ?? resolveHref(href); + const resolvedHref = as ?? resolveHref(href, currentPathname); + if (hasRepeatedForwardSlashOrBackslash(resolvedHref)) { + warnInvalidNavigationHref(resolvedHref, routerContext?.pathname ?? routerContext?.route ?? ""); + } const isDangerous = typeof resolvedHref === "string" && isDangerousScheme(resolvedHref); // Apply locale prefix if specified (safe even for dangerous hrefs since we // won't use the result when isDangerous is true) - const localizedHref = applyLocaleToHref(isDangerous ? "/" : resolvedHref, locale); + const routeHref = resolveHref(href, currentPathname); + const useImplicitActiveLocale = + locale !== undefined || routerContext == null || hasLocalePrefix(currentPathname); + const localizedRouteHref = normalizeLocalTrailingSlashHref( + applyLocaleToHref(isDangerous ? "/" : routeHref, locale, { useImplicitActiveLocale }), + __trailingSlash, + ); + const localizedHref = normalizeLocalTrailingSlashHref( + applyLocaleToHref(isDangerous ? "/" : resolvedHref, locale, { useImplicitActiveLocale }), + __trailingSlash, + ); // Full href with basePath for browser URLs and fetches const fullHref = withBasePath(localizedHref, __basePath); // Track pending state for useLinkStatus() const [pending, setPending] = useState(false); + const linkStatusIdRef = useRef(null); + if (linkStatusIdRef.current === null) { + linkStatusIdRef.current = getLinkStatusState().nextLinkId++; + } const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; + installLinkStatusNavigationListeners(); + const syncPending = () => { + const state = getLinkStatusState(); + setPending(state.activeLinkId === linkStatusIdRef.current); + }; + const unsubscribe = subscribeLinkStatus(syncPending); + syncPending(); return () => { mountedRef.current = false; + unsubscribe(); }; }, []); + useEffect(() => { + clearLinkPending(linkStatusIdRef.current ?? undefined); + }, [fullHref]); + // Prefetching: observe the element when it enters the viewport. // prefetch={false} disables, prefetch={true} or undefined/null (default) enables. const internalRef = useRef(null); - const shouldPrefetch = prefetchProp !== false && !isDangerous; + const shouldPrefetch = prefetchProp !== false && !isDangerous && !shallow; const setRefs = useCallback( (node: HTMLAnchorElement | null) => { @@ -343,14 +699,14 @@ const Link = forwardRef(function Link( const observer = getSharedObserver(); if (!observer) return; - observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch)); + observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch, resolveHref(href))); observer.observe(node); return () => { observer.unobserve(node); observerCallbacks.delete(node); }; - }, [shouldPrefetch, localizedHref]); + }, [shouldPrefetch, localizedHref, href]); const handleClick = async (e: MouseEvent) => { if (onClick) onClick(e); @@ -366,30 +722,60 @@ const Link = forwardRef(function Link( return; } + // Download links keep native browser behavior and do not fire onNavigate. + if (e.currentTarget.hasAttribute("download")) { + return; + } + // External links: let the browser handle it. // Same-origin absolute URLs (e.g. http://localhost:3000/about) are // normalized to local paths so they get client-side navigation. let navigateHref = localizedHref; + let navigateRouteHref = localizedRouteHref; if ( resolvedHref.startsWith("http://") || resolvedHref.startsWith("https://") || resolvedHref.startsWith("//") ) { const localPath = toSameOriginAppPath(resolvedHref, __basePath); - if (localPath == null) return; // truly external + if (localPath == null) { + if (replace) { + e.preventDefault(); + window.location.replace(resolvedHref); + } + return; // truly external + } navigateHref = localPath; } + if ( + routeHref.startsWith("http://") || + routeHref.startsWith("https://") || + routeHref.startsWith("//") + ) { + const localPath = toSameOriginAppPath(routeHref, __basePath); + if (localPath != null) { + navigateRouteHref = normalizeLocalTrailingSlashHref(localPath, __trailingSlash); + } + } e.preventDefault(); - // Resolve relative hrefs (#hash, ?query) against the current URL once so - // onNavigate and the actual navigation target stay in sync. - const absoluteHref = resolveRelativeHref(navigateHref, window.location.href, __basePath); const absoluteFullHref = toBrowserNavigationHref( navigateHref, window.location.href, __basePath, ); + const explicitLocale = + typeof locale === "string" && locale !== "" && locale !== getDefaultLocale(); + if (explicitLocale && localizedHref.startsWith("/") && !localizedHref.startsWith("//")) { + void fetch(absoluteFullHref, { + method: "HEAD", + credentials: "include", + }).catch(() => { + // Redirect probes are best-effort; the actual router navigation below + // remains authoritative. + }); + } // Call onNavigate callback if provided (Next.js 16 View Transitions support) if (onNavigate) { @@ -419,11 +805,11 @@ const Link = forwardRef(function Link( // App Router: delegate to navigateClientSide which handles scroll save, // hash-only changes, RSC fetch, and two-phase URL commit. if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { - setPending(true); + beginLinkPending(linkStatusIdRef.current!); try { await navigateClientSide(navigateHref, replace ? "replace" : "push", scroll); } finally { - if (mountedRef.current) setPending(false); + if (mountedRef.current) clearLinkPending(linkStatusIdRef.current ?? undefined); } } else { // Next.js only consumes onRouterTransitionStart in the App Router. @@ -431,13 +817,11 @@ const Link = forwardRef(function Link( // during startup, but it does not invoke the named export on navigation. // Pages Router: use the Router singleton try { - const routerModule = await import("next/router"); - // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- vinext's Router shim accepts (url, as, options) - const Router = routerModule.default as any; + const asHref = as === undefined ? undefined : navigateHref; if (replace) { - await Router.replace(absoluteHref, undefined, { scroll }); + await Router.replace(navigateRouteHref, asHref, { scroll, shallow }); } else { - await Router.push(absoluteHref, undefined, { scroll }); + await Router.push(navigateRouteHref, asHref, { scroll, shallow }); } } catch { // Fallback to hard navigation if router fails @@ -451,8 +835,14 @@ const Link = forwardRef(function Link( } }; + const handleMouseEnter = (e: MouseEvent) => { + if (onMouseEnter) onMouseEnter(e); + if (!shouldPrefetch || typeof window === "undefined") return; + prefetchUrl(localizedHref, resolveHref(href), { immediate: true }); + }; + // Remove props that shouldn't be on - const { passHref: _p, ...anchorProps } = restWithoutLocale; + const { passHref, legacyBehavior, ...anchorProps } = restWithoutLocale; const linkStatusValue = React.useMemo(() => ({ pending }), [pending]); @@ -467,9 +857,66 @@ const Link = forwardRef(function Link( return {children}; } + if (legacyBehavior) { + if (React.isValidElement(children)) { + const childProps = children.props as AnchorHTMLAttributes; + const legacyProps: AnchorHTMLAttributes & { + ref?: React.Ref; + } = { + onClick(e) { + childProps.onClick?.(e); + if (!e.defaultPrevented) { + handleClick(e); + } + }, + onMouseEnter(e) { + childProps.onMouseEnter?.(e); + handleMouseEnter(e); + }, + }; + + if ( + passHref || + (typeof children.type === "string" && children.type === "a" && childProps.href == null) + ) { + legacyProps.href = fullHref; + } + + if (typeof children.type === "string" && children.type === "a") { + legacyProps.ref = setRefs; + } + + return ( + + {React.cloneElement(children, legacyProps)} + + ); + } + + return ( + + + {children} + + + ); + } + return ( - + {children} diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 7b97e4a0f..c87ed29b4 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -28,6 +28,11 @@ import { assertSafeNavigationUrl } from "./url-safety.js"; // still line up if Vite loads this shim through multiple resolved module IDs. const _LAYOUT_SEGMENT_CTX_KEY = Symbol.for("vinext.layoutSegmentContext"); const _SERVER_INSERTED_HTML_CTX_KEY = Symbol.for("vinext.serverInsertedHTMLContext"); +const LINK_STATUS_NAVIGATION_EVENT = "vinext:link-status-navigation"; + +function notifyLinkStatusNavigationStart(): void { + window.dispatchEvent(new Event(LINK_STATUS_NAVIGATION_EVENT)); +} /** * Map of parallel route key → child segments below the current layout. @@ -1149,7 +1154,12 @@ export async function navigateClientSide( mode: "push" | "replace", scroll: boolean, programmaticTransition = false, + deferredLoadingTransition = false, ): Promise { + if (programmaticTransition) { + notifyLinkStatusNavigationStart(); + } + // Normalize same-origin absolute URLs to local paths for SPA navigation let normalizedHref = href; if (isExternalUrl(href)) { @@ -1211,6 +1221,7 @@ export async function navigateClientSide( mode, undefined, programmaticTransition, + deferredLoadingTransition, ); } else { if (mode === "replace") { @@ -1244,6 +1255,7 @@ const _appRouter = { push(href: string, options?: { scroll?: boolean }): void { assertSafeNavigationUrl(href); if (isServer) return; + notifyLinkStatusNavigationStart(); React.startTransition(() => { void navigateClientSide(href, "push", options?.scroll !== false, true); }); @@ -1251,6 +1263,7 @@ const _appRouter = { replace(href: string, options?: { scroll?: boolean }): void { assertSafeNavigationUrl(href); if (isServer) return; + notifyLinkStatusNavigationStart(); React.startTransition(() => { void navigateClientSide(href, "replace", options?.scroll !== false, true); }); diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index c595c69b3..017646240 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -5,12 +5,25 @@ * Backed by the browser History API. Supports client-side navigation * by fetching new page data and re-rendering the React root. */ -import { useState, useEffect, useCallback, useMemo, createElement, type ReactElement } from "react"; +import { + useState, + useEffect, + useCallback, + useMemo, + useContext, + createElement, + type ComponentType, + type ReactElement, +} from "react"; import { RouterContext } from "./internal/router-context.js"; import type { VinextNextData } from "../client/vinext-next-data.js"; import { isValidModulePath } from "../client/validate-module-path.js"; -import { toBrowserNavigationHref, toSameOriginAppPath } from "./url-utils.js"; -import { stripBasePath } from "../utils/base-path.js"; +import { + normalizeLocalTrailingSlashHref, + toBrowserNavigationHref, + toSameOriginAppPath, +} from "./url-utils.js"; +import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { addLocalePrefix, getDomainLocaleUrl, type DomainLocale } from "../utils/domain-locale.js"; import { addQueryParam, @@ -21,6 +34,8 @@ import { /** basePath from next.config.js, injected by the plugin at build time */ const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? ""; +const __trailingSlash = process.env.__NEXT_ROUTER_TRAILING_SLASH === "true"; +const __scrollRestoration = process.env.__NEXT_SCROLL_RESTORATION === "true"; type BeforePopStateCallback = (state: { url: string; @@ -66,10 +81,33 @@ export type NextRouter = { prefetch(url: string): Promise; /** Register a callback to run before popstate navigation */ beforePopState(cb: BeforePopStateCallback): void; + /** Static data cache used by Next's legacy Pages Router internals. */ + sdc: Record; /** Listen for route changes */ events: RouterEvents; }; +type LegacyRouterEvent = + | "routeChangeStart" + | "routeChangeComplete" + | "routeChangeError" + | "beforeHistoryChange" + | "hashChangeStart" + | "hashChangeComplete"; + +type LegacyRouterEventHandler = (...args: unknown[]) => void; + +type LegacyRouterEventProperty = + | "onRouteChangeStart" + | "onRouteChangeComplete" + | "onRouteChangeError" + | "onBeforeHistoryChange" + | "onHashChangeStart" + | "onHashChangeComplete"; + +export type NextRouterSingleton = NextRouter & + Partial>; + type UrlObject = { pathname?: string; query?: UrlQuery; @@ -87,6 +125,17 @@ type RouterEvents = { emit(event: string, ...args: unknown[]): void; }; +const LEGACY_ROUTER_EVENT_PROPS: Record = { + routeChangeStart: "onRouteChangeStart", + routeChangeComplete: "onRouteChangeComplete", + routeChangeError: "onRouteChangeError", + beforeHistoryChange: "onBeforeHistoryChange", + hashChangeStart: "onHashChangeStart", + hashChangeComplete: "onHashChangeComplete", +}; + +let singletonRouter: NextRouterSingleton | null = null; + function createRouterEvents(): RouterEvents { const listeners = new Map void>>(); @@ -100,6 +149,9 @@ function createRouterEvents(): RouterEvents { }, emit(event: string, ...args: unknown[]) { listeners.get(event)?.forEach((handler) => handler(...args)); + const legacyProp = LEGACY_ROUTER_EVENT_PROPS[event as LegacyRouterEvent]; + const legacyHandler = legacyProp ? singletonRouter?.[legacyProp] : null; + if (typeof legacyHandler === "function") legacyHandler(...args); }, }; } @@ -107,6 +159,15 @@ function createRouterEvents(): RouterEvents { // Singleton events instance const routerEvents = createRouterEvents(); +function dispatchVinextNavigate(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent("vinext:navigate")); + setTimeout(() => { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent("vinext:navigate")); + }, 0); +} + function resolveUrl(url: string | UrlObject): string { if (typeof url === "string") return url; let result = url.pathname ?? "/"; @@ -117,6 +178,92 @@ function resolveUrl(url: string | UrlObject): string { return result; } +function getDynamicParamNames(pattern: string): string[] { + const names: string[] = []; + const dynamicParamRe = /\[(?:\.\.\.)?([^\]]+)\]/g; + let match: RegExpExecArray | null; + while ((match = dynamicParamRe.exec(pattern)) !== null) { + names.push(match[1]); + } + return names; +} + +function interpolateVisibleDynamicPath( + routePattern: string, + visiblePathname: string, + query: URLSearchParams, +): string | null { + const patternParts = routePattern.split("/").filter(Boolean); + const pathParts = visiblePathname.split("/").filter(Boolean); + const nextParts: string[] = []; + + for (let i = 0; i < patternParts.length; i++) { + const patternPart = patternParts[i]; + const catchAll = patternPart.match(/^\[\.\.\.([^\]]+)\]$/); + if (catchAll) { + const values = query.getAll(catchAll[1]); + if (values.length === 0) return null; + nextParts.push(...values.map((value) => encodeURIComponent(value))); + query.delete(catchAll[1]); + return `/${nextParts.join("/")}`; + } + + const dynamic = patternPart.match(/^\[([^\]]+)\]$/); + if (dynamic) { + if (pathParts[i] === undefined) return null; + const value = query.get(dynamic[1]); + nextParts.push(encodeURIComponent(value ?? decodeURIComponent(pathParts[i]))); + if (value !== null) query.delete(dynamic[1]); + continue; + } + + if (patternPart !== pathParts[i]) return null; + nextParts.push(pathParts[i]); + } + + if (patternParts.length !== pathParts.length) return null; + return `/${nextParts.join("/")}`; +} + +function resolveCurrentVisibleQueryNavigation(queryString: string): string { + if (typeof window === "undefined") return queryString; + + const currentPathname = stripBasePath(window.location.pathname, __basePath); + const origin = window.location.origin ?? new URL(window.location.href).origin; + const parsed = new URL(`${currentPathname}${queryString}`, origin); + const nextDataPage = window.__NEXT_DATA__?.page; + let searchParams = new URLSearchParams(parsed.search); + + let pathname = currentPathname; + if (nextDataPage && getDynamicParamNames(nextDataPage).some((name) => searchParams.has(name))) { + const interpolatedSearchParams = new URLSearchParams(searchParams); + const interpolatedPathname = interpolateVisibleDynamicPath( + nextDataPage, + currentPathname, + interpolatedSearchParams, + ); + if (interpolatedPathname) { + pathname = interpolatedPathname; + searchParams = interpolatedSearchParams; + } + } + + const search = searchParams.toString(); + return `${pathname}${search ? `?${search}` : ""}${parsed.hash}`; +} + +function resolvePagesRelativeNavigationUrl(url: string | UrlObject): string { + if (typeof url === "string") { + return url.startsWith("?") ? resolveCurrentVisibleQueryNavigation(url) : url; + } + + if (url.pathname !== undefined) return resolveUrl(url); + + const params = url.query ? urlQueryToSearchParams(url.query) : new URLSearchParams(); + const query = params.toString(); + return resolveCurrentVisibleQueryNavigation(query ? `?${query}` : ""); +} + /** * When `as` is provided, use it as the navigation target. This is a * simplification: Next.js keeps `url` and `as` as separate values (url for @@ -129,7 +276,151 @@ function resolveNavigationTarget( as: string | undefined, locale: string | undefined, ): string { - return applyNavigationLocale(as ?? resolveUrl(url), locale); + return normalizeLocalTrailingSlashHref( + applyNavigationLocale(as ?? resolvePagesRelativeNavigationUrl(url), locale), + __trailingSlash, + ); +} + +function resolveNavigationRouteTarget(url: string | UrlObject, locale: string | undefined): string { + return normalizeLocalTrailingSlashHref( + applyNavigationLocale(resolvePagesRelativeNavigationUrl(url), locale), + __trailingSlash, + ); +} + +function resolveErrorRouteFetchTarget( + url: string | UrlObject, + as: string | undefined, +): string | null { + if (as === undefined) return null; + const routeTarget = resolvePagesRelativeNavigationUrl(url); + const routePathname = routeTarget.split(/[?#]/, 1)[0]; + if (routePathname === "/404") return routeTarget; + if (routePathname === "/_error") { + return routeTarget.replace(/^\/_error(?=$|[?#])/, "/404"); + } + return null; +} + +function hasDynamicRouteSegment(pathname: string): boolean { + return /\[[^\]]+\]/.test(pathname); +} + +function appendSearchToPath(path: string, searchParams: URLSearchParams, hash: string): string { + const search = searchParams.toString(); + return `${path}${search ? `?${search}` : ""}${hash}`; +} + +function interpolateDynamicRouteTarget(routeTarget: string, asTarget: string): string { + const routeUrl = new URL(routeTarget, "http://vinext.local"); + if (!hasDynamicRouteSegment(routeUrl.pathname)) return routeTarget; + + const routeSearchParams = new URLSearchParams(routeUrl.search); + const dynamicParamNames = getDynamicParamNames(routeUrl.pathname); + let missingParam = false; + + const interpolatedPathname = routeUrl.pathname + .replace(/\[\[\.\.\.([^\]]+)\]\]/g, (_match, key: string) => { + const values = routeSearchParams.getAll(key); + routeSearchParams.delete(key); + if (values.length === 0) { + missingParam = true; + return ""; + } + return values.map((value) => encodeURIComponent(value)).join("/"); + }) + .replace(/\[\.\.\.([^\]]+)\]/g, (_match, key: string) => { + const values = routeSearchParams.getAll(key); + routeSearchParams.delete(key); + if (values.length === 0) { + missingParam = true; + return ""; + } + return values.map((value) => encodeURIComponent(value)).join("/"); + }) + .replace(/\[([^\]]+)\]/g, (_match, key: string) => { + const value = routeSearchParams.get(key); + routeSearchParams.delete(key); + if (value == null) { + missingParam = true; + return ""; + } + return encodeURIComponent(value); + }); + + if (!missingParam && !hasDynamicRouteSegment(interpolatedPathname)) { + return appendSearchToPath(interpolatedPathname, routeSearchParams, routeUrl.hash); + } + + const asUrl = new URL(asTarget, "http://vinext.local"); + for (const key of dynamicParamNames) { + routeSearchParams.delete(key); + } + return appendSearchToPath(asUrl.pathname, routeSearchParams, asUrl.hash); +} + +function shouldHardNavigateManualBasePathTarget(resolved: string): boolean { + if (!__basePath || !resolved.startsWith("/") || resolved.startsWith("//")) return false; + try { + return hasBasePath(new URL(resolved, "http://vinext.local").pathname, __basePath); + } catch { + return false; + } +} + +function isCurrentBrowserUrl(url: string): boolean { + try { + const current = new URL(window.location.href); + const target = new URL(url, window.location.href); + return ( + current.pathname === target.pathname && + current.search === target.search && + current.hash === target.hash + ); + } catch { + return false; + } +} + +function shouldCommitQueryNavigationBeforeFetch(url: string): boolean { + try { + const target = new URL(url, window.location.href); + return target.search !== "" && target.pathname === window.location.pathname; + } catch { + return false; + } +} + +function resolveNavigationRouteFetch( + url: string | UrlObject, + as: string | undefined, + locale: string | undefined, + browserFullUrl: string, +): { routeFull: string; allowErrorPageData: boolean } { + const errorRouteFetchTarget = resolveErrorRouteFetchTarget(url, as); + let routeTarget: string | null = errorRouteFetchTarget; + if (!routeTarget && as !== undefined) { + const routeHrefTarget = resolveNavigationRouteTarget(url, locale); + const asTarget = resolveNavigationTarget(as, undefined, locale); + routeTarget = interpolateDynamicRouteTarget(routeHrefTarget, asTarget); + } + if (!routeTarget) { + return { routeFull: browserFullUrl, allowErrorPageData: false }; + } + + if (isExternalUrl(routeTarget)) { + const localPath = toSameOriginAppPath(routeTarget, __basePath); + if (localPath == null) { + return { routeFull: browserFullUrl, allowErrorPageData: errorRouteFetchTarget !== null }; + } + routeTarget = localPath; + } + + return { + routeFull: toBrowserNavigationHref(routeTarget, window.location.href, __basePath), + allowErrorPageData: errorRouteFetchTarget !== null, + }; } function getDomainLocales(): readonly DomainLocale[] | undefined { @@ -171,20 +462,27 @@ export function isExternalUrl(url: string): boolean { return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//"); } -/** Resolve a hash URL to a basePath-stripped app URL for event payloads */ +/** Resolve a hash URL to the browser-visible URL for event payloads. */ function resolveHashUrl(url: string): string { if (typeof window === "undefined") return url; - if (url.startsWith("#")) - return stripBasePath(window.location.pathname, __basePath) + window.location.search + url; - // Full-path hash URL — strip basePath for consistency with other events + if (url.startsWith("#")) return window.location.pathname + window.location.search + url; try { const parsed = new URL(url, window.location.href); - return stripBasePath(parsed.pathname, __basePath) + parsed.search + parsed.hash; + return parsed.pathname + parsed.search + parsed.hash; } catch { return url; } } +function toRouterEventUrl(fullHref: string): string { + try { + const parsed = new URL(fullHref, window.location.href); + return parsed.pathname + parsed.search + parsed.hash; + } catch { + return fullHref; + } +} + /** Check if a href is only a hash change relative to the current URL */ export function isHashOnlyChange(href: string): boolean { if (href.startsWith("#")) return true; @@ -208,17 +506,83 @@ function scrollToHash(hash: string): void { if (el) el.scrollIntoView({ behavior: "auto" }); } +function createKey(): string { + return Math.random().toString(36).slice(2, 10); +} + +function canUseManualScrollRestoration(): boolean { + if (!__scrollRestoration || typeof window === "undefined") return false; + if (!window.history) return false; + if (!("scrollRestoration" in window.history)) return false; + try { + const testKey = "__vinext_scroll_test"; + window.sessionStorage.setItem(testKey, testKey); + window.sessionStorage.removeItem(testKey); + return true; + } catch { + return false; + } +} + +const manualScrollRestoration = canUseManualScrollRestoration(); +let _historyKey = + typeof window !== "undefined" && + window.history?.state && + typeof window.history.state === "object" && + typeof (window.history.state as { key?: unknown }).key === "string" + ? (window.history.state as { key: string }).key + : createKey(); + +function saveScrollPositionToSession(key: string): void { + if (!manualScrollRestoration) return; + try { + window.sessionStorage.setItem( + `__next_scroll_${key}`, + JSON.stringify({ x: window.scrollX, y: window.scrollY }), + ); + } catch { + // Fall back to browser behavior if sessionStorage is unavailable. + } +} + +function readScrollPositionFromSession(key: string): { x: number; y: number } | null { + if (!manualScrollRestoration) return null; + try { + const value = window.sessionStorage.getItem(`__next_scroll_${key}`); + if (!value) return null; + const parsed = JSON.parse(value) as { x?: unknown; y?: unknown }; + if (typeof parsed.x !== "number" || typeof parsed.y !== "number") return null; + return { x: parsed.x, y: parsed.y }; + } catch { + return { x: 0, y: 0 }; + } +} + /** Save current scroll position into history state for back/forward restoration */ function saveScrollPosition(): void { + saveScrollPositionToSession(_historyKey); const state = window.history.state ?? {}; window.history.replaceState( - { ...state, __vinext_scrollX: window.scrollX, __vinext_scrollY: window.scrollY }, + { + ...state, + __vinext_scrollX: window.scrollX, + __vinext_scrollY: window.scrollY, + __vinext_restore_url: + window.location.pathname + window.location.search + window.location.hash, + }, "", ); } /** Restore scroll position from history state */ -function restoreScrollPosition(state: unknown): void { +function restoreScrollPosition( + state: unknown, + forcedScroll?: { x: number; y: number } | null, +): void { + if (forcedScroll) { + requestAnimationFrame(() => window.scrollTo(forcedScroll.x, forcedScroll.y)); + return; + } if (state && typeof state === "object" && "__vinext_scrollY" in state) { const { __vinext_scrollX: x, __vinext_scrollY: y } = state as { __vinext_scrollX: number; @@ -228,6 +592,19 @@ function restoreScrollPosition(state: unknown): void { } } +function preserveTargetSearchIfRewriteDroppedIt(targetHref: string): void { + const target = new URL(targetHref, window.location.href); + if (target.search === "") return; + if (window.location.pathname !== target.pathname || window.location.search !== "") return; + + window.history.replaceState( + window.history.state ?? {}, + "", + target.pathname + target.search + target.hash, + ); + _lastPathnameAndSearch = window.location.pathname + window.location.search; +} + /** * SSR context - set by the dev server before rendering each page. */ @@ -235,6 +612,7 @@ type SSRContext = { pathname: string; query: Record; asPath: string; + isFallback?: boolean; locale?: string; locales?: string[]; defaultLocale?: string; @@ -270,32 +648,56 @@ export function setSSRContext(ctx: SSRContext | null): void { _setSSRContextImpl(ctx); } -/** - * Extract param names from a Next.js route pattern. - * E.g., "/posts/[id]" → ["id"], "/docs/[...slug]" → ["slug"], - * "/shop/[[...path]]" → ["path"], "/blog/[year]/[month]" → ["year", "month"] - * Also handles internal format: "/posts/:id" → ["id"], "/docs/:slug+" → ["slug"] - */ -function extractRouteParamNames(pattern: string): string[] { - const names: string[] = []; - // Match Next.js bracket format: [id], [...slug], [[...slug]] - const bracketMatches = pattern.matchAll(/\[{1,2}(?:\.\.\.)?([\w-]+)\]{1,2}/g); - for (const m of bracketMatches) { - names.push(m[1]); - } - if (names.length > 0) return names; - // Fallback: match internal :param format - const colonMatches = pattern.matchAll(/:([\w-]+)[+*]?/g); - for (const m of colonMatches) { - names.push(m[1]); +function extractDynamicParamsFromPath( + pattern: string, + pathname: string, +): Record | null { + const patternParts = pattern.split("/").filter(Boolean); + const pathParts = pathname.split("/").filter(Boolean); + const params: Record = {}; + + for (let i = 0; i < patternParts.length; i++) { + const patternPart = patternParts[i]; + const catchAll = patternPart.match(/^\[\.\.\.([^\]]+)\]$/); + if (catchAll) { + params[catchAll[1]] = pathParts.slice(i).map((part) => decodeURIComponent(part)); + return params; + } + + const dynamic = patternPart.match(/^\[([^\]]+)\]$/); + if (dynamic) { + const pathPart = pathParts[i]; + if (pathPart === undefined) return null; + params[dynamic[1]] = decodeURIComponent(pathPart); + continue; + } + + if (patternPart !== pathParts[i]) return null; } - return names; + + return patternParts.length === pathParts.length ? params : null; } function getPathnameAndQuery(): { pathname: string; query: Record; asPath: string; +} { + return getPathnameAndQueryFromLocation({ includeSearchParams: true }); +} + +function getHydrationPathnameAndQuery(): { + pathname: string; + query: Record; + asPath: string; +} { + return getPathnameAndQueryFromLocation({ includeSearchParams: false }); +} + +function getPathnameAndQueryFromLocation(options: { includeSearchParams: boolean }): { + pathname: string; + query: Record; + asPath: string; } { if (typeof window === "undefined") { const _ssrCtx = _getSSRContext(); @@ -308,35 +710,50 @@ function getPathnameAndQuery(): { } return { pathname: "/", query: {}, asPath: "/" }; } - const resolvedPath = stripBasePath(window.location.pathname, __basePath); + const browserUrl = new URL( + _pendingNavigationBrowserUrl ?? window.location.href, + window.location.href, + ); + const resolvedPath = stripBasePath(browserUrl.pathname, __basePath); // In Next.js, router.pathname is the route pattern (e.g., "/posts/[id]"), // not the resolved path ("/posts/42"). __NEXT_DATA__.page holds the route // pattern and is updated by navigateClient() on every client-side navigation. const pathname = window.__NEXT_DATA__?.page ?? resolvedPath; - const routeQuery: Record = {}; - // Include dynamic route params from __NEXT_DATA__ (e.g., { id: "42" } from /posts/[id]). - // Only include keys that are part of the route pattern (not stale query params). + const nextDataQuery: Record = {}; + // Include serialized router query from __NEXT_DATA__ (e.g., dynamic params + // plus query params introduced by middleware/config rewrites but not visible + // in window.location.search). const nextData = window.__NEXT_DATA__; if (nextData && nextData.query && nextData.page) { - const routeParamNames = extractRouteParamNames(nextData.page); - for (const key of routeParamNames) { - const value = nextData.query[key]; + for (const [key, value] of Object.entries(nextData.query)) { if (typeof value === "string") { - routeQuery[key] = value; + nextDataQuery[key] = value; } else if (Array.isArray(value)) { - routeQuery[key] = [...value]; + nextDataQuery[key] = [...value]; } } } - // URL search params always reflect the current URL + // URL search params always reflect the current URL after hydration. During + // the first client render, prefer serialized __NEXT_DATA__.query so static + // Pages Router apps hydrate against the same query object the server used. const searchQuery: Record = {}; - const params = new URLSearchParams(window.location.search); - for (const [key, value] of params) { - addQueryParam(searchQuery, key, value); + if (options.includeSearchParams) { + const params = new URLSearchParams(browserUrl.search); + for (const [key, value] of params) { + addQueryParam(searchQuery, key, value); + } + } + const query = { ...nextDataQuery, ...searchQuery }; + const dynamicPathParams = extractDynamicParamsFromPath(pathname, resolvedPath); + for (const key of getDynamicParamNames(pathname)) { + if (dynamicPathParams && key in dynamicPathParams) { + query[key] = dynamicPathParams[key]; + } else if (key in nextDataQuery) { + query[key] = nextDataQuery[key]; + } } - const query = { ...searchQuery, ...routeQuery }; // asPath uses the resolved browser path, not the route pattern - const asPath = resolvedPath + window.location.search + window.location.hash; + const asPath = resolvedPath + browserUrl.search + browserUrl.hash; return { pathname, query, asPath }; } @@ -346,8 +763,8 @@ function getPathnameAndQuery(): { */ class NavigationCancelledError extends Error { cancelled = true; - constructor(route: string) { - super(`Abort fetching component for route: "${route}"`); + constructor(_route: string) { + super("Route Cancelled"); this.name = "NavigationCancelledError"; } } @@ -379,15 +796,289 @@ let _navigationId = 0; /** AbortController for the in-flight fetch, so superseded navigations abort network I/O. */ let _activeAbortController: AbortController | null = null; +let _activeNavigationUrl: string | null = null; +let _activeNavigationPromise: Promise | null = null; +let _pendingNavigationBrowserUrl: string | null = null; +const _preEmittedCancelledUrls = new Set(); +const _inFlightPagesDataRequests = new Map>(); + +function emitActiveNavigationCancelled(): void { + if (!_activeAbortController || !_activeNavigationUrl) return; + const cancelledUrl = _activeNavigationUrl; + _preEmittedCancelledUrls.add(cancelledUrl); + routerEvents.emit( + "routeChangeError", + new NavigationCancelledError(cancelledUrl), + toRouterEventUrl(cancelledUrl), + { shallow: false }, + ); +} -function scheduleHardNavigationAndThrow(url: string, message: string): never { +function scheduleHardNavigationAndThrow(url: string, message: string, delayMs = 0): never { if (typeof window === "undefined") { throw new HardNavigationScheduledError(message); } - window.location.href = url; + if (delayMs > 0) { + setTimeout(() => { + window.location.href = url; + }, delayMs); + } else { + window.location.href = url; + } throw new HardNavigationScheduledError(message); } +function getPagesDataPathParts(appPathname: string): { + localePrefix: string; + pagePathname: string; +} { + const locales = window.__VINEXT_LOCALES__ ?? []; + const defaultLocale = window.__VINEXT_DEFAULT_LOCALE__; + const targetLocale = appPathname.split("/").filter(Boolean)[0]; + if (targetLocale && locales.includes(targetLocale)) { + const pagePathname = appPathname.slice(targetLocale.length + 1) || "/"; + return { localePrefix: `/${targetLocale}`, pagePathname }; + } + + if (defaultLocale && locales.includes(defaultLocale)) { + return { localePrefix: `/${defaultLocale}`, pagePathname: appPathname }; + } + + // Non-i18n data URLs do not carry a locale prefix. + return { localePrefix: "", pagePathname: appPathname }; +} + +function buildPagesDataUrl(url: string): string | null { + const buildId = window.__NEXT_DATA__?.buildId ?? process.env.__VINEXT_BUILD_ID; + if (!buildId) return null; + + const parsed = new URL(url, window.location.href); + const appPathname = stripBasePath(parsed.pathname, __basePath); + const dataPathParts = getPagesDataPathParts(appPathname); + const pagePathname = + dataPathParts.pagePathname === "/" + ? dataPathParts.localePrefix + ? "" + : "/index" + : dataPathParts.pagePathname.replace(/\/$/, ""); + const basePathPrefix = __basePath || ""; + + return `${parsed.origin}${basePathPrefix}/_next/data/${buildId}${dataPathParts.localePrefix}${pagePathname}.json${parsed.search}`; +} + +type PagesNavigationData = Record & { + pageProps?: Record; + page?: string; + query?: Record; + buildId?: string; + gssp?: boolean; + gsp?: boolean; + isFallback?: boolean; + locale?: string; + locales?: string[]; + defaultLocale?: string; + domainLocales?: VinextNextData["domainLocales"]; + __vinext?: VinextNextData["__vinext"]; +}; + +type PagesNavigationDataResult = + | { kind: "data"; data: PagesNavigationData } + | { + kind: "redirect"; + url: string; + result: Exclude; + } + | { kind: "not-found" } + | { kind: "fallback" }; + +async function renderPagesRoot( + root: { render(element: ReactElement): void }, + element: ReactElement, +) { + try { + const { flushSync } = await import("react-dom"); + flushSync(() => root.render(element)); + } catch { + root.render(element); + } +} + +async function fetchPagesNavigationData( + url: string, + signal: AbortSignal, + options: { allowErrorPageData?: boolean } = {}, +): Promise { + const dataUrl = buildPagesDataUrl(url); + if (!dataUrl) return { kind: "fallback" }; + + const cachedData = window.next?.router?.sdc?.[dataUrl]; + if (cachedData && typeof cachedData === "object") { + return { kind: "data", data: cachedData as PagesNavigationData }; + } + + const inFlightRequest = _inFlightPagesDataRequests.get(dataUrl); + if (inFlightRequest) { + return await inFlightRequest; + } + + const requestPromise = fetchUncachedPagesNavigationData(url, dataUrl, signal, options); + _inFlightPagesDataRequests.set(dataUrl, requestPromise); + try { + return await requestPromise; + } finally { + if (_inFlightPagesDataRequests.get(dataUrl) === requestPromise) { + _inFlightPagesDataRequests.delete(dataUrl); + } + } +} + +async function fetchUncachedPagesNavigationData( + url: string, + dataUrl: string, + signal: AbortSignal, + options: { allowErrorPageData?: boolean }, +): Promise { + let res: Response; + try { + res = await fetch(dataUrl, { + headers: { "x-nextjs-data": "1" }, + credentials: "include", + signal, + }); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") { + throw new NavigationCancelledError(url); + } + throw err; + } + + let redirectUrl: string | undefined; + const redirectHeader = res.headers.get("x-nextjs-redirect"); + if (redirectHeader) { + redirectUrl = redirectHeader; + } + if (res.redirected) { + try { + const finalUrl = new URL(res.url); + if (finalUrl.origin === window.location.origin) { + redirectUrl = finalUrl.pathname + finalUrl.search + finalUrl.hash; + } + } catch { + // Ignore malformed redirect URLs and handle the response normally. + } + } + + const withRedirect = ( + result: Exclude, + ): PagesNavigationDataResult => + redirectUrl ? { kind: "redirect", url: redirectUrl, result } : result; + + if (!res.ok) { + if (redirectUrl) { + return withRedirect({ kind: "fallback" }); + } + if (res.status === 404) { + const contentType = res.headers.get("Content-Type") ?? ""; + if (!redirectUrl && !contentType.toLowerCase().includes("application/json")) { + return { kind: "fallback" }; + } + if (!redirectUrl && contentType.toLowerCase().includes("application/json")) { + try { + const data = (await res.clone().json()) as { page?: unknown }; + if (data.page === "/404" && !options.allowErrorPageData) { + scheduleHardNavigationAndThrow(url, "Navigation data request resolved to /404"); + } + } catch (err: unknown) { + if (err instanceof HardNavigationScheduledError) { + throw err; + } + // Malformed 404 JSON falls back to the soft not-found render below. + } + } + return withRedirect({ kind: "not-found" }); + } + if (window.__VINEXT_SUPPRESS_DATA_NAVIGATION_FAILURE === true) { + return withRedirect({ kind: "fallback" }); + } + scheduleHardNavigationAndThrow(url, "Failed to load static props", 1500); + } + + const contentType = res.headers.get("Content-Type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + return withRedirect({ kind: "fallback" }); + } + + try { + const data = (await res.json()) as PagesNavigationData; + if (!redirectUrl && data.page === "/404" && !options.allowErrorPageData) { + scheduleHardNavigationAndThrow(url, "Navigation data request resolved to /404"); + } + return withRedirect({ kind: "data", data }); + } catch (err: unknown) { + if (err instanceof HardNavigationScheduledError) { + throw err; + } + return withRedirect({ kind: "fallback" }); + } +} + +function renderPagesNotFound(root: { render(element: ReactElement): void }): void { + const element = createElement( + "div", + null, + createElement("h1", null, "404 - Page not found"), + createElement("p", null, "This page could not be found."), + ); + window.__NEXT_DATA__ = { + ...window.__NEXT_DATA__, + page: "/404", + query: {}, + isFallback: false, + } as VinextNextData; + root.render(element); +} + +function getCurrentBrowserPathSearchHash(): string { + return window.location.pathname + window.location.search + window.location.hash; +} + +function syncHistoryTrackingFromCurrent(): void { + const state = window.history.state as NextHistoryState | null; + if (state?.__N && typeof state.key === "string") { + _historyKey = state.key; + } + _lastPathnameAndSearch = window.location.pathname + window.location.search; +} + +function commitPushNavigationHistory( + historyState: NextHistoryState, + full: string, + routeFull: string, + hasAs: boolean, +): void { + if (hasAs && routeFull !== full && isCurrentBrowserUrl(full)) { + historyState.key = _historyKey; + window.history.replaceState(historyState, "", full); + } else { + window.history.pushState(historyState, "", full); + _historyKey = historyState.key ?? _historyKey; + } + _lastPathnameAndSearch = window.location.pathname + window.location.search; +} + +function commitReplaceNavigationHistory(historyState: NextHistoryState, full: string): void { + window.history.replaceState(historyState, "", full); + _historyKey = historyState.key ?? _historyKey; + _lastPathnameAndSearch = window.location.pathname + window.location.search; +} + +function syncI18nGlobalsFromNextData(nextData: VinextNextData): void { + if (!nextData.locales) return; + window.__VINEXT_LOCALE__ = nextData.locale; + window.__VINEXT_LOCALES__ = nextData.locales; + window.__VINEXT_DEFAULT_LOCALE__ = nextData.defaultLocale; +} + /** * Perform client-side navigation: fetch the target page's HTML, * extract __NEXT_DATA__, and re-render the React root. @@ -396,7 +1087,10 @@ function scheduleHardNavigationAndThrow(url: string, message: string): never { * Throws on hard-navigation failures (non-OK response, missing data) so the * caller can distinguish success from failure for event emission. */ -async function navigateClient(url: string): Promise { +async function navigateClient( + url: string, + options: { allowErrorPageData?: boolean; beforeRender?: () => void } = {}, +): Promise { if (typeof window === "undefined") return; const root = window.__VINEXT_ROOT__; @@ -406,6 +1100,12 @@ async function navigateClient(url: string): Promise { return; } + if (_activeNavigationUrl === url) { + await _activeNavigationPromise; + return; + } + _activeNavigationUrl = url; + // Cancel any in-flight navigation (abort its fetch, mark it stale) _activeAbortController?.abort(); const controller = new AbortController(); @@ -420,7 +1120,115 @@ async function navigateClient(url: string): Promise { } } - try { + const navigationPromise = (async () => { + let dataResult = await fetchPagesNavigationData(url, controller.signal, options); + assertStillCurrent(); + + if (dataResult.kind === "redirect") { + const targetUrl = new URL(url, window.location.href); + const redirectUrl = new URL(dataResult.url, window.location.href); + if (redirectUrl.origin !== window.location.origin) { + scheduleHardNavigationAndThrow( + redirectUrl.href, + "Navigation data request redirected externally", + ); + } + const redirectOnlyDroppedQuery = + targetUrl.pathname === redirectUrl.pathname && + targetUrl.search !== "" && + redirectUrl.search === ""; + if (!redirectOnlyDroppedQuery) { + window.history.replaceState( + {}, + "", + redirectUrl.pathname + redirectUrl.search + redirectUrl.hash, + ); + _lastPathnameAndSearch = window.location.pathname + window.location.search; + url = redirectUrl.pathname + redirectUrl.search + redirectUrl.hash; + } + dataResult = dataResult.result; + } + + if (dataResult.kind === "not-found") { + options.beforeRender?.(); + renderPagesNotFound(root); + return; + } + + if (dataResult.kind === "data" && dataResult.data.__vinext?.pageModuleUrl) { + const { pageProps = {}, ...appProps } = dataResult.data; + const nextData = { + ...window.__NEXT_DATA__, + props: { ...appProps, pageProps }, + page: + dataResult.data.page ?? + window.__NEXT_DATA__?.page ?? + stripBasePath(new URL(url, window.location.href).pathname, __basePath), + query: dataResult.data.query ?? {}, + buildId: dataResult.data.buildId ?? window.__NEXT_DATA__?.buildId, + isFallback: dataResult.data.isFallback ?? false, + locale: dataResult.data.locale ?? window.__NEXT_DATA__?.locale, + locales: dataResult.data.locales ?? window.__NEXT_DATA__?.locales, + defaultLocale: dataResult.data.defaultLocale ?? window.__NEXT_DATA__?.defaultLocale, + domainLocales: dataResult.data.domainLocales ?? window.__NEXT_DATA__?.domainLocales, + ...(dataResult.data.gsp ? { gsp: true } : {}), + ...(dataResult.data.gssp ? { gssp: true } : {}), + __vinext: dataResult.data.__vinext, + } as VinextNextData; + + const pageModuleUrl = dataResult.data.__vinext.pageModuleUrl; + if (!isValidModulePath(pageModuleUrl)) { + console.error("[vinext] Blocked import of invalid page module path:", pageModuleUrl); + scheduleHardNavigationAndThrow(url, "Navigation failed: invalid page module path"); + } + + const pageModule = await import(/* @vite-ignore */ pageModuleUrl); + assertStillCurrent(); + + const PageComponent = pageModule.default; + if (!PageComponent) { + scheduleHardNavigationAndThrow(url, "Navigation failed: page module has no default export"); + } + + const React = (await import("react")).default; + assertStillCurrent(); + + let AppComponent = window.__VINEXT_APP__; + const appModuleUrl = dataResult.data.__vinext.appModuleUrl; + if (!AppComponent && appModuleUrl) { + if (!isValidModulePath(appModuleUrl)) { + console.error("[vinext] Blocked import of invalid app module path:", appModuleUrl); + } else { + try { + const appModule = await import(/* @vite-ignore */ appModuleUrl); + AppComponent = appModule.default; + window.__VINEXT_APP__ = AppComponent; + } catch { + // _app not available — continue without it + } + } + } + assertStillCurrent(); + + let element; + if (AppComponent) { + element = React.createElement(AppComponent, { + Component: PageComponent, + pageProps, + ...appProps, + }); + } else { + element = React.createElement(PageComponent, pageProps); + } + + window.__NEXT_DATA__ = nextData; + syncI18nGlobalsFromNextData(nextData); + element = wrapWithRouterContext(element); + options.beforeRender?.(); + await renderPagesRoot(root, element); + return; + } + // Fetch the target page's SSR HTML let res: Response; try { @@ -437,7 +1245,30 @@ async function navigateClient(url: string): Promise { } assertStillCurrent(); - if (!res.ok) { + if (res.redirected) { + try { + const targetUrl = new URL(url, window.location.href); + const finalUrl = new URL(res.url); + if (finalUrl.origin === window.location.origin) { + const redirectOnlyDroppedQuery = + targetUrl.pathname === finalUrl.pathname && + targetUrl.search !== "" && + finalUrl.search === ""; + if (!redirectOnlyDroppedQuery) { + window.history.replaceState( + {}, + "", + finalUrl.pathname + finalUrl.search + finalUrl.hash, + ); + _lastPathnameAndSearch = window.location.pathname + window.location.search; + } + } + } catch { + // Ignore malformed redirect URLs and continue with the fetched response. + } + } + + if (!res.ok && res.status !== 404) { // Set window.location.href first so the browser navigates to the correct // page even if the caller suppresses the error. The assignment schedules // the navigation asynchronously (as a task), so synchronous routeChangeError @@ -453,14 +1284,27 @@ async function navigateClient(url: string): Promise { const html = await res.text(); assertStillCurrent(); - // Extract __NEXT_DATA__ from the HTML - const match = html.match(/'); + tags.push(''); } if (m) { // Always inject shared chunks (framework, vinext runtime, entry) and @@ -15049,7 +15366,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(''); } } } @@ -15103,12 +15420,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 renderHeadPrepassAsync(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, @@ -15117,6 +15633,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; @@ -15132,7 +15649,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" } }); } @@ -15144,11 +15671,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, @@ -15166,12 +15727,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 isCrawlerRequest = __isPagesHtmlBotUserAgent(request.headers.get("user-agent") || ""); + const shouldBufferResponse = isCrawlerRequest; // 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 = ""; @@ -15184,14 +15746,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, @@ -15210,7 +15772,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 @@ -15231,6 +15793,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, @@ -15254,29 +15821,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, @@ -15299,6 +16045,8 @@ async function _renderPage(request, url, manifest, middlewareHeaders) { }, getSSRHeadHTML: typeof getSSRHeadHTML === "function" ? getSSRHeadHTML : undefined, gsspRes, + isFallback, + isGsp: isGspPage, isrCacheKey, isrRevalidateSeconds, isrSet, @@ -15309,10 +16057,15 @@ async function _renderPage(request, url, manifest, middlewareHeaders) { domainLocales: domainLocales, }, pageProps, - params, + pageModuleUrl, + appModuleUrl, + params: query, renderDocumentToString(element) { return renderToStringAsync(element); }, + renderHeadPrepassToStringAsync(element) { + return renderHeadPrepassAsync(element); + }, renderIsrPassToStringAsync, renderToReadableStream(element) { return renderToReadableStream(element); @@ -15322,6 +16075,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders) { routeUrl, safeJsonStringify, scriptNonce, + shouldBufferResponse, }); } catch (e) { console.error("[vinext] SSR error:", e); @@ -15330,6 +16084,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 }); } }); @@ -15339,6 +16107,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) { @@ -15723,6 +16494,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; @@ -15739,10 +16514,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); } @@ -15790,7 +16573,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 }; } diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index e88ef9e7a..2fbe61b41 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import React from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { devOnCaughtError } from "../packages/vinext/src/server/app-browser-error.js"; @@ -25,6 +27,11 @@ import { type AppRouterState, } from "../packages/vinext/src/server/app-browser-state.js"; +const APP_BROWSER_ENTRY_SOURCE = readFileSync( + fileURLToPath(new URL("../packages/vinext/src/server/app-browser-entry.ts", import.meta.url)), + "utf8", +); + function createResolvedElements( routeId: string, rootLayoutTreePath: string | null, @@ -54,6 +61,30 @@ function createState(overrides: Partial = {}): AppRouterState { } describe("app browser entry state helpers", () => { + it("fetches client navigation RSC payloads with RSC headers", () => { + expect(APP_BROWSER_ENTRY_SOURCE).toContain( + 'const headers = new Headers({ Accept: "text/x-component", RSC: "1" });', + ); + expect(APP_BROWSER_ENTRY_SOURCE).toContain("const rscFetchUrl = rscUrl;"); + expect(APP_BROWSER_ENTRY_SOURCE).toContain("navResponse = await fetch(rscFetchUrl, {"); + expect(APP_BROWSER_ENTRY_SOURCE).toContain('"X-Vinext-Loading-Payload": "1"'); + }); + + it("handles internal server action redirects through the App Router navigator", () => { + // Ported from Next.js: test/e2e/next-form/default/app-dir.test.ts + // Server action redirects from should not trigger MPA navigation after hydration. + expect(APP_BROWSER_ENTRY_SOURCE).toContain( + 'const actionRedirect = fetchResponse.headers.get("x-action-redirect");', + ); + expect(APP_BROWSER_ENTRY_SOURCE).toContain( + 'window.dispatchEvent(new Event("vinext:link-status-navigation"));', + ); + expect(APP_BROWSER_ENTRY_SOURCE).toContain("await window.__VINEXT_RSC_NAVIGATE__?.("); + expect(APP_BROWSER_ENTRY_SOURCE).toContain('redirectType === "push" ? "push" : "replace"'); + expect(APP_BROWSER_ENTRY_SOURCE).not.toContain("window.location.assign(actionRedirect);"); + expect(APP_BROWSER_ENTRY_SOURCE).not.toContain("window.location.replace(actionRedirect);"); + }); + it("requires renderId when creating pending commits", () => { // @ts-expect-error renderId is required to avoid duplicate commit ids. void createPendingNavigationCommit({ diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 8f7dde93d..93ab4fd2c 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -6,6 +6,7 @@ import { type AppPageModule, type AppPageSlotOverride, buildAppPageElements, + buildAppPageLoadingElements, createAppPageLayoutEntries, resolveAppPageChildSegments, } from "../packages/vinext/src/server/app-page-route-wiring.js"; @@ -167,6 +168,10 @@ function PageProbe() { return createElement("main", { "data-page-segments": segments.join("|") }, "Page"); } +function LoadingProbe() { + return createElement("div", { id: "loading" }, "Loading..."); +} + function LayoutWithoutChildren() { return createElement("div", { "data-layout": "without-children" }, "Layout only"); } @@ -193,6 +198,25 @@ describe("app page route wiring helpers", () => { expect(entries.map((entry) => entry.treePath)).toEqual(["/", "/(marketing)"]); }); + it("builds a route loading payload without rendering the async page", async () => { + // Ported from Next.js: test/e2e/next-form/default/next-form-prefetch.test.ts + // Client navigations need a target loading.tsx shell before the full RSC payload resolves. + const elements = buildAppPageLoadingElements({ + route: { + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: { default: LoadingProbe }, + routeSegments: ["search"], + }, + routePath: "/search", + }); + + expect(elements).not.toBeNull(); + const html = await renderRouteEntry(elements!, "route:/search"); + expect(html).toContain('id="loading"'); + expect(html).toContain("Loading..."); + }); + it("builds a flat elements map with route, layout, template, page, and slot entries", async () => { const elements = buildAppPageElements({ element: createElement(PageProbe), diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 2dfa50a17..62a29bada 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1894,6 +1894,24 @@ describe("App Router Production server (startProdServer)", () => { expect(res.headers.get("cache-control")).toContain("immutable"); }); + it("serves valid Next static build manifests and plain-text 404s invalid Next static assets", async () => { + // Ported from Next.js: test/e2e/invalid-static-asset-404-app/invalid-static-asset-404-app.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/invalid-static-asset-404-app/invalid-static-asset-404-app.test.ts + const nextStaticDir = path.join(outDir, "client", "_next", "static"); + const buildId = fs + .readdirSync(nextStaticDir) + .find((entry) => fs.existsSync(path.join(nextStaticDir, entry, "_buildManifest.js"))); + expect(buildId).toBeDefined(); + + const manifestRes = await fetch(`${baseUrl}/_next/static/${buildId}/_buildManifest.js`); + expect(manifestRes.status).toBe(200); + expect(await manifestRes.text()).toContain("__BUILD_MANIFEST"); + + const missingAssetRes = await fetch(`${baseUrl}/_next/static/invalid-path`); + expect(missingAssetRes.status).toBe(404); + expect(await missingAssetRes.text()).toBe("Not Found"); + }); + it("serves public files from the build output", async () => { // Ported from Next.js: test/production/export/index.test.ts // https://github.com/vercel/next.js/blob/canary/test/production/export/index.test.ts @@ -1955,6 +1973,14 @@ describe("App Router Production server (startProdServer)", () => { const withoutBasePathRes = await fetch(`${tmpBaseUrl}/logo/logo.svg`); expect(withoutBasePathRes.status).toBe(404); + + const assetsDir = path.join(fixtureRoot, "dist", "client", "assets"); + const jsFile = fs.readdirSync(assetsDir).find((f: string) => f.endsWith(".js")); + expect(jsFile).toBeDefined(); + + const assetRes = await fetch(`${tmpBaseUrl}/app/assets/${jsFile}`); + expect(assetRes.status).toBe(200); + expect(assetRes.headers.get("content-type")).toContain("javascript"); } finally { basePathServer?.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -3190,6 +3216,12 @@ describe("App Router next.config.js features (generateRscEntry)", () => { }, ] as any[]; + it("generates Next-compatible default 404 HTML for unmatched routes without not-found.tsx", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + expect(code).toContain("This page could not be found."); + expect(code).toContain('"Content-Type": "text/html; charset=utf-8"'); + }); + it("generates redirect handling code when redirects are provided", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, { redirects: [ @@ -3242,9 +3274,14 @@ describe("App Router next.config.js features (generateRscEntry)", () => { it("embeds basePath and trailingSlash alongside config", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "/app", true, { redirects: [{ source: "/old", destination: "/new", permanent: true }], + assetPrefix: "/assets", }); expect(code).toContain('__basePath = "/app"'); expect(code).toContain("__trailingSlash = true"); + expect(code).toContain('__assetPrefix = "/assets"'); + expect(code).toContain( + "export const vinextConfig = { basePath: __basePath, assetPrefix: __assetPrefix }", + ); expect(code).toContain("/old"); }); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 88a4b3619..d191989d7 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -352,6 +352,72 @@ describe("process.env.NODE_ENV define", () => { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } }, 15000); + + it("maps compiler.define to all environments and compiler.defineServer to server environments", async () => { + const { mainPlugin, tmpDir, fsp } = await setupTmpProject(); + try { + await fsp.rm(path.join(tmpDir, "pages"), { recursive: true, force: true }); + await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "app", "layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "app", "page.tsx"), + `export default function Home() { return

Home

; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "next.config.mjs"), + `export default { + compiler: { + define: { MY_MAGIC_VARIABLE: "foobar", "process.env.MY_MAGIC_EXPR": "barbaz" }, + defineServer: { MY_SERVER_VARIABLE: "server" } + } + };`, + ); + + const mockConfig = { root: tmpDir, build: {}, plugins: [] }; + const result = await mainPlugin.config(mockConfig, { command: "build", mode: "production" }); + + expect(result.define?.MY_MAGIC_VARIABLE).toBe(JSON.stringify("foobar")); + expect(result.environments.client.define?.MY_MAGIC_VARIABLE).toBe(JSON.stringify("foobar")); + expect(result.environments.client.define?.MY_SERVER_VARIABLE).toBeUndefined(); + expect(result.environments.rsc.define?.MY_SERVER_VARIABLE).toBe(JSON.stringify("server")); + expect(result.environments.ssr.define?.MY_SERVER_VARIABLE).toBe(JSON.stringify("server")); + expect(result.environments.rsc.define?.["process.env.MY_MAGIC_EXPR"]).toBe( + JSON.stringify("barbaz"), + ); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + + it("defines process.browser only as true in browser builds", async () => { + // Ported from Next.js: test/e2e/esm-externals/esm-externals.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/esm-externals/esm-externals.test.ts + const { mainPlugin, tmpDir, fsp } = await setupTmpProject(); + try { + await fsp.rm(path.join(tmpDir, "pages"), { recursive: true, force: true }); + await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "app", "layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "app", "page.tsx"), + `export default function Home() { return

Home

; }`, + ); + + const mockConfig = { root: tmpDir, build: {}, plugins: [] }; + const result = await mainPlugin.config(mockConfig, { command: "build", mode: "production" }); + + expect(result.environments.client.define?.["process.browser"]).toBe("true"); + expect(result.environments.rsc.define?.["process.browser"]).toBe("false"); + expect(result.environments.ssr.define?.["process.browser"]).toBe("false"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); }); // ─── Treeshake config applied to Vite builds ────────────────────────────────── @@ -1074,7 +1140,7 @@ describe("collectAssetTags lazy chunk filtering", () => { } else if (tf.endsWith(".js")) { if (lazySet.has(tf)) continue; tags.push(``); - tags.push(``); + tags.push(``); } } return tags; @@ -1116,7 +1182,9 @@ describe("collectAssetTags lazy chunk filtering", () => { // Entry and framework should have modulepreload + script tags expect(tags).toContain(''); - expect(tags).toContain(''); + expect(tags).toContain( + '', + ); expect(tags).toContain(''); // Page chunk and mermaid are lazy — should have NO tags at all @@ -1171,8 +1239,12 @@ describe("collectAssetTags lazy chunk filtering", () => { // Both should be present expect(tags).toContain(''); expect(tags).toContain(''); - expect(tags).toContain(''); - expect(tags).toContain(''); + expect(tags).toContain( + '', + ); + expect(tags).toContain( + '', + ); }); it("normalizes leading slashes from SSR manifest values", () => { @@ -1215,7 +1287,9 @@ describe("collectAssetTags lazy chunk filtering", () => { // Entry and framework should be present with correct single-slash paths expect(tags).toContain(''); - expect(tags).toContain(''); + expect(tags).toContain( + '', + ); expect(tags).toContain(''); // Page chunk is lazy — should be excluded even with leading-slash input @@ -1249,7 +1323,7 @@ describe("collectAssetTags lazy chunk filtering", () => { expect(tags).toContain(''); expect(tags).toContain( - '', + '', ); expect(tags).toContain(''); expect(tags.join("\n")).not.toContain("page-index.js"); @@ -1559,9 +1633,9 @@ export { getServerSideProps }; `; const result = _stripServerExports(code); expect(result).not.toBeNull(); - expect(result).toContain("export const getServerSideProps = undefined;"); // The original local declaration remains (dead code, tree-shaken later) expect(result).not.toContain("export { getServerSideProps }"); + expect(result).not.toContain("export const getServerSideProps = undefined;"); }); it("handles export { name } with other specifiers", () => { @@ -1581,7 +1655,26 @@ export { getServerSideProps, config }; expect(result).not.toBeNull(); // config should be preserved expect(result).toContain("export { config }"); - expect(result).toContain("export const getServerSideProps = undefined;"); + expect(result).not.toContain("export const getServerSideProps = undefined;"); + }); + + it("does not redeclare a local binding when stripping export specifiers", () => { + const code = ` +const getServerSideProps = async () => { + return { props: {} }; +}; + +export default function Page() { + return null; +} + +export { getServerSideProps }; +`; + const result = _stripServerExports(code); + expect(result).not.toBeNull(); + expect(result).toContain("const getServerSideProps = async"); + expect(result).not.toContain("export const getServerSideProps"); + expect(result).not.toContain("export { getServerSideProps }"); }); it("handles strings containing braces", () => { diff --git a/tests/css-data-url.test.ts b/tests/css-data-url.test.ts new file mode 100644 index 000000000..f338a381a --- /dev/null +++ b/tests/css-data-url.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + createCssDataUrlPlugin, + decodeCssDataUrl, +} from "../packages/vinext/src/plugins/css-data-url.js"; + +describe("css data URL imports", () => { + it("decodes plain CSS data URLs with hash selectors", () => { + // Ported from Next.js: test/e2e/css-data-url-global-pages/css-data-url-global-pages.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/css-data-url-global-pages/css-data-url-global-pages.test.ts + expect(decodeCssDataUrl("data:text/css,#styled{font-weight:700}")).toBe( + "#styled{font-weight:700}", + ); + }); + + it("resolves CSS data URLs to browser style injection modules", async () => { + const plugin = createCssDataUrlPlugin(); + const resolved = await (plugin.resolveId as (id: string) => string | null)( + "data:text/css,#styled{font-weight:700}", + ); + + expect(resolved).toContain("vinext:css-data-url"); + + const code = await (plugin.load as (id: string) => string | null)(resolved!); + + expect(code).toContain("#styled{font-weight:700}"); + expect(code).toContain('document.createElement("style")'); + expect(code).toContain("document.head.appendChild(style)"); + }); +}); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index adb91890e..874069d97 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -591,7 +591,8 @@ describe("generatePagesRouterWorkerEntry", () => { // After stripping, a new request with the stripped URL must be created // so middleware matchers see the basePath-free pathname (matching prod-server) expect(content).toContain("strippedUrl.pathname = pathname"); - expect(content).toContain("new Request(strippedUrl, request)"); + expect(content).toContain('strippedHeaders.set("x-vinext-request-had-base-path"'); + expect(content).toContain("new Request(strippedUrl"); }); it("handles trailing slash normalization", () => { @@ -882,6 +883,20 @@ describe("generatePagesRouterWorkerEntry", () => { expect(basePathPos).toBeLessThan(imagePos); }); + it("serves Vite assets after basePath stripping before middleware", () => { + const content = generatePagesRouterWorkerEntry(); + const basePathPos = content.indexOf("const stripped = stripBasePath(pathname, basePath);"); + const assetPos = content.indexOf('pathname.startsWith("/assets/")'); + const middlewarePos = content.indexOf("runMiddleware(request, ctx)"); + + expect(basePathPos).toBeGreaterThan(-1); + expect(assetPos).toBeGreaterThan(-1); + expect(middlewarePos).toBeGreaterThan(-1); + expect(basePathPos).toBeLessThan(assetPos); + expect(assetPos).toBeLessThan(middlewarePos); + expect(content).toContain("new URL(pathname + url.search, request.url)"); + }); + it("uses segment-boundary check before skipping redirect destination prefixing", () => { const content = generatePagesRouterWorkerEntry(); expect(content).toContain("!isExternalUrl(redirect.destination)"); 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/app-router/instrumentation-client.spec.ts b/tests/e2e/app-router/instrumentation-client.spec.ts index fb325ee3a..07b817be8 100644 --- a/tests/e2e/app-router/instrumentation-client.spec.ts +++ b/tests/e2e/app-router/instrumentation-client.spec.ts @@ -23,10 +23,12 @@ test.describe.serial("instrumentation-client (App Router)", () => { const win = window as Window & { __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; __VINEXT_HYDRATED_AT?: number; + __NEXT_HYDRATED_AT?: number; }; return ( win.__INSTRUMENTATION_CLIENT_EXECUTED_AT !== undefined && - win.__VINEXT_HYDRATED_AT !== undefined + win.__VINEXT_HYDRATED_AT !== undefined && + win.__NEXT_HYDRATED_AT !== undefined ); }); @@ -34,19 +36,27 @@ test.describe.serial("instrumentation-client (App Router)", () => { const win = window as Window & { __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; __VINEXT_HYDRATED_AT?: number; + __NEXT_HYDRATED_AT?: number; }; return { instrumentation: win.__INSTRUMENTATION_CLIENT_EXECUTED_AT, hydration: win.__VINEXT_HYDRATED_AT, + nextHydration: win.__NEXT_HYDRATED_AT, }; }); expect(timing.instrumentation).toBeDefined(); expect(timing.hydration).toBeDefined(); - if (timing.instrumentation === undefined || timing.hydration === undefined) { + expect(timing.nextHydration).toBeDefined(); + if ( + timing.instrumentation === undefined || + timing.hydration === undefined || + timing.nextHydration === undefined + ) { throw new Error("Instrumentation or hydration timing marker was not recorded"); } expect(timing.instrumentation).toBeLessThan(timing.hydration); + expect(timing.nextHydration).toBe(timing.hydration); expect( logs.some((message) => message.startsWith("[Client Instrumentation Hook] Slow execution detected"), diff --git a/tests/e2e/app-with-src/instrumentation-client.spec.ts b/tests/e2e/app-with-src/instrumentation-client.spec.ts index b42187db1..229c05dc7 100644 --- a/tests/e2e/app-with-src/instrumentation-client.spec.ts +++ b/tests/e2e/app-with-src/instrumentation-client.spec.ts @@ -10,10 +10,12 @@ test("executes src/instrumentation-client before hydration", async ({ page }) => const win = window as Window & { __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; __VINEXT_HYDRATED_AT?: number; + __NEXT_HYDRATED_AT?: number; }; return ( win.__INSTRUMENTATION_CLIENT_EXECUTED_AT !== undefined && - win.__VINEXT_HYDRATED_AT !== undefined + win.__VINEXT_HYDRATED_AT !== undefined && + win.__NEXT_HYDRATED_AT !== undefined ); }); @@ -21,18 +23,26 @@ test("executes src/instrumentation-client before hydration", async ({ page }) => const win = window as Window & { __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; __VINEXT_HYDRATED_AT?: number; + __NEXT_HYDRATED_AT?: number; }; return { instrumentation: win.__INSTRUMENTATION_CLIENT_EXECUTED_AT, hydration: win.__VINEXT_HYDRATED_AT, + nextHydration: win.__NEXT_HYDRATED_AT, }; }); expect(timing.instrumentation).toBeDefined(); expect(timing.hydration).toBeDefined(); - if (timing.instrumentation === undefined || timing.hydration === undefined) { + expect(timing.nextHydration).toBeDefined(); + if ( + timing.instrumentation === undefined || + timing.hydration === undefined || + timing.nextHydration === undefined + ) { throw new Error("Instrumentation or hydration timing marker was not recorded"); } expect(timing.instrumentation).toBeLessThan(timing.hydration); + expect(timing.nextHydration).toBe(timing.hydration); await expect(page.locator("#app-with-src-home")).toBeVisible(); }); 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(/]*\btype="module"[^>]*\bsrc="\/assets\/[^"]+\.js"[^>]*>/); }); test("counter becomes interactive after hydration", async ({ page }) => { diff --git a/tests/edge-blob-assets.test.ts b/tests/edge-blob-assets.test.ts new file mode 100644 index 000000000..61ed20965 --- /dev/null +++ b/tests/edge-blob-assets.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; +import { transformEdgeBlobAssetUrls } from "../packages/vinext/src/plugins/edge-blob-assets.js"; + +describe("edge blob assets", () => { + it("inlines local import.meta.url asset URLs as fetchable data URLs", async () => { + // Ported from Next.js: test/e2e/edge-compiler-can-import-blob-assets/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/edge-compiler-can-import-blob-assets/index.test.ts + const transformed = await transformEdgeBlobAssetUrls( + `const url = new URL("../../src/text-file.txt", import.meta.url); return fetch(url);`, + "/app/pages/api/edge.js", + async () => Buffer.from("Hello, from text-file.txt!"), + ); + + expect(transformed).toContain('new URL("data:text/plain;base64,'); + expect(transformed).toContain(Buffer.from("Hello, from text-file.txt!").toString("base64")); + }); + + it("leaves remote URLs untouched", async () => { + const source = `const url = new URL("https://example.vercel.sh"); return fetch(url);`; + await expect(transformEdgeBlobAssetUrls(source, "/app/pages/api/edge.js")).resolves.toBeNull(); + }); +}); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 5fbbac7fc..e5d423d4d 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -332,7 +332,7 @@ describe("Pages Router entry templates", () => { it("server entry seeds the main Pages Router unified context with executionContext", async () => { const code = await getVirtualModuleCode("virtual:vinext-server-entry"); const renderPageIndex = code.indexOf( - "async function _renderPage(request, url, manifest, middlewareHeaders) {", + "async function _renderPage(request, url, manifest, middlewareHeaders", ); const unifiedCtxIndex = code.indexOf("const __uCtx = _createUnifiedCtx({", renderPageIndex); @@ -361,7 +361,7 @@ describe("Pages Router entry templates", () => { const code = await getVirtualModuleCode("virtual:vinext-server-entry"); expect(code).toContain("renderPagesPageResponse as __renderPagesPageResponse"); - expect(code).toContain("return __renderPagesPageResponse({"); + expect(code).toContain("__renderPagesPageResponse({"); expect(code).not.toContain('var BODY_MARKER = "";'); expect(code).not.toContain("var compositeStream = new ReadableStream({"); }); diff --git a/tests/features.test.ts b/tests/features.test.ts index 29d639dca..65812f973 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -3702,6 +3702,39 @@ describe("host header poisoning prevention", () => { }); }); +// --------------------------------------------------------------------------- +// Pages Router data request normalization +// --------------------------------------------------------------------------- + +describe("Pages Router data request normalization", () => { + // Ported from Next.js: test/e2e/middleware-general/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts + it("maps _next/data URLs to page URLs before middleware/routing", async () => { + const { normalizePagesDataRequestPageUrl, parsePagesDataRequest } = + await import("../packages/vinext/src/server/prod-server.js"); + + expect(parsePagesDataRequest("/_next/data/build-1/ssr-page.json", "", "build-1")).toEqual({ + localePrefix: "", + pageUrl: "/ssr-page", + }); + expect( + parsePagesDataRequest("/_next/data/build-1/en/send-url.json", "?foo=1", "build-1", { + locales: ["en", "fr"], + defaultLocale: "en", + }), + ).toEqual({ + localePrefix: "/en", + pageUrl: "/send-url?foo=1", + }); + expect(normalizePagesDataRequestPageUrl({ localePrefix: "/de", pageUrl: "/" }, false)).toBe( + "/de", + ); + expect( + normalizePagesDataRequestPageUrl({ localePrefix: "/en", pageUrl: "/send-url?foo=1" }, false), + ).toBe("/en/send-url?foo=1"); + }); +}); + // --------------------------------------------------------------------------- // X-Forwarded-Proto trust proxy gating // --------------------------------------------------------------------------- diff --git a/tests/form.test.ts b/tests/form.test.ts index 7441de141..b5e6bfd15 100644 --- a/tests/form.test.ts +++ b/tests/form.test.ts @@ -93,11 +93,22 @@ function renderClientForm(props: Record) { }; } -function createWindowStub() { +function createWindowStub( + location: Partial<{ + origin: string; + href: string; + pathname: string; + search: string; + hash: string; + hostname: string; + }> = {}, +) { const navigate = vi.fn(async () => {}); const pushState = vi.fn(); const replaceState = vi.fn(); const scrollTo = vi.fn(); + const href = location.href ?? "http://localhost:3000/current"; + const url = new URL(href); return { navigate, @@ -112,12 +123,12 @@ function createWindowStub() { state: null, }, location: { - origin: "http://localhost:3000", - href: "http://localhost:3000/current", - pathname: "/current", - search: "", - hash: "", - hostname: "localhost", + origin: location.origin ?? url.origin, + href, + pathname: location.pathname ?? url.pathname, + search: location.search ?? url.search, + hash: location.hash ?? url.hash, + hostname: location.hostname ?? url.hostname, }, scrollTo, scrollX: 0, @@ -147,8 +158,14 @@ function createSubmitEvent({ return event; } -function installClientGlobals({ supportsSubmitter }: { supportsSubmitter: boolean }) { - const windowStub = createWindowStub(); +function installClientGlobals({ + location, + supportsSubmitter, +}: { + location?: Parameters[0]; + supportsSubmitter: boolean; +}) { + const windowStub = createWindowStub(location); vi.stubGlobal("window", windowStub.window); vi.stubGlobal("Element", FakeElement); vi.stubGlobal("HTMLButtonElement", FakeButtonElement); @@ -236,6 +253,29 @@ describe("Form SSR rendering", () => { // No explicit method attribute in HTML — browser defaults to GET expect(html).toContain('action="/search"'); }); + + it("adds basePath to the rendered action", () => { + // Based on Next.js: test/e2e/next-form/basepath/next-form-basepath.test.ts + const originalBasePath = process.env.__NEXT_ROUTER_BASEPATH; + process.env.__NEXT_ROUTER_BASEPATH = "/base"; + + try { + const html = ReactDOMServer.renderToString( + React.createElement( + Form, + { action: "/search" }, + React.createElement("input", { name: "q", type: "text" }), + ), + ); + expect(html).toContain('action="/base/search"'); + } finally { + if (originalBasePath === undefined) { + delete process.env.__NEXT_ROUTER_BASEPATH; + } else { + process.env.__NEXT_ROUTER_BASEPATH = originalBasePath; + } + } + }); }); // ─── useActionState re-export ─────────────────────────────────────────── @@ -262,18 +302,41 @@ describe("Form client GET interception", () => { ' received an `action` that contains search params: "/search?lang=en". This is not supported, and they will be ignored. If you need to pass in additional search params, use an `` instead.', ); expect(event.preventDefault).toHaveBeenCalledOnce(); - // navigateClientSide delegates URL push to __VINEXT_RSC_NAVIGATE__ (two-phase commit) + // Ported from Next.js: test/e2e/next-form/default/next-form-prefetch.test.ts + // Default App Router forms use the prefetched loading-state transition. expect(navigate).toHaveBeenCalledWith( "/search?q=react", 0, "navigate", "push", undefined, + true, false, ); expect(scrollTo).toHaveBeenCalledWith(0, 0); }); + it("preserves non-prefetched navigation when prefetch is false", async () => { + const { navigate } = installClientGlobals({ supportsSubmitter: true }); + const { onSubmit } = renderClientForm({ action: "/search", prefetch: false }); + const event = createSubmitEvent({ + entries: [["q", "react"]], + }); + + await onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith( + "/search?q=react", + 0, + "navigate", + "push", + undefined, + false, + true, + ); + }); + it("honors submitter formAction, formMethod, and submitter name/value", async () => { const { navigate } = installClientGlobals({ supportsSubmitter: true }); const { onSubmit } = renderClientForm({ action: "/search", method: "POST" }); @@ -302,10 +365,53 @@ describe("Form client GET interception", () => { "navigate", "push", undefined, + true, false, ); }); + it("does not double-prefix a basePath included in submitter formAction", async () => { + // Based on Next.js: test/e2e/next-form/basepath/next-form-basepath.test.ts + const originalBasePath = process.env.__NEXT_ROUTER_BASEPATH; + process.env.__NEXT_ROUTER_BASEPATH = "/base"; + const { navigate } = installClientGlobals({ + location: { href: "http://localhost:3000/base/forms/button-formaction" }, + supportsSubmitter: true, + }); + + try { + const { onSubmit } = renderClientForm({ action: "/" }); + const submitter = new FakeButtonElement({ + attributes: { + formaction: "/base/search", + }, + }); + const event = createSubmitEvent({ + entries: [["query", "my search"]], + submitter, + }); + + await onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith( + "/base/search?query=my+search", + 0, + "navigate", + "push", + undefined, + true, + false, + ); + } finally { + if (originalBasePath === undefined) { + delete process.env.__NEXT_ROUTER_BASEPATH; + } else { + process.env.__NEXT_ROUTER_BASEPATH = originalBasePath; + } + } + }); + it("falls back to appending submitter name/value when FormData submitter overload is unavailable", async () => { const { navigate } = installClientGlobals({ supportsSubmitter: false }); const { onSubmit } = renderClientForm({ action: "/search" }); @@ -332,6 +438,7 @@ describe("Form client GET interception", () => { "navigate", "push", undefined, + true, false, ); }); @@ -376,6 +483,7 @@ describe("Form client GET interception", () => { "navigate", "push", undefined, + true, false, ); }); diff --git a/tests/head.test.ts b/tests/head.test.ts index 36df52a83..a2351300f 100644 --- a/tests/head.test.ts +++ b/tests/head.test.ts @@ -60,7 +60,7 @@ describe("Head SSR collection", () => { expect(headHtml).toContain(""); - expect(headHtml).toContain('data-vinext-head="true"'); + expect(headHtml).toContain('data-next-head=""'); }); it("collects meta elements as self-closing", () => { diff --git a/tests/import-meta-url.test.ts b/tests/import-meta-url.test.ts new file mode 100644 index 000000000..1bb631239 --- /dev/null +++ b/tests/import-meta-url.test.ts @@ -0,0 +1,45 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; +import { transformNextImportMetaUrl } from "../packages/vinext/src/plugins/import-meta-url.js"; + +describe("import.meta.url transform", () => { + it("rewrites server import.meta.url to the source file URL", () => { + // Ported from Next.js: test/e2e/import-meta/import-meta.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/import-meta/import-meta.test.ts + const root = "/repo/app"; + const id = path.join(root, "pages/index.tsx"); + const result = transformNextImportMetaUrl("export const url = import.meta.url;", id, { + environmentName: "ssr", + root, + }); + + expect(result?.code).toContain(JSON.stringify(pathToFileURL(id).href)); + }); + + it("rewrites client import.meta.url with the Turbopack ROOT placeholder", () => { + // Ported from Next.js: test/e2e/import-meta/import-meta.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/import-meta/import-meta.test.ts + const root = "/repo/app"; + const id = path.join(root, "pages/index.tsx"); + const result = transformNextImportMetaUrl("export const url = import.meta.url;", id, { + environmentName: "client", + root, + turbopackRootPlaceholder: true, + }); + + expect(result?.code).toContain('"file:///ROOT/pages/index.tsx"'); + }); + + it("does not rewrite import.meta.url used as a new URL asset base", () => { + const root = "/repo/app"; + const code = 'export const asset = new URL("./asset.txt", import.meta.url);'; + const result = transformNextImportMetaUrl(code, path.join(root, "pages/index.tsx"), { + environmentName: "client", + root, + turbopackRootPlaceholder: true, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/link.test.ts b/tests/link.test.ts index 6a1cd42d1..e436375d3 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -18,7 +18,12 @@ import ReactDOMServer from "react-dom/server"; import Link, { useLinkStatus } from "../packages/vinext/src/shims/link.js"; // Internal helpers re-exported or accessible via the router shim -import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/router.js"; +import { + isExternalUrl, + isHashOnlyChange, + setSSRContext, + wrapWithRouterContext, +} from "../packages/vinext/src/shims/router.js"; // Import server-only i18n state to register ALS-backed accessors before any // rendering occurs (same as dev-server.ts and pages-server-entry.ts do). @@ -26,6 +31,7 @@ import { runWithI18nState } from "../packages/vinext/src/shims/i18n-state.js"; import { setI18nContext } from "../packages/vinext/src/shims/i18n-context.js"; import { + normalizeLocalTrailingSlashHref, resolveRelativeHref, toBrowserNavigationHref, toSameOriginAppPath, @@ -70,6 +76,31 @@ describe("Link rendering", () => { expect(html).toContain('href="/?tab=settings"'); }); + it("resolves object href with only query against the current router path", () => { + // Based on Next.js: test/e2e/middleware-shallow-link/index.test.ts + setSSRContext({ + pathname: "/page2", + query: {}, + asPath: "/page2", + }); + + try { + const html = ReactDOMServer.renderToString( + wrapWithRouterContext( + React.createElement( + Link, + { href: { query: { params: "testParams" } }, shallow: true }, + "Shallow replace", + ), + ), + ); + + expect(html).toContain('href="/page2?params=testParams"'); + } finally { + setSSRContext(null); + } + }); + it("renders with as prop overriding href", () => { // Legacy pattern: href is the route pattern, as is the actual URL const html = ReactDOMServer.renderToString( @@ -117,6 +148,73 @@ describe("Link rendering", () => { expect(html).toContain("Nested child"); expect(html).toContain('href="/nested"'); }); + + it("legacyBehavior wraps primitive children in an anchor", () => { + const textHtml = ReactDOMServer.renderToString( + React.createElement(Link, { href: "/about", legacyBehavior: true } as any, "About"), + ); + expect(textHtml).toContain('About'); + + const numberHtml = ReactDOMServer.renderToString( + React.createElement(Link, { href: "/about", legacyBehavior: true } as any, 1000), + ); + expect(numberHtml).toContain('1000'); + }); + + it("legacyBehavior clones anchor children instead of nesting anchors", () => { + const html = ReactDOMServer.renderToString( + React.createElement( + Link, + { href: "/about", legacyBehavior: true } as any, + React.createElement("a", null, "About"), + ), + ); + + expect(html).toContain('About'); + expect(html).not.toContain(" { + function CustomComponent(props: React.AnchorHTMLAttributes) { + return React.createElement("a", props); + } + + const html = ReactDOMServer.renderToString( + React.createElement( + Link, + { href: "/about", legacyBehavior: true, passHref: true } as any, + React.createElement(CustomComponent, null, "About"), + ), + ); + + expect(html).toContain('About'); + expect(html.match(/ { + // Ported from Next.js: test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + setSSRContext({ + pathname: "/my/path/[name]", + query: { name: "name" }, + asPath: "/my/path/name", + }); + + try { + ReactDOMServer.renderToString( + wrapWithRouterContext(React.createElement(Link, { href: "/hello//world" }, "Hello")), + ); + + expect(errorSpy).toHaveBeenCalledWith( + "Invalid href '/hello//world' passed to next/router in page: '/my/path/[name]'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.", + ); + } finally { + setSSRContext(null); + errorSpy.mockRestore(); + } + }); }); // ─── useLinkStatus ────────────────────────────────────────────────────── @@ -290,11 +388,95 @@ describe("Link locale handling", () => { expect(html).toContain('href="/about"'); }); - it("locale=undefined keeps href as-is", () => { + it("locale=undefined keeps href as-is without active i18n locale", () => { const html = ReactDOMServer.renderToString(React.createElement(Link, { href: "/about" }, "x")); expect(html).toContain('href="/about"'); }); + // Ported from Next.js: test/e2e/i18n-navigations-middleware/i18n-navigations-middleware.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-navigations-middleware/i18n-navigations-middleware.test.ts + it("locale=undefined uses the active i18n locale", async () => { + delete (globalThis as any).window; + + const html = await runWithI18nState(async () => { + setI18nContext({ + locale: "de", + defaultLocale: "default", + locales: ["default", "en", "de"], + }); + + return ReactDOMServer.renderToString( + React.createElement(Link, { href: "/dynamic/1" }, "Dynamic 1"), + ); + }); + + expect(html).toContain('href="/de/dynamic/1"'); + }); + + it("locale=undefined keeps default-locale links unprefixed from non-locale-prefixed i18n paths", async () => { + // Ported from Next.js: test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts + delete (globalThis as any).window; + + const html = await runWithI18nState(async () => { + setI18nContext({ + locale: "id", + defaultLocale: "en", + locales: ["en", "id"], + }); + setSSRContext({ + pathname: "/new", + query: {}, + asPath: "/new", + locale: "id", + defaultLocale: "en", + locales: ["en", "id"], + }); + + try { + return ReactDOMServer.renderToString( + wrapWithRouterContext(React.createElement(Link, { href: "/" }, "Index")), + ); + } finally { + setSSRContext(null); + } + }); + + expect(html).toContain('href="/"'); + }); + + it("locale=undefined keeps active locale links prefixed from locale-prefixed i18n paths", async () => { + // Ported from Next.js: test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts + delete (globalThis as any).window; + + const html = await runWithI18nState(async () => { + setI18nContext({ + locale: "id", + defaultLocale: "en", + locales: ["en", "id"], + }); + setSSRContext({ + pathname: "/new", + query: {}, + asPath: "/id/new", + locale: "id", + defaultLocale: "en", + locales: ["en", "id"], + }); + + try { + return ReactDOMServer.renderToString( + wrapWithRouterContext(React.createElement(Link, { href: "/" }, "Index")), + ); + } finally { + setSSRContext(null); + } + }); + + expect(html).toContain('href="/id"'); + }); + it("locale string prepends locale prefix", () => { // When locale is a non-default locale string, it prepends /{locale} // Note: default locale check uses __VINEXT_DEFAULT_LOCALE__ which is undefined in tests @@ -655,6 +837,24 @@ describe("toBrowserNavigationHref", () => { }); }); +describe("normalizeLocalTrailingSlashHref", () => { + it("strips trailing slash before query when trailingSlash is false", () => { + expect(normalizeLocalTrailingSlashHref("/about/?hello=world", false)).toBe( + "/about?hello=world", + ); + }); + + it("adds trailing slash before query when trailingSlash is true", () => { + expect(normalizeLocalTrailingSlashHref("/about?hello=world", true)).toBe("/about/?hello=world"); + }); + + it("strips trailing slash for file-like paths even when trailingSlash is true", () => { + expect(normalizeLocalTrailingSlashHref("/catch-all/hello.world/", true)).toBe( + "/catch-all/hello.world", + ); + }); +}); + // ─── Link with same-origin absolute URL (SSR rendering) ───────────────── // Verifies that renders the absolute URL as the // href attribute (the normalization happens at click time, not render time). diff --git a/tests/middleware.test.ts b/tests/middleware.test.ts new file mode 100644 index 000000000..869c6b2bb --- /dev/null +++ b/tests/middleware.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ModuleRunner } from "vite/module-runner"; +import { runMiddleware } from "../packages/vinext/src/server/middleware.js"; +import { NextRequest, NextResponse } from "../packages/vinext/src/shims/server.js"; + +function createRunner(moduleExports: Record): ModuleRunner { + return { + async import() { + return moduleExports; + }, + } as unknown as ModuleRunner; +} + +describe("middleware runner", () => { + it("preserves cross-origin rewrite URLs for proxying", async () => { + const result = await runMiddleware( + createRunner({ + middleware() { + return NextResponse.rewrite("https://example.com/external?from=middleware"); + }, + }), + "/fixture/middleware.ts", + new Request("http://localhost/_next/static/chunks/pages/_app-non-existent.js"), + ); + + expect(result).toMatchObject({ + continue: true, + rewriteUrl: "https://example.com/external?from=middleware", + }); + }); + + it("normalizes same-origin rewrite URLs to path and search", async () => { + const result = await runMiddleware( + createRunner({ + middleware(request: NextRequest) { + return NextResponse.rewrite(new URL("/target?from=middleware", request.url)); + }, + }), + "/fixture/middleware.ts", + new Request("http://localhost/original?keep=1"), + ); + + expect(result).toMatchObject({ + continue: true, + rewriteUrl: "/target?from=middleware", + }); + }); + + it("exposes an empty NextURL basePath for unprefixed middleware requests", async () => { + // Ported from Next.js: test/e2e/middleware-base-path/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-base-path/test/index.test.ts + let seenBasePath: string | undefined; + const result = await runMiddleware( + createRunner({ + middleware(request: NextRequest) { + seenBasePath = request.nextUrl.basePath; + request.nextUrl.basePath = "/root"; + return NextResponse.redirect(request.nextUrl as unknown as URL); + }, + }), + "/fixture/middleware.ts", + new Request("http://localhost/redirect-with-basepath"), + undefined, + "", + ); + + expect(seenBasePath).toBe(""); + expect(result).toMatchObject({ + continue: false, + redirectUrl: "http://localhost/root/redirect-with-basepath", + redirectStatus: 307, + }); + }); + + it("preserves NextURL basePath for stripped middleware requests that originally had it", async () => { + let seenBasePath: string | undefined; + const result = await runMiddleware( + createRunner({ + middleware(request: NextRequest) { + seenBasePath = request.nextUrl.basePath; + request.nextUrl.pathname = "/about"; + return NextResponse.rewrite(request.nextUrl as unknown as URL); + }, + }), + "/fixture/middleware.ts", + new Request("http://localhost/redirect-with-basepath"), + undefined, + "/root", + ); + + expect(seenBasePath).toBe("/root"); + expect(result).toMatchObject({ + continue: true, + rewriteUrl: "/about", + }); + }); +}); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 115ca236b..bbc6884bc 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -87,6 +87,45 @@ describe("loadNextConfig phase argument", () => { }); }); +describe("loadNextConfig deprecated option warnings", () => { + it("warns for explicitly configured Next.js proxy rename deprecations", async () => { + // Ported from Next.js: test/e2e/deprecation-warnings/deprecation-warnings.test.ts + const tmpDir = makeTempDir(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + fs.writeFileSync( + path.join(tmpDir, "next.config.js"), + `module.exports = { + skipMiddlewareUrlNormalize: true, + experimental: { + instrumentationHook: true, + middlewarePrefetch: "strict", + middlewareClientMaxBodySize: "5mb", + externalMiddlewareRewritesResolve: true, + }, + };\n`, + ); + + await loadNextConfig(tmpDir, PHASE_PRODUCTION_BUILD); + + const messages = warn.mock.calls.map((call) => String(call[0])).join("\n"); + expect(messages).toContain("experimental.instrumentationHook"); + expect(messages).toContain("no longer needed"); + expect(messages).toContain("experimental.middlewarePrefetch"); + expect(messages).toContain("Please use `experimental.proxyPrefetch` instead"); + expect(messages).toContain("experimental.middlewareClientMaxBodySize"); + expect(messages).toContain("Please use `experimental.proxyClientMaxBodySize` instead"); + expect(messages).toContain("experimental.externalMiddlewareRewritesResolve"); + expect(messages).toContain("Please use `experimental.externalProxyRewritesResolve` instead"); + expect(messages).toContain("skipMiddlewareUrlNormalize"); + expect(messages).toContain("Please use `skipProxyUrlNormalize` instead"); + } finally { + warn.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + describe("resolveNextConfig alias extraction", () => { let tmpDir: string; @@ -176,6 +215,29 @@ module.exports = withPlugin({ basePath: "/wrapped" });`, expect(config.aliases["wrapped/config"]).toBe(path.join(tmpDir, "turbopack", "request.ts")); }); + it("preserves bare package alias targets from turbopack and webpack config", async () => { + tmpDir = makeTempDir(); + + const rawConfig = { + turbopack: { + resolveAlias: { + "preact/compat": "react", + }, + }, + webpack(webpackConfig: any) { + webpackConfig.resolve = webpackConfig.resolve || {}; + webpackConfig.resolve.alias = webpackConfig.resolve.alias || {}; + webpackConfig.resolve.alias["scheduler/tracing"] = "scheduler"; + return webpackConfig; + }, + }; + + const config = await resolveNextConfig(rawConfig, tmpDir); + + expect(config.aliases["preact/compat"]).toBe("react"); + expect(config.aliases["scheduler/tracing"]).toBe("scheduler"); + }); + it("does not attribute turbopack aliases to webpack support warnings", async () => { tmpDir = makeTempDir(); @@ -398,6 +460,43 @@ describe("resolveNextConfig serverActionsBodySizeLimit", () => { }); }); +describe("resolveNextConfig experimental.scrollRestoration", () => { + // Ported from Next.js: test/e2e/reload-scroll-backforward-restoration/next.config.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/reload-scroll-backforward-restoration/next.config.js + it("defaults to disabled", async () => { + const resolved = await resolveNextConfig(null); + expect(resolved.experimentalScrollRestoration).toBe(false); + }); + + it("reads experimental.scrollRestoration", async () => { + const resolved = await resolveNextConfig({ + experimental: { + scrollRestoration: true, + }, + }); + expect(resolved.experimentalScrollRestoration).toBe(true); + }); +}); + +describe("resolveNextConfig experimental.clientTraceMetadata", () => { + // Ported from Next.js: test/e2e/opentelemetry/client-trace-metadata/next.config.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/opentelemetry/client-trace-metadata/next.config.js + it("defaults to disabled", async () => { + const resolved = await resolveNextConfig(null); + expect(resolved.clientTraceMetadata).toBeUndefined(); + }); + + it("reads string metadata keys", async () => { + const resolved = await resolveNextConfig({ + experimental: { + clientTraceMetadata: ["my-test-key-1", 42, "my-test-key-2"], + }, + }); + + expect(resolved.clientTraceMetadata).toEqual(["my-test-key-1", "my-test-key-2"]); + }); +}); + describe("detectNextIntlConfig", () => { let tmpDir: string; @@ -411,6 +510,7 @@ describe("detectNextIntlConfig", () => { return { env: {}, basePath: "", + assetPrefix: "", trailingSlash: false, output: "", pageExtensions: ["tsx", "ts", "jsx", "js"], @@ -420,13 +520,21 @@ describe("detectNextIntlConfig", () => { 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: "test-build-id", ...overrides, }; diff --git a/tests/next-static-compat.test.ts b/tests/next-static-compat.test.ts new file mode 100644 index 000000000..438762bfd --- /dev/null +++ b/tests/next-static-compat.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + getNextStaticAssetLookupPath, + isNextStaticAssetPath, + writeNextStaticCompatAssets, +} from "../packages/vinext/src/server/next-static-compat.js"; +import { StaticFileCache } from "../packages/vinext/src/server/static-file-cache.js"; + +describe("Next static asset compatibility", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.map((dir) => fsp.rm(dir, { recursive: true, force: true }))); + tempDirs.length = 0; + }); + + async function makeClientDir(): Promise { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-next-static-")); + tempDirs.push(dir); + return dir; + } + + it("identifies _next/static asset requests", () => { + expect(isNextStaticAssetPath("/_next/static/build/_buildManifest.js")).toBe(true); + expect(isNextStaticAssetPath("/_next/static/invalid-path")).toBe(true); + expect(isNextStaticAssetPath("/_next/data/build/index.json")).toBe(false); + expect(isNextStaticAssetPath("/assets/app.js")).toBe(false); + }); + + it("maps path assetPrefix _next/static requests to the emitted lookup path", () => { + // Ported from Next.js: test/e2e/invalid-static-asset-404-pages/invalid-static-asset-404-pages-asset-prefix.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/invalid-static-asset-404-pages/invalid-static-asset-404-pages-asset-prefix.test.ts + expect( + getNextStaticAssetLookupPath("/assets/_next/static/build/_buildManifest.js", "/assets"), + ).toBe("/_next/static/build/_buildManifest.js"); + expect(getNextStaticAssetLookupPath("/assets/_next/static/invalid-path", "/assets/")).toBe( + "/_next/static/invalid-path", + ); + expect(getNextStaticAssetLookupPath("/assets/app.js", "/assets")).toBe("/assets/app.js"); + expect(getNextStaticAssetLookupPath("/_next/static/invalid-path", "/assets")).toBe( + "/_next/static/invalid-path", + ); + }); + + it("emits a static _buildManifest.js file under the build id", async () => { + const clientDir = await makeClientDir(); + + writeNextStaticCompatAssets(clientDir, "build-123"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/_next/static/build-123/_buildManifest.js"); + expect(entry).toBeDefined(); + expect(entry!.original.headers["Content-Type"]).toBe("application/javascript"); + expect(entry!.original.buffer?.toString("utf-8")).toContain("__BUILD_MANIFEST"); + }); +}); diff --git a/tests/og-inline.test.ts b/tests/og-inline.test.ts index 532f43e77..9baec34c2 100644 --- a/tests/og-inline.test.ts +++ b/tests/og-inline.test.ts @@ -31,10 +31,15 @@ function createOgInlinePlugin(command: "serve" | "build" = "serve"): Plugin { let tmpDir: string; const fontContent = Buffer.from("fake-font-data-for-testing"); const fontBase64 = fontContent.toString("base64"); +const nestedFontContent = Buffer.from("fake-nested-font-data"); +const nestedFontBase64 = nestedFontContent.toString("base64"); beforeAll(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "og-inline-test-")); await fsp.writeFile(path.join(tmpDir, "noto-sans.ttf"), fontContent); + await fsp.mkdir(path.join(tmpDir, "assets"), { recursive: true }); + await fsp.mkdir(path.join(tmpDir, "app", "app", "og"), { recursive: true }); + await fsp.writeFile(path.join(tmpDir, "assets", "typewr__.ttf"), nestedFontContent); }); afterAll(async () => { @@ -77,6 +82,20 @@ describe("vinext:og-inline-fetch-assets plugin", () => { expect(result.code).not.toContain("fetch("); }); + it("transforms parent-directory fetch asset references", async () => { + // Ported from Next.js: test/e2e/og-routes-custom-font/app/app/og/route.js + const plugin = createOgInlinePlugin(); + const transform = unwrapHook(plugin.transform); + const code = `const font = fetch(new URL("../../../assets/typewr__.ttf", import.meta.url)).then((res) => res.arrayBuffer());`; + const moduleId = path.join(tmpDir, "app", "app", "og", "route.js"); + + const result = await transform.call(plugin, code, moduleId); + expect(result).not.toBeNull(); + expect(result.code).toContain(JSON.stringify(nestedFontBase64)); + expect(result.code).toContain("Promise.resolve(a.buffer)"); + expect(result.code).not.toContain("fetch("); + }); + // ── Pattern 2: readFileSync ────────────────────────────── it("transforms fs.readFileSync(fileURLToPath(new URL(..., import.meta.url)))", async () => { diff --git a/tests/pages-api-route.test.ts b/tests/pages-api-route.test.ts index 2c0b67d44..ccc4dae68 100644 --- a/tests/pages-api-route.test.ts +++ b/tests/pages-api-route.test.ts @@ -1,18 +1,31 @@ +import { Transform } from "node:stream"; import { describe, expect, it, vi } from "vite-plus/test"; import { handlePagesApiRoute, type PagesApiRouteMatch, } from "../packages/vinext/src/server/pages-api-route.js"; +import type { + PagesReqResRequest, + PagesReqResResponse, +} from "../packages/vinext/src/server/pages-node-compat.js"; +import type { NextRequest } from "../packages/vinext/src/shims/server.js"; + +type TestPagesApiHandler = ( + req: PagesReqResRequest, + res: PagesReqResResponse, +) => unknown | Promise; function createMatch( - handler: PagesApiRouteMatch["route"]["module"]["default"], + handler: TestPagesApiHandler, params: Record = {}, + moduleOverrides: Partial = {}, ): PagesApiRouteMatch { return { params, route: { pattern: "/api/test", module: { + ...moduleOverrides, default: handler, }, }, @@ -39,6 +52,181 @@ describe("pages api route", () => { }); }); + it("calls edge runtime Pages API routes with NextRequest", async () => { + // Ported from Next.js deploy fixture: + // test/e2e/middleware-general/app/pages/api/edge-search-params.js + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/edge-search-params", + module: { + config: { runtime: "edge" }, + default(req: NextRequest) { + return Response.json(Object.fromEntries((req as any).nextUrl.searchParams)); + }, + }, + }, + }, + request: new Request("https://example.com/api/edge-search-params?hello=world"), + url: "/api/edge-search-params?hello=world&foo=bar", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ foo: "bar", hello: "world" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('config.runtime = "edge"')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("https://nextjs.org/blog/next-16")); + warn.mockRestore(); + }); + + it("adds dynamic params to edge runtime Pages API nextUrl search params", async () => { + // Ported from Next.js: test/e2e/edge-pages-support/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: { id: "id-1" }, + route: { + pattern: "/api/[id]", + module: { + config: { runtime: "edge" }, + default(req: NextRequest) { + return Response.json(Object.fromEntries((req as any).nextUrl.searchParams)); + }, + }, + }, + }, + request: new Request("https://example.com/api/id-1?a=b"), + url: "/api/id-1?a=b", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ a: "b", id: "id-1" }); + warn.mockRestore(); + }); + + it("exposes AsyncLocalStorage as an edge runtime global", async () => { + // Ported from Next.js: test/e2e/edge-async-local-storage/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/edge-async-local-storage/index.test.ts + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "AsyncLocalStorage"); + Reflect.deleteProperty(globalThis, "AsyncLocalStorage"); + + try { + const { installEdgeRuntimeGlobals } = + await import("../packages/vinext/src/server/edge-runtime-globals.js"); + installEdgeRuntimeGlobals(); + + const AsyncLocalStorageGlobal = ( + globalThis as typeof globalThis & { + AsyncLocalStorage: new () => { + getStore(): T | undefined; + run(store: T, callback: () => R): R; + }; + } + ).AsyncLocalStorage; + const storage = new AsyncLocalStorageGlobal<{ id: string }>(); + + await storage.run({ id: "req-1" }, async () => { + await Promise.resolve(); + expect(storage.getStore()).toEqual({ id: "req-1" }); + }); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "AsyncLocalStorage", descriptor); + } else { + Reflect.deleteProperty(globalThis, "AsyncLocalStorage"); + } + } + }); + + it("recognizes top-level runtime = edge exports", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/top-level-edge", + module: { + runtime: "edge", + default() { + return Response.json({ ok: true }); + }, + }, + }, + }, + request: new Request("https://example.com/api/top-level-edge"), + url: "/api/top-level-edge", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + warn.mockRestore(); + }); + + it("treats config.runtime = experimental-edge as an edge-style Pages API route", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/experimental-edge", + module: { + config: { runtime: "experimental-edge" }, + default() { + return Response.json({ ok: true }); + }, + }, + }, + }, + request: new Request("https://example.com/api/experimental-edge"), + url: "/api/experimental-edge", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + warn.mockRestore(); + }); + + it("strips encoded body headers from edge runtime Pages API responses", async () => { + // Ported from Next.js: test/e2e/edge-compiler-can-import-blob-assets/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/edge-compiler-can-import-blob-assets/index.test.ts + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/edge", + module: { + config: { runtime: "edge" }, + default() { + return new Response("Example Domain", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/html; charset=utf-8", + }, + }); + }, + }, + }, + }, + request: new Request("https://example.com/api/edge"), + url: "/api/edge", + }); + + expect(response.headers.has("content-encoding")).toBe(false); + expect(response.headers.has("content-length")).toBe(false); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + await expect(response.text()).resolves.toContain("Example Domain"); + warn.mockRestore(); + }); + it("returns 400 with an Invalid JSON statusText for malformed JSON bodies", async () => { const response = await handlePagesApiRoute({ match: createMatch((req, res) => { @@ -137,6 +325,84 @@ describe("pages api route", () => { await expect(response.text()).resolves.toBe("Request body too large"); }); + it("honors route-level bodyParser sizeLimit config", async () => { + const response = await handlePagesApiRoute({ + match: createMatch( + (_req, res) => { + res.status(200).json({ ok: true }); + }, + {}, + { config: { api: { bodyParser: { sizeLimit: "5kb" } } } }, + ), + request: new Request("https://example.com/api/parse", { + method: "POST", + headers: { + "content-length": String(5 * 1024 + 1), + "content-type": "text/plain", + }, + body: "x", + }), + url: "/api/parse", + }); + + expect(response.status).toBe(413); + await expect(response.text()).resolves.toBe("Request body too large"); + }); + + it("leaves body unparsed and exposes a raw async iterable when bodyParser is false", async () => { + const response = await handlePagesApiRoute({ + match: createMatch( + async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req as AsyncIterable) { + chunks.push(chunk); + } + res.json({ body: req.body, rawBody: Buffer.concat(chunks).toString("utf8") }); + }, + {}, + { config: { api: { bodyParser: false } } }, + ), + request: new Request("https://example.com/api/raw", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "hello raw body", + }), + url: "/api/raw", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ rawBody: "hello raw body" }); + }); + + it("supports piping raw Pages API requests into streamed responses", async () => { + // Ported from Next.js: test/e2e/proxy-request-with-middleware/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/proxy-request-with-middleware/test/index.test.ts + const response = await handlePagesApiRoute({ + match: createMatch( + (req, res) => { + const passthrough = new Transform({ + transform(chunk, _encoding, callback) { + callback(null, chunk); + }, + }); + + return req.pipe(passthrough).pipe(res); + }, + {}, + { config: { api: { bodyParser: false } } }, + ), + request: new Request("https://example.com/api/raw", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"key":"value"}', + }), + url: "/api/raw", + }); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe('{"key":"value"}'); + }); + it("returns 404 when match is null", async () => { const response = await handlePagesApiRoute({ match: null, @@ -189,6 +455,24 @@ describe("pages api route", () => { expect(customRedirectResponse.headers.get("location")).toBe("/permanent"); }); + it("forwards res.revalidate calls to the Pages runtime", async () => { + const onRevalidate = vi.fn(async () => {}); + + const response = await handlePagesApiRoute({ + match: createMatch(async (_req, res) => { + await res.revalidate("/posts/one", { unstable_onlyGenerated: true }); + res.json({ revalidated: true }); + }), + onRevalidate, + request: new Request("https://example.com/api/revalidate"), + url: "/api/revalidate", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ revalidated: true }); + expect(onRevalidate).toHaveBeenCalledWith("/posts/one", { unstable_onlyGenerated: true }); + }); + it("res.writeHead() lowercases header keys and joins array values", async () => { const response = await handlePagesApiRoute({ match: createMatch((_req, res) => { diff --git a/tests/pages-i18n.test.ts b/tests/pages-i18n.test.ts index 3062cfeb1..c4bcd5a3d 100644 --- a/tests/pages-i18n.test.ts +++ b/tests/pages-i18n.test.ts @@ -120,4 +120,25 @@ describe("Pages i18n domain helpers", () => { ).redirectUrl, ).toBe("http://example.fr/?utm=campaign&next=%2Fcheckout"); }); + + it("can skip preferred-locale redirects for Pages data requests", () => { + // Ported from Next.js: test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts + expect( + resolvePagesI18nRequest( + "/", + i18n, + { "accept-language": "fr-FR,fr;q=0.9,en;q=0.8" }, + "example.com", + "", + false, + { skipLocaleRedirect: true }, + ), + ).toMatchObject({ + locale: "en", + url: "/", + hadPrefix: false, + redirectUrl: undefined, + }); + }); }); diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index df32a30e5..e36a64a9e 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -42,6 +42,7 @@ function createOptions( pageModule: {}, params: { slug: "post" }, query: { slug: "post" }, + resolvedUrl: "/posts/post", renderIsrPassToStringAsync: vi.fn(async () => "
fresh-body
"), route: { isDynamic: false }, routePattern: "/posts/[slug]", @@ -114,7 +115,71 @@ describe("pages page data", () => { throw new Error("expected response result"); } expect(result.response.status).toBe(404); - await expect(result.response.text()).resolves.toContain("404 - Page not found"); + const html = await result.response.text(); + expect(html).toContain("404 - Page not found"); + expect(html).toContain("This page could not be found."); + }); + + it("matches string paths returned from getStaticPaths", async () => { + // Ported from Next.js deploy fixture: + // test/e2e/middleware-general/app/pages/ssg-fallback-false/[slug].js + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticPaths() { + return { + fallback: false, + paths: ["/ssg-fallback-false/first", "/ssg-fallback-false/hello"], + }; + }, + async getStaticProps({ params }) { + return { props: { params } }; + }, + }, + params: { slug: "hello" }, + query: { slug: "hello" }, + route: { isDynamic: true }, + routePattern: "/ssg-fallback-false/[slug]", + routeUrl: "/ssg-fallback-false/hello", + }), + ); + + expect(result).toMatchObject({ + kind: "render", + pageProps: { params: { slug: "hello" } }, + }); + }); + + it("returns a notFound signal for HTML when getStaticProps returns notFound", async () => { + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticProps() { + return { notFound: true }; + }, + }, + }), + ); + + expect(result.kind).toBe("notFound"); + }); + + it("returns Next-compatible 404 HTML for data requests when getStaticProps returns notFound", async () => { + const result = await resolvePagesPageData( + createOptions({ + isDataRequest: true, + pageModule: { + async getStaticProps() { + return { notFound: true }; + }, + }, + }), + ); + + expect(result.kind).toBe("response"); + if (result.kind !== "response") throw new Error("expected response result"); + expect(result.response.status).toBe(404); + await expect(result.response.text()).resolves.toContain("This page could not be found."); }); it("short-circuits getServerSideProps responses after res.end()", async () => { @@ -158,10 +223,65 @@ describe("pages page data", () => { await expect(result.response.text()).resolves.toBe('{"ok":true}'); }); + it("passes undefined params to getServerSideProps for non-dynamic pages", async () => { + // Ported from Next.js: test/e2e/edge-pages-support/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getServerSideProps({ params }) { + return { + props: { + params: params ?? null, + }, + }; + }, + }, + params: {}, + query: {}, + route: { isDynamic: false }, + routePattern: "/", + routeUrl: "/", + }), + ); + + expect(result).toMatchObject({ + kind: "render", + pageProps: { params: null }, + }); + }); + + it("awaits promise-valued getServerSideProps props", async () => { + // Ported from Next.js: test/e2e/getserversideprops/app/pages/promise/index.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/getserversideprops/app/pages/promise/index.js + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getServerSideProps() { + return { + props: Promise.resolve({ + text: "promise", + }), + }; + }, + }, + }), + ); + + expect(result).toMatchObject({ + kind: "render", + pageProps: { text: "promise" }, + }); + }); + it("serves stale ISR entries immediately and regenerates them through typed helpers", async () => { let regenPromise: Promise | null = null; const applyRequestContexts = vi.fn(); const isrSet = vi.fn(async () => {}); + const getStaticProps = vi.fn(async () => ({ + props: { title: "fresh" }, + revalidate: 15, + })); const runInFreshUnifiedContext = vi.fn( async (callback: () => Promise): Promise => callback(), ) as ResolvePagesPageDataOptions["runInFreshUnifiedContext"]; @@ -188,12 +308,7 @@ describe("pages page data", () => { }), isrSet, pageModule: { - async getStaticProps() { - return { - props: { title: "fresh" }, - revalidate: 15, - }; - }, + getStaticProps, }, runInFreshUnifiedContext, triggerBackgroundRegeneration, @@ -222,6 +337,9 @@ describe("pages page data", () => { await pendingRegen; expect(runInFreshUnifiedContext).toHaveBeenCalledOnce(); + expect(getStaticProps).toHaveBeenCalledWith( + expect.objectContaining({ revalidateReason: "stale" }), + ); expect(applyRequestContexts).toHaveBeenCalledOnce(); expect(isrSet).toHaveBeenCalledWith( "pages:/posts/post", @@ -234,6 +352,176 @@ describe("pages page data", () => { ); }); + it("passes build revalidateReason to getStaticProps on prerender cache misses", async () => { + const getStaticProps = vi.fn(async ({ revalidateReason }) => ({ + props: { reason: revalidateReason }, + revalidate: 30, + })); + + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + getStaticProps, + }, + revalidateReason: "build", + }), + ); + + expect(getStaticProps).toHaveBeenCalledWith( + expect.objectContaining({ revalidateReason: "build" }), + ); + expect(result).toMatchObject({ + kind: "render", + pageProps: { reason: "build" }, + }); + }); + + it("bypasses a fresh ISR hit for on-demand revalidation", async () => { + const getStaticProps = vi.fn(async ({ revalidateReason }) => ({ + props: { reason: revalidateReason }, + revalidate: 30, + })); + + const result = await resolvePagesPageData( + createOptions({ + isrGet: vi.fn().mockResolvedValue({ + isStale: false, + value: { + lastModified: 1, + cacheState: "hit", + value: { + kind: "PAGES", + html: "cached html", + pageData: { reason: "cached" }, + headers: undefined, + status: undefined, + }, + }, + }), + pageModule: { + getStaticProps, + }, + revalidateReason: "on-demand", + }), + ); + + expect(getStaticProps).toHaveBeenCalledWith( + expect.objectContaining({ revalidateReason: "on-demand" }), + ); + expect(result).toMatchObject({ + kind: "render", + isrRevalidateSeconds: 30, + pageProps: { reason: "on-demand" }, + }); + }); + + it("returns cached page data instead of cached HTML for Pages data requests", async () => { + // Ported from Next.js: test/e2e/prerender.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/prerender.test.ts + const result = await resolvePagesPageData( + createOptions({ + isDataRequest: true, + isrGet: vi.fn().mockResolvedValue({ + isStale: false, + value: { + lastModified: 1, + cacheState: "hit", + value: { + kind: "PAGES", + html: "cached html", + pageData: { post: "post-1" }, + headers: undefined, + status: undefined, + }, + }, + }), + pageModule: { + async getStaticProps() { + return { + props: { post: "fresh" }, + revalidate: 10, + }; + }, + }, + }), + ); + + expect(result).toEqual({ + kind: "render", + gsspRes: null, + isrRevalidateSeconds: null, + pageProps: { post: "post-1" }, + }); + }); + + it("returns a fallback shell result for missing fallback true HTML paths", async () => { + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticPaths() { + return { + fallback: true, + paths: [{ params: { slug: "seeded" } }], + }; + }, + async getStaticProps() { + throw new Error("fallback shell should not call getStaticProps"); + }, + }, + params: { slug: "lazy" }, + query: { slug: "lazy" }, + route: { isDynamic: true }, + routeUrl: "/posts/lazy", + }), + ); + + expect(result).toEqual({ + kind: "render", + gsspRes: null, + isFallback: true, + isrRevalidateSeconds: null, + pageProps: {}, + }); + }); + + it("blocks fallback true HTML paths for crawler requests", async () => { + // Ported from Next.js: test/e2e/prerender-crawler.test.ts + const getStaticProps = vi.fn(async ({ params }) => ({ + props: { slug: params.slug }, + })); + + const result = await resolvePagesPageData( + createOptions({ + isCrawlerRequest: true, + pageModule: { + async getStaticPaths() { + return { + fallback: true, + paths: [{ params: { slug: "seeded" } }], + }; + }, + getStaticProps, + }, + params: { slug: "bot-slug" }, + query: { slug: "bot-slug" }, + route: { isDynamic: true }, + routeUrl: "/posts/bot-slug", + }), + ); + + expect(getStaticProps).toHaveBeenCalledWith( + expect.objectContaining({ + params: { slug: "bot-slug" }, + }), + ); + expect(result).toEqual({ + kind: "render", + gsspRes: null, + isrRevalidateSeconds: null, + pageProps: { slug: "bot-slug" }, + }); + }); + it("returns normalized render data for cache misses", async () => { const result = await resolvePagesPageData( createOptions({ @@ -255,4 +543,84 @@ describe("pages page data", () => { pageProps: { title: "hello" }, }); }); + + it("runs page getInitialProps for Pages SSR routes", async () => { + // Ported from Next.js: test/e2e/streaming-ssr-edge/pages/err/index.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/streaming-ssr-edge/pages/err/index.js + function Page() { + return "page"; + } + Page.getInitialProps = vi.fn(({ pathname, query }) => ({ + pathname, + slug: query.slug, + })); + + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + default: Page, + }, + }), + ); + + expect(Page.getInitialProps).toHaveBeenCalledWith( + expect.objectContaining({ + asPath: "/posts/post", + pathname: "/posts/[slug]", + query: { slug: "post" }, + }), + ); + expect(result).toEqual({ + kind: "render", + gsspRes: null, + isrRevalidateSeconds: null, + pageProps: { + pathname: "/posts/[slug]", + slug: "post", + }, + }); + }); + + it("surfaces page getInitialProps errors to the Pages error renderer", async () => { + // Ported from Next.js: test/e2e/streaming-ssr-edge/streaming-ssr-edge.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/streaming-ssr-edge/streaming-ssr-edge.test.ts + function Page() { + return "page"; + } + Page.getInitialProps = () => { + throw new Error("gip-oops"); + }; + + await expect( + resolvePagesPageData( + createOptions({ + pageModule: { + default: Page, + }, + }), + ), + ).rejects.toThrow("gip-oops"); + }); + + it("treats getStaticProps revalidate false as indefinitely cacheable", async () => { + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticProps() { + return { + props: { title: "static" }, + revalidate: false, + }; + }, + }, + }), + ); + + expect(result).toEqual({ + kind: "render", + gsspRes: null, + isrRevalidateSeconds: 31536000, + pageProps: { title: "static" }, + }); + }); }); diff --git a/tests/pages-page-response.test.ts b/tests/pages-page-response.test.ts index 403e2c89a..7ab439437 100644 --- a/tests/pages-page-response.test.ts +++ b/tests/pages-page-response.test.ts @@ -1,6 +1,16 @@ +import { createHash } from "node:crypto"; import React from "react"; -import { describe, expect, it, vi } from "vite-plus/test"; -import { renderPagesPageResponse } from "../packages/vinext/src/server/pages-page-response.js"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + buildPagesIsrCacheControl, + isPagesHtmlBotUserAgent, + renderPagesPageResponse, +} from "../packages/vinext/src/server/pages-page-response.js"; +import { NextScript } from "../packages/vinext/src/shims/document.js"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); function createStream(chunks: string[]): ReadableStream { return new ReadableStream({ @@ -14,6 +24,13 @@ function createStream(chunks: string[]): ReadableStream { }); } +function createReadyStream( + chunks: string[], + allReady: Promise, +): ReadableStream & { allReady: Promise } { + return Object.assign(createStream(chunks), { allReady }); +} + function createCommonOptions() { const clearSsrContext = vi.fn(); const createPageElement = vi.fn((pageProps: Record) => @@ -37,7 +54,7 @@ function createCommonOptions() { renderIsrPassToStringAsync, renderToReadableStream, options: { - assetTags: '', + assetTags: '', buildId: "build-123", clearSsrContext, createPageElement, @@ -77,6 +94,12 @@ function createCommonOptions() { }; } +function cspHashOf(text: string): string { + const hash = createHash("sha256"); + hash.update(text); + return `'sha256-${hash.digest("base64")}'`; +} + describe("pages page response", () => { it("renders the document shell, merges gSSP headers, and marks streamed HTML responses", async () => { const common = createCommonOptions(); @@ -111,11 +134,92 @@ describe("pages page response", () => { expect(html).toContain(''); expect(html).toContain("window.__NEXT_DATA__"); expect(html).toContain("__VINEXT_LOCALE__"); + // Ported from Next.js: test/e2e/optimized-loading/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/optimized-loading/test/index.test.ts + expect(html).toContain('', + '', scriptNonce: "pages-test-nonce", }); @@ -187,7 +383,7 @@ describe("pages page response", () => { ); expect(html).toContain(' + + + + `); + + expect(transformed).toContain("body { margin: 0 }"); + expect(transformed).toContain("p{color:blue;}"); + }); + + it("normalizes safe CSS whitespace without rewriting values", () => { + expect(minifyStyledJsxCss("p, a { font-family: Test Sans; color: #00f; }")).toBe( + "p,a{font-family:Test Sans;color:#00f;}", + ); + }); +}); diff --git a/tests/swc-helpers.test.ts b/tests/swc-helpers.test.ts new file mode 100644 index 000000000..d7d1f9b9f --- /dev/null +++ b/tests/swc-helpers.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { resolveSwcHelperFromNext } from "../packages/vinext/src/plugins/swc-helpers.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createProject(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-swc-helpers-")); + tempDirs.push(root); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ private: true })); + return root; +} + +describe("SWC helper resolution", () => { + it("resolves helpers nested under next/node_modules", () => { + // Ported from Next.js: test/e2e/handle-non-hoisted-swc-helpers/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts + const root = createProject(); + const nextDir = path.join(root, "node_modules", "next"); + const helperDir = path.join(nextDir, "node_modules", "@swc", "helpers"); + const helperFile = path.join(helperDir, "esm", "_object_spread.js"); + + fs.mkdirSync(path.dirname(helperFile), { recursive: true }); + fs.writeFileSync(path.join(nextDir, "package.json"), JSON.stringify({ name: "next" })); + fs.writeFileSync( + path.join(helperDir, "package.json"), + JSON.stringify({ + name: "@swc/helpers", + exports: { + "./_/_object_spread": "./esm/_object_spread.js", + }, + }), + ); + fs.writeFileSync(helperFile, "export default function objectSpread() {}\n"); + + expect(resolveSwcHelperFromNext(root, "@swc/helpers/_/_object_spread")).toBe( + fs.realpathSync(helperFile), + ); + }); + + it("ignores non-helper specifiers", () => { + const root = createProject(); + + expect(resolveSwcHelperFromNext(root, "react")).toBeNull(); + }); +}); diff --git a/tests/trace-metadata.test.ts b/tests/trace-metadata.test.ts new file mode 100644 index 000000000..2f540376b --- /dev/null +++ b/tests/trace-metadata.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + getClientTraceMetadataHtml, + injectHtmlBeforeHeadClose, +} from "../packages/vinext/src/server/trace-metadata.js"; + +async function readStream(stream: ReadableStream): Promise { + return new Response(stream).text(); +} + +describe("client trace metadata", () => { + it("filters OpenTelemetry propagation entries by clientTraceMetadata", async () => { + // Ported from Next.js: test/e2e/opentelemetry/client-trace-metadata/client-trace-metadata.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/opentelemetry/client-trace-metadata/client-trace-metadata.test.ts + const html = await getClientTraceMetadataHtml( + ["my-parent-span-id", "my-test-key-1", "my-test-key-2"], + async () => ({ + context: { + active: () => ({}), + with: (_context, fn) => fn(), + }, + propagation: { + inject(_context, carrier, setter) { + const textMapSetter = setter as { + set(carrier: Array<{ key: string; value: string }>, key: string, value: string): void; + }; + textMapSetter.set(carrier, "my-test-key-1", "my-test-value-1"); + textMapSetter.set(carrier, "my-test-key-2", "my-test-value-2"); + textMapSetter.set(carrier, "non-metadata-key-3", "non-metadata-key-3"); + textMapSetter.set(carrier, "my-parent-span-id", "0123456789abcdef"); + }, + }, + trace: { + setSpan: (context) => context, + wrapSpanContext: (context) => context, + }, + }), + ); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).not.toContain("non-metadata-key-3"); + }); + + it("injects trace metadata before the head closes", async () => { + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("x")); + controller.enqueue(new TextEncoder().encode("ok")); + controller.close(); + }, + }); + + await expect( + readStream(injectHtmlBeforeHeadClose(input, '')), + ).resolves.toBe( + 'xok', + ); + }); +}); diff --git a/tests/tsconfig-paths-vite8.test.ts b/tests/tsconfig-paths-vite8.test.ts index 1c4c28648..92c94b1bd 100644 --- a/tests/tsconfig-paths-vite8.test.ts +++ b/tests/tsconfig-paths-vite8.test.ts @@ -172,6 +172,55 @@ describe("Vite tsconfig paths support", () => { fs.rmSync(root, { recursive: true, force: true }); }); + it("materializes aliases from next.config typescript.tsconfigPath on Vite 8", async () => { + const root = setupProject({ name: "vite", version: "8.0.0" }); + process.chdir(root); + fs.writeFileSync(path.join(root, "bar.ts"), "export default 'bar123';\n"); + fs.writeFileSync( + path.join(root, "web.tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + paths: { + foo: ["./bar.ts"], + }, + }, + }, + null, + 2, + ), + ); + + const plugins = vinext({ + appDir: root, + nextConfig: { + typescript: { + tsconfigPath: "web.tsconfig.json", + }, + }, + }); + const configPlugin = findNamedPlugin(plugins, "vinext:config") as { + config?: ( + config: { root: string }, + env: { command: "serve"; mode: string }, + ) => Promise<{ + resolve?: Record; + }>; + }; + const resolvedConfig = await configPlugin.config?.( + { root }, + { command: "serve", mode: "development" }, + ); + + expect(resolvedConfig?.resolve?.alias).toEqual( + expect.objectContaining({ + foo: "/bar.ts", + }), + ); + + fs.rmSync(root, { recursive: true, force: true }); + }); + it("does not override user-defined resolve.tsconfigPaths on Vite 8", async () => { const root = setupProject({ name: "vite", version: "8.0.0" }); process.chdir(root); diff --git a/tests/wasm-module.test.ts b/tests/wasm-module.test.ts new file mode 100644 index 000000000..ea6644650 --- /dev/null +++ b/tests/wasm-module.test.ts @@ -0,0 +1,42 @@ +import { Buffer } from "node:buffer"; +import path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { + isWasmModuleRequest, + renderWasmModuleCode, + resolveWasmModuleFile, + stripWasmModuleQuery, +} from "../packages/vinext/src/plugins/wasm-module.js"; + +const emptyWasmModule = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); + +describe("wasm ?module imports", () => { + it("matches only Next edge WASM module imports", () => { + // Ported from Next.js: test/e2e/edge-can-use-wasm-files/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/edge-can-use-wasm-files/index.test.ts + expect(isWasmModuleRequest("./add.wasm?module")).toBe(true); + expect(isWasmModuleRequest("./add.wasm?init")).toBe(false); + expect(isWasmModuleRequest("./add.wasm?url")).toBe(false); + }); + + it("resolves relative wasm module imports against the importer", () => { + const root = path.resolve("/repo/app"); + const importer = path.join(root, "src/add.js"); + + expect(resolveWasmModuleFile("./add.wasm?module", importer, root)).toBe( + path.join(root, "src/add.wasm"), + ); + expect(stripWasmModuleQuery("./add.wasm?module")).toBe("./add.wasm"); + }); + + it("exports a WebAssembly.Module that can be instantiated", async () => { + const code = renderWasmModuleCode(emptyWasmModule); + const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`; + const mod = (await import(moduleUrl)) as { default: WebAssembly.Module }; + + expect(mod.default).toBeInstanceOf(WebAssembly.Module); + await expect(WebAssembly.instantiate(mod.default)).resolves.toBeInstanceOf( + WebAssembly.Instance, + ); + }); +});