1import type { Database } from "bun:sqlite";2import type { Context, Hono } from "hono";3import type { EventRecord, SaveFile, 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 SAVE_ID = /^[0-9a-f]{12}$/;15const OBJECT_HASH = /^[0-9a-f]{64}$/;1617181920const SAVE_CACHE = "private, max-age=60";21const OBJECT_CACHE = "private, max-age=31536000";22const DAY_CACHE = "private, max-age=86400";23const NO_CACHE = "no-cache";2425type SaveRow = {26 id: string;27 parent: string | null;28 message: string;29 created_at: number;30 machine: string;31 is_checkpoint: number;32 size_bytes: number;33 agent: string | null;34 session: string | null;35 restores: string | null;36 replayed_from: string | null;37};3839type EventRow = {40 id: string;41 kind: EventRecord["kind"];42 save_id: string | null;43 path: string | null;44 reason: string | null;45 created_at: number;46 machine: string;47};4849const SAVE_COLUMNS = `id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent,50 session, restores, replayed_from`;51const EVENT_COLUMNS = "id, kind, save_id, path, reason, created_at, machine";525354function miss(c: Context): Response {55 return c.body(NOT_FOUND, 404, { "Content-Type": JSON_TYPE, "Cache-Control": "no-store" });56}575859606162export function registerKohRemote(app: Hono, ctx: PageContext): void {63 const serve = (c: Context, name: string, body: (mirror: Database) => Response): Response => {64 const repo = openPublicRepo(ctx, name);65 return repo ? body(repo.mirror) : miss(c);66 };67 const json = (c: Context, body: string, cache: string): Response =>68 c.body(body, 200, { "Content-Type": JSON_TYPE, "Cache-Control": cache });69 const text = (c: Context, body: string, cache: string): Response =>70 c.body(body, 200, { "Content-Type": TEXT_TYPE, "Cache-Control": cache });7172 app.get("/:repo/koh/format", (c) =>73 serve(c, c.req.param("repo"), () => text(c, FORMAT, DAY_CACHE)),74 );7576 app.get("/:repo/koh/head", (c) =>77 serve(c, c.req.param("repo"), (mirror) => {78 const head = headId(mirror);79 return head ? text(c, head, NO_CACHE) : miss(c);80 }),81 );8283 84 85 app.get("/:repo/koh/saves/:id", (c) =>86 serve(c, c.req.param("repo"), (mirror) => {87 const id = c.req.param("id");88 if (!SAVE_ID.test(id)) return miss(c);89 const row = mirror90 .query<SaveRow, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`)91 .get(id);92 if (!row) return miss(c);93 94 95 const record: SaveRecord = {96 id: row.id,97 parent: row.parent ?? "",98 message: row.message,99 created_at: row.created_at,100 machine: row.machine,101 is_checkpoint: row.is_checkpoint === 1,102 size_bytes: row.size_bytes,103 agent: row.agent ?? "",104 session: row.session ?? "",105 restores: row.restores ?? "",106 replayed_from: row.replayed_from ?? "",107 108 files: filesAt(mirror, row.id).map(109 (file): SaveFile => ({ path: file.path, hash: file.hash, executable: file.executable }),110 ),111 };112 return json(c, JSON.stringify(record), SAVE_CACHE);113 }),114 );115116 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 WHERE hash = ?",124 )125 .get(hash);126 return row127 ? c.body(row.data, 200, {128 "Content-Type": "application/octet-stream",129 "Cache-Control": OBJECT_CACHE,130 })131 : miss(c);132 }),133 );134135 136 137 138 app.get("/:repo/koh/saves", (c) =>139 serve(c, c.req.param("repo"), (mirror) => {140 const ids = mirror141 .query<{ id: string }, []>("SELECT id FROM saves ORDER BY id")142 .all()143 .map((row) => row.id);144 return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);145 }),146 );147148 app.get("/:repo/koh/objects", (c) =>149 serve(c, c.req.param("repo"), (mirror) => {150 const hashes = mirror151 .query<{ hash: string }, []>("SELECT hash FROM objects ORDER BY hash")152 .all()153 .map((row) => row.hash);154 return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);155 }),156 );157158 app.get("/:repo/koh/events", (c) =>159 serve(c, c.req.param("repo"), (mirror) => {160 const ids = mirror161 .query<{ id: string }, []>("SELECT id FROM events ORDER BY id")162 .all()163 .map((row) => row.id);164 return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);165 }),166 );167168 app.get("/:repo/koh/events/:id", (c) =>169 serve(c, c.req.param("repo"), (mirror) => {170 const id = c.req.param("id");171 if (!SAVE_ID.test(id)) return miss(c);172 const row = mirror173 .query<EventRow, [string]>(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`)174 .get(id);175 if (!row) return miss(c);176 const record: EventRecord = {177 id: row.id,178 kind: row.kind,179 save_id: row.save_id ?? "",180 path: row.path ?? "",181 reason: row.reason ?? "",182 created_at: row.created_at,183 machine: row.machine,184 };185 return json(c, JSON.stringify(record), DAY_CACHE);186 }),187 );188}