Skip to content

perf(build): stop unpacking node_modules wholesale from the Windows asar - #5877

Open
tsouth89 wants to merge 6 commits into
pingdotgg:mainfrom
tsouth89:perf/windows-installer-file-count
Open

perf(build): stop unpacking node_modules wholesale from the Windows asar#5877
tsouth89 wants to merge 6 commits into
pingdotgg:mainfrom
tsouth89:perf/windows-installer-file-count

Conversation

@tsouth89

@tsouth89 tsouth89 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes the install-time and cold-start half of #5876.

What Changed

WINDOWS_ASAR_UNPACK was ["apps/server/dist/**", "**/node_modules/**"]. This inverts the CLI bundler's dependency rule — bundle everything except the packages that genuinely cannot be inlined — and narrows asarUnpack to exactly that set.

A package earns an exemption for one of two reasons:

  • Native addons. A .node binary cannot be inlined into JS and must sit on disk for both the Windows primary and the Linux Node inside WSL. The JS wrappers that dlopen them count too (ffi-rs, @ff-labs/fff-node, msgpackr-extract, node-gyp-build), since they resolve their binary by real filesystem path at runtime.
  • Bun-only entry points. @effect/platform-bun and @effect/sql-sqlite-bun are reached through a runtime-conditional dynamic import and resolve bun:sqlite, which does not exist when bundling for Node.

Both consumers now derive from one list in scripts/lib/cli-external-packages.ts, so they cannot drift.

Why

The Windows installer writes 14,687 files, 13,875 of them loose node_modules files, to support 20 native binaries. The entire Electron runtime is 22 files because it stays inside the archive.

That count costs twice: NSIS install time tracks file count, not bytes; and each file is a separate open/stat/scan the first time the server runs after an install, when the file cache is cold and the on-access scanner is not.

Measured on this repo, win/nsis x64:

before after
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, extracting each build's payload to a fresh directory so the files had never been read, alternating run order between builds:

before after
server boot to Listening on 9,044ms / 10,160ms 3,667ms / 3,779ms
module load only (--version) 6,521 / 6,238 / 6,208ms 761 / 659 / 654ms

The main window is not created until the backend answers HTTP, so that ~6s comes off a cold launch.

Why one shared list

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. That asymmetry makes the drift invisible on the platform you are most likely to test on.

node-gyp-build-optional-packages hit exactly this while I was writing the patch — matched as external by the node-gyp-build prefix, missed by a glob without a trailing wildcard. There are tests for the invariant.

Verification

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.

scripts/build-desktop-artifact.test.ts (30) and the new scripts/lib/cli-external-packages.test.ts (7) pass. vp lint and @t3tools/server typecheck are clean.

One caveat on my verification: the build warned No WSL node-pty prebuild provided, so I exercised the WSL module resolution path with Linux-shaped constraints rather than a real WSL launch. Happy to rerun with a Linux pty.node prebuild if you want that closed before merging.

UI Changes

None.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (n/a — no UI change)
  • I included a video for animation/interaction changes (n/a — no motion change)

Note

Medium Risk
Desktop packaging and bundling policy changes can break WSL if a native or dlopen-dependent package is omitted from the shared external list; tests guard the external dependency closure but regressions would mainly surface on WSL, not the Windows primary backend.

Overview
Narrows Windows desktop packaging by inverting the server CLI bundler: JS runtime deps are inlined into apps/server/dist, and only packages that cannot be bundled (native addons, dlopen wrappers, and their on-disk JS closure, plus Bun-only entry points) stay external.

