← Files ModRetro Chromatic PluginARCHIVED FILE
dist/web-build.js
44.1 KB · Oct 2, 2026 · 00:37 UTC
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import { link, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile, } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { inspectRom, runBuildCommand } from "./build.js";
import { assertSafePlatformPath, isPathWithinRoot, readPlatformEnvironmentVariable, runtimeExecutablePaths, sanitizeSubprocessEnvironment, verifyWindowsPrivatePath, } from "./platform.js";
const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_TIMEOUT_MS = 600_000;
const MAX_ARTIFACT_FILES = 4_096;
const MAX_ARTIFACT_BYTES = 128 * 1024 * 1024;
const MANIFEST_FILENAME = ".codex-web-build.json";
const WASM_MAGIC = Buffer.from([0x00, 0x61, 0x73, 0x6d]);
const BUILD_LOCKS = new Map();
// Project-local files cannot authenticate themselves. Durable signing material
// belongs only to a private toolchain directory outside the selected project.
const PROCESS_WEB_BUILD_ATTESTATION_KEY = randomBytes(32);
const TRUSTED_WEB_BUILD_KEYS = new Map();
const MAX_WINDOWS_PUBLISH_RETRIES = 6;
function isInside(root, candidate, platform = process.platform) {
return isPathWithinRoot(root, candidate, platform);
}
function samePath(left, right, platform = process.platform) {
if (platform === "win32") {
return path.win32.normalize(left).toLowerCase() === path.win32.normalize(right).toLowerCase();
}
return left === right;
}
function runtimeOptions(options) {
const platform = options.platform ?? process.platform;
const delay = options.retryDelayMs ?? 20;
if (!Number.isInteger(delay) || delay < 0 || delay > 1_000) {
throw new Error("Web build retryDelayMs must be an integer between 0 and 1000.");
}
return {
platform,
localAppData: options.localAppData,
inspectWindowsAcl: options.inspectWindowsAcl,
rename: options.rename ?? rename,
retryDelayMs: delay,
};
}
function environmentValue(environment, name, platform) {
const value = readPlatformEnvironmentVariable(environment, name, platform);
return typeof value === "string" && value.trim() ? value : undefined;
}
function isSystemError(error, code) {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
async function exists(candidate) {
try {
await lstat(candidate);
return true;
}
catch (error) {
if (isSystemError(error, "ENOENT"))
return false;
throw error;
}
}
async function resolveExistingFile(root, input, runtime) {
assertSafePlatformPath(input, runtime.platform);
const candidate = path.resolve(root, input);
if (!isInside(root, candidate, runtime.platform))
throw new Error(`Project input escapes the project root: ${input}`);
const metadata = await lstat(candidate);
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new Error(`Project input must be a real regular file: ${input}`);
}
const canonical = await realpath(candidate);
if (!samePath(canonical, candidate, runtime.platform) || !isInside(root, canonical, runtime.platform)) {
throw new Error(`Project input resolves through a symbolic link or outside the project root: ${input}`);
}
return canonical;
}
async function resolveExistingAncestor(root, candidate, runtime) {
let ancestor = candidate;
while (true) {
try {
const canonical = await realpath(ancestor);
if (!samePath(canonical, ancestor, runtime.platform) || !isInside(root, canonical, runtime.platform)) {
throw new Error(`Web output resolves through a symbolic link or outside the project root: ${candidate}`);
}
return;
}
catch (error) {
if (!isSystemError(error, "ENOENT"))
throw error;
const parent = path.dirname(ancestor);
if (samePath(parent, ancestor, runtime.platform) || !isInside(root, parent, runtime.platform)) {
throw new Error(`Web output escapes the project root: ${candidate}`);
}
ancestor = parent;
}
}
}
async function resolveOutput(root, input, runtime) {
if (typeof input !== "string" || input.trim().length === 0 || input.includes("\0")) {
throw new Error("Web output must name a non-empty project-relative build directory.");
}
assertSafePlatformPath(input, runtime.platform);
const output = path.resolve(root, input);
const buildRoot = path.join(root, "build");
if (samePath(output, buildRoot, runtime.platform) || !isInside(buildRoot, output, runtime.platform)) {
throw new Error("Web output must be a directory inside the selected project's build directory.");
}
await resolveExistingAncestor(root, output, runtime);
if (await exists(output)) {
const metadata = await lstat(output);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error("Web output must be a real directory, not a file or symbolic link.");
}
}
return output;
}
function cliCandidates(root, runtime) {
return runtimeExecutablePaths(root, runtime.platform).cliCandidates;
}
async function canonicalToolchainRoot(candidate) {
const root = await realpath(path.resolve(candidate));
if (!(await stat(root)).isDirectory()) {
throw new Error("The selected game toolchain root must name an existing directory.");
}
const local = path.join(root, ".local");
if (await exists(local)) {
const metadata = await lstat(local);
if (metadata.isSymbolicLink()) {
const canonical = await realpath(local);
if (path.basename(canonical) !== ".local" || !(await stat(canonical)).isDirectory()) {
throw new Error("A linked game toolchain must resolve to an actual .local directory.");
}
return path.dirname(canonical);
}
}
return root;
}
async function discoverToolchainRoot(projectRoot, runtime, configuredRoot) {
const explicitRoot = configuredRoot ?? process.env.GB_STUDIO_TOOLCHAIN_ROOT;
if (explicitRoot)
return canonicalToolchainRoot(explicitRoot);
let current = projectRoot;
for (let depth = 0; depth < 8; depth += 1) {
if ((await Promise.all(cliCandidates(current, runtime).map((candidate) => exists(candidate)))).some(Boolean)) {
return canonicalToolchainRoot(current);
}
const parent = path.dirname(current);
if (parent === current)
break;
current = parent;
}
const packageRoot = await realpath(path.resolve(import.meta.dirname, ".."));
if ((await Promise.all(cliCandidates(packageRoot, runtime).map((candidate) => exists(candidate)))).some(Boolean)) {
return canonicalToolchainRoot(packageRoot);
}
return projectRoot;
}
async function findOfficialCli(projectRoot, runtime, configuredRoot) {
const root = await discoverToolchainRoot(projectRoot, runtime, configuredRoot);
for (const candidate of cliCandidates(root, runtime)) {
if (!(await exists(candidate)))
continue;
const metadata = await lstat(candidate);
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new Error("The official game CLI must be a real regular file.");
}
const canonical = await realpath(candidate);
if (!isInside(root, canonical)) {
throw new Error("The official game CLI resolves outside its selected toolchain root.");
}
return { cliPath: canonical, toolchainRoot: root };
}
throw new Error('The official GB Studio CLI is unavailable in the selected toolchain. Use the packaged setup skill to prepare runtime,build and bind the resulting toolchain before building. toolchain_doctor with tasks:["projectBuild"] checks readiness; it does not install dependencies.');
}
async function trustedOwnedDirectory(directory, uid, privateDirectory) {
try {
const metadata = await lstat(directory);
return (!metadata.isSymbolicLink() && metadata.isDirectory() && metadata.uid === uid &&
(metadata.mode & (privateDirectory ? 0o077 : 0o022)) === 0 &&
(await realpath(directory)) === directory);
}
catch {
return false;
}
}
async function windowsPrivatePath(candidate, runtime, purpose) {
return verifyWindowsPrivatePath(candidate, {
platform: runtime.platform,
purpose,
inspectAcl: runtime.inspectWindowsAcl,
});
}
async function windowsSigningDirectory(toolchainRoot, projectRoot, runtime) {
const configured = runtime.localAppData ?? environmentValue(process.env, "LOCALAPPDATA", runtime.platform);
if (!configured)
return null;
let localDirectory;
try {
assertSafePlatformPath(configured, runtime.platform);
const requested = path.resolve(configured);
const requestedMetadata = await lstat(requested);
if (requestedMetadata.isSymbolicLink() || !requestedMetadata.isDirectory())
return null;
localDirectory = await realpath(requested);
if (!samePath(localDirectory, requested, runtime.platform))
return null;
if (!(await windowsPrivatePath(localDirectory, runtime, "secret")))
return null;
}
catch {
return null;
}
const toolchainIdentity = createHash("sha256")
.update(path.win32.normalize(toolchainRoot).toLowerCase())
.digest("hex");
let current = localDirectory;
for (const segment of ["Codex", "GBStudio", "WebPreview", toolchainIdentity]) {
current = path.join(current, segment);
if (isInside(projectRoot, current, runtime.platform))
return null;
try {
try {
await mkdir(current, { mode: 0o700 });
}
catch (error) {
if (!isSystemError(error, "EEXIST"))
return null;
}
const metadata = await lstat(current);
if (metadata.isSymbolicLink() || !metadata.isDirectory() ||
!samePath(await realpath(current), current, runtime.platform) ||
!(await windowsPrivatePath(current, runtime, "secret")))
return null;
}
catch {
return null;
}
}
return current;
}
async function loadTrustedSigningKey(toolchainRoot, projectRoot, runtime) {
const windows = runtime.platform === "win32";
const uid = !windows && typeof process.getuid === "function" ? process.getuid() : undefined;
if (!windows && uid === undefined)
return null;
const localDirectory = windows
? undefined
: path.join(toolchainRoot, ".local");
if (!windows && !(await trustedOwnedDirectory(localDirectory, uid, false)))
return null;
const keyDirectory = windows
? await windowsSigningDirectory(toolchainRoot, projectRoot, runtime)
: path.join(localDirectory, ".codex-web-cache");
if (!keyDirectory)
return null;
const keyPath = path.join(keyDirectory, "attestation.key");
try {
if (!windows) {
try {
await mkdir(keyDirectory, { mode: 0o700 });
}
catch (error) {
if (!isSystemError(error, "EEXIST"))
return null;
}
if (!(await trustedOwnedDirectory(keyDirectory, uid, true)))
return null;
}
if (!(await exists(keyPath))) {
const temporaryPath = path.join(keyDirectory, `.attestation-${randomBytes(12).toString("hex")}`);
let created;
try {
created = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL |
(windows ? 0 : (constants.O_NOFOLLOW ?? 0)), 0o600);
await created.writeFile(randomBytes(32));
await created.sync();
}
finally {
await created?.close();
}
try {
// A hard link publishes a fully written private key atomically without
// allowing simultaneous processes to replace one another's key.
await link(temporaryPath, keyPath);
}
catch (error) {
if (!isSystemError(error, "EEXIST"))
throw error;
}
finally {
await unlink(temporaryPath).catch(() => { });
}
}
const before = await lstat(keyPath);
if (before.isSymbolicLink() || !before.isFile() ||
!samePath(await realpath(keyPath), keyPath, runtime.platform) ||
(windows && !(await windowsPrivatePath(keyPath, runtime, "secret"))))
return null;
const descriptor = await open(keyPath, constants.O_RDONLY | (windows ? 0 : (constants.O_NOFOLLOW ?? 0)));
try {
const metadata = await descriptor.stat();
if (!metadata.isFile() || metadata.size !== 32 ||
(!windows && (metadata.uid !== uid || (metadata.mode & 0o777) !== 0o600)))
return null;
const bytes = Buffer.alloc(33);
const result = await descriptor.read(bytes, 0, bytes.length, 0);
if (result.bytesRead !== 32)
return null;
const named = await lstat(keyPath);
if (named.isSymbolicLink() || named.dev !== metadata.dev || named.ino !== metadata.ino ||
!samePath(await realpath(keyPath), keyPath, runtime.platform) ||
(windows
? !(await windowsPrivatePath(keyDirectory, runtime, "secret")) ||
!(await windowsPrivatePath(keyPath, runtime, "secret"))
: !(await trustedOwnedDirectory(keyDirectory, uid, true)) ||
!(await trustedOwnedDirectory(localDirectory, uid, false))))
return null;
return Buffer.from(bytes.subarray(0, 32));
}
finally {
await descriptor.close();
}
}
catch {
return null;
}
}
async function signingKey(projectRoot, toolchainRoot, runtime) {
// A project-owned or overlapping toolchain can be modified through normal
// project authoring, so it must never contain a trusted durable secret.
if (isInside(projectRoot, toolchainRoot, runtime.platform) ||
isInside(toolchainRoot, projectRoot, runtime.platform)) {
return PROCESS_WEB_BUILD_ATTESTATION_KEY;
}
const cacheKey = runtime.platform === "win32"
? `win32:${toolchainRoot.toLowerCase()}:${(runtime.localAppData ?? environmentValue(process.env, "LOCALAPPDATA", runtime.platform) ?? "").toLowerCase()}`
: toolchainRoot;
let trusted = TRUSTED_WEB_BUILD_KEYS.get(cacheKey);
if (!trusted) {
trusted = loadTrustedSigningKey(toolchainRoot, projectRoot, runtime);
TRUSTED_WEB_BUILD_KEYS.set(cacheKey, trusted);
}
return (await trusted) ?? PROCESS_WEB_BUILD_ATTESTATION_KEY;
}
async function hashFile(filePath) {
const digest = createHash("sha256");
for await (const chunk of createReadStream(filePath))
digest.update(chunk);
return digest.digest("hex");
}
function updateHash(digest, value) {
const encoded = Buffer.from(value, "utf8");
const length = Buffer.allocUnsafe(4);
length.writeUInt32BE(encoded.byteLength);
digest.update(length).update(encoded);
}
async function hashSourceDirectory(root, directory, digest) {
if (!(await exists(directory)))
return;
const metadata = await lstat(directory);
if (metadata.isSymbolicLink() || !metadata.isDirectory() || (await realpath(directory)) !== directory) {
throw new Error("game source directories must be real directories inside the selected project.");
}
const entries = (await readdir(directory, { withFileTypes: true }))
.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
for (const entry of entries) {
const candidate = path.join(directory, entry.name);
const entryMetadata = await lstat(candidate);
if (entryMetadata.isSymbolicLink()) {
throw new Error(`game source fingerprint refuses symbolic links: ${path.relative(root, candidate)}`);
}
if (entryMetadata.isDirectory()) {
await hashSourceDirectory(root, candidate, digest);
continue;
}
if (!entryMetadata.isFile()) {
throw new Error(`game source fingerprint refuses non-regular files: ${path.relative(root, candidate)}`);
}
updateHash(digest, path.relative(root, candidate).split(path.sep).join("/"));
updateHash(digest, await hashFile(candidate));
}
}
async function sourceFingerprint(root, projectPath, revision) {
const digest = createHash("sha256").update("codex-gb-studio\0web-export\0v1\0");
if (revision !== undefined) {
if (typeof revision !== "string" || revision.length === 0 || revision.length > 1_024 || revision.includes("\0")) {
throw new Error("Project revision must be a non-empty string of at most 1024 characters.");
}
updateHash(digest, "indexed-revision");
updateHash(digest, revision);
}
else {
updateHash(digest, path.relative(root, projectPath).split(path.sep).join("/"));
updateHash(digest, await hashFile(projectPath));
await hashSourceDirectory(root, path.join(root, "project"), digest);
await hashSourceDirectory(root, path.join(root, "assets"), digest);
}
// ProjectReadAccess revisions intentionally omit custom plugins, but those
// extensions can alter the real ROM and web template. Always include them.
updateHash(digest, "project-plugins");
await hashSourceDirectory(root, path.join(root, "plugins"), digest);
return digest.digest("hex");
}
async function collectArtifacts(root) {
const files = [];
let totalBytes = 0;
async function visit(directory) {
const entries = (await readdir(directory, { withFileTypes: true }))
.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
for (const entry of entries) {
const candidate = path.join(directory, entry.name);
const relative = path.relative(root, candidate).split(path.sep).join("/");
if (relative === MANIFEST_FILENAME)
continue;
const metadata = await lstat(candidate);
if (metadata.isSymbolicLink()) {
throw new Error(`Official game web export must not contain symbolic links: ${relative}`);
}
if (metadata.isDirectory()) {
await visit(candidate);
continue;
}
if (!metadata.isFile()) {
throw new Error(`Official game web export contains a non-regular artifact: ${relative}`);
}
files.push({ path: relative, sha256: await hashFile(candidate), sizeBytes: metadata.size });
totalBytes += metadata.size;
if (files.length > MAX_ARTIFACT_FILES || totalBytes > MAX_ARTIFACT_BYTES) {
throw new Error("Official game web export exceeds its bounded artifact budget.");
}
}
}
await visit(root);
return files;
}
async function validateArtifacts(root, sourceRevision, attestationKey) {
const files = await collectArtifacts(root);
const index = files.find((entry) => entry.path === "index.html" && entry.sizeBytes > 0);
if (!index)
throw new Error("Official game web export did not produce a real non-empty index.html.");
const scripts = files.filter((entry) => entry.path.toLowerCase().endsWith(".js") && entry.sizeBytes > 0);
if (scripts.length === 0)
throw new Error("Official game web export did not produce its JavaScript player.");
const wasmFiles = files.filter((entry) => entry.path.toLowerCase().endsWith(".wasm") && entry.sizeBytes >= 8);
let validWasm = false;
for (const candidate of wasmFiles) {
const bytes = await readFile(path.join(root, candidate.path));
if (bytes.subarray(0, WASM_MAGIC.length).equals(WASM_MAGIC)) {
validWasm = true;
break;
}
}
if (!validWasm)
throw new Error("Official game web export did not produce a valid WebAssembly emulator.");
const roms = files.filter((entry) => /\.(?:gb|gbc)$/iu.test(entry.path));
if (roms.length !== 1) {
throw new Error("Official game web export must contain exactly one genuine Game Boy ROM.");
}
const rom = roms[0];
const metadata = await inspectRom(path.join(root, rom.path), root);
if (!metadata.valid)
throw new Error("Official game web export contains an invalid Game Boy ROM.");
const unsigned = {
version: 1,
builder: "gb-studio-cli",
sourceRevision,
indexPath: index.path,
romPath: rom.path,
files,
};
const signature = createHmac("sha256", attestationKey)
.update("codex-gb-studio\0trusted-web-build\0v1\0")
.update(JSON.stringify(unsigned))
.digest("hex");
return { ...unsigned, signature };
}
async function readCachedManifest(root, sourceRevision, attestationKey) {
const manifestPath = path.join(root, MANIFEST_FILENAME);
try {
const metadata = await lstat(manifestPath);
if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_ARTIFACT_BYTES)
return null;
const decoded = JSON.parse(await readFile(manifestPath, "utf8"));
if (typeof decoded !== "object" || decoded === null ||
!("version" in decoded) || decoded.version !== 1 ||
!("builder" in decoded) || decoded.builder !== "gb-studio-cli" ||
!("sourceRevision" in decoded) || decoded.sourceRevision !== sourceRevision ||
!("indexPath" in decoded) || decoded.indexPath !== "index.html" ||
!("romPath" in decoded) || typeof decoded.romPath !== "string" ||
!("files" in decoded) || !Array.isArray(decoded.files) ||
!("signature" in decoded) || typeof decoded.signature !== "string" ||
!/^[a-f\d]{64}$/u.test(decoded.signature))
return null;
const actual = await validateArtifacts(root, sourceRevision, attestationKey);
if (decoded.romPath !== actual.romPath ||
JSON.stringify(decoded.files) !== JSON.stringify(actual.files) ||
!timingSafeEqual(Buffer.from(decoded.signature, "hex"), Buffer.from(actual.signature, "hex")))
return null;
return actual;
}
catch {
return null;
}
}
async function trustedCompilerTemporaryParent(projectRoot, runtime) {
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
const localData = runtime.localAppData ?? environmentValue(process.env, "LOCALAPPDATA", runtime.platform);
const candidates = runtime.platform === "win32"
? [...(localData ? [path.join(localData, "Temp")] : []), os.tmpdir()]
: [os.tmpdir(), "/tmp"];
const inspected = new Set();
for (const candidate of candidates) {
try {
assertSafePlatformPath(candidate, runtime.platform);
const provided = path.resolve(candidate);
const entry = await lstat(provided);
// /tmp is a system alias for /private/tmp on macOS; arbitrary
// environment-selected symbolic links are never trusted.
if (entry.isSymbolicLink() && (runtime.platform === "win32" || provided !== "/tmp"))
continue;
const canonical = await realpath(provided);
const identity = runtime.platform === "win32" ? canonical.toLowerCase() : canonical;
if (inspected.has(identity) || isInside(projectRoot, canonical, runtime.platform))
continue;
inspected.add(identity);
const metadata = await stat(canonical);
if (!metadata.isDirectory())
continue;
if (runtime.platform === "win32") {
if (await windowsPrivatePath(canonical, runtime, "temporary"))
return canonical;
continue;
}
const privateOwned = uid !== undefined && metadata.uid === uid && (metadata.mode & 0o022) === 0;
const stickySystem = (metadata.mode & 0o1000) !== 0 &&
(metadata.uid === 0 || (uid !== undefined && metadata.uid === uid));
if (privateOwned || stickySystem)
return canonical;
}
catch {
// Reject hostile, missing, and inaccessible environment candidates.
}
}
throw new Error("No trusted system temporary directory exists outside the selected native game project.");
}
async function createIsolatedCompilerDirectory(projectRoot, runtime, resources) {
const parent = await trustedCompilerTemporaryParent(projectRoot, runtime);
const directory = await mkdtemp(path.join(parent, "gbs-web-"));
resources.allocations.push({ kind: "temporary", path: directory, returnedAt: new Date().toISOString() });
resources.temporary = await resourceIdentity(directory);
try {
const metadata = await lstat(directory);
if (metadata.isSymbolicLink() || !metadata.isDirectory() ||
!samePath(await realpath(directory), directory, runtime.platform) ||
!samePath(path.dirname(directory), parent, runtime.platform) ||
isInside(projectRoot, directory, runtime.platform) ||
(runtime.platform === "win32" && !(await windowsPrivatePath(directory, runtime, "temporary")))) {
throw new Error("The official game web compiler temporary directory is unsafe.");
}
return { directory, parent };
}
catch (error) {
// The caller independently closes both allocations and preserves errors.
throw error;
}
}
async function removeIsolatedCompilerDirectory(directory, parent, runtime, expected) {
if (!samePath(await realpath(parent), parent, runtime.platform)) {
throw new Error("Refusing to remove a game web compiler directory from an altered temporary parent.");
}
if (!samePath(path.dirname(directory), parent, runtime.platform) ||
!/^gbs-web-[A-Za-z\d]{6}$/u.test(path.basename(directory))) {
throw new Error("Refusing to remove an unexpected game web compiler temporary directory.");
}
const metadata = await lstat(directory);
if (metadata.isSymbolicLink() || !metadata.isDirectory() ||
metadata.dev !== expected.identity.dev || metadata.ino !== expected.identity.ino ||
!samePath(await realpath(directory), directory, runtime.platform) ||
(runtime.platform === "win32" && !(await windowsPrivatePath(directory, runtime, "temporary")))) {
throw new Error("Refusing to remove a relocated game web compiler temporary directory.");
}
await rm(directory, { recursive: true, force: true });
}
async function resourceIdentity(directory) {
const entry = await lstat(directory);
if (!entry.isDirectory() || entry.isSymbolicLink() || await realpath(directory) !== directory) {
throw new Error("Refusing an unsafe web-build resource directory.");
}
return { path: directory, identity: { dev: entry.dev, ino: entry.ino, uid: entry.uid, mode: entry.mode }, cleanup: "pending" };
}
async function verifyResource(resource) {
const actual = await resourceIdentity(resource.path);
if (JSON.stringify(actual.identity) !== JSON.stringify(resource.identity)) {
throw new Error("Refusing to remove a replaced web-build resource directory.");
}
}
async function sampleResource(resource) {
const sample = {
at: new Date().toISOString(), entries: 0, logicalBytes: 0, allocatedBytes: 0, complete: false,
};
resource.snapshot = sample;
try {
await verifyResource(resource);
const visit = async (candidate) => {
const entry = await lstat(candidate);
if (++sample.entries > 32_768)
throw new Error("Resource sample entry limit reached.");
sample.logicalBytes += entry.size;
sample.allocatedBytes += entry.blocks * 512;
if (sample.logicalBytes > MAX_ARTIFACT_BYTES)
throw new Error("Resource sample byte limit reached.");
if (entry.isDirectory() && !entry.isSymbolicLink()) {
for (const name of await readdir(candidate))
await visit(path.join(candidate, name));
}
};
await visit(resource.path);
await verifyResource(resource);
sample.complete = true;
}
catch (error) {
sample.error = error instanceof Error ? error.message : String(error);
}
}
function exportIdentity(manifest) {
return {
rom: { ...manifest.files.find((entry) => entry.path === manifest.romPath) },
index: { ...manifest.files.find((entry) => entry.path === manifest.indexPath) },
inventorySha256: createHash("sha256").update(JSON.stringify(manifest.files)).digest("hex"),
files: manifest.files.length,
logicalBytes: manifest.files.reduce((sum, entry) => sum + entry.sizeBytes, 0),
};
}
/** Preserve resource outcomes for public callers while keeping thrown validation errors. */
export function webBuildFailureResult(error) {
return error instanceof Error && "webBuildResult" in error
? error.webBuildResult : undefined;
}
function compilerEnvironment(temporaryDirectory, runtime) {
const result = sanitizeSubprocessEnvironment(process.env, runtime.platform, temporaryDirectory);
const disallowed = new Set([
"NODE_OPTIONS", "NODE_PATH", "NODE_EXTRA_CA_CERTS", "NODE_REPL_HISTORY",
"BASH_ENV", "ENV", "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
"PYTHONUSERBASE", "PYTHONWARNINGS", "VIRTUAL_ENV",
]);
for (const name of Object.keys(result)) {
const normalized = name.toUpperCase();
if (disallowed.has(normalized) || normalized.startsWith("UV_") ||
normalized === "TMPDIR" || normalized === "TMP" || normalized === "TEMP")
delete result[name];
}
result.TMPDIR = temporaryDirectory;
result.TMP = temporaryDirectory;
result.TEMP = temporaryDirectory;
return result;
}
function commandFailure(error) {
if (!(error instanceof Error))
return { exitCode: null, stdout: "", stderr: String(error) };
const failure = error;
return {
exitCode: typeof failure.code === "number" ? failure.code : null,
stdout: failure.stdout?.toString() ?? "",
stderr: failure.stderr?.toString() || failure.message,
};
}
async function retryPlatformRename(projectRoot, source, destination, runtime, expectedSource) {
const attempts = runtime.platform === "win32" ? MAX_WINDOWS_PUBLISH_RETRIES : 1;
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (runtime.platform === "win32") {
for (const candidate of [source, destination]) {
assertSafePlatformPath(candidate, runtime.platform);
if (!isInside(projectRoot, candidate, runtime.platform)) {
throw new Error("Refusing to publish a web export outside the selected project.");
}
const parent = path.dirname(candidate);
const parentMetadata = await lstat(parent);
if (parentMetadata.isSymbolicLink() || !parentMetadata.isDirectory() ||
!samePath(await realpath(parent), parent, runtime.platform) ||
!isInside(projectRoot, parent, runtime.platform)) {
throw new Error("Refusing to publish through a relocated or reparse-point web output parent.");
}
}
const sourceMetadata = await lstat(source);
if (sourceMetadata.isSymbolicLink() || !sourceMetadata.isDirectory() ||
!samePath(await realpath(source), source, runtime.platform)) {
throw new Error("Refusing to publish a relocated or reparse-point web output directory.");
}
if (await exists(destination)) {
throw new Error("Refusing to overwrite an unexpected web output inserted during publication.");
}
}
try {
// Sampling is best effort; publication must authenticate the allocation
// immediately before each rename, including a platform retry.
if (expectedSource)
await verifyResource(expectedSource);
await runtime.rename(source, destination);
return;
}
catch (error) {
const retryable = isSystemError(error, "EPERM") || isSystemError(error, "EBUSY") ||
isSystemError(error, "EACCES") || isSystemError(error, "ENOTEMPTY");
if (runtime.platform !== "win32" || !retryable || attempt + 1 >= attempts)
throw error;
const delay = runtime.retryDelayMs * (attempt + 1);
if (delay > 0)
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
async function replaceOutput(root, staged, output, runtime, resources) {
await resolveExistingAncestor(root, path.dirname(output), runtime);
const staging = resources.staging;
if (!staging || staging.path !== staged)
throw new Error("Web publication has no matching staging allocation.");
// Refuse before moving an existing export aside as well as at the rename.
await verifyResource(staging);
if (!(await exists(output))) {
await retryPlatformRename(root, staged, output, runtime, staging);
resources.staging.cleanup = "published";
return;
}
const metadata = await lstat(output);
if (metadata.isSymbolicLink() || !metadata.isDirectory() ||
!samePath(await realpath(output), output, runtime.platform)) {
throw new Error("Refusing to replace a relocated or symbolic-link game web output.");
}
const backup = await mkdtemp(path.join(path.dirname(output), ".web-previous-"));
resources.allocations.push({ kind: "previous", path: backup, returnedAt: new Date().toISOString() });
resources.previous = await resourceIdentity(backup);
await verifyResource(resources.previous);
await rm(backup, { recursive: true, force: true });
resources.previous.cleanup = "removed";
await retryPlatformRename(root, output, backup, runtime);
resources.previous = await resourceIdentity(backup);
resources.previous.cleanup = "retained";
await sampleResource(resources.previous);
try {
await retryPlatformRename(root, staged, output, runtime, staging);
resources.staging.cleanup = "published";
}
catch (error) {
try {
await verifyResource(resources.previous);
await retryPlatformRename(root, backup, output, runtime);
resources.previous.cleanup = "restored";
}
catch (rollbackError) {
resources.previous.error = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
resources.cleanupErrors.push(`Previous export rollback: ${resources.previous.error}`);
}
throw error;
}
await verifyResource(resources.previous);
await rm(backup, { recursive: true, force: true });
resources.previous.cleanup = "removed";
}
async function withOutputLock(output, operation) {
const previous = BUILD_LOCKS.get(output) ?? Promise.resolve();
const current = previous.catch(() => { }).then(operation);
const cleanup = current.then(() => { }, () => { });
BUILD_LOCKS.set(output, cleanup);
try {
return await current;
}
finally {
if (BUILD_LOCKS.get(output) === cleanup)
BUILD_LOCKS.delete(output);
}
}
/** Export the real, official game/Binjgb web player without inventing an HTML wrapper. */
export async function buildWeb(options, platformOptions = {}) {
const runtime = runtimeOptions(platformOptions);
assertSafePlatformPath(options.projectRoot, runtime.platform);
const root = await realpath(path.resolve(options.projectRoot));
if (!(await stat(root)).isDirectory())
throw new Error("The selected project root must be a real directory.");
if (options.force !== undefined && typeof options.force !== "boolean") {
throw new Error("force must be a boolean.");
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > MAX_TIMEOUT_MS) {
throw new Error(`timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}.`);
}
const source = await resolveExistingFile(root, options.projectPath, runtime);
if (path.extname(source).toLowerCase() !== ".gbsproj") {
throw new Error("The official game web exporter requires a .gbsproj project file.");
}
const outputPath = await resolveOutput(root, options.outputPath ?? path.join("build", "web"), runtime);
return withOutputLock(outputPath, async () => {
const sourceRevision = await sourceFingerprint(root, source, options.revision);
const official = await findOfficialCli(root, runtime, options.toolchainRoot);
const cli = official.cliPath;
const attestationKey = await signingKey(root, official.toolchainRoot, runtime);
const command = process.execPath;
const publishedArgs = [cli, "make:web", source, outputPath];
const startedAt = performance.now();
const resources = {
allocations: [],
attestation: {
storage: attestationKey === PROCESS_WEB_BUILD_ATTESTATION_KEY ? "process-only" : "trusted-key-reused-or-created",
// A process-local fallback does not prove that no durable allocation was attempted.
persistentKeyBytes: attestationKey === PROCESS_WEB_BUILD_ATTESTATION_KEY ? null : 32,
allocationObserved: false,
},
measurementScope: "bounded post-compiler samples, not peak usage; attestation directory allocation unobserved",
cleanupErrors: [],
};
const result = {
success: false, builder: "gb-studio-cli", command, args: publishedArgs,
exitCode: null, stdout: "", stderr: "", durationMs: 0, outputPath,
indexPath: null, romPath: null, cached: false, sourceRevision,
sourceIdentityScope: options.revision === undefined
? "descriptor, project, assets and plugin contents" : "selected indexed revision plus plugin contents",
execution: { status: "unrun" }, resources,
};
let failure;
let temporary;
try {
if (options.signal?.aborted)
throw Object.assign(new Error("The web build was cancelled before compilation."), { name: "AbortError" });
if (!options.force && (await exists(outputPath))) {
const cached = await readCachedManifest(outputPath, sourceRevision, attestationKey);
if (cached) {
if (await sourceFingerprint(root, source, options.revision) !== sourceRevision) {
throw new Error("Selected web-build source revision or plugin contents changed during cache validation.");
}
result.success = true;
result.cached = true;
result.indexPath = path.join(outputPath, cached.indexPath);
result.romPath = path.join(outputPath, cached.romPath);
result.exportIdentity = exportIdentity(cached);
return result;
}
}
if (options.cacheOnly)
throw new Error("No unchanged, verified web export is available. Cache-only preview will not compile or replace it.");
await mkdir(path.dirname(outputPath), { recursive: true });
await resolveExistingAncestor(root, path.dirname(outputPath), runtime);
const staging = await mkdtemp(path.join(path.dirname(outputPath), ".web-stage-"));
resources.allocations.push({ kind: "staging", path: staging, returnedAt: new Date().toISOString() });
resources.staging = await resourceIdentity(staging);
temporary = await createIsolatedCompilerDirectory(root, runtime, resources);
const actualArgs = [cli, "make:web", source, staging];
const child = { descendants: "unobserved" };
result.execution = { status: "attempted", args: actualArgs, cwd: root, child };
try {
const output = await runBuildCommand(command, actualArgs, {
cwd: root, timeoutMs, environment: compilerEnvironment(temporary.directory, runtime),
signal: options.signal, evidence: child,
});
result.stdout = output.stdout;
result.stderr = output.stderr;
result.exitCode = child.close?.code ?? null;
}
catch (error) {
Object.assign(result, commandFailure(error));
result.error = error instanceof Error ? error.message : String(error);
return result;
}
// This is a second selected-input fingerprint, not a continuous source lock.
if (await sourceFingerprint(root, source, options.revision) !== sourceRevision) {
throw new Error("Selected web-build source revision or plugin contents changed during compilation.");
}
const manifest = await validateArtifacts(staging, sourceRevision, attestationKey);
await writeFile(path.join(staging, MANIFEST_FILENAME), JSON.stringify(manifest, null, 2) + "\n", {
encoding: "utf8", flag: "wx", mode: 0o600,
});
await sampleResource(resources.staging);
await replaceOutput(root, staging, outputPath, runtime, resources);
result.success = true;
result.indexPath = path.join(outputPath, manifest.indexPath);
result.romPath = path.join(outputPath, manifest.romPath);
result.exportIdentity = exportIdentity(manifest);
return result;
}
catch (error) {
failure = error;
result.error = error instanceof Error ? error.message : String(error);
throw error;
}
finally {
const child = result.execution.child;
if (child?.spawnedAt)
result.execution.status = "started";
const unsettled = child?.cleanupWarning ?? (child?.startedAt && !child.close ? "Compiler close was not observed." : undefined);
// One cleanup failure must neither hide the original error nor skip the other allocation.
for (const [kind, resource] of [["staging", resources.staging], ["temporary", resources.temporary]]) {
if (!resource || resource.cleanup === "published")
continue;
if (unsettled) {
resource.cleanup = "unresolved";
resource.error = unsettled;
resources.cleanupErrors.push(kind + ": " + unsettled);
continue;
}
try {
await sampleResource(resource);
await verifyResource(resource);
if (kind === "temporary") {
await removeIsolatedCompilerDirectory(resource.path, temporary?.parent ?? path.dirname(resource.path), runtime, resource);
}
else
await rm(resource.path, { recursive: true, force: true });
resource.cleanup = "removed";
}
catch (error) {
resource.cleanup = "unresolved";
resource.error = error instanceof Error ? error.message : String(error);
resources.cleanupErrors.push(kind + ": " + resource.error);
}
}
if (resources.cleanupErrors.length) {
result.success = false;
result.error ??= "Web-build resource cleanup was not confirmed.";
}
result.durationMs = Math.round(performance.now() - startedAt);
if (failure instanceof Error)
Object.assign(failure, { webBuildResult: result });
}
});
}
//# sourceMappingURL=web-build.js.mapSHA-256: 774c131f69e4720a19d4b1052d698ec3e93779432f8bc50ad400dd32489731e3