Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. It drives its own Playwright-managed Chromium in the background, which is why its installer is large and why I have written about getting that browser into the package before: Shipping Playwright's Chromium inside a packaged Electron app.
This post is about a smaller thing in the same package.json: the build.files array that decides what ends up inside the app. It is 42 entries long. Nine of them say what to include. Thirty-three say what to leave out. Twenty-five of those thirty-three name a path inside pnpm's content store.
That ratio is the interesting part, and it is a consequence of one unavoidable line.
The line that causes everything else
"files": [
"dist-ts/**/*",
"public/**/*",
"assets/**/*",
"config.json",
"data",
"browser-data",
"playwright-browsers/**/*",
"node_modules/**/*",
"package.json",
...
]
node_modules/**/* is there because Playwright has to ship. Not the browser, which is staged separately into playwright-browsers/, but the Node package that knows how to launch it. The app calls chromium.launchPersistentContext() at runtime, in the packaged build, on a user's machine. If playwright is not in the bundle there is nothing to call.
The moment you write that glob, you have declared that your dependency tree is part of your product. On npm that is merely wasteful. On pnpm it is something else.
What node_modules is under pnpm
pnpm does not copy packages into node_modules. It writes a store at node_modules/.pnpm/ where every version of every package lives exactly once, and then node_modules/<name> is a symlink into it. Only your direct dependencies get a top-level link. Everything else, transitive packages and dev dependencies alike, lives in the store.
So node_modules/**/* does not mean "my dependencies". It means the store: TypeScript, Vite, esbuild, Rollup, Tailwind, electron-builder itself, and a copy of Electron that the packaging step is going to supply from somewhere else anyway.
Hence the negations, and hence their shape:
"!node_modules/.pnpm/typescript*/**/*",
"!node_modules/.pnpm/vite*/**/*",
"!node_modules/.pnpm/@vitejs*/**/*",
"!node_modules/.pnpm/esbuild*/**/*",
"!node_modules/.pnpm/@esbuild*/**/*",
"!node_modules/.pnpm/rollup*/**/*",
"!node_modules/.pnpm/@rolldown*/**/*",
"!node_modules/.pnpm/electron-builder*/**/*",
"!node_modules/.pnpm/electron-winstaller*/**/*",
"!node_modules/.pnpm/app-builder-lib*/**/*",
"!node_modules/.pnpm/dmg-builder*/**/*",
"!node_modules/.pnpm/@electron-internal*/**/*",
"!node_modules/.pnpm/rimraf*/**/*",
"!node_modules/.pnpm/tsx*/**/*",
"!node_modules/.pnpm/tailwindcss*/**/*",
"!node_modules/.pnpm/@tailwindcss*/**/*",
"!node_modules/.pnpm/lightningcss*/**/*",
"!node_modules/.pnpm/postcss*/**/*",
"!node_modules/.pnpm/sharp*/**/*",
"!node_modules/.pnpm/@img*/**/*",
"!node_modules/.pnpm/@types*/**/*",
"!node_modules/.pnpm/jiti*/**/*",
"!node_modules/.pnpm/glob*/**/*",
"!node_modules/.pnpm/node-gyp*/**/*",
"!node_modules/.pnpm/electron@*/**/*"
Every pattern ends in */**/* rather than naming a directory, because a store entry is not typescript, it is typescript@5.9.3, and the next upgrade renames it. Every pattern starts at .pnpm/ rather than at node_modules/<name>, because excluding the symlink would leave the thing it points at in the bundle.
Two of them are worth reading twice. !node_modules/.pnpm/electron@*/**/* excludes Electron, the runtime this app is built on, because electron-builder downloads and embeds its own copy and the one in the store would be a second one for nobody. And !node_modules/.pnpm/@types*/**/* exists because type definitions are .d.ts files, which are not .ts files as far as the following glob is concerned:
"!**/*.map",
"!**/*.ts",
"!**/test/**",
"!**/tests/**",
"!**/__tests__/**",
"!**/CHANGELOG*",
"!**/README*",
"!**/LICENSE*"
!**/*.ts does catch .d.ts, since the glob is a suffix match on the filename. The @types exclusion is still not redundant, because a type-definition package is not only its .d.ts files: each one also has a package.json, a licence and a readme, and the directories holding them. Excluding the store entry outright is one line instead of trusting four other lines to cover everything inside it.
This list is a changelog
A list of exclusions written this way is a record of discoveries, not a design. Nobody sits down and types out @rolldown, lightningcss and node-gyp from first principles. Each line is there because somebody looked inside a packaged app, or at an installer size, and found something that had no business being there.
Which makes the maintenance problem obvious: the list is a denylist, and a denylist is wrong by default every time a dependency is added. Adding one build-time package to devDependencies silently adds its whole subtree to the next installer, and nothing fails. The only symptom is a number in megabytes that nobody is watching.
The honest alternative is an allowlist, naming the handful of packages the runtime actually needs: playwright, express, dotenv, tldts, react, react-dom, and whatever they pull in. That is harder to write, because getting it wrong breaks the app rather than inflating it, and the failure arrives on a user's machine rather than on mine. For now the trade is deliberate: a build that is too big fails quietly, and a build that is too small fails loudly in the wrong place.
The other half: what cannot be packed
Alongside files there is a second list, and the two are in tension:
"asarUnpack": [
"node_modules/playwright/**/*",
"playwright-browsers/**/*",
"config.json",
"data",
"browser-data"
]
An asar is a single archive the app reads through a virtual filesystem. That works for JavaScript. It does not work for an executable, because the operating system cannot launch a process from inside an archive, and it does not work for a browser profile, because Chromium wants a real directory it can write to.
So the largest thing in the installer is also the one thing archiving cannot help with. Everything else gets packed, and roughly a gigabyte of Chromium sits beside the archive in plain view. You can see the result on the download page: one Windows installer and two Mac disk images, because a universal Mac build would contain two of those browsers rather than one. That decision has its own post, Two DMGs, because a universal build would ship two Chromiums.
Three of the nine includes do not exist
Here is the detail I did not expect when I counted the array. Of the nine include patterns, only two name something that is in the repository: assets/**/* and package.json.
Three are build outputs, all gitignored: dist-ts/**/* from tsc, public/**/* from Vite, playwright-browsers/**/* from the browser staging step. One is installed: node_modules/**/*.
And three name paths that are not there at all. There is no config.json, no data/ and no browser-data/ in app/. They were there once, when the app kept its state next to itself. All writable state moved to the OS user-data directory, because inside a packaged asar the application directory is read-only:
/**
* Inside a packaged asar, __dirname is read-only. All writable data must live
* in app.getPath('userData') which resolves to:
* Windows: %APPDATA%\Notifio
* macOS: ~/Library/Application Support/Notifio
*/
export const USER_DATA_DIR = app.getPath('userData');
export const DATA_DIR = path.join(USER_DATA_DIR, 'data');
export const BROWSER_DATA_DIR = path.join(USER_DATA_DIR, 'browser-data');
export const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json');
The three entries in files and asarUnpack are the shadows of the old layout. electron-builder does not complain about a glob that matches nothing, so they have been packing nothing, quietly, for months.
The one place it still shows is first-run setup, which prefers a bundled config and then falls back:
if (!fs.existsSync(CONFIG_PATH)) {
const bundledConfig = path.join(__dirname, '..', 'config.json');
if (fs.existsSync(bundledConfig)) {
fs.copyFileSync(bundledConfig, CONFIG_PATH);
} else {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf8');
}
}
The branch that runs on every install is the else. The if is dead, and it reads like the primary path.
What I would tell someone starting this
If you are packaging an Electron app with pnpm and you need node_modules in the bundle:
- Expect
node_modules/**/*to mean the store, not your dependency list. - Write exclusions against
.pnpm/<name>*and not against the top-level symlink. - Unpack only what has to be a real file on disk, and accept that it is the big part.
- Count your includes occasionally. Mine outnumbered the things that exist.
If you want to see what all of this produces, the installers are on the download page, and what the app does with them is on the help page and the per-site pages.
Top comments (0)