Bundle Vanilla Node Runtime for Packaged Backend — Implementation Plan

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


File Structure


Task 1: Shared runtime constants module

Files:

// 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"

Task 2: fetch-node-runtime.mjs + gitignore + build wiring

Files:

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 --versionv22.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"

Task 3: Stage the webgpu package in the backend bundle

Files:

In 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"

Task 4: Prod backend spawns the bundled Node

Files:

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"

Task 5: after-pack places Node, prunes dawn, rebuilds for Node ABI

Files:

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"

Task 6: Flip postinstall rebuild to Node ABI

Files:

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)"

Task 7: Packaged-app verification (manual)

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 — " note to `docs/superpowers/specs/2026-05-22-bundle-node-runtime-design.md` capturing: app size delta, dawn binary present, signing result, and that the compositor produced output. Commit:

git add docs/superpowers/specs/2026-05-22-bundle-node-runtime-design.md
git commit -m "docs: record packaged-backend GPU verification results"

Self-Review notes