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";91011const FORMAT = "koh-remote 1";1213const NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}';14const OBJECT_HASH = /^[0-9a-f]{64}$/;1516171819const 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";515253function miss(c: Context): Response {54 return c.body(NOT_FOUND, 404, { "Content-Type": JSON_TYPE, "Cache-Control": "no-store" });55}565758596061export 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 83 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 96 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 118 119 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 166 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 185 cancel() {186 chunks.return(undefined);187 },188 });189}190191192function* 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 199 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 215 yield encoder.encode("end 0\n");216 } finally {217 saves.finalize();218 events.finalize();219 objects.finalize();220 }221}222223224225function 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 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}