← Files ModRetro Chromatic PluginARCHIVED FILE
dist/web-preview.js
68.4 KB · Oct 2, 2026 · 00:37 UTC
import { PreviewRecordingBroker } from "./web-preview-recording.js";
import { parseRecordingSaved } from "./web-annotations/recording-protocol.js";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, realpath } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { CHROMATIC_PACKED_V2, readPackedDeviceArtwork } from "../scripts/chromatic-runtime.mjs";
import { pipeline } from "node:stream/promises";
import { assertSafePlatformPath, isPathWithinRoot } from "./platform.js";
import { PreviewStateStore } from "./web-preview-states.js";
import { PreviewCaptureStore } from "./web-preview-captures.js";
import { PreviewPlayerControl, PreviewPlayerControlError } from "./web-preview-player.js";
import { PreviewFlashControl, PreviewFlashError } from "./web-preview-flash.js";
import { ChromaticDeviceError } from "./chromatic.js";
import { MAX_SAVE_STATE_FILE_BYTES } from "./web-annotations/save-state.js";
import { Readable } from "node:stream";
import { listenLoopback } from "./loopback-listener.js";
const LOOPBACK_ADDRESS = "127.0.0.1";
const CAPABILITY_BYTES = 32;
const MAX_REQUEST_TARGET_LENGTH = 8192;
const MAX_PATH_SEGMENTS = 64;
const MAX_STATIC_FILE_BYTES = 64 * 1024 * 1024;
const MAX_ANNOTATION_FILE_BYTES = 1024 * 1024;
const ANNOTATION_MOUNT = "codex-annotations";
const PLAYER_MOUNT = "codex-player-control";
const STATE_MOUNT = "codex-states";
const DEVICE_MOUNT = "codex-device";
const DEVICE_VIEW_MOUNT = "codex-device-view";
const ACTIVATION_MOUNT = "codex-activation";
const CAPTURE_MOUNT = "codex-captures";
const MAX_CAPTURE_METADATA_BYTES = 2 * 1024 * 1024;
const MAX_CAPTURE_UPLOAD_BYTES = 128 * 1024 * 1024 + MAX_CAPTURE_METADATA_BYTES + 16 * 1024;
const ANNOTATION_FILES = ["bridge.js", "native.js", "targets.js", "player.js", "view.js", "save-state.js", "input.js", "remote.js", "audio.js", "flash.js", "flash-diagnostics.js", "recording.js", "recording-protocol.js", "recording-controller.js", "error-dialog.js", "input-protocol.js", "input-owner.js"];
const DEVICE_IMAGES = ["codex", "cloud", "midnight", "wave", "leaf", "inferno", "volt", "bubblegum"].map(color => `chromatic-${color}.webp`);
const MAX_DEVICE_IMAGE_BYTES = 3 * 1024 * 1024;
const MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gb": "application/octet-stream",
".gbc": "application/octet-stream",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".otf": "font/otf",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "font/ttf",
".wasm": "application/wasm",
".wav": "audio/wav",
".webmanifest": "application/manifest+json; charset=utf-8",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
};
function recordingResult(value, active) {
if (!value || typeof value !== "object")
return value;
const result = structuredClone(value);
if (!result.saved || typeof result.saved !== "object")
return result;
const binding = result.binding, identity = active?.annotations?.players?.identityForInput();
const live = !!binding && !!active?.server.listening && !!active.annotations?.captures && binding.listenerId === active.status.listenerId &&
binding.romSha256 === identity?.romSha256 && binding.runtimeSha256 === identity?.runtimeSha256 && binding.sourceRevision === identity?.sourceRevision;
if (!live) {
const saved = result.saved;
delete saved.url;
delete saved.metadataUrl;
result.linksAvailable = false;
}
else
result.linksAvailable = true;
return result;
}
function pathsMatch(left, right, platform) {
if (platform !== "win32")
return left === right;
return path.win32.normalize(left).toLowerCase() === path.win32.normalize(right).toLowerCase();
}
function secureHeaders(response) {
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
response.setHeader("Referrer-Policy", "no-referrer");
}
function reject(response, status, message, allow = "GET, HEAD") {
if (response.headersSent) {
response.destroy();
return;
}
secureHeaders(response);
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
if (status === 405)
response.setHeader("Allow", allow);
response.statusCode = status;
response.end(response.req.method === "HEAD" ? undefined : message);
}
function hasSingleHeader(request, name) {
let count = 0;
for (let index = 0; index < request.rawHeaders.length; index += 2) {
if (request.rawHeaders[index]?.toLowerCase() === name)
count += 1;
}
return count === 1;
}
function isAuthorizedRequest(request, port) {
const authority = `${LOOPBACK_ADDRESS}:${port}`;
if (!hasSingleHeader(request, "host") || request.headers.host !== authority)
return false;
if (request.headers.origin !== undefined) {
if (!hasSingleHeader(request, "origin") || request.headers.origin !== `http://${authority}`) {
return false;
}
}
return true;
}
function matchesCapability(received, expected) {
if (!/^[a-f0-9]{64}$/.test(received))
return false;
return timingSafeEqual(Buffer.from(received, "ascii"), Buffer.from(expected, "ascii"));
}
function relativeAssetPath(target, capability, platform) {
if (!target.startsWith("/") || target.startsWith("//") || target.includes("#"))
return null;
const pathname = target.split("?", 1)[0];
if (pathname === undefined)
return null;
const segments = pathname.slice(1).split("/");
if (segments.length > MAX_PATH_SEGMENTS)
return null;
const token = segments.shift();
if (token === undefined || !matchesCapability(token, capability))
return null;
if (segments.length === 0)
return null;
if (segments.length === 1 && segments[0] === "")
return "index.html";
const decoded = [];
for (const segment of segments) {
let value;
try {
value = decodeURIComponent(segment);
}
catch {
return null;
}
if (value.length === 0 ||
value.length > 255 ||
value.startsWith(".") ||
value.includes("/") ||
value.includes("\\") ||
value.includes("\0")) {
return null;
}
if (platform === "win32") {
try {
assertSafePlatformPath(value, platform);
}
catch {
return null;
}
}
decoded.push(value);
}
return decoded.join(path.sep);
}
function assetContentType(relative) {
const extension = path.extname(relative).toLowerCase();
if (extension === ".html" && relative !== "index.html")
return undefined;
// Native project descriptors, lockfiles, and source maps never belong in the browser export.
if (extension === ".json") {
const basename = path.basename(relative).toLowerCase();
if (basename !== "gbstudio.json" && basename !== "manifest.json")
return undefined;
}
return MIME_TYPES[extension];
}
async function verifyAssetPath(root, relative, platform) {
try {
if (platform === "win32") {
assertSafePlatformPath(root, platform);
assertSafePlatformPath(relative, platform);
}
const rootMetadata = await lstat(root);
if (!rootMetadata.isDirectory() ||
rootMetadata.isSymbolicLink() ||
!pathsMatch(await realpath(root), root, platform)) {
return null;
}
let candidate = root;
const components = relative.split(path.sep);
for (let index = 0; index < components.length; index += 1) {
candidate = path.join(candidate, components[index]);
if (!isPathWithinRoot(root, candidate, platform))
return null;
const metadata = await lstat(candidate);
if (metadata.isSymbolicLink())
return null;
if (index < components.length - 1 && !metadata.isDirectory())
return null;
if (index === components.length - 1 && !metadata.isFile())
return null;
// Some Windows reparse points do not report themselves as conventional symbolic links.
// Resolve every existing component so junctions and mount points cannot redirect a path.
if (platform === "win32" && !pathsMatch(await realpath(candidate), candidate, platform)) {
return null;
}
}
const canonical = await realpath(candidate);
if (!pathsMatch(canonical, candidate, platform) || !isPathWithinRoot(root, canonical, platform)) {
return null;
}
return canonical;
}
catch {
return null;
}
}
async function readAnnotationFile(root, relative, platform, maximum = MAX_ANNOTATION_FILE_BYTES) {
const filename = await verifyAssetPath(root, relative, platform);
if (filename === null)
throw new Error(`Annotation input must be a canonical regular file: ${relative}`);
// Nonblocking open also prevents a regular file swapped for a FIFO from
// holding startup before the descriptor's type can be checked.
const handle = await open(filename, constants.O_RDONLY | (platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK));
try {
const before = await handle.stat();
if (!before.isFile() || !Number.isSafeInteger(before.size) || before.size < 1 || before.size > maximum) {
throw new Error(`Annotation input must contain 1–${maximum} bytes: ${relative}`);
}
// The extra byte detects growth without allowing a replaced or growing file
// to turn this bounded startup read into an unbounded allocation.
const bytes = Buffer.alloc(before.size + 1);
let offset = 0;
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (bytesRead === 0)
break;
offset += bytesRead;
}
const after = await handle.stat();
const canonical = await realpath(filename);
const named = await lstat(filename);
if (!pathsMatch(canonical, filename, platform) || !isPathWithinRoot(root, canonical, platform) ||
named.isSymbolicLink() || !named.isFile() || before.dev !== named.dev || before.ino !== named.ino ||
offset !== before.size || after.size !== before.size || named.size !== before.size ||
after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ||
named.mtimeMs !== after.mtimeMs || named.ctimeMs !== after.ctimeMs) {
throw new Error(`Annotation input changed while reading it: ${relative}`);
}
return bytes.subarray(0, offset);
}
finally {
await handle.close();
}
}
function annotationContext(options, platform) {
if (typeof options !== "object" || options === null || typeof options.assetsRoot !== "string" || !options.assetsRoot.trim()) {
throw new Error("Annotations require a trusted compiled assets directory.");
}
const { romPath, romSha256, sourceRevision } = options;
if (typeof romPath !== "string" || romPath.length === 0 || romPath.length > 1024 ||
/[\\:%?#\u0000-\u001f]/u.test(romPath) || path.posix.isAbsolute(romPath) || path.win32.isAbsolute(romPath) ||
romPath.split("/").length > MAX_PATH_SEGMENTS ||
romPath.split("/").some((part) => !part || part.startsWith(".") || part.length > 255) ||
!/\.(?:gb|gbc)$/iu.test(romPath)) {
throw new Error("Annotation ROM path must name a web-export-relative .gb or .gbc file.");
}
if (platform === "win32")
assertSafePlatformPath(romPath, platform);
if (typeof romSha256 !== "string" || !/^[a-f0-9]{64}$/iu.test(romSha256)) {
throw new Error("Annotations require the exported ROM SHA-256.");
}
if (typeof sourceRevision !== "string" || sourceRevision.length === 0 || sourceRevision.length > 1024) {
throw new Error("Annotations require a bounded source revision.");
}
return { romPath, romSha256: romSha256.toLowerCase(), sourceRevision };
}
async function prepareAnnotations(outputRoot, options, platform, flashEnabled, deviceViewEnabled, deviceCaptureAvailability) {
const context = {
...annotationContext(options, platform), ...(options.projectRoot ? { playerPath: PLAYER_MOUNT } : {}),
};
const assetsRoot = path.resolve(options.assetsRoot);
if (platform === "win32")
assertSafePlatformPath(assetsRoot, platform);
const directory = await lstat(assetsRoot);
if (!directory.isDirectory() || directory.isSymbolicLink() || !pathsMatch(await realpath(assetsRoot), assetsRoot, platform)) {
throw new Error("Annotation assets must use a canonical directory without links or reparse points.");
}
const assets = new Map();
const assetHashes = [];
for (const filename of ANNOTATION_FILES) {
const bytes = await readAnnotationFile(assetsRoot, filename, platform);
const sha256 = createHash("sha256").update(bytes).digest("hex");
assets.set(filename, { bytes, etag: `"sha256-${sha256}"`, contentType: "text/javascript; charset=utf-8" });
assetHashes.push({ path: filename, sha256 });
}
if (options.deviceAssetsRoot !== undefined) {
const deviceRoot = path.resolve(options.deviceAssetsRoot);
const packed = options.deviceAssetsFormat === CHROMATIC_PACKED_V2.format
? readPackedDeviceArtwork(deviceRoot) : undefined;
for (const filename of DEVICE_IMAGES) {
const bytes = packed ? packed.get(filename)
: await readAnnotationFile(deviceRoot, filename, platform, MAX_DEVICE_IMAGE_BYTES);
if (!bytes || bytes.length > MAX_DEVICE_IMAGE_BYTES)
throw new Error(`Missing or oversized device artwork: ${filename}`);
if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") {
throw new Error(`Device artwork must be a WebP: ${filename}`);
}
const sha256 = createHash("sha256").update(bytes).digest("hex");
assets.set(filename, { bytes, etag: `"sha256-${sha256}"`, contentType: "image/webp" });
assetHashes.push({ path: filename, sha256 });
}
}
const index = await readAnnotationFile(outputRoot, "index.html", platform);
let states;
let captureCartridgeType = 0;
let captures;
let flash;
let bootstrap = "";
if (options.projectRoot !== undefined) {
if (deviceViewEnabled) {
context.deviceView = true;
if (deviceCaptureAvailability)
context.deviceCaptureAvailability = deviceCaptureAvailability;
}
const [rom, runtime] = await Promise.all([
readAnnotationFile(outputRoot, context.romPath.split("/").join(path.sep), platform, 8 * 1024 * 1024),
readAnnotationFile(outputRoot, path.join("js", "binjgb.wasm"), platform, 16 * 1024 * 1024),
]);
if (rom.length < 0x150 || createHash("sha256").update(rom).digest("hex") !== context.romSha256) {
throw new Error("Saved states require the verified exported cartridge.");
}
if (flashEnabled) {
if (options.romSizeBytes !== rom.length)
throw new Error("Device flashing requires the exact verified export size.");
context.flash = { generation: randomBytes(16).toString("hex") };
flash = { generation: context.flash.generation, projectRoot: path.resolve(options.projectRoot),
romPath: path.join(outputRoot, ...context.romPath.split("/")), romSha256: context.romSha256, romSizeBytes: rom.length };
}
context.runtimeSha256 = createHash("sha256").update(runtime).digest("hex");
const runtimeName = `binjgb-${context.runtimeSha256}.wasm`;
assets.set(runtimeName, { bytes: runtime, etag: `"sha256-${context.runtimeSha256}"`, contentType: "application/wasm" });
// The actual official player receives these verified bytes, so later
// captures cannot label an old loaded core with a newly fetched hash.
bootstrap = `<script>(function(){const expected=${JSON.stringify(context.runtimeSha256)};` +
`const factory=Binjgb;const bytes=fetch(${JSON.stringify(`./${ANNOTATION_MOUNT}/${runtimeName}`)}).then(async response=>{` +
`if(!response.ok)throw new Error("The game runtime is unavailable.");const bytes=new Uint8Array(await response.arrayBuffer());` +
`const hash=Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",bytes)),v=>v.toString(16).padStart(2,"0")).join("");` +
`if(hash!==expected)throw new Error("The game runtime changed.");return bytes;});` +
`Binjgb=options=>Promise.all([bytes,import(${JSON.stringify(`./${ANNOTATION_MOUNT}/input-owner.js`)})]).then(([wasmBinary,input])=>factory({...options,wasmBinary}).then(module=>{module.codexRuntimeSha256=expected;input.installInputOwner(module);return module;}));})();</script>\n`;
states = new PreviewStateStore({
projectRoot: options.projectRoot, platform,
identity: { romSha256: context.romSha256, sourceRevision: context.sourceRevision,
runtimeSha256: context.runtimeSha256, cartridgeType: rom[0x147] },
});
captureCartridgeType = rom[0x147];
context.captures = { generation: randomBytes(16).toString("hex") };
captures = { generation: context.captures.generation, store: new PreviewCaptureStore({
projectRoot: options.projectRoot, platform,
identity: { romSha256: context.romSha256, sourceRevision: context.sourceRevision,
runtimeSha256: context.runtimeSha256, cartridgeType: rom[0x147] },
}) };
}
const reuseKey = createHash("sha256").update(JSON.stringify({ projectRoot: options.projectRoot,
context: { ...context, captures: context.captures ? true : undefined, flash: context.flash ? true : undefined },
indexSha256: createHash("sha256").update(index).digest("hex"), assets: assetHashes,
})).digest("hex");
const fingerprint = createHash("sha256").update(JSON.stringify({
version: 1, context, indexSha256: createHash("sha256").update(index).digest("hex"), assets: assetHashes,
})).digest("hex");
// JSON in a raw-text script element must not contain an HTML closing tag.
const serialized = JSON.stringify(context).replace(/</gu, "\\u003c").replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029");
const injection = `\n<script type="application/json" id="codex-web-annotations-context">${serialized}</script>\n` +
`<script type="module" src="./${ANNOTATION_MOUNT}/bridge.js"></script>\n`;
let html = index.toString("utf8");
if (bootstrap) {
const playerScript = /<script\b[^>]*\bsrc=(["'])(?:\.\/)?js\/script\.js\1[^>]*>\s*<\/script>/iu.exec(html);
if (!playerScript)
throw new Error("Saved states require the official game browser player.");
html = html.slice(0, playerScript.index) + bootstrap + html.slice(playerScript.index);
}
const closingBody = [...html.matchAll(/<\/body\s*>/giu)].at(-1)?.index ?? html.length;
const bytes = Buffer.from(html.slice(0, closingBody) + injection + html.slice(closingBody));
return {
reuseKey,
fingerprint,
assets,
...(flash ? { flash } : {}),
...(captures ? { captures } : {}),
...(states ? { states, players: new PreviewPlayerControl({ romSha256: context.romSha256, sourceRevision: context.sourceRevision, runtimeSha256: context.runtimeSha256 }, path.resolve(options.projectRoot), captureCartridgeType), projectRoot: path.resolve(options.projectRoot) } : {}),
index: { bytes, etag: `"annotations-${fingerprint}"`, contentType: "text/html; charset=utf-8" },
};
}
function serveBufferedAsset(request, response, asset) {
secureHeaders(response);
response.setHeader("Cache-Control", "private, no-cache, must-revalidate");
response.setHeader("ETag", asset.etag);
if (request.headers["if-none-match"] === asset.etag) {
response.writeHead(304);
response.end();
return;
}
response.setHeader("Content-Type", asset.contentType);
response.setHeader("Content-Length", asset.bytes.length);
response.writeHead(200);
response.end(request.method === "HEAD" ? undefined : asset.bytes);
}
function copyStatus(status) {
return { ...status, ...(status.annotations ? { annotations: { ...status.annotations } } : {}) };
}
function stateResponse(request, response, status, value) {
secureHeaders(response);
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.statusCode = status;
response.end(request.method === "HEAD" ? undefined : JSON.stringify(value));
}
/** Only this preview's verified ROM is eligible; the browser never supplies a path. */
async function serveDevice(request, response, active, relative) {
const annotations = active.annotations;
const binding = annotations?.flash;
if (!active.flash || !binding || relative !== DEVICE_MOUNT) {
reject(response, 404, "Not found");
return;
}
const current = () => active.annotations === annotations && active.server.listening && active.acceptingStateRequests && active.isProjectSelected(binding.projectRoot);
if (request.method !== "POST") {
reject(response, 405, "Method not allowed", "POST");
return;
}
const length = request.headers["content-length"];
if (request.headers.origin !== `http://${LOOPBACK_ADDRESS}:${active.status.port}` ||
!hasSingleHeader(request, "x-codex-preview-device") || request.headers["x-codex-preview-device"] !== "1" ||
!hasSingleHeader(request, "content-type") || request.headers["content-type"] !== "application/json" ||
request.headers["content-encoding"] !== undefined || request.headers["transfer-encoding"] !== undefined ||
!hasSingleHeader(request, "content-length") || typeof length !== "string" || !/^[1-9][0-9]*$/.test(length)) {
reject(response, 400, "Invalid device request");
return;
}
const maximum = 4096;
if (!Number.isSafeInteger(Number(length)) || Number(length) > maximum) {
reject(response, 413, "Device request is too large");
return;
}
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > Number(length) || size > maximum) {
reject(response, 413, "Device request is too large");
return;
}
chunks.push(Buffer.from(chunk));
}
if (size !== Number(length)) {
reject(response, 400, "Incomplete device request");
return;
}
let body;
try {
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
if (!current()) {
const recovered = active.flash.recover(body, binding);
if (recovered)
stateResponse(request, response, 200, recovered);
else
stateResponse(request, response, 409, { error: "The preview or selected project changed. Reload the preview.", code: "PREVIEW_CHANGED" });
return;
}
const result = active.flash.execute(body, binding);
stateResponse(request, response, 200, result);
}
catch (error) {
const known = error instanceof PreviewFlashError || error instanceof ChromaticDeviceError;
const input = body && typeof body === "object" ? body : undefined;
const notAdmitted = (input?.action === "discover" || input?.action === "flash") && !active.flash.hasRequest(input.requestId) &&
(error instanceof PreviewFlashError || error instanceof ChromaticDeviceError && [
"CHROMATIC_SELECTION_EXPIRED", "CHROMATIC_INVALID_INPUT", "CHROMATIC_INVALID_ROM_BINDING",
"CHROMATIC_CONFIRMATION_REQUIRED", "CHROMATIC_CLOSING", "CHROMATIC_BUSY", "CHROMATIC_SESSION_CHANGED",
].includes(error.code));
stateResponse(request, response, known ? 409 : 400, {
error: known ? error.message : "The device request could not be completed. Check its original status before trying anything else.",
...(known ? { code: error.code } : {}),
...(notAdmitted ? { admitted: false } : {}),
});
}
}
/** Secret bodies never enter the device fingerprint, raw journal or error formatter. */
/** Polling stays private to this preview; only the plugin can queue commands. */
async function servePlayer(request, response, active, relative) {
const annotations = active.annotations;
const players = annotations?.players;
const states = annotations?.states;
if (!players || relative !== PLAYER_MOUNT) {
reject(response, 404, "Not found");
return;
}
const current = () => active.annotations === annotations && active.server.listening && active.acceptingStateRequests;
if (!current()) {
reject(response, 409, "The player is being reopened");
return;
}
if (request.method !== "POST") {
reject(response, 405, "Method not allowed", "POST");
return;
}
const length = request.headers["content-length"];
if (request.headers.origin !== `http://${LOOPBACK_ADDRESS}:${active.status.port}` ||
!hasSingleHeader(request, "x-codex-preview-state") || request.headers["x-codex-preview-state"] !== "1" ||
!hasSingleHeader(request, "content-type") || request.headers["content-type"] !== "application/json" ||
request.headers["content-encoding"] !== undefined || request.headers["transfer-encoding"] !== undefined ||
!hasSingleHeader(request, "content-length") || typeof length !== "string" || !/^[1-9][0-9]*$/.test(length)) {
reject(response, 400, "Invalid player-control request");
return;
}
const maximum = MAX_SAVE_STATE_FILE_BYTES + 8192;
if (Number(length) > maximum) {
reject(response, 413, "Player-control response is too large");
return;
}
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > Number(length) || size > maximum) {
reject(response, 413, "Player-control response is too large");
return;
}
chunks.push(Buffer.from(chunk));
}
if (size !== Number(length)) {
reject(response, 400, "Incomplete player-control response");
return;
}
if (!current()) {
reject(response, 409, "The player changed");
return;
}
try {
const body = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
const decoded = JSON.parse(body);
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded))
throw new Error("Invalid player-control request.");
const { initialize, freshStart, snapshot, ...poll } = decoded;
if ((initialize !== undefined && typeof initialize !== "boolean") ||
(freshStart !== undefined && (typeof freshStart !== "boolean" || initialize !== true)) ||
(snapshot !== undefined && (typeof snapshot !== "string" || Buffer.byteLength(snapshot) > MAX_SAVE_STATE_FILE_BYTES || poll.result !== undefined))) {
throw new Error("Invalid automatic-save request.");
}
if (Buffer.byteLength(JSON.stringify({ inputStatus: poll.inputStatus, inputActive: poll.inputActive, inputResult: poll.inputResult })) > 4096) {
throw new Error("Browser input fields exceed their size limit.");
}
const retiring = !!poll.recordingStatus && typeof poll.recordingStatus === "object" && "retireId" in poll.recordingStatus;
if ((retiring || players.recording?.isRetired(String(poll.viewId))) &&
(initialize !== undefined || freshStart !== undefined || snapshot !== undefined || active.stateWrites.size || active.captureUpload)) {
throw new Error("Finish current state and capture writes before retiring this view.");
}
const reply = players.poll(poll);
if (initialize === true) {
reply.initialState = freshStart === true || reply.command ? null : (await states.latest())?.text ?? null;
if (!current()) {
reject(response, 409, "The player changed");
return;
}
}
if (typeof snapshot === "string" && (players.input.blocking || players.recording?.active))
reply.snapshotDeferred = true;
else if (typeof snapshot === "string") {
const write = states.save(snapshot);
active.stateWrites.add(write);
try {
await write;
reply.snapshotSaved = true;
}
catch {
reply.snapshotError = "The automatic save could not be stored.";
}
finally {
active.stateWrites.delete(write);
}
}
if (!current()) {
reject(response, 409, "The player changed");
return;
}
stateResponse(request, response, 200, reply);
}
catch (error) {
if (error instanceof PreviewPlayerControlError && error.code === "RESULT_MISMATCH") {
stateResponse(request, response, 409, { error: "This command result is no longer pending.", discardResult: true });
}
else
stateResponse(request, response, 400, { error: "Invalid or stale player-control response." });
}
}
async function serveHistory(request, response, active, relative) {
const annotations = active.annotations;
const states = annotations?.states;
const current = () => active.annotations === annotations && active.server.listening && active.acceptingStateRequests;
if (!states) {
reject(response, 404, "Not found");
return;
}
if (!current()) {
reject(response, 409, "The player is being reopened");
return;
}
if (request.method !== "GET" && request.method !== "HEAD") {
reject(response, 405, "Method not allowed");
return;
}
if (request.headers["transfer-encoding"] !== undefined ||
(request.headers["content-length"] !== undefined && request.headers["content-length"] !== "0")) {
reject(response, 400, "Request body not allowed");
return;
}
if (relative === STATE_MOUNT) {
const listed = await states.list();
if (!current()) {
reject(response, 409, "The player changed");
return;
}
stateResponse(request, response, 200, { states: listed });
return;
}
const name = relative.startsWith(`${STATE_MOUNT}${path.sep}`) ? relative.slice(STATE_MOUNT.length + path.sep.length) : "";
const match = /^([a-f0-9]{64})\.gbstate\.json$/.exec(name);
const text = match ? await states.read(match[1]) : null;
if (!current()) {
reject(response, 409, "The player changed");
return;
}
if (text === null) {
reject(response, 404, "Saved moment not found");
return;
}
secureHeaders(response);
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("Content-Length", Buffer.byteLength(text));
response.end(request.method === "HEAD" ? undefined : text);
}
function captureLinks(record) {
return { ...record, url: `./${CAPTURE_MOUNT}/${record.id}/media`, metadataUrl: `./${CAPTURE_MOUNT}/${record.id}/metadata` };
}
/** Browser media stays in the selected project; neither filenames nor paths come from the request. */
async function serveCaptures(request, response, active, relative) {
const annotations = active.annotations;
const captures = annotations?.captures;
const current = () => active.annotations === annotations && active.server.listening && active.acceptingStateRequests &&
!!annotations?.projectRoot && active.isProjectSelected(annotations.projectRoot);
if (!captures || !relative.startsWith(CAPTURE_MOUNT)) {
reject(response, 404, "Not found");
return;
}
if (!current()) {
reject(response, 409, "The preview or selected project changed.");
return;
}
if (relative === CAPTURE_MOUNT) {
if (request.method !== "POST") {
reject(response, 405, "Method not allowed", "POST");
return;
}
const length = request.headers["content-length"];
const contentType = request.headers["content-type"];
if (request.headers.origin !== `http://${LOOPBACK_ADDRESS}:${active.status.port}` ||
!hasSingleHeader(request, "x-codex-preview-capture") || request.headers["x-codex-preview-capture"] !== captures.generation ||
!hasSingleHeader(request, "content-type") || typeof contentType !== "string" ||
!/^multipart\/form-data;\s*boundary=(?:[A-Za-z0-9'()+_,.\/:=?-]{1,70}|"[A-Za-z0-9'()+_,.\/:=?-]{1,70}")$/i.test(contentType) ||
request.headers["content-encoding"] !== undefined || request.headers["transfer-encoding"] !== undefined ||
!hasSingleHeader(request, "content-length") || typeof length !== "string" || !/^[1-9][0-9]*$/.test(length)) {
reject(response, 400, "Invalid capture upload");
return;
}
const expected = Number(length);
if (!Number.isSafeInteger(expected) || expected > MAX_CAPTURE_UPLOAD_BYTES) {
reject(response, 413, "Capture upload is too large");
return;
}
if (active.captureUpload) {
reject(response, 409, "Another capture is still being saved.");
return;
}
let managed;
try {
for (const key of ["x-codex-recording-id", "x-codex-recording-view", "x-codex-recording-generation"])
if (request.headers[key] !== undefined && !hasSingleHeader(request, key))
throw new Error("Duplicate recording ownership header.");
managed = annotations?.players?.recording?.admitUpload(request.headers["x-codex-recording-id"], request.headers["x-codex-recording-view"], request.headers["x-codex-recording-generation"]);
if (!managed && request.headers["x-codex-recording-id"] !== undefined)
throw new Error("Unknown recording owner.");
}
catch (error) {
reject(response, 409, error instanceof Error ? error.message : "Invalid recording owner.");
return;
}
const save = (async () => {
try {
async function* boundedBody() {
let received = 0;
for await (const chunk of request) {
received += chunk.length;
if (received > expected || received > MAX_CAPTURE_UPLOAD_BYTES)
throw new Error("Capture upload exceeded its size limit.");
yield chunk;
}
if (received !== expected)
throw new Error("The capture upload was incomplete.");
}
// Native multipart parsing receives a bounded stream, without a second full Buffer copy.
const body = Readable.toWeb(Readable.from(boundedBody()));
const form = await new Response(body, { headers: { "content-type": contentType } }).formData();
const keys = [];
form.forEach((_value, key) => { keys.push(key); });
if (keys.length !== 3 || new Set(keys).size !== 3 || !["kind", "metadata", "media"].every(key => form.has(key))) {
throw new Error("A capture must contain exactly kind, metadata, and media.");
}
const kind = form.get("kind"), metadataText = form.get("metadata"), media = form.get("media");
if ((kind !== "screenshot" && kind !== "video") || typeof metadataText !== "string" ||
Buffer.byteLength(metadataText) > MAX_CAPTURE_METADATA_BYTES || !(media instanceof Blob)) {
throw new Error("Invalid capture media or metadata.");
}
const metadata = JSON.parse(metadataText);
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
throw new Error("Invalid capture metadata.");
if (!current()) {
reject(response, 409, "The preview or selected project changed before saving.");
return;
}
if (managed && kind !== "video")
throw new Error("Managed recording must be video.");
const record = await captures.store.save({ kind, media, mimeType: media.type, metadata: metadata });
const linked = captureLinks(record);
if (managed)
await annotations.players.recording.published(managed.id, parseRecordingSaved(linked), metadata.status === "partial" ? "partial" : "complete");
stateResponse(request, response, 201, linked);
}
catch (error) {
if (managed)
await annotations.players.recording.uploadFailed(managed.id, error);
stateResponse(request, response, 400, {
error: error instanceof Error ? error.message : "The capture save could not be confirmed. Keep the browser download.",
...(error?.partial ? { partial: error.partial } : {}),
});
}
})();
active.captureUpload = save;
try {
await save;
}
finally {
if (active.captureUpload === save)
delete active.captureUpload;
}
return;
}
if (request.method !== "GET" && request.method !== "HEAD") {
reject(response, 405, "Method not allowed");
return;
}
if (request.headers["transfer-encoding"] !== undefined ||
(request.headers["content-length"] !== undefined && request.headers["content-length"] !== "0")) {
reject(response, 400, "Request body not allowed");
return;
}
const parts = relative.split(path.sep);
if (parts.length !== 3 || parts[0] !== CAPTURE_MOUNT || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(parts[1]) ||
(parts[2] !== "media" && parts[2] !== "metadata")) {
reject(response, 404, "Not found");
return;
}
const file = await captures.store.open(parts[1], parts[2]);
if (!file) {
reject(response, 404, "Capture not found");
return;
}
try {
if (!current()) {
reject(response, 409, "The preview changed.");
return;
}
secureHeaders(response);
response.setHeader("Cache-Control", "no-store");
response.setHeader("Content-Type", file.mimeType);
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("ETag", `"sha256-${file.sha256}"`);
const filename = path.basename(parts[2] === "metadata" ? file.record.metadataPath : file.record.path);
// Video captures stay download-only, including direct and range requests.
const download = (parts[2] === "media" && file.record.kind === "video") ||
new URL(request.url, active.status.url).searchParams.get("download") === "1";
response.setHeader("Content-Disposition", `${download ? "attachment" : "inline"}; filename="${filename}"`);
let start = 0, end = file.bytes - 1;
const range = request.headers.range;
if (range !== undefined) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match || (!match[1] && !match[2]) || match.slice(1).some(value => value && !Number.isSafeInteger(Number(value)))) {
response.setHeader("Content-Range", `bytes */${file.bytes}`);
reject(response, 416, "Invalid range");
return;
}
if (!match[1])
start = Math.max(0, file.bytes - Number(match[2]));
else {
start = Number(match[1]);
if (match[2])
end = Math.min(end, Number(match[2]));
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || end >= file.bytes ||
(!match[1] && (!Number.isSafeInteger(Number(match[2])) || Number(match[2]) < 1))) {
response.setHeader("Content-Range", `bytes */${file.bytes}`);
reject(response, 416, "Invalid range");
return;
}
response.statusCode = 206;
response.setHeader("Content-Range", `bytes ${start}-${end}/${file.bytes}`);
}
response.setHeader("Content-Length", end - start + 1);
if (request.method === "HEAD") {
response.end();
return;
}
await pipeline(file.handle.createReadStream({ start, end, autoClose: false }), response);
}
finally {
await file.handle.close();
}
}
async function serveAsset(request, response, active) {
if (!isAuthorizedRequest(request, active.status.port)) {
reject(response, 403, "Forbidden");
return;
}
const target = request.url;
if (target === undefined || target.length > MAX_REQUEST_TARGET_LENGTH) {
reject(response, 414, "Invalid request target");
return;
}
const relative = relativeAssetPath(target, active.capability, active.platform);
if (relative === null) {
reject(response, 403, "Forbidden");
return;
}
const foldedRelative = relative.toLowerCase();
if (foldedRelative === DEVICE_VIEW_MOUNT || foldedRelative.startsWith(`${DEVICE_VIEW_MOUNT}${path.sep}`)) {
const projectRoot = active.annotations?.projectRoot;
if (!active.serveDeviceView || !projectRoot || !active.acceptingStateRequests || !active.isProjectSelected(projectRoot) || !relative.startsWith(`${DEVICE_VIEW_MOUNT}${path.sep}`)) {
reject(response, 409, "The selected game preview is unavailable.");
return;
}
const operation = active.serveDeviceView(request, response, projectRoot, relative.slice(DEVICE_VIEW_MOUNT.length + 1).split(path.sep).join("/"), `http://${LOOPBACK_ADDRESS}:${active.status.port}`);
active.stateWrites.add(operation);
try {
await operation;
}
finally {
active.stateWrites.delete(operation);
}
return;
}
if (foldedRelative === ACTIVATION_MOUNT || foldedRelative.startsWith(`${ACTIVATION_MOUNT}${path.sep}`)) {
reject(response, 404, "Not found");
return;
}
if (foldedRelative === CAPTURE_MOUNT || foldedRelative.startsWith(`${CAPTURE_MOUNT}${path.sep}`)) {
const operation = serveCaptures(request, response, active, relative);
active.stateWrites.add(operation);
try {
await operation;
}
finally {
active.stateWrites.delete(operation);
}
return;
}
if (foldedRelative === DEVICE_MOUNT || foldedRelative.startsWith(`${DEVICE_MOUNT}${path.sep}`)) {
await serveDevice(request, response, active, relative);
return;
}
if (foldedRelative === STATE_MOUNT || foldedRelative.startsWith(`${STATE_MOUNT}${path.sep}`)) {
await serveHistory(request, response, active, relative);
return;
}
if (foldedRelative === PLAYER_MOUNT || foldedRelative.startsWith(`${PLAYER_MOUNT}${path.sep}`)) {
await servePlayer(request, response, active, relative);
return;
}
if (request.method !== "GET" && request.method !== "HEAD") {
reject(response, 405, "Method not allowed");
return;
}
if (request.headers["transfer-encoding"] !== undefined ||
(request.headers["content-length"] !== undefined && request.headers["content-length"] !== "0")) {
reject(response, 400, "Request body not allowed");
return;
}
if (active.annotations && (foldedRelative === ANNOTATION_MOUNT || foldedRelative.startsWith(`${ANNOTATION_MOUNT}${path.sep}`))) {
// Reserve case aliases too: Windows must not fall through to an authored
// export file that shadows one of the trusted, pinned module responses.
const canonicalMount = relative.startsWith(`${ANNOTATION_MOUNT}${path.sep}`);
const asset = canonicalMount ? active.annotations.assets.get(relative.slice(ANNOTATION_MOUNT.length + path.sep.length)) : undefined;
if (asset)
serveBufferedAsset(request, response, asset);
else
reject(response, 404, "Not found");
return;
}
if (relative === "index.html" && active.annotations) {
serveBufferedAsset(request, response, active.annotations.index);
return;
}
const contentType = assetContentType(relative);
if (contentType === undefined) {
reject(response, 404, "Not found");
return;
}
const assetPath = await verifyAssetPath(active.status.outputRoot, relative, active.platform);
if (assetPath === null) {
reject(response, 404, "Not found");
return;
}
let handle;
try {
// Windows does not implement O_NOFOLLOW. Its fallback is protected by component-by-component
// reparse checks plus canonical confinement and opened-descriptor identity verification.
const noFollow = active.platform === "win32" ? 0 : constants.O_NOFOLLOW;
handle = await open(assetPath, constants.O_RDONLY | noFollow);
const metadata = await handle.stat();
// Verify the opened descriptor against its canonical path after opening as well. A swapped
// parent-directory symlink must not turn the earlier confinement checks into a TOCTOU escape.
const openedPath = await realpath(assetPath);
const pathMetadata = await lstat(openedPath);
if (!pathsMatch(openedPath, assetPath, active.platform) ||
!isPathWithinRoot(active.status.outputRoot, openedPath, active.platform) ||
pathMetadata.isSymbolicLink() ||
metadata.dev !== pathMetadata.dev ||
metadata.ino !== pathMetadata.ino ||
!metadata.isFile() ||
metadata.size > MAX_STATIC_FILE_BYTES) {
await handle.close();
reject(response, metadata.size > MAX_STATIC_FILE_BYTES ? 413 : 404, "Unavailable");
return;
}
const etag = `W/"${metadata.size.toString(36)}-${metadata.mtimeMs.toString(36)}"`;
secureHeaders(response);
response.setHeader("Cache-Control", "private, no-cache, must-revalidate");
response.setHeader("ETag", etag);
if (request.headers["if-none-match"] === etag) {
await handle.close();
response.writeHead(304);
response.end();
return;
}
response.setHeader("Content-Type", contentType);
response.setHeader("Content-Length", metadata.size);
if (request.method === "HEAD") {
await handle.close();
response.writeHead(200);
response.end();
return;
}
response.writeHead(200);
await pipeline(handle.createReadStream({ autoClose: true }), response);
}
catch {
if (handle !== undefined)
await handle.close().catch(() => undefined);
reject(response, 404, "Not found");
}
}
/** Serves official game exports and optional annotation modules behind one capability URL. */
async function verifyRecordingCapture(projectRoot, recording) {
if (!recording.saved)
return;
const b = recording.binding;
const store = new PreviewCaptureStore({ projectRoot, identity: { romSha256: b.romSha256, runtimeSha256: b.runtimeSha256, sourceRevision: b.sourceRevision, cartridgeType: recording.cartridgeType } });
const file = await store.open(recording.saved.id, "media");
if (!file)
throw new Error("The original capture file is unavailable; preserve its journal.");
try {
if (file.record.sha256 !== recording.saved.sha256 || file.record.bytes !== recording.saved.bytes || file.record.path !== recording.saved.path || file.record.metadataPath !== recording.saved.metadataPath)
throw new Error("The original capture differs from its saved receipt.");
}
finally {
await file.handle.close();
}
}
export class WebPreviewService {
#active = null;
#closedPort;
#disposed = false;
#disposal;
#operation = Promise.resolve();
#platform;
#flash;
#isProjectSelected;
#serveDeviceView;
#deviceCaptureAvailability;
constructor(options = {}) {
this.#platform = options.platform ?? process.platform;
this.#flash = options.chromatic ? new PreviewFlashControl(options.chromatic) : undefined;
this.#isProjectSelected = options.isProjectSelected ?? (() => true);
this.#serveDeviceView = options.serveDeviceView;
this.#deviceCaptureAvailability = options.deviceCaptureAvailability;
}
status() {
if (this.#active === null)
return null;
return { ...copyStatus(this.#active.status), listening: this.#active.server.listening };
}
async control(input) {
const active = this.#active;
const annotations = active?.annotations;
if ((!active || annotations?.projectRoot !== path.resolve(input.projectRoot)) && ["recording_status", "read_capture"].includes(input.action)) {
const recording = await new PreviewRecordingBroker(input.projectRoot).read(input.recordingId);
if (input.action === "read_capture")
await verifyRecordingCapture(input.projectRoot, recording);
return { success: true, action: input.action, recording: recordingResult(recording) };
}
if (!active || !annotations?.states || !annotations.players ||
!pathsMatch(path.resolve(input.projectRoot), annotations.projectRoot, this.#platform)) {
throw new Error("Open a browser preview for the selected project first.");
}
const { states, players } = annotations;
const result = { success: true, action: input.action, preview: { ...copyStatus(active.status), listening: active.server.listening } };
const unchanged = () => {
if (this.#active !== active || active.annotations !== annotations || !active.acceptingStateRequests || !active.server.listening) {
throw new Error("The browser preview changed while the state command was running.");
}
};
if (input.action === "install_status" || input.action === "dismiss_install_failure") {
unchanged();
if (!active.flash || !annotations.flash || !this.#isProjectSelected(input.projectRoot))
throw new Error("Installation control requires the current selected preview.");
const installation = active.flash.execute(input.action === "install_status"
? { action: "status", generation: annotations.flash.generation, ...(input.installationRequestId ? { requestId: input.installationRequestId } : {}) }
: { action: "dismiss_failure", ...input.installationBinding, acknowledgePreviousOutcome: true }, annotations.flash);
const previous = installation.operation;
return { ...result, installation,
...(typeof previous?.requestId === "string" && typeof previous.operationId === "string" ? { installationBinding: {
generation: annotations.flash.generation, requestId: previous.requestId, operationId: previous.operationId,
} } : {}),
note: "Dismissal preserves the original result and does not write a cartridge. A new install requires fresh discovery, device selection and erasure confirmation." };
}
if (input.action === "status")
return { ...result, players: players.status(), identity: players.identityForInput(), input: players.input.status(), recording: recordingResult(await players.recording?.unresolved(), active) };
if (["recording_status", "read_capture"].includes(input.action)) {
const recording = await (input.action === "recording_status" ? players.recording.status(input.recordingId) : players.recording.read(input.recordingId));
if (input.action === "read_capture")
await verifyRecordingCapture(input.projectRoot, recording);
return { ...result, recording: recordingResult(recording, active) };
}
if (input.action === "stop_recording")
return { ...result, recording: players.recording.stop(input.recordingId) };
if (!active.server.listening || !active.acceptingStateRequests || active.status.closureState !== "open")
throw new Error("The original browser owner is closing or unresolved. Inspect status.");
if (input.action === "start_recording") {
const binding = input.recordingBinding, identity = players.identityForInput();
if (!binding || binding.listenerId !== active.status.listenerId || binding.romSha256 !== identity.romSha256 || binding.runtimeSha256 !== identity.runtimeSha256 || binding.sourceRevision !== identity.sourceRevision || !this.#isProjectSelected(input.projectRoot))
throw new Error("Recording requires the exact current project, listener, view and build binding from status.");
if (players.status().find(v => v.id === binding.viewId)?.paused !== false)
throw new Error("Resume the visible player before starting a recording.");
return { ...result, recording: await players.recording.start(binding, input.recordingDurationMs) };
}
if (input.action === "input") {
const binding = input.binding;
const identity = players.identityForInput();
if (!binding || binding.listenerId !== active.status.listenerId || binding.romSha256 !== identity.romSha256 ||
binding.runtimeSha256 !== identity.runtimeSha256 || binding.sourceRevision !== identity.sourceRevision ||
!this.#isProjectSelected(input.projectRoot))
throw new Error("Browser input requires the exact current listener, project and build binding from status.");
const receipt = await players.requestInput(binding.viewId, binding.inputGeneration, input.buttons, input.durationMs ?? 250, input.signal);
unchanged();
return { ...result, binding, receipt };
}
if (input.action === "list_states") {
const listed = await states.list();
unchanged();
return { ...result, states: listed };
}
if (input.action === "capture_state") {
const captured = await players.request("capture", { viewId: input.viewId });
unchanged();
if (!captured.state)
throw new Error("The browser did not return a captured state.");
const write = states.save(captured.state);
active.stateWrites.add(write);
let state;
try {
state = await write;
}
finally {
active.stateWrites.delete(write);
}
unchanged();
return { ...result, state, paused: true };
}
if (!input.stateId)
throw new Error("Choose a saved state ID from list_states.");
const state = await states.read(input.stateId);
unchanged();
if (!state)
throw new Error("This saved state is unavailable for the current game build.");
const restored = await players.request("restore", { viewId: input.viewId, state });
unchanged();
return { ...result, stateId: input.stateId, frame: restored.frame, paused: true };
}
async start(outputRoot, annotations) {
return this.#serialize(async () => {
this.#assertOpen();
if (typeof outputRoot !== "string" || outputRoot.trim().length === 0) {
throw new Error("A game web-export directory is required.");
}
if (this.#platform === "win32")
assertSafePlatformPath(outputRoot, this.#platform);
const candidate = path.resolve(outputRoot);
if (this.#platform === "win32")
assertSafePlatformPath(candidate, this.#platform);
const requested = await lstat(candidate);
if (requested.isSymbolicLink() || !requested.isDirectory()) {
throw new Error("The game web-export directory must be a real directory.");
}
const root = await realpath(candidate);
if (this.#platform === "win32" &&
!pathsMatch(root, candidate, this.#platform)) {
throw new Error("The game web-export directory must not contain a reparse point.");
}
if ((await verifyAssetPath(root, "index.html", this.#platform)) === null) {
throw new Error("The game web export must contain a real index.html file.");
}
this.#assertClosureResolved();
// Finish all reads before replacing the live listener. Missing or changed
// trusted assets must not close an otherwise usable official preview.
const prepared = annotations === undefined ? undefined : await prepareAnnotations(root, annotations, this.#platform, !!this.#flash, !!this.#serveDeviceView, this.#deviceCaptureAvailability?.());
this.#assertOpen();
this.#assertClosureResolved();
const activePreview = this.#active;
if (activePreview !== null && activePreview.server.listening && activePreview.status.closureState === "open" && activePreview.status.outputRoot === root) {
if (prepared && prepared.reuseKey === activePreview.annotations?.reuseKey) {
activePreview.status.reused = true;
return copyStatus(activePreview.status);
}
// Keep the existing preview URL across rebuilds. All preparation above
// finishes before this synchronous swap, so requests see one complete
// context/module set. Already loaded pages retain their own ROM context.
const status = { ...activePreview.status, reused: true };
try {
await this.#drainActive(activePreview, false);
}
catch (error) {
activePreview.status.closureState = activePreview.annotations?.players?.recording?.closeUncertain ? "unresolved" : "open";
activePreview.status.closeError = error instanceof Error ? error.message : String(error);
throw error;
}
activePreview.acceptingStateRequests = false;
this.#assertOpen();
if (this.#active !== activePreview || !activePreview.server.listening || activePreview.status.closureState !== "open") {
throw new Error("The browser preview closed while it was being reopened.");
}
activePreview.annotations?.players?.close();
if (prepared) {
activePreview.annotations = prepared;
status.annotations = { injected: true, fingerprint: prepared.fingerprint };
}
else {
delete activePreview.annotations;
delete status.annotations;
}
delete activePreview.drainDeadline;
activePreview.status = status;
activePreview.acceptingStateRequests = true;
return copyStatus(activePreview.status);
}
await this.#stopActive();
this.#assertOpen();
const capability = randomBytes(CAPABILITY_BYTES).toString("hex");
let active;
const server = createServer((request, response) => {
void serveAsset(request, response, active).catch(() => reject(response, 500, "Internal server error"));
});
server.requestTimeout = 10_000;
server.headersTimeout = 10_000;
server.keepAliveTimeout = 5_000;
server.maxHeadersCount = 32;
await listenLoopback(server, this.#closedPort);
const address = server.address();
if (address === null || typeof address === "string" || address.address !== LOOPBACK_ADDRESS) {
await new Promise((resolve) => server.close(() => resolve()));
throw new Error("The game web preview could not bind its loopback listener.");
}
const status = {
url: `http://${LOOPBACK_ADDRESS}:${address.port}/${capability}/`,
port: address.port,
outputRoot: root,
reused: false,
listenerId: randomBytes(16).toString("hex"),
startedAt: new Date().toISOString(),
kind: "in-process-http",
listening: true,
closureState: "open",
...(prepared ? { annotations: { injected: true, fingerprint: prepared.fingerprint } } : {}),
};
active = { server, capability, status, platform: this.#platform, stateWrites: new Set(), acceptingStateRequests: true,
flash: this.#flash, isProjectSelected: this.#isProjectSelected, serveDeviceView: this.#serveDeviceView, ...(prepared ? { annotations: prepared } : {}) };
this.#active = active;
server.on("close", () => {
if (active.annotations?.players?.recording?.busy) {
active.annotations.players.recording.markUnknown("The listener closed before capture finalization was confirmed.");
active.status.closureState = "unresolved";
}
if (active.annotations?.players?.input.blocking) {
active.annotations.players.input.connectionLost();
active.status.closureState = "unresolved";
active.status.closeError = "The listener closed before browser input release was confirmed.";
return;
}
try {
active.annotations?.players?.close();
}
catch (error) {
active.status.closureState = "unresolved";
active.status.closeError = error instanceof Error ? error.message : String(error);
}
// TCP closure alone does not settle pending state/capture writes.
if (this.#active?.server === server && active.status.closureState === "open")
this.#active = null;
});
if (this.#disposed) {
await this.#stopActive();
this.#assertOpen();
}
return copyStatus(status);
});
}
#assertClosureResolved() {
if (this.#active?.status.closureState === "unresolved") {
throw new Error("The previous web-preview listener has unresolved closure; close it before starting another preview.");
}
}
#assertOpen() {
if (this.#disposed)
throw new Error("The web-preview service is closed.");
}
/** Terminal plugin teardown; ordinary stop() remains reusable. */
dispose() {
this.#disposed = true;
return this.#disposal ??= this.stop();
}
async stop() {
// A reopening operation can itself be waiting on a state write. Stop
// listener admission now, not only once that serialized operation drains.
const pending = this.#active ? this.#stopActive() : undefined;
void pending?.catch(() => undefined);
return this.#serialize(async () => {
const previous = await pending;
return this.#active ? this.#stopActive() : previous ?? this.#stopActive();
});
}
async #serialize(operation) {
const previous = this.#operation;
let release;
this.#operation = new Promise((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
}
finally {
release();
}
}
#stopActive() {
const active = this.#active;
if (active === null)
return Promise.resolve({ closed: true, hadActiveListener: false, closeObserved: false, exportRetained: true });
if (active.closing)
return active.closing;
const closing = this.#closeActive(active);
active.closing = closing;
// A confirmed failure remains visible and can be explicitly retried.
void closing.catch(() => { if (active.closing === closing)
delete active.closing; });
return closing;
}
async #drainActive(active, terminal) {
const recording = active.annotations?.players?.recording;
active.drainDeadline ??= performance.now() + 30_000;
let expired = false, timer;
const current = () => { if (expired || performance.now() >= active.drainDeadline)
throw new Error("Preview finalization deadline is UNKNOWN."); };
const work = async () => {
await recording?.drain(terminal);
current();
await active.annotations?.players?.input.drain();
current();
active.acceptingStateRequests = false;
await Promise.all([...active.stateWrites, ...(active.captureUpload ? [active.captureUpload] : [])]);
current();
};
try {
await Promise.race([work(), new Promise((_, reject) => { timer = setTimeout(() => { expired = true; recording?.markUnknown("Preview finalization deadline is UNKNOWN. Preserve the original capture."); reject(new Error("Preview finalization deadline is UNKNOWN.")); }, Math.max(0, active.drainDeadline - performance.now())); })]);
}
catch (error) {
expired = true;
// An ordinary human refusal cancels its close attempt, not its capture.
if (!recording?.closeUncertain && !terminal)
delete active.drainDeadline;
throw error;
}
finally {
if (timer)
clearTimeout(timer);
}
}
async #closeActive(active) {
let terminalError;
active.status.closureState = "closing";
try {
await this.#drainActive(active, this.#disposed);
}
catch (error) {
active.status.closureState = active.annotations?.players?.recording?.closeUncertain ? "unresolved" : "open";
active.status.closeError = error instanceof Error ? error.message : String(error);
if (!this.#disposed)
throw error;
terminalError = error;
}
active.acceptingStateRequests = false;
try {
active.annotations?.players?.close();
}
catch (error) {
active.status.closureState = "unresolved";
active.status.closeError = String(error);
if (!this.#disposed)
throw error;
terminalError ??= error;
}
finally {
if (this.#disposed)
active.annotations?.players?.recording?.dispose();
}
// Stop accepting connections before waiting for writes. TCP closure may
// finish first, but the active record keeps ownership until both settle.
const listener = new Promise((resolve, rejectStop) => {
active.server.close((error) => { error ? rejectStop(error) : resolve(); });
active.server.closeAllConnections();
});
const [closed] = await Promise.allSettled([listener]);
if (closed.status === "rejected") {
const error = closed.reason;
active.status.closureState = "unresolved";
active.status.closeError = error instanceof Error ? error.message : String(error);
throw error;
}
this.#closedPort = active.status.port;
if (this.#active === active)
this.#active = null;
if (terminalError)
throw terminalError;
return {
closed: true, hadActiveListener: true, listenerId: active.status.listenerId,
closedAt: new Date().toISOString(), closeObserved: true, exportRetained: true,
};
}
}
//# sourceMappingURL=web-preview.js.mapSHA-256: 4de23636b9bf23a20a755980c3fb64273d0e7fc623125117f8d438387eced587