mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
The desktop release matrix ran the full Next.js standalone build on all four legs (windows, macos-intel, macos-arm64, linux), duplicating the platform-neutral majority of that work four times and re-exposing every leg to the hosted-runner RAM class of failure that took the linux leg out of v3.8.49. - scripts/build/standaloneTarball.mjs: deterministic, dependency-free tar.gz writer/reader (uid/gid/mtime pinned, sorted entries, symlink + exec-bit preservation; GNU-tar interop covered by tests). - scripts/build/standaloneManifest.mjs: byte-level manifest of .build/next (sha256 + size + symlink target per entry, plus the archive's own digest) catching artifact-transfer corruption before extraction and re-verifying the restored tree byte-for-byte, smuggling included. - scripts/build/standaloneBundle.mjs: pack / restore / hydrate CLI over the two modules above. - scripts/build/hydrateNativeDeps.mjs: swaps install-machine-forked native optionals (@img/sharp-*, @ngrok/ngrok-*, fsevents) from the leg's own npm ci into the restored tree, then verifies the bundled-native closure (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime with its documented darwin-x64 exemption) services the leg's platform/arch before packaging starts. - .github/workflows/electron-release.yml: new web-build job builds the standalone once on ubuntu with webpack and uploads the bundle; legs download, restore, and hydrate it, skipping the per-leg build. The legacy per-leg build remains as a rollback path via the ELECTRON_SHARED_STANDALONE workflow_dispatch input, and legs fail closed if web-build ran and failed. Regression tests cover archive roundtrip, byte determinism, manifest tamper/smuggle detection, forked-native swaps, and native-closure serviceability.
222 lines
8.0 KiB
JavaScript
222 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* CLI entry for the shared Next standalone web build (issue #10321, Stage 8).
|
|
*
|
|
* One ubuntu `web-build` job runs `pack` once; every desktop matrix leg runs
|
|
* `restore` (byte-verified against the manifest) and `hydrate` (replaces
|
|
* install-machine-forked native optionals with this leg's own `npm ci` forks,
|
|
* then asserts the bundled natives can service the leg's platform/arch).
|
|
*
|
|
* Rollback: set repo variable ELECTRON_SHARED_STANDALONE=disabled and the
|
|
* workflow falls back to the legacy per-leg `npm run build` — no revert needed.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { createReadStream } from "node:fs";
|
|
import {
|
|
buildStandaloneManifest,
|
|
verifyStandaloneManifest,
|
|
MANIFEST_VERSION,
|
|
} from "./standaloneManifest.mjs";
|
|
import { createTarGz, extractTarGz } from "./standaloneTarball.mjs";
|
|
import { hydratePlatformNatives, verifyBundledNatives } from "./hydrateNativeDeps.mjs";
|
|
|
|
function sha256File(filePath) {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = createHash("sha256");
|
|
const stream = createReadStream(filePath);
|
|
stream.on("data", (chunk) => hash.update(chunk));
|
|
stream.on("error", reject);
|
|
stream.on("end", () => resolve(hash.digest("hex")));
|
|
});
|
|
}
|
|
|
|
function manifestPathFor(archive) {
|
|
return `${archive}.manifest.json`;
|
|
}
|
|
|
|
/**
|
|
* Pack a web-build tree into a deterministic archive plus a byte-level
|
|
* manifest (which embeds the archive's own sha256 so transfer corruption is
|
|
* caught before extraction).
|
|
*
|
|
* @param {{dir?: string, out: string, manifest?: string}} opts
|
|
* @returns {Promise<{archive: string, manifest: string, files: number, archiveBytes: number}>}
|
|
*/
|
|
export async function runPack({ dir = ".build/next", out, manifest }) {
|
|
if (!out) throw new Error("pack requires --out <file.tar.gz>");
|
|
const rootDir = path.resolve(dir);
|
|
if (!fs.existsSync(rootDir)) {
|
|
throw new Error(`web build tree not found: ${rootDir} (did 'npm run build' run?)`);
|
|
}
|
|
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
|
|
const built = await buildStandaloneManifest(rootDir);
|
|
await createTarGz(rootDir, out);
|
|
const archiveBytes = fs.statSync(out).size;
|
|
const archiveSha = await sha256File(out);
|
|
|
|
const manifestFile = manifest ?? manifestPathFor(out);
|
|
const payload = {
|
|
version: MANIFEST_VERSION,
|
|
archive: { name: path.basename(out), bytes: archiveBytes, sha256: archiveSha },
|
|
entries: built.entries,
|
|
};
|
|
fs.writeFileSync(manifestFile, `${JSON.stringify(payload, null, 2)}\n`);
|
|
return { archive: out, manifest: manifestFile, files: built.entries.length, archiveBytes };
|
|
}
|
|
|
|
/**
|
|
* Verify + extract a packed archive into `dir`, then prove the restored tree
|
|
* matches the manifest byte-for-byte.
|
|
*
|
|
* @param {{archive: string, manifest?: string, dir?: string}} opts
|
|
* @returns {Promise<{archive: string, dir: string, files: number}>}
|
|
*/
|
|
export async function runRestore({ archive, manifest, dir = ".build/next" }) {
|
|
if (!archive) throw new Error("restore requires --archive <file.tar.gz>");
|
|
const manifestFile = manifest ?? manifestPathFor(archive);
|
|
const raw = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
|
|
if (raw.version !== MANIFEST_VERSION) {
|
|
throw new Error(`unsupported manifest version: ${raw.version}`);
|
|
}
|
|
|
|
const archiveBytes = fs.statSync(archive).size;
|
|
if (archiveBytes !== raw.archive.bytes) {
|
|
throw new Error(`archive size ${archiveBytes} != manifest ${raw.archive.bytes}`);
|
|
}
|
|
const archiveSha = await sha256File(archive);
|
|
if (archiveSha !== raw.archive.sha256) {
|
|
throw new Error(`archive sha256 mismatch (expected ${raw.archive.sha256.slice(0, 12)})`);
|
|
}
|
|
|
|
const destDir = path.resolve(dir);
|
|
fs.rmSync(destDir, { recursive: true, force: true });
|
|
await extractTarGz(archive, destDir);
|
|
|
|
const verdict = await verifyStandaloneManifest(destDir, raw);
|
|
if (!verdict.ok) {
|
|
throw new Error(
|
|
`restored tree failed manifest verification:\n ${verdict.errors.join("\n ")}`
|
|
);
|
|
}
|
|
return { archive, dir: destDir, files: raw.entries.length };
|
|
}
|
|
|
|
/**
|
|
* Hydrate the restored tree's node_modules with this machine's forked
|
|
* optionals and assert bundled natives cover every requested arch.
|
|
*
|
|
* @param {{standaloneNodeModules?: string, sourceNodeModules?: string, platform: string, arch: string}} opts
|
|
* `arch` accepts a comma-separated list (the linux leg ships x64+arm64).
|
|
* @returns {Promise<{replaced: string[], removed: string[], copied: string[], verified: string[]}>}
|
|
*/
|
|
export async function runHydrate({
|
|
standaloneNodeModules = ".build/next/standalone/node_modules",
|
|
sourceNodeModules = "node_modules",
|
|
platform,
|
|
arch,
|
|
}) {
|
|
if (!platform || !arch) throw new Error("hydrate requires --platform <os> --arch <a[,a2...]>");
|
|
const result = hydratePlatformNatives({
|
|
standaloneNodeModules: path.resolve(standaloneNodeModules),
|
|
sourceNodeModules: path.resolve(sourceNodeModules),
|
|
});
|
|
const verified = [];
|
|
for (const one of arch
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)) {
|
|
const verdict = verifyBundledNatives({
|
|
nodeModulesDir: path.resolve(standaloneNodeModules),
|
|
platform,
|
|
arch: one,
|
|
});
|
|
if (!verdict.ok) {
|
|
throw new Error(
|
|
`bundled natives cannot service ${platform}/${one}:\n ${verdict.errors.join("\n ")}`
|
|
);
|
|
}
|
|
verified.push(one);
|
|
}
|
|
return { ...result, verified };
|
|
}
|
|
|
|
// ─── argv plumbing ───────────────────────────────────────────────────────────────
|
|
|
|
/** Minimal `--key value` parser (booleans: `--key` alone → true). */
|
|
export function parseArgs(argv) {
|
|
const opts = { _: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const token = argv[i];
|
|
if (!token.startsWith("--")) {
|
|
opts._.push(token);
|
|
continue;
|
|
}
|
|
const key = token.slice(2);
|
|
const next = argv[i + 1];
|
|
if (next !== undefined && !next.startsWith("--")) {
|
|
opts[key] = next;
|
|
i++;
|
|
} else {
|
|
opts[key] = true;
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
"usage:",
|
|
" standaloneBundle.mjs pack --out <file.tar.gz> [--dir .build/next] [--manifest <file.json>]",
|
|
" standaloneBundle.mjs restore --archive <file.tar.gz> [--manifest <file.json>] [--dir .build/next]",
|
|
" standaloneBundle.mjs hydrate --platform <os> --arch <a[,a2...]>",
|
|
" [--standalone-node-modules <dir>] [--source-node-modules <dir>]",
|
|
].join("\n");
|
|
}
|
|
|
|
async function main(argv) {
|
|
const [command = "", ...rest] = argv;
|
|
const opts = parseArgs(rest);
|
|
try {
|
|
if (command === "pack") {
|
|
const r = await runPack({ dir: opts.dir, out: opts.out, manifest: opts.manifest });
|
|
console.log(
|
|
`[standalone-bundle] packed ${r.files} entries -> ${r.archive} ` +
|
|
`(${(r.archiveBytes / 1e6).toFixed(1)} MB); manifest ${r.manifest}`
|
|
);
|
|
} else if (command === "restore") {
|
|
const r = await runRestore({ archive: opts.archive, manifest: opts.manifest, dir: opts.dir });
|
|
console.log(
|
|
`[standalone-bundle] restored ${r.files} entries from ${path.basename(r.archive)} -> ${r.dir}`
|
|
);
|
|
} else if (command === "hydrate") {
|
|
const r = await runHydrate({
|
|
standaloneNodeModules: opts["standalone-node-modules"],
|
|
sourceNodeModules: opts["source-node-modules"],
|
|
platform: opts.platform,
|
|
arch: opts.arch,
|
|
});
|
|
console.log(
|
|
`[standalone-bundle] hydrated forks: copied=${r.copied.length} replaced=${r.replaced.length} ` +
|
|
`removed=${r.removed.length}; bundled natives verified for ${r.verified.join("+")}`
|
|
);
|
|
} else {
|
|
console.error(usage());
|
|
process.exitCode = 2;
|
|
}
|
|
} catch (err) {
|
|
console.error(`[standalone-bundle] ${command || "(no command)"} failed: ${err.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === new URL(`file://${path.resolve(process.argv[1])}`).href
|
|
) {
|
|
await main(process.argv.slice(2));
|
|
}
|