repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/strata/server.ts
Rawimport { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { createRequire } from "node:module";
import { validateDiagram } from "./diagram.js";
import { carryDraft } from "./draft.js";
import { renderReviewPage } from "./render.js";
import type {
AskRequest,
AskThread,
Draft,
DraftRecovery,
Feedback,
Finding,
Review,
ReviewServer,
ServerOptions,
Snapshot,
} from "./types.js";
const BODY_LIMIT = 64 * 1024;
const RECOVERY_BODY_LIMIT = 512 * 1024;
const MAX_RECOVERIES = 32;
const MAX_RECOVERY_TEXT = 128 * 1024;
const MAX_NOTES = 20_000;
const MAX_FINDINGS = 1_000;
const MAX_TEXT = 4_000;
const MAX_QUESTION_BYTES = 4 * 1024;
const MAX_ANSWER_BYTES = 16 * 1024;
const MAX_ID = 128;
const MAX_ASK_EXCHANGES = 128;
const SECURITY_HEADERS = {
"Cache-Control": "no-store",
"Content-Security-Policy":
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
} as const;
type JsonRecord = Record<string, unknown>;
let mermaidAsset: Promise<string> | undefined;
export async function startReviewServer(
options: ServerOptions,
): Promise<ReviewServer> {
let review = options.review;
let revision = 0;
let threads: AskThread[] = [];
let recoveries = validateRecoveries(review.recoveries ?? []);
review = { ...review, recoveries };
let closed = false;
let busy = false;
const sessionToken = randomBytes(32).toString("base64url");
const cookieName = `strata_${randomBytes(8).toString("hex")}`;
const controllers = new Set<AbortController>();
const requests = new Set<Promise<void>>();
let closing: Promise<void> | undefined;
const [script, stylesheet, diagramScript, mermaidScript] = await Promise.all([
readFile(new URL("./assets/app.js", import.meta.url), "utf8"),
readFile(new URL("./assets/style.css", import.meta.url), "utf8"),
readFile(new URL("./assets/diagram.js", import.meta.url), "utf8"),
readMermaidAsset(),
]);
const page = renderReviewPage();
let expectedHost = "";
let expectedOrigin = "";
const close = (): Promise<void> => {
if (closing) return closing;
closed = true;
for (const controller of controllers) controller.abort();
const stopped = new Promise<void>((resolve) =>
server.close(() => resolve()),
);
server.closeAllConnections();
closing = Promise.allSettled([...requests, stopped]).then(() => {});
options.onClose?.();
return closing;
};
const runExclusive = async <T>(
req: IncomingMessage,
res: ServerResponse,
work: (signal: AbortSignal) => Promise<T> | T,
): Promise<T> => {
if (busy) throw new HttpError(409, "review server is busy", "busy");
if (closed) throw new HttpError(410, "review server is closed");
busy = true;
const controller = new AbortController();
const abort = () => controller.abort();
const abortOnPrematureClose = () => {
if (!res.writableEnded) controller.abort();
};
controllers.add(controller);
req.once("aborted", abort);
res.once("close", abortOnPrematureClose);
try {
const result = await work(controller.signal);
if (closed || controller.signal.aborted) {
throw new HttpError(410, "review server is closed");
}
return result;
} finally {
req.off("aborted", abort);
res.off("close", abortOnPrematureClose);
controllers.delete(controller);
busy = false;
}
};
const responseVersion = (value: JsonRecord): JsonRecord => ({
...value,
revision,
snapshotId: review.snapshot.id,
});
const reportFailure = (error: unknown): void => {
if (error instanceof HttpError && error.code === "stale") return;
if (
error instanceof HttpError &&
["busy", "confirm_required", "stale_client"].includes(error.code ?? "")
)
return;
options.onLifecycle?.({
type: "error",
message: error instanceof Error ? error.message : "request failed",
});
};
const handle = async (req: IncomingMessage, res: ServerResponse) => {
setHeaders(res);
if (closed) return json(res, 410, { error: "review server is closed" });
if (req.headers.host !== expectedHost) {
return json(res, 400, { error: "invalid host" });
}
const url = new URL(req.url ?? "/", expectedOrigin);
if (req.method === "GET" && url.pathname === "/" && url.search) {
if (
url.searchParams.size !== 1 ||
url.searchParams.get("token") !== sessionToken
) {
return json(res, 401, { error: "unauthorized" });
}
res.writeHead(303, {
Location: "/",
"Set-Cookie": `${cookieName}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
});
res.end();
return;
}
if (!authenticated(req, cookieName, sessionToken)) {
return json(res, 401, { error: "unauthorized" });
}
const origin = req.headers.origin;
if (
(req.method !== "GET" && origin !== expectedOrigin) ||
(origin !== undefined && origin !== expectedOrigin)
) {
return json(res, 403, { error: "invalid origin" });
}
if (req.method === "GET" && url.pathname === "/") {
return text(res, 200, "text/html; charset=utf-8", page);
}
if (req.method === "GET" && url.pathname === "/diagram") {
const nonce = randomBytes(16).toString("base64");
setDiagramHeaders(res, nonce);
return text(
res,
200,
"text/html; charset=utf-8",
renderDiagramPage(expectedOrigin, nonce, mermaidScript, diagramScript),
);
}
if (req.method === "GET" && url.pathname === "/assets/app.js") {
return text(res, 200, "text/javascript; charset=utf-8", script);
}
if (req.method === "GET" && url.pathname === "/assets/style.css") {
return text(res, 200, "text/css; charset=utf-8", stylesheet);
}
if (req.method === "GET" && url.pathname === "/api/state") {
return json(res, 200, {
review,
threads,
recoveries,
busy,
revision,
snapshotId: review.snapshot.id,
});
}
if (req.method !== "POST") {
return json(res, 404, { error: "not found" });
}
if (url.pathname === "/api/close") {
const body = exactObject(
await readJson(req, RECOVERY_BODY_LIMIT),
[],
["recovery", "recoveries"],
);
if (body["recovery"] !== undefined && body["recoveries"] !== undefined)
throw new HttpError(400, "provide recovery or recoveries, not both");
if (body["recoveries"] !== undefined) {
if (
!Array.isArray(body["recoveries"]) ||
body["recoveries"].length > MAX_RECOVERIES
)
throw new HttpError(400, "invalid recovery collection");
for (const recovery of body["recoveries"]) appendRecovery(recovery);
} else if (body["recovery"] !== undefined)
appendRecovery(body["recovery"]);
json(res, 200, responseVersion({ closed: true, review, recoveries }));
setImmediate(close);
return;
}
if (url.pathname === "/api/recovery") {
const body = exactObject(
await readJson(req, RECOVERY_BODY_LIMIT),
[],
["recovery", "remove"],
);
const hasRecovery = body["recovery"] !== undefined;
const hasRemove = body["remove"] !== undefined;
if (hasRecovery === hasRemove) {
throw new HttpError(
400,
"request must contain exactly one recovery action",
);
}
await runExclusive(req, res, () => {
if (hasRecovery) {
appendRecovery(body["recovery"]);
} else {
assertClientVersion(req, revision, review.snapshot.id);
const id = boundedString(body["remove"], "remove", 1, MAX_ID);
const next = recoveries.filter((recovery) => recovery.id !== id);
if (next.length === recoveries.length)
throw new HttpError(404, "unknown recovery");
persistRecoveries(next);
revision += 1;
}
});
return json(res, 200, responseVersion({ review, threads, recoveries }));
}
if (url.pathname === "/api/draft") {
const body = await readJson(req);
const draft = await runExclusive(req, res, () => {
assertClientVersion(req, revision, review.snapshot.id);
const next = validateDraft(body, review.snapshot);
options.save(next);
review = { ...review, draft: next };
revision += 1;
options.onLifecycle?.({ type: "draft", draft: next });
return next;
});
return json(res, 200, responseVersion({ draft }));
}
if (url.pathname === "/api/ask") {
const body = exactObject(await readJson(req), [
"layerId",
"hunkId",
"question",
]);
const answer = await runExclusive(req, res, async (signal) => {
assertClientVersion(req, revision, review.snapshot.id);
const layerId = nullableString(body["layerId"], "layerId", MAX_ID);
const hunkId = nullableString(body["hunkId"], "hunkId", MAX_ID);
const question = boundedString(
body["question"],
"question",
1,
MAX_TEXT,
).trim();
if (!question)
throw new HttpError(400, "question must not be whitespace");
assertUtf8Bytes(question, MAX_QUESTION_BYTES, "question", 400);
const layer = layerId === null ? undefined : findLayer(review, layerId);
if (layerId !== null && !layer)
throw new HttpError(400, "unknown layerId");
const hunk =
hunkId === null
? undefined
: review.snapshot.hunks.find(
(candidate) => candidate.id === hunkId,
);
if (hunkId !== null && !hunk)
throw new HttpError(400, "unknown hunkId");
if (
hunkId !== null &&
layer &&
!layer.hunks.some((candidate) => candidate.id === hunkId)
)
throw new HttpError(400, "hunkId is outside layer scope");
const exchangeCount = threads.reduce(
(total, thread) => total + thread.messages.length,
0,
);
if (exchangeCount >= MAX_ASK_EXCHANGES)
throw new HttpError(
409,
`review already contains ${MAX_ASK_EXCHANGES} Ask exchanges`,
"history_limit",
);
const thread = threads.find(
(candidate) => candidate.layerId === layerId,
);
const request: AskRequest = {
layerId,
hunkId,
question,
history: (thread?.messages ?? []).map((exchange) => ({
...exchange,
})),
};
if (!(await options.isCurrent(signal))) {
options.onLifecycle?.({ type: "stale" });
throw new HttpError(
409,
"snapshot is stale; refresh before asking",
"stale",
);
}
options.onLifecycle?.({ type: "verified-current" });
signal.throwIfAborted();
options.onLifecycle?.({ type: "answering" });
const next = await options.ask(request, signal);
signal.throwIfAborted();
if (typeof next !== "string" || !next.trim())
throw new HttpError(500, "ask returned an invalid answer");
assertUtf8Bytes(next, MAX_ANSWER_BYTES, "ask answer", 500);
if (!(await options.isCurrent(signal))) {
options.onLifecycle?.({ type: "stale" });
throw new HttpError(
409,
"snapshot became stale while answering; response was discarded",
"stale",
);
}
options.onLifecycle?.({ type: "verified-current" });
signal.throwIfAborted();
const completed = {
question,
answer: next,
hunkId,
};
if (thread) thread.messages.push(completed);
else threads.push({ layerId, messages: [completed] });
revision += 1;
options.onLifecycle?.({ type: "ready", review });
return next;
}).catch((error: unknown) => {
reportFailure(error);
throw error;
});
return json(res, 200, responseVersion({ answer, threads }));
}
if (url.pathname === "/api/refresh") {
const body = exactObject(await readJson(req), ["confirm"]);
if (typeof body["confirm"] !== "boolean") {
throw new HttpError(400, "confirm must be a boolean");
}
const refreshed = await runExclusive(req, res, async (signal) => {
assertClientVersion(req, revision, review.snapshot.id);
const wasCurrent = await options.isCurrent(signal);
if (wasCurrent) options.onLifecycle?.({ type: "verified-current" });
else options.onLifecycle?.({ type: "stale" });
signal.throwIfAborted();
if (wasCurrent) options.onLifecycle?.({ type: "refreshing" });
const next = await options.refresh(signal);
signal.throwIfAborted();
const identicalSnapshot =
review.snapshot.id === next.snapshot.id &&
JSON.stringify(review.snapshot.hunks) ===
JSON.stringify(next.snapshot.hunks);
const carried = identicalSnapshot
? { draft: review.draft, orphaned: [] }
: carryDraft(review.snapshot, next.snapshot, review.draft);
if (carried.orphaned.length > 0 && !body["confirm"]) {
throw new HttpError(
409,
"refresh requires confirmation before discarding orphaned findings",
"confirm_required",
);
}
const draft = validateDraft(carried.draft, next.snapshot);
const unchanged =
identicalSnapshot &&
JSON.stringify(review.forge) === JSON.stringify(next.forge) &&
JSON.stringify(review.plan) === JSON.stringify(next.plan);
const updated = {
...next,
draft,
...(recoveries.length ? { recoveries } : {}),
};
options.save(draft, updated);
review = updated;
if (!unchanged) threads = [];
revision += 1;
options.onLifecycle?.({ type: "ready", review });
return { wasCurrent };
}).catch((error: unknown) => {
reportFailure(error);
throw error;
});
return json(res, 200, responseVersion({ review, threads, ...refreshed }));
}
if (url.pathname === "/api/submit") {
const body = exactObject(await readJson(req), ["draft"]);
const draft = await runExclusive(req, res, async (signal) => {
assertClientVersion(req, revision, review.snapshot.id);
const next = validateDraft(body["draft"], review.snapshot);
options.save(next);
review = { ...review, draft: next };
revision += 1;
options.onLifecycle?.({ type: "draft", draft: next });
const current = await options.isCurrent(signal);
if (current) options.onLifecycle?.({ type: "verified-current" });
else options.onLifecycle?.({ type: "stale" });
signal.throwIfAborted();
if (!current) {
throw new HttpError(
409,
"snapshot is stale; refresh before sending feedback",
"stale",
);
}
const feedback: Feedback = {
...next,
snapshotId: review.snapshot.id,
};
await options.submit(feedback);
signal.throwIfAborted();
options.onLifecycle?.({ type: "feedback-sent" });
const empty: Draft = {
reviewed: next.reviewed,
findings: [],
notes: "",
};
options.save(empty);
review = { ...review, draft: empty };
revision += 1;
options.onLifecycle?.({ type: "draft", draft: empty });
return empty;
}).catch((error: unknown) => {
reportFailure(error);
throw error;
});
return json(res, 200, responseVersion({ draft, submitted: true }));
}
return json(res, 404, { error: "not found" });
};
const persistRecoveries = (next: DraftRecovery[]): void => {
options.saveRecoveries?.(next);
recoveries = next;
review = { ...review, recoveries: next };
};
const appendRecovery = (value: unknown): void => {
const input = exactObject(value, ["snapshotId", "text"]);
const snapshotId = boundedString(
input["snapshotId"],
"recovery.snapshotId",
1,
MAX_ID,
);
const text = boundedString(
input["text"],
"recovery.text",
0,
MAX_RECOVERY_TEXT,
);
if (
recoveries.some(
(recovery) =>
recovery.snapshotId === snapshotId && recovery.text === text,
)
)
return;
if (recoveries.length >= MAX_RECOVERIES)
throw new HttpError(
409,
`recovery limit of ${MAX_RECOVERIES} reached`,
"recovery_limit",
);
persistRecoveries([
...recoveries,
{ id: randomBytes(16).toString("hex"), snapshotId, text },
]);
};
const server = createServer((req, res) => {
const request = handle(req, res).catch((error: unknown) => {
if (res.headersSent || res.destroyed) return;
const status = error instanceof HttpError ? error.status : 500;
const body: JsonRecord = {
error: error instanceof Error ? error.message : "request failed",
};
if (error instanceof HttpError && error.code) body["code"] = error.code;
if (error instanceof HttpError && error.status === 409) {
body["revision"] = revision;
body["snapshotId"] = review.snapshot.id;
}
json(res, status, body);
});
requests.add(request);
void request.finally(() => requests.delete(request));
});
const port = await listen(server);
expectedHost = `127.0.0.1:${port}`;
expectedOrigin = `http://${expectedHost}`;
return {
url: `${expectedOrigin}/?token=${encodeURIComponent(sessionToken)}`,
close,
};
}
export function validateDraft(value: unknown, snapshot: Snapshot): Draft {
const input = exactObject(value, ["reviewed", "findings", "notes"]);
if (!Array.isArray(input["reviewed"])) {
throw new HttpError(400, "reviewed must be an array");
}
if (!Array.isArray(input["findings"])) {
throw new HttpError(400, "findings must be an array");
}
if (input["reviewed"].length > snapshot.hunks.length) {
throw new HttpError(400, "reviewed contains too many entries");
}
if (input["findings"].length > MAX_FINDINGS) {
throw new HttpError(400, "findings contains too many entries");
}
const hunkIds = new Set(snapshot.hunks.map((hunk) => hunk.id));
const reviewed = input["reviewed"].map((value, index) =>
boundedString(value, `reviewed[${index}]`, 1, MAX_ID),
);
if (new Set(reviewed).size !== reviewed.length) {
throw new HttpError(400, "reviewed IDs must be unique");
}
for (const id of reviewed) {
if (!hunkIds.has(id))
throw new HttpError(400, `unknown reviewed hunk: ${id}`);
}
const findingIds = new Set<string>();
const findings = input["findings"].map((value, index): Finding => {
const finding = exactObject(
value,
["id", "hunkId", "side", "line", "severity", "text"],
["endLine"],
);
const id = boundedString(finding["id"], `findings[${index}].id`, 1, MAX_ID);
if (findingIds.has(id))
throw new HttpError(400, "finding IDs must be unique");
findingIds.add(id);
const hunkId = boundedString(
finding["hunkId"],
`findings[${index}].hunkId`,
1,
MAX_ID,
);
const hunk = snapshot.hunks.find((candidate) => candidate.id === hunkId);
if (!hunk) throw new HttpError(400, `unknown finding hunk: ${hunkId}`);
const side = finding["side"];
if (side !== "old" && side !== "new") {
throw new HttpError(400, `findings[${index}].side is invalid`);
}
if (!Number.isSafeInteger(finding["line"]) || Number(finding["line"]) < 1) {
throw new HttpError(400, `findings[${index}].line is invalid`);
}
const line = Number(finding["line"]);
const endLineValue = finding["endLine"];
if (
endLineValue !== undefined &&
(!Number.isSafeInteger(endLineValue) || Number(endLineValue) < line)
) {
throw new HttpError(400, `findings[${index}].endLine is invalid`);
}
const endLine =
endLineValue === undefined ? undefined : Number(endLineValue);
const lines = hunk.lines.flatMap((candidate) => {
const candidateLine =
side === "old" ? candidate.oldLine : candidate.newLine;
return candidateLine === undefined ? [] : [candidateLine];
});
const start = lines.indexOf(line);
const end = lines.indexOf(endLine ?? line);
if (start < 0 || end < start || end - start !== (endLine ?? line) - line) {
throw new HttpError(400, `finding line does not exist: ${hunkId}`);
}
if (endLine !== undefined && endLine === line) {
throw new HttpError(400, `findings[${index}].endLine must extend line`);
}
const severity = finding["severity"];
if (
severity !== "question" &&
severity !== "minor" &&
severity !== "major"
) {
throw new HttpError(400, `findings[${index}].severity is invalid`);
}
const text = boundedString(
finding["text"],
`findings[${index}].text`,
1,
MAX_TEXT,
);
return {
id,
hunkId,
side,
line,
...(endLine === undefined ? {} : { endLine }),
severity,
text,
};
});
const notes = boundedString(input["notes"], "notes", 0, MAX_NOTES);
return { reviewed, findings, notes };
}
function findLayer(review: Review, layerId: string) {
for (const cohort of review.plan.cohorts) {
const layer = cohort.layers.find((candidate) => candidate.id === layerId);
if (layer) return layer;
}
return undefined;
}
function nullableString(
value: unknown,
name: string,
maximum: number,
): string | null {
return value === null ? null : boundedString(value, name, 1, maximum);
}
function assertUtf8Bytes(
value: string,
maximum: number,
name: string,
status: number,
): void {
if (Buffer.byteLength(value, "utf8") > maximum)
throw new HttpError(status, `${name} exceeds ${maximum} byte limit`);
}
function assertClientVersion(
req: IncomingMessage,
revision: number,
snapshotId: string,
): void {
if (
req.headers["if-match"] !== String(revision) ||
req.headers["x-strata-snapshot"] !== snapshotId
) {
throw new HttpError(
409,
"review changed in another tab; reload before continuing",
"stale_client",
);
}
}
function authenticated(
req: IncomingMessage,
name: string,
token: string,
): boolean {
const cookies = req.headers.cookie?.split(";") ?? [];
return cookies.some((cookie) => cookie.trim() === `${name}=${token}`);
}
async function readJson(
req: IncomingMessage,
limit = BODY_LIMIT,
): Promise<unknown> {
const type = req.headers["content-type"]?.split(";", 1)[0]?.trim();
if (type !== "application/json") throw new HttpError(415, "expected JSON");
const declared = Number(req.headers["content-length"] ?? 0);
if (Number.isFinite(declared) && declared > limit) {
throw new HttpError(413, "request body is too large");
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > limit) throw new HttpError(413, "request body is too large");
chunks.push(buffer);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new HttpError(400, "malformed JSON");
}
}
function exactObject(
value: unknown,
keys: string[],
optionalKeys: string[] = [],
): JsonRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new HttpError(400, "request body must be an object");
}
const input = value as JsonRecord;
const actual = Object.keys(input);
if (
keys.some((key) => !Object.hasOwn(input, key)) ||
actual.some((key) => !keys.includes(key) && !optionalKeys.includes(key))
) {
throw new HttpError(400, "request body has unexpected fields");
}
return input;
}
function validateRecoveries(value: unknown): DraftRecovery[] {
if (!Array.isArray(value) || value.length > MAX_RECOVERIES) return [];
try {
const recoveries = value.map((recovery) => {
const input = exactObject(recovery, ["id", "snapshotId", "text"]);
return {
id: boundedString(input["id"], "recovery.id", 1, MAX_ID),
snapshotId: boundedString(
input["snapshotId"],
"recovery.snapshotId",
1,
MAX_ID,
),
text: boundedString(
input["text"],
"recovery.text",
0,
MAX_RECOVERY_TEXT,
),
};
});
return new Set(recoveries.map((recovery) => recovery.id)).size ===
recoveries.length
? recoveries
: [];
} catch {
return [];
}
}
function boundedString(
value: unknown,
name: string,
minimum: number,
maximum: number,
): string {
if (
typeof value !== "string" ||
value.length < minimum ||
value.length > maximum
) {
throw new HttpError(
400,
`${name} must be a string between ${minimum} and ${maximum} characters`,
);
}
return value;
}
function setHeaders(res: ServerResponse): void {
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
res.setHeader(name, value);
}
}
function readMermaidAsset(): Promise<string> {
mermaidAsset ??= (async () => {
try {
return await readFile(
new URL("./assets/mermaid.min.js", import.meta.url),
"utf8",
);
} catch (error) {
// Only source workspaces resolve dependencies; installed packages own the asset.
if (
!import.meta.url.endsWith("/server.ts") ||
(error as NodeJS.ErrnoException).code !== "ENOENT"
)
throw error;
const require = createRequire(import.meta.url);
return readFile(require.resolve("mermaid/dist/mermaid.min.js"), "utf8");
}
})();
return mermaidAsset;
}
function setDiagramHeaders(res: ServerResponse, nonce: string): void {
res.setHeader(
"Content-Security-Policy",
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts`,
);
res.setHeader("X-Frame-Options", "SAMEORIGIN");
}
function renderDiagramPage(
origin: string,
nonce: string,
mermaidScript: string,
diagramScript: string,
): string {
return `<!doctype html><html lang="en" data-parent-origin="${escapeAttribute(origin)}"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><body><main id="diagram" aria-live="polite">Loading diagram…</main><script nonce="${nonce}">${escapeScript(mermaidScript)}</script><script nonce="${nonce}">${escapeScript(`const validateDiagram = ${validateDiagram.toString()};\n${diagramScript}`)}</script></body></html>`;
}
function escapeScript(value: string): string {
return value.replace(/<\/script/giu, "<\\/script");
}
function escapeAttribute(value: string): string {
return value.replace(/[&"<>]/gu, (character) => {
return { "&": "&", '"': """, "<": "<", ">": ">" }[
character
] as string;
});
}
function json(res: ServerResponse, status: number, value: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(value));
}
function text(
res: ServerResponse,
status: number,
contentType: string,
value: string,
): void {
res.writeHead(status, { "Content-Type": contentType });
res.end(value);
}
function listen(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once("error", onError);
server.listen(0, "127.0.0.1", () => {
server.off("error", onError);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Failed to bind Strata review server"));
return;
}
resolve(address.port);
});
});
}
class HttpError extends Error {
constructor(
readonly status: number,
message: string,
readonly code?: string,
) {
super(message);
}
}