Skip to content

Commit e64eebc

Browse files
author
GPU VM
committed
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.
1 parent e9fdc7b commit e64eebc

File tree

11 files changed

+549
-102
lines changed

11 files changed

+549
-102
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ Options: `-p / --port <port>`, `-H / --hostname <host>`, `--turbopack` (accepted
6868

6969
`vinext init` options: `--port <port>` (default: 3001), `--skip-check`, `--force`.
7070

71+
If your `next.config.*` sets `output: "standalone"`, `vinext build` emits a self-hosting bundle at `dist/standalone/`. Start it with:
72+
73+
```bash
74+
node dist/standalone/server.js
75+
```
76+
7177
### Starting a new vinext project
7278

7379
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:
8894
2. Install `vite` (and `@vitejs/plugin-rsc` for App Router projects) as devDependencies
8995
3. Rename CJS config files (e.g. `postcss.config.js` -> `.cjs`) to avoid ESM conflicts
9096
4. Add `"type": "module"` to `package.json`
91-
5. Add `dev:vinext` and `build:vinext` scripts to `package.json`
97+
5. Add `dev:vinext`, `build:vinext`, and `start:vinext` scripts to `package.json`
9298
6. Generate a minimal `vite.config.ts`
9399

94100
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.
95101

96102
```bash
97103
npm run dev:vinext # Start the vinext dev server (port 3001)
104+
npm run build:vinext # Build production output with vinext
105+
npm run start:vinext # Start vinext production server
98106
npm run dev # Still runs Next.js as before
99107
```
100108

@@ -311,6 +319,7 @@ Every `next/*` import is shimmed to a Vite-compatible implementation.
311319
| `generateStaticParams` || With `dynamicParams` enforcement |
312320
| Metadata file routes || sitemap.xml, robots.txt, manifest, favicon, OG images (static + dynamic) |
313321
| Static export (`output: 'export'`) || Generates static HTML/JSON for all routes |
322+
| Standalone output (`output: 'standalone'`) || Generates `dist/standalone` with `server.js`, build artifacts, and runtime deps |
314323
| `connection()` || Forces dynamic rendering |
315324
| `"use cache"` directive || File-level and function-level. `cacheLife()` profiles, `cacheTag()`, stale-while-revalidate |
316325
| `instrumentation.ts` || `register()` and `onRequestError()` callbacks |
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { createRequire } from "node:module";
4+
import { fileURLToPath } from "node:url";
5+
6+
interface PackageJson {
7+
dependencies?: Record<string, string>;
8+
devDependencies?: Record<string, string>;
9+
optionalDependencies?: Record<string, string>;
10+
}
11+
12+
export interface StandaloneBuildOptions {
13+
root: string;
14+
outDir: string;
15+
/**
16+
* Test hook: override vinext package root used for embedding runtime files.
17+
*/
18+
vinextPackageRoot?: string;
19+
}
20+
21+
export interface StandaloneBuildResult {
22+
standaloneDir: string;
23+
copiedPackages: string[];
24+
}
25+
26+
interface QueueEntry {
27+
packageName: string;
28+
resolver: NodeRequire;
29+
}
30+
31+
function readPackageJson(packageJsonPath: string): PackageJson {
32+
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as PackageJson;
33+
}
34+
35+
function runtimeDeps(pkg: PackageJson): string[] {
36+
return Object.keys({
37+
...pkg.dependencies,
38+
...pkg.optionalDependencies,
39+
});
40+
}
41+
42+
function walkFiles(dir: string): string[] {
43+
const files: string[] = [];
44+
const entries = fs.readdirSync(dir, { withFileTypes: true });
45+
for (const entry of entries) {
46+
const fullPath = path.join(dir, entry.name);
47+
if (entry.isDirectory()) {
48+
files.push(...walkFiles(fullPath));
49+
} else {
50+
files.push(fullPath);
51+
}
52+
}
53+
return files;
54+
}
55+
56+
function packageNameFromSpecifier(specifier: string): string | null {
57+
if (
58+
specifier.startsWith(".") ||
59+
specifier.startsWith("/") ||
60+
specifier.startsWith("node:") ||
61+
specifier.startsWith("#")
62+
) {
63+
return null;
64+
}
65+
66+
if (specifier.startsWith("@")) {
67+
const parts = specifier.split("/");
68+
if (parts.length >= 2) {
69+
return `${parts[0]}/${parts[1]}`;
70+
}
71+
return null;
72+
}
73+
74+
return specifier.split("/")[0] || null;
75+
}
76+
77+
function collectServerExternalPackages(serverDir: string): string[] {
78+
const packages = new Set<string>();
79+
const files = walkFiles(serverDir).filter((filePath) => /\.(c|m)?js$/.test(filePath));
80+
81+
const importExportRE = /(?:import|export)\s+(?:[^"'()]*?\s+from\s+)?["']([^"']+)["']/g;
82+
const dynamicImportRE = /import\(\s*["']([^"']+)["']\s*\)/g;
83+
const requireRE = /require\(\s*["']([^"']+)["']\s*\)/g;
84+
85+
for (const filePath of files) {
86+
const code = fs.readFileSync(filePath, "utf-8");
87+
88+
for (const regex of [importExportRE, dynamicImportRE, requireRE]) {
89+
regex.lastIndex = 0;
90+
let match: RegExpExecArray | null;
91+
while ((match = regex.exec(code)) !== null) {
92+
const packageName = packageNameFromSpecifier(match[1]);
93+
if (packageName) {
94+
packages.add(packageName);
95+
}
96+
}
97+
}
98+
}
99+
100+
return [...packages];
101+
}
102+
103+
function copyPackageAndRuntimeDeps(
104+
root: string,
105+
targetNodeModulesDir: string,
106+
initialPackages: string[],
107+
): string[] {
108+
const rootResolver = createRequire(path.join(root, "package.json"));
109+
const copied = new Set<string>();
110+
const queue: QueueEntry[] = initialPackages.map((packageName) => ({
111+
packageName,
112+
resolver: rootResolver,
113+
}));
114+
115+
while (queue.length > 0) {
116+
const entry = queue.shift();
117+
if (!entry) continue;
118+
if (copied.has(entry.packageName)) continue;
119+
120+
let packageJsonPath: string;
121+
try {
122+
packageJsonPath = entry.resolver.resolve(`${entry.packageName}/package.json`);
123+
} catch {
124+
continue;
125+
}
126+
127+
const packageRoot = path.dirname(packageJsonPath);
128+
const packageTarget = path.join(targetNodeModulesDir, entry.packageName);
129+
fs.mkdirSync(path.dirname(packageTarget), { recursive: true });
130+
fs.cpSync(packageRoot, packageTarget, { recursive: true, dereference: true });
131+
132+
copied.add(entry.packageName);
133+
134+
const packageResolver = createRequire(packageJsonPath);
135+
const pkg = readPackageJson(packageJsonPath);
136+
for (const depName of runtimeDeps(pkg)) {
137+
if (!copied.has(depName)) {
138+
queue.push({ packageName: depName, resolver: packageResolver });
139+
}
140+
}
141+
}
142+
143+
return [...copied];
144+
}
145+
146+
function resolveVinextPackageRoot(explicitRoot?: string): string {
147+
if (explicitRoot) {
148+
return path.resolve(explicitRoot);
149+
}
150+
151+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
152+
// dist/build/standalone.js -> package root is ../..
153+
return path.resolve(currentDir, "..", "..");
154+
}
155+
156+
function writeStandaloneServerEntry(filePath: string): void {
157+
const content = `#!/usr/bin/env node
158+
const path = require("node:path");
159+
160+
async function main() {
161+
const { startProdServer } = await import("vinext/server/prod-server");
162+
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
163+
const host = process.env.HOSTNAME ?? "0.0.0.0";
164+
165+
await startProdServer({
166+
port,
167+
host,
168+
outDir: path.join(__dirname, "dist"),
169+
});
170+
}
171+
172+
main().catch((error) => {
173+
console.error("[vinext] Failed to start standalone server");
174+
console.error(error);
175+
process.exit(1);
176+
});
177+
`;
178+
fs.writeFileSync(filePath, content, "utf-8");
179+
fs.chmodSync(filePath, 0o755);
180+
}
181+
182+
/**
183+
* Emit standalone production output for self-hosted deployments.
184+
*
185+
* Creates:
186+
* - <outDir>/standalone/server.js
187+
* - <outDir>/standalone/dist/{client,server}
188+
* - <outDir>/standalone/node_modules (runtime deps only)
189+
*/
190+
export function emitStandaloneOutput(options: StandaloneBuildOptions): StandaloneBuildResult {
191+
const root = path.resolve(options.root);
192+
const outDir = path.resolve(options.outDir);
193+
const clientDir = path.join(outDir, "client");
194+
const serverDir = path.join(outDir, "server");
195+
196+
if (!fs.existsSync(clientDir) || !fs.existsSync(serverDir)) {
197+
throw new Error(`No build output found in ${outDir}. Run vinext build first.`);
198+
}
199+
200+
const standaloneDir = path.join(outDir, "standalone");
201+
const standaloneDistDir = path.join(standaloneDir, "dist");
202+
const standaloneNodeModulesDir = path.join(standaloneDir, "node_modules");
203+
204+
fs.rmSync(standaloneDir, { recursive: true, force: true });
205+
fs.mkdirSync(standaloneDistDir, { recursive: true });
206+
207+
fs.cpSync(clientDir, path.join(standaloneDistDir, "client"), {
208+
recursive: true,
209+
dereference: true,
210+
});
211+
fs.cpSync(serverDir, path.join(standaloneDistDir, "server"), {
212+
recursive: true,
213+
dereference: true,
214+
});
215+
216+
const publicDir = path.join(root, "public");
217+
if (fs.existsSync(publicDir)) {
218+
fs.cpSync(publicDir, path.join(standaloneDir, "public"), {
219+
recursive: true,
220+
dereference: true,
221+
});
222+
}
223+
224+
fs.mkdirSync(standaloneNodeModulesDir, { recursive: true });
225+
226+
const appPkg = readPackageJson(path.join(root, "package.json"));
227+
const appRuntimeDeps = runtimeDeps(appPkg);
228+
const serverRuntimeDeps = collectServerExternalPackages(serverDir);
229+
const initialPackages = [...new Set([...appRuntimeDeps, ...serverRuntimeDeps])].filter(
230+
(name) => name !== "vinext",
231+
);
232+
const copiedPackages = copyPackageAndRuntimeDeps(
233+
root,
234+
standaloneNodeModulesDir,
235+
initialPackages,
236+
);
237+
238+
// Always embed the exact vinext runtime that produced this build.
239+
const vinextPackageRoot = resolveVinextPackageRoot(options.vinextPackageRoot);
240+
const vinextDistDir = path.join(vinextPackageRoot, "dist");
241+
if (!fs.existsSync(vinextDistDir)) {
242+
throw new Error(`vinext runtime dist/ not found at ${vinextPackageRoot}`);
243+
}
244+
const vinextTargetDir = path.join(standaloneNodeModulesDir, "vinext");
245+
fs.mkdirSync(vinextTargetDir, { recursive: true });
246+
fs.copyFileSync(
247+
path.join(vinextPackageRoot, "package.json"),
248+
path.join(vinextTargetDir, "package.json"),
249+
);
250+
fs.cpSync(vinextDistDir, path.join(vinextTargetDir, "dist"), {
251+
recursive: true,
252+
dereference: true,
253+
});
254+
255+
writeStandaloneServerEntry(path.join(standaloneDir, "server.js"));
256+
257+
return {
258+
standaloneDir,
259+
copiedPackages: [...new Set([...copiedPackages, "vinext"])],
260+
};
261+
}

packages/vinext/src/check.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const CONFIG_SUPPORT: Record<string, { status: Status; detail?: string }> = {
7171
i18n: { status: "supported", detail: "path-prefix routing (domains not yet supported)" },
7272
env: { status: "supported" },
7373
images: { status: "partial", detail: "remotePatterns validated, no local optimization" },
74-
output: { status: "supported", detail: "'export' and 'standalone' modes" },
74+
output: { status: "supported", detail: "'export' mode and 'standalone' output (dist/standalone/server.js)" },
7575
transpilePackages: { status: "supported", detail: "Vite handles this natively" },
7676
webpack: { status: "unsupported", detail: "Vite replaces webpack — custom webpack configs need migration" },
7777
"experimental.ppr": { status: "unsupported", detail: "partial prerendering not yet implemented" },

packages/vinext/src/cli.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { deploy as runDeploy, parseDeployArgs } from "./deploy.js";
2323
import { runCheck, formatReport } from "./check.js";
2424
import { init as runInit } from "./init.js";
2525
import { loadDotenv } from "./config/dotenv.js";
26+
import { loadNextConfig, resolveNextConfig } from "./config/next-config.js";
27+
import { emitStandaloneOutput } from "./build/standalone.js";
2628

2729
// ─── Resolve Vite from the project root ────────────────────────────────────────
2830
//
@@ -218,6 +220,11 @@ async function buildApp() {
218220
console.log(`\n vinext build (Vite ${getViteVersion()})\n`);
219221

220222
const isApp = hasAppDir();
223+
const resolvedNextConfig = await resolveNextConfig(
224+
await loadNextConfig(process.cwd(), "phase-production-build"),
225+
);
226+
const outputMode = resolvedNextConfig.output;
227+
const distDir = path.resolve(process.cwd(), "dist");
221228

222229
if (isApp) {
223230
// App Router: use createBuilder for multi-environment RSC builds
@@ -260,6 +267,16 @@ async function buildApp() {
260267
});
261268
}
262269

270+
if (outputMode === "standalone") {
271+
const standalone = emitStandaloneOutput({
272+
root: process.cwd(),
273+
outDir: distDir,
274+
});
275+
console.log(` Generated standalone output in ${path.relative(process.cwd(), standalone.standaloneDir)}/`);
276+
console.log(" Start it with: node dist/standalone/server.js\n");
277+
return;
278+
}
279+
263280
console.log("\n Build complete. Run `vinext start` to start the production server.\n");
264281
}
265282

@@ -416,6 +433,7 @@ function printHelp(cmd?: string) {
416433
417434
Automatically detects App Router (app/) or Pages Router (pages/) and
418435
runs the appropriate multi-environment build via Vite.
436+
If next.config sets output: "standalone", also emits dist/standalone/server.js.
419437
420438
Options:
421439
-h, --help Show this help
@@ -431,6 +449,7 @@ function printHelp(cmd?: string) {
431449
432450
Serves the output from \`vinext build\`. Supports SSR, static files,
433451
compression, and all middleware.
452+
For output: "standalone", you can also run: node dist/standalone/server.js
434453
435454
Options:
436455
-p, --port <port> Port to listen on (default: 3000, or PORT env)

0 commit comments

Comments
 (0)