@afterpack/electron obfuscates all three programs one Electron app ships (main, preload, renderer) from a single call, sharing one build seed across them. Wired by hand, each leg is its own bundler build and draws its own random seed, so the parts of one application come out structurally unrelated to each other.
electron-builder is not a bundler. It packages an already-built app into an installer and never touches your JavaScript, so it needs a bundler above it regardless of AfterPack.
Install
$ npm install -D @afterpack/electron// electron.vite.config.ts
import { withAfterpack } from "@afterpack/electron";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
export default withAfterpack(
defineConfig({
main: { plugins: [externalizeDepsPlugin()] },
preload: { plugins: [externalizeDepsPlugin()] },
renderer: {},
}),
);withAfterpack wraps an entire electron-vite config (the object, function or promise form defineConfig returns) and wires @afterpack/vite into every leg the config declares, never one it doesn't. Peer dependency: vite ^5 \| ^6 \| ^7 \| ^8.
Build
How you wire the pass depends on whether your toolchain configures all three legs in one call, or one config per leg.
One leg at a time
Toolchains that configure each leg separately (Electron Forge with @electron-forge/plugin-vite) call afterpackElectron once per config instead:
// vite.main.config.ts (and vite.preload.config.ts / vite.renderer.config.ts)
import { afterpackElectron } from "@afterpack/electron";
export default { plugins: [afterpackElectron({ leg: "main" })] };leg is required.
One seed, three ways
- electron-vite (
withAfterpack) and Forge +plugin-vite: all three configs build in one Node process, so the legs share a seed automatically. vite-plugin-electron: also one process. PlaceafterpackElectron({ leg: "renderer" })last inplugins, aftervite-plugin-electronitself, so AfterPack'sgenerateBundleseals the finished bundle.- Hand-rolled scripts that run each leg as a separate command: there is no shared process, so pin the seed across them with an environment variable:
// package.json
"build": "AFTERPACK_seed=git vite build -c vite.main.ts && vite build -c vite.renderer.ts"AFTERPACK_seed takes the same values as seed: an integer, "git" (derived from HEAD), or any other string, hashed by the engine. An explicit seed option still wins over it. Seed strategy covers when to pin one and when not to.
Verify
The Protection Map is written per leg, into .afterpack/<leg>/protectionMap.html, when a source map was discoverable and the build isn't running under NODE_ENV=production / CI=true. Inspect main, preload and renderer separately. Each report contains your full source. Never commit it or serve it.
A .map in app.asar reaches your original source. Keep source-map emission off (the default in production) for anything you package.
Options
Every configuration key works here too, applied to every leg through withAfterpack(config, options) or forwarded per leg through afterpackElectron(options). These options exist only on this package:
| Option | Type | Default |
|---|---|---|
leg | "main" | "preload" | "renderer" | required on afterpackElectron |
projectRoot | string | process.cwd(). electron-vite roots the renderer build at src/renderer/. Override it if your build runs from somewhere else |
seed defaults to one fresh value shared by every leg in a build session. protectionMap.enabled follows the usual source-map rule, and each leg writes its own report to .afterpack/<leg>/protectionMap.html.
// Heavier protection, reproducible per commit, applied to every leg:
withAfterpack(config, { preset: "hard", seed: "git" });Directives
Each leg wires @afterpack/vite underneath, so directive capture works the same way there.
Edge cases
Support matrix
| Toolchain | Wiring | One seed automatically? |
|---|---|---|
| electron-vite | withAfterpack(defineConfig({ … })), one call | Yes |
Forge + plugin-vite | afterpackElectron({ leg }) in each of the three configs | Yes |
Forge + plugin-webpack | @afterpack/webpack in each config | Yes |
vite-plugin-electron | afterpackElectron({ leg: "renderer" }), placed last | Yes, once placed after that plugin |
| electron-builder | it packages, it doesn't bundle; wire the bundler above it | n/a |
| Hand-rolled scripts (separate processes) | a plugin per leg, or npx afterpack@latest <dir> | No; pin AFTERPACK_seed |
What it refuses
Two fail-closed checks apply on any leg wired through this package.
bytecodePlugin. electron-vite's own bytecodePlugin replaces every emitted chunk with a 3-line loader stub and ships the real application as V8 bytecode (.jsc). The two cannot compose, because bytecode replaces the chunk Rollup generated. The build fails on the leg where bytecodePlugin is enabled, or wherever a .jsc/.cjsc file turns up in the output afterward. Drop one or the other.
build.backup: true. Backups are refused outright: the call throws instead of proceeding, because a .backup.<hash>.js is your complete original source, written next to the output inside the tree your packager copies into app.asar. Every user who installs the app would receive it.
The same risk extends to any artifact your packager doesn't know to exclude. electron-builder's default files glob matches with dot: true and does not exclude .afterpack/ by default. Exclude these from what you package:
// electron-builder
"files": ["**/*", "!.afterpack/**", "!**/*.backup.*", "!**/*.map"]
// electron-forge
packagerConfig: { ignore: [/^\/\.afterpack/, /\.backup\./, /\.map$/] }Source maps get a warning rather than a refusal: a .map with sourcesContent inside app.asar is full deobfuscation. Keep sourceMap: { enabled: true, emitUrl: true } to local debug builds and exclude *.map from what you package.
IPC channels
Channel names must match byte-for-byte across the two bundles. ipcMain.on("save-file", …) and ipcRenderer.send("save-file", …) are the same string on both sides. They're ordinary string literals in ordinary emitted JavaScript, so keeping both legs on the same seed and the same posture (preset / complexity) is what keeps the two halves consistent.
If a channel name is assembled at runtime rather than written as a literal, keep that construction in a module you exclude with paths.exclude, or lower the posture for that region through a bundler-plugin regions entry (Pro).
Without the plugin: the CLI
Prefer not to add a dependency, or building a leg with something other than Vite? Run npx afterpack@latest once per output directory after the build instead:
npx afterpack@latest out/main --seed=git
npx afterpack@latest out/renderer --seed=gitThe path argument is required; there's no auto-detection. Each run walks the directory, collects every .js/.mjs/.cjs, and obfuscates them in place; a single file works as the argument too. Pin the same --seed across every run: git, derived from the current commit, is the simplest way to guarantee it. A CLI run gets none of the refusals above; it obfuscates whatever JavaScript it finds. Keep the same --seed and --preset across runs.
Next
- Frameworks: the full matrix.
- Vite: the option semantics this package forwards.
- CLI reference: the fallback path's flag list.
- Builds & CI: seeds, dev-vs-prod, promoting the same bytes.
- Protect an Electron app's license logic: applying this to a local license or trial check.
- Best practices → Seed strategy: when to pin, when not to.
- Protection Map: reading the report.
- Configuration: every option, and which surface can set it.