Files
David Pine 5b6da4c588 Add PR Artifact Explorer canvas 🤖🤖🤖 (#2341)
* Add PR Artifact Explorer canvas

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d

* Address PR #2341 review feedback and codespell CI failures

PR review fixes:

- zip.mjs: bounded streaming inflate for readZipEntry (output cap = entry.uncompressedSize) to prevent memory exhaustion from malformed entries

- zip.mjs: new bounded verify Transform in streamZipEntry that enforces decompressed byte cap and verifies CRC32 on flush

- zip.mjs: new readEntryPrefix() helper that inflates only up to a byte cap for indexing use cases

- preview.mjs: move URL/path parsing inside the try block so URIError becomes a 400 response instead of an unhandled rejection

- cache.mjs: buildMetadata uses bounded readEntryPrefix (8 KiB) instead of full readEntry+slice, eliminating unbounded decompression during indexing

- cache.mjs: clearArtifactCache aborts and awaits in-flight downloads via AbortController before removing cache dirs so clear cannot be repopulated

- server.mjs / preview.mjs: track static preview servers by canvas origin and stop them on canvas close, preventing loopback server leaks

- trx-preview.js: prefer authoritative ResultSummary.outcome when it is a recognized TRX value; only infer from counters when unknown

- extension.mjs: set_account validates the requested id resolves to the active account before persisting; unknown ids now return an error

- server.mjs: reject progressive-pull offsets that are not multiples of PROGRESSIVE_PULL_BATCH_SIZE to prevent caching incomplete pulls as complete

- README.md: remove Markdown-rendering claim (files are shown as escaped text)

CI fix:

- .codespellrc: skip vendored asciinema-player(*.min.js) and primer-*.css bundles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f

* Address second round of PR #2341 review feedback

- github.mjs: request isDraft in the pullRequestSignals GraphQL enrichment and propagate draft into search-sourced pulls (search REST API has no draft field)

- cache.mjs: give each writeJsonAtomic write a unique temp filename via a module sequence and clean up the temp file on failure to avoid concurrent-write races

- cache.mjs: deleteCachedArtifact now aborts and awaits any in-flight download for the artifact before removing files; internal mismatched-metadata purge uses a raw removeArtifactFiles helper to avoid aborting its own operation

- accounts.mjs: skip non-github.com CLI accounts before reading tokens so GitHub Enterprise Server credentials are never sent to api.github.com

- preview.mjs: require the requested entry to be the root index.html before launching a static preview (previously any nested HTML file could start one)

- zip.mjs: validate the EOCD comment length ends exactly at the archive tail so a file comment containing the EOCD signature is not mistaken for the record

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f

* Address remaining PR artifact explorer feedback

Fix malformed preference normalization, replace artifact-presence request fanout with cached repository artifact pagination, restrict static previews to the root index, and derive completed TRX outcomes from counters. Add focused regression coverage for all four behaviors.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4

* Fix PR artifact explorer review feedback

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132

* Stabilize artifact discovery and cache cleanup

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132

---------

Co-authored-by: David Pine <7679720+IEvangelist@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d
Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f
Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4
Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132
2026-07-30 10:16:58 +10:00

963 lines
30 KiB
JavaScript

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();
}