import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import { join, posix } from "node:path"; import { createServer } from "node:http"; import { randomBytes, randomUUID } from "node:crypto"; import { pipeline } from "node:stream/promises"; import { resolveAccounts, invalidateAccounts } from "./accounts.mjs"; import { clearArtifactCache, deleteCachedArtifact, getCacheSummary, getCachedArtifact, getCachedEntry, inspectArtifact, readCachedEntry, } from "./cache.mjs"; import { ASSET_ROOT, MAX_INLINE_PREVIEW_BYTES, MAX_STREAMED_ENTRY_BYTES, MAX_TRX_PREVIEW_BYTES, } from "./constants.mjs"; import { hasRootIndexHtml, isInlineTextKind, kindForPath, mimeForPath, } from "./detector.mjs"; import { enrichPullRequests, filterPullRequests, getRepository, GitHubApiError, listRepositoryContributors, listPullRequestArtifacts, searchPullRequestBase, searchPullRequests, suggestRepositories, } from "./github.mjs"; import { startStaticPreview, stopAllStaticPreviews, stopStaticPreviewsForArtifact, stopStaticPreviewsForOrigin, } from "./preview.mjs"; import { ExpiringPromiseCache } from "./memory-cache.mjs"; import { renderHtml } from "./render.mjs"; import { hasCapabilityToken, isCanonicalHost, isCrossSiteRequest, requiresCapabilityToken, } from "./security.mjs"; import { loadPrefs, normalizeRepository, rememberRepository, savePrefs, setExplorerPreferences, setFavoriteRepository, setPinnedRepository, setPlayerPreferences, setPullFilterPreferences, } from "./state.mjs"; import { streamZipEntry } from "./zip.mjs"; const servers = new Map(); const MAX_BODY_BYTES = 1024 * 1024; const PULL_REQUEST_CACHE_TTL_MS = 2 * 60 * 1_000; const PROGRESSIVE_PULL_BATCH_SIZE = 25; const pullRequestCache = new ExpiringPromiseCache({ maxEntries: 100, ttlMs: PULL_REQUEST_CACHE_TTL_MS, }); const progressivePullRequestCache = new ExpiringPromiseCache({ maxEntries: 100, ttlMs: PULL_REQUEST_CACHE_TTL_MS, }); const MAIN_CSP = [ "default-src 'self'", "base-uri 'self'", "connect-src 'self'", "font-src 'self' data:", "form-action 'self'", "frame-src http://127.0.0.1:*", "img-src 'self' data: https://avatars.githubusercontent.com https://github.com", "object-src 'none'", "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", "worker-src 'self' blob:", ].join("; "); class HttpError extends Error { constructor(status, message) { super(message); this.name = "HttpError"; this.status = status; } } function createCapabilityToken() { return randomBytes(32).toString("base64url"); } function sendJson(res, status, value) { const body = JSON.stringify(value); res.writeHead(status, { "Cache-Control": "no-store", "Content-Length": Buffer.byteLength(body), "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", }); res.end(body); } function sendText(res, status, value, type = "text/plain; charset=utf-8", headers = {}) { const body = String(value); res.writeHead(status, { "Cache-Control": "no-store", "Content-Length": Buffer.byteLength(body), "Content-Type": type, "X-Content-Type-Options": "nosniff", ...headers, }); res.end(body); } function pullRequestCacheKey(account, kind, values) { return [ account?.id ?? account?.login ?? "unknown", kind, ...values.map((value) => String(value ?? "").toLocaleLowerCase()), ].join("|"); } function progressivePullPayload(session, pulls, { complete = false, offset = 0, } = {}) { const filterActive = session.base.filters.artifacts !== "all" || session.base.filters.ci !== "all"; return { ...session.base, filtered: complete && filterActive, pulls, progressive: { batchSize: PROGRESSIVE_PULL_BATCH_SIZE, complete, detailedCount: session.completedNumbers.size, loadedCount: Math.min( session.base.pulls.length, offset + pulls.length, ), offset, sessionId: session.id, sourceCount: session.base.pulls.length, }, }; } function completeProgressivePullPayload(session) { const enriched = session.base.pulls .map((pull) => session.enrichedPulls.get(Number(pull.number))) .filter(Boolean); return progressivePullPayload( session, filterPullRequests(enriched, session.base.filters), { complete: true }, ); } async function loadProgressivePullDetails( session, cacheKey, token, repository, offset, ) { const completed = session.detailResults.get(offset); if (completed) return completed; const existing = session.detailPromises.get(offset); if (existing) return existing; const source = session.base.pulls.slice( offset, offset + PROGRESSIVE_PULL_BATCH_SIZE, ); const promise = (async () => { const enriched = await enrichPullRequests( token, repository, source, session.base.filters, ); for (const pull of enriched) { session.enrichedPulls.set(Number(pull.number), pull); session.completedNumbers.add(Number(pull.number)); } const sourceNumbers = source.map((pull) => Number(pull.number)); const complete = session.completedNumbers.size >= session.base.pulls.length; const payload = complete ? completeProgressivePullPayload(session) : null; if ( payload && progressivePullRequestCache.peek(cacheKey) === session ) { pullRequestCache.set(cacheKey, payload); } const result = { complete, completedCount: session.completedNumbers.size, payload, pulls: filterPullRequests(enriched, session.base.filters), sourceNumbers, }; session.detailResults.set(offset, result); return result; })(); session.detailPromises.set(offset, promise); try { return await promise; } finally { if (session.detailPromises.get(offset) === promise) { session.detailPromises.delete(offset); } } } async function sendFile(res, filePath, type) { const info = await stat(filePath); if (!info.isFile()) throw new HttpError(404, "Asset not found."); res.writeHead(200, { "Cache-Control": "public, max-age=3600", "Content-Length": String(info.size), "Content-Type": type, "X-Content-Type-Options": "nosniff", }); await pipeline(createReadStream(filePath), res); } async function readJsonBody(req) { const type = req.headers["content-type"] ?? ""; if (!String(type).toLowerCase().startsWith("application/json")) { throw new HttpError(415, "Request body must be JSON."); } const chunks = []; let length = 0; for await (const chunk of req) { length += chunk.length; if (length > MAX_BODY_BYTES) throw new HttpError(413, "Request body is too large."); chunks.push(chunk); } if (!chunks.length) return {}; try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch (error) { throw new HttpError(400, "Request body is not valid JSON.", { cause: error }); } } function broadcast(entry, event, payload) { const encoded = JSON.stringify(payload); for (const response of entry.sseClients) { try { response.write(`event: ${event}\ndata: ${encoded}\n\n`); } catch { entry.sseClients.delete(response); } } } async function resolveAuth(repository, force = false) { const prefs = await loadPrefs(); const previousRepository = prefs.repository; const previousRecent = JSON.stringify(prefs.repositories.recent); const normalizedRepository = rememberRepository(prefs, repository); const auth = await resolveAccounts({ preferredId: prefs.account, repository: normalizedRepository, force, }); let changed = prefs.repository !== previousRepository || JSON.stringify(prefs.repositories.recent) !== previousRecent; if (auth.active && prefs.account !== auth.active.id) { prefs.account = auth.active.id; changed = true; } if (changed) await savePrefs(prefs); if (!auth.activeToken) { throw new HttpError( 401, "No usable GitHub account was detected. Sign in through the Copilot app or GitHub CLI.", ); } return { prefs, auth }; } function parseArtifactRoute(pathname) { return pathname.match(/^\/api\/artifacts\/(\d+)$/); } function decodeEntryPath(encoded) { try { return encoded.split("/").map((part) => decodeURIComponent(part)).join("/"); } catch (error) { throw new HttpError(400, "File path contains invalid URL encoding.", { cause: error }); } } export function contentDeliveryMode(entry, download) { if (entry?.supported) return "entry"; return download ? "archive" : "unsupported"; } function archiveDownloadName(context, artifactId) { const rawName = String( context.metadata?.artifact?.name || `artifact-${artifactId}`, ).replaceAll("\\", "/"); const baseName = posix.basename(rawName) || `artifact-${artifactId}`; return baseName.toLowerCase().endsWith(".zip") ? baseName : `${baseName}.zip`; } async function sendArchiveDownload(req, res, context, artifactId) { const info = await stat(context.archivePath); const filename = archiveDownloadName(context, artifactId); res.writeHead(200, { "Cache-Control": "no-store", "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, "Content-Length": String(info.size), "Content-Security-Policy": "default-src 'none'; sandbox", "Content-Type": "application/zip", "Cross-Origin-Resource-Policy": "same-origin", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", }); if (req.method === "HEAD") { res.end(); return; } await pipeline(createReadStream(context.archivePath), res); } async function handleContentRequest(req, res, pathname, searchParams) { const match = pathname.match(/^\/content\/(\d+)\/(.+)$/); if (!match) return false; if (!["GET", "HEAD"].includes(req.method)) { throw new HttpError(405, "Only GET and HEAD are supported."); } const artifactId = match[1]; const entryPath = decodeEntryPath(match[2]); const context = await getCachedEntry(artifactId, entryPath); const download = searchParams.get("download") === "1"; const deliveryMode = contentDeliveryMode(context.entry, download); if (deliveryMode === "unsupported") { throw new HttpError(415, "This ZIP entry cannot be served."); } if (deliveryMode === "archive") { await sendArchiveDownload(req, res, context, artifactId); return true; } if (context.entry.uncompressedSize > MAX_STREAMED_ENTRY_BYTES) { throw new HttpError( 413, "This file exceeds the 512 MiB streaming safety limit.", ); } const headers = { "Cache-Control": "no-store", "Content-Length": String(context.entry.uncompressedSize), "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", "Content-Type": mimeForPath(context.entry.name), "Cross-Origin-Resource-Policy": "same-origin", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", }; if (download) { headers["Content-Disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(posix.basename(context.entry.name))}`; } res.writeHead(200, headers); if (req.method === "HEAD") { res.end(); } else { await streamZipEntry(context.archivePath, context.entry, res); } return true; } async function handleApi(req, res, entry, url) { const path = url.pathname; if (req.method === "GET" && path === "/api/bootstrap") { const prefs = await loadPrefs(); const repository = typeof entry.initialInput?.repository === "string" ? normalizeRepository(entry.initialInput.repository) : prefs.repository; const auth = await resolveAccounts({ preferredId: prefs.account, repository, }); if (auth.active && prefs.account !== auth.active.id) { prefs.account = auth.active.id; await savePrefs(prefs); } return sendJson(res, 200, { prefs, account: auth.active, accounts: auth.accounts, cache: await getCacheSummary(), initialInput: entry.initialInput ?? null, }); } if (req.method === "GET" && path === "/api/accounts") { const prefs = await loadPrefs(); const repository = normalizeRepository( url.searchParams.get("repo") || prefs.repository, ); const { auth } = await resolveAuth(repository, true); pullRequestCache.clear(); progressivePullRequestCache.clear(); return sendJson(res, 200, { account: auth.active, accounts: auth.accounts, prefs: await loadPrefs(), }); } if (req.method === "POST" && path === "/api/account") { const body = await readJsonBody(req); if (typeof body.id !== "string" || !body.id) { throw new HttpError(400, "Account id is required."); } const repository = normalizeRepository(body.repository); const prefs = await loadPrefs(); prefs.account = body.id; await savePrefs(prefs); invalidateAccounts(); pullRequestCache.clear(); progressivePullRequestCache.clear(); const { auth } = await resolveAuth(repository, true); broadcast(entry, "refresh", { reason: "account" }); return sendJson(res, 200, { account: auth.active, accounts: auth.accounts, prefs: await loadPrefs(), }); } if (req.method === "GET" && path === "/api/repositories/suggest") { const query = String(url.searchParams.get("q") ?? "").trim(); const prefs = await loadPrefs(); const auth = await resolveAccounts({ preferredId: prefs.account, repository: prefs.repository, }); if (!auth.activeToken) { throw new HttpError(401, "No usable GitHub account was found."); } return sendJson(res, 200, { repositories: await suggestRepositories(auth.activeToken, query), }); } if (req.method === "POST" && path === "/api/repositories/validate") { const body = await readJsonBody(req); const repository = normalizeRepository(body.repository); const prefs = await loadPrefs(); const auth = await resolveAccounts({ preferredId: prefs.account, repository, }); if (!auth.activeToken) { throw new HttpError(401, "No usable GitHub account was found."); } return sendJson(res, 200, { repository: await getRepository(auth.activeToken, repository), }); } if (req.method === "GET" && path === "/api/repositories/authors") { const repository = normalizeRepository(url.searchParams.get("repo")); const { auth } = await resolveAuth(repository); return sendJson(res, 200, { authors: await listRepositoryContributors(auth.activeToken, repository), }); } if (req.method === "POST" && path === "/api/repositories/pin") { const body = await readJsonBody(req); const prefs = await loadPrefs(); setPinnedRepository(prefs, body.repository); return sendJson(res, 200, await savePrefs(prefs)); } if (req.method === "POST" && path === "/api/repositories/favorite") { const body = await readJsonBody(req); if (typeof body.favorite !== "boolean") { throw new HttpError(400, "favorite must be a boolean."); } const prefs = await loadPrefs(); try { setFavoriteRepository(prefs, body.repository, body.favorite); } catch (error) { throw new HttpError( 400, error instanceof Error ? error.message : "Repository could not be favorited.", ); } return sendJson(res, 200, await savePrefs(prefs)); } if (req.method === "POST" && path === "/api/preferences") { const body = await readJsonBody(req); const prefs = await loadPrefs(); if (typeof body.repository === "string") { rememberRepository(prefs, body.repository); } if (["open", "closed", "all"].includes(body.pullState)) { prefs.pullState = body.pullState; } if ( typeof body.repository === "string" && body.pullFilter && typeof body.pullFilter === "object" ) { setPullFilterPreferences(prefs, body.repository, body.pullFilter); } if (body.explorer && typeof body.explorer === "object") { setExplorerPreferences(prefs, body.explorer); } if (body.player && typeof body.player === "object") { setPlayerPreferences(prefs, body.player); } return sendJson(res, 200, await savePrefs(prefs)); } if (req.method === "GET" && path === "/api/pulls") { const repository = normalizeRepository(url.searchParams.get("repo")); const state = url.searchParams.get("state") ?? "open"; const query = url.searchParams.get("q") ?? ""; const artifacts = url.searchParams.get("artifacts") ?? "all"; const ci = url.searchParams.get("ci") ?? "all"; const { auth } = await resolveAuth(repository); const cacheKey = pullRequestCacheKey(auth.active, "list", [ repository, state, query, artifacts, ci, ]); const phase = url.searchParams.get("phase"); const force = url.searchParams.get("refresh") === "1"; if (phase) { if (!["initial", "batch", "details"].includes(phase)) { throw new HttpError(400, "Unknown progressive pull request phase."); } if (phase === "initial") { const cached = force ? undefined : pullRequestCache.peek(cacheKey); if (cached) { return sendJson(res, 200, { repository, ...cached, account: auth.active, progressive: { batchSize: PROGRESSIVE_PULL_BATCH_SIZE, complete: true, detailedCount: cached.evaluatedCount, loadedCount: cached.pulls.length, offset: 0, sourceCount: cached.evaluatedCount, }, }); } if (force) pullRequestCache.delete(cacheKey); const session = await progressivePullRequestCache.get( cacheKey, async () => ({ id: randomUUID(), base: await searchPullRequestBase( auth.activeToken, repository, query, state, { artifacts, ci }, { perPage: 100 }, ), completedNumbers: new Set(), detailPromises: new Map(), detailResults: new Map(), enrichedPulls: new Map(), }), { force }, ); if (!session.base.pulls.length) { if (progressivePullRequestCache.peek(cacheKey) !== session) { throw new HttpError( 409, "The progressive pull request load expired. Refresh the list.", ); } const payload = completeProgressivePullPayload(session); pullRequestCache.set(cacheKey, payload); return sendJson(res, 200, { repository, ...payload, account: auth.active, }); } return sendJson(res, 200, { repository, ...progressivePullPayload( session, session.base.pulls.slice(0, PROGRESSIVE_PULL_BATCH_SIZE), ), account: auth.active, }); } const session = progressivePullRequestCache.peek(cacheKey); const requestedSessionId = url.searchParams.get("session"); if (!session || !requestedSessionId || session.id !== requestedSessionId) { throw new HttpError( 409, "The progressive pull request load expired. Refresh the list.", ); } const offset = Number.parseInt(url.searchParams.get("offset"), 10); if ( !Number.isSafeInteger(offset) || offset < 0 || offset >= session.base.pulls.length || offset % PROGRESSIVE_PULL_BATCH_SIZE !== 0 ) { throw new HttpError(400, "A valid progressive batch offset is required."); } if (phase === "batch") { return sendJson(res, 200, { repository, ...progressivePullPayload( session, session.base.pulls.slice( offset, offset + PROGRESSIVE_PULL_BATCH_SIZE, ), { offset }, ), account: auth.active, }); } const details = await loadProgressivePullDetails( session, cacheKey, auth.activeToken, repository, offset, ); if (progressivePullRequestCache.peek(cacheKey) !== session) { throw new HttpError( 409, "The progressive pull request load expired. Refresh the list.", ); } return sendJson(res, 200, { repository, ...details, account: auth.active, }); } const result = await pullRequestCache.get( cacheKey, () => searchPullRequests( auth.activeToken, repository, query, state, { artifacts, ci }, ), { force }, ); return sendJson(res, 200, { repository, ...result, account: auth.active, }); } const pullMatch = path.match(/^\/api\/pulls\/(\d+)$/); if (req.method === "GET" && pullMatch) { const repository = normalizeRepository(url.searchParams.get("repo")); const { auth } = await resolveAuth(repository); const payload = await pullRequestCache.get( pullRequestCacheKey(auth.active, "detail", [repository, pullMatch[1]]), () => listPullRequestArtifacts( auth.activeToken, repository, pullMatch[1], ), { force: url.searchParams.get("refresh") === "1" }, ); return sendJson(res, 200, { repository, ...payload, account: auth.active, }); } if (req.method === "POST" && path === "/api/artifacts/inspect") { const body = await readJsonBody(req); const repository = normalizeRepository(body.repository); const { auth } = await resolveAuth(repository); const metadata = await inspectArtifact( auth.activeToken, repository, body.artifactId, { onProgress: (progress) => broadcast(entry, "artifact-progress", { repository, ...progress }), }, ); broadcast(entry, "cache", await getCacheSummary()); return sendJson(res, 200, metadata); } const artifactMatch = parseArtifactRoute(path); if (req.method === "GET" && artifactMatch) { const metadata = await getCachedArtifact(artifactMatch[1]); if (!metadata) throw new HttpError(404, "Artifact is not cached."); return sendJson(res, 200, metadata); } const textMatch = path.match(/^\/api\/artifacts\/(\d+)\/text$/); if (req.method === "GET" && textMatch) { const entryPath = url.searchParams.get("path"); if (!entryPath) throw new HttpError(400, "File path is required."); const kind = kindForPath(entryPath); if (!isInlineTextKind(kind)) { throw new HttpError(415, "This file is not an inline text preview."); } const context = await readCachedEntry( textMatch[1], entryPath, kind === "trx" ? MAX_TRX_PREVIEW_BYTES : MAX_INLINE_PREVIEW_BYTES, ); return sendJson(res, 200, { path: context.entry.name, kind, content: context.content.toString("utf8"), }); } const previewMatch = path.match(/^\/api\/artifacts\/(\d+)\/preview-url$/); if (req.method === "GET" && previewMatch) { const entryPath = url.searchParams.get("path"); if (!entryPath) throw new HttpError(400, "File path is required."); if (kindForPath(entryPath) !== "html") { throw new HttpError(415, "Only HTML files can use the site preview."); } const metadata = await getCachedArtifact(previewMatch[1], { touch: false }); if (!metadata) throw new HttpError(404, "Artifact is not cached. Inspect it first."); if (!hasRootIndexHtml(metadata.analysis?.entries ?? [])) { throw new HttpError( 415, "HTML previews require index.html at the artifact root.", ); } const theme = url.searchParams.get("theme"); if (theme !== "light" && theme !== "dark") { throw new HttpError(400, "Preview theme must be light or dark."); } return sendJson(res, 200, { url: await startStaticPreview(previewMatch[1], entryPath, { theme, parentOrigin: new URL(entry.url).origin, }), }); } if (req.method === "GET" && path === "/api/cache") { return sendJson(res, 200, await getCacheSummary()); } const cacheMatch = path.match(/^\/api\/cache\/(\d+)$/); if (req.method === "DELETE" && cacheMatch) { await stopStaticPreviewsForArtifact(cacheMatch[1]); const result = await deleteCachedArtifact(cacheMatch[1]); broadcast(entry, "cache", await getCacheSummary()); return sendJson(res, 200, result); } if (req.method === "DELETE" && path === "/api/cache") { await stopAllStaticPreviews(); const result = await clearArtifactCache(); broadcast(entry, "cache", await getCacheSummary()); return sendJson(res, 200, result); } if (req.method === "GET" && path === "/events") { res.writeHead(200, { "Cache-Control": "no-cache", Connection: "keep-alive", "Content-Type": "text/event-stream", }); res.write(": connected\n\n"); entry.sseClients.add(res); req.on("close", () => entry.sseClients.delete(res)); return; } throw new HttpError(404, "API route not found."); } async function handleRequest(req, res, entry) { const url = new URL(req.url, "http://127.0.0.1"); try { if (!isCanonicalHost(req, entry.host)) { throw new HttpError(403, "Request host is not allowed."); } const protectedRoute = requiresCapabilityToken(url.pathname); if (protectedRoute && isCrossSiteRequest(req, entry.origin)) { throw new HttpError(403, "Cross-site requests are not allowed."); } if (protectedRoute && !hasCapabilityToken(req, url, entry.token)) { throw new HttpError(403, "Missing or invalid capability token."); } const documentRoute = req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html"); if (documentRoute && url.searchParams.get("token") !== entry.token) { throw new HttpError(403, "Missing or invalid capability token."); } if (documentRoute) { return sendText(res, 200, renderHtml(entry.token), "text/html; charset=utf-8", { "Content-Security-Policy": MAIN_CSP, "Referrer-Policy": "no-referrer", }); } const assetTypes = new Map([ ["/assets/primer-color-modes.css", ["primer-color-modes.css", "text/css; charset=utf-8"]], ["/assets/primer-core.css", ["primer-core.css", "text/css; charset=utf-8"]], ["/assets/primer-product.css", ["primer-product.css", "text/css; charset=utf-8"]], ["/assets/asciinema-player.css", ["asciinema-player.css", "text/css; charset=utf-8"]], ["/assets/asciinema-player.min.js", ["asciinema-player.min.js", "text/javascript; charset=utf-8"]], ["/assets/asciinema-player-worker.min.js", ["asciinema-player-worker.min.js", "text/javascript; charset=utf-8"]], ["/assets/app.css", ["app.css", "text/css; charset=utf-8"]], ["/assets/trx-preview.js", ["trx-preview.js", "text/javascript; charset=utf-8"]], ["/assets/app.js", ["app.js", "text/javascript; charset=utf-8"]], ]); if (req.method === "GET" && assetTypes.has(url.pathname)) { const [name, type] = assetTypes.get(url.pathname); return await sendFile(res, join(ASSET_ROOT, name), type); } const iconMatch = url.pathname.match(/^\/assets\/octicons\/([a-z-]+)\.svg$/); if (req.method === "GET" && iconMatch) { return await sendFile( res, join(ASSET_ROOT, "octicons", `${iconMatch[1]}.svg`), "image/svg+xml", ); } if (await handleContentRequest(req, res, url.pathname, url.searchParams)) return; if (url.pathname.startsWith("/api/") || url.pathname === "/events") { return await handleApi(req, res, entry, url); } throw new HttpError(404, "Route not found."); } catch (error) { entry.log?.(`${req.method} ${url.pathname}: ${error instanceof Error ? error.message : String(error)}`); if (res.headersSent) { res.end(); return; } const status = error instanceof HttpError ? error.status : error instanceof GitHubApiError ? error.status : 500; sendJson(res, status, { error: error instanceof Error ? error.message : String(error), status, }); } } export async function startInstance(instanceId, initialInput, log) { let entry = servers.get(instanceId); if (entry) { entry.initialInput = initialInput ?? entry.initialInput; return entry; } entry = { host: null, initialInput: initialInput ?? null, log, origin: null, server: null, sseClients: new Set(), token: createCapabilityToken(), url: null, }; const server = createServer((req, res) => { void handleRequest(req, res, entry); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); if (!address || typeof address === "string") { await new Promise((resolve) => server.close(resolve)); throw new Error("Canvas server did not receive a TCP port."); } entry.server = server; entry.host = `127.0.0.1:${address.port}`; entry.origin = `http://${entry.host}`; entry.url = `${entry.origin}/?token=${encodeURIComponent(entry.token)}`; servers.set(instanceId, entry); return entry; } export async function stopInstance(instanceId) { const entry = servers.get(instanceId); if (!entry) return; servers.delete(instanceId); for (const response of entry.sseClients) response.end(); if (entry.url) { await stopStaticPreviewsForOrigin(new URL(entry.url).origin); } await new Promise((resolve) => entry.server.close(resolve)); } export function navigateInstance(instanceId, route) { const entry = servers.get(instanceId); if (!entry) return false; broadcast(entry, "navigate", { route }); return true; } export async function broadcastCache({ refresh = false } = {}) { const summary = await getCacheSummary(); for (const entry of servers.values()) { broadcast(entry, "cache", summary); if (refresh) broadcast(entry, "refresh", { reason: "cache" }); } } export async function closeAllPreviews() { await stopAllStaticPreviews(); }