This commit is contained in:
462
scripts/stage-sidecar-deps.mjs
Normal file
462
scripts/stage-sidecar-deps.mjs
Normal file
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stage the externalized runtime dependencies of the sidecar bundle into
|
||||
* app/src-tauri/resources/node_modules/ so the packaged Tauri app can resolve
|
||||
* the bare require()/import() calls that build-sidecar.mjs deliberately left
|
||||
* `external`. Without this, the packaged sidecar boots straight into
|
||||
* MODULE_NOT_FOUND on the first eval-time external (better-sqlite3,
|
||||
* @fastify/static, drizzle-orm, …).
|
||||
*
|
||||
* How it works:
|
||||
* 1. Read the esbuild metafile written by build-sidecar.mjs to learn EXACTLY
|
||||
* which external packages the bundle imports (no more guessing from the
|
||||
* EXTERNAL list — some of those, e.g. mammoth/sharp, aren't actually
|
||||
* reached).
|
||||
* 2. Walk the transitive production-dependency closure of that set from the
|
||||
* repo's own node_modules and copy each package dir verbatim — preserving
|
||||
* prebuilt native .node binaries in place (better-sqlite3/build/Release,
|
||||
* onnxruntime-node/bin) so require('better-sqlite3') both RESOLVES and
|
||||
* FINDS its binary via the package's own relative loader.
|
||||
*
|
||||
* Run after build-sidecar.mjs, before `tauri build`. Arch-parameterized: honors
|
||||
* TARGET_ARCH (like bundle-native-deps.mjs) to prune onnxruntime-node's
|
||||
* cross-platform native binaries down to the single build target.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/stage-sidecar-deps.mjs
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { builtinModules } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const resourcesDir = path.join(root, 'app', 'src-tauri', 'resources');
|
||||
// Must match the metafile path written by build-sidecar.mjs (temp, not repo).
|
||||
const metaFile = path.join(os.tmpdir(), 'waggle-sidecar-meta.json');
|
||||
const stageDir = path.join(resourcesDir, 'node_modules');
|
||||
const hookRuntimeBuild = path.join(root, 'scripts', 'build-hook-runtime.mjs');
|
||||
const HOOK_RUNTIME_ROOTS = new Set([
|
||||
'@waggle/hive-mind-cli',
|
||||
'@waggle/hive-mind-hooks-claude-code',
|
||||
'@waggle/hive-mind-hooks-claude-desktop',
|
||||
'@waggle/hive-mind-hooks-codex',
|
||||
'@waggle/hive-mind-hooks-codex-desktop',
|
||||
'@waggle/hive-mind-hooks-cursor',
|
||||
'@waggle/hive-mind-hooks-hermes',
|
||||
'@waggle/hive-mind-hooks-openclaw',
|
||||
]);
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.env.TARGET_ARCH || process.arch;
|
||||
|
||||
// macOS "universal" is NOT a real staging target — onnxruntime-node's native
|
||||
// binding is per-arch (bin/napi-v3/<os>/<arch>), so a universal prune keeps
|
||||
// nothing. Build per-arch and lipo the app bundle instead (see release.yml).
|
||||
if (arch === 'universal') {
|
||||
console.error(
|
||||
'[stage-sidecar-deps] FATAL — TARGET_ARCH=universal is not supported.\n'
|
||||
+ ' onnxruntime-node ships a per-arch native binding; there is no universal\n'
|
||||
+ ' variant to stage. Build each arch separately (TARGET_ARCH=arm64 and =x64,\n'
|
||||
+ ' targets aarch64-apple-darwin / x86_64-apple-darwin) — release.yml already\n'
|
||||
+ ' does this via its macOS matrix.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Packages we deliberately DO NOT stage even though the bundle references them.
|
||||
// Each is either a guarded lazy import with graceful fallback, or verified
|
||||
// unreachable on the desktop code path — staging them would add 100s of MB of
|
||||
// dead weight.
|
||||
// playwright-core / chromium-bidi — browser-tools.ts loads playwright-core via
|
||||
// a try/catch dynamic import and returns an "npm install playwright-core"
|
||||
// message when absent; the huge chromium tree is not part of boot or memory.
|
||||
// onnxruntime-web — @huggingface/transformers' node build
|
||||
// (dist/transformers.node.mjs) imports only onnxruntime-node +
|
||||
// onnxruntime-common; the 91MB web/wasm backend is never required on Node.
|
||||
// pg — a lazy dynamic import on the hosted-Postgres path only; the desktop
|
||||
// sidecar uses better-sqlite3 and never reaches it (and it isn't installed).
|
||||
const SKIP = new Set([
|
||||
'playwright-core',
|
||||
'chromium-bidi',
|
||||
'@playwright/test',
|
||||
'onnxruntime-web',
|
||||
'pg',
|
||||
]);
|
||||
|
||||
const BUILTINS = new Set(builtinModules);
|
||||
const RUNTIME_PRUNED_DIR_NAMES = new Set([
|
||||
'.github',
|
||||
'__tests__',
|
||||
'benchmark',
|
||||
'benchmarks',
|
||||
'coverage',
|
||||
'example',
|
||||
'examples',
|
||||
'fixture',
|
||||
'fixtures',
|
||||
'test',
|
||||
'tests',
|
||||
]);
|
||||
const WINDOWS_1252_EXTRA_CODEPOINTS = new Set([
|
||||
0x20ac, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030,
|
||||
0x0160, 0x2039, 0x0152, 0x017d, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022,
|
||||
0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0x017e, 0x0178,
|
||||
]);
|
||||
|
||||
/** Map an import specifier to its top-level package name (handles scopes/subpaths). */
|
||||
function toPackageName(spec) {
|
||||
if (spec.startsWith('@')) {
|
||||
const [scope, name] = spec.split('/');
|
||||
return `${scope}/${name}`;
|
||||
}
|
||||
return spec.split('/')[0];
|
||||
}
|
||||
|
||||
/** Read the metafile and return the set of external, non-builtin package names. */
|
||||
function readExternalPackages() {
|
||||
if (!fs.existsSync(metaFile)) {
|
||||
console.error(
|
||||
`[stage-sidecar-deps] FATAL — metafile not found at ${metaFile}.\n`
|
||||
+ ' Run `node scripts/build-sidecar.mjs` first (it writes the metafile).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
||||
const outKey = Object.keys(meta.outputs).find((k) => k.endsWith('service.js'));
|
||||
if (!outKey) {
|
||||
console.error('[stage-sidecar-deps] FATAL — no service.js output in metafile.');
|
||||
process.exit(1);
|
||||
}
|
||||
const names = new Set();
|
||||
for (const imp of meta.outputs[outKey].imports) {
|
||||
if (!imp.external) continue;
|
||||
const spec = imp.path.replace(/^node:/, '');
|
||||
if (BUILTINS.has(spec)) continue;
|
||||
names.add(toPackageName(imp.path));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a package's install directory as Node would from `fromDir`, walking
|
||||
* up the node_modules chain. Returns the absolute dir or null if not installed
|
||||
* (optional deps that npm skipped on this platform legitimately return null).
|
||||
*/
|
||||
function resolvePkgDir(name, fromDir) {
|
||||
let dir = fromDir;
|
||||
for (;;) {
|
||||
const candidate = path.join(dir, 'node_modules', name);
|
||||
if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
let copiedPackages = 0;
|
||||
let prunedRuntimeDirs = 0;
|
||||
|
||||
function isWorkspacePackageDir(pkgDir) {
|
||||
const realDir = fs.realpathSync.native(pkgDir);
|
||||
return [path.join(root, 'packages'), path.join(root, 'apps')].some((workspaceRoot) => {
|
||||
const relative = path.relative(workspaceRoot, realDir);
|
||||
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
|
||||
});
|
||||
}
|
||||
|
||||
/** Recursively copy a package dir, preserving native binaries and nested deps. */
|
||||
function copyPackage(srcDir, name) {
|
||||
const destDir = path.join(stageDir, name);
|
||||
if (fs.existsSync(destDir)) return; // already staged (dedup by flat name)
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
const copyOptions = { recursive: true, dereference: true };
|
||||
if (isWorkspacePackageDir(srcDir)) {
|
||||
// npm does not publish a workspace package's local node_modules. Copying
|
||||
// it from a dereferenced workspace symlink would leak dev-only packages;
|
||||
// production dependencies are staged separately from the manifest below.
|
||||
copyOptions.filter = (source) => path.basename(source) !== 'node_modules';
|
||||
}
|
||||
fs.cpSync(srcDir, destDir, copyOptions);
|
||||
copiedPackages++;
|
||||
}
|
||||
|
||||
function readManifest(pkgDir) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage the transitive production closure of the given root package names.
|
||||
* Follows `dependencies` + any `optionalDependencies` that actually resolve
|
||||
* (installed on this platform). Copies each package flat into node_modules/;
|
||||
* nested node_modules ride along inside their parent for version-pinned deps.
|
||||
*/
|
||||
function stageClosure(rootNames) {
|
||||
const processedDirs = new Set();
|
||||
const queue = [...rootNames].map((name) => ({ name, fromDir: root }));
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { name, fromDir } = queue.shift();
|
||||
if (SKIP.has(name)) continue;
|
||||
|
||||
const pkgDir = resolvePkgDir(name, fromDir);
|
||||
if (!pkgDir) {
|
||||
// Optional/absent (e.g. bufferutil, utf-8-validate, pg): the bundle
|
||||
// guards these or never reaches them — nothing to stage.
|
||||
continue;
|
||||
}
|
||||
const pkgKey = fs.realpathSync.native(pkgDir);
|
||||
if (processedDirs.has(pkgKey)) continue;
|
||||
processedDirs.add(pkgKey);
|
||||
copyPackage(pkgDir, name);
|
||||
|
||||
const manifest = readManifest(pkgDir);
|
||||
const deps = { ...manifest.dependencies, ...manifest.optionalDependencies };
|
||||
for (const dep of Object.keys(deps)) {
|
||||
if (SKIP.has(dep)) continue;
|
||||
queue.push({ name: dep, fromDir: pkgDir });
|
||||
}
|
||||
}
|
||||
return processedDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune onnxruntime-node's cross-platform native binding tree down to the
|
||||
* single build target. Its loader does a hard relative require of
|
||||
* `bin/napi-v3/<process.platform>/<process.arch>/onnxruntime_binding.node`, so
|
||||
* only the target platform/arch dir is ever loaded — the other five (~174MB)
|
||||
* are dead weight in a per-platform installer.
|
||||
*/
|
||||
function pruneOnnxRuntime() {
|
||||
const napi = path.join(stageDir, 'onnxruntime-node', 'bin', 'napi-v3');
|
||||
if (!fs.existsSync(napi)) return;
|
||||
const keepPlatform = platform; // win32 | darwin | linux
|
||||
const keepArch = arch === 'arm64' ? 'arm64' : 'x64';
|
||||
let pruned = 0;
|
||||
for (const plat of fs.readdirSync(napi)) {
|
||||
const platDir = path.join(napi, plat);
|
||||
if (!fs.statSync(platDir).isDirectory()) continue;
|
||||
if (plat !== keepPlatform) {
|
||||
fs.rmSync(platDir, { recursive: true, force: true });
|
||||
pruned++;
|
||||
continue;
|
||||
}
|
||||
for (const a of fs.readdirSync(platDir)) {
|
||||
if (a !== keepArch) {
|
||||
fs.rmSync(path.join(platDir, a), { recursive: true, force: true });
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pruned > 0) {
|
||||
console.log(`[stage-sidecar-deps] Pruned ${pruned} onnxruntime-node cross-platform binding dir(s); kept ${keepPlatform}/${keepArch}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defense in depth: delete any SKIP-listed package that rode along inside a
|
||||
* nested node_modules, so the huge trees never reach the bundle even if some
|
||||
* dependency vendored them.
|
||||
*/
|
||||
function pruneSkipListed(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (path.basename(dir) === 'node_modules') {
|
||||
// Reconstruct the package name (scoped or plain) at this node_modules level.
|
||||
if (entry.name.startsWith('@')) {
|
||||
for (const sub of fs.readdirSync(full, { withFileTypes: true })) {
|
||||
if (!sub.isDirectory()) continue;
|
||||
const scoped = `${entry.name}/${sub.name}`;
|
||||
if (SKIP.has(scoped)) {
|
||||
fs.rmSync(path.join(full, sub.name), { recursive: true, force: true });
|
||||
} else {
|
||||
pruneSkipListed(path.join(full, sub.name));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (SKIP.has(entry.name)) {
|
||||
fs.rmSync(full, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
pruneSkipListed(full);
|
||||
}
|
||||
}
|
||||
|
||||
function isPackageContainer(dir) {
|
||||
const base = path.basename(dir);
|
||||
if (base === 'node_modules') return true;
|
||||
return base.startsWith('@') && path.basename(path.dirname(dir)) === 'node_modules';
|
||||
}
|
||||
|
||||
/**
|
||||
* npm packages often ship tests, fixtures, examples, and CI metadata. They are
|
||||
* not loaded by the packaged sidecar, and they can contain filenames that WiX
|
||||
* cannot encode in the en-US MSI database codepage.
|
||||
*/
|
||||
function pruneRuntimeOnlyDirs(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
const name = entry.name.toLowerCase();
|
||||
if (!isPackageContainer(dir) && RUNTIME_PRUNED_DIR_NAMES.has(name)) {
|
||||
fs.rmSync(full, { recursive: true, force: true });
|
||||
prunedRuntimeDirs++;
|
||||
continue;
|
||||
}
|
||||
pruneRuntimeOnlyDirs(full);
|
||||
}
|
||||
}
|
||||
|
||||
function isWindows1252PathSafe(value) {
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0) || 0;
|
||||
if (code <= 0x7f || (code >= 0xa0 && code <= 0xff)) continue;
|
||||
if (WINDOWS_1252_EXTRA_CODEPOINTS.has(code)) continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function listFiles(dir) {
|
||||
const files = [];
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) stack.push(full);
|
||||
else if (entry.isFile()) files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function listPackageDirs(nodeModulesDir) {
|
||||
if (!fs.existsSync(nodeModulesDir)) return [];
|
||||
const packageDirs = [];
|
||||
const stack = [nodeModulesDir];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (fs.existsSync(path.join(full, 'package.json'))) {
|
||||
packageDirs.push(full);
|
||||
}
|
||||
stack.push(full);
|
||||
}
|
||||
}
|
||||
return packageDirs;
|
||||
}
|
||||
|
||||
function resolveWithinStagedResources(fromPackageDir, dep) {
|
||||
let current = fromPackageDir;
|
||||
for (;;) {
|
||||
const candidate = path.join(current, 'node_modules', ...dep.split('/'), 'package.json');
|
||||
if (fs.existsSync(candidate)) return true;
|
||||
if (path.resolve(current) === path.resolve(resourcesDir)) return false;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return false;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function assertStagedNodeModulesSelfContained() {
|
||||
const missing = [];
|
||||
for (const packageDir of listPackageDirs(stageDir)) {
|
||||
const manifest = readManifest(packageDir);
|
||||
for (const dep of Object.keys(manifest.dependencies || {})) {
|
||||
if (SKIP.has(dep)) continue;
|
||||
if (!resolveWithinStagedResources(packageDir, dep)) {
|
||||
missing.push(`${path.relative(stageDir, packageDir)} -> ${dep}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0) return;
|
||||
console.error(
|
||||
'[stage-sidecar-deps] FATAL - staged node_modules is not self-contained:\n'
|
||||
+ missing.map((dep) => ` - ${dep}`).join('\n')
|
||||
+ '\n Add the missing transitive runtime dependency to the staged closure.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function assertWindowsMsiSafeResourcePaths() {
|
||||
if (platform !== 'win32') return;
|
||||
const unsafe = listFiles(resourcesDir)
|
||||
.map((file) => path.relative(resourcesDir, file))
|
||||
.filter((file) => !isWindows1252PathSafe(file));
|
||||
|
||||
if (unsafe.length === 0) return;
|
||||
console.error(
|
||||
'[stage-sidecar-deps] FATAL - staged resource paths are not Windows MSI codepage-safe:\n'
|
||||
+ unsafe.map((file) => ` - ${file}`).join('\n')
|
||||
+ '\n Prune the package payload or configure an MSI codepage before bundling.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function dirSizeMB(dir) {
|
||||
let bytes = 0;
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const d = stack.pop();
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) stack.push(p);
|
||||
else if (e.isFile()) {
|
||||
try { bytes += fs.statSync(p).size; } catch { /* transient */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
return (bytes / 1024 / 1024).toFixed(1);
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────
|
||||
console.log(`[stage-sidecar-deps] Platform: ${platform}-${arch}`);
|
||||
|
||||
// These workspace packages are loaded by hook installers/external agents, not
|
||||
// by the sidecar bundle itself, so the esbuild metafile cannot discover them.
|
||||
// Build them explicitly before copying their production dependency closure.
|
||||
execFileSync(process.execPath, [hookRuntimeBuild], { cwd: root, stdio: 'inherit' });
|
||||
|
||||
// Fresh stage dir each run so a removed dep never lingers in a stale bundle.
|
||||
fs.rmSync(stageDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(stageDir, { recursive: true });
|
||||
|
||||
const externals = readExternalPackages();
|
||||
const runtimeRoots = new Set([...externals, ...HOOK_RUNTIME_ROOTS]);
|
||||
const staged = [...runtimeRoots].filter((n) => !SKIP.has(n)).sort();
|
||||
const skipped = [...externals].filter((n) => SKIP.has(n)).sort();
|
||||
console.log(`[stage-sidecar-deps] Bundle/runtime roots: ${runtimeRoots.size} (${staged.length} to stage, ${skipped.length} skipped)`);
|
||||
if (skipped.length) console.log(`[stage-sidecar-deps] skipped: ${skipped.join(', ')}`);
|
||||
|
||||
const closure = stageClosure(runtimeRoots);
|
||||
|
||||
pruneOnnxRuntime();
|
||||
pruneSkipListed(stageDir);
|
||||
pruneRuntimeOnlyDirs(stageDir);
|
||||
if (prunedRuntimeDirs > 0) {
|
||||
console.log(`[stage-sidecar-deps] Pruned ${prunedRuntimeDirs} runtime-unused package artifact dir(s)`);
|
||||
}
|
||||
assertWindowsMsiSafeResourcePaths();
|
||||
assertStagedNodeModulesSelfContained();
|
||||
|
||||
console.log(
|
||||
`[stage-sidecar-deps] Staged ${copiedPackages} packages `
|
||||
+ `(${closure.size} in closure) → resources/node_modules/ (${dirSizeMB(stageDir)} MB)`,
|
||||
);
|
||||
Reference in New Issue
Block a user