diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 47dfec9de..8e91093fb 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -132,12 +132,21 @@ export function createClientManualChunks(shimsDir: string) { * compression efficiency — small files restart the compression dictionary, * adding ~5-15% wire overhead vs fewer larger chunks. */ +export function createClientFileNameConfig(assetsDir: string) { + const chunksDir = `${assetsDir}/chunks`; + return { + entryFileNames: `${chunksDir}/[name]-[hash].js`, + chunkFileNames: `${chunksDir}/[name]-[hash].js`, + }; +} + export function createClientOutputConfig( clientManualChunks: (id: string) => string | undefined, assetsDir: string, ) { return { assetFileNames: createClientAssetFileNames(assetsDir), + ...createClientFileNameConfig(assetsDir), manualChunks: clientManualChunks, experimentalMinChunkSize: 10_000, }; diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 0e972d8a3..c8c69fea8 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -244,6 +244,15 @@ declare global { // oxlint-disable-next-line no-var var __VINEXT_LAZY_CHUNKS__: string[] | undefined; + /** + * Per-module preload files for rendered `next/dynamic()` boundaries. + * Keys are root-relative module IDs injected by vinext's dynamic metadata + * transform. Values are JS/CSS files from Vite's build manifest, with any + * configured base path / asset prefix already applied. + */ + // oxlint-disable-next-line no-var + var __VINEXT_DYNAMIC_PRELOADS__: Record | undefined; + /** * The client entry JS filename (e.g. `"_next/static/entry-abc123.js"`) for Pages * Router builds. diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 65f6a1530..f805b14a8 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -87,7 +87,6 @@ import { import { scanMetadataFiles } from "./server/metadata-routes.js"; import { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; import { detectPackageManager } from "./utils/project.js"; -import { manifestFilesWithBase } from "./utils/manifest-paths.js"; import { hasBasePath } from "./utils/base-path.js"; import { mergeRewriteQuery } from "./utils/query.js"; import { @@ -106,6 +105,7 @@ import { } from "./client/instrumentation-client-inject.js"; import { createMiddlewareServerOnlyPlugin } from "./plugins/middleware-server-only.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; +import { createDynamicPreloadMetadataPlugin } from "./plugins/dynamic-preload-metadata.js"; import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js"; import { generateRouteTypes } from "./typegen.js"; import { @@ -123,11 +123,11 @@ import { createLocalFontsPlugin, } 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 { computeClientRuntimeMetadata } from "./utils/client-runtime-metadata.js"; import { resolvePostcssStringPlugins } from "./plugins/postcss.js"; import { buildSassPreprocessorOptions } from "./plugins/sass.js"; import { + createClientFileNameConfig, createClientManualChunks, createClientOutputConfig, createClientCodeSplittingConfig, @@ -613,13 +613,13 @@ const _reactServerShims = new Map([ ]); const clientManualChunks = createClientManualChunks(_shimsDir); -const clientCodeSplittingConfig = createClientCodeSplittingConfig(clientManualChunks); function getClientOutputConfigForVite(viteMajorVersion: number, assetsDir: string) { return viteMajorVersion >= 8 ? { assetFileNames: createClientAssetFileNames(assetsDir), - codeSplitting: clientCodeSplittingConfig, + ...createClientFileNameConfig(assetsDir), + codeSplitting: createClientCodeSplittingConfig(clientManualChunks), } : createClientOutputConfig(clientManualChunks, assetsDir); } @@ -2079,14 +2079,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ], }, build: { - // When targeting Cloudflare Workers, enable manifest generation - // so the vinext:cloudflare-build closeBundle hook can read the - // client build manifest, compute lazy chunks (only reachable - // via dynamic imports), and inject __VINEXT_LAZY_CHUNKS__ into - // 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 } : {}), + // Production App Router rendering needs Vite's client manifest + // to resolve next/dynamic module IDs to the exact JS/CSS files + // that should be preloaded when a dynamic boundary renders. + // Cloudflare builds also use it to inject lazy chunk metadata + // into the Worker entry. + manifest: true, // Client-scoped so RSC/SSR keep their normal asset handling // unless the user configured Vite globally. assetsInlineLimit: clientAssetsInlineLimit, @@ -3884,6 +3882,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { () => nextConfig, () => root, ), + // next/dynamic preload metadata: + // Mirrors Next.js's react-loadable transform by recording which resolved + // module IDs belong to each dynamic() boundary. The runtime resolves those + // IDs through Vite's build manifest so it can emit boundary-scoped preload + // hints with the request CSP nonce. + createDynamicPreloadMetadataPlugin(), // "use cache" directive transform: // Detects "use cache" at file-level or function-level and wraps the // exports/functions with registerCachedFunction() from vinext/cache-runtime. @@ -4413,14 +4417,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, // Cloudflare Workers production build integration: - // After all environments are built, compute lazy chunks from the client - // build manifest and inject globals into the worker entry. + // After all environments are built, compute dynamic chunk metadata from + // the client build manifest and inject globals into the worker entry. // // Pages Router: injects __VINEXT_CLIENT_ENTRY__, __VINEXT_SSR_MANIFEST__, - // and __VINEXT_LAZY_CHUNKS__ into the worker entry (found via wrangler.json). + // __VINEXT_LAZY_CHUNKS__, and __VINEXT_DYNAMIC_PRELOADS__ into the worker + // entry (found via wrangler.json). // App Router: the RSC plugin handles __VINEXT_CLIENT_ENTRY__ via - // loadBootstrapScriptContent(), but we still inject __VINEXT_LAZY_CHUNKS__ - // and __VINEXT_SSR_MANIFEST__ into the worker entry at dist/server/index.js. + // loadBootstrapScriptContent(), but we still inject dynamic preload + // metadata and __VINEXT_SSR_MANIFEST__ into the worker entry at + // dist/server/index.js. // Both: generates _headers file for immutable asset caching. { name: "vinext:cloudflare-build", @@ -4443,25 +4449,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const clientDir = path.resolve(buildRoot, "dist", "client"); const clientBase = envConfig.base ?? "/"; - // Read build manifest and compute lazy chunks (only reachable via - // dynamic imports). This runs for BOTH App Router and Pages Router. - // clientEntryFile is only used by the Pages Router path below — - // App Router gets its client entry via the RSC plugin instead. - let lazyChunksData: string[] | null = null; - let clientEntryFile: string | null = null; - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - 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; - } + // Compute runtime metadata from the client build manifest: lazy + // chunks, per-next/dynamic preload files, and (for Pages Router) + // the client entry file. This runs for BOTH App Router and Pages + // Router — clientEntryFile is only used by the Pages Router path + // below (App Router gets its client entry via the RSC plugin). + const runtimeMetadata = computeClientRuntimeMetadata({ + clientDir, + assetBase: clientBase, + assetPrefix: nextConfig.assetPrefix, + includeClientEntry: !hasAppDir, + }); + const lazyChunksData: string[] | null = runtimeMetadata.lazyChunks ?? null; + const dynamicPreloadsData: Record | null = + runtimeMetadata.dynamicPreloads ?? null; + let clientEntryFile: string | null = runtimeMetadata.clientEntryFile ?? null; // Read SSR manifest for per-page CSS/JS injection let ssrManifestData: Record | null = null; @@ -4477,10 +4479,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (hasAppDir) { // App Router: the RSC plugin handles __VINEXT_CLIENT_ENTRY__ // via loadBootstrapScriptContent(), but we still need to inject - // __VINEXT_LAZY_CHUNKS__ and __VINEXT_SSR_MANIFEST__ into the - // worker entry at dist/server/index.js. + // __VINEXT_LAZY_CHUNKS__, __VINEXT_DYNAMIC_PRELOADS__, and + // __VINEXT_SSR_MANIFEST__ into the worker entry at dist/server/index.js. const workerEntry = path.resolve(distDir, "server", "index.js"); - if (fs.existsSync(workerEntry) && (lazyChunksData || ssrManifestData)) { + if ( + fs.existsSync(workerEntry) && + (lazyChunksData || dynamicPreloadsData || ssrManifestData) + ) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (ssrManifestData) { @@ -4493,6 +4498,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`, ); } + if (dynamicPreloadsData) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + ); + } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); } @@ -4516,27 +4526,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const workerEntry = path.join(workerOutDir, "index.js"); if (!fs.existsSync(workerEntry)) return; - // Fallback: scan the on-disk assets directory for the client entry - // chunk when the SSR manifest lookup didn't surface one. Pages Router - // uses "vinext-client-entry", App Router uses "vinext-app-browser-entry". - // - // When `assetPrefix` is configured, chunks live under - // `/_next/static/` (path-prefix) or `_next/static/` - // (absolute-URL prefix) — NOT `assets/`. Resolve the actual - // subdirectory from the same helper that drives `build.assetsDir` - // and the prod-server lookup path, so this fallback works for every - // layout supported by the rest of the pipeline. - if (!clientEntryFile) { - clientEntryFile = - findClientEntryFile({ - clientDir, - assetsSubdir: resolveAssetsDir(nextConfig?.assetPrefix), - assetBase: clientBase, - }) ?? null; - } - // Prepend globals to worker entry - if (clientEntryFile || ssrManifestData || lazyChunksData) { + if (clientEntryFile || ssrManifestData || lazyChunksData || dynamicPreloadsData) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (clientEntryFile) { @@ -4554,6 +4545,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`, ); } + if (dynamicPreloadsData) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + ); + } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); } diff --git a/packages/vinext/src/plugins/dynamic-preload-metadata.ts b/packages/vinext/src/plugins/dynamic-preload-metadata.ts new file mode 100644 index 000000000..db53698ee --- /dev/null +++ b/packages/vinext/src/plugins/dynamic-preload-metadata.ts @@ -0,0 +1,612 @@ +import type { Plugin } from "vite"; +import { parseAst } from "vite"; +import MagicString from "magic-string"; +import path from "node:path"; +import { isUnknownRecord as isRecord } from "../utils/record.js"; + +type AstRecord = Record; + +type TransformResult = { + code: string; + map: ReturnType; +}; + +type ResolveDynamicImport = (specifier: string, importer: string) => Promise; + +function getString(node: AstRecord, key: string): string | null { + const value = node[key]; + return typeof value === "string" ? value : null; +} + +function getNumber(node: AstRecord, key: string): number | null { + const value = node[key]; + return typeof value === "number" ? value : null; +} + +function getArray(node: AstRecord, key: string): unknown[] { + const value = node[key]; + return Array.isArray(value) ? value : []; +} + +function getBoolean(node: AstRecord, key: string): boolean { + return node[key] === true; +} + +function nodeName(node: unknown): string | null { + if (!isRecord(node)) return null; + const name = node.name; + if (typeof name === "string") return name; + const value = node.value; + return typeof value === "string" ? value : null; +} + +function nodeStringValue(node: unknown): string | null { + if (!isRecord(node)) return null; + const value = node.value; + return typeof value === "string" ? value : null; +} + +function walkAst(value: unknown, visitor: (node: AstRecord) => void): void { + if (!isRecord(value)) return; + visitor(value); + + for (const [key, child] of Object.entries(value)) { + if (key === "parent") continue; + if (Array.isArray(child)) { + for (const item of child) { + walkAst(item, visitor); + } + } else if (isRecord(child)) { + walkAst(child, visitor); + } + } +} + +function importSource(node: AstRecord): string | null { + const source = node.source; + if (!isRecord(source)) return null; + return nodeStringValue(source); +} + +function isNextDynamicSource(source: string | null): boolean { + return source === "next/dynamic" || source === "next/dynamic.js"; +} + +function collectDynamicImportLocals(ast: unknown): Set { + const locals = new Set(); + if (!isRecord(ast)) return locals; + + for (const node of getArray(ast, "body")) { + if (!isRecord(node)) continue; + if (getString(node, "type") !== "ImportDeclaration") continue; + if (!isNextDynamicSource(importSource(node))) continue; + + for (const specifier of getArray(node, "specifiers")) { + if (!isRecord(specifier)) continue; + if (getString(specifier, "type") !== "ImportDefaultSpecifier") continue; + const local = nodeName(specifier.local); + if (local) locals.add(local); + } + } + + return locals; +} + +function isIdentifierNamed(node: unknown, names: Set): boolean { + if (!isRecord(node)) return false; + return getString(node, "type") === "Identifier" && names.has(getString(node, "name") ?? ""); +} + +function isDynamicCall(node: AstRecord, dynamicLocals: Set): boolean { + if (getString(node, "type") !== "CallExpression") return false; + return isIdentifierNamed(node.callee, dynamicLocals); +} + +function addBindingName(pattern: unknown, names: Set): void { + if (!isRecord(pattern)) return; + + const type = getString(pattern, "type"); + if (type === null) return; + + switch (type) { + case "Identifier": { + const name = getString(pattern, "name"); + if (name) names.add(name); + return; + } + case "AssignmentPattern": + addBindingName(pattern.left, names); + return; + case "RestElement": + addBindingName(pattern.argument, names); + return; + case "ArrayPattern": + for (const element of getArray(pattern, "elements")) { + addBindingName(element, names); + } + return; + case "ObjectPattern": + for (const property of getArray(pattern, "properties")) { + if (!isRecord(property)) continue; + if (getString(property, "type") === "RestElement") { + addBindingName(property.argument, names); + continue; + } + addBindingName(property.value, names); + } + return; + default: + return; + } +} + +function addVariableDeclarationBindingNames(node: unknown, names: Set): void { + if (!isRecord(node) || getString(node, "type") !== "VariableDeclaration") return; + for (const declaration of getArray(node, "declarations")) { + if (isRecord(declaration)) addBindingName(declaration.id, names); + } +} + +function collectBlockScopedBindingNames(body: readonly unknown[]): Set { + const names = new Set(); + + for (const statement of body) { + if (!isRecord(statement)) continue; + + const type = getString(statement, "type"); + if (type === "VariableDeclaration") { + if (getString(statement, "kind") !== "var") { + addVariableDeclarationBindingNames(statement, names); + } + continue; + } + + if (type === "FunctionDeclaration" || type === "ClassDeclaration") { + const name = nodeName(statement.id); + if (name) names.add(name); + } + } + + return names; +} + +function collectSwitchScopedBindingNames(node: AstRecord): Set { + const names = new Set(); + + for (const switchCase of getArray(node, "cases")) { + if (!isRecord(switchCase)) continue; + for (const statement of getArray(switchCase, "consequent")) { + for (const name of collectBlockScopedBindingNames([statement])) { + names.add(name); + } + } + } + + return names; +} + +function collectVarBindingNames(value: unknown, names: Set): void { + if (!isRecord(value)) return; + + const type = getString(value, "type"); + if ( + type === "FunctionDeclaration" || + type === "FunctionExpression" || + type === "ArrowFunctionExpression" + ) { + return; + } + + if (type === "VariableDeclaration" && getString(value, "kind") === "var") { + addVariableDeclarationBindingNames(value, names); + } + + for (const [key, child] of Object.entries(value)) { + if (key === "parent") continue; + if (Array.isArray(child)) { + for (const item of child) { + collectVarBindingNames(item, names); + } + } else if (isRecord(child)) { + collectVarBindingNames(child, names); + } + } +} + +function collectFunctionScopeBindingNames(node: AstRecord): Set { + const names = new Set(); + + if (getString(node, "type") === "FunctionExpression") { + const name = nodeName(node.id); + if (name) names.add(name); + } + + for (const param of getArray(node, "params")) { + addBindingName(param, names); + } + + collectVarBindingNames(node.body, names); + return names; +} + +function collectForBindingNames(node: AstRecord): Set { + const names = new Set(); + addVariableDeclarationBindingNames(node.init, names); + addVariableDeclarationBindingNames(node.left, names); + return names; +} + +function withoutBindings(activeNames: Set, localNames: Set): Set { + if (activeNames.size === 0 || localNames.size === 0) return activeNames; + + let scoped: Set | null = null; + for (const name of localNames) { + if (!activeNames.has(name)) continue; + scoped ??= new Set(activeNames); + scoped.delete(name); + } + + return scoped ?? activeNames; +} + +function visitChildren( + node: AstRecord, + dynamicLocals: Set, + visitor: (node: AstRecord) => void, +): void { + for (const [key, child] of Object.entries(node)) { + if (key === "parent") continue; + if (Array.isArray(child)) { + for (const item of child) { + visitDynamicCalls(item, dynamicLocals, visitor); + } + } else if (isRecord(child)) { + visitDynamicCalls(child, dynamicLocals, visitor); + } + } +} + +function visitDynamicCalls( + value: unknown, + dynamicLocals: Set, + visitor: (node: AstRecord) => void, +): void { + if (!isRecord(value) || dynamicLocals.size === 0) return; + + const type = getString(value, "type"); + if (type === "Program") { + const scoped = withoutBindings( + dynamicLocals, + collectBlockScopedBindingNames(getArray(value, "body")), + ); + for (const statement of getArray(value, "body")) { + visitDynamicCalls(statement, scoped, visitor); + } + return; + } + + if (type === "BlockStatement") { + const scoped = withoutBindings( + dynamicLocals, + collectBlockScopedBindingNames(getArray(value, "body")), + ); + for (const statement of getArray(value, "body")) { + visitDynamicCalls(statement, scoped, visitor); + } + return; + } + + if (type === "SwitchStatement") { + visitDynamicCalls(value.discriminant, dynamicLocals, visitor); + + const scoped = withoutBindings(dynamicLocals, collectSwitchScopedBindingNames(value)); + for (const switchCase of getArray(value, "cases")) { + visitDynamicCalls(switchCase, scoped, visitor); + } + return; + } + + if ( + type === "FunctionDeclaration" || + type === "FunctionExpression" || + type === "ArrowFunctionExpression" + ) { + visitChildren( + value, + withoutBindings(dynamicLocals, collectFunctionScopeBindingNames(value)), + visitor, + ); + return; + } + + if (type === "ClassDeclaration" || type === "ClassExpression") { + const names = new Set(); + const name = nodeName(value.id); + if (name) names.add(name); + visitChildren(value, withoutBindings(dynamicLocals, names), visitor); + return; + } + + if (type === "ForStatement" || type === "ForInStatement" || type === "ForOfStatement") { + visitChildren(value, withoutBindings(dynamicLocals, collectForBindingNames(value)), visitor); + return; + } + + if (type === "CatchClause") { + const names = new Set(); + addBindingName(value.param, names); + visitChildren(value, withoutBindings(dynamicLocals, names), visitor); + return; + } + + if (isDynamicCall(value, dynamicLocals)) { + visitor(value); + } + visitChildren(value, dynamicLocals, visitor); +} + +function collectImportSpecifiers(node: unknown): string[] { + const specifiers: string[] = []; + const seen = new Set(); + + walkAst(node, (item) => { + if (getString(item, "type") === "ImportExpression") { + const specifier = nodeStringValue(item.source); + if (specifier && !seen.has(specifier)) { + seen.add(specifier); + specifiers.push(specifier); + } + return; + } + + if (getString(item, "type") !== "CallExpression") return; + const callee = item.callee; + if (!isRecord(callee) || getString(callee, "type") !== "Import") return; + const firstArg = getArray(item, "arguments")[0]; + const specifier = nodeStringValue(firstArg); + if (specifier && !seen.has(specifier)) { + seen.add(specifier); + specifiers.push(specifier); + } + }); + + return specifiers; +} + +function propertyKeyName(property: unknown): string | null { + if (!isRecord(property)) return null; + if (getBoolean(property, "computed")) return null; + return nodeName(property.key); +} + +function objectProperties(node: unknown): AstRecord[] { + if (!isRecord(node) || getString(node, "type") !== "ObjectExpression") return []; + return getArray(node, "properties").filter(isRecord); +} + +function hasObjectProperty(node: unknown, name: string): boolean { + return objectProperties(node).some((property) => propertyKeyName(property) === name); +} + +function findObjectProperty(node: unknown, name: string): AstRecord | null { + return objectProperties(node).find((property) => propertyKeyName(property) === name) ?? null; +} + +function dynamicLoaderNode(firstArg: unknown): unknown { + if (!isRecord(firstArg) || getString(firstArg, "type") !== "ObjectExpression") return firstArg; + const loaderProperty = + findObjectProperty(firstArg, "loader") ?? findObjectProperty(firstArg, "modules"); + return loaderProperty?.value; +} + +function findLastEndedProperty(node: AstRecord): AstRecord | null { + const properties = objectProperties(node); + for (let index = properties.length - 1; index >= 0; index -= 1) { + if (getNumber(properties[index], "end") !== null) { + return properties[index]; + } + } + return null; +} + +function appendObjectProperty( + output: MagicString, + objectNode: AstRecord, + property: string, +): boolean { + const start = getNumber(objectNode, "start"); + const end = getNumber(objectNode, "end"); + if (start === null || end === null) return false; + + const lastProperty = findLastEndedProperty(objectNode); + if (!lastProperty) { + output.appendLeft(start + 1, property); + return true; + } + + const propertyEnd = getNumber(lastProperty, "end"); + if (propertyEnd === null) return false; + output.appendLeft(propertyEnd, `, ${property}`); + return true; +} + +function insertSecondOptionsArgument( + output: MagicString, + code: string, + callNode: AstRecord, + firstArg: AstRecord, + optionsLiteral: string, +): boolean { + const callEnd = getNumber(callNode, "end"); + const firstArgEnd = getNumber(firstArg, "end"); + if (callEnd === null || firstArgEnd === null) return false; + + const closeParen = callEnd - 1; // AST `end` is exclusive (past the closing paren) + const betweenFirstArgAndClose = code.slice(firstArgEnd, closeParen); + if (betweenFirstArgAndClose.includes(",")) { + output.overwrite(firstArgEnd, closeParen, `, ${optionsLiteral}`); + } else { + output.appendLeft(closeParen, `, ${optionsLiteral}`); + } + return true; +} + +function cleanResolvedId(id: string): string { + let start = 0; + while (start < id.length && id.charCodeAt(start) === 0) { + start += 1; + } + + return id + .slice(start) + .replace(/^\/@fs\//, "/") + .split("?")[0] + .replace(/\\/g, "/"); +} + +function toManifestModuleId(root: string, resolvedId: string): string | null { + const cleaned = cleanResolvedId(resolvedId); + if (!path.isAbsolute(cleaned)) return cleaned.replace(/^\/+/, ""); + + const relative = path.relative(root, cleaned); + if (relative.startsWith("..") || path.isAbsolute(relative)) return null; + return relative.split(path.sep).join("/"); +} + +async function resolveManifestModuleIds( + specifiers: readonly string[], + importer: string, + root: string, + resolveDynamicImport: ResolveDynamicImport, +): Promise { + const resolvedIds: string[] = []; + const seen = new Set(); + + for (const specifier of specifiers) { + const resolved = await resolveDynamicImport(specifier, importer); + const moduleId = resolved ? toManifestModuleId(root, resolved) : null; + if (!moduleId || seen.has(moduleId)) continue; + seen.add(moduleId); + resolvedIds.push(moduleId); + } + + return resolvedIds; +} + +function shouldSkipCall(firstArg: unknown, secondArg: unknown): boolean { + if (hasObjectProperty(firstArg, "loadableGenerated")) return true; + return hasObjectProperty(secondArg, "loadableGenerated"); +} + +function applyLoadableGenerated( + output: MagicString, + code: string, + callNode: AstRecord, + moduleIds: readonly string[], +): boolean { + const args = getArray(callNode, "arguments"); + const firstArg = args[0]; + const secondArg = args[1]; + if (!isRecord(firstArg)) return false; + if (shouldSkipCall(firstArg, secondArg)) return false; + + const property = `loadableGenerated: { modules: ${JSON.stringify(moduleIds)} }`; + const firstArgIsObject = getString(firstArg, "type") === "ObjectExpression"; + if (firstArgIsObject) { + return appendObjectProperty(output, firstArg, property); + } + + if (secondArg === undefined) { + return insertSecondOptionsArgument(output, code, callNode, firstArg, `{ ${property} }`); + } + + if (isRecord(secondArg) && getString(secondArg, "type") === "ObjectExpression") { + return appendObjectProperty(output, secondArg, property); + } + + return false; +} + +export async function transformNextDynamicPreloadMetadata( + code: string, + id: string, + root: string, + resolveDynamicImport: ResolveDynamicImport, +): Promise { + if (!code.includes("next/dynamic")) return null; + + let ast: unknown; + try { + ast = parseAst(code); + } catch { + return null; + } + + const dynamicLocals = collectDynamicImportLocals(ast); + if (dynamicLocals.size === 0) return null; + + const output = new MagicString(code); + let changed = false; + const pending: Promise[] = []; + + // Mutations are safe: microtask callbacks from Promise.all run sequentially + // on the JS microtask queue, so no two .then() callbacks overlap. + visitDynamicCalls(ast, dynamicLocals, (node) => { + const args = getArray(node, "arguments"); + const specifiers = collectImportSpecifiers(dynamicLoaderNode(args[0])); + if (specifiers.length === 0) return; + + pending.push( + resolveManifestModuleIds(specifiers, id, root, resolveDynamicImport).then((moduleIds) => { + if (moduleIds.length === 0) return; + if (applyLoadableGenerated(output, code, node, moduleIds)) { + changed = true; + } + }), + ); + }); + + await Promise.all(pending); + + if (!changed) return null; + return { + code: output.toString(), + map: output.generateMap({ hires: "boundary" }), + }; +} + +export function createDynamicPreloadMetadataPlugin(): Plugin { + let root = process.cwd(); + + return { + name: "vinext:dynamic-preload-metadata", + configResolved(config) { + root = config.root; + }, + transform: { + filter: { + id: { + include: /\.(tsx?|jsx?|mjs)$/, + exclude: /node_modules/, + }, + code: "next/dynamic", + }, + async handler(code, id) { + if (id.includes("node_modules") || id.startsWith("\0")) return null; + if (!/\.(tsx?|jsx?|mjs)$/.test(id)) return null; + + const result = await transformNextDynamicPreloadMetadata( + code, + id, + root, + async (specifier) => { + const resolved = await this.resolve(specifier, id, { skipSelf: true }); + return resolved?.id ?? null; + }, + ); + if (!result) return null; + return result; + }, + }, + }; +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 47a600b72..3b0d5c065 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -64,11 +64,8 @@ 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 { computeClientRuntimeMetadata } from "../utils/client-runtime-metadata.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import type { ExecutionContextLike } from "vinext/shims/request-context"; import { collectInlineCssManifest } from "../build/inline-css.js"; @@ -294,6 +291,16 @@ function stripHeaders( } } +function installClientBuildManifestGlobals( + clientDir: string, + assetBase: string, + assetPrefix: string, +): void { + const metadata = computeClientRuntimeMetadata({ clientDir, assetBase, assetPrefix }); + globalThis.__VINEXT_LAZY_CHUNKS__ = metadata.lazyChunks; + globalThis.__VINEXT_DYNAMIC_PRELOADS__ = metadata.dynamicPreloads; +} + function isNoBodyResponseStatus(status: number): boolean { return NO_BODY_RESPONSE_STATUSES.has(status); } @@ -1086,11 +1093,15 @@ async function startAppRouterServer(options: AppRouterServerOptions) { globalThis.__VINEXT_INLINE_CSS__ = appRouterInlineCss ? collectInlineCssManifest(clientDir, appRouterAssetPrefix) : undefined; + const appRouterBasePath: string = + typeof rscModule.__basePath === "string" ? rscModule.__basePath : ""; // Path portion of the assetPrefix to match incoming asset requests against // (empty when the prefix is an absolute URL with no path component, or when // no prefix is configured). The URL prefix the prod-server needs to strip // before locating files on disk includes this path plus `_next/static/`. const appAssetPathPrefix = assetPrefixPathname(appRouterAssetPrefix); + const appAssetBase = appRouterBasePath ? `${appRouterBasePath}/` : "/"; + installClientBuildManifestGlobals(clientDir, appAssetBase, appRouterAssetPrefix); // 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. @@ -1413,29 +1424,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { ssrManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); } - // 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"); - 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, + // Compute client runtime metadata from the build manifest: client entry, + // lazy chunks, and dynamic preload files. These globals are read by shims + // during SSR and must match what Cloudflare builds inject into the Worker + // entry at build time. + const runtimeMetadata = computeClientRuntimeMetadata({ clientDir, - assetsSubdir: resolveAssetsDir(assetPrefix), assetBase, + assetPrefix, + includeClientEntry: true, }); - 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; - } + globalThis.__VINEXT_CLIENT_ENTRY__ = runtimeMetadata.clientEntryFile; + globalThis.__VINEXT_LAZY_CHUNKS__ = runtimeMetadata.lazyChunks; + globalThis.__VINEXT_DYNAMIC_PRELOADS__ = runtimeMetadata.dynamicPreloads; // Build the static file metadata cache at startup (same as App Router). const staticCache = await StaticFileCache.create(clientDir); diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index ff8b174c4..412e56d52 100644 --- a/packages/vinext/src/shims/dynamic.ts +++ b/packages/vinext/src/shims/dynamic.ts @@ -20,6 +20,8 @@ * - dynamic(() => import('./Component'), { ssr: false }) */ import React, { type ComponentType } from "react"; +import * as ReactDOM from "react-dom"; +import { useScriptNonce } from "./script-nonce-context.js"; type DynamicLoadingProps = { error?: Error | null; @@ -36,6 +38,10 @@ type LoaderFn

= () => LoaderComponent

; type DynamicOptions

= { loading?: ComponentType; loader?: Loader

; + loadableGenerated?: { + modules?: readonly string[]; + }; + modules?: readonly string[]; ssr?: boolean; }; @@ -96,6 +102,73 @@ function createLazyComponent

(loader: LoaderFn

) { }); } +function dynamicPreloadHref(file: string): string { + if ( + file.startsWith("/") || + file.startsWith("http://") || + file.startsWith("https://") || + file.startsWith("//") + ) { + return file; + } + return `/${file}`; +} + +function resolveDynamicPreloadFiles(moduleIds: readonly string[] | undefined): string[] { + if (!moduleIds || moduleIds.length === 0) return []; + + const preloadMap = globalThis.__VINEXT_DYNAMIC_PRELOADS__; + if (!preloadMap) return []; + + const files: string[] = []; + const seen = new Set(); + for (const moduleId of moduleIds) { + for (const file of preloadMap[moduleId] ?? []) { + if (seen.has(file)) continue; + seen.add(file); + files.push(file); + } + } + + return files; +} + +function DynamicPreloadChunks(props: { moduleIds?: readonly string[] }) { + const nonce = useScriptNonce(); + const files = resolveDynamicPreloadFiles(props.moduleIds); + if (files.length === 0) return null; + + const stylesheets: React.ReactNode[] = []; + for (const file of files) { + const href = dynamicPreloadHref(file); + if (href.endsWith(".css")) { + stylesheets.push( + React.createElement("link", { + key: href, + rel: "stylesheet", + href, + nonce, + precedence: "dynamic", + }), + ); + continue; + } + + if (href.endsWith(".js") && typeof ReactDOM.preload === "function") { + const preloadOptions: ReactDOM.PreloadOptions = { + as: "script", + fetchPriority: "low", + }; + if (nonce !== undefined) { + preloadOptions.nonce = nonce; + } + ReactDOM.preload(href, preloadOptions); + } + } + + return stylesheets.length > 0 ? React.createElement(React.Fragment, null, ...stylesheets) : null; +} + function useRetryableLazyComponent

( loader: LoaderFn

, initialLazyComponent: ReturnType>, @@ -194,10 +267,13 @@ function dynamic

( ): ComponentType

{ const { loader: dynamicLoader, + loadableGenerated, loading: LoadingComponent, + modules, ssr = true, } = normalizeDynamicOptions(dynamicInput, options); const loader = dynamicLoader ? normalizeLoader(dynamicLoader) : () => Promise.resolve(() => null); + const preloadModuleIds = loadableGenerated?.modules ?? modules; // ssr: false — render nothing on the server, lazy-load on client if (!ssr) { @@ -296,7 +372,12 @@ function dynamic

( ); } } - return React.createElement(React.Suspense, { fallback }, content); + return React.createElement( + React.Fragment, + null, + React.createElement(DynamicPreloadChunks, { moduleIds: preloadModuleIds }), + React.createElement(React.Suspense, { fallback }, content), + ); }; ServerDynamic.displayName = "DynamicServer"; diff --git a/packages/vinext/src/shims/script-nonce-context.tsx b/packages/vinext/src/shims/script-nonce-context.tsx index 1e42b4dc9..14341f141 100644 --- a/packages/vinext/src/shims/script-nonce-context.tsx +++ b/packages/vinext/src/shims/script-nonce-context.tsx @@ -1,23 +1,43 @@ import React from "react"; -export const ScriptNonceContext = React.createContext(undefined); +export const ScriptNonceContext = + typeof React.createContext === "function" + ? React.createContext(undefined) + : null; export function ScriptNonceProvider( props: React.PropsWithChildren<{ nonce?: string; }>, ): React.ReactElement { + if (!ScriptNonceContext) { + return React.createElement(React.Fragment, null, props.children); + } return React.createElement(ScriptNonceContext.Provider, { value: props.nonce }, props.children); } export function withScriptNonce(element: React.ReactElement, nonce?: string): React.ReactElement { - if (!nonce) { + if (!nonce || !ScriptNonceContext) { return element; } return React.createElement(ScriptNonceProvider, { nonce }, element); } +function createScriptNonceHook(context: typeof ScriptNonceContext): () => string | undefined { + if (!context || typeof React.useContext !== "function") { + return function useScriptNonceFromContext(): string | undefined { + return undefined; + }; + } + + return function useScriptNonceFromContext(): string | undefined { + return React.useContext(context); + }; +} + +const useScriptNonceFromContext = createScriptNonceHook(ScriptNonceContext); + export function useScriptNonce(): string | undefined { - return React.useContext(ScriptNonceContext); + return useScriptNonceFromContext(); } diff --git a/packages/vinext/src/utils/client-runtime-metadata.ts b/packages/vinext/src/utils/client-runtime-metadata.ts new file mode 100644 index 000000000..f82e9f7fe --- /dev/null +++ b/packages/vinext/src/utils/client-runtime-metadata.ts @@ -0,0 +1,69 @@ +import path from "node:path"; +import { readClientBuildManifest, findClientEntryFile } from "./client-build-manifest.js"; +import { + computeLazyChunks, + computeDynamicImportPreloads, + dynamicImportPreloadsWithBase, +} from "./lazy-chunks.js"; +import { manifestFileWithAssetPrefix } from "./manifest-paths.js"; +import { resolveAssetsDir } from "./asset-prefix.js"; + +type ClientRuntimeMetadata = { + clientEntryFile?: string; + lazyChunks?: string[]; + dynamicPreloads?: Record; +}; + +/** + * Read the client build manifest and compute runtime metadata used by + * Cloudflare worker entry injection and Node production server startup. + * + * - `lazyChunks` — chunks only reachable through dynamic `import()`, excluded + * from modulepreload hints. + * - `dynamicPreloads` — per-module JS/CSS files for rendered `next/dynamic()` + * boundaries, injected as preload links during SSR. + * - `clientEntryFile` — the client entry chunk filename (optional, only + * needed for Pages Router). + * + * All file paths are normalised with the configured `assetBase` (basePath) + * and `assetPrefix`. + */ +export function computeClientRuntimeMetadata(opts: { + clientDir: string; + assetBase: string; + assetPrefix: string; + includeClientEntry?: boolean; +}): ClientRuntimeMetadata { + const buildManifestPath = path.join(opts.clientDir, ".vite", "manifest.json"); + const buildManifest = readClientBuildManifest(buildManifestPath); + + const metadata: ClientRuntimeMetadata = {}; + + if (opts.includeClientEntry) { + const entry = findClientEntryFile({ + buildManifest, + clientDir: opts.clientDir, + assetsSubdir: resolveAssetsDir(opts.assetPrefix), + assetBase: opts.assetBase, + }); + if (entry) metadata.clientEntryFile = entry; + } + + if (!buildManifest) return metadata; + + const applyAssetPrefix = (file: string) => + manifestFileWithAssetPrefix(file, opts.assetBase, opts.assetPrefix); + + const lazyChunks = computeLazyChunks(buildManifest).map(applyAssetPrefix); + if (lazyChunks.length > 0) metadata.lazyChunks = lazyChunks; + + const dynamicPreloads = dynamicImportPreloadsWithBase( + computeDynamicImportPreloads(buildManifest), + applyAssetPrefix, + ); + if (Object.keys(dynamicPreloads).length > 0) { + metadata.dynamicPreloads = dynamicPreloads; + } + + return metadata; +} diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts index e178f4441..ebaa8e208 100644 --- a/packages/vinext/src/utils/lazy-chunks.ts +++ b/packages/vinext/src/utils/lazy-chunks.ts @@ -85,3 +85,89 @@ export function computeLazyChunks(buildManifest: Record, file: string | undefined): void { + if (!file || seen.has(file)) return; + seen.add(file); + files.push(file); +} + +function collectStaticChunkFiles( + buildManifest: Record, + key: string, + files: string[], + seenFiles: Set, + visitedChunks: Set, +): void { + if (visitedChunks.has(key)) return; + visitedChunks.add(key); + + const chunk = buildManifest[key]; + if (!chunk) return; + + if (chunk.file.endsWith(".js")) { + addFile(files, seenFiles, chunk.file); + } + for (const cssFile of chunk.css ?? []) { + addFile(files, seenFiles, cssFile); + } + + for (const importedKey of chunk.imports ?? []) { + collectStaticChunkFiles(buildManifest, importedKey, files, seenFiles, visitedChunks); + } +} + +/** + * Compute the production preload files for each module referenced by a + * `next/dynamic()` boundary. + * + * Next.js records module IDs during compilation, then resolves those IDs + * against its react-loadable manifest at render time. Vinext's equivalent + * source of truth is Vite's build manifest: each chunk lists the modules it + * reaches through `dynamicImports`, and each dynamic entry lists the JS/CSS + * files required to evaluate it. + * + * @returns A map keyed by root-relative module ID, with JS/CSS files that + * should be preloaded when that dynamic boundary is rendered. + */ +export function computeDynamicImportPreloads( + buildManifest: Record, +): Record { + const preloads: Record = {}; + + for (const chunk of Object.values(buildManifest)) { + for (const dynamicKey of chunk.dynamicImports ?? []) { + const files: string[] = []; + const seenFiles = new Set(); + collectStaticChunkFiles(buildManifest, dynamicKey, files, seenFiles, new Set()); + if (files.length === 0) continue; + + const normalizedKey = normalizeManifestKey(dynamicKey); + const existing = preloads[normalizedKey] ?? []; + const merged = new Set(existing); + for (const file of files) { + if (merged.has(file)) continue; + merged.add(file); + existing.push(file); + } + preloads[normalizedKey] = existing; + } + } + + return preloads; +} + +export function dynamicImportPreloadsWithBase( + preloads: Record, + applyBase: (file: string) => string, +): Record { + const withBase: Record = {}; + for (const [key, files] of Object.entries(preloads)) { + withBase[key] = files.map(applyBase); + } + return withBase; +} diff --git a/packages/vinext/src/utils/manifest-paths.ts b/packages/vinext/src/utils/manifest-paths.ts index 7f8404a3d..ac9f3783b 100644 --- a/packages/vinext/src/utils/manifest-paths.ts +++ b/packages/vinext/src/utils/manifest-paths.ts @@ -1,3 +1,10 @@ +import { + ASSET_PREFIX_URL_DIR, + isAbsoluteAssetPrefix, + resolveAssetsDir, + resolveAssetUrlPrefix, +} from "./asset-prefix.js"; + function normalizeManifestFile(file: string): string { return file.startsWith("/") ? file.slice(1) : file; } @@ -14,8 +21,28 @@ export function manifestFileWithBase(file: string, base: string): string { return normalizedBase + "/" + normalizedFile; } -export function manifestFilesWithBase(files: string[], base: string): string[] { - return files.map((file) => manifestFileWithBase(file, base)); +export function manifestFileWithAssetPrefix( + file: string, + base: string, + assetPrefix: string, +): string { + if (!assetPrefix) return manifestFileWithBase(file, base); + + const normalizedFile = normalizeManifestFile(file); + const onDiskDir = normalizeManifestFile(resolveAssetsDir(assetPrefix)); + const onDiskDirPrefix = onDiskDir + "/"; + const staticDirPrefix = ASSET_PREFIX_URL_DIR + "/"; + const stripped = normalizedFile.startsWith(onDiskDirPrefix) + ? normalizedFile.slice(onDiskDirPrefix.length) + : normalizedFile.startsWith(staticDirPrefix) + ? normalizedFile.slice(staticDirPrefix.length) + : normalizedFile; + + const urlPrefix = resolveAssetUrlPrefix(assetPrefix); + const normalizedUrlPrefix = isAbsoluteAssetPrefix(assetPrefix) + ? urlPrefix + : normalizeManifestFile(urlPrefix); + return normalizedUrlPrefix + stripped; } /** diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d37abfc61..625b84cb6 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2271,9 +2271,9 @@ describe("App Router Production build", () => { // Client bundle should exist expect(fs.existsSync(path.join(outDir, "client"))).toBe(true); - // Client should have hashed JS assets under Next.js's canonical - // `_next/static/` directory (matches `resolveAssetsDir("")`). - const clientAssets = fs.readdirSync(path.join(outDir, "client", "_next", "static")); + // Client JS should land under Next.js's canonical `_next/static/chunks/` + // directory. + const clientAssets = fs.readdirSync(path.join(outDir, "client", "_next", "static", "chunks")); expect(clientAssets.some((f: string) => f.endsWith(".js"))).toBe(true); // RSC bundle should contain route handling code @@ -2308,7 +2308,9 @@ describe("App Router Production build", () => { expect(homeHtml).toContain(" // tag (via React's bootstrapModules option) referencing hashed assets. - expect(homeHtml).toMatch(/]+type="module"[^>]+src="\/_next\/static\/[^"]+\.js"/); + expect(homeHtml).toMatch( + /]+type="module"[^>]+src="\/_next\/static\/chunks\/[^"]+\.js"/, + ); // Dynamic route works const blogRes = await fetch(`${previewUrl}/blog/test-post`); @@ -2408,6 +2410,129 @@ describe("App Router Production server (startProdServer)", () => { expect(secondHtml).not.toContain('nonce="first"'); }); + it("preloads rendered next/dynamic chunks with the CSP nonce", async () => { + // Ported from Next.js: test/e2e/app-dir/next-dynamic-csp-nonce/next-dynamic-csp-nonce.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-dynamic-csp-nonce/next-dynamic-csp-nonce.test.ts + const res = await fetch(`${baseUrl}/nextjs-compat/dynamic?csp-nonce=1`); + expect(res.status).toBe(200); + expect(res.headers.get("content-security-policy")).toBe( + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + ); + + const html = await res.text(); + const dynamicScriptPreloads = + html.match( + /]*\brel="preload")(?=[^>]*\bas="script")(?=[^>]*\bhref="[^"]*\/_next\/static\/chunks\/[^"]*\.js")[^>]*>/g, + ) ?? []; + + expect(dynamicScriptPreloads.length).toBeGreaterThan(0); + for (const tag of dynamicScriptPreloads) { + expect(tag).toContain('nonce="vinext-test-nonce"'); + } + }); + + it("preloads rendered next/dynamic chunks with assetPrefix and the CSP nonce", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-dynamic-asset-prefix-")); + const fixtureRoot = path.join(tmpDir, "fixture"); + const prefixedOutDir = path.join(fixtureRoot, "dist"); + let assetPrefixServer: import("node:http").Server | undefined; + const prodGlobalKeys = [ + "__VINEXT_CLIENT_ENTRY__", + "__VINEXT_DYNAMIC_PRELOADS__", + "__VINEXT_LAZY_CHUNKS__", + "__VINEXT_SSR_MANIFEST__", + "__vite_rsc_client_require__", + "__vite_rsc_require__", + "__vite_rsc_server_require__", + "__webpack_chunk_load__", + "__webpack_require__", + ]; + // startProdServer installs build/runtime globals process-wide. This test + // starts a second prod server while the shared server is still alive, so + // restore the shared server's globals before later tests run. + const previousGlobals = new Map( + prodGlobalKeys.map((key) => [ + key, + { + exists: Reflect.has(globalThis, key), + value: Reflect.get(globalThis, key), + }, + ]), + ); + + try { + fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true }); + fs.rmSync(prefixedOutDir, { recursive: true, force: true }); + const fixtureNodeModules = path.join(fixtureRoot, "node_modules"); + if (!fs.existsSync(fixtureNodeModules)) { + fs.symlinkSync( + path.resolve(__dirname, "..", "node_modules"), + fixtureNodeModules, + "junction", + ); + } + + const nextConfigPath = path.join(fixtureRoot, "next.config.ts"); + const nextConfig = fs.readFileSync(nextConfigPath, "utf-8"); + fs.writeFileSync( + nextConfigPath, + nextConfig.replace( + "const nextConfig: NextConfig = {", + 'const nextConfig: NextConfig = {\n assetPrefix: "/cdn",', + ), + ); + + const builder = await createBuilder({ + root: fixtureRoot, + configFile: false, + plugins: [vinext({ appDir: fixtureRoot })], + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + ({ server: assetPrefixServer } = await startProdServer({ + port: 0, + outDir: prefixedOutDir, + noCompression: true, + })); + const addr = assetPrefixServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + const tmpBaseUrl = `http://localhost:${port}`; + + const res = await fetch(`${tmpBaseUrl}/nextjs-compat/dynamic?csp-nonce=1`); + expect(res.status).toBe(200); + expect(res.headers.get("content-security-policy")).toBe( + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + ); + + const html = await res.text(); + // DynamicPreloadChunks emits ReactDOM.preload(..., { fetchPriority: "low" }); + // use that signal to avoid matching route bootstrap modulepreload links. + const dynamicScriptPreloads = (html.match(/]*>/g) ?? []).filter( + (tag) => + tag.includes('fetchPriority="low"') && + tag.includes('nonce="vinext-test-nonce"') && + tag.includes("/_next/static/chunks/"), + ); + + expect(dynamicScriptPreloads.length).toBeGreaterThan(0); + for (const tag of dynamicScriptPreloads) { + expect(tag).toMatch(/\bhref="\/cdn\/_next\/static\/chunks\/[^"]+\.js"/); + } + } finally { + assetPrefixServer?.close(); + for (const [key, previous] of previousGlobals) { + if (previous.exists) { + Reflect.set(globalThis, key, previous.value); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 60000); + it("does not collapse encoded slashes onto nested routes in production", async () => { const encodedRes = await fetch(`${baseUrl}/headers%2Foverride-from-middleware`); expect(encodedRes.status).toBe(404); @@ -2551,14 +2676,13 @@ describe("App Router Production server (startProdServer)", () => { }); it("serves static assets with cache headers", async () => { - // Find an actual hashed asset from the build (on disk under - // `_next/static/`, matching `resolveAssetsDir("")`). - const assetsDir = path.join(outDir, "client", "_next", "static"); + // Find an actual hashed JS asset from the build. + const assetsDir = path.join(outDir, "client", "_next", "static", "chunks"); const assets = fs.readdirSync(assetsDir); const jsFile = assets.find((f: string) => f.endsWith(".js")); expect(jsFile).toBeDefined(); - const res = await fetch(`${baseUrl}/_next/static/${jsFile}`); + const res = await fetch(`${baseUrl}/_next/static/chunks/${jsFile}`); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("javascript"); expect(res.headers.get("cache-control")).toContain("immutable"); diff --git a/tests/asset-prefix.test.ts b/tests/asset-prefix.test.ts index a0bcbb566..839df8060 100644 --- a/tests/asset-prefix.test.ts +++ b/tests/asset-prefix.test.ts @@ -35,6 +35,7 @@ import { assetPrefixPathname, ASSET_PREFIX_URL_DIR, } from "../packages/vinext/src/utils/asset-prefix.js"; +import { manifestFileWithAssetPrefix } from "../packages/vinext/src/utils/manifest-paths.js"; import { resolveAppRouterAssetPath } from "../packages/vinext/src/server/prod-server.js"; import { normalizeAssetPrefix } from "../packages/vinext/src/config/next-config.js"; @@ -130,6 +131,36 @@ describe("resolveAssetUrlPrefix", () => { }); }); +describe("manifestFileWithAssetPrefix", () => { + it("uses basePath-compatible base when assetPrefix is unset", () => { + expect(manifestFileWithAssetPrefix("_next/static/chunks/page.js", "/docs/", "")).toBe( + "docs/_next/static/chunks/page.js", + ); + }); + + it("anchors unprefixed manifest files under a path assetPrefix", () => { + expect(manifestFileWithAssetPrefix("_next/static/chunks/page.js", "/", "/cdn")).toBe( + "cdn/_next/static/chunks/page.js", + ); + }); + + it("does not double-prefix files already emitted under a path assetPrefix", () => { + expect(manifestFileWithAssetPrefix("cdn/_next/static/chunks/page.js", "/docs/", "/cdn")).toBe( + "cdn/_next/static/chunks/page.js", + ); + }); + + it("anchors manifest files under an absolute assetPrefix", () => { + expect( + manifestFileWithAssetPrefix( + "_next/static/chunks/page.js", + "/docs/", + "https://cdn.example.com/assets", + ), + ).toBe("https://cdn.example.com/assets/_next/static/chunks/page.js"); + }); +}); + describe("assetPrefixPathname", () => { it("returns empty for unset prefix and absolute URLs without a path component", () => { expect(assetPrefixPathname("")).toBe(""); @@ -292,6 +323,18 @@ async function buildFixtureWithConfig( return { fixtureRoot, outDir }; } +function directoryContainsFileWithExtension(dir: string, extension: string): boolean { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (directoryContainsFileWithExtension(entryPath, extension)) return true; + continue; + } + if (entry.isFile() && entry.name.endsWith(extension)) return true; + } + return false; +} + describe("assetPrefix end-to-end build", () => { // Track tmp dirs so we can clean up even if a build throws. `cleanups` is // populated by `buildFixtureWithConfig` synchronously right after the tmp @@ -317,8 +360,7 @@ describe("assetPrefix end-to-end build", () => { "static", ); expect(fs.existsSync(onDiskStatic), `expected on-disk layout under ${onDiskStatic}`).toBe(true); - const entries = fs.readdirSync(onDiskStatic); - expect(entries.some((f) => f.endsWith(".js"))).toBe(true); + expect(directoryContainsFileWithExtension(onDiskStatic, ".js")).toBe(true); // Serve the build via startProdServer and verify SSR HTML references // the assetPrefix-anchored URLs, and that those URLs return 200. diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index b953f7b2e..70b457069 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -17,7 +17,11 @@ import { clientTreeshakeConfig, getClientTreeshakeConfigForVite, } from "../packages/vinext/src/build/client-build-config.js"; -import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; +import { + computeDynamicImportPreloads, + computeLazyChunks, +} from "../packages/vinext/src/utils/lazy-chunks.js"; +import { transformNextDynamicPreloadMetadata as _transformNextDynamicPreloadMetadata } from "../packages/vinext/src/plugins/dynamic-preload-metadata.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; // Create a clientManualChunks instance with a test shims directory. @@ -910,6 +914,8 @@ describe("treeshake config integration", () => { // output config should include the min chunk size setting. const output = getBuildBundlerOptions(result).output; expect(output).toBeDefined(); + expect(output.entryFileNames).toBe("_next/static/chunks/[name]-[hash].js"); + expect(output.chunkFileNames).toBe("_next/static/chunks/[name]-[hash].js"); if (output.codeSplitting) { expect(output.codeSplitting.minSize).toBe(10_000); } else { @@ -920,12 +926,11 @@ describe("treeshake config integration", () => { } }, 15000); - it("App Router client env gets manifest: true when Cloudflare plugin is present", async () => { - // When deploying to Cloudflare Workers, the client environment must produce - // a build manifest (manifest.json) so the vinext:cloudflare-build plugin can - // read dynamicImports and compute lazy chunks. Without this, all chunks get - // modulepreloaded on every page, defeating code-splitting for React.lazy() - // and next/dynamic boundaries. + it("App Router client env gets manifest: true for dynamic preload metadata", async () => { + // App Router production rendering uses Vite's client manifest to map + // next/dynamic module IDs to the chunk files that need rendered preload + // hints. Cloudflare builds also read it during closeBundle to inject those + // globals into the Worker entry. const vinext = (await import("../packages/vinext/src/index.js")).default; const plugins = vinext(); @@ -968,11 +973,13 @@ describe("treeshake config integration", () => { }); // Client environment should have manifest: true for lazy chunk detection + // and rendered next/dynamic preload metadata. expect(result.environments).toBeDefined(); expect(result.environments.client).toBeDefined(); expect(result.environments.client.build.manifest).toBe(true); - // Without Cloudflare plugin, manifest should NOT be set (standard App Router) + // Node production App Router needs the same manifest at startProdServer() + // time, so this is no longer Cloudflare-only. const resultNoCf = await (mainPlugin as any).config( { root: tmpDir, @@ -982,7 +989,7 @@ describe("treeshake config integration", () => { { command: "build" }, ); - expect(resultNoCf.environments.client.build.manifest).toBeUndefined(); + expect(resultNoCf.environments.client.build.manifest).toBe(true); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } @@ -1259,6 +1266,297 @@ describe("computeLazyChunks", () => { }); }); +describe("computeDynamicImportPreloads", () => { + it("maps each dynamic import to its own JS and static dependency files", () => { + const manifest = { + "virtual:vinext-app-browser-entry": { + file: "_next/static/app-entry.js", + isEntry: true, + imports: ["node_modules/react/index.js"], + dynamicImports: ["app/dynamic/widget.tsx"], + }, + "node_modules/react/index.js": { + file: "_next/static/framework.js", + }, + "app/dynamic/widget.tsx": { + file: "_next/static/widget.js", + isDynamicEntry: true, + imports: ["app/dynamic/widget-helper.ts"], + css: ["_next/static/widget.css"], + }, + "app/dynamic/widget-helper.ts": { + file: "_next/static/widget-helper.js", + }, + "app/dynamic/unrelated.tsx": { + file: "_next/static/unrelated.js", + isDynamicEntry: true, + }, + }; + + expect(computeDynamicImportPreloads(manifest)).toEqual({ + "app/dynamic/widget.tsx": [ + "_next/static/widget.js", + "_next/static/widget.css", + "_next/static/widget-helper.js", + ], + }); + }); + + it("does not pull nested dynamic imports into the parent boundary", () => { + const manifest = { + "app/page.tsx": { + file: "_next/static/page.js", + isEntry: true, + dynamicImports: ["app/dynamic/chart.tsx"], + }, + "app/dynamic/chart.tsx": { + file: "_next/static/chart.js", + isDynamicEntry: true, + dynamicImports: ["app/dynamic/heavy-vendor.ts"], + }, + "app/dynamic/heavy-vendor.ts": { + file: "_next/static/heavy-vendor.js", + isDynamicEntry: true, + }, + }; + + expect(computeDynamicImportPreloads(manifest)).toEqual({ + "app/dynamic/chart.tsx": ["_next/static/chart.js"], + "app/dynamic/heavy-vendor.ts": ["_next/static/heavy-vendor.js"], + }); + }); +}); + +describe("next/dynamic preload metadata transform", () => { + const root = path.resolve("/repo"); + const importer = path.join(root, "app/page.tsx"); + const resolveDynamicImport = async (specifier: string) => + specifier === "./dynamic-widget" + ? path.join(root, "app/dynamic-widget.tsx") + : specifier === "./named" + ? path.join(root, "app/named.tsx") + : specifier === "./ignored" + ? path.join(root, "app/ignored.tsx") + : null; + + it("adds loadableGenerated modules to dynamic loader calls", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(() => import("./dynamic-widget"), { loading: Loading });`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + }); + + it("preserves existing explicit loadableGenerated metadata", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(() => import("./dynamic-widget"), { loadableGenerated: { modules: ["custom"] } });`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("supports the object loader form", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic({ loader: () => import("./named"), ssr: true });`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/named.tsx"] }`); + }); + + it("does not transform a function parameter that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `function makeThing(dynamic) {`, + ` return dynamic(() => import("./dynamic-widget"));`, + `}`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("does not transform an arrow parameter that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const makeThing = (dynamic) => dynamic(() => import("./dynamic-widget"));`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("does not transform a block binding that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `{`, + ` const dynamic = customFactory;`, + ` dynamic(() => import("./dynamic-widget"));`, + `}`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("does not transform a switch case binding that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `switch (kind) {`, + ` case "x":`, + ` const dynamic = customFactory;`, + ` dynamic(() => import("./dynamic-widget"));`, + `}`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("does not transform inside a named class expression that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const Component = class dynamic {`, + ` static Widget = dynamic(() => import("./dynamic-widget"));`, + `};`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("transforms renamed next/dynamic imports", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import loadDynamic from "next/dynamic";`, + `const Widget = loadDynamic(() => import("./dynamic-widget"));`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + }); + + it("only records the object-form loader import", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic({`, + ` loader: () => import("./dynamic-widget"),`, + ` debugOnly: () => import("./ignored"),`, + `});`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + expect(result?.code).not.toContain("app/ignored.tsx"); + }); + + it("transforms dynamic imports with whitespace between import and paren", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(() => import ("./dynamic_widget"), { loading: Loading });`, + ].join("\n"), + importer, + root, + async (specifier) => + specifier === "./dynamic_widget" ? path.join(root, "app/dynamic_widget.tsx") : null, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic_widget.tsx"] }`); + }); + + it("transforms generic next/dynamic calls in TSX-shaped source after type stripping", async () => { + // Vite's built-in TS transform strips type annotations and JSX before + // the transform hook runs, so dynamic(args) becomes dynamic(args) + // and { loading: () =>

} becomes { loading: () => ... }. + // This test verifies the post-strip JS passes through parseAst correctly. + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(`, + ` () => import("./dynamic-widget"),`, + ` { loading: () => null },`, + `);`, + ].join("\n"), + importer, + root, + async (specifier) => + specifier === "./dynamic-widget" ? path.join(root, "app/dynamic-widget.tsx") : null, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + }); + + it("preserves existing loadableGenerated metadata in object-form dynamic options", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic({`, + ` loader: () => import("./dynamic-widget"),`, + ` loadableGenerated: { modules: ["custom"] },`, + `});`, + ].join("\n"); + + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); +}); + describe("augmentSsrManifestFromBundle", () => { it("backfills inlined page modules with the containing entry chunk", () => { const bundle = { diff --git a/tests/client-build-manifest.test.ts b/tests/client-build-manifest.test.ts index 998525a8c..7714191a2 100644 --- a/tests/client-build-manifest.test.ts +++ b/tests/client-build-manifest.test.ts @@ -7,6 +7,7 @@ import { findClientEntryFileFromManifest, readClientBuildManifest, } from "../packages/vinext/src/utils/client-build-manifest.js"; +import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; describe("client build manifest helpers", () => { let tmpDir: string; @@ -114,3 +115,228 @@ describe("client build manifest helpers", () => { expect(entry).toBe("cdn/_next/static/vinext-client-entry-5678.js"); }); }); + +describe("computeClientRuntimeMetadata", () => { + let tmpDir: string; + let clientDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-client-runtime-metadata-")); + clientDir = path.join(tmpDir, "client"); + await fsp.mkdir(path.join(clientDir, ".vite"), { recursive: true }); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + const baseManifest = { + "entry.js": { + file: "_next/static/entry-abc123.js", + isEntry: true, + imports: ["shared.js"], + dynamicImports: ["LazyComponent"], + }, + "shared.js": { + file: "_next/static/shared-def456.js", + }, + LazyComponent: { + file: "_next/static/lazy-ghi789.js", + isDynamicEntry: true, + css: ["_next/static/lazy-jkl012.css"], + }, + }; + + it("returns empty metadata when no manifest exists", () => { + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + }); + expect(result).toEqual({}); + }); + + it("returns empty metadata when manifest is malformed", async () => { + await fsp.writeFile(path.join(clientDir, ".vite", "manifest.json"), "not json"); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + }); + expect(result).toEqual({}); + }); + + it("computes client entry, lazy chunks, and dynamic preloads without base path or asset prefix", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "_next/static/entry-abc123.js", + lazyChunks: ["_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["_next/static/lazy-ghi789.js", "_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("applies base path to all paths", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/docs/", + assetPrefix: "", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "docs/_next/static/entry-abc123.js", + lazyChunks: ["docs/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["docs/_next/static/lazy-ghi789.js", "docs/_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("applies path-based asset prefix", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "/cdn", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "_next/static/entry-abc123.js", + lazyChunks: ["cdn/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["cdn/_next/static/lazy-ghi789.js", "cdn/_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("applies absolute URL asset prefix", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "https://cdn.example.com/assets", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "_next/static/entry-abc123.js", + lazyChunks: ["https://cdn.example.com/assets/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: [ + "https://cdn.example.com/assets/_next/static/lazy-ghi789.js", + "https://cdn.example.com/assets/_next/static/lazy-jkl012.css", + ], + }, + }); + }); + + it("does not double-prefix when manifest already has asset-prefix paths", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify({ + "entry.js": { + file: "cdn/_next/static/entry-abc123.js", + isEntry: true, + imports: ["shared.js"], + dynamicImports: ["LazyComponent"], + }, + "shared.js": { + file: "cdn/_next/static/shared-def456.js", + }, + LazyComponent: { + file: "cdn/_next/static/lazy-ghi789.js", + isDynamicEntry: true, + css: ["cdn/_next/static/lazy-jkl012.css"], + }, + }), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "/cdn", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "cdn/_next/static/entry-abc123.js", + lazyChunks: ["cdn/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["cdn/_next/static/lazy-ghi789.js", "cdn/_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("omits client entry when includeClientEntry is false", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + includeClientEntry: false, + }); + expect(result.clientEntryFile).toBeUndefined(); + expect(result.lazyChunks).toBeDefined(); + expect(result.dynamicPreloads).toBeDefined(); + }); + + it("falls back to on-disk client entry scan when manifest has no entry", async () => { + await fsp.mkdir(path.join(clientDir, "_next", "static"), { recursive: true }); + await fsp.writeFile(path.join(clientDir, "_next", "static", "vinext-client-entry-abcd.js"), ""); + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify({ + "lazy-chunk.js": { + file: "_next/static/lazy-1234.js", + isDynamicEntry: true, + }, + }), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + includeClientEntry: true, + }); + expect(result.clientEntryFile).toBe("_next/static/vinext-client-entry-abcd.js"); + expect(result.lazyChunks).toBeDefined(); + }); + + it("lazy chunks and dynamic preloads use the same URL normalisation path", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/docs/", + assetPrefix: "/cdn", + includeClientEntry: true, + }); + // Both lazyChunks and dynamicPreloads pass through the same + // manifestFileWithAssetPrefix, so the same chunk file gets the + // identical URL regardless of which computation surface produced it. + expect(result.lazyChunks).toContain("cdn/_next/static/lazy-ghi789.js"); + expect(result.dynamicPreloads!.LazyComponent).toContain("cdn/_next/static/lazy-ghi789.js"); + }); +}); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 5d84486af..27686d2d6 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -27,9 +27,9 @@ import { findInNodeModules, ensureViteConfigCompatibility, } from "../packages/vinext/src/utils/project.js"; -import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; import { scanPublicFileRoutes } from "../packages/vinext/src/utils/public-routes.js"; import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; +import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; import { mergeHeaders, resolveStaticAssetSignal, @@ -1952,30 +1952,20 @@ describe("Cloudflare _headers file generation", () => { describe("Cloudflare closeBundle lazy chunk injection", () => { /** * Replicates the closeBundle hook logic for App Router builds. - * In #358's architecture, the RSC env IS the worker, so the worker entry - * is at dist/server/index.js. The RSC plugin handles __VINEXT_CLIENT_ENTRY__, - * but we still need to inject __VINEXT_LAZY_CHUNKS__ and __VINEXT_SSR_MANIFEST__. + * Uses the real computeClientRuntimeMetadata helper instead of + * duplicating manifest/runtime-metadata logic. */ - function simulateCloseBundleAppRouter(buildRoot: string, base = "/"): void { + function simulateCloseBundleAppRouter(buildRoot: string, base = "/", assetPrefix = ""): void { const distDir = path.resolve(buildRoot, "dist"); if (!fs.existsSync(distDir)) return; const clientDir = path.resolve(buildRoot, "dist", "client"); - // Read build manifest and compute lazy chunks - let lazyChunksData: 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")); - const lazy = computeLazyChunks(buildManifest).map((file) => - manifestFileWithBase(file, base), - ); - if (lazy.length > 0) lazyChunksData = lazy; - } catch { - /* ignore */ - } - } + const runtimeMetadata = computeClientRuntimeMetadata({ + clientDir, + assetBase: base, + assetPrefix, + }); // Read SSR manifest let ssrManifestData: Record | null = null; @@ -1988,16 +1978,25 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { } } - // App Router: inject into dist/server/index.js (NOT __VINEXT_CLIENT_ENTRY__) const workerEntry = path.resolve(distDir, "server", "index.js"); - if (fs.existsSync(workerEntry) && (lazyChunksData || ssrManifestData)) { + if ( + fs.existsSync(workerEntry) && + (runtimeMetadata.lazyChunks || runtimeMetadata.dynamicPreloads || ssrManifestData) + ) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (ssrManifestData) { globals.push(`globalThis.__VINEXT_SSR_MANIFEST__ = ${JSON.stringify(ssrManifestData)};`); } - if (lazyChunksData) { - globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`); + if (runtimeMetadata.lazyChunks) { + globals.push( + `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(runtimeMetadata.lazyChunks)};`, + ); + } + if (runtimeMetadata.dynamicPreloads) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(runtimeMetadata.dynamicPreloads)};`, + ); } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); @@ -2006,15 +2005,22 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { /** * Replicates the closeBundle hook logic for Pages Router builds. - * The worker entry is found by scanning dist/ for a directory containing - * wrangler.json. All three globals are injected. + * Uses the real computeClientRuntimeMetadata helper instead of + * duplicating manifest/runtime-metadata logic. */ - function simulateCloseBundlePagesRouter(buildRoot: string, base = "/"): void { + function simulateCloseBundlePagesRouter(buildRoot: string, base = "/", assetPrefix = ""): void { const distDir = path.resolve(buildRoot, "dist"); if (!fs.existsSync(distDir)) return; const clientDir = path.resolve(buildRoot, "dist", "client"); + const runtimeMetadata = computeClientRuntimeMetadata({ + clientDir, + assetBase: base, + assetPrefix, + includeClientEntry: true, + }); + // Find worker output directory (contains wrangler.json) let workerOutDir: string | null = null; for (const entry of fs.readdirSync(distDir)) { @@ -2033,28 +2039,6 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const workerEntry = path.join(workerOutDir, "index.js"); if (!fs.existsSync(workerEntry)) return; - // Read build manifest and compute lazy chunks - 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")); - for (const [, value] of Object.entries(buildManifest) as [string, any][]) { - if (value && value.isEntry && value.file) { - clientEntryFile = manifestFileWithBase(value.file, base); - break; - } - } - const lazy = computeLazyChunks(buildManifest).map((file) => - manifestFileWithBase(file, base), - ); - if (lazy.length > 0) lazyChunksData = lazy; - } catch { - /* ignore */ - } - } - // Read SSR manifest let ssrManifestData: Record | null = null; const ssrManifestPath = path.join(clientDir, ".vite", "ssr-manifest.json"); @@ -2066,18 +2050,31 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { } } - // Pages Router: inject all three globals - if (clientEntryFile || ssrManifestData || lazyChunksData) { + if ( + runtimeMetadata.clientEntryFile || + ssrManifestData || + runtimeMetadata.lazyChunks || + runtimeMetadata.dynamicPreloads + ) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; - if (clientEntryFile) { - globals.push(`globalThis.__VINEXT_CLIENT_ENTRY__ = ${JSON.stringify(clientEntryFile)};`); + if (runtimeMetadata.clientEntryFile) { + globals.push( + `globalThis.__VINEXT_CLIENT_ENTRY__ = ${JSON.stringify(runtimeMetadata.clientEntryFile)};`, + ); } if (ssrManifestData) { globals.push(`globalThis.__VINEXT_SSR_MANIFEST__ = ${JSON.stringify(ssrManifestData)};`); } - if (lazyChunksData) { - globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`); + if (runtimeMetadata.lazyChunks) { + globals.push( + `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(runtimeMetadata.lazyChunks)};`, + ); + } + if (runtimeMetadata.dynamicPreloads) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(runtimeMetadata.dynamicPreloads)};`, + ); } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); @@ -2186,6 +2183,51 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { expect(lazyChunks).not.toContain("assets/framework.js"); }); + it("App Router: injects __VINEXT_DYNAMIC_PRELOADS__ into dist/server/index.js", () => { + setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundleAppRouter(tmpDir); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); + expect(code).toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["assets/mermaid-chart.js","assets/mermaid-vendor.js"]`, + ); + expect(code).not.toContain(`"virtual:vinext-app-browser-entry"`); + }); + + it("App Router: prefixes lazy chunks and dynamic preloads with assetPrefix", () => { + setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundleAppRouter(tmpDir, "/docs/", "/cdn-prefix"); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); + const lazyMatch = code.match(/globalThis\.__VINEXT_LAZY_CHUNKS__\s*=\s*(\[.*?\]);/); + expect(lazyMatch).not.toBeNull(); + const lazyChunks = JSON.parse(lazyMatch![1]); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-chart.js"); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-vendor.js"); + expect(lazyChunks).not.toContain("docs/assets/mermaid-chart.js"); + + const preloadMatch = code.match(/globalThis\.__VINEXT_DYNAMIC_PRELOADS__\s*=\s*(\{.*?\});/); + expect(preloadMatch).not.toBeNull(); + const dynamicPreloads = JSON.parse(preloadMatch![1]); + expect(dynamicPreloads["src/components/MermaidChart.tsx"]).toEqual([ + "cdn-prefix/_next/static/assets/mermaid-chart.js", + "cdn-prefix/_next/static/assets/mermaid-vendor.js", + ]); + }); + + it("App Router: emits absolute assetPrefix URLs in dynamic preload globals", () => { + setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundleAppRouter(tmpDir, "/docs/", "https://cdn.example.com/assets"); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); + expect(code).toContain("https://cdn.example.com/assets/_next/static/assets/mermaid-chart.js"); + expect(code).not.toContain("docs/assets/mermaid-chart.js"); + }); + it("App Router: does NOT inject __VINEXT_CLIENT_ENTRY__", () => { setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); @@ -2237,8 +2279,10 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { simulateCloseBundleAppRouter(tmpDir); const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); - // No globals should be injected since there are no lazy chunks and no SSR manifest + // No globals should be injected since there are no lazy chunks, no dynamic + // preload entries, and no SSR manifest expect(code).not.toContain("globalThis.__VINEXT_LAZY_CHUNKS__"); + expect(code).not.toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); expect(code).not.toContain("globalThis.__VINEXT_SSR_MANIFEST__"); // Original code untouched expect(code).toBe("// RSC worker entry\nexport default { fetch() {} };"); @@ -2258,7 +2302,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { // ── Pages Router tests ──────────────────────────────────────────────── - it("Pages Router: injects all three globals into worker entry", () => { + it("Pages Router: injects all runtime globals into worker entry", () => { const ssrManifest = { "pages/index.tsx": ["/assets/page-index.js", "/assets/page-index.css"], }; @@ -2270,6 +2314,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { expect(code).toContain("globalThis.__VINEXT_CLIENT_ENTRY__"); expect(code).toContain("globalThis.__VINEXT_SSR_MANIFEST__"); expect(code).toContain("globalThis.__VINEXT_LAZY_CHUNKS__"); + expect(code).toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); }); it("Pages Router: injects correct lazy chunks", () => { @@ -2287,6 +2332,18 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { expect(lazyChunks).not.toContain("assets/framework.js"); }); + it("Pages Router: injects __VINEXT_DYNAMIC_PRELOADS__ into worker entry", () => { + setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundlePagesRouter(tmpDir); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "worker", "index.js"), "utf-8"); + expect(code).toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["assets/mermaid-chart.js","assets/mermaid-vendor.js"]`, + ); + }); + it("Pages Router: prefixes client entry and lazy chunks with basePath", () => { setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks); @@ -2300,6 +2357,26 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const lazyChunks = JSON.parse(match![1]); expect(lazyChunks).toContain("docs/assets/mermaid-chart.js"); expect(lazyChunks).toContain("docs/assets/mermaid-vendor.js"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["docs/assets/mermaid-chart.js","docs/assets/mermaid-vendor.js"]`, + ); + }); + + it("Pages Router: prefixes lazy chunks and dynamic preloads with assetPrefix", () => { + setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundlePagesRouter(tmpDir, "/docs/", "/cdn-prefix"); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "worker", "index.js"), "utf-8"); + const lazyMatch = code.match(/globalThis\.__VINEXT_LAZY_CHUNKS__\s*=\s*(\[.*?\]);/); + expect(lazyMatch).not.toBeNull(); + const lazyChunks = JSON.parse(lazyMatch![1]); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-chart.js"); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-vendor.js"); + expect(lazyChunks).not.toContain("docs/assets/mermaid-chart.js"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["cdn-prefix/_next/static/assets/mermaid-chart.js","cdn-prefix/_next/static/assets/mermaid-vendor.js"]`, + ); }); it("Pages Router: finds worker entry via wrangler.json directory scan", () => { diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 7894f831b..b968529bd 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2631,9 +2631,9 @@ export const config = { matcher: ["/protected"] }; }, }); - // Verify client output exists under Next.js's canonical `_next/static/` - // directory (matches `resolveAssetsDir("")`). - const assetsDir = path.join(outDir, "client", "_next", "static"); + // Verify client JS output exists under Next.js's canonical + // `_next/static/chunks/` directory. + const assetsDir = path.join(outDir, "client", "_next", "static", "chunks"); expect(fs.existsSync(assetsDir)).toBe(true); // Verify SSR manifest was produced