Files
cloudflare__vinext/tests/standalone-build.test.ts
T
Sankalp Mukim f6aedd3615 Add standalone self-host output and align init production scripts (#178)
* 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.

* fmt

* Address bonk review: fix duplicate PHASE constant, complete lazy-chunks extraction, remove dead imports, add process.exit to standalone path

* Address bonk nits: document regex patterns, move PHASE re-exports to bottom, add assets field to BuildManifestChunk

* refactor(standalone): use Vite bundle graph for server externals instead of regex scan

Replace the fragile post-build regex scan of emitted JS files and the
package.json#dependencies over-seeding with a new
vinext:server-externals-manifest Vite plugin. The plugin collects
chunk.imports + chunk.dynamicImports from the SSR/RSC writeBundle hook
and writes dist/server/vinext-externals.json — the authoritative,
compiler-derived list of packages left external in the server bundle.

emitStandaloneOutput now reads this manifest as its sole seed for the
BFS node_modules copy, so standalone output only includes packages the
server actually imports at runtime. Removed collectServerExternalPackages,
walkFiles, the four regex constants, and the package.json deps seeding.

* fix(standalone): copy vinext runtime deps, add node_modules filter to cpSync, remove unused pendingWrites

* fix(standalone): address round-6 bonk nits — explicit root, manifest comment, pre-flight check, bare-specifier comment, export-from re-export

* fix(standalone): address round 7 bonk review comments

- harden outDir resolution in server-externals-manifest: replace fragile
  walk-up heuristic with direct basename check (ssr -> dirname, else dir),
  avoiding misfires when a user's project path contains a 'server' segment
- remove redundant 'chunk' alias in server-externals-manifest writeBundle;
  use 'item' directly after the type guard narrows it to OutputChunk
- add node_modules filter to vinext dist/ cpSync in standalone.ts, matching
  the same defensive filter used for app package copies
- rewrite standalone server.js as pure ESM using import.meta.dirname
  (Node >= 21.2, vinext requires >= 22); drop CJS require/__dirname and
  switch standalone package.json to 'type':'module'
- add explanatory comment for DEFAULT_PHASE constant in next-config.ts
- add comment in cli.ts noting pre-flight path and resolveVinextPackageRoot
  must stay in sync

* fix(standalone): address round 8 bonk review comments

- fix test import: use 'vite-plus/test' instead of 'vitest' in
  standalone-build.test.ts, matching the convention used by every other
  test file in the repo (documented in AGENTS.md)
- extract resolveVinextPackageRoot into utils/vinext-root.ts so cli.ts
  and standalone.ts share a single source of truth; removes the coupling
  comment added in round 7 and the duplicated path traversal logic
- add node_modules filter to dist/client and dist/server cpSync calls for
  defensive consistency with the vinext dist/ and package copies

* interface -> type

* fix(standalone): address round 9 bonk review comments

- add node_modules filter to public/ cpSync for defensive consistency
  with all other cpSync calls in emitStandaloneOutput
- warn on malformed vinext-externals.json instead of silently returning
  an empty list; includes the error string to aid debugging
- add doc comment on copyPackageAndRuntimeDeps clarifying that the return
  value is the full accumulated set (including pre-existing entries), not
  only packages copied by the current call
- document HOST (not HOSTNAME) env var in README standalone section, with
  a note explaining the difference from Next.js standalone behaviour

* fix(standalone): address round 10 bonk review comments

- add comment in writeStandaloneServerEntry explaining why the import of
  vinext/server/prod-server is intentional as a static import: it is a
  documented exports-map entry, always present in the copied dist tree,
  and a static import gives a clearer ERR_MODULE_NOT_FOUND at startup
- generalise outDir sub-directory detection in server-externals-manifest:
  replace basename === 'ssr' with basename !== 'server' so any future
  sub-directory environment (e.g. 'edge') is handled without code changes
- add JSDoc on runtimeDeps clarifying that it returns both dependencies
  and optionalDependencies

* fix(next-config): drop PHASE_DEVELOPMENT_SERVER from re-export

No external consumer imports it from next-config.ts — only
PHASE_PRODUCTION_BUILD is used externally (by cli.ts). Removing it
minimises the public surface area of the module.

---------

Co-authored-by: GPU VM <copilot-gpu-vm@example.com>
Co-authored-by: James <james@eli.cx>
2026-03-30 20:42:40 +01:00

463 lines
15 KiB
TypeScript

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<string, string> = {},
options: { exports?: Record<string, string> } = {},
): void {
const packageRoot = path.join(root, "node_modules", packageName);
fs.mkdirSync(packageRoot, { recursive: true });
const pkgJson: Record<string, unknown> = {
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);
});
});