Skip to content

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

Open
tsouth89 wants to merge 8 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 8 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
Changes Windows packaging and WSL module resolution; a bundler/unpack mismatch would break the WSL backend or silently lose native acceleration, though new build-time checks mitigate that.

Overview
Inverts CLI bundling so Windows asar unpacks only native externals, cutting install file count ~92% and cold-start latency.

The server bundle now inlines JS dependencies and leaves only native addons (and their loaders/closures) external. WINDOWS_ASAR_UNPACK drops the blanket **/node_modules/** glob in favor of targeted globs from a shared list in cli-external-packages.ts, used by both the bundler and the desktop packager so they cannot drift.

Adds build-time checks that scan emitted .mjs chunks and fail if externals were inlined or if the bundle stopped inlining JS deps (sentinel: effect). Updates the WSL preflight probe to resolve node-pty instead of effect, matching what remains unpacked.

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

Note

Stop unpacking all node_modules from the Windows asar by bundling JS deps inline

  • Introduces scripts/lib/cli-external-packages.ts to centralize which packages stay external (native addons, bun-only entry points) vs. bundled, replacing scattered ad-hoc logic.
  • Updates apps/server/vite.config.ts to inline all JS dependencies by default, keeping only declared native/bun-only packages external via neverBundle.
  • Replaces the broad **/node_modules/** asar unpack glob in scripts/build-desktop-artifact.ts with targeted globs covering only the server bundle and declared external packages.
  • Adds build-time verification that rejects builds where JS deps are externalized (missing sentinel effect) or native externals are inlined.
  • Updates the WSL preflight probe in apps/desktop/src/wsl/DesktopWslEnvironment.ts to check for node-pty/package.json instead of effect, since effect is now bundled.
  • Risk: the bundle correctness checks are new hard failures — any regression in bundler wiring will break the desktop build.

Macroscope summarized 1bac125.

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: 3d797042-503b-4ffd-b433-32937d35c196

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 fundamentally changes the Windows build's bundling strategy, introducing new package scanning logic, build-time validation, and external package handling. While well-documented and tested, the infrastructure complexity and subtle platform-dependent failure modes warrant human review.

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

@t3-code

t3-code Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🔴 blocker: alwaysBundle: shouldBundleCliDependency in apps/server/vite.config.ts does not force dependencies for which the predicate returns false to remain external.

As a result, transitive packages including msgpackr-extract, node-gyp-build-optional-packages, and detect-libc are still inlined in the generated bundle. Their native loader then resolves from the bundle directory and silently loses native acceleration.

I suggest using neverBundle for the external dependency list and adding a test against the emitted bundle, rather than only testing the dependency list.

Verified locally at bfd60896ebc4d9e8581153f72ee42b28bb815ea5:

  • the exact head built successfully
  • 39 targeted tests passed
  • the packages above are present in dist/bin.mjs

I could not run the packaged Windows + WSL workflow on this Linux host, so this is not end-to-end WSL verification.

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

Copy link
Copy Markdown
Contributor Author

You're right on all counts. Confirmed it against the emitted bundle before changing anything: detect-libc, msgpackr-extract and node-gyp-build-optional-packages were present as full inlined //#region ../../node_modules/.pnpm/... blocks in bin.mjs.

The mechanism is that alwaysBundle is a NoExternalFn, so returning false from it means "no opinion" rather than "keep external". The default then applies, and it differs by dependency kind: node-pty and @ff-labs/fff-node are declared dependencies of apps/server so they stayed external and the packaging looked fine, while the three above are transitive and got bundled. Wired the same list to neverBundle as you suggested. Rebuilt and checked: msgpackr-extract is external again, and detect-libc and node-gyp-build-optional-packages are gone from the bundle entirely.

Also added the artifact check. findInlinedExternalPackages scans the emitted chunks for inlined externals and the desktop build now fails on one. It returns the module region count too, so "nothing inlined" is distinguishable from "the marker format changed and the scan is blind", which is the failure mode my earlier list-based test had.

Worth correcting something I said earlier in this PR: the detect-libc fix I pushed was chasing a symptom my own probe created. My probe required msgpackr-extract from disk, but the server was loading the inlined copy, so that MODULE_NOT_FOUND was never a real runtime failure. Unpacking it is correct now that msgpackr-extract is genuinely external, but the reasoning I gave for it was wrong.

On end to end: I built a Linux pty.node from source in Ubuntu 24.04, packaged an installer with --wsl-prebuild, and ran the real paths under Linux node against the extracted tree.

preflight probe:   node-pty loaded from .../prebuilds/linux-x64
msgpackr-extract:  native LOADED, resolved from
                   ~/e2e/app.asar.unpacked/node_modules/msgpackr-extract/index.js
pty spawn:         "pty-works"
server boot:       Migrations ran successfully
                   Listening on http://127.0.0.1:39225
                   0 module resolution errors

So the accelerator loads from disk rather than falling back, and the WSL backend comes up on the platform this actually matters for.

Comment thread scripts/lib/cli-external-packages.ts
…ernals

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

Copy link
Copy Markdown
Contributor Author

Right, the check was one-directional. Fixed in 1bac125.

The scan now reports every package it found 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, and its absence means the dependencies went external again. That is the case you described, and the old check would have passed it.

I went looking for a general "every non-external dependency from package.json is inlined" assertion first, but a few declared deps legitimately never appear as regions once tree-shaken, so it would fail on correct builds. The sentinel avoids that without weakening the guarantee much.

Verified against the emitted bundle:

regions:              788
external violations:  none
effect inlined:       yes (209 regions)
inlined packages:     25

One thing worth mentioning from checking this: ajv's codegen emits require("ajv/dist/runtime/validation_error") and require("ajv-formats/dist/formats") as generated source strings. ajv itself is inlined, so those strings would only resolve if ajv were also on disk. I could not find a path that reaches them at runtime here, and the WSL server boots clean, but if you know that ajv standalone codegen is used anywhere I would rather add it to the external list than guess.

@t3-code

t3-code Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

the neverBundle fix works at current head, and the emitted bundle is correct now.

🔴 remaining blocker: scripts/build-desktop-artifact.ts:1895-1897

using only effect as the self-contained sentinel leaves a false negative for partial externalization. i reproduced this by changing alwaysBundle to inline only effect: the validator still passes because regions exist, no native external is inlined, and effect is present, while the emitted chunks retain bare imports including yaml and @effect/platform-node/*. those packages are not covered by the unpack globs, so that artifact is not self-contained for plain node under WSL.

check emitted bare imports instead: every non-builtin bare import should match the intentional external list. a bundler metafile would also work.

verified at 1bac1254:

  • current server build passes
  • 45 targeted tests pass
  • server and scripts typechecks pass
  • current emitted bundle has 664 module regions, 25 inlined packages, effect present, and no configured external package inlined
  • all github checks are green

this is a guard/test blocker, not evidence that the current emitted bundle is broken. i still did not run the packaged windows + WSL flow.

@t3-code

t3-code Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

remaining validation problem

what is the problem?

The emitted-bundle guard at scripts/build-desktop-artifact.ts:1895-1897 treats effect as the only proof that ordinary dependencies were bundled. A partially externalized build can still inline effect while leaving other dependencies, such as yaml or @effect/platform-node/*, as bare imports. That artifact passes the current guard.

why is it a problem?

Those ordinary dependencies are not included by the narrowed asarUnpack globs. Electron on Windows may still resolve them through asar handling, but plain Node under WSL cannot read them from app.asar. The WSL backend can therefore fail with ERR_MODULE_NOT_FOUND, even though the build-time validation reported success.

I reproduced the false negative by changing alwaysBundle to inline only effect. The guard passed while the emitted chunks retained bare imports for yaml and @effect/platform-node/*.

suggested fix

Validate all bare imports in every emitted server chunk. Allow only:

  • Node built-ins, including node:*;
  • packages in the intentional external-package list.

Fail the build for every other bare import. Using the bundler metafile to inspect external modules would be even more robust if it is available. Add a regression test where effect is inlined but another ordinary dependency remains external.

Current head 1bac1254fd86e7f9bcb40a5dbf2cd90405b72a50 emits a correct bundle. This blocker is that the guard does not reliably prevent a future partial-externalization regression.

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