mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-08 10:09:37 +00:00
chore: publish from main
This commit is contained in:
@@ -0,0 +1,962 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user