Repository navigation
perf(build): stop unpacking node_modules wholesale from the Windows asar #5877
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
shivamhwp
merged 13 commits into
pingdotgg:main
from
btsouth:perf/windows-installer-file-count
Aug 14, 2026
Merged
Changes from 5 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
ba61d1f
perf(build): stop unpacking node_modules wholesale from the Windows asar
0aacacd
fix(build): keep the dependency closure of external packages external
45b4517
test(build): make the closure guard actually read the dependency closure
12a5bf0
fix(test): tolerate non-directory entries in the pnpm store
2134d96
fix(wsl): probe an external package, not one the bundle now inlines
bfd6089
fix(wsl): update the exit-3 reason text for the new sentinel
1e5a52a
fix(build): force external packages out of the bundle, and check the …
1bac125
test(build): check the bundle is self-contained, not just free of ext…
22731fd
build: verify the packaged bundle by running it, not by reading it
6a1b459
fix(build): isolate the self-check from Node's global module folders
4013ebd
fix(build): keep bundle probe symlinks isolated
t3-code[bot] cebcb55
test(build): canonicalize copied package paths
shivamhwp 2103eb6
Merge remote-tracking branch 'upstream/main' into perf/windows-instal…
shivamhwp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| 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, | ||
| CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, | ||
| CLI_RUNTIME_EXTERNAL_PREFIXES, | ||
| 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"]) { | ||
| 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*/**/*"); | ||
| }); | ||
| }); | ||
|
|
||
| // 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. | ||
| it.layer(NodeServices.layer)("external package dependency closure", (it) => { | ||
| // Read manifests off disk from the pnpm store rather than resolving them. | ||
| // `require("<name>/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", | ||
| ); | ||
|
|
||
| // 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<string, PackageManifest>(); | ||
| if (!(yield* isPresent(storeDir))) return installed; | ||
|
|
||
| for (const entry of yield* fileSystem.readDirectory(storeDir)) { | ||
| const modulesDir = path.join(storeDir, entry, "node_modules"); | ||
| if (!(yield* isPresent(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* isPresent(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.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<string>(); | ||
| // 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); | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
|
|
||
| assert.deepStrictEqual( | ||
| violations, | ||
| [], | ||
| `these dependencies of external packages would be bundled away and fail to resolve under WSL: ${violations.join(", ")}`, | ||
| ); | ||
| }), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| /** | ||
| * 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. | ||
| */ | ||
| /** | ||
| * 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/", | ||
| "@ff-labs/", | ||
| "@clerk/electron-passkeys", | ||
| "@msgpackr-extract/", | ||
| "msgpackr-extract", | ||
| "node-gyp-build", | ||
| "node-addon-api", | ||
| // 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; | ||
| 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, | ||
| ); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.