diff --git a/README.md b/README.md index 6a181526f..a9d30a7bf 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ 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 +``` + +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. @@ -94,7 +104,7 @@ This will: 2. Install `vite`, `@vitejs/plugin-react`, and App Router-only deps (`@vitejs/plugin-rsc`, `react-server-dom-webpack`) 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. @@ -103,6 +113,8 @@ vinext targets Vite 8, which defaults to Rolldown, Oxc, Lightning CSS, and a new ```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 ``` @@ -458,25 +470,26 @@ Every `next/*` import is shimmed to a Vite-compatible implementation. ### Server features -| Feature | | Notes | -| ---------------------------------- | --- | ------------------------------------------------------------------------------------------- | -| SSR (Pages Router) | ✅ | Streaming, `_app`/`_document`, `__NEXT_DATA__`, hydration | -| SSR (App Router) | ✅ | RSC pipeline, nested layouts, streaming, nav context for client components | -| `getStaticProps` | ✅ | Props, redirect, notFound, revalidate | -| `getStaticPaths` | ✅ | `fallback: false`, `true`, `"blocking"` | -| `getServerSideProps` | ✅ | Full context including locale | -| ISR | ✅ | Stale-while-revalidate, pluggable `CacheHandler`, background regeneration | -| Server Actions (`"use server"`) | ✅ | Action execution, FormData, re-render after mutation, `redirect()` in actions | -| React Server Components | ✅ | Via `@vitejs/plugin-rsc`. `"use client"` boundaries work correctly | -| Streaming SSR | ✅ | Both routers | -| Metadata API | ✅ | `metadata`, `generateMetadata`, `viewport`, `generateViewport`, title templates | -| `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 | -| `connection()` | ✅ | Forces dynamic rendering | -| `"use cache"` directive | ✅ | File-level and function-level. `cacheLife()` profiles, `cacheTag()`, stale-while-revalidate | -| `instrumentation.ts` | ✅ | `register()` and `onRequestError()` callbacks | -| Route segment config | 🟡 | `revalidate`, `dynamic`, `dynamicParams`. `runtime` and `preferredRegion` are ignored | +| Feature | | Notes | +| ------------------------------------------ | --- | ------------------------------------------------------------------------------------------- | +| SSR (Pages Router) | ✅ | Streaming, `_app`/`_document`, `__NEXT_DATA__`, hydration | +| SSR (App Router) | ✅ | RSC pipeline, nested layouts, streaming, nav context for client components | +| `getStaticProps` | ✅ | Props, redirect, notFound, revalidate | +| `getStaticPaths` | ✅ | `fallback: false`, `true`, `"blocking"` | +| `getServerSideProps` | ✅ | Full context including locale | +| ISR | ✅ | Stale-while-revalidate, pluggable `CacheHandler`, background regeneration | +| Server Actions (`"use server"`) | ✅ | Action execution, FormData, re-render after mutation, `redirect()` in actions | +| React Server Components | ✅ | Via `@vitejs/plugin-rsc`. `"use client"` boundaries work correctly | +| Streaming SSR | ✅ | Both routers | +| Metadata API | ✅ | `metadata`, `generateMetadata`, `viewport`, `generateViewport`, title templates | +| `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 | +| Route segment config | 🟡 | `revalidate`, `dynamic`, `dynamicParams`. `runtime` and `preferredRegion` are ignored | ### Configuration diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts new file mode 100644 index 000000000..4d54335c6 --- /dev/null +++ b/packages/vinext/src/build/standalone.ts @@ -0,0 +1,326 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { resolveVinextPackageRoot } from "../utils/vinext-root.js"; + +type PackageJson = { + name?: string; + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; +}; + +export type StandaloneBuildOptions = { + root: string; + outDir: string; + /** + * Test hook: override vinext package root used for embedding runtime files. + */ + vinextPackageRoot?: string; +}; + +export type StandaloneBuildResult = { + standaloneDir: string; + copiedPackages: string[]; +}; + +type 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, + }); +} + +/** + * 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 []; + } + try { + return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as string[]; + } catch (err) { + console.warn( + `[vinext] Warning: failed to parse ${manifestPath}, proceeding without externals manifest: ${String(err)}`, + ); + return []; + } +} + +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[], + 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 ?? {})); + const copied = alreadyCopied ?? 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, + // 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. 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); + + 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 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 +import { join } from "node:path"; +import { startProdServer } from "vinext/server/prod-server"; + +const port = Number.parseInt(process.env.PORT ?? "3000", 10); +const host = process.env.HOST ?? "0.0.0.0"; + +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); +}); +`; + fs.writeFileSync(filePath, content, "utf-8"); + fs.chmodSync(filePath, 0o755); +} + +function writeStandalonePackageJson(filePath: string): void { + fs.writeFileSync( + filePath, + JSON.stringify( + { + private: true, + type: "module", + }, + 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) + * + * 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); + 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, + // 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"); + if (fs.existsSync(publicDir)) { + 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"), + }); + } + + fs.mkdirSync(standaloneNodeModulesDir, { recursive: true }); + + // 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. + // + // 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", + ); + const copiedSet = new Set(); + copyPackageAndRuntimeDeps(root, standaloneNodeModulesDir, initialPackages, copiedSet); + + // 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, + // 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"); + + // 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: [...copiedSet], + }; +} diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index 4b1005641..1460f48ea 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -149,7 +149,10 @@ const CONFIG_SUPPORT: Record = { env: { status: "supported" }, images: { status: "partial", detail: "remotePatterns validated, no local optimization" }, allowedDevOrigins: { status: "supported", detail: "dev server cross-origin allowlist" }, - 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", diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index d89db6efa..3fb5d561e 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -26,7 +26,10 @@ 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 } from "./config/next-config.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 ──────────────────────────────────────── // // When vinext is installed via `bun link` or `npm link`, Node follows the @@ -366,6 +369,26 @@ 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), + 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.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.`, + ); + 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 }) @@ -462,10 +485,20 @@ async function buildApp() { } } - const nextConfig = await resolveNextConfig(await loadNextConfig(process.cwd()), process.cwd()); + 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 process.exit(0); + } let prerenderResult; - const shouldPrerender = parsed.prerenderAll || nextConfig.output === "export"; + const shouldPrerender = parsed.prerenderAll || resolvedNextConfig.output === "export"; if (shouldPrerender) { const label = parsed.prerenderAll @@ -479,7 +512,7 @@ async function buildApp() { process.stdout.write("\x1b[0m"); await printBuildReport({ root: process.cwd(), - pageExtensions: nextConfig.pageExtensions, + pageExtensions: resolvedNextConfig.pageExtensions, prerenderResult: prerenderResult ?? undefined, }); @@ -639,6 +672,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: --verbose Show full Vite/Rollup build output (suppressed by default) @@ -657,6 +691,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 15afbcb9c..6d4b8eca8 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -8,7 +8,7 @@ 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"; import { normalizePageExtensions } from "../routing/file-matcher.js"; import { isExternalUrl } from "./config-matchers.js"; @@ -271,6 +271,12 @@ 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; + /** * Emit a warning when config loading fails, with a targeted hint for * known plugin wrappers that are unnecessary in vinext. @@ -301,7 +307,7 @@ function warnConfigLoadFailure(filename: string, err: Error): void { */ async function resolveConfigValue( config: unknown, - phase: string = PHASE_DEVELOPMENT_SERVER, + phase: string = DEFAULT_PHASE, ): Promise { if (typeof config === "function") { const result = await config(phase, { @@ -351,7 +357,7 @@ export async function resolveNextConfigInput( */ export async function loadNextConfig( root: string, - phase: string = PHASE_DEVELOPMENT_SERVER, + phase: string = DEFAULT_PHASE, ): Promise { const configPath = findNextConfigPath(root); if (!configPath) return null; @@ -843,3 +849,5 @@ function extractPluginsFromOptions(opts: any): MdxOptions | null { return null; } + +export { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from "../shims/constants.js"; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index fc4e9c2f7..3e88d9c2b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -66,6 +66,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, @@ -75,6 +76,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"; @@ -629,91 +631,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; @@ -3264,6 +3181,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/init.ts b/packages/vinext/src/init.ts index 588c73644..86441d96e 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -95,15 +95,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"); } @@ -360,6 +365,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/plugins/server-externals-manifest.ts b/packages/vinext/src/plugins/server-externals-manifest.ts new file mode 100644 index 000000000..12c7e2330 --- /dev/null +++ b/packages/vinext/src/plugins/server-externals-manifest.ts @@ -0,0 +1,118 @@ +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(); + 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) { + // 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; + // 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 [...item.imports, ...item.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/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"; diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts new file mode 100644 index 000000000..8aeb6ca8d --- /dev/null +++ b/packages/vinext/src/utils/lazy-chunks.ts @@ -0,0 +1,87 @@ +/** + * Build-manifest chunk metadata used to compute lazy chunks. + */ +export 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. + */ +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/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/init.test.ts b/tests/init.test.ts index 1dbc91736..6c371bd79 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -192,17 +192,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", () => { @@ -211,7 +213,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", () => { @@ -224,6 +226,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 () => { @@ -674,6 +679,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..bd1691e11 --- /dev/null +++ b/tests/standalone-build.test.ts @@ -0,0 +1,462 @@ +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"; +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("copies packages listed in vinext-externals.json and their transitive deps", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile( + appRoot, + "package.json", + JSON.stringify( + { + 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: { + typescript: "5.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'); + // 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/vinext-externals.json", + JSON.stringify(["react", "react-server-dom-webpack"]), + ); + 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, + }); + + // 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", + ); + const standalonePkg = JSON.parse( + fs.readFileSync(path.join(appRoot, "dist/standalone/package.json"), "utf-8"), + ) as { type: string }; + expect(standalonePkg.type).toBe("module"); + + 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/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/dep-a/package.json")), + ).toBe(false); + 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("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 }); + 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"); + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify(["dep-hidden"])); + + 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"); + // 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( + 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 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 }); + + 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"); + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify(["dep-link"])); + + 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); + }); +});