From ba61d1fe4db9d5b552c7b9a5402183266e6af56f Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Sun, 9 Aug 2026 16:17:30 -0400 Subject: [PATCH 01/10] perf(build): stop unpacking node_modules wholesale from the Windows asar A Windows installer built from main writes 14,687 files, of which 13,875 are loose node_modules files under app.asar.unpacked. Only 20 of them are native .node binaries. For contrast, the entire Electron runtime -- several hundred MB -- is 22 files, because it stays inside the archive. That file count costs twice. NSIS install time tracks file count, not bytes. And every one of those files is a separate open/stat/scan the first time the server starts after an install, which is exactly when the OS file cache is cold and the on-access virus scanner is not. The blanket `**/node_modules/**` unpack exists because the CLI bundle externalizes its runtime dependencies, and the WSL backend launches plain `wsl.exe -- node`, which cannot read inside an asar. So every external dep has to be a real file on disk. Invert the bundler's rule: bundle everything except the packages that genuinely cannot be inlined -- native addons, the JS wrappers that dlopen them, and the Bun-only entry points that resolve `bun:*` specifiers -- then narrow asarUnpack to exactly that set. Measured on this tree, win/nsis x64: files written at install 14,687 -> 1,192 (-92%) loose node_modules files 13,875 -> 370 native .node binaries 20 -> 20 installer size 145.0 MiB -> 138.9 MiB Cold start improves by the same mechanism. Extracting each build's payload to a fresh directory (so the files have never been read) and booting the server: server boot to "Listening on" 9044ms / 10160ms -> 3667ms / 3779ms module load only (--version) 6521 / 6238 / 6208ms -> 761 / 659 / 654ms Run order was alternated between builds to keep cache and scanner state from favouring either one. The desktop main window is not created until the backend answers HTTP, so that ~6s comes straight off a cold launch. Both consumers now derive from one list in scripts/lib/cli-external-packages.ts. They cannot drift, and the drift is worth guarding: a package that is external but not unpacked still resolves on the Windows primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar transparently. It fails only under WSL. `node-gyp-build-optional-packages` hit exactly this while writing the patch -- matched as external by the `node-gyp-build` prefix, missed by a glob without a trailing wildcard, and invisible on the platform being tested on. Verified the way this can actually fail: extracted app.asar.unpacked into a directory with no node_modules ancestor -- what plain node sees under WSL -- and booted the server there. Migrations ran, it listened on 127.0.0.1, and no module failed to resolve. node-pty, ffi-rs, msgpackr-extract and @ff-labs/fff-node all load from that isolated tree. --- apps/server/vite.config.ts | 19 +++---- scripts/build-desktop-artifact.ts | 23 +++++--- scripts/lib/cli-external-packages.test.ts | 64 +++++++++++++++++++++++ scripts/lib/cli-external-packages.ts | 56 ++++++++++++++++++++ 4 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 scripts/lib/cli-external-packages.test.ts create mode 100644 scripts/lib/cli-external-packages.ts diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f3279..84b38f26658 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -5,16 +5,17 @@ import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; import packageJson from "./package.json" with { type: "json" }; -const bundledPackagePrefixes = [ - "@pierre/diffs", - "@t3tools/", - "effect-acp", - "effect-codex-app-server", -]; +// The bundle used to inline only workspace packages, leaving every third-party +// runtime dep external. External deps must exist on the real filesystem (the WSL +// backend runs plain `wsl.exe -- node`, which cannot read inside an asar), so the +// desktop build unpacked `**\/node_modules\/**` wholesale: 13,875 loose files to +// support 20 native binaries. NSIS install time tracks file count, not bytes. +// +// Inverted here — bundle everything except the packages that genuinely cannot be +// inlined. See scripts/lib/cli-external-packages.ts for what earns an exemption. +import { shouldBundleCliDependency } from "../../scripts/lib/cli-external-packages.ts"; -export function shouldBundleCliDependency(id: string): boolean { - return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix)); -} +export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a30b6d4a90a..12b19a5b886 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -17,6 +17,7 @@ import { type WebAssetBrand, } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; +import { CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -633,13 +634,21 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", ] as const; -// The WSL backend launches the server with plain `wsl.exe -- node`, which -// cannot read inside an asar archive — and the server bundle externalizes its -// runtime deps, so the whole node_modules tree must be unpacked, not just the -// bundle (otherwise ERR_MODULE_NOT_FOUND: "Cannot find package 'effect'"). -// The Windows primary backend reads the same files through the asar redirect, -// so nothing is duplicated. -export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const; +// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot +// read inside an asar archive, so everything it loads must be on the real +// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the +// server bundle externalized its runtime deps and the Linux Node would fail with +// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached +// node-pty. +// +// The CLI bundle now inlines its JS dependencies, so the only things that still +// have to be loose are the server bundle itself and the packages the bundle +// leaves external — derived from the same list the bundler uses, so the two +// cannot drift apart. +export const WINDOWS_ASAR_UNPACK = [ + "apps/server/dist/**", + ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, +] as const; export const DESKTOP_EXTRA_RESOURCES = [ { from: "apps/desktop/prod-resources/resource-monitor", diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts new file mode 100644 index 00000000000..67365a60b8d --- /dev/null +++ b/scripts/lib/cli-external-packages.test.ts @@ -0,0 +1,64 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + CLI_EXTERNAL_PACKAGE_PREFIXES, + CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, + shouldBundleCliDependency, +} from "./cli-external-packages.ts"; + +describe("shouldBundleCliDependency", () => { + it("bundles ordinary runtime dependencies", () => { + for (const id of ["effect", "@effect/platform", "hono", "@t3tools/shared/hostProcess"]) { + assert.strictEqual(shouldBundleCliDependency(id), true, id); + } + }); + + it("never bundles node: builtins", () => { + assert.strictEqual(shouldBundleCliDependency("node:fs"), false); + }); + + it("leaves native addons and their dlopen wrappers external", () => { + for (const id of [ + "node-pty", + "ffi-rs", + "@yuuang/ffi-rs-win32-x64-msvc", + "@ff-labs/fff-node", + "@clerk/electron-passkeys", + "msgpackr-extract", + "@msgpackr-extract/msgpackr-extract-win32-x64", + ]) { + assert.strictEqual(shouldBundleCliDependency(id), false, id); + } + }); + + it("leaves bun-only entry points external", () => { + assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), false); + assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), false); + }); + + // The real package is `node-gyp-build-optional-packages`, reached by prefix. + // Matching it as external while failing to unpack it is invisible on the + // Windows primary (which reads app.asar) and breaks only under WSL. + it("treats prefix-matched siblings as external", () => { + assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false); + }); +}); + +describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { + it("unpacks every external prefix from both the top level and the pnpm store", () => { + for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) { + assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix); + assert.include( + CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, + `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`, + prefix, + ); + } + }); + + // Without the trailing `*` the globs stop covering prefix-matched siblings, + // which is exactly how a package ends up external but not unpacked. + it("keeps the trailing wildcard that matches prefix siblings", () => { + assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*"); + }); +}); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts new file mode 100644 index 00000000000..ae41ecf3b5f --- /dev/null +++ b/scripts/lib/cli-external-packages.ts @@ -0,0 +1,56 @@ +/** + * The single source of truth for packages the server CLI bundle must NOT inline. + * + * Two consumers derive from this list, and they must never disagree: + * + * - apps/server/vite.config.ts decides what stays external to the bundle. + * - scripts/build-desktop-artifact.ts decides what gets unpacked out of the asar. + * + * A package that is external but not unpacked still resolves on the Windows + * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar + * transparently. It fails only under WSL, where the backend is launched as plain + * `wsl.exe -- node` and cannot read inside an archive. That asymmetry makes the + * drift invisible on the platform you are most likely to test on, which is why + * both consumers derive from one list instead of maintaining their own. + * + * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover + * a package's platform-specific siblings — `node-gyp-build` covers + * `node-gyp-build-optional-packages`, `@yuuang/` covers every `ffi-rs-*` binding. + */ +export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ + // Native addons (.node), and the JS wrappers that dlopen them by real path. + "node-pty", + "ffi-rs", + "@yuuang/", + "@ff-labs/", + "@clerk/electron-passkeys", + "@msgpackr-extract/", + "msgpackr-extract", + "node-gyp-build", + "node-addon-api", + // Bun-only entry points: reached through a runtime-conditional dynamic import + // and resolving `bun:*` specifiers, which do not exist when bundling for Node. + "@effect/platform-bun", + "@effect/sql-sqlite-bun", +] as const; + +/** True when the CLI bundle should inline `id` rather than leave it external. */ +export function shouldBundleCliDependency(id: string): boolean { + if (id.startsWith("node:")) return false; + return !CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + +/** + * asar-unpack globs covering every external package. + * + * The trailing `*` is what keeps these aligned with the prefix matching above: + * without it, `node-gyp-build` would be left external by the bundler and then + * not unpacked, because the real package is `node-gyp-build-optional-packages`. + * + * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both + * paths are unpacked for the link target to exist on disk. + */ +export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.flatMap( + (prefix) => + [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const, +); From 0aacacd8196977df4f7b63a57ca3ed6db92ffc7a Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Sun, 9 Aug 2026 17:38:30 -0400 Subject: [PATCH 02/10] fix(build): keep the dependency closure of external packages external Real WSL testing on this branch found a case the hand-maintained list could not catch by inspection. node-gyp-build-optional-packages is external, so it is loaded from the real filesystem, so its own `require` resolves from the real filesystem too. It requires detect-libc, which was not on the list and therefore got bundled into the CLI bundle -- present only inside app.asar. The Windows primary reads that transparently under ELECTRON_RUN_AS_NODE and resolves it; plain node under WSL cannot. msgpackr-extract failed through the same chain. Measured under Ubuntu 24.04 with Linux node v24.18.0 against the packaged tree: before: MISSING (cjs) msgpackr-extract [MODULE_NOT_FOUND] detect-libc MISSING (cjs) node-gyp-build-optional-packages [MODULE_NOT_FOUND] after : no resolution failures The general rule is that an external package's entire runtime dependency closure must be external. That is not something to maintain by staring at a list, so it is now a test: it walks each runtime-external package's declared dependencies transitively and fails if any would be bundled away. Writing that test surfaced a distinction the single list had flattened. The Bun-only entries are external for a build-time reason -- they resolve `bun:*` specifiers that do not exist when bundling for Node -- and Node never loads them, so their closure genuinely does not need to be external. The native packages are external for a runtime reason and theirs does. The list is split along that line, and the closure test applies only to the runtime set. Adds 6 files to the installer (1,192 -> 1,198). Native binaries and installer size are unchanged. --- scripts/lib/cli-external-packages.test.ts | 55 +++++++++++++++++++++++ scripts/lib/cli-external-packages.ts | 34 ++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 67365a60b8d..23e06b49f0d 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { CLI_EXTERNAL_PACKAGE_PREFIXES, CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, + CLI_RUNTIME_EXTERNAL_PREFIXES, shouldBundleCliDependency, } from "./cli-external-packages.ts"; @@ -62,3 +63,57 @@ describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*"); }); }); + +// The failure this guards is invisible on Windows and fatal under WSL. +// +// An external package is loaded from the real filesystem, so its own `require` +// also resolves from the real filesystem. If one of its dependencies was +// bundled away instead of left external, that dependency exists only inside +// app.asar — which the Windows primary reads transparently under +// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot. +// +// Found the hard way: node-gyp-build-optional-packages requires detect-libc, +// which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND. +describe("external package dependency closure", () => { + // Must be runtime-external specifically: the dependency has to exist on disk. + const isExternal = (name: string) => + CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => name.startsWith(prefix)); + + it("keeps every runtime dependency of an external package external too", async () => { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + + const violations: string[] = []; + const seen = new Set(); + // Only the runtime-external set. The build-only entries resolve `bun:*` + // and are never loaded by Node, so their closure is irrelevant here. + const queue: string[] = CLI_RUNTIME_EXTERNAL_PREFIXES.filter((prefix) => !prefix.endsWith("/")); + + for (const name of queue) { + if (seen.has(name)) continue; + seen.add(name); + + let manifest: { dependencies?: Record }; + try { + manifest = require(`${name}/package.json`); + } catch { + // Not installed on this platform (or reached only by prefix); nothing + // to check. The globs still cover it if it does get installed. + continue; + } + + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + if (!isExternal(dependency)) { + violations.push(`${name} -> ${dependency}`); + } + if (!seen.has(dependency)) queue.push(dependency); + } + } + + assert.deepStrictEqual( + violations, + [], + `these dependencies of external packages would be bundled away, and fail to resolve under WSL: ${violations.join(", ")}`, + ); + }); +}); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index ae41ecf3b5f..1477b382f68 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -17,8 +17,17 @@ * a package's platform-specific siblings — `node-gyp-build` covers * `node-gyp-build-optional-packages`, `@yuuang/` covers every `ffi-rs-*` binding. */ -export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ - // Native addons (.node), and the JS wrappers that dlopen them by real path. +/** + * External because Node actually loads them from disk at runtime. + * + * Native addons (.node), the JS wrappers that dlopen them by real path, and — + * critically — the ordinary JS packages those wrappers require. An external + * package is loaded from the real filesystem, so its own `require` also + * resolves from the real filesystem; a dependency that was bundled away exists + * only inside app.asar and is unreachable there. This closure is enforced by a + * test, not by inspection. + */ +export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "node-pty", "ffi-rs", "@yuuang/", @@ -28,12 +37,29 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ "msgpackr-extract", "node-gyp-build", "node-addon-api", - // Bun-only entry points: reached through a runtime-conditional dynamic import - // and resolving `bun:*` specifiers, which do not exist when bundling for Node. + // Required by node-gyp-build-optional-packages. Not native, but in the + // closure: without it, WSL gets MODULE_NOT_FOUND while Windows is fine. + "detect-libc", +] as const; + +/** + * External only so the bundler never has to resolve them. + * + * These are reached through a runtime-conditional dynamic import that Node + * never takes, and they resolve `bun:*` specifiers that do not exist when + * bundling for Node. Because Node never loads them, their dependency closure + * does not need to be external — only the entry point must stay unbundled. + */ +export const CLI_BUILD_ONLY_EXTERNAL_PREFIXES = [ "@effect/platform-bun", "@effect/sql-sqlite-bun", ] as const; +export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ + ...CLI_RUNTIME_EXTERNAL_PREFIXES, + ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, +] as const; + /** True when the CLI bundle should inline `id` rather than leave it external. */ export function shouldBundleCliDependency(id: string): boolean { if (id.startsWith("node:")) return false; From 45b45176a5198897ba05f4330f1d28f2daffbf8e Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Sun, 9 Aug 2026 18:41:35 -0400 Subject: [PATCH 03/10] test(build): make the closure guard actually read the dependency closure The guard added in the previous commit could pass without checking anything. It resolved manifests with `require("/package.json")` from scripts/lib, and swallowed resolution failures as "not installed on this platform". Under pnpm isolation that catch swallowed nearly everything. Probed from scripts/lib, every seed failed with MODULE_NOT_FOUND -- node-pty, msgpackr-extract, ffi-rs, node-gyp-build, detect-libc, node-addon-api. Probed from apps/server, only its direct dependencies resolved; the transitive packages that actually caused the WSL breakage still did not. `exports` maps are a second hole: @ff-labs/fff-node refuses the /package.json subpath with ERR_PACKAGE_PATH_NOT_EXPORTED, which the same catch treated as absent. Seeding the queue from the prefix strings was wrong for a second reason: the filter dropped every prefix ending in "/", so "@yuuang/", "@ff-labs/" and "@msgpackr-extract/" were never visited even where resolution worked. Read the manifests off disk from the pnpm store instead. That is the same tree asarUnpack globs target, it reaches transitive packages, and it is not subject to resolution or exports semantics. Seeds now come from what is installed and matches a prefix, so scoped prefixes are covered. Added a guard test that fails unless node-pty, node-gyp-build-optional-packages and detect-libc are actually found, because a closure check that reads nothing is worse than no check -- it reports success. Verified by mutation: removing detect-libc from the list fails with "node-gyp-build-optional-packages -> detect-libc", the real bug. The previous version of this test passed with detect-libc removed. --- scripts/lib/cli-external-packages.test.ts | 140 ++++++++++++++++------ 1 file changed, 105 insertions(+), 35 deletions(-) diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 23e06b49f0d..cb684a7b10a 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -1,4 +1,11 @@ +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { CLI_EXTERNAL_PACKAGE_PREFIXES, @@ -7,6 +14,14 @@ import { shouldBundleCliDependency, } from "./cli-external-packages.ts"; +// Only the field this test cares about; decoding ignores everything else. +const PackageManifest = Schema.Struct({ + dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); +type PackageManifest = typeof PackageManifest.Type; + +const decodeManifest = Schema.decodeUnknownSync(Schema.fromJsonString(PackageManifest)); + describe("shouldBundleCliDependency", () => { it("bundles ordinary runtime dependencies", () => { for (const id of ["effect", "@effect/platform", "hono", "@t3tools/shared/hostProcess"]) { @@ -74,46 +89,101 @@ describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { // // Found the hard way: node-gyp-build-optional-packages requires detect-libc, // which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND. -describe("external package dependency closure", () => { - // Must be runtime-external specifically: the dependency has to exist on disk. - const isExternal = (name: string) => +it.layer(NodeServices.layer)("external package dependency closure", (it) => { + // Read manifests off disk from the pnpm store rather than resolving them. + // `require("/package.json")` cannot do this job: under pnpm isolation a + // transitive package (detect-libc, msgpackr-extract, ffi-rs) is not reachable + // by name from this file at all, and an `exports` map can refuse the + // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not + // installed", which would let this test skip everything and pass while + // checking nothing. The store is also what asarUnpack globs target, so this + // reads the same tree the build packages. + const readInstalledPackages = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const storeDir = path.resolve( + path.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../../node_modules/.pnpm", + ); + + const installed = new Map(); + if (!(yield* fileSystem.exists(storeDir))) return installed; + + for (const entry of yield* fileSystem.readDirectory(storeDir)) { + const modulesDir = path.join(storeDir, entry, "node_modules"); + if (!(yield* fileSystem.exists(modulesDir))) continue; + + for (const owner of yield* fileSystem.readDirectory(modulesDir)) { + const names = owner.startsWith("@") + ? (yield* fileSystem.readDirectory(path.join(modulesDir, owner))).map( + (scoped) => `${owner}/${scoped}`, + ) + : [owner]; + + for (const name of names) { + if (installed.has(name)) continue; + const manifestPath = path.join(modulesDir, name, "package.json"); + if (!(yield* fileSystem.exists(manifestPath))) continue; + installed.set(name, decodeManifest(yield* fileSystem.readFileString(manifestPath))); + } + } + } + return installed; + }).pipe(Effect.cached, Effect.runSync); + + // Runtime-external only. The build-only entries resolve `bun:*` and are never + // loaded by Node, so their closure genuinely does not need to be external. + const isRuntimeExternal = (name: string) => CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => name.startsWith(prefix)); - it("keeps every runtime dependency of an external package external too", async () => { - const { createRequire } = await import("node:module"); - const require = createRequire(import.meta.url); - - const violations: string[] = []; - const seen = new Set(); - // Only the runtime-external set. The build-only entries resolve `bun:*` - // and are never loaded by Node, so their closure is irrelevant here. - const queue: string[] = CLI_RUNTIME_EXTERNAL_PREFIXES.filter((prefix) => !prefix.endsWith("/")); - - for (const name of queue) { - if (seen.has(name)) continue; - seen.add(name); - - let manifest: { dependencies?: Record }; - try { - manifest = require(`${name}/package.json`); - } catch { - // Not installed on this platform (or reached only by prefix); nothing - // to check. The globs still cover it if it does get installed. - continue; + it.effect("finds the runtime-external packages on disk", () => + Effect.gen(function* () { + const installed = yield* readInstalledPackages; + const found = [...installed.keys()].filter(isRuntimeExternal); + + // Without this the closure check below can pass vacuously: if nothing is + // read, nothing is checked. These are the packages whose closure actually + // broke WSL, so require them by name. + for (const required of ["node-pty", "node-gyp-build-optional-packages", "detect-libc"]) { + assert.ok( + found.includes(required), + `expected ${required} in the pnpm store; the closure check is only meaningful if it can read these (found ${found.length})`, + ); } + }), + ); + + it.effect("keeps every runtime dependency of an external package external too", () => + Effect.gen(function* () { + const installed = yield* readInstalledPackages; + const violations: string[] = []; + const seen = new Set(); + // Seeded from what is actually installed and matches a prefix, so scoped + // prefixes like "@yuuang/" and "@ff-labs/" are covered too. Seeding from + // the prefix strings themselves would skip every scoped entry, since a + // prefix is not a package name. + const queue = [...installed.keys()].filter(isRuntimeExternal); + + for (const name of queue) { + if (seen.has(name)) continue; + seen.add(name); - for (const dependency of Object.keys(manifest.dependencies ?? {})) { - if (!isExternal(dependency)) { - violations.push(`${name} -> ${dependency}`); + const manifest = installed.get(name); + if (!manifest) continue; + + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + if (!isRuntimeExternal(dependency)) { + violations.push(`${name} -> ${dependency}`); + } + if (!seen.has(dependency)) queue.push(dependency); } - if (!seen.has(dependency)) queue.push(dependency); } - } - assert.deepStrictEqual( - violations, - [], - `these dependencies of external packages would be bundled away, and fail to resolve under WSL: ${violations.join(", ")}`, - ); - }); + assert.deepStrictEqual( + violations, + [], + `these dependencies of external packages would be bundled away and fail to resolve under WSL: ${violations.join(", ")}`, + ); + }), + ); }); From 12a5bf026f2fd5efde257aec14e9b1e9783a423c Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Sun, 9 Aug 2026 19:18:16 -0400 Subject: [PATCH 04/10] fix(test): tolerate non-directory entries in the pnpm store The closure guard walked node_modules/.pnpm and built a node_modules path under each entry. The store also contains a regular file, lock.yaml, so that path is rooted in a file rather than a directory. Linux raises ENOTDIR from the access call; Windows quietly reports false. The test therefore passed locally and failed on CI -- itself an instance of the platform asymmetry this file exists to catch. Existence checks now treat any failure as absence. --- scripts/lib/cli-external-packages.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index cb684a7b10a..3504540e956 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -106,12 +106,19 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { "../../node_modules/.pnpm", ); + // The store holds regular files too (lock.yaml), so a path built under one + // raises ENOTDIR rather than reporting absence. That throws on Linux while + // Windows quietly returns false, which is exactly the kind of difference + // this test exists to catch, so treat any failure as "not there". + const isPresent = (candidate: string) => + fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + const installed = new Map(); - if (!(yield* fileSystem.exists(storeDir))) return installed; + if (!(yield* isPresent(storeDir))) return installed; for (const entry of yield* fileSystem.readDirectory(storeDir)) { const modulesDir = path.join(storeDir, entry, "node_modules"); - if (!(yield* fileSystem.exists(modulesDir))) continue; + if (!(yield* isPresent(modulesDir))) continue; for (const owner of yield* fileSystem.readDirectory(modulesDir)) { const names = owner.startsWith("@") @@ -123,7 +130,7 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { for (const name of names) { if (installed.has(name)) continue; const manifestPath = path.join(modulesDir, name, "package.json"); - if (!(yield* fileSystem.exists(manifestPath))) continue; + if (!(yield* isPresent(manifestPath))) continue; installed.set(name, decodeManifest(yield* fileSystem.readFileString(manifestPath))); } } From 2134d965b6ebd6dc7bbc7d9425813cc12a2720aa Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Tue, 11 Aug 2026 06:51:18 -0400 Subject: [PATCH 05/10] fix(wsl): probe an external package, not one the bundle now inlines The WSL health probe resolved "effect" to confirm the server's dependencies were unpacked on the real filesystem. That premise held while the bundle externalized its runtime deps and the whole node_modules tree was unpacked. This branch inlines those dependencies, so "effect" no longer exists on disk. The probe therefore exits 3 and wsl-only mode refuses to launch, reporting a packaging regression that isn't one. Resolve node-pty instead: it is external precisely because it cannot be inlined, so it is a valid sentinel for the unpacked tree both before and after this change. Verified against the packaged tree, where require.resolve("effect") fails with MODULE_NOT_FOUND and require.resolve("node-pty/package.json") succeeds. Reported by @ikifar2012, who hit it running wsl-only mode from this branch. --- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index c6c274d8500..a474d5e7883 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -229,15 +229,18 @@ const NODE_PTY_PROBE_SCRIPT = ( printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 -// The server bundle externalizes its deps to node_modules, and the WSL Node -// can't read inside app.asar, so confirm those deps are unpacked on the real -// filesystem before reporting the backend healthy. "effect" is the framework -// every server module imports; resolving it validates the whole node_modules -// tree. Exit 3 marks this distinct from a node-pty problem so the caller can -// report it accurately instead of letting the server crash on -// ERR_MODULE_NOT_FOUND at launch (which, in wsl-only mode, would just fail to -// launch with no fallback). -try { require.resolve("effect"); } catch (_e) { process.exit(3); } +// The WSL Node can't read inside app.asar, so confirm what the server needs is +// unpacked on the real filesystem before reporting the backend healthy. Exit 3 +// marks this distinct from a node-pty prebuild problem so the caller can report +// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at +// launch (which, in wsl-only mode, would just fail to launch with no fallback). +// +// The sentinel must be a package the CLI bundle leaves external. It used to be +// "effect", back when the bundle externalized its runtime deps and the whole +// node_modules tree was unpacked. The bundle now inlines its JS dependencies, +// so "effect" no longer exists on disk and only the native packages do — +// resolving node-pty is what actually validates the unpacked tree. +try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); @@ -462,11 +465,12 @@ const ensureNodePtyImpl = ( } as const; } - // Server dependencies (e.g. "effect") couldn't be resolved on the WSL - // filesystem — a packaging regression, since the server bundle needs its - // node_modules unpacked from the asar. Fatal so wsl-only mode falls back to - // Windows and dual mode surfaces the reason inline, instead of the server - // crash-looping on ERR_MODULE_NOT_FOUND once it actually launches. + // The packages the server bundle leaves external (node-pty and the other + // native addons) couldn't be resolved on the WSL filesystem — a packaging + // regression, since those must be unpacked from the asar. Fatal so wsl-only + // mode falls back to Windows and dual mode surfaces the reason inline, + // instead of the server crash-looping on ERR_MODULE_NOT_FOUND once it + // actually launches. if (probe.exitCode === 3) { return { ok: false, From bfd60896ebc4d9e8581153f72ee42b28bb815ea5 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Tue, 11 Aug 2026 06:58:23 -0400 Subject: [PATCH 06/10] fix(wsl): update the exit-3 reason text for the new sentinel The probe now resolves node-pty rather than "effect", but the user-facing reason still named "effect" and described an unreadable bundled node_modules. That points anyone hitting a packaging failure at a package this branch deliberately inlines. Reworded to name the native packages that actually have to be unpacked. --- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index a474d5e7883..164117727ea 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -475,7 +475,7 @@ const ensureNodePtyImpl = ( return { ok: false, reason: - "WSL server dependencies could not be loaded (for example \"effect\"). The server's bundled node_modules is not readable by the WSL distro's Node — this is a packaging problem with this build. Please report it.", + 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', fatal: true, } as const; } From 1e5a52a842394d0d239f3f095d3914c9b7b13d2c Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Tue, 11 Aug 2026 13:33:40 -0400 Subject: [PATCH 07/10] fix(build): force external packages out of the bundle, and check the artifact `alwaysBundle` only forces packages IN. Returning false from the predicate means "no opinion", after which the default applies: a declared dependency stays external, a transitive one gets bundled. node-pty and @ff-labs/fff-node are declared dependencies of apps/server, so they stayed external and the packaging looked correct. msgpackr-extract, node-gyp-build-optional-packages and detect-libc are transitive, and were silently inlined. An inlined native loader resolves its prebuilds relative to the bundle, finds nothing, and falls back to a slower pure-JS path. No crash, no error, just a quiet loss of native acceleration. Wire the same list to `neverBundle`, which actually marks packages external. Every test to this point checked the dependency list rather than the bundle, so none of them saw it. Added findInlinedExternalPackages, which scans the emitted chunks for inlined externals, and wired it into the desktop build so a regression fails the build. It reports the module-region count as well, so "nothing inlined" is distinguishable from "the marker format changed and this scan is now blind" -- the failure mode the earlier closure guard had. Verified against the emitted bundle: msgpackr-extract is external again, and detect-libc and node-gyp-build-optional-packages are absent from it entirely. Reported by cursor bot. --- apps/server/vite.config.ts | 12 +++++- scripts/build-desktop-artifact.ts | 47 ++++++++++++++++++++- scripts/lib/cli-external-packages.test.ts | 47 +++++++++++++++++++++ scripts/lib/cli-external-packages.ts | 51 ++++++++++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 84b38f26658..647af2a889d 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -13,7 +13,10 @@ import packageJson from "./package.json" with { type: "json" }; // // Inverted here — bundle everything except the packages that genuinely cannot be // inlined. See scripts/lib/cli-external-packages.ts for what earns an exemption. -import { shouldBundleCliDependency } from "../../scripts/lib/cli-external-packages.ts"; +import { + isExternalCliDependency, + shouldBundleCliDependency, +} from "../../scripts/lib/cli-external-packages.ts"; export { shouldBundleCliDependency }; @@ -38,7 +41,14 @@ export default mergeConfig( sourcemap: true, clean: true, deps: { + // Both halves are required. `alwaysBundle` forces the JS dependencies in + // (declared deps are external by default, which is what this change is + // undoing). `neverBundle` forces the native packages out: returning + // false from `alwaysBundle` only means "no opinion", so a transitive + // dependency would still be bundled — which silently inlined + // msgpackr-extract and its loader, losing native acceleration. alwaysBundle: shouldBundleCliDependency, + neverBundle: (id: string) => isExternalCliDependency(id), onlyBundle: false, }, banner: { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 12b19a5b886..8c193115dbf 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -17,7 +17,10 @@ import { type WebAssetBrand, } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; -import { CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS } from "./lib/cli-external-packages.ts"; +import { + CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, + findInlinedExternalPackages, +} from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -376,6 +379,15 @@ const desktopBuildInputArtifactNames = { "bundled-server-client": "bundled server client", } satisfies Record; +export class InlinedExternalPackageError extends Schema.TaggedErrorClass()( + "InlinedExternalPackageError", + { packages: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `The server bundle inlined packages that must stay external: ${this.packages.join(", ")}. These are native addons or their loaders; inlined, they resolve prebuilds relative to the bundle and silently lose native acceleration. Check the deps.neverBundle wiring in apps/server/vite.config.ts.`; + } +} + export class MissingDesktopBuildInputError extends Schema.TaggedErrorClass()( "MissingDesktopBuildInputError", { @@ -1826,6 +1838,39 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } } + // Assert against the emitted bundle, not the bundler config. `alwaysBundle` + // only forces packages IN, so a transitive dependency of an external package + // is bundled by default however the predicate is written — that silently + // inlined msgpackr-extract and its native loader while every list-based test + // still passed. An inlined native loader resolves its prebuilds relative to + // the bundle and quietly falls back to a slower pure-JS path, so this fails + // the build rather than shipping a silent regression. + { + const chunkNames = (yield* fs.readDirectory(distDirs.serverDist)).filter((entry) => + entry.endsWith(".mjs"), + ); + let totalRegions = 0; + const inlined = new Set(); + for (const chunkName of chunkNames) { + const source = yield* fs.readFileString(path.join(distDirs.serverDist, chunkName)); + const scan = findInlinedExternalPackages(source); + totalRegions += scan.regionCount; + for (const name of scan.inlined) inlined.add(name); + } + if (inlined.size > 0) { + return yield* new InlinedExternalPackageError({ + packages: [...inlined].sort(), + }); + } + // No regions at all means the scan went blind (marker format changed), not + // that the bundle is clean. + if (totalRegions === 0) { + return yield* new InlinedExternalPackageError({ + packages: [""], + }); + } + } + if (!(yield* fs.exists(bundledClientEntry))) { return yield* new MissingDesktopBuildInputError({ artifact: "bundled-server-client", diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 3504540e956..e95f156a738 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -11,6 +11,7 @@ import { CLI_EXTERNAL_PACKAGE_PREFIXES, CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, CLI_RUNTIME_EXTERNAL_PREFIXES, + findInlinedExternalPackages, shouldBundleCliDependency, } from "./cli-external-packages.ts"; @@ -194,3 +195,49 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { }), ); }); + +// Configuring the bundler is not the same as checking what it emitted. These +// exercise the scanner against the marker shape rolldown actually produces. +describe("findInlinedExternalPackages", () => { + const region = (path: string) => `//#region ${path} +var x = 1; +//#endregion +`; + + it("flags an external package that was inlined", () => { + const source = + region("../../node_modules/.pnpm/detect-libc@2.1.2/node_modules/detect-libc/lib/process.js") + + region( + "../../node_modules/.pnpm/msgpackr-extract@3.0.4/node_modules/msgpackr-extract/index.js", + ); + const result = findInlinedExternalPackages(source); + + assert.deepStrictEqual(result.inlined, ["detect-libc", "msgpackr-extract"]); + assert.strictEqual(result.regionCount, 2); + }); + + it("flags scoped external packages", () => { + const result = findInlinedExternalPackages( + region("../../node_modules/@ff-labs/fff-node/dist/src/index.js"), + ); + assert.deepStrictEqual(result.inlined, ["@ff-labs/fff-node"]); + }); + + it("ignores packages that are meant to be bundled", () => { + const source = + region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js") + + region("../../src/server/main.ts"); + const result = findInlinedExternalPackages(source); + + assert.deepStrictEqual(result.inlined, []); + assert.strictEqual(result.regionCount, 2); + }); + + // regionCount is what separates "clean" from "this scan went blind because the + // marker format changed". A caller that ignores it gets a vacuous pass. + it("reports no regions when the marker format is absent", () => { + const result = findInlinedExternalPackages("var x = 1; // node_modules/detect-libc/lib.js"); + assert.strictEqual(result.regionCount, 0); + assert.deepStrictEqual(result.inlined, []); + }); +}); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 1477b382f68..e8a0417e394 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -60,10 +60,24 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, ] as const; +/** + * True when `id` must stay out of the bundle. + * + * This has to be wired to the bundler's `neverBundle`, not just to + * `alwaysBundle`. `alwaysBundle` only forces packages IN — returning false from + * it means "no opinion", and the default then applies: a declared dependency + * stays external, but a transitive one gets bundled. That is how + * msgpackr-extract, node-gyp-build-optional-packages and detect-libc ended up + * inlined while node-pty (a declared dependency) stayed external. + */ +export function isExternalCliDependency(id: string): boolean { + return CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + /** True when the CLI bundle should inline `id` rather than leave it external. */ export function shouldBundleCliDependency(id: string): boolean { if (id.startsWith("node:")) return false; - return !CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix)); + return !isExternalCliDependency(id); } /** @@ -80,3 +94,38 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f (prefix) => [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const, ); + +/** + * Scan an emitted bundle chunk for runtime-external packages that were inlined. + * + * Configuring the bundler is not the same as checking what it produced. The + * `alwaysBundle` predicate only forces packages IN; returning false from it + * means "no opinion", so a transitive dependency still gets bundled by default. + * msgpackr-extract, node-gyp-build-optional-packages and detect-libc were + * inlined that way while every list-based test passed, which is why this reads + * the artifact instead. + * + * `regionCount` is reported so the caller can tell "nothing was inlined" apart + * from "the marker format changed and this scan no longer sees anything". + */ +export function findInlinedExternalPackages(source: string): { + readonly regionCount: number; + readonly inlined: ReadonlyArray; +} { + // Rolldown marks each inlined module with a `//#region ` comment. + const regionPattern = /\/\/#region\s+(\S+)/g; + const packagePattern = /node_modules\/((?:@[^/\s]+\/)?[^/\s]+)\//g; + + let regionCount = 0; + const inlined = new Set(); + for (const region of source.matchAll(regionPattern)) { + regionCount += 1; + const regionPath = region[1] ?? ""; + for (const candidate of regionPath.matchAll(packagePattern)) { + const name = candidate[1]; + if (name !== undefined && isExternalCliDependency(name)) inlined.add(name); + } + } + + return { regionCount, inlined: [...inlined].sort() }; +} From 1bac1254fd86e7f9bcb40a5dbf2cd90405b72a50 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Tue, 11 Aug 2026 18:34:02 -0400 Subject: [PATCH 08/10] test(build): check the bundle is self-contained, not just free of externals The bundle scan only asserted that external packages were absent. A build that externalized everything would pass it: source-file regions still exist, so the region count is non-zero and no external is inlined. That is the exact failure this change exists to prevent, because those packages are not covered by the unpack globs either and the WSL backend dies on ERR_MODULE_NOT_FOUND. The scan now reports every package it saw in a region, and the build asserts "effect" is among them. Every server module imports it, so it is inlined in any correctly bundled build -- 209 regions in the current one -- and its absence means the dependencies went external again. Verified against the emitted bundle: 788 regions, no external violations, effect inlined, 25 third-party packages inlined in total. Reported by macroscope. --- scripts/build-desktop-artifact.ts | 31 +++++++++++++++++++++++ scripts/lib/cli-external-packages.test.ts | 21 +++++++++++++++ scripts/lib/cli-external-packages.ts | 19 ++++++++++++-- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8c193115dbf..d58ead37053 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -379,6 +379,22 @@ const desktopBuildInputArtifactNames = { "bundled-server-client": "bundled server client", } satisfies Record; +/** + * Imported by every server module, so it is inlined in any correctly bundled + * build. Its absence means the bundle went back to externalizing its + * dependencies, which the unpack globs do not cover. + */ +const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; + +export class ExternalizedBundleError extends Schema.TaggedErrorClass()( + "ExternalizedBundleError", + { sentinel: Schema.String, inlinedPackageCount: Schema.Number }, +) { + override get message(): string { + return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the native externals; if its dependencies are external again they will not be unpacked, and the WSL backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; + } +} + export class InlinedExternalPackageError extends Schema.TaggedErrorClass()( "InlinedExternalPackageError", { packages: Schema.Array(Schema.String) }, @@ -1851,11 +1867,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); let totalRegions = 0; const inlined = new Set(); + const inlinedPackages = new Set(); for (const chunkName of chunkNames) { const source = yield* fs.readFileString(path.join(distDirs.serverDist, chunkName)); const scan = findInlinedExternalPackages(source); totalRegions += scan.regionCount; for (const name of scan.inlined) inlined.add(name); + for (const name of scan.inlinedPackages) inlinedPackages.add(name); } if (inlined.size > 0) { return yield* new InlinedExternalPackageError({ @@ -1869,6 +1887,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( packages: [""], }); } + // The check above is one-directional: it only proves nothing external got + // inlined. A regression to externalizing everything would also pass it, + // since source-file regions still exist -- and that is the failure this + // whole change exists to prevent, because those packages are not in the + // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND. + // `effect` is imported by every server module, so it is inlined in any + // correctly bundled build (209 regions at the time of writing). + if (!inlinedPackages.has(BUNDLE_SELF_CONTAINED_SENTINEL)) { + return yield* new ExternalizedBundleError({ + sentinel: BUNDLE_SELF_CONTAINED_SENTINEL, + inlinedPackageCount: inlinedPackages.size, + }); + } } if (!(yield* fs.exists(bundledClientEntry))) { diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index e95f156a738..15b636c230d 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -235,6 +235,27 @@ var x = 1; // regionCount is what separates "clean" from "this scan went blind because the // marker format changed". A caller that ignores it gets a vacuous pass. + // The scan has to answer both directions. Checking only that externals are + // absent still passes on a bundle that externalized everything, which is the + // failure this whole change prevents. + it("reports the packages that were inlined, not just the violations", () => { + const source = + region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js") + + region("../../node_modules/.pnpm/yaml@2.4.0/node_modules/yaml/dist/index.js") + + region("../../src/server/main.ts"); + const result = findInlinedExternalPackages(source); + + assert.deepStrictEqual(result.inlinedPackages, ["effect", "yaml"]); + assert.deepStrictEqual(result.inlined, []); + }); + + it("does not report the pnpm store directory as a package", () => { + const result = findInlinedExternalPackages( + region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js"), + ); + assert.deepStrictEqual(result.inlinedPackages, ["effect"]); + }); + it("reports no regions when the marker format is absent", () => { const result = findInlinedExternalPackages("var x = 1; // node_modules/detect-libc/lib.js"); assert.strictEqual(result.regionCount, 0); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index e8a0417e394..def27f5520f 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -107,10 +107,18 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f * * `regionCount` is reported so the caller can tell "nothing was inlined" apart * from "the marker format changed and this scan no longer sees anything". + * + * `inlinedPackages` is every package seen in a region, which lets the caller + * check the opposite direction too. Verifying only that externals are absent + * would still pass if the bundler reverted to leaving everything external: the + * scan would see source-file regions, report nothing inlined, and the packaged + * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages + * are not in the unpack globs either. */ export function findInlinedExternalPackages(source: string): { readonly regionCount: number; readonly inlined: ReadonlyArray; + readonly inlinedPackages: ReadonlyArray; } { // Rolldown marks each inlined module with a `//#region ` comment. const regionPattern = /\/\/#region\s+(\S+)/g; @@ -118,14 +126,21 @@ export function findInlinedExternalPackages(source: string): { let regionCount = 0; const inlined = new Set(); + const inlinedPackages = new Set(); for (const region of source.matchAll(regionPattern)) { regionCount += 1; const regionPath = region[1] ?? ""; for (const candidate of regionPath.matchAll(packagePattern)) { const name = candidate[1]; - if (name !== undefined && isExternalCliDependency(name)) inlined.add(name); + if (name === undefined || name === ".pnpm") continue; + inlinedPackages.add(name); + if (isExternalCliDependency(name)) inlined.add(name); } } - return { regionCount, inlined: [...inlined].sort() }; + return { + regionCount, + inlined: [...inlined].sort(), + inlinedPackages: [...inlinedPackages].sort(), + }; } From 22731fda26c239259f29985bb4590408c17831cc Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Wed, 12 Aug 2026 18:48:38 -0400 Subject: [PATCH 09/10] build: verify the packaged bundle by running it, not by reading it Static analysis of the emitted source kept getting this wrong. Scanning for bare imports matched specifiers inside effect's JSDoc examples and inside ajv's runtime codegen template; asserting that one sentinel package was inlined passed a build that inlined `effect` and left `yaml` and @effect/platform-node external. Both were reported as clean while the artifact was broken. After electron-builder runs, copy the packaged app.asar.unpacked into a scratch directory and run `node apps/server/dist/bin.mjs --version` there. Node either resolves every eagerly imported module or it does not, which is exactly the question, and the failure it prints is the one a WSL user would have hit. The copy matters: the stage has a node_modules of its own further up that would satisfy imports missing from the package. The probe refuses to run at all if a node_modules is visible above it, and if no unpacked directory is found, rather than reporting success it did not earn. Verified by breaking the build on purpose: inlining only `effect` fails with ERR_MODULE_NOT_FOUND for @effect/platform-node, and dropping neverBundle fails listing the four inlined externals. A correct build passes. Also adds a check for inlined packages that load native binaries, found by asking the pnpm store what each one is rather than consulting a list. bufferutil and utf-8-validate were being inlined from the dev store: both carry binding.gyp and prebuilds and load through node-gyp-build, and a loader inlined into a chunk searches for prebuilds that cannot be beside it. Neither is declared in this repo, so neither reaches the staged install and ws falls back to its JS paths regardless -- listing them keeps that from becoming real if either is ever declared. The dependency-closure test now reads optionalDependencies and peerDependencies as well. Every native family here declares its actual platform bindings there, so reading only `dependencies` checked nothing for exactly those packages. --- scripts/build-desktop-artifact.test.ts | 38 ++++ scripts/build-desktop-artifact.ts | 259 +++++++++++++++++++++- scripts/lib/cli-external-packages.test.ts | 13 +- scripts/lib/cli-external-packages.ts | 10 + 4 files changed, 318 insertions(+), 2 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 7d2b7410a9e..dcff251d910 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -43,6 +43,7 @@ import { stageLinuxIconSize, STAGE_INSTALL_ARGS, WINDOWS_ASAR_UNPACK, + ancestorNodeModulesPaths, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -767,3 +768,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); }); + +// The self-containment check runs the packaged tree in a scratch directory. Its +// own node_modules holds the unpacked externals and must be ignored, but any +// node_modules *above* it would let Node's parent walk satisfy an import that is +// missing from the package, so the probe refuses to run in that case. +it("lists ancestor node_modules, nearest first, excluding the start directory", () => { + assert.deepStrictEqual(ancestorNodeModulesPaths("C:\\tmp\\probe\\app", "\\"), [ + "C:\\tmp\\probe\\node_modules", + "C:\\tmp\\node_modules", + "C:\\node_modules", + ]); +}); + +it("includes the filesystem root for posix paths", () => { + assert.deepStrictEqual(ancestorNodeModulesPaths("/tmp/probe", "/"), [ + "/tmp/node_modules", + "/node_modules", + ]); +}); + +// A UNC root must keep its \\server\share prefix. Rebuilding it from segments +// produced relative paths, which fs.exists resolves against the build cwd, so +// the guard checked directories that do not exist and silently passed. +it("keeps the prefix of a UNC path instead of going relative", () => { + const paths = ancestorNodeModulesPaths("\\\\server\\share\\tmp\\app", "\\"); + for (const candidate of paths) { + assert.ok(candidate.startsWith("\\\\server\\share"), candidate); + } + assert.deepStrictEqual(paths[0], "\\\\server\\share\\tmp\\node_modules"); +}); + +it("ignores trailing separators", () => { + assert.deepStrictEqual( + ancestorNodeModulesPaths("C:\\tmp\\probe\\app\\", "\\"), + ancestorNodeModulesPaths("C:\\tmp\\probe\\app", "\\"), + ); +}); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index d58ead37053..c5df1647379 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -27,6 +27,7 @@ import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -386,6 +387,8 @@ const desktopBuildInputArtifactNames = { */ const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; +const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); + export class ExternalizedBundleError extends Schema.TaggedErrorClass()( "ExternalizedBundleError", { sentinel: Schema.String, inlinedPackageCount: Schema.Number }, @@ -395,6 +398,25 @@ export class ExternalizedBundleError extends Schema.TaggedErrorClass()( + "BundleNotSelfContainedError", + { exitCode: Schema.Number, output: Schema.String }, +) { + override get message(): string { + return `The packaged server bundle could not load with only its unpacked dependencies present (exit ${this.exitCode}). Anything it imports that is neither a Node built-in nor an unpacked external is unreachable to the WSL backend, which runs plain node and cannot read app.asar. Output: +${this.output}`; + } +} + +export class InlinedNativePackageError extends Schema.TaggedErrorClass()( + "InlinedNativePackageError", + { packages: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `The server bundle inlined packages that load native binaries: ${this.packages.join(", ")}. A node-gyp-build style loader resolves prebuilds relative to its own file, so inlined into a chunk it finds none and the importer quietly falls back to a slower JS path. Add them to CLI_RUNTIME_EXTERNAL_PREFIXES in scripts/lib/cli-external-packages.ts so they stay external and get unpacked.`; + } +} + export class InlinedExternalPackageError extends Schema.TaggedErrorClass()( "InlinedExternalPackageError", { packages: Schema.Array(Schema.String) }, @@ -1215,6 +1237,212 @@ const runCommand = Effect.fn("runCommand")(function* ( } }); +/** + * Every `node_modules` directory that would be visible from `startDir`. + * + * The self-containment check is only meaningful in a directory with none of + * these: Node walks parents when resolving a bare import, so a stray + * node_modules above the probe would satisfy imports that are missing from the + * packaged tree and turn the check into a silent pass. + */ +function trimTrailingSeparators(value: string): string { + let end = value.length; + while (end > 1 && (value[end - 1] === "/" || value[end - 1] === "\\")) end -= 1; + return value.slice(0, end); +} + +/** + * Length of the `\\server\share` prefix, or 0 when the path is not UNC. + * + * The share is the highest real directory on a UNC path: `\\server` on its own + * is not one, so the ancestor walk must stop there. + */ +function uncShareRootLength(value: string): number { + const isUnc = value.startsWith("\\\\") || value.startsWith("//"); + if (!isUnc) return 0; + const separator = /[\\/]/; + const serverEnd = value.slice(2).search(separator); + if (serverEnd < 0) return value.length; + const shareStart = 2 + serverEnd + 1; + const shareEnd = value.slice(shareStart).search(separator); + return shareEnd < 0 ? value.length : shareStart + shareEnd; +} + +export function ancestorNodeModulesPaths( + startDir: string, + separator: string, +): ReadonlyArray { + // Walks with lastIndexOf rather than splitting into segments so UNC roots + // (\\server\share) and drive roots keep their prefix instead of being + // rebuilt into a relative path that silently resolves against the build cwd. + const paths: string[] = []; + let current = trimTrailingSeparators(startDir); + // On a UNC path the share itself is the root: \\server is not a directory, so + // walking past \\server\share would emit paths that cannot exist. + const uncRootLength = uncShareRootLength(current); + for (;;) { + const cut = Math.max(current.lastIndexOf("/"), current.lastIndexOf("\\")); + if (cut < 0 || (uncRootLength > 0 && cut < uncRootLength)) break; + const parent = cut === 0 ? current.slice(0, 1) : current.slice(0, cut); + if (parent === current) break; + paths.push( + parent.endsWith(separator) ? `${parent}node_modules` : `${parent}${separator}node_modules`, + ); + if (cut === 0) break; + current = parent; + } + return paths; +} + +const NativeMarkerManifest = Schema.Struct({ + dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); +const decodeNativeMarkerManifest = Schema.decodeUnknownSync( + Schema.fromJsonString(NativeMarkerManifest), +); + +/** Locate a package inside the pnpm store, which is where the real files live. */ +const findStorePackageDirectory = Effect.fn("findStorePackageDirectory")(function* ( + repoRoot: string, + packageName: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const storeDir = path.join(repoRoot, "node_modules/.pnpm"); + const exists = (candidate: string) => + fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!(yield* exists(storeDir))) return null; + + const flattened = `${packageName.replace("/", "+")}@`; + const entries = yield* fs + .readDirectory(storeDir) + .pipe(Effect.orElseSucceed(() => [] as string[])); + for (const entry of entries) { + if (!entry.startsWith(flattened)) continue; + const candidate = path.join(storeDir, entry, "node_modules", packageName); + if (yield* exists(candidate)) return candidate; + } + return null; +}); + +/** Whether a package builds or ships a native addon it loads at runtime. */ +const hasNativeLoaderMarkers = Effect.fn("hasNativeLoaderMarkers")(function* (packageDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = (candidate: string) => + fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + + if (yield* exists(path.join(packageDir, "binding.gyp"))) return true; + if (yield* exists(path.join(packageDir, "prebuilds"))) return true; + + const manifestPath = path.join(packageDir, "package.json"); + if (!(yield* exists(manifestPath))) return false; + const source = yield* fs.readFileString(manifestPath).pipe(Effect.orElseSucceed(() => "")); + if (source === "") return false; + const manifest = yield* Effect.try(() => decodeNativeMarkerManifest(source)).pipe( + Effect.orElseSucceed(() => null), + ); + if (manifest === null) return false; + return Object.keys({ ...manifest.dependencies, ...manifest.optionalDependencies }).some( + (dependency) => dependency.startsWith("node-gyp-build"), + ); +}); + +const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( + function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // electron-builder names this win-unpacked, win-arm64-unpacked, and so on. + const distEntries = yield* fs + .readDirectory(input.stageDistDir) + .pipe(Effect.orElseSucceed(() => [] as Array)); + let unpackedRoot: string | null = null; + for (const entry of distEntries) { + const candidate = path.join(input.stageDistDir, entry, "resources/app.asar.unpacked"); + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + unpackedRoot = candidate; + break; + } + } + // Nothing to verify rather than silently passing: a packaging layout change + // should surface here instead of turning the check into a no-op. + if (unpackedRoot === null) { + return yield* new BundleNotSelfContainedError({ + exitCode: -1, + output: `No */resources/app.asar.unpacked directory under ${input.stageDistDir}; the bundle self-containment check found nothing to verify.`, + }); + } + + const probeRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-bundle-selfcheck-", + }); + const probeApp = path.join(probeRoot, "app"); + yield* fs.copy(unpackedRoot, probeApp); + + // Guard the guard: if anything above the probe provides a node_modules, a + // missing dependency would resolve there and the check would pass while the + // packaged tree is broken. + for (const candidate of ancestorNodeModulesPaths(probeApp, path.sep)) { + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return yield* new BundleNotSelfContainedError({ + exitCode: -1, + output: `Refusing to report success: ${candidate} is visible from the probe directory, so bare imports could resolve outside the packaged tree. Remove or rename it, or point TMPDIR somewhere without one.`, + }); + } + } + + const entryPoint = path.join(probeApp, "apps/server/dist/bin.mjs"); + if (!(yield* fs.exists(entryPoint).pipe(Effect.orElseSucceed(() => false)))) { + return yield* new BundleNotSelfContainedError({ + exitCode: -1, + output: `Expected the server entry at ${entryPoint}.`, + }); + } + + // --version exercises the eagerly loaded module graph, which is where a + // missing dependency shows up, without starting a server or touching disk + // state. It does not cover lazily imported externals: node-pty is checked + // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node + // and the bun adapters are only covered by the unpack globs and the + // inlined-native check below. + yield* runCommand( + ChildProcess.make(process.execPath, [entryPoint, "--version"], { + cwd: probeApp, + stdout: "pipe", + stderr: "pipe", + // NODE_PATH would let a createRequire call inside the bundle resolve a + // missing external from outside the packaged tree, which is the whole + // thing this is trying to rule out. + env: { ...process.env, NODE_PATH: "" }, + }), + { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose }, + ).pipe( + // Printing a version should be immediate. A regression that blocks (on + // stdin, a port, a lock) would otherwise hang release CI until the job + // times out with nothing useful in the log. + Effect.timeout(BUNDLE_SELF_CHECK_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new BundleNotSelfContainedError({ + exitCode: -1, + output: `The packaged bundle did not print its version within ${Duration.toSeconds(BUNDLE_SELF_CHECK_TIMEOUT)}s; it is hanging rather than failing to resolve.`, + }), + ), + ), + Effect.catchTag("BuildCommandFailedError", (error) => + Effect.fail( + new BundleNotSelfContainedError({ + exitCode: error.exitCode, + output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(), + }), + ), + ), + ); + }, +); + const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: { readonly repoRoot: string; readonly stageResourcesDir: string; @@ -1893,7 +2121,21 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // whole change exists to prevent, because those packages are not in the // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND. // `effect` is imported by every server module, so it is inlined in any - // correctly bundled build (209 regions at the time of writing). + // correctly bundled build. + // The list-based check above only sees packages someone already thought to + // list. bufferutil and utf-8-validate were inlined for exactly that reason: + // native, but absent from the list, so nothing flagged them. Ask the store + // what each inlined package actually is instead. + const nativeInlined: string[] = []; + for (const name of [...inlinedPackages].sort()) { + const packageDir = yield* findStorePackageDirectory(repoRoot, name); + if (packageDir === null) continue; + if (yield* hasNativeLoaderMarkers(packageDir)) nativeInlined.push(name); + } + if (nativeInlined.length > 0) { + return yield* new InlinedNativePackageError({ packages: nativeInlined }); + } + if (!inlinedPackages.has(BUNDLE_SELF_CONTAINED_SENTINEL)) { return yield* new ExternalizedBundleError({ sentinel: BUNDLE_SELF_CONTAINED_SENTINEL, @@ -2141,6 +2383,21 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); } + // Prove the packaged bundle is self-contained by loading it the way the WSL + // backend does, rather than by reasoning about the emitted source. + // + // Static analysis kept getting this wrong here. Scanning for bare imports + // matched specifiers inside effect's JSDoc examples and inside ajv's runtime + // codegen template, and asserting that one sentinel package was inlined + // missed a build that inlined `effect` while leaving `yaml` external. Node's + // resolver has no such ambiguity: it either finds every import or it does not. + // + // Only Windows unpacks anything; macOS and Linux keep the whole tree inside + // the asar, where this check has nothing to look at. + if (options.platform === "win") { + yield* verifyPackagedBundleIsSelfContained({ stageDistDir, verbose: options.verbose }); + } + const stageEntries = yield* fs.readDirectory(stageDistDir); yield* fs.makeDirectory(options.outputDir, { recursive: true }); diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 15b636c230d..189634dfee6 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -16,8 +16,14 @@ import { } from "./cli-external-packages.ts"; // Only the field this test cares about; decoding ignores everything else. +// optionalDependencies matter as much as dependencies here: every native family +// in the list declares its actual platform bindings there (ffi-rs -> @yuuang/*, +// msgpackr-extract -> @msgpackr-extract/*, fff-node -> @ff-labs/fff-bin-*), so +// reading only `dependencies` would check nothing for exactly those packages. const PackageManifest = Schema.Struct({ dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), }); type PackageManifest = typeof PackageManifest.Type; @@ -179,7 +185,12 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { const manifest = installed.get(name); if (!manifest) continue; - for (const dependency of Object.keys(manifest.dependencies ?? {})) { + const declared = { + ...(manifest.dependencies ?? {}), + ...(manifest.optionalDependencies ?? {}), + ...(manifest.peerDependencies ?? {}), + }; + for (const dependency of Object.keys(declared)) { if (!isRuntimeExternal(dependency)) { violations.push(`${name} -> ${dependency}`); } diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index def27f5520f..f50718af4fe 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -40,6 +40,16 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ // Required by node-gyp-build-optional-packages. Not native, but in the // closure: without it, WSL gets MODULE_NOT_FOUND while Windows is fine. "detect-libc", + // ws's optional accelerators. Nothing in this repo declares them, so they are + // not in the staged production install and the packaged app does not ship + // them either way -- ws wraps the require in try/catch and falls back to its + // JS paths. They are listed because they were being inlined from the dev + // store: both carry binding.gyp and prebuilds and load through + // node-gyp-build, and a native loader inlined into a bundle chunk searches + // for prebuilds that cannot be beside it. Listing them keeps that from + // becoming real if either is ever declared as a dependency. + "bufferutil", + "utf-8-validate", ] as const; /** From 6a1b459eca06de813d2cf3b9a75c14c025a9b03b Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Wed, 12 Aug 2026 19:39:04 -0400 Subject: [PATCH 10/10] fix(build): isolate the self-check from Node's global module folders Clearing NODE_PATH does not isolate CommonJS resolution. Node still falls back to $HOME/.node_modules, $HOME/.node_libraries and the install prefix, so a globally installed copy of a dependency missing from the package would satisfy the probe and the check would report success on a broken artifact. Reproduced by putting a package in %USERPROFILE%\.node_modules: with NODE_PATH cleared it still resolved; with --no-global-search-paths it does not. Reported by @SunkenInTime. --- scripts/build-desktop-artifact.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index c5df1647379..19f26b69cdb 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1408,15 +1408,23 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // and the bun adapters are only covered by the unpack globs and the // inlined-native check below. yield* runCommand( - ChildProcess.make(process.execPath, [entryPoint, "--version"], { - cwd: probeApp, - stdout: "pipe", - stderr: "pipe", - // NODE_PATH would let a createRequire call inside the bundle resolve a - // missing external from outside the packaged tree, which is the whole - // thing this is trying to rule out. - env: { ...process.env, NODE_PATH: "" }, - }), + ChildProcess.make( + process.execPath, + // --no-global-search-paths because clearing NODE_PATH is not enough: + // CommonJS resolution still falls back to $HOME/.node_modules, + // $HOME/.node_libraries and the install prefix, so a globally installed + // copy of a missing dependency would quietly satisfy this check. + ["--no-global-search-paths", entryPoint, "--version"], + { + cwd: probeApp, + stdout: "pipe", + stderr: "pipe", + // NODE_PATH would let a createRequire call inside the bundle resolve + // a missing external from outside the packaged tree, which is the + // whole thing this is trying to rule out. + env: { ...process.env, NODE_PATH: "" }, + }, + ), { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose }, ).pipe( // Printing a version should be immediate. A regression that blocks (on