remote.ts

9.1 KB · raw

1import type { Database } from "bun:sqlite";2import type { Context, Hono } from "hono";3import { type EventRecord, SAVE_ID, type SaveFile, type SaveRecord } from "../bucket/repo.ts";4import { openPublicRepo, type PageContext } from "../pages/layout.ts";5import { filesAt, headId } from "./queries.ts";67const JSON_TYPE = "application/json; charset=UTF-8";8const TEXT_TYPE = "text/plain; charset=UTF-8";9// §9.2's name for the layout. Koh trims whitespace before it compares, so no trailing newline is10// needed to agree with a bucket.11const FORMAT = "koh-remote 1";12// A private repo and an unknown one answer alike, to a person and to Koh (§20).13const NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}';14const OBJECT_HASH = /^[0-9a-f]{64}$/;15// The addendum's lifetimes — a save's record can still be rewritten by a strip, so a minute, and an16// object never changes once written but a strip can take it away, so a year rather than immutable.17// `private`, as the pages are: a repo can be turned private, and no shared cache may keep serving18// what it fetched while the repo was public (§20).19const SAVE_CACHE = "private, max-age=60";20const OBJECT_CACHE = "private, max-age=31536000";21const DAY_CACHE = "private, max-age=86400";22const NO_CACHE = "no-cache";2324type SaveRow = {25	id: string;26	parent: string | null;27	message: string;28	created_at: number;29	machine: string;30	is_checkpoint: number;31	size_bytes: number;32	agent: string | null;33	session: string | null;34	restores: string | null;35	replayed_from: string | null;36};3738type EventRow = {39	id: string;40	kind: EventRecord["kind"];41	save_id: string | null;42	path: string | null;43	reason: string | null;44	created_at: number;45	machine: string;46};4748const SAVE_COLUMNS = `id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent,49	session, restores, replayed_from`;50const EVENT_COLUMNS = "id, kind, save_id, path, reason, created_at, machine";5152/** The one answer for a private repo, an unknown one, and a record the mirror does not hold. */53function miss(c: Context): Response {54	return c.body(NOT_FOUND, 404, { "Content-Type": JSON_TYPE, "Cache-Control": "no-store" });55}5657/**58 * §9.2's layout, read-only, for the repos a visitor can see: what `koh steal <host>/<repo>`59 * fetches, so the paths, the bytes and the field names are Koh's, not Kepr's (A2, part 2).60 */61export function registerKohRemote(app: Hono, ctx: PageContext): void {62	const serve = (c: Context, name: string, body: (mirror: Database) => Response): Response => {63		const repo = openPublicRepo(ctx, name);64		return repo ? body(repo.mirror) : miss(c);65	};66	const json = (c: Context, body: string, cache: string): Response =>67		c.body(body, 200, { "Content-Type": JSON_TYPE, "Cache-Control": cache });68	const text = (c: Context, body: string, cache: string): Response =>69		c.body(body, 200, { "Content-Type": TEXT_TYPE, "Cache-Control": cache });7071	app.get("/:repo/koh/format", (c) =>72		serve(c, c.req.param("repo"), () => text(c, FORMAT, DAY_CACHE)),73	);7475	app.get("/:repo/koh/head", (c) =>76		serve(c, c.req.param("repo"), (mirror) => {77			const head = headId(mirror);78			return head ? text(c, head, NO_CACHE) : miss(c);79		}),80	);8182	// Checkpoints are served like any other save: the parent links of the saves around them need83	// them, even though the pages hide them (§21.4).84	app.get("/:repo/koh/saves/:id", (c) =>85		serve(c, c.req.param("repo"), (mirror) => {86			const id = c.req.param("id");87			if (!SAVE_ID.test(id)) return miss(c);88			const row = mirror89				.query<SaveRow, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`)90				.get(id);91			return row ? json(c, saveRecord(mirror, row), SAVE_CACHE) : miss(c);92		}),93	);9495	// The object's stored bytes, zlib and all: `koh steal` inflates them itself and checks the hash.96	// Only objects a visible file refers to: the mirror keeps hidden files' objects for unhiding.97	app.get("/:repo/koh/objects/:hash", (c) =>98		serve(c, c.req.param("repo"), (mirror) => {99			const hash = c.req.param("hash");100			if (!OBJECT_HASH.test(hash)) return miss(c);101			const row = mirror102				.query<{ data: Uint8Array<ArrayBuffer> }, [string]>(103					`SELECT data FROM objects o104					WHERE hash = ?105					AND EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)`,106				)107				.get(hash);108			return row109				? c.body(row.data, 200, {110						"Content-Type": "application/octet-stream",111						"Cache-Control": OBJECT_CACHE,112					})113				: miss(c);114		}),115	);116117	// Koh lists `<repo>/saves` and `<repo>/objects` as well as its events: a steal walks the chain118	// it is given here, and fetches every object those saves name. Both lists follow the mirror, so119	// neither is cached.120	app.get("/:repo/koh/saves", (c) =>121		serve(c, c.req.param("repo"), (mirror) => {122			const ids = mirror123				.query<{ id: string }, []>("SELECT id FROM saves ORDER BY id")124				.all()125				.map((row) => row.id);126			return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);127		}),128	);129130	app.get("/:repo/koh/objects", (c) =>131		serve(c, c.req.param("repo"), (mirror) => {132			const hashes = mirror133				.query<{ hash: string }, []>(134					`SELECT hash FROM objects o135					WHERE EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)136					ORDER BY hash`,137				)138				.all()139				.map((row) => row.hash);140			return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);141		}),142	);143144	app.get("/:repo/koh/events", (c) =>145		serve(c, c.req.param("repo"), (mirror) => {146			const ids = mirror147				.query<{ id: string }, []>("SELECT id FROM events ORDER BY id")148				.all()149				.map((row) => row.id);150			return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);151		}),152	);153154	app.get("/:repo/koh/events/:id", (c) =>155		serve(c, c.req.param("repo"), (mirror) => {156			const id = c.req.param("id");157			if (!SAVE_ID.test(id)) return miss(c);158			const row = mirror159				.query<EventRow, [string]>(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`)160				.get(id);161			return row ? json(c, eventRecord(row), DAY_CACHE) : miss(c);162		}),163	);164165	// The whole layout in one response, so a steal costs one request rather than one per key: a166	// 2,071-save repo took an hour of round trips. Streamed, because it is as big as the repo.167	app.get("/:repo/koh/bundle", (c) =>168		serve(c, c.req.param("repo"), (mirror) =>169			c.body(stream(bundle(mirror)), 200, {170				"Content-Type": "application/octet-stream",171				"Cache-Control": NO_CACHE,172			}),173		),174	);175}176177function stream(chunks: Generator<Uint8Array>): ReadableStream<Uint8Array> {178	return new ReadableStream({179		pull(controller) {180			const next = chunks.next();181			if (next.done) controller.close();182			else controller.enqueue(next.value);183		},184		// A client that hangs up leaves the generator part way through; this finalizes its queries.185		cancel() {186			chunks.return(undefined);187		},188	});189}190191/** `<key> <length>\n` and that many bytes per entry, keyed as the single routes are; `end 0` last. */192function* bundle(mirror: Database): Generator<Uint8Array> {193	const encoder = new TextEncoder();194	function* entry(key: string, bytes: Uint8Array): Generator<Uint8Array> {195		yield encoder.encode(`${key} ${bytes.length}\n`);196		yield bytes;197	}198	// `prepare`, never the cached `query`: two bundles streaming at once would share one cursor and199	// split the rows between them.200	const saves = mirror.prepare<SaveRow, []>(`SELECT ${SAVE_COLUMNS} FROM saves`);201	const events = mirror.prepare<EventRow, []>(`SELECT ${EVENT_COLUMNS} FROM events`);202	const objects = mirror.prepare<{ hash: string; data: Uint8Array }, []>(203		`SELECT hash, data FROM objects o204		WHERE EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)`,205	);206	try {207		for (const row of saves.iterate()) {208			yield* entry(`saves/${row.id}`, encoder.encode(saveRecord(mirror, row)));209		}210		for (const row of events.iterate()) {211			yield* entry(`events/${row.id}`, encoder.encode(eventRecord(row)));212		}213		for (const row of objects.iterate()) yield* entry(`objects/${row.hash}`, row.data);214		// A connection cut between two entries would otherwise pass for the whole repo.215		yield encoder.encode("end 0\n");216	} finally {217		saves.finalize();218		events.finalize();219		objects.finalize();220	}221}222223// Field names and their order are Koh's, and every optional is an empty string, as `json2.encode`224// writes it: `koh steal` decodes this straight into its own record.225function saveRecord(mirror: Database, row: SaveRow): string {226	const record: SaveRecord = {227		id: row.id,228		parent: row.parent ?? "",229		message: row.message,230		created_at: row.created_at,231		machine: row.machine,232		is_checkpoint: row.is_checkpoint === 1,233		size_bytes: row.size_bytes,234		agent: row.agent ?? "",235		session: row.session ?? "",236		restores: row.restores ?? "",237		replayed_from: row.replayed_from ?? "",238		// Whatever the strips left: a path they removed is gone from the record too.239		files: filesAt(mirror, row.id).map(240			(file): SaveFile => ({ path: file.path, hash: file.hash, executable: file.executable }),241		),242	};243	return JSON.stringify(record);244}245246function eventRecord(row: EventRow): string {247	const record: EventRecord = {248		id: row.id,249		kind: row.kind,250		save_id: row.save_id ?? "",251		path: row.path ?? "",252		reason: row.reason ?? "",253		created_at: row.created_at,254		machine: row.machine,255	};256	return JSON.stringify(record);257}