Skip to content

Commit 74d7d08

Browse files
fix(release): declare the npm platform packages' shipped node_modules as bundled dependencies
The platform packages carry the executable's native addons inside node_modules but declared none of them, so npm treated the tree as extraneous and pruned it on the next npm install in the same project. The executable then failed with "Cannot find module '@ff-labs/fff-node'". Every top-level package in the shipped tree is now listed in dependencies at its on-disk version and in bundleDependencies, which is how npm is told the tree belongs to the package. Reported by @Nipsuli against 0.0.41-nightly.20260914.1707 on macOS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9375c77 commit 74d7d08

2 files changed

Lines changed: 67 additions & 4 deletions

File tree

‎scripts/build-npm-platform-packages.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,22 @@ const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () {
5555
const stem = `t3-${VERSION}-${key}`;
5656
const stage = path.join(root, "stage", key);
5757
const contentDir = path.join(stage, stem);
58-
for (const dir of ["client", "resource-monitor", "node_modules/node-pty"]) {
58+
for (const dir of [
59+
"client",
60+
"resource-monitor",
61+
"node_modules/node-pty",
62+
"node_modules/@ff-labs/fff-node",
63+
]) {
5964
yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true });
6065
}
66+
yield* fs.writeFileString(
67+
path.join(contentDir, "node_modules/node-pty/package.json"),
68+
'{ "name": "node-pty", "version": "1.1.0" }\n',
69+
);
70+
yield* fs.writeFileString(
71+
path.join(contentDir, "node_modules/@ff-labs/fff-node/package.json"),
72+
'{ "name": "@ff-labs/fff-node", "version": "0.9.4" }\n',
73+
);
6174
yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "<html></html>\n");
6275
yield* fs.writeFileString(
6376
path.join(contentDir, "t3"),
@@ -127,6 +140,13 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => {
127140
]);
128141
assert.equal(linuxManifest.preferUnplugged, true);
129142
assert.isUndefined(linuxManifest.bin);
143+
// The shipped node_modules is declared, or npm prunes it as extraneous
144+
// on the next install in the same project and the executable breaks.
145+
assert.deepStrictEqual(linuxManifest.dependencies, {
146+
"@ff-labs/fff-node": "0.9.4",
147+
"node-pty": "1.1.0",
148+
});
149+
assert.deepStrictEqual(linuxManifest.bundleDependencies, ["@ff-labs/fff-node", "node-pty"]);
130150
// Archive contents sit at the package root, not under the archive stem.
131151
assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html")));
132152
// A root README, or npm would display a bundled dependency's.

‎scripts/build-npm-platform-packages.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,22 @@ export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): stri
8888
return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`;
8989
}
9090

91-
/** package.json for one platform package; `os`/`cpu` let npm skip the other five. */
92-
export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, version: string) {
91+
/**
92+
* package.json for one platform package; `os`/`cpu` let npm skip the other
93+
* five. The archive's runtime `node_modules` (native addons and their
94+
* loaders) ships inside the tarball, and npm only keeps a nested tree it can
95+
* account for: anything not declared is extraneous and pruned on the next
96+
* `npm install` in that project, which then breaks the executable. Declaring
97+
* every bundled package as a bundled dependency at the exact version on disk
98+
* makes npm treat the tree as part of this package and leave it alone.
99+
*/
100+
export function npmPlatformPackageManifest(
101+
platformKey: CliArchivePlatformKey,
102+
version: string,
103+
bundled: Readonly<Record<string, string>>,
104+
) {
93105
const [os, cpu] = platformKey.split("-") as [string, string];
106+
const bundleDependencies = Object.keys(bundled).sort();
94107
return {
95108
name: npmPlatformPackageName(platformKey),
96109
version,
@@ -101,9 +114,38 @@ export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, v
101114
cpu: [cpu],
102115
files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"],
103116
preferUnplugged: true,
117+
dependencies: Object.fromEntries(bundleDependencies.map((name) => [name, bundled[name]])),
118+
bundleDependencies,
104119
};
105120
}
106121

122+
const PackageVersion = Schema.Struct({ version: Schema.String });
123+
const decodePackageVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageVersion));
124+
125+
/** Every top-level package under `node_modules`, scoped ones included, at the version its manifest names. */
126+
const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModulesDir: string) {
127+
const fs = yield* FileSystem.FileSystem;
128+
const path = yield* Path.Path;
129+
const bundled: Record<string, string> = {};
130+
const packageDirs: Array<{ readonly name: string; readonly dir: string }> = [];
131+
for (const entry of yield* fs.readDirectory(nodeModulesDir)) {
132+
if (entry.startsWith(".")) continue;
133+
const dir = path.join(nodeModulesDir, entry);
134+
if (entry.startsWith("@")) {
135+
for (const scoped of yield* fs.readDirectory(dir)) {
136+
packageDirs.push({ name: `${entry}/${scoped}`, dir: path.join(dir, scoped) });
137+
}
138+
} else {
139+
packageDirs.push({ name: entry, dir });
140+
}
141+
}
142+
for (const { name, dir } of packageDirs) {
143+
const manifest = yield* fs.readFileString(path.join(dir, "package.json"));
144+
bundled[name] = (yield* decodePackageVersion(manifest)).version;
145+
}
146+
return bundled;
147+
});
148+
107149
/**
108150
* README for one platform package. Without one at the package root, npm
109151
* shows the first README it finds in the tarball, which is a bundled
@@ -302,9 +344,10 @@ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input:
302344
if (executableName === "t3") {
303345
yield* fs.chmod(executable, 0o755);
304346
}
347+
const bundled = yield* readBundledPackages(path.join(contentDir, "node_modules"));
305348
yield* fs.writeFileString(
306349
path.join(contentDir, "package.json"),
307-
`${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`,
350+
`${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version, bundled))}\n`,
308351
);
309352
yield* fs.writeFileString(
310353
path.join(contentDir, "README.md"),

0 commit comments

Comments
 (0)