#!/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(" (firstOtherScript < 0 ? injected.length : firstOtherScript)) { throw new BuildError("the injected line is not the first