Files
OmniRoute/scripts/packs/optionalPackManifest.mjs
backryun c6c134300b perf(electron): ship optional ML/browser deps as installable packs (#10382)
Stage 7 of issue #10321 moves the optional ML and browser automation dependency closures out of the desktop bundle into checksummed, versioned packs installed on demand through the omniroute packs command.

- scripts/build/optionalPackStaging.mjs stages pack members under .build/optional-packs, creates release tarballs, and emits optional-packs.index.json with per-member SHA-256 checksums.
- scripts/packs provides manifest, install, remove, and verification helpers plus the packs CLI commands.
- Runtime lookup includes installed pack node_modules directories, while LLMLingua and browser executors continue to degrade gracefully when packs are absent.

The measured darwin-arm64 staging closure was about 534 MB of the 929 MB standalone node_modules tree (57%).
2026-08-16 02:20:59 -03:00

230 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
/**
* OmniRoute — Optional runtime pack manifest + integrity core.
*
* Stage 7 of the Electron efficiency roadmap (issue #10321): the heavy optional
* ML / browser automation dependency closure is excluded from the packaged
* desktop app and shipped as versioned, checksummed packs that install on first
* use into `DATA_DIR/packs/<name>/node_modules`.
*
* This module owns the *contract* shared by three consumers:
* - `scripts/build/optionalPackStaging.mjs` (build): checksums the staged
* closure, emits `optional-packs.index.json`, removes pack members from the
* Electron staging tree, optionally tars the packs for release assets.
* - `scripts/packs/optionalPackInstaller.mjs` (first use): installs/verifies/
* removes packs in DATA_DIR against the shipped index.
* - `bin/cli/commands/packs.mjs` (UX): `omniroute packs …`.
*
* The runtime *resolution* side (making an installed pack light up the SLM /
* embeddings / browser features) lives in `open-sse/utils/optionalPacks.ts` and
* intentionally does NOT import this file — it embeds only the pack names.
*
* Fail-open philosophy: every consumer of a pack degrades gracefully when the
* pack is absent; nothing here may throw into a code path that works today.
*/
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import fs from "node:fs";
import path from "node:path";
/**
* The optional runtime packs. Membership changes require bumping `packVersion`.
*
* `os`/`cpu` use Node `process.platform`/`process.arch` values and exist so the
* installer can refuse (with a clear error) a pack whose native payloads do not
* match the machine — e.g. a future pack that only ships darwin/win prebuilds.
*/
export const OPTIONAL_PACKS = [
{
name: "ml-runtime",
packVersion: 1,
description:
"Local ML inference closure: LLMLingua-2 SLM prompt compression and transformers.js memory embeddings",
packages: [
// NOTE: exact versions are resolved at packaging time from the staged
// tree and recorded in optional-packs.index.json — the manifest defines
// MEMBERSHIP only, so member bumps don't need a manifest edit unless the
// set of packages changes.
{ name: "@huggingface/transformers" },
{ name: "onnxruntime-node" },
{ name: "@atjsh/llmlingua-2" },
{ name: "@tensorflow/tfjs" },
{ name: "js-tiktoken" },
],
},
{
name: "browser-runtime",
packVersion: 1,
description:
"Browser automation closure: Claude Turnstile solver and ChatGPT/Gemini web executors",
packages: [{ name: "playwright" }, { name: "playwright-core" }],
},
];
/** Index file emitted at the standalone bundle root (same walk-up anchor style as llmlingua's GATE_DEP_REL). */
export const PACK_INDEX_FILENAME = "optional-packs.index.json";
/** Look up a pack definition by name. */
export function findPack(name) {
return OPTIONAL_PACKS.find((pack) => pack.name === name) ?? null;
}
/** Flatten every package name across all packs (sorted, deduped). */
export function allPackPackageNames() {
return [...new Set(OPTIONAL_PACKS.flatMap((pack) => pack.packages.map((p) => p.name)))].sort();
}
/** Whether `platform`/`arch` satisfy a package's optional os/cpu filters. */
export function packageMatchesPlatform(pkg, platform = process.platform, arch = process.arch) {
if (Array.isArray(pkg.os) && !pkg.os.includes(platform)) return false;
if (Array.isArray(pkg.cpu) && !pkg.cpu.includes(arch)) return false;
return true;
}
/** Whether every package of `pack` matches the platform (compat gate for installs). */
export function packMatchesPlatform(pack, platform = process.platform, arch = process.arch) {
return pack.packages.every((pkg) => packageMatchesPlatform(pkg, platform, arch));
}
// ─── deterministic directory checksum ───────────────────────────────────────────
/**
* Recursively collect sorted relative POSIX paths of regular files under `dir`.
* Symlinks are included as their own entries (link target hashed) — npm trees can
* contain them and silently skipping them would weaken tamper detection.
*
* @param {string} dir
* @returns {{rel: string, absolute: string, symlink: boolean}[]}
*/
export function listDirFiles(dir) {
const out = [];
const walk = (current, prefix) => {
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
// Sort for determinism across platforms/FS orderings.
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of sorted) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(absolute, rel);
} else {
out.push({ rel, absolute, symlink: entry.isSymbolicLink() });
}
}
};
walk(dir, "");
return out;
}
/**
* Deterministic sha256 over a directory tree: sorted relative path + per-file
* content (or link target). Byte-stable across platforms (POSIX separators).
*
* @param {string} dir
* @returns {Promise<{sha256: string, files: number, bytes: number}>}
*/
export async function dirChecksum(dir) {
const hash = createHash("sha256");
let files = 0;
let bytes = 0;
for (const { rel, absolute, symlink } of listDirFiles(dir)) {
hash.update(rel);
hash.update("\0");
if (symlink) {
let target = "";
try {
target = fs.readlinkSync(absolute);
} catch {
/* unreadable link — hash as empty target */
}
hash.update(`link:${target}`);
} else {
let size = 0;
try {
size = fs.statSync(absolute).size;
} catch {
/* stat race — hash content stream anyway */
}
bytes += size;
hash.update(String(size));
hash.update("\0");
try {
// Stream to keep memory bounded on multi-hundred-MB packages (tfjs).
for await (const chunk of createReadStream(absolute)) hash.update(chunk);
} catch {
hash.update("<unreadable>");
}
}
hash.update("\0");
files++;
}
return { sha256: hash.digest("hex"), files, bytes };
}
// ─── index build / verify ────────────────────────────────────────────────────────
/**
* Build the pack index entry for one pack from a populated `node_modules` dir.
* Records resolved versions + deterministic checksums so installs and `verify`
* can prove integrity without network access.
*
* @param {{name: string, packVersion: number, description?: string, packages: {name: string}[]}} pack
* @param {string} nodeModulesDir tree containing the pack members
* @returns {Promise<{name: string, packVersion: number, description: string, tarball: string, packages: object[]}>}
*/
export async function buildPackIndexEntry(pack, nodeModulesDir) {
const packages = [];
for (const pkg of pack.packages) {
const pkgDir = path.join(nodeModulesDir, ...pkg.name.split("/"));
if (!fs.existsSync(path.join(pkgDir, "package.json"))) {
throw new Error(`pack member missing from staging tree: ${pkg.name}`);
}
const manifest = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
const checksum = await dirChecksum(pkgDir);
packages.push({
name: pkg.name,
version: manifest.version ?? null,
sha256: checksum.sha256,
files: checksum.files,
bytes: checksum.bytes,
});
}
return {
name: pack.name,
packVersion: pack.packVersion,
description: pack.description,
tarball: `optional-pack-${pack.name}.tar.gz`,
packages,
};
}
/**
* Verify a directory tree against an index entry (every member checksum).
*
* @returns {Promise<{ok: true} | {ok: false, errors: string[]}>}
*/
export async function verifyAgainstIndexEntry(entry, nodeModulesDir) {
const errors = [];
for (const pkg of entry.packages) {
const pkgDir = path.join(nodeModulesDir, ...pkg.name.split("/"));
if (!fs.existsSync(pkgDir)) {
errors.push(`${pkg.name}: missing`);
continue;
}
const checksum = await dirChecksum(pkgDir);
if (checksum.sha256 !== pkg.sha256) {
errors.push(
`${pkg.name}: checksum mismatch (expected ${pkg.sha256.slice(0, 12)}, got ${checksum.sha256.slice(0, 12)})`
);
}
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}