From 17a416e3191e742bf26740dba7860fd4122a9410 Mon Sep 17 00:00:00 2001 From: GPU VM Date: Fri, 27 Feb 2026 23:23:57 +0000 Subject: [PATCH 01/11] feat: add standalone self-host output for vinext build Generate dist/standalone from output: 'standalone' with a runnable server entry and runtime deps, and align init scripts with vinext build/start for production self-host workflows. --- README.md | 11 +- packages/vinext/src/build/standalone.ts | 318 ++++++++++++++++++++++ packages/vinext/src/check.ts | 2 +- packages/vinext/src/cli.ts | 19 ++ packages/vinext/src/config/next-config.ts | 18 +- packages/vinext/src/index.ts | 85 +----- packages/vinext/src/init.ts | 11 +- packages/vinext/src/server/prod-server.ts | 2 +- packages/vinext/src/utils/lazy-chunks.ts | 88 ++++++ tests/init.test.ts | 22 +- tests/standalone-build.test.ts | 252 +++++++++++++++++ 11 files changed, 726 insertions(+), 102 deletions(-) create mode 100644 packages/vinext/src/build/standalone.ts create mode 100644 packages/vinext/src/utils/lazy-chunks.ts create mode 100644 tests/standalone-build.test.ts diff --git a/README.md b/README.md index 4a423cd75..79ec3a208 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ Options: `-p / --port `, `-H / --hostname `, `--turbopack` (accepted `vinext init` options: `--port ` (default: 3001), `--skip-check`, `--force`. +If your `next.config.*` sets `output: "standalone"`, `vinext build` emits a self-hosting bundle at `dist/standalone/`. Start it with: + +```bash +node dist/standalone/server.js +``` + ### Starting a new vinext project Run `npm create next-app@latest` to create a new Next.js project, and then follow these instructions to migrate it to vinext. @@ -88,13 +94,15 @@ This will: 2. Install `vite` (and `@vitejs/plugin-rsc` for App Router projects) as devDependencies 3. Rename CJS config files (e.g. `postcss.config.js` -> `.cjs`) to avoid ESM conflicts 4. Add `"type": "module"` to `package.json` -5. Add `dev:vinext` and `build:vinext` scripts to `package.json` +5. Add `dev:vinext`, `build:vinext`, and `start:vinext` scripts to `package.json` 6. Generate a minimal `vite.config.ts` The migration is non-destructive -- your existing Next.js setup continues to work alongside vinext. It does not modify `next.config`, `tsconfig.json`, or any source files, and it does not remove Next.js dependencies. ```bash npm run dev:vinext # Start the vinext dev server (port 3001) +npm run build:vinext # Build production output with vinext +npm run start:vinext # Start vinext production server npm run dev # Still runs Next.js as before ``` @@ -311,6 +319,7 @@ Every `next/*` import is shimmed to a Vite-compatible implementation. | `generateStaticParams` | ✅ | With `dynamicParams` enforcement | | Metadata file routes | ✅ | sitemap.xml, robots.txt, manifest, favicon, OG images (static + dynamic) | | Static export (`output: 'export'`) | ✅ | Generates static HTML/JSON for all routes | +| Standalone output (`output: 'standalone'`) | ✅ | Generates `dist/standalone` with `server.js`, build artifacts, and runtime deps | | `connection()` | ✅ | Forces dynamic rendering | | `"use cache"` directive | ✅ | File-level and function-level. `cacheLife()` profiles, `cacheTag()`, stale-while-revalidate | | `instrumentation.ts` | ✅ | `register()` and `onRequestError()` callbacks | diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts new file mode 100644 index 000000000..16d46bc0c --- /dev/null +++ b/packages/vinext/src/build/standalone.ts @@ -0,0 +1,318 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +interface PackageJson { + name?: string; + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; +} + +export interface StandaloneBuildOptions { + root: string; + outDir: string; + /** + * Test hook: override vinext package root used for embedding runtime files. + */ + vinextPackageRoot?: string; +} + +export interface StandaloneBuildResult { + standaloneDir: string; + copiedPackages: string[]; +} + +interface QueueEntry { + packageName: string; + resolver: NodeRequire; + optional: boolean; +} + +function readPackageJson(packageJsonPath: string): PackageJson { + return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as PackageJson; +} + +function runtimeDeps(pkg: PackageJson): string[] { + return Object.keys({ + ...pkg.dependencies, + ...pkg.optionalDependencies, + }); +} + +function walkFiles(dir: string): string[] { + const files: string[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(fullPath)); + } else { + files.push(fullPath); + } + } + return files; +} + +function packageNameFromSpecifier(specifier: string): string | null { + if ( + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("node:") || + specifier.startsWith("#") + ) { + return null; + } + + if (specifier.startsWith("@")) { + const parts = specifier.split("/"); + if (parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + return null; + } + + return specifier.split("/")[0] || null; +} + +function collectServerExternalPackages(serverDir: string): string[] { + const packages = new Set(); + const files = walkFiles(serverDir).filter((filePath) => /\.(c|m)?js$/.test(filePath)); + + const fromRE = /\bfrom\s*["']([^"']+)["']/g; + const importSideEffectRE = /\bimport\s*["']([^"']+)["']/g; + const dynamicImportRE = /import\(\s*["']([^"']+)["']\s*\)/g; + const requireRE = /require\(\s*["']([^"']+)["']\s*\)/g; + + for (const filePath of files) { + const code = fs.readFileSync(filePath, "utf-8"); + + // These regexes are stateful (/g) and intentionally function-local. + // Reset lastIndex before every file scan to avoid leaking state across files. + for (const regex of [fromRE, importSideEffectRE, dynamicImportRE, requireRE]) { + regex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(code)) !== null) { + const packageName = packageNameFromSpecifier(match[1]); + if (packageName) { + packages.add(packageName); + } + } + } + } + + return [...packages]; +} + +function resolvePackageJsonPath( + packageName: string, + resolver: NodeRequire, +): string | null { + try { + return resolver.resolve(`${packageName}/package.json`); + } catch { + // Some packages do not export ./package.json via exports map. + // Fallback: resolve package entry and walk up to the nearest matching package.json. + try { + const entryPath = resolver.resolve(packageName); + let dir = path.dirname(entryPath); + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, "package.json"); + if (fs.existsSync(candidate)) { + const pkg = readPackageJson(candidate); + if (pkg.name === packageName) { + return candidate; + } + } + dir = path.dirname(dir); + } + } catch { + // fallthrough to null + } + return null; + } +} + +function copyPackageAndRuntimeDeps( + root: string, + targetNodeModulesDir: string, + initialPackages: string[], +): string[] { + const rootResolver = createRequire(path.join(root, "package.json")); + const rootPkg = readPackageJson(path.join(root, "package.json")); + const rootOptional = new Set(Object.keys(rootPkg.optionalDependencies ?? {})); + const copied = new Set(); + const queue: QueueEntry[] = initialPackages.map((packageName) => ({ + packageName, + resolver: rootResolver, + optional: rootOptional.has(packageName), + })); + + while (queue.length > 0) { + const entry = queue.shift(); + if (!entry) continue; + if (copied.has(entry.packageName)) continue; + + const packageJsonPath = resolvePackageJsonPath(entry.packageName, entry.resolver); + if (!packageJsonPath) { + if (entry.optional) { + continue; + } + throw new Error( + `Failed to resolve required runtime dependency "${entry.packageName}" for standalone output`, + ); + } + + const packageRoot = path.dirname(packageJsonPath); + const packageTarget = path.join(targetNodeModulesDir, entry.packageName); + fs.mkdirSync(path.dirname(packageTarget), { recursive: true }); + fs.cpSync(packageRoot, packageTarget, { recursive: true, dereference: true }); + + copied.add(entry.packageName); + + const packageResolver = createRequire(packageJsonPath); + const pkg = readPackageJson(packageJsonPath); + const optionalDeps = new Set(Object.keys(pkg.optionalDependencies ?? {})); + for (const depName of runtimeDeps(pkg)) { + if (!copied.has(depName)) { + queue.push({ + packageName: depName, + resolver: packageResolver, + optional: optionalDeps.has(depName), + }); + } + } + } + + return [...copied]; +} + +function resolveVinextPackageRoot(explicitRoot?: string): string { + if (explicitRoot) { + return path.resolve(explicitRoot); + } + + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + // dist/build/standalone.js -> package root is ../.. + return path.resolve(currentDir, "..", ".."); +} + +function writeStandaloneServerEntry(filePath: string): void { + const content = `#!/usr/bin/env node +const path = require("node:path"); + +async function main() { + const { startProdServer } = await import("vinext/server/prod-server"); + const port = Number.parseInt(process.env.PORT ?? "3000", 10); + const host = process.env.HOST ?? "0.0.0.0"; + + await startProdServer({ + port, + host, + outDir: path.join(__dirname, "dist"), + }); +} + +main().catch((error) => { + console.error("[vinext] Failed to start standalone server"); + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(filePath, content, "utf-8"); + fs.chmodSync(filePath, 0o755); +} + +function writeStandalonePackageJson(filePath: string): void { + fs.writeFileSync( + filePath, + JSON.stringify({ + private: true, + type: "commonjs", + }, null, 2) + "\n", + "utf-8", + ); +} + +/** + * Emit standalone production output for self-hosted deployments. + * + * Creates: + * - /standalone/server.js + * - /standalone/dist/{client,server} + * - /standalone/node_modules (runtime deps only) + */ +export function emitStandaloneOutput(options: StandaloneBuildOptions): StandaloneBuildResult { + const root = path.resolve(options.root); + const outDir = path.resolve(options.outDir); + const clientDir = path.join(outDir, "client"); + const serverDir = path.join(outDir, "server"); + + if (!fs.existsSync(clientDir) || !fs.existsSync(serverDir)) { + throw new Error(`No build output found in ${outDir}. Run vinext build first.`); + } + + const standaloneDir = path.join(outDir, "standalone"); + const standaloneDistDir = path.join(standaloneDir, "dist"); + const standaloneNodeModulesDir = path.join(standaloneDir, "node_modules"); + + fs.rmSync(standaloneDir, { recursive: true, force: true }); + fs.mkdirSync(standaloneDistDir, { recursive: true }); + + fs.cpSync(clientDir, path.join(standaloneDistDir, "client"), { + recursive: true, + dereference: true, + }); + fs.cpSync(serverDir, path.join(standaloneDistDir, "server"), { + recursive: true, + dereference: true, + }); + + const publicDir = path.join(root, "public"); + if (fs.existsSync(publicDir)) { + fs.cpSync(publicDir, path.join(standaloneDir, "public"), { + recursive: true, + dereference: true, + }); + } + + fs.mkdirSync(standaloneNodeModulesDir, { recursive: true }); + + const appPkg = readPackageJson(path.join(root, "package.json")); + const appRuntimeDeps = runtimeDeps(appPkg); + const serverRuntimeDeps = collectServerExternalPackages(serverDir); + const initialPackages = [...new Set([...appRuntimeDeps, ...serverRuntimeDeps])].filter( + (name) => name !== "vinext", + ); + const copiedPackages = copyPackageAndRuntimeDeps( + root, + standaloneNodeModulesDir, + initialPackages, + ); + + // Always embed the exact vinext runtime that produced this build. + const vinextPackageRoot = resolveVinextPackageRoot(options.vinextPackageRoot); + const vinextDistDir = path.join(vinextPackageRoot, "dist"); + if (!fs.existsSync(vinextDistDir)) { + throw new Error(`vinext runtime dist/ not found at ${vinextPackageRoot}`); + } + const vinextTargetDir = path.join(standaloneNodeModulesDir, "vinext"); + fs.mkdirSync(vinextTargetDir, { recursive: true }); + fs.copyFileSync( + path.join(vinextPackageRoot, "package.json"), + path.join(vinextTargetDir, "package.json"), + ); + fs.cpSync(vinextDistDir, path.join(vinextTargetDir, "dist"), { + recursive: true, + dereference: true, + }); + + writeStandaloneServerEntry(path.join(standaloneDir, "server.js")); + writeStandalonePackageJson(path.join(standaloneDir, "package.json")); + + return { + standaloneDir, + copiedPackages: [...new Set([...copiedPackages, "vinext"])], + }; +} diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index f5db48f40..6bfa04e0c 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -71,7 +71,7 @@ const CONFIG_SUPPORT: Record = { i18n: { status: "supported", detail: "path-prefix routing (domains not yet supported)" }, env: { status: "supported" }, images: { status: "partial", detail: "remotePatterns validated, no local optimization" }, - output: { status: "supported", detail: "'export' and 'standalone' modes" }, + output: { status: "supported", detail: "'export' mode and 'standalone' output (dist/standalone/server.js)" }, transpilePackages: { status: "supported", detail: "Vite handles this natively" }, webpack: { status: "unsupported", detail: "Vite replaces webpack — custom webpack configs need migration" }, "experimental.ppr": { status: "unsupported", detail: "partial prerendering not yet implemented" }, diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 0f942d0f4..50d8cffcf 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -23,6 +23,8 @@ import { deploy as runDeploy, parseDeployArgs } from "./deploy.js"; import { runCheck, formatReport } from "./check.js"; import { init as runInit, getReactUpgradeDeps } from "./init.js"; import { loadDotenv } from "./config/dotenv.js"; +import { loadNextConfig, resolveNextConfig, PHASE_PRODUCTION_BUILD } from "./config/next-config.js"; +import { emitStandaloneOutput } from "./build/standalone.js"; import { detectPackageManager as _detectPackageManager } from "./utils/project.js"; // ─── Resolve Vite from the project root ──────────────────────────────────────── @@ -219,6 +221,11 @@ async function buildApp() { console.log(`\n vinext build (Vite ${getViteVersion()})\n`); const isApp = hasAppDir(); + const resolvedNextConfig = await resolveNextConfig( + await loadNextConfig(process.cwd(), PHASE_PRODUCTION_BUILD), + ); + const outputMode = resolvedNextConfig.output; + const distDir = path.resolve(process.cwd(), "dist"); // For App Router: upgrade React if needed for react-server-dom-webpack compatibility. // Without this, builds with react<19.2.4 produce a Worker that crashes at @@ -270,6 +277,16 @@ async function buildApp() { })); } + if (outputMode === "standalone") { + const standalone = emitStandaloneOutput({ + root: process.cwd(), + outDir: distDir, + }); + console.log(` Generated standalone output in ${path.relative(process.cwd(), standalone.standaloneDir)}/`); + console.log(" Start it with: node dist/standalone/server.js\n"); + return; + } + console.log("\n Build complete. Run `vinext start` to start the production server.\n"); } @@ -426,6 +443,7 @@ function printHelp(cmd?: string) { Automatically detects App Router (app/) or Pages Router (pages/) and runs the appropriate multi-environment build via Vite. + If next.config sets output: "standalone", also emits dist/standalone/server.js. Options: -h, --help Show this help @@ -441,6 +459,7 @@ function printHelp(cmd?: string) { Serves the output from \`vinext build\`. Supports SSR, static files, compression, and all middleware. + For output: "standalone", you can also run: node dist/standalone/server.js Options: -p, --port Port to listen on (default: 3000, or PORT env) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 63b811ad2..e2a2ce0d8 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -171,10 +171,15 @@ function isCjsError(e: unknown): boolean { * Unwrap the config value from a loaded module, calling it if it's a * function-form config (Next.js supports `module.exports = (phase, opts) => config`). */ -async function unwrapConfig(mod: any): Promise { +export const PHASE_DEVELOPMENT_SERVER = "phase-development-server"; +export const PHASE_PRODUCTION_BUILD = "phase-production-build"; + +const DEFAULT_PHASE = PHASE_DEVELOPMENT_SERVER; + +async function unwrapConfig(mod: any, phase: string): Promise { const config = mod.default ?? mod; if (typeof config === "function") { - const result = await config("phase-development-server", { + const result = await config(phase, { defaultConfig: {}, }); return result as NextConfig; @@ -191,7 +196,10 @@ async function unwrapConfig(mod: any): Promise { * back to loading it via `createRequire` so that CJS config files (common in * the Next.js ecosystem for plugin wrappers like nextra, @next/mdx, etc.) work. */ -export async function loadNextConfig(root: string): Promise { +export async function loadNextConfig( + root: string, + phase: string = DEFAULT_PHASE, +): Promise { for (const filename of CONFIG_FILES) { const configPath = path.join(root, filename); if (!fs.existsSync(configPath)) continue; @@ -200,7 +208,7 @@ export async function loadNextConfig(root: string): Promise { // Use dynamic import for ESM/TS config files const fileUrl = pathToFileURL(configPath).href; const mod = await import(fileUrl); - return await unwrapConfig(mod); + return await unwrapConfig(mod, phase); } catch (e) { // If the error indicates a CJS file loaded in ESM context, retry with // createRequire which provides a proper CommonJS environment. @@ -208,7 +216,7 @@ export async function loadNextConfig(root: string): Promise { try { const require = createRequire(path.join(root, "package.json")); const mod = require(configPath); - return await unwrapConfig({ default: mod }); + return await unwrapConfig({ default: mod }, phase); } catch (e2) { console.warn( `[vinext] Failed to load ${filename}: ${(e2 as Error).message}`, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 737f8e052..d70746939 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -35,6 +35,7 @@ import { } from "./config/config-matchers.js"; import { scanMetadataFiles } from "./server/metadata-routes.js"; import { staticExportPages } from "./build/static-export.js"; +import { computeLazyChunks } from "./utils/lazy-chunks.js"; import tsconfigPaths from "vite-tsconfig-paths"; import MagicString from "magic-string"; import path from "node:path"; @@ -471,90 +472,6 @@ const clientTreeshakeConfig = { moduleSideEffects: "no-external" as const, }; -/** - * Compute the set of chunk filenames that are ONLY reachable through dynamic - * imports (i.e. behind React.lazy(), next/dynamic, or manual import()). - * - * These chunks should NOT be modulepreloaded in the HTML — they will be - * fetched on demand when the dynamic import executes. - * - * Algorithm: Starting from all entry chunks in the build manifest, walk the - * static `imports` tree (breadth-first). Any chunk file NOT reached by this - * walk is only reachable through `dynamicImports` and is therefore "lazy". - * - * @param buildManifest - Vite's build manifest (manifest.json), which is a - * Record where each chunk has `file`, `imports`, - * `dynamicImports`, `isEntry`, and `isDynamicEntry` fields. - * @returns Array of chunk filenames (e.g. "assets/mermaid-NOHMQCX5.js") that - * should be excluded from modulepreload hints. - */ -function computeLazyChunks( - buildManifest: Record -): string[] { - // Collect all chunk files that are statically reachable from entries - const eagerFiles = new Set(); - const visited = new Set(); - const queue: string[] = []; - - // Start BFS from all entry chunks - for (const key of Object.keys(buildManifest)) { - const chunk = buildManifest[key]; - if (chunk.isEntry) { - queue.push(key); - } - } - - while (queue.length > 0) { - const key = queue.shift()!; - if (visited.has(key)) continue; - visited.add(key); - - const chunk = buildManifest[key]; - if (!chunk) continue; - - // Mark this chunk's file as eager - eagerFiles.add(chunk.file); - - // Also mark its CSS as eager (CSS should always be preloaded to avoid FOUC) - if (chunk.css) { - for (const cssFile of chunk.css) { - eagerFiles.add(cssFile); - } - } - - // Follow only static imports — NOT dynamicImports - if (chunk.imports) { - for (const imp of chunk.imports) { - if (!visited.has(imp)) { - queue.push(imp); - } - } - } - } - - // Any JS file in the manifest that's NOT in eagerFiles is a lazy chunk - const lazyChunks: string[] = []; - const allFiles = new Set(); - for (const key of Object.keys(buildManifest)) { - const chunk = buildManifest[key]; - if (chunk.file && !allFiles.has(chunk.file)) { - allFiles.add(chunk.file); - if (!eagerFiles.has(chunk.file) && chunk.file.endsWith(".js")) { - lazyChunks.push(chunk.file); - } - } - } - - return lazyChunks; -} - export interface VinextOptions { /** * Base directory containing the app/ and pages/ directories. diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index 74b43f92e..3a4bd6f89 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -92,15 +92,20 @@ export function addScripts(root: string, port: number): string[] { const added: string[] = []; if (!pkg.scripts["dev:vinext"]) { - pkg.scripts["dev:vinext"] = `vite dev --port ${port}`; + pkg.scripts["dev:vinext"] = `vinext dev --port ${port}`; added.push("dev:vinext"); } if (!pkg.scripts["build:vinext"]) { - pkg.scripts["build:vinext"] = "vite build"; + pkg.scripts["build:vinext"] = "vinext build"; added.push("build:vinext"); } + if (!pkg.scripts["start:vinext"]) { + pkg.scripts["start:vinext"] = "vinext start"; + added.push("start:vinext"); + } + if (added.length > 0) { fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8"); } @@ -319,6 +324,8 @@ export async function init(options: InitOptions): Promise { console.log(` Next steps: ${pmName} run dev:vinext Start the vinext dev server + ${pmName} run build:vinext Build production output + ${pmName} run start:vinext Start vinext production server ${pmName} run dev Start Next.js (still works as before) `); diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index f192627f9..5b4481c2f 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -27,7 +27,7 @@ import { matchRedirect, matchRewrite, matchHeaders, requestContextFromRequest, i import type { RequestContext } from "../config/config-matchers.js"; import { IMAGE_OPTIMIZATION_PATH, IMAGE_CONTENT_SECURITY_POLICY, parseImageParams, isSafeImageContentType, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "./image-optimization.js"; import { normalizePath } from "./normalize-path.js"; -import { computeLazyChunks } from "../index.js"; +import { computeLazyChunks } from "../utils/lazy-chunks.js"; /** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */ function readNodeStream(req: IncomingMessage): ReadableStream { diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts new file mode 100644 index 000000000..950d0ed71 --- /dev/null +++ b/packages/vinext/src/utils/lazy-chunks.ts @@ -0,0 +1,88 @@ +/** + * Build-manifest chunk metadata used to compute lazy chunks. + */ +export interface BuildManifestChunk { + file: string; + isEntry?: boolean; + isDynamicEntry?: boolean; + imports?: string[]; + dynamicImports?: string[]; + css?: string[]; +} + +/** + * Compute the set of chunk filenames that are ONLY reachable through dynamic + * imports (i.e. behind React.lazy(), next/dynamic, or manual import()). + * + * These chunks should NOT be modulepreloaded in the HTML — they will be + * fetched on demand when the dynamic import executes. + * + * Algorithm: Starting from all entry chunks in the build manifest, walk the + * static `imports` tree (breadth-first). Any chunk file NOT reached by this + * walk is only reachable through `dynamicImports` and is therefore "lazy". + * + * @param buildManifest - Vite's build manifest (manifest.json), which is a + * Record where each chunk has `file`, `imports`, + * `dynamicImports`, `isEntry`, and `isDynamicEntry` fields. + * @returns Array of chunk filenames (e.g. "assets/mermaid-NOHMQCX5.js") that + * should be excluded from modulepreload hints. + */ +export function computeLazyChunks( + buildManifest: Record, +): string[] { + // Collect all chunk files that are statically reachable from entries + const eagerFiles = new Set(); + const visited = new Set(); + const queue: string[] = []; + + // Start BFS from all entry chunks + for (const key of Object.keys(buildManifest)) { + const chunk = buildManifest[key]; + if (chunk.isEntry) { + queue.push(key); + } + } + + while (queue.length > 0) { + const key = queue.shift(); + if (!key || visited.has(key)) continue; + visited.add(key); + + const chunk = buildManifest[key]; + if (!chunk) continue; + + // Mark this chunk's file as eager + eagerFiles.add(chunk.file); + + // Also mark its CSS as eager (CSS should always be preloaded to avoid FOUC) + if (chunk.css) { + for (const cssFile of chunk.css) { + eagerFiles.add(cssFile); + } + } + + // Follow only static imports — NOT dynamicImports + if (chunk.imports) { + for (const imp of chunk.imports) { + if (!visited.has(imp)) { + queue.push(imp); + } + } + } + } + + // Any JS file in the manifest that's NOT in eagerFiles is a lazy chunk + const lazyChunks: string[] = []; + const allFiles = new Set(); + for (const key of Object.keys(buildManifest)) { + const chunk = buildManifest[key]; + if (chunk.file && !allFiles.has(chunk.file)) { + allFiles.add(chunk.file); + if (!eagerFiles.has(chunk.file) && chunk.file.endsWith(".js")) { + lazyChunks.push(chunk.file); + } + } + } + + return lazyChunks; +} diff --git a/tests/init.test.ts b/tests/init.test.ts index 558519dd6..d5abc4028 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -194,17 +194,19 @@ describe("generateViteConfig", () => { // ─── Unit Tests: addScripts ────────────────────────────────────────────────── describe("addScripts", () => { - it("adds dev:vinext and build:vinext scripts", () => { + it("adds dev:vinext, build:vinext, and start:vinext scripts", () => { setupProject(tmpDir, { router: "app" }); const added = addScripts(tmpDir, 3001); expect(added).toContain("dev:vinext"); expect(added).toContain("build:vinext"); + expect(added).toContain("start:vinext"); const pkg = readPkg(tmpDir) as { scripts: Record }; - expect(pkg.scripts["dev:vinext"]).toBe("vite dev --port 3001"); - expect(pkg.scripts["build:vinext"]).toBe("vite build"); + expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 3001"); + expect(pkg.scripts["build:vinext"]).toBe("vinext build"); + expect(pkg.scripts["start:vinext"]).toBe("vinext start"); }); it("uses custom port", () => { @@ -213,7 +215,7 @@ describe("addScripts", () => { addScripts(tmpDir, 4000); const pkg = readPkg(tmpDir) as { scripts: Record }; - expect(pkg.scripts["dev:vinext"]).toBe("vite dev --port 4000"); + expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 4000"); }); it("does not overwrite existing scripts", () => { @@ -226,6 +228,7 @@ describe("addScripts", () => { expect(added).not.toContain("dev:vinext"); expect(added).toContain("build:vinext"); + expect(added).toContain("start:vinext"); const pkg = readPkg(tmpDir) as { scripts: Record }; expect(pkg.scripts["dev:vinext"]).toBe("custom-command"); @@ -386,17 +389,19 @@ describe("init — basic functionality", () => { expect(result.addedTypeModule).toBe(false); }); - it("adds dev:vinext and build:vinext scripts", async () => { + it("adds dev:vinext, build:vinext, and start:vinext scripts", async () => { setupProject(tmpDir, { router: "app" }); const { result } = await runInit(tmpDir); expect(result.addedScripts).toContain("dev:vinext"); expect(result.addedScripts).toContain("build:vinext"); + expect(result.addedScripts).toContain("start:vinext"); const pkg = readPkg(tmpDir) as { scripts: Record }; - expect(pkg.scripts["dev:vinext"]).toBe("vite dev --port 3001"); - expect(pkg.scripts["build:vinext"]).toBe("vite build"); + expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 3001"); + expect(pkg.scripts["build:vinext"]).toBe("vinext build"); + expect(pkg.scripts["start:vinext"]).toBe("vinext start"); }); it("uses custom port in dev:vinext script", async () => { @@ -405,7 +410,7 @@ describe("init — basic functionality", () => { await runInit(tmpDir, { port: 4000 }); const pkg = readPkg(tmpDir) as { scripts: Record }; - expect(pkg.scripts["dev:vinext"]).toBe("vite dev --port 4000"); + expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 4000"); }); it("does not overwrite existing scripts", async () => { @@ -601,6 +606,7 @@ describe("init — guard rails", () => { // Scripts should still be added expect(result.addedScripts).toContain("dev:vinext"); expect(result.addedScripts).toContain("build:vinext"); + expect(result.addedScripts).toContain("start:vinext"); // But vite config should be skipped expect(result.generatedViteConfig).toBe(false); expect(result.skippedViteConfig).toBe(true); diff --git a/tests/standalone-build.test.ts b/tests/standalone-build.test.ts new file mode 100644 index 000000000..da0b78766 --- /dev/null +++ b/tests/standalone-build.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { emitStandaloneOutput } from "../packages/vinext/src/build/standalone.js"; + +let tmpDir: string; + +function createTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-standalone-test-")); +} + +function writeFile(root: string, relativePath: string, content: string): void { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, "utf-8"); +} + +function writePackage( + root: string, + packageName: string, + dependencies: Record = {}, + options: { exports?: Record } = {}, +): void { + const packageRoot = path.join(root, "node_modules", packageName); + fs.mkdirSync(packageRoot, { recursive: true }); + const pkgJson: Record = { + name: packageName, + version: "1.0.0", + main: "index.js", + dependencies, + }; + if (options.exports) { + pkgJson.exports = options.exports; + } + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify(pkgJson, null, 2), + "utf-8", + ); + fs.writeFileSync(path.join(packageRoot, "index.js"), "module.exports = {};\n", "utf-8"); +} + +beforeEach(() => { + tmpDir = createTmpDir(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("emitStandaloneOutput", () => { + it("creates standalone output with runtime deps and entry script", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile( + appRoot, + "package.json", + JSON.stringify({ + name: "app", + dependencies: { + "dep-a": "1.0.0", + react: "1.0.0", + vinext: "1.0.0", + }, + devDependencies: { + // This is intentionally in devDependencies. The standalone builder + // should still copy it because the server bundle imports it directly. + "react-server-dom-webpack": "1.0.0", + typescript: "5.0.0", + }, + }, null, 2), + ); + + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile( + appRoot, + "dist/server/entry.js", + 'import "react-server-dom-webpack/server.edge";\nconsole.log("server");\n', + ); + writeFile(appRoot, "public/robots.txt", "User-agent: *\n"); + + writePackage(appRoot, "dep-a", { "dep-b": "1.0.0" }); + writePackage(appRoot, "dep-b"); + writePackage(appRoot, "react"); + writePackage(appRoot, "react-server-dom-webpack"); + + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify({ + name: "vinext", + version: "0.0.0-test", + type: "module", + }, null, 2), + ); + writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); + + const result = emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }); + + expect(result.copiedPackages).toContain("dep-a"); + expect(result.copiedPackages).toContain("dep-b"); + expect(result.copiedPackages).toContain("react"); + expect(result.copiedPackages).toContain("react-server-dom-webpack"); + expect(result.copiedPackages).toContain("vinext"); + + expect(fs.existsSync(path.join(appRoot, "dist/standalone/server.js"))).toBe(true); + expect( + fs.readFileSync(path.join(appRoot, "dist/standalone/server.js"), "utf-8"), + ).toContain("startProdServer"); + const standalonePkg = JSON.parse( + fs.readFileSync(path.join(appRoot, "dist/standalone/package.json"), "utf-8"), + ) as { type: string }; + expect(standalonePkg.type).toBe("commonjs"); + + expect(fs.existsSync(path.join(appRoot, "dist/standalone/dist/client/assets/main.js"))).toBe(true); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/dist/server/entry.js"))).toBe(true); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/public/robots.txt"))).toBe(true); + + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-a/package.json"))).toBe(true); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-b/package.json"))).toBe(true); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/react/package.json"))).toBe(true); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/react-server-dom-webpack/package.json"))).toBe(true); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/typescript/package.json"))).toBe(false); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/vinext/dist/server/prod-server.js"))).toBe(true); + }); + + it("throws when dist/client or dist/server are missing", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + writeFile(appRoot, "package.json", JSON.stringify({ name: "app" }, null, 2)); + + expect(() => + emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + }), + ).toThrow("Run vinext build first."); + }); + + it("falls back when package.json is hidden by exports map", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile( + appRoot, + "package.json", + JSON.stringify({ + name: "app", + dependencies: { + "dep-hidden": "1.0.0", + vinext: "1.0.0", + }, + }, null, 2), + ); + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", "import 'dep-hidden';\n"); + + writePackage(appRoot, "dep-hidden", {}, { exports: { ".": "./index.js" } }); + + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile(fakeVinextRoot, "package.json", JSON.stringify({ name: "vinext", type: "module" }, null, 2)); + writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); + + const result = emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }); + + expect(result.copiedPackages).toContain("dep-hidden"); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-hidden/package.json"))).toBe(true); + }); + + it("throws when a required runtime dependency cannot be resolved", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile( + appRoot, + "package.json", + JSON.stringify({ + name: "app", + dependencies: { + "missing-required": "1.0.0", + vinext: "1.0.0", + }, + }, null, 2), + ); + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", "console.log('server');\n"); + + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile(fakeVinextRoot, "package.json", JSON.stringify({ name: "vinext", type: "module" }, null, 2)); + writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); + + expect(() => + emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }), + ).toThrow('Failed to resolve required runtime dependency "missing-required"'); + }); + + it("copies packages referenced through symlinked node_modules entries", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile( + appRoot, + "package.json", + JSON.stringify({ + name: "app", + dependencies: { + "dep-link": "1.0.0", + vinext: "1.0.0", + }, + }, null, 2), + ); + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", "import 'dep-link';\n"); + + const storeDir = path.join(tmpDir, "store", "dep-link"); + fs.mkdirSync(storeDir, { recursive: true }); + writeFile(storeDir, "package.json", JSON.stringify({ name: "dep-link", version: "1.0.0", main: "index.js" }, null, 2)); + writeFile(storeDir, "index.js", "module.exports = {};\n"); + + const nodeModulesDir = path.join(appRoot, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync(storeDir, path.join(nodeModulesDir, "dep-link"), "dir"); + + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile(fakeVinextRoot, "package.json", JSON.stringify({ name: "vinext", type: "module" }, null, 2)); + writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); + + emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }); + + expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-link/package.json"))).toBe(true); + expect(fs.lstatSync(path.join(appRoot, "dist/standalone/node_modules/dep-link")).isSymbolicLink()).toBe(false); + }); +}); From b8c22d45497eb18b4bdd0c02ea9335e0c2bb30c1 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 22:46:48 +0000 Subject: [PATCH 02/11] fmt --- packages/vinext/src/build/standalone.ts | 23 ++- packages/vinext/src/utils/lazy-chunks.ts | 4 +- tests/standalone-build.test.ts | 190 ++++++++++++++++------- 3 files changed, 144 insertions(+), 73 deletions(-) diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index 16d46bc0c..63eed6efe 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -105,10 +105,7 @@ function collectServerExternalPackages(serverDir: string): string[] { return [...packages]; } -function resolvePackageJsonPath( - packageName: string, - resolver: NodeRequire, -): string | null { +function resolvePackageJsonPath(packageName: string, resolver: NodeRequire): string | null { try { return resolver.resolve(`${packageName}/package.json`); } catch { @@ -227,10 +224,14 @@ main().catch((error) => { function writeStandalonePackageJson(filePath: string): void { fs.writeFileSync( filePath, - JSON.stringify({ - private: true, - type: "commonjs", - }, null, 2) + "\n", + JSON.stringify( + { + private: true, + type: "commonjs", + }, + null, + 2, + ) + "\n", "utf-8", ); } @@ -285,11 +286,7 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon const initialPackages = [...new Set([...appRuntimeDeps, ...serverRuntimeDeps])].filter( (name) => name !== "vinext", ); - const copiedPackages = copyPackageAndRuntimeDeps( - root, - standaloneNodeModulesDir, - initialPackages, - ); + const copiedPackages = copyPackageAndRuntimeDeps(root, standaloneNodeModulesDir, initialPackages); // Always embed the exact vinext runtime that produced this build. const vinextPackageRoot = resolveVinextPackageRoot(options.vinextPackageRoot); diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts index 950d0ed71..b0ddf3d6c 100644 --- a/packages/vinext/src/utils/lazy-chunks.ts +++ b/packages/vinext/src/utils/lazy-chunks.ts @@ -27,9 +27,7 @@ export interface BuildManifestChunk { * @returns Array of chunk filenames (e.g. "assets/mermaid-NOHMQCX5.js") that * should be excluded from modulepreload hints. */ -export function computeLazyChunks( - buildManifest: Record, -): string[] { +export function computeLazyChunks(buildManifest: Record): string[] { // Collect all chunk files that are statically reachable from entries const eagerFiles = new Set(); const visited = new Set(); diff --git a/tests/standalone-build.test.ts b/tests/standalone-build.test.ts index da0b78766..1c6029a50 100644 --- a/tests/standalone-build.test.ts +++ b/tests/standalone-build.test.ts @@ -57,20 +57,24 @@ describe("emitStandaloneOutput", () => { writeFile( appRoot, "package.json", - JSON.stringify({ - name: "app", - dependencies: { - "dep-a": "1.0.0", - react: "1.0.0", - vinext: "1.0.0", + JSON.stringify( + { + name: "app", + dependencies: { + "dep-a": "1.0.0", + react: "1.0.0", + vinext: "1.0.0", + }, + devDependencies: { + // This is intentionally in devDependencies. The standalone builder + // should still copy it because the server bundle imports it directly. + "react-server-dom-webpack": "1.0.0", + typescript: "5.0.0", + }, }, - devDependencies: { - // This is intentionally in devDependencies. The standalone builder - // should still copy it because the server bundle imports it directly. - "react-server-dom-webpack": "1.0.0", - typescript: "5.0.0", - }, - }, null, 2), + null, + 2, + ), ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); @@ -90,13 +94,21 @@ describe("emitStandaloneOutput", () => { writeFile( fakeVinextRoot, "package.json", - JSON.stringify({ - name: "vinext", - version: "0.0.0-test", - type: "module", - }, null, 2), + JSON.stringify( + { + name: "vinext", + version: "0.0.0-test", + type: "module", + }, + null, + 2, + ), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", ); - writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); const result = emitStandaloneOutput({ root: appRoot, @@ -111,24 +123,42 @@ describe("emitStandaloneOutput", () => { expect(result.copiedPackages).toContain("vinext"); expect(fs.existsSync(path.join(appRoot, "dist/standalone/server.js"))).toBe(true); - expect( - fs.readFileSync(path.join(appRoot, "dist/standalone/server.js"), "utf-8"), - ).toContain("startProdServer"); + expect(fs.readFileSync(path.join(appRoot, "dist/standalone/server.js"), "utf-8")).toContain( + "startProdServer", + ); const standalonePkg = JSON.parse( fs.readFileSync(path.join(appRoot, "dist/standalone/package.json"), "utf-8"), ) as { type: string }; expect(standalonePkg.type).toBe("commonjs"); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/dist/client/assets/main.js"))).toBe(true); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/dist/client/assets/main.js"))).toBe( + true, + ); expect(fs.existsSync(path.join(appRoot, "dist/standalone/dist/server/entry.js"))).toBe(true); expect(fs.existsSync(path.join(appRoot, "dist/standalone/public/robots.txt"))).toBe(true); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-a/package.json"))).toBe(true); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-b/package.json"))).toBe(true); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/react/package.json"))).toBe(true); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/react-server-dom-webpack/package.json"))).toBe(true); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/typescript/package.json"))).toBe(false); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/vinext/dist/server/prod-server.js"))).toBe(true); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-a/package.json")), + ).toBe(true); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-b/package.json")), + ).toBe(true); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/react/package.json")), + ).toBe(true); + expect( + fs.existsSync( + path.join(appRoot, "dist/standalone/node_modules/react-server-dom-webpack/package.json"), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/typescript/package.json")), + ).toBe(false); + expect( + fs.existsSync( + path.join(appRoot, "dist/standalone/node_modules/vinext/dist/server/prod-server.js"), + ), + ).toBe(true); }); it("throws when dist/client or dist/server are missing", () => { @@ -151,13 +181,17 @@ describe("emitStandaloneOutput", () => { writeFile( appRoot, "package.json", - JSON.stringify({ - name: "app", - dependencies: { - "dep-hidden": "1.0.0", - vinext: "1.0.0", + JSON.stringify( + { + name: "app", + dependencies: { + "dep-hidden": "1.0.0", + vinext: "1.0.0", + }, }, - }, null, 2), + null, + 2, + ), ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); writeFile(appRoot, "dist/server/entry.js", "import 'dep-hidden';\n"); @@ -165,8 +199,16 @@ describe("emitStandaloneOutput", () => { writePackage(appRoot, "dep-hidden", {}, { exports: { ".": "./index.js" } }); const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); - writeFile(fakeVinextRoot, "package.json", JSON.stringify({ name: "vinext", type: "module" }, null, 2)); - writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify({ name: "vinext", type: "module" }, null, 2), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", + ); const result = emitStandaloneOutput({ root: appRoot, @@ -175,7 +217,9 @@ describe("emitStandaloneOutput", () => { }); expect(result.copiedPackages).toContain("dep-hidden"); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-hidden/package.json"))).toBe(true); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-hidden/package.json")), + ).toBe(true); }); it("throws when a required runtime dependency cannot be resolved", () => { @@ -185,20 +229,32 @@ describe("emitStandaloneOutput", () => { writeFile( appRoot, "package.json", - JSON.stringify({ - name: "app", - dependencies: { - "missing-required": "1.0.0", - vinext: "1.0.0", + JSON.stringify( + { + name: "app", + dependencies: { + "missing-required": "1.0.0", + vinext: "1.0.0", + }, }, - }, null, 2), + null, + 2, + ), ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); writeFile(appRoot, "dist/server/entry.js", "console.log('server');\n"); const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); - writeFile(fakeVinextRoot, "package.json", JSON.stringify({ name: "vinext", type: "module" }, null, 2)); - writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify({ name: "vinext", type: "module" }, null, 2), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", + ); expect(() => emitStandaloneOutput({ @@ -216,20 +272,28 @@ describe("emitStandaloneOutput", () => { writeFile( appRoot, "package.json", - JSON.stringify({ - name: "app", - dependencies: { - "dep-link": "1.0.0", - vinext: "1.0.0", + JSON.stringify( + { + name: "app", + dependencies: { + "dep-link": "1.0.0", + vinext: "1.0.0", + }, }, - }, null, 2), + null, + 2, + ), ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); writeFile(appRoot, "dist/server/entry.js", "import 'dep-link';\n"); const storeDir = path.join(tmpDir, "store", "dep-link"); fs.mkdirSync(storeDir, { recursive: true }); - writeFile(storeDir, "package.json", JSON.stringify({ name: "dep-link", version: "1.0.0", main: "index.js" }, null, 2)); + writeFile( + storeDir, + "package.json", + JSON.stringify({ name: "dep-link", version: "1.0.0", main: "index.js" }, null, 2), + ); writeFile(storeDir, "index.js", "module.exports = {};\n"); const nodeModulesDir = path.join(appRoot, "node_modules"); @@ -237,8 +301,16 @@ describe("emitStandaloneOutput", () => { fs.symlinkSync(storeDir, path.join(nodeModulesDir, "dep-link"), "dir"); const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); - writeFile(fakeVinextRoot, "package.json", JSON.stringify({ name: "vinext", type: "module" }, null, 2)); - writeFile(fakeVinextRoot, "dist/server/prod-server.js", "export async function startProdServer() {}\n"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify({ name: "vinext", type: "module" }, null, 2), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", + ); emitStandaloneOutput({ root: appRoot, @@ -246,7 +318,11 @@ describe("emitStandaloneOutput", () => { vinextPackageRoot: fakeVinextRoot, }); - expect(fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-link/package.json"))).toBe(true); - expect(fs.lstatSync(path.join(appRoot, "dist/standalone/node_modules/dep-link")).isSymbolicLink()).toBe(false); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-link/package.json")), + ).toBe(true); + expect( + fs.lstatSync(path.join(appRoot, "dist/standalone/node_modules/dep-link")).isSymbolicLink(), + ).toBe(false); }); }); From d9d676b029283fc677cb1d5574fa35e319f14c08 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 08:54:34 +0100 Subject: [PATCH 03/11] Address bonk review: fix duplicate PHASE constant, complete lazy-chunks extraction, remove dead imports, add process.exit to standalone path --- packages/vinext/src/cli.ts | 3 +- packages/vinext/src/config/next-config.ts | 9 ++- packages/vinext/src/index.ts | 87 +---------------------- packages/vinext/src/server/prod-server.ts | 2 +- 4 files changed, 7 insertions(+), 94 deletions(-) diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 9e9d17103..c07e83405 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -28,7 +28,6 @@ import { init as runInit, getReactUpgradeDeps } from "./init.js"; import { loadDotenv } from "./config/dotenv.js"; import { loadNextConfig, resolveNextConfig, PHASE_PRODUCTION_BUILD } from "./config/next-config.js"; import { emitStandaloneOutput } from "./build/standalone.js"; -import { detectPackageManager as _detectPackageManager } from "./utils/project.js"; // ─── Resolve Vite from the project root ──────────────────────────────────────── // @@ -517,7 +516,7 @@ async function buildApp() { ` Generated standalone output in ${path.relative(process.cwd(), standalone.standaloneDir)}/`, ); console.log(" Start it with: node dist/standalone/server.js\n"); - return; + return process.exit(0); } let prerenderResult; diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 933c2e538..ac467f7ec 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -8,7 +8,8 @@ import path from "node:path"; import { createRequire } from "node:module"; import fs from "node:fs"; import { randomUUID } from "node:crypto"; -import { PHASE_DEVELOPMENT_SERVER } from "../shims/constants.js"; +import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from "../shims/constants.js"; +export { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD }; import { normalizePageExtensions } from "../routing/file-matcher.js"; import { isExternalUrl } from "./config-matchers.js"; @@ -271,14 +272,12 @@ function isCjsError(e: unknown): boolean { ); } +const DEFAULT_PHASE = PHASE_DEVELOPMENT_SERVER; + /** * Emit a warning when config loading fails, with a targeted hint for * known plugin wrappers that are unnecessary in vinext. */ -export const PHASE_PRODUCTION_BUILD = "phase-production-build"; - -const DEFAULT_PHASE = PHASE_DEVELOPMENT_SERVER; - function warnConfigLoadFailure(filename: string, err: Error): void { const msg = err.message ?? ""; const stack = err.stack ?? ""; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 1f148cec0..63d48a30c 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -51,7 +51,6 @@ import { type RequestContext, } from "./config/config-matchers.js"; import { scanMetadataFiles } from "./server/metadata-routes.js"; -import { staticExportPages } from "./build/static-export.js"; import { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; import { detectPackageManager } from "./utils/project.js"; import { @@ -75,6 +74,7 @@ import { createLocalFontsPlugin, } from "./plugins/fonts.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; +import { computeLazyChunks } from "./utils/lazy-chunks.js"; import tsconfigPaths from "vite-tsconfig-paths"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; @@ -622,91 +622,6 @@ function getClientOutputConfigForVite(viteMajorVersion: number) { : clientOutputConfig; } -type BuildManifestChunk = { - file: string; - isEntry?: boolean; - isDynamicEntry?: boolean; - imports?: string[]; - dynamicImports?: string[]; - css?: string[]; - assets?: string[]; -}; - -/** - * Compute the set of chunk filenames that are ONLY reachable through dynamic - * imports (i.e. behind React.lazy(), next/dynamic, or manual import()). - * - * These chunks should NOT be modulepreloaded in the HTML — they will be - * fetched on demand when the dynamic import executes. - * - * Algorithm: Starting from all entry chunks in the build manifest, walk the - * static `imports` tree (breadth-first). Any chunk file NOT reached by this - * walk is only reachable through `dynamicImports` and is therefore "lazy". - * - * @param buildManifest - Vite's build manifest (manifest.json), which is a - * Record where each chunk has `file`, `imports`, - * `dynamicImports`, `isEntry`, and `isDynamicEntry` fields. - * @returns Array of chunk filenames (e.g. "assets/mermaid-NOHMQCX5.js") that - * should be excluded from modulepreload hints. - */ -function computeLazyChunks(buildManifest: Record): string[] { - // Collect all chunk files that are statically reachable from entries - const eagerFiles = new Set(); - const visited = new Set(); - const queue: string[] = []; - - // Start BFS from all entry chunks - for (const key of Object.keys(buildManifest)) { - const chunk = buildManifest[key]; - if (chunk.isEntry) { - queue.push(key); - } - } - - while (queue.length > 0) { - const key = queue.shift()!; - if (visited.has(key)) continue; - visited.add(key); - - const chunk = buildManifest[key]; - if (!chunk) continue; - - // Mark this chunk's file as eager - eagerFiles.add(chunk.file); - - // Also mark its CSS as eager (CSS should always be preloaded to avoid FOUC) - if (chunk.css) { - for (const cssFile of chunk.css) { - eagerFiles.add(cssFile); - } - } - - // Follow only static imports — NOT dynamicImports - if (chunk.imports) { - for (const imp of chunk.imports) { - if (!visited.has(imp)) { - queue.push(imp); - } - } - } - } - - // Any JS file in the manifest that's NOT in eagerFiles is a lazy chunk - const lazyChunks: string[] = []; - const allFiles = new Set(); - for (const key of Object.keys(buildManifest)) { - const chunk = buildManifest[key]; - if (chunk.file && !allFiles.has(chunk.file)) { - allFiles.add(chunk.file); - if (!eagerFiles.has(chunk.file) && chunk.file.endsWith(".js")) { - lazyChunks.push(chunk.file); - } - } - } - - return lazyChunks; -} - type BundleBackfillChunk = { type: "chunk"; fileName: string; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 3db842da4..62d21ef66 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -45,7 +45,7 @@ import { } from "./image-optimization.js"; import { normalizePath } from "./normalize-path.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; -import { computeLazyChunks } from "../index.js"; +import { computeLazyChunks } from "../utils/lazy-chunks.js"; import { manifestFileWithBase } from "../utils/manifest-paths.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import type { ExecutionContextLike } from "../shims/request-context.js"; From c6eba51e20b75d48d27d02d747d9d110c68bc9cf Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 09:37:20 +0100 Subject: [PATCH 04/11] Address bonk nits: document regex patterns, move PHASE re-exports to bottom, add assets field to BuildManifestChunk --- packages/vinext/src/build/standalone.ts | 4 ++++ packages/vinext/src/config/next-config.ts | 3 ++- packages/vinext/src/utils/lazy-chunks.ts | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index 63eed6efe..ddeef896b 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -80,6 +80,10 @@ function collectServerExternalPackages(serverDir: string): string[] { const packages = new Set(); const files = walkFiles(serverDir).filter((filePath) => /\.(c|m)?js$/.test(filePath)); + // fromRE: import { x } from "pkg" / export { x } from "pkg" + // importSideEffectRE: import "pkg" (side-effect-only import) + // dynamicImportRE: import("pkg") (dynamic import) + // requireRE: require("pkg") (CommonJS require) const fromRE = /\bfrom\s*["']([^"']+)["']/g; const importSideEffectRE = /\bimport\s*["']([^"']+)["']/g; const dynamicImportRE = /import\(\s*["']([^"']+)["']\s*\)/g; diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index ac467f7ec..07aebebc4 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -9,7 +9,6 @@ import { createRequire } from "node:module"; import fs from "node:fs"; import { randomUUID } from "node:crypto"; import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from "../shims/constants.js"; -export { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD }; import { normalizePageExtensions } from "../routing/file-matcher.js"; import { isExternalUrl } from "./config-matchers.js"; @@ -838,3 +837,5 @@ function extractPluginsFromOptions(opts: any): MdxOptions | null { return null; } + +export { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD }; diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts index b0ddf3d6c..d3559c5b3 100644 --- a/packages/vinext/src/utils/lazy-chunks.ts +++ b/packages/vinext/src/utils/lazy-chunks.ts @@ -8,6 +8,7 @@ export interface BuildManifestChunk { imports?: string[]; dynamicImports?: string[]; css?: string[]; + assets?: string[]; } /** From 4f0a549ef07a03b6857fc4af53c8eaf2e12fb6ab Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 10:09:53 +0100 Subject: [PATCH 05/11] refactor(standalone): use Vite bundle graph for server externals instead of regex scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fragile post-build regex scan of emitted JS files and the package.json#dependencies over-seeding with a new vinext:server-externals-manifest Vite plugin. The plugin collects chunk.imports + chunk.dynamicImports from the SSR/RSC writeBundle hook and writes dist/server/vinext-externals.json — the authoritative, compiler-derived list of packages left external in the server bundle. emitStandaloneOutput now reads this manifest as its sole seed for the BFS node_modules copy, so standalone output only includes packages the server actually imports at runtime. Removed collectServerExternalPackages, walkFiles, the four regex constants, and the package.json deps seeding. --- packages/vinext/src/build/standalone.ts | 102 +++++--------- packages/vinext/src/index.ts | 6 + .../src/plugins/server-externals-manifest.ts | 125 ++++++++++++++++++ tests/standalone-build.test.ts | 110 +++++++++++++-- 4 files changed, 261 insertions(+), 82 deletions(-) create mode 100644 packages/vinext/src/plugins/server-externals-manifest.ts diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index ddeef896b..bce524488 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -41,72 +41,30 @@ function runtimeDeps(pkg: PackageJson): string[] { }); } -function walkFiles(dir: string): string[] { - const files: string[] = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...walkFiles(fullPath)); - } else { - files.push(fullPath); - } - } - return files; -} - -function packageNameFromSpecifier(specifier: string): string | null { - if ( - specifier.startsWith(".") || - specifier.startsWith("/") || - specifier.startsWith("node:") || - specifier.startsWith("#") - ) { - return null; - } - - if (specifier.startsWith("@")) { - const parts = specifier.split("/"); - if (parts.length >= 2) { - return `${parts[0]}/${parts[1]}`; - } - return null; +/** + * Read the externals manifest written by the `vinext:server-externals-manifest` + * Vite plugin during the production build. + * + * The manifest (`dist/server/vinext-externals.json`) contains the exact set of + * npm packages that the bundler left external in the SSR/RSC output — i.e. + * packages that the server bundle actually imports at runtime. Using this + * instead of scanning emitted files with regexes or seeding from + * `package.json#dependencies` avoids both false negatives (missed imports) and + * false positives (client-only deps that are never loaded server-side). + * + * Falls back to an empty array if the manifest does not exist (e.g. when + * running against a build that predates this feature). + */ +function readServerExternalsManifest(serverDir: string): string[] { + const manifestPath = path.join(serverDir, "vinext-externals.json"); + if (!fs.existsSync(manifestPath)) { + return []; } - - return specifier.split("/")[0] || null; -} - -function collectServerExternalPackages(serverDir: string): string[] { - const packages = new Set(); - const files = walkFiles(serverDir).filter((filePath) => /\.(c|m)?js$/.test(filePath)); - - // fromRE: import { x } from "pkg" / export { x } from "pkg" - // importSideEffectRE: import "pkg" (side-effect-only import) - // dynamicImportRE: import("pkg") (dynamic import) - // requireRE: require("pkg") (CommonJS require) - const fromRE = /\bfrom\s*["']([^"']+)["']/g; - const importSideEffectRE = /\bimport\s*["']([^"']+)["']/g; - const dynamicImportRE = /import\(\s*["']([^"']+)["']\s*\)/g; - const requireRE = /require\(\s*["']([^"']+)["']\s*\)/g; - - for (const filePath of files) { - const code = fs.readFileSync(filePath, "utf-8"); - - // These regexes are stateful (/g) and intentionally function-local. - // Reset lastIndex before every file scan to avoid leaking state across files. - for (const regex of [fromRE, importSideEffectRE, dynamicImportRE, requireRE]) { - regex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = regex.exec(code)) !== null) { - const packageName = packageNameFromSpecifier(match[1]); - if (packageName) { - packages.add(packageName); - } - } - } + try { + return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as string[]; + } catch { + return []; } - - return [...packages]; } function resolvePackageJsonPath(packageName: string, resolver: NodeRequire): string | null { @@ -247,6 +205,13 @@ function writeStandalonePackageJson(filePath: string): void { * - /standalone/server.js * - /standalone/dist/{client,server} * - /standalone/node_modules (runtime deps only) + * + * The set of packages copied into node_modules/ is determined by + * `dist/server/vinext-externals.json`, which is written by the + * `vinext:server-externals-manifest` Vite plugin during the production build. + * It contains exactly the packages the server bundle imports at runtime + * (i.e. those left external by the bundler), so no client-only deps are + * included. */ export function emitStandaloneOutput(options: StandaloneBuildOptions): StandaloneBuildResult { const root = path.resolve(options.root); @@ -284,10 +249,11 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon fs.mkdirSync(standaloneNodeModulesDir, { recursive: true }); - const appPkg = readPackageJson(path.join(root, "package.json")); - const appRuntimeDeps = runtimeDeps(appPkg); - const serverRuntimeDeps = collectServerExternalPackages(serverDir); - const initialPackages = [...new Set([...appRuntimeDeps, ...serverRuntimeDeps])].filter( + // Seed from the manifest written by vinext:server-externals-manifest during + // the production build. This is the authoritative list of packages the server + // bundle actually imports at runtime — determined by the bundler's own graph, + // not regex scanning or package.json#dependencies. + const initialPackages = readServerExternalsManifest(serverDir).filter( (name) => name !== "vinext", ); const copiedPackages = copyPackageAndRuntimeDeps(root, standaloneNodeModulesDir, initialPackages); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 63d48a30c..eeceacfbc 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -65,6 +65,7 @@ import { createInstrumentationClientTransformPlugin } from "./plugins/instrument import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { fixUseServerClosureCollisionPlugin } from "./plugins/fix-use-server-closure-collision.js"; import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js"; +import { createServerExternalsManifestPlugin } from "./plugins/server-externals-manifest.js"; import { VIRTUAL_GOOGLE_FONTS, RESOLVED_VIRTUAL_GOOGLE_FONTS, @@ -3060,6 +3061,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { createOgInlineFetchAssetsPlugin(), // Copy @vercel/og binary assets to the RSC output directory — see src/plugins/og-assets.ts ogAssetsPlugin, + // Collect SSR/RSC bundle externals and write dist/server/vinext-externals.json. + // Used by emitStandaloneOutput to determine which packages to copy into + // standalone/node_modules/ — uses the bundler's own import graph instead of + // fragile regex scanning of emitted files. + createServerExternalsManifestPlugin(), // Write image config JSON for the App Router production server. // The App Router RSC entry doesn't export vinextConfig (that's a Pages // Router pattern), so we write a separate JSON file at build time that diff --git a/packages/vinext/src/plugins/server-externals-manifest.ts b/packages/vinext/src/plugins/server-externals-manifest.ts new file mode 100644 index 000000000..e8558c813 --- /dev/null +++ b/packages/vinext/src/plugins/server-externals-manifest.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Plugin } from "vite"; + +/** + * Extract the npm package name from a bare module specifier. + * + * Returns null for: + * - Relative imports ("./foo", "../bar") + * - Absolute paths ("/abs/path") + * - Node built-ins ("node:fs") + * - Package self-references ("#imports") + */ +function packageNameFromSpecifier(specifier: string): string | null { + if ( + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("node:") || + specifier.startsWith("#") + ) { + return null; + } + + if (specifier.startsWith("@")) { + const parts = specifier.split("/"); + if (parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + return null; + } + + return specifier.split("/")[0] || null; +} + +/** + * vinext:server-externals-manifest + * + * A `writeBundle` plugin that collects the packages left external by the + * SSR/RSC bundler and writes them to `/vinext-externals.json`. + * + * With `noExternal: true`, Vite bundles almost everything — only packages + * explicitly listed in `ssr.external` / `resolve.external` remain as live + * imports in the server bundle. Those packages are exactly what a standalone + * deployment needs in `node_modules/`. + * + * Using the bundler's own import graph (`chunk.imports` + `chunk.dynamicImports`) + * is authoritative: no text parsing, no regex, no guessing. + * + * The written JSON is an array of package-name strings, e.g.: + * ["react", "react-dom", "react-dom/server"] + * + * `emitStandaloneOutput` reads this file and uses it as the seed list for the + * BFS `node_modules/` copy, replacing the old regex-scan approach. + */ +export function createServerExternalsManifestPlugin(): Plugin { + // Accumulate external specifiers across all server environments (rsc + ssr). + // Both environments run writeBundle; we merge their results so Pages Router + // builds (ssr only) and App Router builds (rsc + ssr) both produce a + // complete manifest. + const externals = new Set(); + // Track how many server environments have completed their writeBundle call + // so we know when it is safe to flush to disk. + let pendingWrites = 0; + let outDir: string | null = null; + + return { + name: "vinext:server-externals-manifest", + apply: "build", + enforce: "post", + + writeBundle: { + sequential: true, + order: "post", + handler(options, bundle) { + const envName = this.environment?.name; + // Only collect from server environments (rsc = App Router RSC build, + // ssr = Pages Router SSR build or App Router SSR build). + if (envName !== "rsc" && envName !== "ssr") return; + + const dir = options.dir; + if (!dir) return; + + // Use the first server env's outDir parent as the canonical server dir. + // For Pages Router: options.dir IS dist/server. + // For App Router RSC: options.dir is dist/server. + // For App Router SSR: options.dir is dist/server/ssr. + // We always want dist/server as the manifest location. + if (!outDir) { + // Walk up from options.dir until we find a parent whose basename is "server", + // or fall back to options.dir if none found within 3 levels. + let candidate = dir; + for (let i = 0; i < 3; i++) { + if (path.basename(candidate) === "server") { + outDir = candidate; + break; + } + const parent = path.dirname(candidate); + if (parent === candidate) break; + candidate = parent; + } + if (!outDir) outDir = dir; + } + + pendingWrites++; + + for (const item of Object.values(bundle)) { + if (item.type !== "chunk") continue; + const chunk = item as { imports: string[]; dynamicImports: string[] }; + for (const specifier of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) { + const pkg = packageNameFromSpecifier(specifier); + if (pkg) externals.add(pkg); + } + } + + // After the last expected writeBundle call, flush to disk. + // We flush on every call since we don't know ahead of time how many + // environments will fire — overwriting with the accumulated set is safe. + if (outDir && fs.existsSync(outDir)) { + const manifestPath = path.join(outDir, "vinext-externals.json"); + fs.writeFileSync(manifestPath, JSON.stringify([...externals], null, 2) + "\n", "utf-8"); + } + }, + }, + }; +} diff --git a/tests/standalone-build.test.ts b/tests/standalone-build.test.ts index 1c6029a50..6be00e2f8 100644 --- a/tests/standalone-build.test.ts +++ b/tests/standalone-build.test.ts @@ -50,7 +50,7 @@ afterEach(() => { }); describe("emitStandaloneOutput", () => { - it("creates standalone output with runtime deps and entry script", () => { + it("copies packages listed in vinext-externals.json and their transitive deps", () => { const appRoot = path.join(tmpDir, "app"); fs.mkdirSync(appRoot, { recursive: true }); @@ -61,14 +61,13 @@ describe("emitStandaloneOutput", () => { { name: "app", dependencies: { + // dep-a is in package.json#dependencies but NOT in the externals manifest. + // The standalone builder should NOT copy it — only manifest entries matter. "dep-a": "1.0.0", react: "1.0.0", vinext: "1.0.0", }, devDependencies: { - // This is intentionally in devDependencies. The standalone builder - // should still copy it because the server bundle imports it directly. - "react-server-dom-webpack": "1.0.0", typescript: "5.0.0", }, }, @@ -78,10 +77,13 @@ describe("emitStandaloneOutput", () => { ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", 'console.log("server");\n'); + // The externals manifest is written by vinext:server-externals-manifest at build time. + // It contains only the packages the server bundle actually imports at runtime. writeFile( appRoot, - "dist/server/entry.js", - 'import "react-server-dom-webpack/server.edge";\nconsole.log("server");\n', + "dist/server/vinext-externals.json", + JSON.stringify(["react", "react-server-dom-webpack"]), ); writeFile(appRoot, "public/robots.txt", "User-agent: *\n"); @@ -116,12 +118,17 @@ describe("emitStandaloneOutput", () => { vinextPackageRoot: fakeVinextRoot, }); - expect(result.copiedPackages).toContain("dep-a"); - expect(result.copiedPackages).toContain("dep-b"); + // Packages from the externals manifest are copied. expect(result.copiedPackages).toContain("react"); expect(result.copiedPackages).toContain("react-server-dom-webpack"); expect(result.copiedPackages).toContain("vinext"); + // dep-a is in package.json#dependencies but NOT in the manifest — must NOT be copied. + expect(result.copiedPackages).not.toContain("dep-a"); + expect(result.copiedPackages).not.toContain("dep-b"); + // devDependencies must never be copied. + expect(result.copiedPackages).not.toContain("typescript"); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/server.js"))).toBe(true); expect(fs.readFileSync(path.join(appRoot, "dist/standalone/server.js"), "utf-8")).toContain( "startProdServer", @@ -137,12 +144,6 @@ describe("emitStandaloneOutput", () => { expect(fs.existsSync(path.join(appRoot, "dist/standalone/dist/server/entry.js"))).toBe(true); expect(fs.existsSync(path.join(appRoot, "dist/standalone/public/robots.txt"))).toBe(true); - expect( - fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-a/package.json")), - ).toBe(true); - expect( - fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-b/package.json")), - ).toBe(true); expect( fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/react/package.json")), ).toBe(true); @@ -151,6 +152,9 @@ describe("emitStandaloneOutput", () => { path.join(appRoot, "dist/standalone/node_modules/react-server-dom-webpack/package.json"), ), ).toBe(true); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-a/package.json")), + ).toBe(false); expect( fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/typescript/package.json")), ).toBe(false); @@ -161,6 +165,80 @@ describe("emitStandaloneOutput", () => { ).toBe(true); }); + it("copies transitive dependencies of manifest packages", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile(appRoot, "package.json", JSON.stringify({ name: "app" }, null, 2)); + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", 'console.log("server");\n'); + // dep-a is in the manifest; dep-b is dep-a's dependency (transitive). + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify(["dep-a"])); + + writePackage(appRoot, "dep-a", { "dep-b": "1.0.0" }); + writePackage(appRoot, "dep-b"); + + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify({ name: "vinext", type: "module" }, null, 2), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", + ); + + const result = emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }); + + expect(result.copiedPackages).toContain("dep-a"); + // Transitive dep of dep-a must also be present. + expect(result.copiedPackages).toContain("dep-b"); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-a/package.json")), + ).toBe(true); + expect( + fs.existsSync(path.join(appRoot, "dist/standalone/node_modules/dep-b/package.json")), + ).toBe(true); + }); + + it("falls back gracefully when vinext-externals.json is missing (no manifest)", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile(appRoot, "package.json", JSON.stringify({ name: "app" }, null, 2)); + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", 'console.log("server");\n'); + // No vinext-externals.json written. + + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify({ name: "vinext", type: "module" }, null, 2), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", + ); + + const result = emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }); + + // Only vinext itself should be present (always embedded). + expect(result.copiedPackages).toEqual(["vinext"]); + expect(fs.existsSync(path.join(appRoot, "dist/standalone/server.js"))).toBe(true); + }); + it("throws when dist/client or dist/server are missing", () => { const appRoot = path.join(tmpDir, "app"); fs.mkdirSync(appRoot, { recursive: true }); @@ -195,6 +273,7 @@ describe("emitStandaloneOutput", () => { ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); writeFile(appRoot, "dist/server/entry.js", "import 'dep-hidden';\n"); + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify(["dep-hidden"])); writePackage(appRoot, "dep-hidden", {}, { exports: { ".": "./index.js" } }); @@ -243,6 +322,8 @@ describe("emitStandaloneOutput", () => { ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); writeFile(appRoot, "dist/server/entry.js", "console.log('server');\n"); + // missing-required is in the manifest but not installed in node_modules. + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify(["missing-required"])); const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); writeFile( @@ -286,6 +367,7 @@ describe("emitStandaloneOutput", () => { ); writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); writeFile(appRoot, "dist/server/entry.js", "import 'dep-link';\n"); + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify(["dep-link"])); const storeDir = path.join(tmpDir, "store", "dep-link"); fs.mkdirSync(storeDir, { recursive: true }); From 8dbc4699f167e03864f9a2cca24ed0405aa6e6ea Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 10:33:44 +0100 Subject: [PATCH 06/11] fix(standalone): copy vinext runtime deps, add node_modules filter to cpSync, remove unused pendingWrites --- packages/vinext/src/build/standalone.ts | 30 +++++++++-- .../src/plugins/server-externals-manifest.ts | 5 -- tests/standalone-build.test.ts | 52 +++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index bce524488..e0a53cb1a 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -97,11 +97,12 @@ function copyPackageAndRuntimeDeps( root: string, targetNodeModulesDir: string, initialPackages: string[], + alreadyCopied?: Set, ): string[] { const rootResolver = createRequire(path.join(root, "package.json")); const rootPkg = readPackageJson(path.join(root, "package.json")); const rootOptional = new Set(Object.keys(rootPkg.optionalDependencies ?? {})); - const copied = new Set(); + const copied = alreadyCopied ?? new Set(); const queue: QueueEntry[] = initialPackages.map((packageName) => ({ packageName, resolver: rootResolver, @@ -126,7 +127,13 @@ function copyPackageAndRuntimeDeps( const packageRoot = path.dirname(packageJsonPath); const packageTarget = path.join(targetNodeModulesDir, entry.packageName); fs.mkdirSync(path.dirname(packageTarget), { recursive: true }); - fs.cpSync(packageRoot, packageTarget, { recursive: true, dereference: true }); + fs.cpSync(packageRoot, packageTarget, { + recursive: true, + dereference: true, + // Skip any nested node_modules/ — the BFS walk resolves deps at their + // correct hoisted location, so nested copies would be stale duplicates. + filter: (src) => path.basename(src) !== "node_modules", + }); copied.add(entry.packageName); @@ -256,7 +263,8 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon const initialPackages = readServerExternalsManifest(serverDir).filter( (name) => name !== "vinext", ); - const copiedPackages = copyPackageAndRuntimeDeps(root, standaloneNodeModulesDir, initialPackages); + const copiedSet = new Set(); + copyPackageAndRuntimeDeps(root, standaloneNodeModulesDir, initialPackages, copiedSet); // Always embed the exact vinext runtime that produced this build. const vinextPackageRoot = resolveVinextPackageRoot(options.vinextPackageRoot); @@ -274,12 +282,26 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon recursive: true, dereference: true, }); + copiedSet.add("vinext"); + + // Copy vinext's own runtime dependencies. The prod-server imports packages + // like `rsc-html-stream` at runtime; they must be present in standalone + // node_modules/ even if the user's app doesn't depend on them directly. + // We resolve them from vinext's package root so nested requires work correctly. + const vinextPkg = readPackageJson(path.join(vinextPackageRoot, "package.json")); + const vinextRuntimeDeps = runtimeDeps(vinextPkg).filter((name) => !copiedSet.has(name)); + copyPackageAndRuntimeDeps( + vinextPackageRoot, + standaloneNodeModulesDir, + vinextRuntimeDeps, + copiedSet, + ); writeStandaloneServerEntry(path.join(standaloneDir, "server.js")); writeStandalonePackageJson(path.join(standaloneDir, "package.json")); return { standaloneDir, - copiedPackages: [...new Set([...copiedPackages, "vinext"])], + copiedPackages: [...copiedSet], }; } diff --git a/packages/vinext/src/plugins/server-externals-manifest.ts b/packages/vinext/src/plugins/server-externals-manifest.ts index e8558c813..6ccce756d 100644 --- a/packages/vinext/src/plugins/server-externals-manifest.ts +++ b/packages/vinext/src/plugins/server-externals-manifest.ts @@ -58,9 +58,6 @@ export function createServerExternalsManifestPlugin(): Plugin { // builds (ssr only) and App Router builds (rsc + ssr) both produce a // complete manifest. const externals = new Set(); - // Track how many server environments have completed their writeBundle call - // so we know when it is safe to flush to disk. - let pendingWrites = 0; let outDir: string | null = null; return { @@ -101,8 +98,6 @@ export function createServerExternalsManifestPlugin(): Plugin { if (!outDir) outDir = dir; } - pendingWrites++; - for (const item of Object.values(bundle)) { if (item.type !== "chunk") continue; const chunk = item as { imports: string[]; dynamicImports: string[] }; diff --git a/tests/standalone-build.test.ts b/tests/standalone-build.test.ts index 6be00e2f8..fdbb91e09 100644 --- a/tests/standalone-build.test.ts +++ b/tests/standalone-build.test.ts @@ -346,6 +346,58 @@ describe("emitStandaloneOutput", () => { ).toThrow('Failed to resolve required runtime dependency "missing-required"'); }); + it("copies vinext's own runtime dependencies into standalone node_modules", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile(appRoot, "package.json", JSON.stringify({ name: "app" }, null, 2)); + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", 'console.log("server");\n'); + // No app-level externals; the manifest is empty. + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify([])); + + // Set up a fake vinext package that has its own runtime dependency (rsc-html-stream), + // which the app does NOT depend on but vinext's prod-server needs at runtime. + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify( + { + name: "vinext", + version: "0.0.0-test", + type: "module", + dependencies: { + "rsc-html-stream": "1.0.0", + }, + }, + null, + 2, + ), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", + ); + // Install rsc-html-stream in the fake vinext's node_modules so it can be resolved. + writePackage(fakeVinextRoot, "rsc-html-stream"); + + const result = emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }); + + // vinext's own runtime dep must be present even though the app doesn't list it. + expect(result.copiedPackages).toContain("rsc-html-stream"); + expect( + fs.existsSync( + path.join(appRoot, "dist/standalone/node_modules/rsc-html-stream/package.json"), + ), + ).toBe(true); + }); + it("copies packages referenced through symlinked node_modules entries", () => { const appRoot = path.join(tmpDir, "app"); fs.mkdirSync(appRoot, { recursive: true }); From 15e82546a701108d9e36968513f50df63ee2a634 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 11:41:38 +0100 Subject: [PATCH 07/11] =?UTF-8?q?fix(standalone):=20address=20round-6=20bo?= =?UTF-8?q?nk=20nits=20=E2=80=94=20explicit=20root,=20manifest=20comment,?= =?UTF-8?q?=20pre-flight=20check,=20bare-specifier=20comment,=20export-fro?= =?UTF-8?q?m=20re-export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vinext/src/build/standalone.ts | 15 ++++++++++++--- packages/vinext/src/cli.ts | 16 +++++++++++++++- packages/vinext/src/config/next-config.ts | 2 +- .../src/plugins/server-externals-manifest.ts | 10 ++++++++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index e0a53cb1a..0ad340a16 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -130,9 +130,12 @@ function copyPackageAndRuntimeDeps( fs.cpSync(packageRoot, packageTarget, { recursive: true, dereference: true, - // Skip any nested node_modules/ — the BFS walk resolves deps at their - // correct hoisted location, so nested copies would be stale duplicates. - filter: (src) => path.basename(src) !== "node_modules", + // Skip any nested node_modules/ inside the package — the BFS walk + // resolves deps at their correct hoisted location, so nested copies + // would be stale duplicates. Check only the relative portion of the + // path (after packageRoot) so the source path's own node_modules + // ancestor doesn't accidentally filter out the package itself. + filter: (src) => !path.relative(packageRoot, src).includes(`node_modules`), }); copied.add(entry.packageName); @@ -260,6 +263,12 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon // the production build. This is the authoritative list of packages the server // bundle actually imports at runtime — determined by the bundler's own graph, // not regex scanning or package.json#dependencies. + // + // The manifest is always written to dist/server/vinext-externals.json regardless + // of whether the build is App Router (rsc + ssr sub-dirs) or Pages Router (ssr + // only). The plugin walks up from options.dir to find the "server" ancestor, so + // both dist/server (Pages Router) and dist/server/ssr (App Router SSR) resolve + // to the same dist/server output path. const initialPackages = readServerExternalsManifest(serverDir).filter( (name) => name !== "vinext", ); diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index c07e83405..5dcdd724c 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -18,7 +18,7 @@ import { printBuildReport } from "./build/report.js"; import { runPrerender } from "./build/run-prerender.js"; import path from "node:path"; import fs from "node:fs"; -import { pathToFileURL } from "node:url"; +import { pathToFileURL, fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; import { detectPackageManager, ensureViteConfigCompatibility } from "./utils/project.js"; @@ -370,10 +370,24 @@ async function buildApp() { const isApp = hasAppDir(); const resolvedNextConfig = await resolveNextConfig( await loadNextConfig(process.cwd(), PHASE_PRODUCTION_BUILD), + process.cwd(), ); const outputMode = resolvedNextConfig.output; const distDir = path.resolve(process.cwd(), "dist"); + // Pre-flight check: verify vinext's own dist/ exists before starting the build. + // Without this, a missing dist/ (e.g. from a broken install) only surfaces after + // the full multi-minute Vite build completes, when emitStandaloneOutput runs. + if (outputMode === "standalone") { + const vinextDistDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"); + if (!fs.existsSync(vinextDistDir)) { + console.error( + ` Error: vinext dist/ not found at ${vinextDistDir}. Run \`pnpm run build\` in the vinext package first.`, + ); + process.exit(1); + } + } + // In verbose mode, skip the custom logger so raw Vite/Rollup output is shown. const logger = parsed.verbose ? vite.createLogger("info", { allowClearScreen: false }) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 07aebebc4..28f8388c6 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -838,4 +838,4 @@ function extractPluginsFromOptions(opts: any): MdxOptions | null { return null; } -export { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD }; +export { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from "../shims/constants.js"; diff --git a/packages/vinext/src/plugins/server-externals-manifest.ts b/packages/vinext/src/plugins/server-externals-manifest.ts index 6ccce756d..d24ba5e68 100644 --- a/packages/vinext/src/plugins/server-externals-manifest.ts +++ b/packages/vinext/src/plugins/server-externals-manifest.ts @@ -100,8 +100,14 @@ export function createServerExternalsManifestPlugin(): Plugin { for (const item of Object.values(bundle)) { if (item.type !== "chunk") continue; - const chunk = item as { imports: string[]; dynamicImports: string[] }; - for (const specifier of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) { + const chunk = item; + // In Rollup output, chunk.imports normally contains filenames of other + // chunks in the bundle. But externalized packages remain as bare npm + // specifiers (e.g. "react", "@mdx-js/react") since they were never + // bundled into chunk files. packageNameFromSpecifier filters out chunk + // filenames (relative/absolute paths) and extracts the package name from + // bare specifiers — which is exactly what the standalone BFS needs. + for (const specifier of [...chunk.imports, ...chunk.dynamicImports]) { const pkg = packageNameFromSpecifier(specifier); if (pkg) externals.add(pkg); } From 2f8d0e8d2bd8d3f70ec21a1e4932e798b5083613 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 20:00:39 +0100 Subject: [PATCH 08/11] fix(standalone): address round 7 bonk review comments - harden outDir resolution in server-externals-manifest: replace fragile walk-up heuristic with direct basename check (ssr -> dirname, else dir), avoiding misfires when a user's project path contains a 'server' segment - remove redundant 'chunk' alias in server-externals-manifest writeBundle; use 'item' directly after the type guard narrows it to OutputChunk - add node_modules filter to vinext dist/ cpSync in standalone.ts, matching the same defensive filter used for app package copies - rewrite standalone server.js as pure ESM using import.meta.dirname (Node >= 21.2, vinext requires >= 22); drop CJS require/__dirname and switch standalone package.json to 'type':'module' - add explanatory comment for DEFAULT_PHASE constant in next-config.ts - add comment in cli.ts noting pre-flight path and resolveVinextPackageRoot must stay in sync --- packages/vinext/src/build/standalone.ts | 42 +++++++++++-------- packages/vinext/src/cli.ts | 5 +++ packages/vinext/src/config/next-config.ts | 4 ++ .../src/plugins/server-externals-manifest.ts | 24 ++++------- tests/standalone-build.test.ts | 2 +- 5 files changed, 42 insertions(+), 35 deletions(-) diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index 0ad340a16..8f5b87ba1 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -132,10 +132,13 @@ function copyPackageAndRuntimeDeps( dereference: true, // Skip any nested node_modules/ inside the package — the BFS walk // resolves deps at their correct hoisted location, so nested copies - // would be stale duplicates. Check only the relative portion of the - // path (after packageRoot) so the source path's own node_modules - // ancestor doesn't accidentally filter out the package itself. - filter: (src) => !path.relative(packageRoot, src).includes(`node_modules`), + // would be stale duplicates. Use path segment splitting so that a + // directory merely containing "node_modules" as a substring (e.g. + // "not_node_modules_v2") is not accidentally filtered out. + filter: (src) => { + const rel = path.relative(packageRoot, src); + return !rel.split(path.sep).includes("node_modules"); + }, }); copied.add(entry.packageName); @@ -168,22 +171,20 @@ function resolveVinextPackageRoot(explicitRoot?: string): string { } function writeStandaloneServerEntry(filePath: string): void { + // Uses import.meta.dirname (Node >= 21.2, vinext requires >= 22) so the + // entry point is pure ESM — no need for CJS require() or __dirname. const content = `#!/usr/bin/env node -const path = require("node:path"); - -async function main() { - const { startProdServer } = await import("vinext/server/prod-server"); - const port = Number.parseInt(process.env.PORT ?? "3000", 10); - const host = process.env.HOST ?? "0.0.0.0"; +import { join } from "node:path"; +import { startProdServer } from "vinext/server/prod-server"; - await startProdServer({ - port, - host, - outDir: path.join(__dirname, "dist"), - }); -} +const port = Number.parseInt(process.env.PORT ?? "3000", 10); +const host = process.env.HOST ?? "0.0.0.0"; -main().catch((error) => { +startProdServer({ + port, + host, + outDir: join(import.meta.dirname, "dist"), +}).catch((error) => { console.error("[vinext] Failed to start standalone server"); console.error(error); process.exit(1); @@ -199,7 +200,7 @@ function writeStandalonePackageJson(filePath: string): void { JSON.stringify( { private: true, - type: "commonjs", + type: "module", }, null, 2, @@ -290,6 +291,11 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon fs.cpSync(vinextDistDir, path.join(vinextTargetDir, "dist"), { recursive: true, dereference: true, + // Defensive: skip any node_modules/ that may exist inside vinext's dist/. + filter: (src) => { + const rel = path.relative(vinextDistDir, src); + return !rel.split(path.sep).includes("node_modules"); + }, }); copiedSet.add("vinext"); diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 5dcdd724c..127ef3c4c 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -379,6 +379,11 @@ async function buildApp() { // Without this, a missing dist/ (e.g. from a broken install) only surfaces after // the full multi-minute Vite build completes, when emitStandaloneOutput runs. if (outputMode === "standalone") { + // Resolves to /dist — one level up from this file's own + // directory (src/ at dev time, dist/ at runtime via the built CLI). + // Must stay in sync with resolveVinextPackageRoot() in standalone.ts, which + // uses path.resolve(currentDir, "..", "..") from dist/build/standalone.js + // and arrives at the same package root via a different traversal depth. const vinextDistDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"); if (!fs.existsSync(vinextDistDir)) { console.error( diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 28f8388c6..21f14e371 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -271,6 +271,10 @@ function isCjsError(e: unknown): boolean { ); } +// Dev-server phase is the safe default for config loading: it enables all +// optional config sections (headers, redirects, rewrites) without triggering +// build-only behaviour. Used in two default parameter values below to avoid +// repeating PHASE_DEVELOPMENT_SERVER inline. const DEFAULT_PHASE = PHASE_DEVELOPMENT_SERVER; /** diff --git a/packages/vinext/src/plugins/server-externals-manifest.ts b/packages/vinext/src/plugins/server-externals-manifest.ts index d24ba5e68..12c7e2330 100644 --- a/packages/vinext/src/plugins/server-externals-manifest.ts +++ b/packages/vinext/src/plugins/server-externals-manifest.ts @@ -83,31 +83,23 @@ export function createServerExternalsManifestPlugin(): Plugin { // For App Router SSR: options.dir is dist/server/ssr. // We always want dist/server as the manifest location. if (!outDir) { - // Walk up from options.dir until we find a parent whose basename is "server", - // or fall back to options.dir if none found within 3 levels. - let candidate = dir; - for (let i = 0; i < 3; i++) { - if (path.basename(candidate) === "server") { - outDir = candidate; - break; - } - const parent = path.dirname(candidate); - if (parent === candidate) break; - candidate = parent; - } - if (!outDir) outDir = dir; + // The only sub-directory case is App Router SSR, which outputs to + // dist/server/ssr. For all other environments, options.dir is already + // dist/server. Using basename avoids the fragile walk-up heuristic + // which would misfire if a user's project path contains a directory + // named "server" above the dist output (e.g. /home/user/server/my-app/). + outDir = path.basename(dir) === "ssr" ? path.dirname(dir) : dir; } for (const item of Object.values(bundle)) { if (item.type !== "chunk") continue; - const chunk = item; - // In Rollup output, chunk.imports normally contains filenames of other + // In Rollup output, item.imports normally contains filenames of other // chunks in the bundle. But externalized packages remain as bare npm // specifiers (e.g. "react", "@mdx-js/react") since they were never // bundled into chunk files. packageNameFromSpecifier filters out chunk // filenames (relative/absolute paths) and extracts the package name from // bare specifiers — which is exactly what the standalone BFS needs. - for (const specifier of [...chunk.imports, ...chunk.dynamicImports]) { + for (const specifier of [...item.imports, ...item.dynamicImports]) { const pkg = packageNameFromSpecifier(specifier); if (pkg) externals.add(pkg); } diff --git a/tests/standalone-build.test.ts b/tests/standalone-build.test.ts index fdbb91e09..b69ef7de5 100644 --- a/tests/standalone-build.test.ts +++ b/tests/standalone-build.test.ts @@ -136,7 +136,7 @@ describe("emitStandaloneOutput", () => { const standalonePkg = JSON.parse( fs.readFileSync(path.join(appRoot, "dist/standalone/package.json"), "utf-8"), ) as { type: string }; - expect(standalonePkg.type).toBe("commonjs"); + expect(standalonePkg.type).toBe("module"); expect(fs.existsSync(path.join(appRoot, "dist/standalone/dist/client/assets/main.js"))).toBe( true, From 242cc84b5109a0af53fc450e08175ecc5949f172 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 22:15:48 +0100 Subject: [PATCH 09/11] fix(standalone): address round 8 bonk review comments - fix test import: use 'vite-plus/test' instead of 'vitest' in standalone-build.test.ts, matching the convention used by every other test file in the repo (documented in AGENTS.md) - extract resolveVinextPackageRoot into utils/vinext-root.ts so cli.ts and standalone.ts share a single source of truth; removes the coupling comment added in round 7 and the duplicated path traversal logic - add node_modules filter to dist/client and dist/server cpSync calls for defensive consistency with the vinext dist/ and package copies --- packages/vinext/src/build/standalone.ts | 16 ++++-------- packages/vinext/src/cli.ts | 10 +++----- packages/vinext/src/utils/vinext-root.ts | 31 ++++++++++++++++++++++++ tests/standalone-build.test.ts | 2 +- 4 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 packages/vinext/src/utils/vinext-root.ts diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index 8f5b87ba1..3039ca87f 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; +import { resolveVinextPackageRoot } from "../utils/vinext-root.js"; interface PackageJson { name?: string; @@ -160,16 +160,6 @@ function copyPackageAndRuntimeDeps( return [...copied]; } -function resolveVinextPackageRoot(explicitRoot?: string): string { - if (explicitRoot) { - return path.resolve(explicitRoot); - } - - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - // dist/build/standalone.js -> package root is ../.. - return path.resolve(currentDir, "..", ".."); -} - function writeStandaloneServerEntry(filePath: string): void { // Uses import.meta.dirname (Node >= 21.2, vinext requires >= 22) so the // entry point is pure ESM — no need for CJS require() or __dirname. @@ -244,10 +234,14 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon fs.cpSync(clientDir, path.join(standaloneDistDir, "client"), { recursive: true, dereference: true, + // Build output shouldn't contain node_modules, but filter defensively for + // consistency with the other cpSync calls in this function. + filter: (src) => !path.relative(clientDir, src).split(path.sep).includes("node_modules"), }); fs.cpSync(serverDir, path.join(standaloneDistDir, "server"), { recursive: true, dereference: true, + filter: (src) => !path.relative(serverDir, src).split(path.sep).includes("node_modules"), }); const publicDir = path.join(root, "public"); diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 127ef3c4c..90dca2c92 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -18,7 +18,7 @@ import { printBuildReport } from "./build/report.js"; import { runPrerender } from "./build/run-prerender.js"; import path from "node:path"; import fs from "node:fs"; -import { pathToFileURL, fileURLToPath } from "node:url"; +import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; import { detectPackageManager, ensureViteConfigCompatibility } from "./utils/project.js"; @@ -28,6 +28,7 @@ import { init as runInit, getReactUpgradeDeps } from "./init.js"; import { loadDotenv } from "./config/dotenv.js"; import { loadNextConfig, resolveNextConfig, PHASE_PRODUCTION_BUILD } from "./config/next-config.js"; import { emitStandaloneOutput } from "./build/standalone.js"; +import { resolveVinextPackageRoot } from "./utils/vinext-root.js"; // ─── Resolve Vite from the project root ──────────────────────────────────────── // @@ -379,12 +380,7 @@ async function buildApp() { // Without this, a missing dist/ (e.g. from a broken install) only surfaces after // the full multi-minute Vite build completes, when emitStandaloneOutput runs. if (outputMode === "standalone") { - // Resolves to /dist — one level up from this file's own - // directory (src/ at dev time, dist/ at runtime via the built CLI). - // Must stay in sync with resolveVinextPackageRoot() in standalone.ts, which - // uses path.resolve(currentDir, "..", "..") from dist/build/standalone.js - // and arrives at the same package root via a different traversal depth. - const vinextDistDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"); + const vinextDistDir = path.join(resolveVinextPackageRoot(), "dist"); if (!fs.existsSync(vinextDistDir)) { console.error( ` Error: vinext dist/ not found at ${vinextDistDir}. Run \`pnpm run build\` in the vinext package first.`, diff --git a/packages/vinext/src/utils/vinext-root.ts b/packages/vinext/src/utils/vinext-root.ts new file mode 100644 index 000000000..c08117bdb --- /dev/null +++ b/packages/vinext/src/utils/vinext-root.ts @@ -0,0 +1,31 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Resolve the root directory of the vinext package at runtime. + * + * Both the CLI pre-flight check (`cli.ts`) and the standalone output emitter + * (`build/standalone.ts`) need to locate vinext's package root so they can + * verify that `dist/` exists and copy vinext's runtime files into standalone + * output. Centralising the logic here ensures the two callers stay in sync. + * + * The resolution works for both the compiled output layout and for running + * directly from source: + * + * - Compiled layout: this file lives at `dist/utils/vinext-root.js` + * → two levels up (`../..`) is the package root. + * - Source layout: this file lives at `src/utils/vinext-root.ts` + * → two levels up (`../..`) is the package root. + * + * If an explicit root is provided (e.g. from a test fixture), it is returned + * as-is after resolving to an absolute path. + */ +export function resolveVinextPackageRoot(explicitRoot?: string): string { + if (explicitRoot) { + return path.resolve(explicitRoot); + } + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + // src/utils/vinext-root.ts → ../.. → package root + // dist/utils/vinext-root.js → ../.. → package root + return path.resolve(currentDir, "..", ".."); +} diff --git a/tests/standalone-build.test.ts b/tests/standalone-build.test.ts index b69ef7de5..bd1691e11 100644 --- a/tests/standalone-build.test.ts +++ b/tests/standalone-build.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; From 5bed771bed66475265b66512865fb5a4bb65a103 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 22:46:04 +0100 Subject: [PATCH 10/11] interface -> type --- packages/vinext/src/build/standalone.ts | 16 ++++++++-------- packages/vinext/src/utils/lazy-chunks.ts | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index 3039ca87f..42d6553a1 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -3,32 +3,32 @@ import path from "node:path"; import { createRequire } from "node:module"; import { resolveVinextPackageRoot } from "../utils/vinext-root.js"; -interface PackageJson { +type PackageJson = { name?: string; dependencies?: Record; devDependencies?: Record; optionalDependencies?: Record; -} +}; -export interface StandaloneBuildOptions { +export type StandaloneBuildOptions = { root: string; outDir: string; /** * Test hook: override vinext package root used for embedding runtime files. */ vinextPackageRoot?: string; -} +}; -export interface StandaloneBuildResult { +export type StandaloneBuildResult = { standaloneDir: string; copiedPackages: string[]; -} +}; -interface QueueEntry { +type QueueEntry = { packageName: string; resolver: NodeRequire; optional: boolean; -} +}; function readPackageJson(packageJsonPath: string): PackageJson { return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as PackageJson; diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts index d3559c5b3..8aeb6ca8d 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. */ -export interface BuildManifestChunk { +export type BuildManifestChunk = { file: string; isEntry?: boolean; isDynamicEntry?: boolean; @@ -9,7 +9,7 @@ export interface BuildManifestChunk { dynamicImports?: string[]; css?: string[]; assets?: string[]; -} +}; /** * Compute the set of chunk filenames that are ONLY reachable through dynamic From 4656869d019f8aa50bb8a53bfb59f64f09b49fd7 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 22:48:33 +0100 Subject: [PATCH 11/11] fix(standalone): address round 9 bonk review comments - add node_modules filter to public/ cpSync for defensive consistency with all other cpSync calls in emitStandaloneOutput - warn on malformed vinext-externals.json instead of silently returning an empty list; includes the error string to aid debugging - add doc comment on copyPackageAndRuntimeDeps clarifying that the return value is the full accumulated set (including pre-existing entries), not only packages copied by the current call - document HOST (not HOSTNAME) env var in README standalone section, with a note explaining the difference from Next.js standalone behaviour --- README.md | 4 ++++ packages/vinext/src/build/standalone.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d0d029fc8..a9d30a7bf 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ If your `next.config.*` sets `output: "standalone"`, `vinext build` emits a self node dist/standalone/server.js ``` +Environment variables: `PORT` (default `3000`), `HOST` (default `0.0.0.0`). + +> **Note:** Next.js standalone uses `HOSTNAME` for the bind address, but vinext uses `HOST` to avoid collision with the system-set `HOSTNAME` variable on Linux. Update your deployment config accordingly. + ### Starting a new vinext project Run `npm create next-app@latest` to create a new Next.js project, and then follow these instructions to migrate it to vinext. diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index 42d6553a1..4d54335c6 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -62,7 +62,10 @@ function readServerExternalsManifest(serverDir: string): string[] { } try { return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as string[]; - } catch { + } catch (err) { + console.warn( + `[vinext] Warning: failed to parse ${manifestPath}, proceeding without externals manifest: ${String(err)}`, + ); return []; } } @@ -99,6 +102,10 @@ function copyPackageAndRuntimeDeps( initialPackages: string[], alreadyCopied?: Set, ): string[] { + // Returns the full set of package names in `copied` after the BFS completes — + // including any entries that were already in `alreadyCopied` before this call. + // Callers that need to track incremental additions should diff against their + // own snapshot, or use the shared `alreadyCopied` set directly. const rootResolver = createRequire(path.join(root, "package.json")); const rootPkg = readPackageJson(path.join(root, "package.json")); const rootOptional = new Set(Object.keys(rootPkg.optionalDependencies ?? {})); @@ -249,6 +256,9 @@ export function emitStandaloneOutput(options: StandaloneBuildOptions): Standalon fs.cpSync(publicDir, path.join(standaloneDir, "public"), { recursive: true, dereference: true, + // Defensive: public/ containing node_modules is extremely unlikely but + // filter for consistency with the other cpSync calls in this function. + filter: (src) => !path.relative(publicDir, src).split(path.sep).includes("node_modules"), }); }