diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f8459a523..5c7b23f5f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -87,7 +87,7 @@ import { import { scanMetadataFiles } from "./server/metadata-routes.js"; import { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; import { detectPackageManager } from "./utils/project.js"; -import { manifestFileWithBase, manifestFilesWithBase } from "./utils/manifest-paths.js"; +import { manifestFilesWithBase } from "./utils/manifest-paths.js"; import { hasBasePath } from "./utils/base-path.js"; import { mergeRewriteQuery } from "./utils/query.js"; import { @@ -124,6 +124,7 @@ import { } from "./plugins/fonts.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; import { computeLazyChunks } from "./utils/lazy-chunks.js"; +import { findClientEntryFile, readClientBuildManifest } from "./utils/client-build-manifest.js"; import { resolvePostcssStringPlugins } from "./plugins/postcss.js"; import { buildSassPreprocessorOptions } from "./plugins/sass.js"; import { @@ -142,6 +143,7 @@ import { } from "./build/ssr-manifest.js"; import { stripServerExports } from "./plugins/strip-server-exports.js"; import { removeConsoleCalls } from "./plugins/remove-console.js"; +import { createImportMetaUrlPlugin } from "./plugins/import-meta-url.js"; import { hasMdxFiles } from "./utils/mdx-scan.js"; import { scanPublicFileRoutes } from "./utils/public-routes.js"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -4005,6 +4007,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, }, + createImportMetaUrlPlugin({ + getRoot: () => root, + }), // Inline binary assets fetched via `fetch(new URL("./asset", import.meta.url))` — // see src/plugins/og-assets.ts createOgInlineFetchAssetsPlugin(), @@ -4358,21 +4363,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let lazyChunksData: string[] | null = null; let clientEntryFile: string | null = null; const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - if (fs.existsSync(buildManifestPath)) { - try { - const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); - // oxlint-disable-next-line typescript/no-explicit-any - for (const [, value] of Object.entries(buildManifest) as [string, any][]) { - if (value && value.isEntry && value.file) { - clientEntryFile = manifestFileWithBase(value.file, clientBase); - break; - } - } - const lazy = manifestFilesWithBase(computeLazyChunks(buildManifest), clientBase); - if (lazy.length > 0) lazyChunksData = lazy; - } catch { - /* ignore parse errors */ - } + const buildManifest = readClientBuildManifest(buildManifestPath); + if (buildManifest) { + clientEntryFile = + findClientEntryFile({ + buildManifest, + clientDir, + assetsSubdir: resolveAssetsDir(nextConfig?.assetPrefix), + assetBase: clientBase, + }) ?? null; + const lazy = manifestFilesWithBase(computeLazyChunks(buildManifest), clientBase); + if (lazy.length > 0) lazyChunksData = lazy; } // Read SSR manifest for per-page CSS/JS injection @@ -4439,18 +4440,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // and the prod-server lookup path, so this fallback works for every // layout supported by the rest of the pipeline. if (!clientEntryFile) { - const assetsSubdir = resolveAssetsDir(nextConfig?.assetPrefix); - const assetsDir = path.join(clientDir, assetsSubdir); - if (fs.existsSync(assetsDir)) { - const files = fs.readdirSync(assetsDir); - const entry = files.find( - (f: string) => - (f.includes("vinext-client-entry") || f.includes("vinext-app-browser-entry")) && - f.endsWith(".js"), - ); - if (entry) - clientEntryFile = manifestFileWithBase(`${assetsSubdir}/${entry}`, clientBase); - } + clientEntryFile = + findClientEntryFile({ + clientDir, + assetsSubdir: resolveAssetsDir(nextConfig?.assetPrefix), + assetBase: clientBase, + }) ?? null; } // Prepend globals to worker entry 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..64494e2fc --- /dev/null +++ b/packages/vinext/src/plugins/import-meta-url.ts @@ -0,0 +1,339 @@ +// Rewrites direct `import.meta.url` reads in user modules to the source-module +// URL (a real file URL on the server, a Turbopack-style `file:///ROOT/...` URL +// on the client) so module identity survives bundling, matching Next.js. +// +// Two known limitations, both matching Vite's own `import.meta.url` handling: +// 1. Destructured access — `const { url } = import.meta;` — is not detected +// and will leak the bundled chunk URL. +// 2. An aliased `import.meta.url` used as a `new URL()` base — e.g. +// `const u = import.meta.url; new URL("./file", u);` — is rewritten, +// breaking Vite's asset detection for that expression. Only the direct +// `new URL("./file", import.meta.url)` form is preserved. +// Both are edge cases that are unlikely in real Next.js apps. +import { normalizePath, parseAst, type Plugin } from "vite"; +import MagicString from "magic-string"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +type NodeLike = { + type?: string; + start?: number; + end?: number; + [key: string]: unknown; +}; + +type ImportMetaUrlEnvironment = "client" | "server"; + +type RewriteResult = { + code: string; + map: ReturnType; +}; + +type RootPaths = { + root: string; + canonicalRoot: string; + normalizedRoot: string; + excludedRelativePrefixes: string[]; +}; + +const TRANSFORMABLE_SCRIPT_EXTENSIONS = new Set([ + ".cjs", + ".cts", + ".js", + ".jsx", + ".mjs", + ".mts", + ".ts", + ".tsx", +]); + +export function createImportMetaUrlPlugin(options: { getRoot: () => string | undefined }): Plugin { + let rootPaths: RootPaths | undefined; + let outputDirs: string[] = []; + + function getRootPaths(): RootPaths | undefined { + const root = options.getRoot(); + if (!root) return rootPaths; + if (!rootPaths || rootPaths.root !== root) { + rootPaths = createRootPaths(root, { outputDirs }); + } + return rootPaths; + } + + return { + name: "vinext:import-meta-url", + enforce: "post", + configResolved(config) { + const root = options.getRoot() ?? config.root; + outputDirs = [config.build.outDir]; + rootPaths = createRootPaths(root, { outputDirs }); + }, + transform(code, id) { + if (!mayContainImportMetaUrl(code)) return null; + const paths = getRootPaths(); + if (!paths) return null; + const cleanId = cleanModuleId(id); + const canonicalId = transformableModuleCanonicalId(cleanId, paths); + if (!canonicalId) return null; + + const environment: ImportMetaUrlEnvironment = + this.environment?.name === "client" ? "client" : "server"; + const rewritten = rewriteCanonicalImportMetaUrl(code, canonicalId, paths, environment); + if (!rewritten) return null; + return { + code: rewritten.code, + map: rewritten.map, + }; + }, + }; +} + +export function rewriteImportMetaUrl( + code: string, + id: string, + root: string, + environment: ImportMetaUrlEnvironment, +): RewriteResult | null { + if (!mayContainImportMetaUrl(code)) return null; + return rewriteCanonicalImportMetaUrl( + code, + canonicalizePath(id), + createRootPaths(root), + environment, + ); +} + +function rewriteCanonicalImportMetaUrl( + code: string, + canonicalId: string, + rootPaths: RootPaths, + environment: ImportMetaUrlEnvironment, +): RewriteResult | null { + let ast: unknown; + try { + ast = parseAst(code); + } catch { + return null; + } + + const ranges = collectImportMetaUrlRanges(ast); + if (ranges.length === 0) return null; + + const replacement = JSON.stringify(importMetaUrlValue(canonicalId, rootPaths, environment)); + const output = new MagicString(code); + for (const range of ranges) { + output.overwrite(range.start, range.end, replacement); + } + + return { + code: output.toString(), + map: output.generateMap({ hires: "boundary" }), + }; +} + +function cleanModuleId(id: string): string { + return id.split("?", 1)[0]; +} + +function createRootPaths(root: string, options: { outputDirs?: string[] } = {}): RootPaths { + const canonicalRoot = canonicalizePath(root); + const normalizedRoot = normalizePath(canonicalRoot); + return { + root, + canonicalRoot, + normalizedRoot, + excludedRelativePrefixes: excludedRelativePrefixes(canonicalRoot, normalizedRoot, options), + }; +} + +// Returns the canonical module id when the module is eligible for rewriting, +// or null otherwise. Threading the canonical id back to the caller avoids a +// second realpathSync when computing the replacement value. +function transformableModuleCanonicalId(id: string, rootPaths: RootPaths): string | null { + if (!id || id.startsWith("\0")) return null; + if (!path.isAbsolute(id)) return null; + const normalizedInputId = normalizePath(id); + // Early-exit optimization: skip the realpathSync below for node_modules + // paths, which are the majority of modules in a typical project. The + // isPathWithin check at line 150 provides a second safety net in case a + // symlink causes the canonical path to land outside node_modules. + if (normalizedInputId.includes("/node_modules/")) return null; + if (!TRANSFORMABLE_SCRIPT_EXTENSIONS.has(path.extname(normalizedInputId))) return null; + + const canonicalId = canonicalizePath(id); + const normalizedId = normalizePath(canonicalId); + if (!isPathWithin(normalizedId, rootPaths.normalizedRoot)) return null; + + const relativePath = normalizePath(path.relative(rootPaths.canonicalRoot, canonicalId)); + if (isExcludedRelativePath(relativePath, rootPaths.excludedRelativePrefixes)) return null; + return canonicalId; +} + +function mayContainImportMetaUrl(code: string): boolean { + return code.includes("import.meta.url") || code.includes("import.meta?.url"); +} + +function excludedRelativePrefixes( + canonicalRoot: string, + normalizedRoot: string, + options: { outputDirs?: string[] }, +): string[] { + // Static list of known output/build directories whose modules must + // never have import.meta.url rewritten (they are build artifacts, not + // user source). Custom output directories are added dynamically from + // config.build.outDir in configResolved. Using .gitignore was considered + // but adds unnecessary filesystem overhead for this narrow use case. + const prefixes = new Set([".next", ".vinext", ".vinext-local-package", "dist", "out"]); + + for (const outputDir of options.outputDirs ?? []) { + const absoluteOutputDir = path.isAbsolute(outputDir) + ? outputDir + : path.resolve(canonicalRoot, outputDir); + const canonicalOutputDir = canonicalizePath(absoluteOutputDir); + const normalizedOutputDir = normalizePath(canonicalOutputDir); + if (!isPathWithin(normalizedOutputDir, normalizedRoot)) continue; + + const relativePath = normalizePath(path.relative(canonicalRoot, canonicalOutputDir)); + if (relativePath && relativePath !== ".") prefixes.add(relativePath); + } + + return [...prefixes]; +} + +function isExcludedRelativePath(relativePath: string, prefixes: string[]): boolean { + return prefixes.some( + (prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`), + ); +} + +function isPathWithin(candidate: string, root: string): boolean { + return candidate === root || candidate.startsWith(root.endsWith("/") ? root : `${root}/`); +} + +function importMetaUrlValue( + canonicalId: string, + rootPaths: RootPaths, + environment: ImportMetaUrlEnvironment, +): string { + if (environment === "client") { + const relativePath = normalizePath(path.relative(rootPaths.canonicalRoot, canonicalId)); + return `file:///ROOT/${relativePath}`; + } + + return pathToFileURL(canonicalId).href; +} + +function canonicalizePath(value: string): string { + try { + return fs.realpathSync.native(value); + } catch { + return path.resolve(value); + } +} + +function collectImportMetaUrlRanges(ast: unknown): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + + function visit(value: unknown): void { + if (!isNodeLike(value)) return; + + if (isImportMetaUrlNode(value)) { + ranges.push({ start: value.start, end: value.end }); + return; + } + + if (isChainExpressionWrappingImportMetaUrl(value)) { + ranges.push({ start: value.start, end: value.end }); + return; + } + + if (isNewUrlExpression(value)) { + const args = nodeArray(value.arguments); + for (let index = 0; index < args.length; index += 1) { + if (index === 1 && isImportMetaUrlOrChainedNode(args[index])) continue; + visit(args[index]); + } + // The callee is always the bare `URL` identifier (see isNewUrlExpression), + // so it can never contain an import.meta.url read — no need to visit it. + return; + } + + for (const [key, child] of Object.entries(value)) { + if (key === "type" || key === "start" || key === "end" || key === "loc") continue; + if (Array.isArray(child)) { + for (const item of child) visit(item); + } else { + visit(child); + } + } + } + + visit(ast); + return ranges; +} + +function isNodeLike(value: unknown): value is NodeLike { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isIdentifierNamed(value: unknown, name: string): boolean { + return isNodeLike(value) && value.type === "Identifier" && value.name === name; +} + +function isImportMetaNode(value: unknown): boolean { + return ( + isNodeLike(value) && + value.type === "MetaProperty" && + isIdentifierNamed(value.meta, "import") && + isIdentifierNamed(value.property, "meta") + ); +} + +function isImportMetaUrlNode(value: unknown): value is NodeLike & { start: number; end: number } { + return ( + isNodeLike(value) && + value.type === "MemberExpression" && + typeof value.start === "number" && + typeof value.end === "number" && + isImportMetaNode(value.object) && + isIdentifierNamed(value.property, "url") + ); +} + +// Accepts both import.meta.url (MemberExpression) and import.meta?.url +// (ChainExpression wrapping a MemberExpression) so that the new URL() skip +// correctly handles optional-chained base arguments. +function isImportMetaUrlOrChainedNode( + value: unknown, +): value is NodeLike & { start: number; end: number } { + if (isImportMetaUrlNode(value)) return true; + return ( + isNodeLike(value) && value.type === "ChainExpression" && isImportMetaUrlNode(value.expression) + ); +} + +// Catches the ChainExpression wrapper so we record the outer node range +// and avoid descending into the inner MemberExpression (which happens +// to share the same start/end, but this is more explicit). +function isChainExpressionWrappingImportMetaUrl( + value: unknown, +): value is NodeLike & { start: number; end: number } { + return ( + isNodeLike(value) && + value.type === "ChainExpression" && + typeof value.start === "number" && + typeof value.end === "number" && + isImportMetaUrlNode(value.expression) + ); +} + +// Only matches bare `new URL(...)`, not `new globalThis.URL(...)` or +// `new window.URL(...)`. Matches Vite's own asset-detection scope. +function isNewUrlExpression(value: NodeLike): boolean { + return value.type === "NewExpression" && isIdentifierNamed(value.callee, "URL"); +} + +function nodeArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 1b5baa09c..47a600b72 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -64,9 +64,11 @@ import { ASSET_PREFIX_URL_DIR, assetPrefixPathname, isAbsoluteAssetPrefix, + resolveAssetsDir, } from "../utils/asset-prefix.js"; import { computeLazyChunks } from "../utils/lazy-chunks.js"; import { manifestFileWithBase } from "../utils/manifest-paths.js"; +import { findClientEntryFile, readClientBuildManifest } from "../utils/client-build-manifest.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import type { ExecutionContextLike } from "vinext/shims/request-context"; import { collectInlineCssManifest } from "../build/inline-css.js"; @@ -1411,22 +1413,28 @@ 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 expose the Pages Router client entry and compute + // lazy chunks. Prerendered HTML is rendered through this Node server too, so + // it needs the same client-entry global that Cloudflare builds inject into + // the Worker entry at build time. const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - if (fs.existsSync(buildManifestPath)) { - try { - const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); - const lazyChunks = computeLazyChunks(buildManifest).map((file: string) => - manifestFileWithBase(file, assetBase), - ); - if (lazyChunks.length > 0) { - globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks; - } - } catch { - /* ignore parse errors */ - } + const buildManifest = readClientBuildManifest(buildManifestPath); + // findClientEntryFile handles a missing manifest by skipping the manifest + // lookup and going straight to the on-disk fallback, so the call is the same + // either way — only the lazy-chunk computation needs the manifest. + globalThis.__VINEXT_CLIENT_ENTRY__ = findClientEntryFile({ + buildManifest, + clientDir, + assetsSubdir: resolveAssetsDir(assetPrefix), + assetBase, + }); + if (buildManifest) { + const lazyChunks = computeLazyChunks(buildManifest).map((file) => + manifestFileWithBase(file, assetBase), + ); + globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks.length > 0 ? lazyChunks : undefined; + } else { + globalThis.__VINEXT_LAZY_CHUNKS__ = undefined; } // Build the static file metadata cache at startup (same as App Router). diff --git a/packages/vinext/src/utils/client-build-manifest.ts b/packages/vinext/src/utils/client-build-manifest.ts new file mode 100644 index 000000000..7a95c713c --- /dev/null +++ b/packages/vinext/src/utils/client-build-manifest.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; +import { manifestFileWithBase } from "./manifest-paths.js"; +import type { BuildManifestChunk } from "./lazy-chunks.js"; +import { isUnknownRecord } from "./record.js"; + +type ClientBuildManifest = Record; + +const CLIENT_ENTRY_MARKERS = ["vinext-client-entry", "vinext-app-browser-entry"] as const; + +export function readClientBuildManifest(manifestPath: string): ClientBuildManifest | undefined { + if (!fs.existsSync(manifestPath)) return undefined; + + try { + const value: unknown = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + if (!isUnknownRecord(value)) return undefined; + + const manifest: ClientBuildManifest = {}; + for (const [key, entry] of Object.entries(value)) { + if (!isUnknownRecord(entry) || typeof entry.file !== "string") continue; + + const imports = readStringArray(entry.imports); + const dynamicImports = readStringArray(entry.dynamicImports); + const css = readStringArray(entry.css); + const assets = readStringArray(entry.assets); + manifest[key] = { + file: entry.file, + ...(entry.isEntry === true ? { isEntry: true } : {}), + ...(entry.isDynamicEntry === true ? { isDynamicEntry: true } : {}), + ...(imports ? { imports } : {}), + ...(dynamicImports ? { dynamicImports } : {}), + ...(css ? { css } : {}), + ...(assets ? { assets } : {}), + }; + } + + return manifest; + } catch { + return undefined; + } +} + +export function findClientEntryFileFromManifest( + buildManifest: ClientBuildManifest, + assetBase: string, +): string | undefined { + const entries = Object.values(buildManifest).filter((entry) => entry.isEntry && entry.file); + // A client build can emit more than one `isEntry` chunk (e.g. the client + // entry plus instrumentation or middleware entries), and the manifest's + // iteration order is not guaranteed to surface the client entry first. + // Prefer the chunk whose file carries a known client-entry marker — matching + // the precise on-disk scan in findClientEntryFileInAssetsDir — and only fall + // back to the first entry when nothing is marked. + const markedEntry = entries.find((entry) => + CLIENT_ENTRY_MARKERS.some((marker) => entry.file.includes(marker)), + ); + const chosen = markedEntry ?? entries[0]; + + return chosen ? manifestFileWithBase(chosen.file, assetBase) : undefined; +} + +function findClientEntryFileInAssetsDir(options: { + clientDir: string; + assetsSubdir: string; + assetBase: string; +}): string | undefined { + const assetsDir = path.join(options.clientDir, options.assetsSubdir); + if (!fs.existsSync(assetsDir)) return undefined; + + const entry = fs + .readdirSync(assetsDir) + .find( + (file) => + CLIENT_ENTRY_MARKERS.some((marker) => file.includes(marker)) && file.endsWith(".js"), + ); + + return entry + ? manifestFileWithBase(`${options.assetsSubdir}/${entry}`, options.assetBase) + : undefined; +} + +export function findClientEntryFile(options: { + buildManifest?: ClientBuildManifest; + clientDir: string; + assetsSubdir: string; + assetBase: string; +}): string | undefined { + return ( + (options.buildManifest + ? findClientEntryFileFromManifest(options.buildManifest, options.assetBase) + : undefined) ?? findClientEntryFileInAssetsDir(options) + ); +} + +function readStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item): item is string => typeof item === "string") + ? value + : undefined; +} diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts index 175cc8a49..e178f4441 100644 --- a/packages/vinext/src/utils/lazy-chunks.ts +++ b/packages/vinext/src/utils/lazy-chunks.ts @@ -1,7 +1,7 @@ /** * Build-manifest chunk metadata used to compute lazy chunks. */ -type BuildManifestChunk = { +export type BuildManifestChunk = { file: string; isEntry?: boolean; isDynamicEntry?: boolean; diff --git a/tests/client-build-manifest.test.ts b/tests/client-build-manifest.test.ts new file mode 100644 index 000000000..998525a8c --- /dev/null +++ b/tests/client-build-manifest.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + findClientEntryFile, + findClientEntryFileFromManifest, + readClientBuildManifest, +} from "../packages/vinext/src/utils/client-build-manifest.js"; + +describe("client build manifest helpers", () => { + let tmpDir: string; + let clientDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-client-build-manifest-")); + clientDir = path.join(tmpDir, "client"); + await fsp.mkdir(path.join(clientDir, ".vite"), { recursive: true }); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it("reads Vite manifest entries used for client entry and lazy chunk computation", async () => { + const manifestPath = path.join(clientDir, ".vite", "manifest.json"); + await fsp.writeFile( + manifestPath, + JSON.stringify({ + "pages-client-entry.ts": { + file: "_next/static/vinext-client-entry-abcd.js", + isEntry: true, + imports: ["shared"], + dynamicImports: ["lazy"], + css: ["_next/static/client.css"], + assets: ["_next/static/logo.svg"], + }, + }), + ); + + const manifest = readClientBuildManifest(manifestPath); + + expect(manifest).toEqual({ + "pages-client-entry.ts": { + file: "_next/static/vinext-client-entry-abcd.js", + isEntry: true, + imports: ["shared"], + dynamicImports: ["lazy"], + css: ["_next/static/client.css"], + assets: ["_next/static/logo.svg"], + }, + }); + }); + + it("finds the client entry from the manifest before scanning disk", () => { + const entry = findClientEntryFileFromManifest( + { + "pages-client-entry.ts": { + file: "_next/static/vinext-client-entry-abcd.js", + isEntry: true, + }, + }, + "/docs/", + ); + + expect(entry).toBe("docs/_next/static/vinext-client-entry-abcd.js"); + }); + + it("prefers the marked client entry over other isEntry chunks regardless of order", () => { + const entry = findClientEntryFileFromManifest( + { + "instrumentation-client.ts": { + file: "_next/static/vinext-instrumentation-client-0001.js", + isEntry: true, + }, + "pages-client-entry.ts": { + file: "_next/static/vinext-client-entry-abcd.js", + isEntry: true, + }, + }, + "/", + ); + + expect(entry).toBe("_next/static/vinext-client-entry-abcd.js"); + }); + + it("falls back to the on-disk assets directory when the manifest has no entry", async () => { + const assetsSubdir = "_next/static"; + await fsp.mkdir(path.join(clientDir, assetsSubdir), { recursive: true }); + await fsp.writeFile(path.join(clientDir, assetsSubdir, "shared.js"), ""); + await fsp.writeFile(path.join(clientDir, assetsSubdir, "vinext-client-entry-1234.js"), ""); + + const entry = findClientEntryFile({ + buildManifest: {}, + clientDir, + assetsSubdir, + assetBase: "/docs/", + }); + + expect(entry).toBe("docs/_next/static/vinext-client-entry-1234.js"); + }); + + it("uses the asset-prefix assets subdirectory for fallback entry lookup", async () => { + const assetsSubdir = "cdn/_next/static"; + await fsp.mkdir(path.join(clientDir, assetsSubdir), { recursive: true }); + await fsp.writeFile(path.join(clientDir, assetsSubdir, "vinext-client-entry-5678.js"), ""); + + const entry = findClientEntryFile({ + clientDir, + assetsSubdir, + assetBase: "/", + }); + + expect(entry).toBe("cdn/_next/static/vinext-client-entry-5678.js"); + }); +}); diff --git a/tests/e2e/pages-router-prod/production.spec.ts b/tests/e2e/pages-router-prod/production.spec.ts index f8ad2db23..5caddab73 100644 --- a/tests/e2e/pages-router-prod/production.spec.ts +++ b/tests/e2e/pages-router-prod/production.spec.ts @@ -60,27 +60,47 @@ test.describe("Pages Router Production Build", () => { expect(content).toContain("hello-world"); }); - // NOTE: Pages Router production build doesn't inject client