Skip to content
49 changes: 29 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ Options: `-p / --port <port>`, `-H / --hostname <host>`, `--turbopack` (accepted

`vinext init` options: `--port <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.
Expand All @@ -94,7 +100,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.
Expand All @@ -103,6 +109,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
```

Expand Down Expand Up @@ -458,25 +466,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

Expand Down
315 changes: 315 additions & 0 deletions packages/vinext/src/build/standalone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
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<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
}

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[] {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Node >= 22 is required, you could replace this recursive function with:

const files = fs.readdirSync(dir, { withFileTypes: true, recursive: true })
  .filter(e => !e.isDirectory())
  .map(e => path.join(e.parentPath, e.name));

This is available since Node 18.17 and removes the manual recursion. Not blocking — the current implementation is clear.

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<string>();
const files = walkFiles(serverDir).filter((filePath) => /\.(c|m)?js$/.test(filePath));

const fromRE = /\bfrom\s*["']([^"']+)["']/g;
const importSideEffectRE = /\bimport\s*["']([^"']+)["']/g;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: importSideEffectRE (\bimport\s*["']) can technically also match the import keyword in a normal import statement if the output is minified without spaces (e.g. import"foo"). Since all results go into a Set<string>, the overlap is harmless. But the intended division of labor between these regexes could be documented:

// fromRE:             import { x } from "pkg"  /  export { x } from "pkg"
// importSideEffectRE: import "pkg"             (side-effect only)
// dynamicImportRE:    import("pkg")
// requireRE:          require("pkg")
// Overlap between regexes is fine — results are collected in a Set.

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functional concern: copyPackageAndRuntimeDeps creates a rootResolver using createRequire(path.join(root, "package.json")). When called for vinext's own runtime deps (line 302-306), root is vinextPackageRoot. If vinext is installed inside the user's node_modules (typical case), its package.json location is something like /app/node_modules/vinext/package.json. The resolver created from there can resolve vinext's own deps if they're hoisted to /app/node_modules/. But in a pnpm strict layout where vinext's deps are in a .pnpm store and only symlinked into vinext's own node_modules, the rootOptional set computed from vinext's package.json would be used to determine optionality.

The issue: vinext's package.json lists its deps in dependencies, not optionalDependencies, so rootOptional would be empty and all vinext deps would be treated as required (which is correct). However, rootPkg.optionalDependencies is read from vinext's package.json, which has no optionalDependencies field — so Object.keys(rootPkg.optionalDependencies ?? {}) returns []. This is fine.

Actually, looking more carefully, this is correct. Disregard the concern — the function works correctly for both the app root and vinext root cases.

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<string>();
const queue: QueueEntry[] = initialPackages.map((packageName) => ({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optional flag for initial packages is determined by checking the root optionalDependencies. But packages discovered via collectServerExternalPackages (server bundle scan) are always added with optional: rootOptional.has(packageName). This means a server-scanned package that's NOT in the root package.json at all (not in deps, devDeps, or optionalDeps) will have optional = false and throw if it can't be resolved.

This is actually the correct behavior — if the server bundle imports it, it's required. Just wanted to confirm this was intentional, since server-scanned packages could theoretically include specifiers that are bundled inline (not externalized) and thus don't need to be in node_modules. In practice, Vite externalizes all bare imports in SSR builds by default, so this should be fine.

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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential performance issue for monorepos: dereference: true with cpSync will follow symlinks and copy full contents. In a hoisted pnpm monorepo, a single large package (e.g. @swc/core with platform-specific binaries) could be ~100MB+. Combined with the transitive dep walk, standalone output could balloon.

Not a blocker for the first version, but worth tracking. A future optimization could check if a package is already a real directory (not a symlink) and skip dereferencing in that case, or add size limits/warnings.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding filter to skip node_modules directories inside packages (some packages ship their own nested node_modules). Without a filter, you'll recursively copy all nested node_modules too, which can balloon the standalone size. cpSync supports a filter callback:

fs.cpSync(packageRoot, packageTarget, {
  recursive: true,
  dereference: true,
  filter: (src) => !src.includes('/node_modules/'),
});

This is a should-fix — nested node_modules in copied packages means duplicated (and potentially version-mismatched) transitive deps that should instead be resolved through the BFS walk.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: consider adding a filter callback to skip nested node_modules directories inside copied packages. Without it, packages that ship their own node_modules (or have nested deps installed) will be fully duplicated, potentially at different versions than what the BFS walk would resolve from the project root.

Suggested change
fs.cpSync(packageRoot, packageTarget, { recursive: true, dereference: true });
fs.cpSync(packageRoot, packageTarget, { recursive: true, dereference: true, filter: (src) => !src.includes(`${path.sep}node_modules${path.sep}`) });


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The shebang (#!/usr/bin/env node) is nice for ./server.js execution, but the path.join(__dirname, "dist") means this script must be run from the standalone directory or via node dist/standalone/server.js from the project root — __dirname resolves to the script's directory either way, so it works. Just confirming this is intentional and correct.

const path = require("node:path");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated server.js uses require() (line 172) and __dirname (line 182) — CJS constructs. This works because of the {"type": "commonjs"} package.json written into the standalone directory. However, the CJS require("node:path") on line 172 is only used for path.join — the actual vinext import uses await import(). Since the only reason for require here is path.join, and the rest is ESM-style dynamic import, consider whether a fully-ESM server.js using import.meta.dirname (Node >= 21.2, and vinext requires >= 22) would be cleaner and eliminate the need for the CJS package.json entirely.

Not blocking — the current CJS approach is correct and the {"type": "commonjs"} package.json ensures it works.


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:
* - <outDir>/standalone/server.js
* - <outDir>/standalone/dist/{client,server}
* - <outDir>/standalone/node_modules (runtime deps only)
*/
export function emitStandaloneOutput(options: StandaloneBuildOptions): StandaloneBuildResult {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: emitStandaloneOutput is a synchronous function that does a lot of I/O. For large apps with many dependencies, this could block the event loop for a while. Not a problem today since it runs at the end of a CLI build command, but worth noting if this is ever called from a context where responsiveness matters.

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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation happens after the full Vite build completes. If output: "standalone" is set and the vinext dist doesn't exist, the entire build runs before this error fires — wasting potentially minutes. Consider adding a pre-flight check in buildApp() (in cli.ts) before builder.buildApp() when outputMode === "standalone":

if (outputMode === "standalone") {
  const vinextDist = path.dirname(fileURLToPath(import.meta.url));
  // Quick sanity check that vinext's dist/ exists before running the full build
}

Not blocking for v1, but a nice UX improvement.

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"), {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vinext dist copy (cpSync) doesn't use the same node_modules filter that package copies use (line 130-138). If vinext's dist/ ever contained a node_modules directory (unlikely but possible during development), it would be copied. Consider adding the same filter here for consistency.

Non-blocking.

recursive: true,
dereference: true,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vinext embedding copies package.json and dist/, but if vinext's own package.json lists runtime dependencies (e.g. a future addition), those won't be present in the standalone node_modules. Currently this works because prod-server.ts's dependencies overlap with the app's own deps that are already copied. But it's a latent gap — if vinext ever adds a runtime dep that users don't also depend on, standalone will break at startup.

Consider adding a follow-up to read vinext's package.json dependencies and feed them into copyPackageAndRuntimeDeps as well, or at minimum leave a TODO comment here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: vinext has runtime dependencies (rsc-html-stream, magic-string, vite-tsconfig-paths, @unpic/react, @vercel/og, vite-plugin-commonjs) that are not copied into the standalone node_modules. Only vinext's package.json and dist/ are embedded.

When server.js starts and imports vinext/server/prod-server, the prod server will try to import these packages. If the user's app doesn't also list them as dependencies (and there's no reason they would — rsc-html-stream is an internal vinext dependency), the standalone server crashes with ERR_MODULE_NOT_FOUND.

Fix: after copying vinext's dist, read vinext's package.json dependencies and feed them into copyPackageAndRuntimeDeps:

Suggested change
});
fs.cpSync(vinextDistDir, path.join(vinextTargetDir, "dist"), {
recursive: true,
dereference: true,
});
// Also copy vinext's own runtime dependencies so prod-server imports resolve.
const vinextPkg = readPackageJson(path.join(vinextPackageRoot, "package.json"));
const vinextRuntimeDeps = runtimeDeps(vinextPkg).filter(
(name) => !copied.has(name) && name !== "vinext",
);
if (vinextRuntimeDeps.length > 0) {
const vinextResolver = createRequire(path.join(vinextPackageRoot, "package.json"));
const vinextQueue: QueueEntry[] = vinextRuntimeDeps.map((packageName) => ({
packageName,
resolver: vinextResolver,
optional: false,
}));
// Reuse the BFS copy logic for vinext's transitive deps
const extraQueue = [...vinextQueue];
while (extraQueue.length > 0) {
const entry = extraQueue.shift();
if (!entry || copied.has(entry.packageName)) continue;
const packageJsonPath = resolvePackageJsonPath(entry.packageName, entry.resolver);
if (!packageJsonPath) continue;
const packageRoot = path.dirname(packageJsonPath);
const packageTarget = path.join(standaloneNodeModulesDir, entry.packageName);
fs.mkdirSync(path.dirname(packageTarget), { recursive: true });
fs.cpSync(packageRoot, packageTarget, { recursive: true, dereference: true });
copied.add(entry.packageName);
const depPkg = readPackageJson(packageJsonPath);
const depResolver = createRequire(packageJsonPath);
for (const depName of runtimeDeps(depPkg)) {
if (!copied.has(depName)) {
extraQueue.push({ packageName: depName, resolver: depResolver, optional: false });
}
}
}
}

Alternatively, refactor copyPackageAndRuntimeDeps to accept additional seed entries so the BFS logic isn't duplicated. The key point is that vinext's deps must be resolved from vinext's own package.json location (using a resolver rooted there), not from the app root — they may not be hoisted into the app's node_modules.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must-fix: vinext's own runtime dependencies are missing from standalone output.

vinext's package.json lists 6 runtime dependencies: rsc-html-stream, magic-string, vite-tsconfig-paths, @unpic/react, @vercel/og, vite-plugin-commonjs. Only vinext's package.json and dist/ are copied here — its deps are not.

When server.js starts and imports vinext/server/prod-server, that module will try to import rsc-html-stream (used for RSC stream handling). If the user's app doesn't also depend on it (and there's no reason it would — it's an internal vinext dependency), the server crashes at startup.

Fix: after copying vinext's dist, read vinext's package.json dependencies and feed them through the same copyPackageAndRuntimeDeps BFS logic. The resolver must be rooted at vinext's package location (not the app root), since these deps may not be hoisted into the app's node_modules:

Suggested change
});
fs.cpSync(vinextDistDir, path.join(vinextTargetDir, "dist"), {
recursive: true,
dereference: true,
});
// Copy vinext's own runtime dependencies so prod-server imports resolve.
const vinextPkg = readPackageJson(path.join(vinextPackageRoot, "package.json"));
const vinextRuntimeDeps = runtimeDeps(vinextPkg).filter(
(name) => !copiedPackages.includes(name) && name !== "vinext",
);
if (vinextRuntimeDeps.length > 0) {
const vinextResolver = createRequire(path.join(vinextPackageRoot, "package.json"));
const vinextNodeModulesDir = standaloneNodeModulesDir;
const vinextCopied = new Set(copiedPackages);
const vinextQueue: QueueEntry[] = vinextRuntimeDeps.map((name) => ({
packageName: name,
resolver: vinextResolver,
optional: false,
}));
while (vinextQueue.length > 0) {
const entry = vinextQueue.shift();
if (!entry || vinextCopied.has(entry.packageName)) continue;
const pkgJsonPath = resolvePackageJsonPath(entry.packageName, entry.resolver);
if (!pkgJsonPath) continue;
const pkgRoot = path.dirname(pkgJsonPath);
const pkgTarget = path.join(vinextNodeModulesDir, entry.packageName);
fs.mkdirSync(path.dirname(pkgTarget), { recursive: true });
fs.cpSync(pkgRoot, pkgTarget, { recursive: true, dereference: true });
vinextCopied.add(entry.packageName);
copiedPackages.push(entry.packageName);
const depPkg = readPackageJson(pkgJsonPath);
const depResolver = createRequire(pkgJsonPath);
for (const depName of runtimeDeps(depPkg)) {
if (!vinextCopied.has(depName)) {
vinextQueue.push({ packageName: depName, resolver: depResolver, optional: false });
}
}
}
}

Alternatively, refactor copyPackageAndRuntimeDeps to accept an optional resolver override and additional seed entries, so the BFS logic isn't duplicated.


writeStandaloneServerEntry(path.join(standaloneDir, "server.js"));
writeStandalonePackageJson(path.join(standaloneDir, "package.json"));

return {
standaloneDir,
copiedPackages: [...new Set([...copiedPackages, "vinext"])],
};
}
Loading
Loading