For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the packaged Electron app run its backend on a bundled, non-sandboxed Node 22.22.1 so the server-side GPU compositor works in production (matching dev).
Architecture: A build step downloads per-arch Node binaries (checksum-verified) into a gitignored cache. bundle-backend.mjs stages the webgpu (dawn.node) package. after-pack.cjs (runs per-arch) places the matching Node binary at Resources/backend/runtime/node[.exe], prunes off-platform dawn.node binaries, and rebuilds better-sqlite3 against Node 22.x’s ABI. server.ts’s prod branch spawns that bundled Node via child_process (the Watchdog command path) instead of utilityProcess.fork.
Tech Stack: Node 22.22.1, electron-builder 26.8.1, esbuild, node-gyp, Jest (electron tests), CJS build scripts.
Spec: docs/superpowers/specs/2026-05-22-bundle-node-runtime-design.md
electron/scripts/node-runtime.constants.cjs — single source of truth: pinned Node version + pure helpers (archive names, node binary name, dawn.node keep/all lists). Shared by the fetch script, after-pack, and tests.electron/scripts/fetch-node-runtime.mjs — downloads + checksum-verifies + extracts per-arch Node binaries into electron/.node-runtime/.electron/src/__tests__/nodeRuntimeConstants.test.ts — unit tests for the pure helpers.electron/scripts/bundle-backend.mjs — add webgpu to externals + required list.electron/scripts/verify-bundle.mjs — assert webgpu staged with a dawn.node binary.electron/src/server.ts — prod branch uses bundled-Node command spawn.electron/scripts/after-pack.cjs — place node binary, prune dawn binaries, rebuild only better-sqlite3 against Node ABI.electron/src/__tests__/afterPack.test.ts — cover node placement + dawn pruning.electron/scripts/rebuild-native.mjs — postinstall builds for Node ABI (127), not Electron.electron/package.json — wire fetch-node-runtime into the build pipeline..gitignore — ignore electron/.node-runtime/.Files:
electron/scripts/node-runtime.constants.cjsTest: electron/src/__tests__/nodeRuntimeConstants.test.ts
// electron/src/__tests__/nodeRuntimeConstants.test.ts
const c = require("../../scripts/node-runtime.constants.cjs") as {
NODE_RUNTIME_VERSION: string;
nodeBinaryName: (platform: string) => string;
nodeArchive: (
platform: string,
arch: string
) => { dir: string; archive: string; binaryInArchive: string };
dawnKeepFiles: (platform: string, arch: string) => string[];
ALL_DAWN_FILES: string[];
};
describe("node-runtime.constants", () => {
it("pins the Node version to 22.22.1", () => {
expect(c.NODE_RUNTIME_VERSION).toBe("22.22.1");
});
it("names the node binary per platform", () => {
expect(c.nodeBinaryName("win32")).toBe("node.exe");
expect(c.nodeBinaryName("darwin")).toBe("node");
expect(c.nodeBinaryName("linux")).toBe("node");
});
it("builds nodejs.org archive info per target", () => {
const mac = c.nodeArchive("darwin", "arm64");
expect(mac.dir).toBe("node-v22.22.1-darwin-arm64");
expect(mac.archive).toBe("node-v22.22.1-darwin-arm64.tar.gz");
expect(mac.binaryInArchive).toBe("node-v22.22.1-darwin-arm64/bin/node");
const win = c.nodeArchive("win32", "x64");
expect(win.dir).toBe("node-v22.22.1-win-x64");
expect(win.archive).toBe("node-v22.22.1-win-x64.zip");
expect(win.binaryInArchive).toBe("node-v22.22.1-win-x64/node.exe");
});
it("keeps only the target platform's dawn.node binaries", () => {
expect(c.dawnKeepFiles("darwin", "arm64")).toEqual([
"darwin-universal.dawn.node",
]);
expect(c.dawnKeepFiles("darwin", "x64")).toEqual([
"darwin-universal.dawn.node",
]);
expect(c.dawnKeepFiles("win32", "x64")).toEqual([
"win32-x64.dawn.node",
"d3dcompiler_47.dll",
]);
expect(c.dawnKeepFiles("linux", "x64")).toEqual(["linux-x64.dawn.node"]);
});
it("lists every shipped dawn binary so off-target ones can be pruned", () => {
expect(c.ALL_DAWN_FILES).toEqual(
expect.arrayContaining([
"darwin-universal.dawn.node",
"linux-x64.dawn.node",
"linux-arm64.dawn.node",
"win32-x64.dawn.node",
"d3dcompiler_47.dll",
])
);
});
});
Run: cd electron && npx jest nodeRuntimeConstants -i
Expected: FAIL — Cannot find module '../../scripts/node-runtime.constants.cjs'.
// electron/scripts/node-runtime.constants.cjs
// Single source of truth for the Node runtime bundled into the packaged
// backend. Pure, dependency-free helpers so the fetch script, after-pack, and
// tests all agree on versions, archive names, and which dawn.node binaries to
// keep per target.
// Pinned to .nvmrc. The backend runs on this exact Node; better-sqlite3 is
// rebuilt against its ABI, so bumping this requires re-fetching binaries and
// rebuilding native modules.
const NODE_RUNTIME_VERSION = "22.22.1";
/** Node executable filename for a platform. */
function nodeBinaryName(platform) {
return platform === "win32" ? "node.exe" : "node";
}
/**
* nodejs.org distribution archive for a build target.
* platform: "darwin" | "win32" | "linux"; arch: "arm64" | "x64".
*/
function nodeArchive(platform, arch) {
const plat = platform === "win32" ? "win" : platform; // darwin | linux | win
const ext = platform === "win32" ? "zip" : "tar.gz";
const dir = `node-v${NODE_RUNTIME_VERSION}-${plat}-${arch}`;
const binaryInArchive =
platform === "win32" ? `${dir}/node.exe` : `${dir}/bin/node`;
return { dir, archive: `${dir}.${ext}`, ext, binaryInArchive, plat };
}
// Every binary the `webgpu` package ships in dist/. After staging we keep only
// the ones for the build's platform (see dawnKeepFiles) and delete the rest.
const ALL_DAWN_FILES = [
"darwin-universal.dawn.node",
"linux-x64.dawn.node",
"linux-arm64.dawn.node",
"win32-x64.dawn.node",
"d3dcompiler_47.dll",
];
/** dawn.node (and sidecar) files to KEEP for a target; others are pruned. */
function dawnKeepFiles(platform, arch) {
if (platform === "darwin") return ["darwin-universal.dawn.node"];
if (platform === "win32") return ["win32-x64.dawn.node", "d3dcompiler_47.dll"];
if (platform === "linux") return [`linux-${arch}.dawn.node`];
return [];
}
module.exports = {
NODE_RUNTIME_VERSION,
nodeBinaryName,
nodeArchive,
ALL_DAWN_FILES,
dawnKeepFiles,
};
Run: cd electron && npx jest nodeRuntimeConstants -i
Expected: PASS (5 tests).
git add electron/scripts/node-runtime.constants.cjs electron/src/__tests__/nodeRuntimeConstants.test.ts
git commit -m "feat(electron): shared Node-runtime constants for backend bundling"
Files:
electron/scripts/fetch-node-runtime.mjs.gitignoreModify: electron/package.json:build
Add to .gitignore (below the existing electron/backend-bundle/ line at .gitignore:25):
electron/.node-runtime/
// electron/scripts/fetch-node-runtime.mjs
// Download + checksum-verify per-arch Node binaries for bundling into the
// packaged backend. Idempotent: skips a target whose cached binary already
// exists. Extraction uses platform tools available on each CI runner (tar for
// .tar.gz, PowerShell Expand-Archive for .zip on Windows).
//
// node scripts/fetch-node-runtime.mjs # all default targets
// node scripts/fetch-node-runtime.mjs darwin-arm64 # one target
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const { NODE_RUNTIME_VERSION, nodeArchive, nodeBinaryName } = require(
"./node-runtime.constants.cjs"
);
const ELECTRON_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const CACHE_ROOT = path.join(ELECTRON_DIR, ".node-runtime", NODE_RUNTIME_VERSION);
const BASE_URL = `https://nodejs.org/dist/v${NODE_RUNTIME_VERSION}`;
const DEFAULT_TARGETS = [
{ platform: "darwin", arch: "arm64" },
{ platform: "darwin", arch: "x64" },
{ platform: "win32", arch: "x64" },
{ platform: "linux", arch: "x64" },
];
function targetsFromArgs() {
const args = process.argv.slice(2);
if (args.length === 0) return DEFAULT_TARGETS;
return args.map((a) => {
const [platform, arch] = a.split("-");
return { platform, arch };
});
}
async function download(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
function expectedChecksum(shasumsText, archiveName) {
for (const line of shasumsText.split("\n")) {
const [hash, name] = line.trim().split(/\s+/);
if (name === archiveName) return hash;
}
throw new Error(`No SHASUMS entry for ${archiveName}`);
}
function extractBinary(archivePath, info, destBinary) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "node-rt-"));
try {
if (info.ext === "zip") {
// Windows runner: PowerShell is available; Expand-Archive is built in.
const r = spawnSync(
"powershell",
["-NoProfile", "-Command", `Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${tmp}'`],
{ stdio: "inherit" }
);
if (r.status !== 0) throw new Error("Expand-Archive failed");
} else {
const r = spawnSync("tar", ["-xzf", archivePath, "-C", tmp], { stdio: "inherit" });
if (r.status !== 0) throw new Error("tar extraction failed");
}
const extracted = path.join(tmp, info.binaryInArchive);
fs.mkdirSync(path.dirname(destBinary), { recursive: true });
fs.copyFileSync(extracted, destBinary);
if (info.ext !== "zip") fs.chmodSync(destBinary, 0o755);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
async function fetchTarget({ platform, arch }) {
const info = nodeArchive(platform, arch);
const destDir = path.join(CACHE_ROOT, `${platform}-${arch}`);
const destBinary = path.join(destDir, nodeBinaryName(platform));
if (fs.existsSync(destBinary)) {
console.log(` cached: ${platform}-${arch}`);
return;
}
console.log(` fetching: ${info.archive}`);
const [archiveBuf, shasums] = await Promise.all([
download(`${BASE_URL}/${info.archive}`),
download(`${BASE_URL}/SHASUMS256.txt`).then((b) => b.toString("utf8")),
]);
const want = expectedChecksum(shasums, info.archive);
const got = sha256(archiveBuf);
if (got !== want) {
throw new Error(`checksum mismatch for ${info.archive}: got ${got}, want ${want}`);
}
const archivePath = path.join(os.tmpdir(), info.archive);
await fsp.writeFile(archivePath, archiveBuf);
try {
extractBinary(archivePath, info, destBinary);
} finally {
await fsp.rm(archivePath, { force: true });
}
console.log(` -> ${destBinary}`);
}
async function main() {
console.log(`Fetching Node ${NODE_RUNTIME_VERSION} runtime binaries...`);
for (const t of targetsFromArgs()) {
await fetchTarget(t);
}
console.log("Node runtime fetch complete.");
}
main().catch((err) => {
console.error("fetch-node-runtime failed:", err);
process.exit(1);
});
Run: cd electron && node scripts/fetch-node-runtime.mjs darwin-arm64
Expected: prints fetching: node-v22.22.1-darwin-arm64.tar.gz then -> .../.node-runtime/22.22.1/darwin-arm64/node; the binary exists and runs:
./.node-runtime/22.22.1/darwin-arm64/node --version → v22.22.1.
Run again: cd electron && node scripts/fetch-node-runtime.mjs darwin-arm64
Expected: prints cached: darwin-arm64, no download.
In electron/package.json, change the build script to fetch the runtime before bundling. Current:
"build": "tsc && vite build && npm run verify:bundle && npm run prepare-backend && electron-builder",
to:
"build": "tsc && vite build && npm run verify:bundle && node scripts/fetch-node-runtime.mjs && npm run prepare-backend && electron-builder",
git add electron/scripts/fetch-node-runtime.mjs electron/package.json .gitignore
git commit -m "feat(electron): fetch per-arch Node runtime binaries at build time"
Files:
electron/scripts/bundle-backend.mjs (EXTERNAL_PACKAGES, REQUIRED_EXTERNAL_PACKAGES)Modify: electron/scripts/verify-bundle.mjs
webgpu to the external + required listsIn electron/scripts/bundle-backend.mjs, add "webgpu" to REQUIRED_EXTERNAL_PACKAGES:
const REQUIRED_EXTERNAL_PACKAGES = [
"sharp",
"better-sqlite3",
"@jitl/quickjs-ng-wasmfile-release-sync",
"webgpu",
];
And add it under the native-modules group in EXTERNAL_PACKAGES (so esbuild leaves the variable-specifier import("webgpu") external and the copier stages it with its dist binaries):
const EXTERNAL_PACKAGES = [
// Native modules (contain .node binaries)
"better-sqlite3",
"sqlite-vec",
"sharp",
"@img/sharp-*",
"node-web-audio-api",
"keytar",
"onnxruntime-node",
"webgpu",
// ... rest unchanged
Append to electron/scripts/verify-bundle.mjs (before its final success exit), a check that the staged bundle contains webgpu with at least one dawn.node binary:
// --- webgpu (dawn.node) staging check ---
// The server-side GPU compositor loads `webgpu` via a variable-specifier
// dynamic import, so esbuild can't see it. It must be copied to _modules/ or
// the packaged compositor fails with "requires the optional 'webgpu' package".
{
const webgpuDist = path.join(
ELECTRON_DIR,
"backend-bundle",
"_modules",
"webgpu",
"dist"
);
let dawnFiles = [];
try {
dawnFiles = readdirSync(webgpuDist).filter((f) => f.endsWith(".dawn.node"));
} catch {
fail(
`webgpu not staged: ${webgpuDist} missing. Add "webgpu" to EXTERNAL_PACKAGES in bundle-backend.mjs.`
);
}
if (dawnFiles.length === 0) {
fail(`webgpu staged but no *.dawn.node binary found in ${webgpuDist}`);
}
console.log(`verify-bundle: webgpu staged with ${dawnFiles.length} dawn.node binary(ies)`);
}
Run: cd /Users/mg/workspace/nodetool2 && npm run build:packages >/dev/null 2>&1; cd electron && npm run prepare-backend 2>&1 | tail -5
Expected: Copied webgpu appears in the output; exit 0.
Run: ls electron/backend-bundle/_modules/webgpu/dist/*.dawn.node
Expected: lists darwin-universal.dawn.node, linux-*.dawn.node, win32-x64.dawn.node.
git add electron/scripts/bundle-backend.mjs electron/scripts/verify-bundle.mjs
git commit -m "feat(electron): stage webgpu (dawn.node) in the backend bundle"
Files:
Modify: electron/src/server.ts (prod watchdogOpts branch ~lines 466-476; add a path helper)
Step 1: Read the current prod branch
Run: sed -n '442,478p' electron/src/server.ts
Expected: see the isDevMode() ? { command: devNodeBinary, ... } : { modulePath: backendEntryPoint, ... } ternary.
In electron/src/server.ts, change the prod (else) branch of watchdogOpts from the modulePath form to a command form that runs the bundled Node on server.mjs. The bundled Node sits next to the entry point at runtime/node[.exe]:
const watchdogOpts: import("./watchdog").WatchdogOptions = isDevMode()
? {
name: "nodetool",
command: devNodeBinary,
args: [
path.join(__dirname, "..", "dev-server-runner.cjs"),
backendEntryPoint,
],
env: backendEnv,
cwd: rootDir,
pidFilePath: PID_FILE_PATH,
healthUrl: `http://127.0.0.1:${selectedPort}/health`,
onOutput: (line) => handleServerOutput(Buffer.from(line)),
logOutput: false,
}
: {
// Production: run the backend on the bundled, non-sandboxed Node
// (Resources/backend/runtime/node) via child_process — NOT Electron's
// sandboxed utilityProcess. The V8 heap sandbox in Electron's embedded
// Node rejects Dawn's external GPU-readback buffers, breaking the GPU
// compositor. The bundled Node has no such sandbox.
name: "nodetool",
command: path.join(
path.dirname(backendEntryPoint),
"runtime",
process.platform === "win32" ? "node.exe" : "node"
),
args: [backendEntryPoint],
env: backendEnv,
cwd: path.dirname(backendEntryPoint),
pidFilePath: PID_FILE_PATH,
healthUrl: `http://127.0.0.1:${selectedPort}/health`,
onOutput: (line) => handleServerOutput(Buffer.from(line)),
logOutput: false,
};
Change the log at electron/src/server.ts:388 from:
logMessage(`Starting backend server via utilityProcess.fork: ${backendEntryPoint}`);
to:
logMessage(`Starting backend server: ${backendEntryPoint}`);
Run: cd electron && npm run typecheck
Expected: no errors.
Run: cd electron && npm run vite:build >/dev/null 2>&1 && grep -c 'runtime' dist-electron/main.js
Expected: ≥ 1 (the runtime/node join is bundled).
git add electron/src/server.ts
git commit -m "feat(electron): run packaged backend on bundled Node via child_process"
Files:
electron/scripts/after-pack.cjsModify: electron/src/__tests__/afterPack.test.ts
Append to electron/src/__tests__/afterPack.test.ts:
const afterPackExtra = require("../../scripts/after-pack.cjs") as {
placeNodeRuntime: (
context: Record<string, unknown>,
cacheRoot: string
) => Promise<void>;
pruneDawnBinaries: (backendDir: string, platform: string, arch: string) => void;
resolveResourcesDir: (context: Record<string, unknown>) => string;
};
describe("placeNodeRuntime", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "nt-place-node-"));
});
afterEach(async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
it("copies the matching-arch node binary into backend/runtime", async () => {
const cacheRoot = path.join(tempDir, "cache");
const srcDir = path.join(cacheRoot, "darwin-arm64");
await fs.promises.mkdir(srcDir, { recursive: true });
await fs.promises.writeFile(path.join(srcDir, "node"), "#!fake-node\n");
const appOutDir = path.join(tempDir, "dist");
const context = {
electronPlatformName: "darwin",
arch: "arm64",
appOutDir,
packager: { appInfo: { productFilename: "Nodetool" } },
};
await afterPackExtra.placeNodeRuntime(context, cacheRoot);
const placed = path.join(
afterPackExtra.resolveResourcesDir(context),
"backend",
"runtime",
"node"
);
expect(fs.existsSync(placed)).toBe(true);
expect((fs.statSync(placed).mode & 0o111) !== 0).toBe(true); // executable
});
});
describe("pruneDawnBinaries", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "nt-prune-dawn-"));
});
afterEach(async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
it("keeps darwin-universal and deletes off-platform binaries on mac", async () => {
const dist = path.join(tempDir, "node_modules", "webgpu", "dist");
await fs.promises.mkdir(dist, { recursive: true });
for (const f of [
"darwin-universal.dawn.node",
"linux-x64.dawn.node",
"linux-arm64.dawn.node",
"win32-x64.dawn.node",
"d3dcompiler_47.dll",
]) {
await fs.promises.writeFile(path.join(dist, f), "x");
}
afterPackExtra.pruneDawnBinaries(tempDir, "darwin", "arm64");
const remaining = fs.readdirSync(dist).sort();
expect(remaining).toEqual(["darwin-universal.dawn.node"]);
});
});
Run: cd electron && npx jest afterPack -i
Expected: FAIL — afterPackExtra.placeNodeRuntime is not a function.
In electron/scripts/after-pack.cjs:
(a) At the top, after the existing requires, import the constants:
const {
NODE_RUNTIME_VERSION,
nodeBinaryName,
ALL_DAWN_FILES,
dawnKeepFiles,
} = require("./node-runtime.constants.cjs");
(b) Add the two helpers (place them above module.exports):
const ELECTRON_DIR = path.dirname(__dirname);
/** Copy the build target's bundled Node binary into backend/runtime/. */
async function placeNodeRuntime(context, cacheRoot) {
const platform = context.electronPlatformName;
const arch = resolveArch(context);
const binName = nodeBinaryName(platform);
const src = path.join(cacheRoot, `${platform}-${arch}`, binName);
if (!fs.existsSync(src)) {
throw new Error(
`Bundled Node binary not found: ${src}. Run scripts/fetch-node-runtime.mjs.`
);
}
const runtimeDir = path.join(resolveResourcesDir(context), "backend", "runtime");
await fsp.mkdir(runtimeDir, { recursive: true });
const dest = path.join(runtimeDir, binName);
await fsp.copyFile(src, dest);
if (platform !== "win32") await fsp.chmod(dest, 0o755);
console.info(`Placed bundled Node (${platform}-${arch}) at ${dest}`);
}
/** Delete dawn.node binaries that don't belong to this build's platform. */
function pruneDawnBinaries(backendDir, platform, arch) {
const dist = path.join(backendDir, "node_modules", "webgpu", "dist");
if (!fs.existsSync(dist)) return;
const keep = new Set(dawnKeepFiles(platform, arch));
for (const f of ALL_DAWN_FILES) {
if (keep.has(f)) continue;
const p = path.join(dist, f);
if (fs.existsSync(p)) fs.rmSync(p, { force: true });
}
}
(c) Replace nodeGypRebuild so it targets the bundled Node ABI (no Electron dist-url — node-gyp defaults to nodejs.org headers):
function nodeGypRebuild(modulePath, nodeVersion, arch) {
const nodeGypBin = require.resolve("node-gyp/bin/node-gyp.js");
const result = spawnSync(
process.execPath,
[
nodeGypBin,
"rebuild",
"--release",
`--target=${nodeVersion}`,
`--arch=${arch}`,
],
{ cwd: modulePath, stdio: "inherit" }
);
if (result.status !== 0) {
throw new Error(
`node-gyp rebuild failed for ${modulePath} (exit ${result.status})`
);
}
}
(d) Replace rebuildNativeModulesForElectron with a version that rebuilds ONLY the V8-locked module (better-sqlite3) against the bundled Node, leaving N-API modules untouched:
// Only V8/NAN-locked modules need a rebuild to match the bundled Node's ABI.
// N-API modules (sharp, dawn.node, bufferutil, keytar, msgpackr-extract,
// onnxruntime-node) are ABI-stable across Node versions and must NOT be rebuilt.
const V8_LOCKED_MODULES = new Set(["better-sqlite3"]);
async function rebuildNativeModulesForBackend(context) {
const backendDir = path.join(resolveResourcesDir(context), "backend");
const arch = resolveArch(context);
const runtimeNodeModulesPath = path.join(backendDir, "node_modules");
const found = findNativeModuleNames(runtimeNodeModulesPath);
const toRebuild = found.filter((n) => V8_LOCKED_MODULES.has(n));
if (!toRebuild.includes("better-sqlite3")) {
throw new Error(
`better-sqlite3 not found in ${runtimeNodeModulesPath}. ` +
`Did bundle-backend.mjs stage modules correctly?`
);
}
console.info(
`Rebuilding ${toRebuild.join(", ")} for Node ${NODE_RUNTIME_VERSION} (${arch})`
);
for (const name of toRebuild) {
nodeGypRebuild(path.join(runtimeNodeModulesPath, name), NODE_RUNTIME_VERSION, arch);
}
console.info("Native backend module rebuild complete.");
}
(e) Update the default export to call the new pieces, and export the new helpers:
module.exports = async function afterPack(context) {
try {
await promoteBackendNodeModules(context);
await placeNodeRuntime(
context,
path.join(ELECTRON_DIR, ".node-runtime", NODE_RUNTIME_VERSION)
);
pruneDawnBinaries(
path.join(resolveResourcesDir(context), "backend"),
context.electronPlatformName,
resolveArch(context)
);
await rebuildNativeModulesForBackend(context);
} catch (error) {
console.error("afterPack failed", error);
throw error;
}
};
module.exports.promoteBackendNodeModules = promoteBackendNodeModules;
module.exports.resolveResourcesDir = resolveResourcesDir;
module.exports.findNativeModuleNames = findNativeModuleNames;
module.exports.placeNodeRuntime = placeNodeRuntime;
module.exports.pruneDawnBinaries = pruneDawnBinaries;
Remove the now-unused resolveElectronVersion function.
Run: cd electron && npx jest afterPack -i
Expected: PASS — all existing tests plus placeNodeRuntime and pruneDawnBinaries.
git add electron/scripts/after-pack.cjs electron/src/__tests__/afterPack.test.ts
git commit -m "feat(electron): place bundled Node, prune off-platform dawn binaries, rebuild for Node ABI"
Files:
Modify: electron/scripts/rebuild-native.mjs
Step 1: Rewrite the script to build against Node, not Electron
Nothing (dev backend, prod backend, or Electron main) needs Electron’s ABI 140 anymore — the backend always runs on vanilla Node. Replace the body of electron/scripts/rebuild-native.mjs so better-sqlite3 is built for the system/bundled Node ABI:
// Rebuild the V8/NAN-locked native module(s) against the Node ABI the backend
// runs on (system Node in dev, the bundled Node 22.x in prod — same ABI).
//
// The backend always runs on vanilla Node, NOT Electron's embedded Node, so we
// build against Node headers (node-gyp's default dist-url), not Electron's.
// Only better-sqlite3 is V8-locked; N-API modules are ABI-stable and skipped.
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { dirname } from "node:path";
const require = createRequire(import.meta.url);
const arch = process.arch;
const moduleDir = dirname(require.resolve("better-sqlite3/package.json"));
const nodeGyp = require.resolve("node-gyp/bin/node-gyp.js");
console.log(`Rebuilding better-sqlite3 for Node ${process.versions.node} (${arch})`);
const result = spawnSync(
process.execPath,
[nodeGyp, "rebuild", "--release", `--arch=${arch}`],
{ cwd: moduleDir, stdio: "inherit" }
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
Run: cd electron && node scripts/rebuild-native.mjs && cd /Users/mg/workspace/nodetool2 && node -e "require('better-sqlite3'); console.log('better-sqlite3 loads on', process.versions.modules)"
Expected: prints better-sqlite3 loads on 127 (system Node 22.x ABI), no NODE_MODULE_VERSION error.
Run: cd /Users/mg/workspace/nodetool2 && timeout 25 npm run dev:server 2>&1 | grep -m1 -iE "listening|7777|ready" && echo OK
Expected: server logs a ready/listening line (OK); no sqlite ABI error. (Ctrl-C / timeout ends it.)
git add electron/scripts/rebuild-native.mjs
git commit -m "fix(electron): rebuild better-sqlite3 for Node ABI (backend runs on vanilla Node)"
Files: none (verification only)
Run: cd electron && npm run build 2>&1 | tail -30
Expected: fetch-node-runtime runs, prepare-backend stages webgpu, electron-builder packs and afterPack logs Placed bundled Node (darwin-arm64) + Rebuilding better-sqlite3 for Node 22.22.1. Build succeeds.
Run:
APP="electron/dist/mac-arm64/Nodetool.app/Contents/Resources/backend"
ls "$APP/runtime/node" && "$APP/runtime/node" --version
ls "$APP/node_modules/webgpu/dist/"*.dawn.node
Expected: node exists and prints v22.22.1; exactly darwin-universal.dawn.node remains (off-platform binaries pruned).
Run:
codesign --verify --deep --strict --verbose=2 electron/dist/mac-arm64/Nodetool.app
codesign -dv "$APP/runtime/node" 2>&1 | grep -i "Authority\|Identifier"
spctl -a -vv electron/dist/mac-arm64/Nodetool.app
Expected: valid on disk, node signed with the Developer ID authority, spctl accepts the app. (If signing/notarization isn’t configured locally, record that this must pass in CI.)
Launch the packaged app, open a workflow with ConstantImage (asset) → Compositor, run it. Confirm a composited image renders and the backend process does not crash.
Cross-check the backend is the bundled Node (not Electron):
ps aux | grep -i "Resources/backend/runtime/node" | grep -v grep
Expected: a process running .../Resources/backend/runtime/node .../backend/server.mjs.
Append a short “Verification —
git add docs/superpowers/specs/2026-05-22-bundle-node-runtime-design.md
git commit -m "docs: record packaged-backend GPU verification results"
rebuildNativeModulesForBackend (renamed from ...ForElectron); exports placeNodeRuntime, pruneDawnBinaries; constants nodeBinaryName, nodeArchive, dawnKeepFiles, ALL_DAWN_FILES, NODE_RUNTIME_VERSION — used consistently across Tasks 1, 4, 5.