Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/strata/server.ts

Raw
import { 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 { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" }[
			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);
	}
}