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 if (!row) return miss(c);92 93 94 const record: SaveRecord = {95 id: row.id,96 parent: row.parent ?? "",97 message: row.message,98 created_at: row.created_at,99 machine: row.machine,100 is_checkpoint: row.is_checkpoint === 1,101 size_bytes: row.size_bytes,102 agent: row.agent ?? "",103 session: row.session ?? "",104 restores: row.restores ?? "",105 replayed_from: row.replayed_from ?? "",106 107 files: filesAt(mirror, row.id).map(108 (file): SaveFile => ({ path: file.path, hash: file.hash, executable: file.executable }),109 ),110 };111 return json(c, JSON.stringify(record), SAVE_CACHE);112 }),113 );114115 116 117 app.get("/:repo/koh/objects/:hash", (c) =>118 serve(c, c.req.param("repo"), (mirror) => {119 const hash = c.req.param("hash");120 if (!OBJECT_HASH.test(hash)) return miss(c);121 const row = mirror122 .query<{ data: Uint8Array<ArrayBuffer> }, [string]>(123 `SELECT data FROM objects o124 WHERE hash = ?125 AND EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)`,126 )127 .get(hash);128 return row129 ? c.body(row.data, 200, {130 "Content-Type": "application/octet-stream",131 "Cache-Control": OBJECT_CACHE,132 })133 : miss(c);134 }),135 );136137 138 139 140 app.get("/:repo/koh/saves", (c) =>141 serve(c, c.req.param("repo"), (mirror) => {142 const ids = mirror143 .query<{ id: string }, []>("SELECT id FROM saves ORDER BY id")144 .all()145 .map((row) => row.id);146 return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);147 }),148 );149150 app.get("/:repo/koh/objects", (c) =>151 serve(c, c.req.param("repo"), (mirror) => {152 const hashes = mirror153 .query<{ hash: string }, []>(154 `SELECT hash FROM objects o155 WHERE EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)156 ORDER BY hash`,157 )158 .all()159 .map((row) => row.hash);160 return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);161 }),162 );163164 app.get("/:repo/koh/events", (c) =>165 serve(c, c.req.param("repo"), (mirror) => {166 const ids = mirror167 .query<{ id: string }, []>("SELECT id FROM events ORDER BY id")168 .all()169 .map((row) => row.id);170 return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);171 }),172 );173174 app.get("/:repo/koh/events/:id", (c) =>175 serve(c, c.req.param("repo"), (mirror) => {176 const id = c.req.param("id");177 if (!SAVE_ID.test(id)) return miss(c);178 const row = mirror179 .query<EventRow, [string]>(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`)180 .get(id);181 if (!row) return miss(c);182 const record: EventRecord = {183 id: row.id,184 kind: row.kind,185 save_id: row.save_id ?? "",186 path: row.path ?? "",187 reason: row.reason ?? "",188 created_at: row.created_at,189 machine: row.machine,190 };191 return json(c, JSON.stringify(record), DAY_CACHE);192 }),193 );194}