electron-native-lib-extraction
An Electron app that grew a native module (N-API addon linking a system framework, e.g. Sparkle) accumulates four coupled layers: the addon source, the Electron-ABI build orchestration, the electron-builder packaging config, and the release CI. Extracting them into a reusable npm package is mostly mechanical — but most of the failure modes are invisible until a consumer installs the package or CI runs a real release. This skill encodes the extraction order and the traps. Reference implementation: https://github.com/Innei/electron-sparkle-updater (Sparkle.framework bridge + appcast release toolchain, extracted from the Kansoku desktop app).
When to use
- In scope: source-distributed native npm packages for Electron (consumer compiles at build time), macOS framework linking, electron-builder integration fragments, composite GitHub Actions for release pipelines, migrating the source app onto the extracted library.
- Out of scope: prebuilt binary distribution (prebuildify/node-gyp-build), Windows native bridges, ASAR internals beyond asarUnpack.
Inputs: the app's working native integration; a free npm name
(npm view <name> → 404 = free); the consumer's Electron version (drives
ABI flags).
Workflow
[1] Carve the package addon + scripts verbatim, params for app values
[2] Neutralize npm install no-op install script, real build = explicit CLI
[3] Runtime loader null-degrading, path contract for dev/packaged
[4] Rebuild CLI node-gyp resolved from OWN deps, never npx
[5] Packager fragments asarUnpack narrow, vendor excluded, sign hook
[6] Release CI as an Action env-routed inputs, EXIT-trap secrets
[7] Dogfood migration app consumes the lib; packaged-layout assertion
[1] Carve
Copy the addon source and fetch scripts verbatim (keep their load-bearing
comments — they move with the code). Everything app-specific (repo slug,
feed URL, tag prefix, arch) becomes a parameter. Remove hardcoded ARCHS
from binding.gyp; let node-gyp's --arch drive it. Parameterize in a
separate, reviewable step after the verbatim copy.
[2] Neutralize npm install
npm auto-runs node-gyp rebuild for ANY package containing a binding.gyp,
against the host Node ABI — the wrong target for Electron, and a build the
consumer never asked for. Ship "install": "node -e \"\"" and put the real
build behind an explicit CLI command. Do not set gypfile: true at the
package root. Document why, prominently — the no-op looks like a bug.
[3] Runtime loader
The loader never throws: non-target-platform → null before any path work;
load/shape failure → null + log. Two path contracts, both derived from the
package's own import.meta.url:
- dev:
<packageRoot>/native/build/Release/<addon>.node - packaged:
<resourcesPath>/app.asar.unpacked/node_modules/<pkg>/native/build/Release/<addon>.node
Provide an addonPath override as the escape hatch for exotic layouts.
Import electron only lazily inside the one convenience wrapper, so the
core stays loadable (and testable) in plain Node.
[4] Rebuild CLI
<pkg> rebuild --electron-version X --arch arm64|x64|universal:
- node-gyp must be a runtime
dependency, and the CLI must spawn its bin script resolved viacreateRequire(import.meta.url)from the package's own install —npx node-gypsilently network-fetches an unpinned version when the package sits in a consumer's node_modules. node-gyp rebuild= clean → configure → build, and clean isrm -rf build/. Any intermediate stashed for a later step (universal lipo) must live OUTSIDEbuild/or the second arch pass destroys it.- Electron ABI:
--dist-url=https://electronjs.org/headers. - Auto-detect the consumer's Electron version from their node_modules when the flag is omitted.
[5] Packager fragments
Export a config-fragment function, not documentation alone. The traps:
- electron-builder packs the ENTIRE production dependency. The vendored
framework (fetched by your build) gets a useless second copy inside
app.asar unless excluded:
files: ["!**/node_modules/<pkg>/native/vendor/**"]. asarUnpackmust be narrow:**/node_modules/<pkg>/native/build/Release/*.node, notnative/build/**(which unpacks Makefiles and object files).- macOS rpath depth is layout-relative. An
@loader_path/../../…/Frameworksentry copied from the app counts from the addon's OLD location; insidenode_modules/<pkg>/native/build/Releasethe addon sits deeper — recount toContents/Frameworks(8 ups for this layout) and verify withotool -l <addon>.node | grep -A2 LC_RPATH. Electron's own@executable_path/../FrameworksLC_RPATH can mask a dead entry — the framework loading is not proof your rpath is right. - Ship the afterPack ad-hoc signing hook as an export; consumers wrap it in a 3-line file (electron-builder takes a file path). Sign once before dmg and zip are packaged so both embed a valid CodeDirectory.
prepublishOnly: build—dist/is gitignored; without the guard a clean checkout publishes a tarball with no JS and npm omits the dir silently.require()of your ESM package from a consumer's.cjshook needs Node ≥ 20.19 / ≥ 22.12 — setenginesto match what your samples assume.
[6] Release CI as a composite Action
- Composite
inputs.*.defaultcannot reference other inputs — derive composed defaults inside the step body with${VAR:-default}. - Never interpolate
${{ inputs.* }}insiderun:bodies (script-injection surface). Route every input through the step'senv:block, reference as"$VAR". - Secrets that touch disk (signing keys): RAM disk +
trap cleanup EXITwith|| trueguards INSIDE the trap only —set -eskips inline cleanup when the signing tool fails mid-step. - An action step that runs
npx <pkg>executes at the caller's workspace ROOT. In a pnpm workspace the bin only exists in the subpackage'snode_modules/.bin, so npx silently falls back to a registry fetch of an unpinned version. Caller-side fix: add the package to ROOT devDependencies. - Pin external tool downloads by version + sha256, verified before extract.
[7] Dogfood migration
Switch the source app to the library and delete the local copy. The one assertion that cannot be skipped: run the app's REAL packaging and inspect the artifact — pnpm's symlinked node_modules is a layout the loader contract never saw in the lib's own repo:
- addon at
app.asar.unpacked/node_modules/<pkg>/native/build/Release/ - framework at
Contents/Frameworks/, a real directory codesign --verify --deep --strictpassesnpx asar list app.asar | grep vendorcomes back empty
If 1 fails, the addonPath override is the fix — record the observed
layout. Keep behavior parity explicit: tag prefixes, throttle values,
notification text, and placeholder-injection anchors must survive the
migration byte-for-byte (grep the old constants against the new wiring).
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Install builds the addon against the wrong ABI, wastes minutes | npm auto-runs node-gyp for any package with binding.gyp |
no-op install script; explicit rebuild CLI owns the real build |
| Consumer's rebuild fetches node-gyp from the registry, or fails offline | node-gyp in devDependencies + npx node-gyp |
move to dependencies; spawn its bin via createRequire resolution |
--arch universal always fails at lipo |
intermediate stored under build/; second arch pass's clean removes it |
stash intermediates outside build/ |
| Framework loads in the packaged app but the rpath entry is dead | ups-count copied from the app's shallower addon location; Electron's own LC_RPATH masks it | recount ups for the node_modules layout; verify with otool -l |
| Consumer's app.asar carries a full copy of the vendored framework | electron-builder packs the whole dependency | files exclusion for native/vendor/** |
Published tarball has no dist/ |
dist gitignored; npm omits missing dirs silently | prepublishOnly build script |
afterPack wrapper throws ERR_REQUIRE_ESM on some Node 20.x |
require(esm) unflagged only ≥ 20.19 | engines.node >= 20.19, noted beside the sample |
| Action step executes an unpinned registry version of your CLI | npx at workspace root can't see the subpackage bin |
package in caller's ROOT devDependencies |
| Signing key survives on the runner after a failed release | set -e aborts before inline scrub |
trap cleanup EXIT, || true only inside the trap |
| Old delta-base archives re-uploaded as the new release's assets | globbing the archive dir after delta bases were fetched into it | record own artifact names BEFORE fetching bases; publish only those |
| Version comparison treats every release as newer after migration | tag prefix not threaded into the generalized compare | prefix is a required parameter; grep every call site |
| Appcast items 404 after a delta rebuild | generator stamps ALL items with the current run's download URL | re-point old enclosures to their own tags (URLs are not signed; safe) |
Rules
- Extraction is verbatim-first: byte-identical copies, then parameterize in a separate reviewable step.
- The library never throws where the app can degrade —
nullcontracts on every load path. - One source of truth per pinned value (framework version, placeholder string): shell script or exported constant, never both.
- Real-artifact verification beats unit tests for packaging claims: otool, codesign, asar list, file-exists on the actual .app.
Verification
-
npm pack --dry-runfrom a CLEAN clone lists dist/, bin/, native sources, scripts — nothing more, nothing missing. - Rebuild CLI produces the addon end-to-end on the target Electron
version;
otool -lshows the recounted rpath entry. - Consumer-side packaging assertions 1–4 (step [7]) all pass on the real .app artifact.
- Action YAML: no
${{ inputs.* }}inside anyrun:body; secret cleanup runs on the failure path. - Behavior-parity greps: old constants (URLs, prefixes, notification strings) resolve to identical values in the new wiring.