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 app.get("/:repo/koh/objects/:hash", (c) =>117 serve(c, c.req.param("repo"), (mirror) => {118 const hash = c.req.param("hash");119 if (!OBJECT_HASH.test(hash)) return miss(c);120 const row = mirror121 .query<{ data: Uint8Array<ArrayBuffer> }, [string]>(122 "SELECT data FROM objects WHERE hash = ?",123 )124 .get(hash);125 return row126 ? c.body(row.data, 200, {127 "Content-Type": "application/octet-stream",128 "Cache-Control": OBJECT_CACHE,129 })130 : miss(c);131 }),132 );133134 135 136 137 app.get("/:repo/koh/saves", (c) =>138 serve(c, c.req.param("repo"), (mirror) => {139 const ids = mirror140 .query<{ id: string }, []>("SELECT id FROM saves ORDER BY id")141 .all()142 .map((row) => row.id);143 return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);144 }),145 );146147 app.get("/:repo/koh/objects", (c) =>148 serve(c, c.req.param("repo"), (mirror) => {149 const hashes = mirror150 .query<{ hash: string }, []>("SELECT hash FROM objects ORDER BY hash")151 .all()152 .map((row) => row.hash);153 return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);154 }),155 );156157 app.get("/:repo/koh/events", (c) =>158 serve(c, c.req.param("repo"), (mirror) => {159 const ids = mirror160 .query<{ id: string }, []>("SELECT id FROM events ORDER BY id")161 .all()162 .map((row) => row.id);163 return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);164 }),165 );166167 app.get("/:repo/koh/events/:id", (c) =>168 serve(c, c.req.param("repo"), (mirror) => {169 const id = c.req.param("id");170 if (!SAVE_ID.test(id)) return miss(c);171 const row = mirror172 .query<EventRow, [string]>(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`)173 .get(id);174 if (!row) return miss(c);175 const record: EventRecord = {176 id: row.id,177 kind: row.kind,178 save_id: row.save_id ?? "",179 path: row.path ?? "",180 reason: row.reason ?? "",181 created_at: row.created_at,182 machine: row.machine,183 };184 return json(c, JSON.stringify(record), DAY_CACHE);185 }),186 );187}