Electron Wrapper for Bun Web Apps
This skill guides wrapping an existing Bun web server into a native desktop app using Electron. It's based on a proven implementation that solved every major integration challenge.
Architecture
"Electron as chrome, Bun as server" — Two runtimes working together:
- Electron/Node.js handles window management, native menus, auto-updates, and IPC
- Bun runs the actual web server with all your application logic
The Electron main process spawns a bundled Bun binary that runs your server, then loads http://localhost:{port} in a BrowserWindow. Your web app doesn't know or care that it's inside Electron — it's just a web page with an optional window.electronAPI bridge for native features.
This architecture means:
- Zero changes to your server code (it's still a standard Bun HTTP server)
- The web app works identically in a browser or in Electron
- Electron handles only what browsers can't: window chrome, system tray, auto-updates, file system access
- Two separate
node_modules — Electron uses npm, your web app uses Bun
Phase 1: Project Setup
Create the Electron subproject alongside your existing Bun web app.
What to create:
electron/ directory with its own package.json (npm, not Bun), two tsconfigs (ESM for main, CJS for preload), electron-builder.yml, and macOS entitlements
scripts/build-server.ts for bundling the server
scripts/download-bun.ts for downloading platform-specific Bun binaries
- Parent project changes: new scripts, tsconfig excludes, .gitignore entries
Reference: project-setup.md
Phase 2: Main Process
Build the Electron main process — the entry point, server spawning, window management, auto-updater, and preload bridge.
Files to create:
| File |
Purpose |
electron/src/main/index.ts |
App lifecycle, dev/prod mode, single-instance lock, IPC handlers |
electron/src/main/bun-server.ts |
Spawn bundled Bun, port selection, health polling, env var injection |
electron/src/main/window.ts |
BrowserWindow config, bounds persistence, security settings, navigation guards |
electron/src/main/updater.ts |
electron-updater setup, event forwarding to renderer |
electron/src/preload/index.ts |
contextBridge API with invoke/on patterns and unsubscribe support |
Key decisions:
- Dev mode uses
ELECTRON_DEV_URL env var to connect to the external dev server (no internal Bun spawn)
autoDownload: false — let users choose when to download updates
- Preload exposes only specific methods, never raw
ipcRenderer
- Window persists bounds via
electron-store
Reference: main-process.md
Phase 3: Web App Adaptation
Adapt the existing web app to detect and respond to the Electron environment while remaining fully functional as a standalone web app.
Changes to the web app:
| Change |
Details |
| Electron detection utility |
isElectron(), getElectronPlatform(), isMacElectron(), applyElectronDocumentAttributes() |
| Type declarations |
window.electronAPI with all properties optional |
| CSS drag regions |
.app-window-drag/.app-window-no-drag classes, auto-exclude interactive elements |
| Traffic light spacing |
--electron-traffic-left CSS variable (72px on macOS, 0px elsewhere) |
| Storage path |
Env var for data directory, falling back to CWD |
| Static asset serving |
Env var for static dir in production mode |
| Auto-update hook |
useElectronUpdater() React hook with download/install controls |
| Update notification |
Pill component showing available → downloading → ready states |
| Feature gating |
Disable demo mode, hosted features when in Electron |
Reference: web-adaptation.md
Phase 4: Build & Distribution
Bundle everything, set up CI/CD, and handle code signing.
Build pipeline:
- Build web app (
bun run build)
- Bundle server to single file (
Bun.build() → resources/server/index.js)
- Download platform-specific Bun binaries →
resources/bun/{platform}-{arch}/
- Compile Electron TypeScript (two passes: main ESM + preload CJS)
- electron-builder packages everything with
extraResources
CI/CD:
- GitHub Actions triggered by version tags (e.g.,
v*, clippy-v*)
- Matrix builds: macOS arm64/x64 on
macos-14, Windows x64 on windows-latest
--publish never in build step, separate publish job creates draft GitHub release
- Apple certificate import and notarization in CI
Code signing:
- macOS: Developer ID Application certificate, exported as base64 .p12
- Notarization via Apple ID + app-specific password
- 5 GitHub secrets required:
APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID
Icons:
- macOS:
sips + iconutil from source PNG → .icns
- Windows:
png-to-ico npm package → .ico
Reference: build-and-distribute.md
Cutting a Release
Never build release artifacts locally. CI has the signing certificates and notarization credentials. Local builds produce unsigned apps that macOS Gatekeeper will block.
Release workflow:
- Bump version in
electron/package.json, commit, and merge to main
- Find the tag pattern the CI workflow expects:
grep -A2 'tags:' .github/workflows/*.yml
- Tag the merged commit on main:
git tag <pattern><version> origin/main
git push origin <pattern><version>
- Monitor CI:
gh run list --workflow=<workflow>.yml --limit=1
- Review and publish the draft release on GitHub
Common mistakes:
- Running
electron-builder --publish always locally — no notarization
- Using
gh release create with local artifacts — unsigned
- Tagging before the version bump is merged — wrong version in build
- Tagging a feature branch instead of
origin/main
See pitfalls.md §13 for full details.
Critical Pitfalls
Quick-reference list — see pitfalls.md for full details with symptoms and code examples.
| # |
Pitfall |
One-line fix |
| 1 |
ESM/CJS conflicts |
"type": "module" + default import pattern for CJS packages |
| 2 |
Preload must be CJS |
Separate tsconfig with "module": "CommonJS" |
| 3 |
__dirname unavailable |
fileURLToPath(import.meta.url) polyfill |
| 4 |
Dev mode MIME errors |
Connect to external dev server via ELECTRON_DEV_URL |
| 5 |
Bun version mismatch |
Pin version in download script, match dev version |
| 6 |
nvm PATH issues |
bash -lc for spawned processes |
| 7 |
Wrong storage path |
Env var + app.getPath("userData") |
| 8 |
White flash on open |
show: false + ready-to-show + dark backgroundColor |
| 11 |
${platform} != process.platform |
Put Bun extraResources in mac:/win: sections with darwin-/win32- prefixes |
| 12 |
Bun workspace hoists deps |
Bundle main with esbuild + createRequire banner, or use npm for electron dir |
| 13 |
Local builds aren't notarized |
Always release via CI tags, never electron-builder --publish locally |
Dev Workflow
The electron:dev command runs the full development environment:
npm run dev
├── concurrently
│ ├── dev:web → cd .. && bun run dev (Bun dev server with HMR)
│ └── dev:electron
│ ├── wait-on http://localhost:3005 (wait for dev server)
│ ├── npm run build (compile TS)
│ └── ELECTRON_DEV_URL=... electron . (launch Electron)
- The web dev server runs with HMR — changes reflect instantly
- Electron connects to the dev server instead of spawning its own Bun
- Preload and main process changes require restarting
electron:dev
- Web app changes hot-reload automatically
To test production-like behavior locally:
bun run electron:pack # builds everything, packages without installer
# Output in electron/release/
Customization Checklist
When adapting this for a new project, update these project-specific values:
1---2name: electron-wrapper3description: Wrap a Bun web app into an Electron desktop app with native window management, auto-updates, code signing, and CI/CD distribution. Use when the user wants to create a native desktop application from an existing Bun-based web server, package it for macOS/Windows, set up auto-updating, or handle Electron UX concerns like drag regions and traffic lights. Also use when cutting releases or tagging versions for Electron apps.4---5
6# Electron Wrapper for Bun Web Apps
7
8This skill guides wrapping an existing Bun web server into a native desktop app using Electron. It's based on a proven implementation that solved every major integration challenge.
9
10## Architecture
11
12**"Electron as chrome, Bun as server"** — Two runtimes working together:
13
14- **Electron/Node.js** handles window management, native menus, auto-updates, and IPC
15- **Bun** runs the actual web server with all your application logic
16
17The Electron main process spawns a bundled Bun binary that runs your server, then loads `http://localhost:{port}` in a `BrowserWindow`. Your web app doesn't know or care that it's inside Electron — it's just a web page with an optional `window.electronAPI` bridge for native features.
18
19This architecture means:
20- Zero changes to your server code (it's still a standard Bun HTTP server)
21- The web app works identically in a browser or in Electron
22- Electron handles only what browsers can't: window chrome, system tray, auto-updates, file system access
23- Two separate `node_modules` — Electron uses npm, your web app uses Bun
24
25## Phase 1: Project Setup
26
27Create the Electron subproject alongside your existing Bun web app.
28
29**What to create:**
30- `electron/` directory with its own `package.json` (npm, not Bun), two tsconfigs (ESM for main, CJS for preload), `electron-builder.yml`, and macOS entitlements
31- `scripts/build-server.ts` for bundling the server
32- `scripts/download-bun.ts` for downloading platform-specific Bun binaries
33- Parent project changes: new scripts, tsconfig excludes, .gitignore entries
34
35**Reference:** [project-setup.md](references/project-setup.md)
36
37## Phase 2: Main Process
38
39Build the Electron main process — the entry point, server spawning, window management, auto-updater, and preload bridge.
40
41**Files to create:**
42
43| File | Purpose |
44|------|---------|
45| `electron/src/main/index.ts` | App lifecycle, dev/prod mode, single-instance lock, IPC handlers |
46| `electron/src/main/bun-server.ts` | Spawn bundled Bun, port selection, health polling, env var injection |
47| `electron/src/main/window.ts` | BrowserWindow config, bounds persistence, security settings, navigation guards |
48| `electron/src/main/updater.ts` | electron-updater setup, event forwarding to renderer |
49| `electron/src/preload/index.ts` | contextBridge API with invoke/on patterns and unsubscribe support |
50
51**Key decisions:**
52- Dev mode uses `ELECTRON_DEV_URL` env var to connect to the external dev server (no internal Bun spawn)
53- `autoDownload: false` — let users choose when to download updates
54- Preload exposes only specific methods, never raw `ipcRenderer`
55- Window persists bounds via `electron-store`
56
57**Reference:** [main-process.md](references/main-process.md)
58
59## Phase 3: Web App Adaptation
60
61Adapt the existing web app to detect and respond to the Electron environment while remaining fully functional as a standalone web app.
62
63**Changes to the web app:**
64
65| Change | Details |
66|--------|---------|
67| Electron detection utility | `isElectron()`, `getElectronPlatform()`, `isMacElectron()`, `applyElectronDocumentAttributes()` |
68| Type declarations | `window.electronAPI` with all properties optional |
69| CSS drag regions | `.app-window-drag`/`.app-window-no-drag` classes, auto-exclude interactive elements |
70| Traffic light spacing | `--electron-traffic-left` CSS variable (72px on macOS, 0px elsewhere) |
71| Storage path | Env var for data directory, falling back to CWD |
72| Static asset serving | Env var for static dir in production mode |
73| Auto-update hook | `useElectronUpdater()` React hook with download/install controls |
74| Update notification | Pill component showing available → downloading → ready states |
75| Feature gating | Disable demo mode, hosted features when in Electron |
76
77**Reference:** [web-adaptation.md](references/web-adaptation.md)
78
79## Phase 4: Build & Distribution
80
81Bundle everything, set up CI/CD, and handle code signing.
82
83**Build pipeline:**
841. Build web app (`bun run build`)
852. Bundle server to single file (`Bun.build()` → `resources/server/index.js`)
863. Download platform-specific Bun binaries → `resources/bun/{platform}-{arch}/`
874. Compile Electron TypeScript (two passes: main ESM + preload CJS)
885. electron-builder packages everything with `extraResources`
89
90**CI/CD:**
91- GitHub Actions triggered by version tags (e.g., `v*`, `clippy-v*`)
92- Matrix builds: macOS arm64/x64 on `macos-14`, Windows x64 on `windows-latest`
93- `--publish never` in build step, separate publish job creates draft GitHub release
94- Apple certificate import and notarization in CI
95
96**Code signing:**
97- macOS: Developer ID Application certificate, exported as base64 .p12
98- Notarization via Apple ID + app-specific password
99- 5 GitHub secrets required: `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
100
101**Icons:**
102- macOS: `sips` + `iconutil` from source PNG → `.icns`
103- Windows: `png-to-ico` npm package → `.ico`
104
105**Reference:** [build-and-distribute.md](references/build-and-distribute.md)
106
107## Cutting a Release
108
109**Never build release artifacts locally.** CI has the signing certificates and notarization credentials. Local builds produce unsigned apps that macOS Gatekeeper will block.
110
111### Release workflow:
112
1131. **Bump version** in `electron/package.json`, commit, and merge to main
1142. **Find the tag pattern** the CI workflow expects:
115 ```bash
116 grep -A2 'tags:' .github/workflows/*.yml
117 ```
1183. **Tag the merged commit on main:**
119 ```bash
120 git tag <pattern><version> origin/main
121 git push origin <pattern><version>
122 ```
1234. **Monitor CI:**
124 ```bash
125 gh run list --workflow=<workflow>.yml --limit=1
126 ```
1275. **Review and publish** the draft release on GitHub
128
129### Common mistakes:
130- Running `electron-builder --publish always` locally — no notarization
131- Using `gh release create` with local artifacts — unsigned
132- Tagging before the version bump is merged — wrong version in build
133- Tagging a feature branch instead of `origin/main`
134
135See [pitfalls.md §13](references/pitfalls.md) for full details.
136
137## Critical Pitfalls
138
139Quick-reference list — see [pitfalls.md](references/pitfalls.md) for full details with symptoms and code examples.
140
141| # | Pitfall | One-line fix |
142|---|---------|-------------|
143| 1 | ESM/CJS conflicts | `"type": "module"` + default import pattern for CJS packages |
144| 2 | Preload must be CJS | Separate tsconfig with `"module": "CommonJS"` |
145| 3 | `__dirname` unavailable | `fileURLToPath(import.meta.url)` polyfill |
146| 4 | Dev mode MIME errors | Connect to external dev server via `ELECTRON_DEV_URL` |
147| 5 | Bun version mismatch | Pin version in download script, match dev version |
148| 6 | nvm PATH issues | `bash -lc` for spawned processes |
149| 7 | Wrong storage path | Env var + `app.getPath("userData")` |
150| 8 | White flash on open | `show: false` + `ready-to-show` + dark `backgroundColor` |
151| 11 | `${platform}` != `process.platform` | Put Bun `extraResources` in `mac:`/`win:` sections with `darwin-`/`win32-` prefixes |
152| 12 | Bun workspace hoists deps | Bundle main with esbuild + `createRequire` banner, or use npm for electron dir |
153| 13 | Local builds aren't notarized | Always release via CI tags, never `electron-builder --publish` locally |
154
155## Dev Workflow
156
157The `electron:dev` command runs the full development environment:
158
159```
160npm run dev
161 ├── concurrently
162 │ ├── dev:web → cd .. && bun run dev (Bun dev server with HMR)
163 │ └── dev:electron
164 │ ├── wait-on http://localhost:3005 (wait for dev server)
165 │ ├── npm run build (compile TS)
166 │ └── ELECTRON_DEV_URL=... electron . (launch Electron)
167```
168
169- The web dev server runs with HMR — changes reflect instantly
170- Electron connects to the dev server instead of spawning its own Bun
171- Preload and main process changes require restarting `electron:dev`
172- Web app changes hot-reload automatically
173
174To test production-like behavior locally:
175```bash
176bun run electron:pack # builds everything, packages without installer
177# Output in electron/release/
178```
179
180## Customization Checklist
181
182When adapting this for a new project, update these project-specific values:
183
184- [ ] App name in `electron-builder.yml` (`productName`, `appId`)
185- [ ] Window title in `window.ts`
186- [ ] Dev server port in `dev:electron` script and `dev` script
187- [ ] Environment variable names (e.g., `APP_DATA_DIR`, `APP_STATIC_DIR`)
188- [ ] `backgroundColor` in window config to match your app's theme
189- [ ] `category` in `electron-builder.yml` mac section
190- [ ] Repository URL in `electron/package.json`
191- [ ] Bun version in `download-bun.ts`
192- [ ] Icon assets in `electron/assets/`