Adds scripts/lib/cli-external-packages.ts as the single source of truth for shouldBundleCliDependency (used by apps/server/vite.config.ts) and CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS (used by WINDOWS_ASAR_UNPACK in scripts/build-desktop-artifact.ts), replacing wholesale **/node_modules/** asar unpack. New tests assert unpack globs match external prefixes and that runtime dependencies of external packages are not bundled away (a failure mode that only shows under WSL).

Updates WSL preflight in DesktopWslEnvironment.ts to resolve node-pty/package.json instead of effect when validating unpacked server deps, with matching user-facing error text for exit code 3.

Reviewed by Cursor Bugbot for commit bfd6089. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Stop unpacking all of node_modules from the Windows asar by bundling most CLI dependencies

  • Introduces cli-external-packages.ts as a single source of truth for which packages stay external at runtime (native addons like node-pty, ffi-rs, FFI helpers) vs. build-only externals (bun-only entry points).
  • Changes WINDOWS_ASAR_UNPACK in build-desktop-artifact.ts from unpacking **/node_modules/** wholesale to unpacking only apps/server/dist/** and globs derived from the external package list.
  • Shares shouldBundleCliDependency between the desktop build script and vite.config.ts, replacing the local implementation in vite config.
  • Updates the WSL health probe in DesktopWslEnvironment.ts to resolve node-pty/package.json instead of effect, targeting native packages that are still unpacked on disk.
  • Behavioral Change: the asar now contains bundled JS dependencies instead of raw node_modules; only native addons and their wrappers are unpacked on the host filesystem.

Macroscope summarized bfd6089.

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.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a5b6143-368f-4823-950d-ec575d1da4ed

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Aug 9, 2026
@macroscopeapp

macroscopeapp Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR changes the packaging strategy from unpacking all node_modules to only specific native packages. While well-tested with dependency closure verification, it affects runtime file availability with platform-specific failure modes (works on Windows, can fail on WSL) that warrant verification.

You can customize Macroscope's approvability policy. Learn more.

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.
@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Aug 9, 2026
@tsouth89

tsouth89 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Fair verdict, and the concern was the right one — so I went and tested it under real WSL. It found a bug. Pushed a fix in 0aacacd.

What broke. node-gyp-build-optional-packages is external, so it loads from the real filesystem, so its own require resolves from the real filesystem too. It requires detect-libc, which was not on my list and therefore got bundled — present only inside app.asar. The Windows primary reads that transparently under ELECTRON_RUN_AS_NODE and resolves it fine. Plain node under WSL cannot. msgpackr-extract failed through the same chain.

Measured on Ubuntu 24.04 with Linux node v24.18.0, against the packaged tree copied out of the NSIS payload:

before:
  MISSING (cjs) msgpackr-extract [MODULE_NOT_FOUND] Cannot find module 'detect-libc'
  MISSING (cjs) node-gyp-build-optional-packages [MODULE_NOT_FOUND] Cannot find module 'detect-libc'
  PROBE-RESULT: 2 unresolved

after:
  OK   (cjs) msgpackr-extract
  OK   (cjs) ffi-rs
  OK   (cjs) node-gyp-build-optional-packages
  OK   (esm) @ff-labs/fff-node
  PROBE-RESULT: no resolution failures

t3 --version runs from that tree under Linux node in both cases.

Why the list alone was never going to be enough. The real invariant is that an external package's entire runtime dependency closure must be external. detect-libc isn't native and doesn't look special; no amount of reading the list surfaces it. So it's now a test, not a convention: it walks each runtime-external package's declared dependencies transitively and fails if any would be bundled away. It reproduces this exact failure on the old list.

Writing that test surfaced a distinction the single list had flattened, which I've now made explicit:

  • Runtime-external (native addons, their dlopen wrappers, and their closure) — Node loads these from disk, so the closure must be external.
  • Build-only external (@effect/platform-bun, @effect/sql-sqlite-bun) — external purely so the bundler never resolves bun:*. Node never loads them, so their closure genuinely doesn't need to be external. The closure test skips them deliberately.

On node-pty. It still fails to load under WSL, but that is pre-existing and unrelated: this build was produced without --wsl-prebuild, which the build itself warns about, and node_modules/node-pty/prebuilds/ contains only darwin-arm64, darwin-x64, win32-arm64, win32-x64 in both the baseline and the patched build. Identical either way, so nothing here changes it. Resolution succeeds; it's the native binary for the platform that's absent.

Cost of the fix: 6 files (1,192 → 1,198). Native binaries and installer size unchanged.

Happy to squash the two commits if you'd prefer a single one.

Comment thread scripts/lib/cli-external-packages.test.ts Outdated
The guard added in the previous commit could pass without checking anything.
It resolved manifests with `require("<name>/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.
@tsouth89

tsouth89 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed, both points. Good catch — the guard was worse than useless, because it reported success. Fixed in 45b4517.

Point 1 is worse than "can pass". I probed it rather than reasoning about it. From scripts/lib, every seed failed:

FAILS  node-pty MODULE_NOT_FOUND
FAILS  msgpackr-extract MODULE_NOT_FOUND
FAILS  ffi-rs MODULE_NOT_FOUND
FAILS  node-gyp-build MODULE_NOT_FOUND
FAILS  detect-libc MODULE_NOT_FOUND
FAILS  node-addon-api MODULE_NOT_FOUND
FAILS  @ff-labs/fff-node ERR_PACKAGE_PATH_NOT_EXPORTED

From apps/server only its direct dependencies resolve; the transitive packages that actually caused the WSL breakage still don't. And ERR_PACKAGE_PATH_NOT_EXPORTED is a second hole the same catch swallowed — an exports map can refuse the /package.json subpath even when the package is right there.

The tell I missed at the time: when the guard first ran it reported violations only from the @effect/platform-bun chain, and never node-gyp-build-optional-packages -> detect-libc — the bug it was written for. It never read that package.

Point 2 confirmed. !prefix.endsWith("/") dropped @yuuang/, @ff-labs/ and @msgpackr-extract/ entirely. Seeding a queue with prefix strings was wrong anyway — a prefix isn't a package name.

Fix. Manifests are now read off disk from the pnpm store, which is the same tree asarUnpack globs target: it reaches transitive packages and isn't subject to resolution or exports semantics. Seeds come from what's actually installed and matches a prefix, so scoped prefixes are covered.

Plus a guard test that fails unless node-pty, node-gyp-build-optional-packages and detect-libc are actually found, so a check that reads nothing can't report success again.

Verified by mutation, not assertion. Removing detect-libc from the list now fails with exactly the real bug:

these dependencies of external packages would be bundled away and fail to
resolve under WSL: node-gyp-build-optional-packages -> detect-libc

The previous version of the test passed with detect-libc removed. That's the difference.

39 tests pass across this file and build-desktop-artifact.test.ts; vp lint clean; @t3tools/server typecheck exits 0.

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.
@ikifar2012

Copy link
Copy Markdown

Hey @tsouth89,

Was having trouble launching this in WSL only mode ran it though an agent and found this

line 243 in apps/desktop/src/wsl/DesktopWslEnvironment.ts

needs to be updated from:

try { require.resolve("effect"); } catch (_e) { process.exit(3); }

to this

try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); }

Seems like the rest of the file is fine and after modification of that one line everything seems to be working as expected

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.
@tsouth89

Copy link
Copy Markdown
Contributor Author

Nice find, thanks. You're right about the cause: this branch inlines the server's JS deps into the bundle, so effect isn't on disk anymore and that probe was checking for something that no longer exists. Verified against the packaged tree, require.resolve("effect") fails while require.resolve("node-pty/package.json") resolves.

Pushed your fix in 2134d96. Used node-pty since it's external precisely because it can't be inlined, so it stays a valid sentinel either way. Also updated the two comments that still described the old behaviour, and the exit-3 message downstream.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2134d96. Configure here.

Comment thread apps/desktop/src/wsl/DesktopWslEnvironment.ts
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.
@tsouth89

Copy link
Copy Markdown
Contributor Author

Good catch, fixed in the latest push. Reworded it to name node-pty and the native packages that actually have to be unpacked, rather than effect.

Checked the rest of the file and the tests for other references to the old sentinel while I was in there. The only remaining mentions of effect are in the comment explaining why the sentinel changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants