feat(cli): add flatten-standalone-deps for pnpm standalone builds

pnpm's standalone output uses symlinks that npm pack silently drops, breaking require('next') on user machines. This flattens the virtual store into a standard node_modules layout.
This commit is contained in:
kumarabhirup 2026-03-04 19:06:50 -08:00
parent ec973f40d3
commit 8847e44854
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
4 changed files with 463 additions and 0 deletions

View File

@ -0,0 +1,13 @@
#!/usr/bin/env node
import { flattenPnpmStandaloneDeps } from "../dist/flatten-standalone-deps.js";
const STANDALONE = "apps/web/.next/standalone";
const result = flattenPnpmStandaloneDeps(STANDALONE);
if (result.skipped) {
console.log("flatten-standalone-deps: no pnpm store found, skipping.");
} else {
console.log(
`flatten-standalone-deps: ${result.copied} packages → standalone app node_modules`,
);
}

View File

@ -0,0 +1,350 @@
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { flattenPnpmStandaloneDeps } from "./flatten-standalone-deps.js";
import { installManagedWebRuntime } from "./web-runtime.js";
// ---------------------------------------------------------------------------
// Helpers to build synthetic pnpm standalone structures
// ---------------------------------------------------------------------------
let tmpRoot: string;
function tmp(...segments: string[]): string {
return path.join(tmpRoot, ...segments);
}
function writeFile(relativePath: string, content = ""): void {
const full = tmp(relativePath);
mkdirSync(path.dirname(full), { recursive: true });
writeFileSync(full, content, "utf-8");
}
/** Create a real package directory in the pnpm virtual store. */
function addPnpmPackage(
storeEntryName: string,
packageName: string,
files: Record<string, string> = { "index.js": `module.exports = "${packageName}";` },
): string {
const pkgDir = tmp("standalone", "node_modules", ".pnpm", storeEntryName, "node_modules", packageName);
mkdirSync(pkgDir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
const filePath = path.join(pkgDir, name);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content, "utf-8");
}
return pkgDir;
}
/** Create a symlink inside a pnpm store entry pointing to another store entry. */
function addPnpmSymlink(
fromStoreEntry: string,
linkName: string,
toStoreEntry: string,
toPackageName: string,
): void {
const linkDir = tmp("standalone", "node_modules", ".pnpm", fromStoreEntry, "node_modules");
const linkPath = path.join(linkDir, linkName);
const target = path.join("..", "..", toStoreEntry, "node_modules", toPackageName);
mkdirSync(path.dirname(linkPath), { recursive: true });
symlinkSync(target, linkPath);
}
/** Create a scoped symlink (e.g. @next/env → real @next/env in another store entry). */
function addScopedPnpmSymlink(
fromStoreEntry: string,
scope: string,
name: string,
toStoreEntry: string,
): void {
const scopeDir = tmp("standalone", "node_modules", ".pnpm", fromStoreEntry, "node_modules", scope);
mkdirSync(scopeDir, { recursive: true });
const linkPath = path.join(scopeDir, name);
const target = path.join("..", "..", "..", toStoreEntry, "node_modules", scope, name);
symlinkSync(target, linkPath);
}
/** Create a scoped package in the pnpm virtual store. */
function addScopedPnpmPackage(
storeEntryName: string,
scope: string,
name: string,
files: Record<string, string> = { "index.js": `module.exports = "${scope}/${name}";` },
): string {
return addPnpmPackage(storeEntryName, `${scope}/${name}`, files);
}
function standaloneDir(): string {
return tmp("standalone");
}
function targetNodeModules(): string {
return tmp("standalone", "apps", "web", "node_modules");
}
beforeEach(() => {
tmpRoot = path.join(os.tmpdir(), `flatten-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpRoot, { recursive: true });
mkdirSync(tmp("standalone", "apps", "web"), { recursive: true });
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// flattenPnpmStandaloneDeps
// ---------------------------------------------------------------------------
describe("flattenPnpmStandaloneDeps", () => {
it("copies non-scoped packages to flat node_modules (basic correctness)", () => {
addPnpmPackage("react@19.0.0", "react", {
"index.js": "module.exports = 'react';",
"package.json": '{"name":"react"}',
});
const result = flattenPnpmStandaloneDeps(standaloneDir());
expect(result.skipped).toBe(false);
expect(result.copied).toBeGreaterThanOrEqual(1);
const reactIndex = path.join(targetNodeModules(), "react", "index.js");
expect(existsSync(reactIndex)).toBe(true);
expect(readFileSync(reactIndex, "utf-8")).toBe("module.exports = 'react';");
});
it("places scoped packages at @scope/name path (prevents broken scoped imports)", () => {
addScopedPnpmPackage("@swc+helpers@0.5.15", "@swc", "helpers", {
"index.js": "module.exports = 'swc-helpers';",
});
flattenPnpmStandaloneDeps(standaloneDir());
const helperIndex = path.join(targetNodeModules(), "@swc", "helpers", "index.js");
expect(existsSync(helperIndex)).toBe(true);
expect(readFileSync(helperIndex, "utf-8")).toBe("module.exports = 'swc-helpers';");
});
it("dereferences non-scoped symlinks into real files (prevents 'fetch failed' on user machines)", () => {
addPnpmPackage("zzz-real-source@1.0.0", "my-dep", {
"index.js": "real-content",
});
addPnpmPackage("aaa-consumer@1.0.0", "aaa-consumer");
addPnpmSymlink("aaa-consumer@1.0.0", "my-dep", "zzz-real-source@1.0.0", "my-dep");
flattenPnpmStandaloneDeps(standaloneDir());
const flatDep = path.join(targetNodeModules(), "my-dep");
expect(existsSync(flatDep)).toBe(true);
expect(lstatSync(flatDep).isSymbolicLink()).toBe(false);
expect(lstatSync(flatDep).isDirectory()).toBe(true);
expect(readFileSync(path.join(flatDep, "index.js"), "utf-8")).toBe("real-content");
});
it("dereferences scoped symlinks into real directories (prevents broken @scope imports)", () => {
addScopedPnpmPackage("zzz-real-scope@1.0.0", "@next", "env", {
"index.js": "env-real",
});
addPnpmPackage("aaa-consumer@1.0.0", "aaa-consumer");
addScopedPnpmSymlink("aaa-consumer@1.0.0", "@next", "env", "zzz-real-scope@1.0.0");
flattenPnpmStandaloneDeps(standaloneDir());
const flatEnv = path.join(targetNodeModules(), "@next", "env");
expect(existsSync(flatEnv)).toBe(true);
expect(lstatSync(flatEnv).isSymbolicLink()).toBe(false);
expect(lstatSync(flatEnv).isDirectory()).toBe(true);
expect(readFileSync(path.join(flatEnv, "index.js"), "utf-8")).toBe("env-real");
});
it("first-write-wins deduplication keeps earliest copy (stable output across store entries)", () => {
addPnpmPackage("alpha@1.0.0", "shared-dep", { "index.js": "FIRST" });
addPnpmPackage("beta@2.0.0", "shared-dep", { "index.js": "SECOND" });
flattenPnpmStandaloneDeps(standaloneDir());
const content = readFileSync(
path.join(targetNodeModules(), "shared-dep", "index.js"),
"utf-8",
);
expect(["FIRST", "SECOND"]).toContain(content);
const entries = readdirSync(targetNodeModules()).filter((e) => e === "shared-dep");
expect(entries).toHaveLength(1);
});
it("removes root node_modules after flattening (prevents stale pnpm symlinks in npm tarball)", () => {
addPnpmPackage("react@19.0.0", "react");
const rootNm = tmp("standalone", "node_modules");
expect(existsSync(rootNm)).toBe(true);
flattenPnpmStandaloneDeps(standaloneDir());
expect(existsSync(rootNm)).toBe(false);
});
it("skips gracefully when no pnpm store exists (non-pnpm setups)", () => {
mkdirSync(tmp("standalone", "node_modules"), { recursive: true });
const result = flattenPnpmStandaloneDeps(standaloneDir());
expect(result.skipped).toBe(true);
expect(result.copied).toBe(0);
});
it("skips gracefully when standalone node_modules is entirely absent", () => {
const result = flattenPnpmStandaloneDeps(standaloneDir());
expect(result.skipped).toBe(true);
expect(result.copied).toBe(0);
});
it("skips store entries that have no node_modules subdirectory (malformed entries)", () => {
const malformedEntry = tmp("standalone", "node_modules", ".pnpm", "broken@1.0.0");
mkdirSync(malformedEntry, { recursive: true });
writeFileSync(path.join(malformedEntry, "package.json"), "{}", "utf-8");
addPnpmPackage("react@19.0.0", "react");
const result = flattenPnpmStandaloneDeps(standaloneDir());
expect(result.copied).toBe(1);
expect(existsSync(path.join(targetNodeModules(), "react"))).toBe(true);
});
it("clears pre-existing app node_modules before flattening (prevents stale leftover packages)", () => {
const staleDir = path.join(targetNodeModules(), "stale-pkg");
mkdirSync(staleDir, { recursive: true });
writeFileSync(path.join(staleDir, "index.js"), "STALE", "utf-8");
addPnpmPackage("react@19.0.0", "react");
flattenPnpmStandaloneDeps(standaloneDir());
expect(existsSync(staleDir)).toBe(false);
expect(existsSync(path.join(targetNodeModules(), "react"))).toBe(true);
});
it("handles mixed scoped and non-scoped packages in a single store entry (next's deps)", () => {
addPnpmPackage("next@15.0.0", "next");
addScopedPnpmPackage("next@15.0.0", "@next", "env");
addScopedPnpmPackage("next@15.0.0", "@swc", "helpers");
flattenPnpmStandaloneDeps(standaloneDir());
expect(existsSync(path.join(targetNodeModules(), "next", "index.js"))).toBe(true);
expect(existsSync(path.join(targetNodeModules(), "@next", "env", "index.js"))).toBe(true);
expect(existsSync(path.join(targetNodeModules(), "@swc", "helpers", "index.js"))).toBe(true);
});
it("merges multiple scoped packages under same scope from different store entries (prevents @scope dedup loss)", () => {
addScopedPnpmPackage("@next+env@15.0.0", "@next", "env", {
"index.js": "env",
});
addScopedPnpmPackage("@next+swc-helpers@15.0.0", "@next", "swc-helpers", {
"index.js": "swc-helpers",
});
flattenPnpmStandaloneDeps(standaloneDir());
expect(existsSync(path.join(targetNodeModules(), "@next", "env", "index.js"))).toBe(true);
expect(existsSync(path.join(targetNodeModules(), "@next", "swc-helpers", "index.js"))).toBe(
true,
);
expect(readFileSync(path.join(targetNodeModules(), "@next", "env", "index.js"), "utf-8")).toBe(
"env",
);
expect(
readFileSync(path.join(targetNodeModules(), "@next", "swc-helpers", "index.js"), "utf-8"),
).toBe("swc-helpers");
});
it("preserves nested file structure within packages (multi-file packages survive)", () => {
addPnpmPackage("next@15.0.0", "next", {
"package.json": '{"name":"next"}',
"index.js": "entry",
"dist/server/lib/start-server.js": "startServer()",
"dist/server/lib/utils.js": "utils()",
});
flattenPnpmStandaloneDeps(standaloneDir());
const nextDir = path.join(targetNodeModules(), "next");
expect(readFileSync(path.join(nextDir, "package.json"), "utf-8")).toBe('{"name":"next"}');
expect(readFileSync(path.join(nextDir, "dist/server/lib/start-server.js"), "utf-8")).toBe(
"startServer()",
);
});
});
// ---------------------------------------------------------------------------
// installManagedWebRuntime — end-to-end with flattened source
// ---------------------------------------------------------------------------
describe("installManagedWebRuntime after flatten", () => {
it("copies flattened node_modules as real directories to runtime dir (end-to-end fix for user installs)", () => {
const packageRoot = tmp("package");
const standaloneDir = path.join(packageRoot, "apps/web/.next/standalone");
const standaloneAppDir = path.join(standaloneDir, "apps/web");
mkdirSync(path.join(standaloneAppDir, "node_modules"), { recursive: true });
writeFileSync(path.join(standaloneAppDir, "server.js"), "// server", "utf-8");
addPnpmPackage("next@15.0.0", "next", { "index.js": "module.exports = 'next';" });
const pnpmStore = tmp("standalone", "node_modules", ".pnpm");
const targetPnpmStore = path.join(standaloneDir, "node_modules", ".pnpm");
mkdirSync(path.dirname(targetPnpmStore), { recursive: true });
cpSync(pnpmStore, targetPnpmStore, { recursive: true });
flattenPnpmStandaloneDeps(standaloneDir);
const stateDir = tmp("state");
mkdirSync(stateDir, { recursive: true });
const result = installManagedWebRuntime({
stateDir,
packageRoot,
denchVersion: "2.0.0-test",
});
expect(result.installed).toBe(true);
const copiedNext = path.join(result.runtimeAppDir, "node_modules", "next");
expect(existsSync(copiedNext)).toBe(true);
expect(lstatSync(copiedNext).isSymbolicLink()).toBe(false);
expect(lstatSync(copiedNext).isDirectory()).toBe(true);
expect(readFileSync(path.join(copiedNext, "index.js"), "utf-8")).toBe(
"module.exports = 'next';",
);
});
it("pnpm symlinks inside source survive cpSync even with dereference (documents Node.js limitation)", () => {
const packageRoot = tmp("package");
const standaloneAppDir = path.join(packageRoot, "apps/web/.next/standalone/apps/web");
mkdirSync(path.join(standaloneAppDir, "node_modules"), { recursive: true });
writeFileSync(path.join(standaloneAppDir, "server.js"), "// server", "utf-8");
const realPkgDir = tmp("real-next-pkg");
mkdirSync(realPkgDir, { recursive: true });
writeFileSync(path.join(realPkgDir, "index.js"), "module.exports = 'next';", "utf-8");
symlinkSync(realPkgDir, path.join(standaloneAppDir, "node_modules", "next"));
const stateDir = tmp("state");
mkdirSync(stateDir, { recursive: true });
const result = installManagedWebRuntime({
stateDir,
packageRoot,
denchVersion: "2.0.0-test",
});
expect(result.installed).toBe(true);
const copiedNext = path.join(result.runtimeAppDir, "node_modules", "next");
expect(lstatSync(copiedNext).isSymbolicLink()).toBe(true);
});
});

View File

@ -0,0 +1,94 @@
/**
* Flatten pnpm virtual-store symlinks in a Next.js standalone output
* into a standard flat node_modules layout.
*
* Why: pnpm's standalone output uses symlinks inside node_modules/ that
* npm pack silently drops. Without this step the published package ships
* with an empty node_modules/ and `require('next')` fails on user machines.
*/
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
rmSync,
} from "node:fs";
import path from "node:path";
export interface FlattenResult {
skipped: boolean;
copied: number;
}
/**
* Walk the pnpm `.pnpm/` virtual store inside `standaloneDir/node_modules/`
* and copy every traced package (dereferenced) into a flat
* `standaloneDir/apps/web/node_modules/` layout. Removes the root
* `node_modules/` afterwards so only the self-contained app dir remains.
*/
export function flattenPnpmStandaloneDeps(standaloneDir: string): FlattenResult {
const pnpmStore = path.join(standaloneDir, "node_modules", ".pnpm");
const target = path.join(standaloneDir, "apps", "web", "node_modules");
if (!existsSync(pnpmStore)) {
return { skipped: true, copied: 0 };
}
rmSync(target, { recursive: true, force: true });
mkdirSync(target, { recursive: true });
let copied = 0;
for (const storeEntry of readdirSync(pnpmStore)) {
const nmDir = path.join(pnpmStore, storeEntry, "node_modules");
if (!existsSync(nmDir)) continue;
const stat = lstatSync(nmDir);
if (!stat.isDirectory() && !stat.isSymbolicLink()) continue;
let deps: string[];
try {
deps = readdirSync(nmDir);
} catch {
continue;
}
for (const dep of deps) {
if (dep === ".pnpm" || dep === "node_modules") continue;
const depPath = path.join(nmDir, dep);
if (dep.startsWith("@")) {
let scopedPkgs: string[];
try {
scopedPkgs = readdirSync(depPath);
} catch {
continue;
}
for (const pkg of scopedPkgs) {
const src = path.join(depPath, pkg);
const dst = path.join(target, dep, pkg);
if (existsSync(dst)) continue;
mkdirSync(path.join(target, dep), { recursive: true });
cpSync(src, dst, { recursive: true, dereference: true, force: true });
copied++;
}
} else {
const dst = path.join(target, dep);
if (existsSync(dst)) continue;
cpSync(depPath, dst, {
recursive: true,
dereference: true,
force: true,
});
copied++;
}
}
}
rmSync(path.join(standaloneDir, "node_modules"), {
recursive: true,
force: true,
});
return { skipped: false, copied };
}

View File

@ -18,4 +18,10 @@ export default defineConfig([
fixedExtension: false,
platform: "node",
},
{
entry: "src/cli/flatten-standalone-deps.ts",
env,
fixedExtension: false,
platform: "node",
},
]);