#!/usr/bin/env node
/**
* Studio L3b slice 2 — the editor-dist build hook (requirement 7).
*
* Wired into `package.json` as `prebuild`, so it runs before every
* `next build` — on a laptop, in CI and on Vercel.
*
* OFF BY DEFAULT. When `INFERWAY_STUDIO_EDITOR_BUILD` is not exactly `"1"`
* this script prints ONE line and exits 0 without touching the network or the
* filesystem — no directory is created, no file is read or written, no request
* is made. That is the whole production story of this slice: with the variable
* unset, the build output for every existing route is what it was before.
*
* With `INFERWAY_STUDIO_EDITOR_BUILD=1` it:
*
* 1. runs `scripts/studio-editor-fetch.mjs` (the wheel's sha256 is verified
* BEFORE anything is extracted — slice 1's contract);
* 2. verifies the extracted `index.html` is still byte-identical to the
* upstream one recorded in the lock, then injects exactly ONE line —
* `` — as the first element of
* `
` (idempotent: a second run changes nothing);
* 3. copies the shim, the protocol, the upstream licence (verified against
* the lock) and the NOTICE into the dist;
* 4. verifies the result: file count against the lock, `index.html` present,
* exactly one injected line, and the upstream sha again after stripping
* that line;
* 5. verifies every file in the dist against the static file inventory
* (`studio-editor/static-files.sha256`, one `sha256␠␠path` line per
* shipped file — requirement 3). `--write-inventory` regenerates that
* file from the dist just built, for a deliberate upstream bump.
*
* Every failure exits non-zero and fails the build.
*
* Usage:
* node scripts/studio-editor-build.mjs [--lock ] [--cache-dir ]
* [--out-dir ] [--inventory ]
* [--write-inventory]
*/
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const WEBSITE_DIR = path.resolve(SCRIPT_DIR, "..");
const SOURCE_DIR = path.join(WEBSITE_DIR, "studio-editor");
const DEFAULT_LOCK = path.join(SCRIPT_DIR, "studio-editor.lock.json");
const DEFAULT_CACHE_DIR = path.join(WEBSITE_DIR, ".studio-editor-cache");
const DEFAULT_OUT_DIR = path.join(WEBSITE_DIR, "public", "console", "studio", "editor");
const DEFAULT_INVENTORY = path.join(SOURCE_DIR, "static-files.sha256");
const FETCH_SCRIPT = path.join(SCRIPT_DIR, "studio-editor-fetch.mjs");
/** How the inventory is regenerated, quoted back when it does not match. */
const REGENERATE_HINT =
`regenerate it with INFERWAY_STUDIO_EDITOR_BUILD=1 node scripts/studio-editor-build.mjs --write-inventory`;
/** The ONE line this hook may add to the upstream index.html. */
const INJECT_TAG = '';
/** Tracked sources copied into the dist, source name → name in the dist. */
const COPIES = [
["inferway-shim.js", "inferway-shim.js"],
["inferway-bridge-protocol.js", "inferway-bridge-protocol.js"],
["LICENSE.comfyui-frontend-v1.51.10.txt", "LICENSE.txt"],
["NOTICE.md", "NOTICE.md"],
];
const ENABLE_ENV = "INFERWAY_STUDIO_EDITOR_BUILD";
class BuildError extends Error {}
function usage() {
return [
"Usage: node scripts/studio-editor-build.mjs [options]",
"",
` The editor dist is only built when ${ENABLE_ENV}=1 (exact string).`,
"",
" --lock lock JSON to honour (default scripts/studio-editor.lock.json)",
" --cache-dir wheel cache (default /.studio-editor-cache)",
" --out-dir extraction target (default /public/console/studio/editor)",
" --inventory static file inventory (default studio-editor/static-files.sha256)",
" --write-inventory rewrite the inventory from the dist just built (instead of verifying it)",
].join("\n");
}
function parseArgs(argv) {
const options = {
lock: DEFAULT_LOCK,
cacheDir: DEFAULT_CACHE_DIR,
outDir: DEFAULT_OUT_DIR,
inventory: DEFAULT_INVENTORY,
writeInventory: false,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
options.help = true;
return options;
}
if (arg === "--write-inventory") {
options.writeInventory = true;
continue;
}
const value = argv[i + 1];
if (arg === "--lock" || arg === "--cache-dir" || arg === "--out-dir" || arg === "--inventory") {
if (!value) throw new BuildError(`${arg} requires a value`);
const key =
arg === "--lock" ? "lock" : arg === "--cache-dir" ? "cacheDir" : arg === "--out-dir" ? "outDir" : "inventory";
options[key] = path.resolve(value);
i += 1;
continue;
}
throw new BuildError(`unknown argument: ${arg}`);
}
return options;
}
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function readLock(lockPath) {
let lock;
try {
lock = JSON.parse(fs.readFileSync(lockPath, "utf8"));
} catch (error) {
throw new BuildError(`lock unreadable: ${lockPath} (${error.message})`);
}
for (const field of ["package", "version", "wheel_url", "sha256"]) {
if (typeof lock[field] !== "string" || lock[field].length === 0) {
throw new BuildError(`lock field "${field}" must be a non-empty string`);
}
}
const indexHtml = lock.index_html;
if (!indexHtml || typeof indexHtml.sha256 !== "string" || typeof indexHtml.size !== "number") {
throw new BuildError('lock field "index_html" must carry the upstream sha256 and size');
}
const license = lock.license;
if (!license || typeof license.sha256 !== "string" || typeof license.size !== "number") {
throw new BuildError('lock field "license" must carry the licence sha256 and size');
}
if (typeof lock.static_files !== "number" || lock.static_files <= 0) {
throw new BuildError('lock field "static_files" must be the number of files in the upstream static tree');
}
return lock;
}
/** Every file under `dir`, relative and sorted (an absent directory is empty). */
function walkFiles(dir) {
const files = [];
if (!fs.existsSync(dir)) return files;
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
for (const dirent of fs.readdirSync(current, { withFileTypes: true })) {
const absolute = path.join(current, dirent.name);
if (dirent.isDirectory()) stack.push(absolute);
else if (dirent.isFile()) files.push(path.relative(dir, absolute));
else throw new BuildError(`refused non-regular file in the dist: ${absolute}`);
}
}
return files.sort();
}
function totalBytes(dir) {
return walkFiles(dir).reduce((sum, relative) => sum + fs.statSync(path.join(dir, relative)).size, 0);
}
function runFetch(options) {
const result = spawnSync(
process.execPath,
[
FETCH_SCRIPT,
"--lock",
options.lock,
"--cache-dir",
options.cacheDir,
"--out-dir",
options.outDir,
],
{ stdio: "inherit" }
);
if (result.error) throw new BuildError(`could not run the fetch script: ${result.error.message}`);
if (result.status !== 0) throw new BuildError(`studio-editor-fetch exited with code ${result.status}`);
}
/**
* Insert the one injected line after ``, or verify it is already the
* only difference from upstream. Returns the html that must be on disk.
*/
function inject(html, lock, outDir) {
const occurrences = html.split(INJECT_TAG).length - 1;
if (occurrences > 1) {
throw new BuildError(`index.html carries the injected line ${occurrences} times (expected at most 1)`);
}
if (!html.includes("")) throw new BuildError("index.html has no to inject into");
let injected = html;
if (occurrences === 0) injected = html.replace("", `${INJECT_TAG}`);
const stripped = injected.replace(INJECT_TAG, "");
const digest = sha256(Buffer.from(stripped, "utf8"));
if (digest !== lock.index_html.sha256) {
throw new BuildError(
`index.html differs from the pinned upstream file beyond the injected line\n` +
` expected sha256 ${lock.index_html.sha256}\n` +
` actual sha256 ${digest}\n` +
` nothing was written to ${outDir}`
);
}
if (Buffer.byteLength(stripped, "utf8") !== lock.index_html.size) {
throw new BuildError(
`index.html is ${Buffer.byteLength(stripped, "utf8")} bytes without the injected line, lock says ${lock.index_html.size}`
);
}
const firstOtherScript = injected.indexOf("