import { JsonCodec, type BatchOp, type Codec, type KVDriver, type ScanOptions } from '../kv/driver.js' export interface KVHttpHandlerOptions { /** Serialises request/response bodies and NDJSON scan lines. Must not emit newlines. Default JSON. */ codec?: Codec /** Path prefix to strip, e.g. `/kv`. Default `''`. */ basePath?: string } export interface HttpCapabilities { setIfAbsent: boolean batch: boolean deleteRange: boolean } /** * Framework-agnostic HTTP front for a driver (`Request → Response`). * * - `POST /getMany {keys}` → `{values, missing}` (`missing` = indices of absent keys) * - `POST /setMany {entries}`, `POST /deleteMany {keys}`, `POST /batch {ops}` → 204 * - `POST /setIfAbsent {entries}` → `{written}` * - `POST /deleteRange {gte, lt}` → 204 * - `GET /scan?gte<&limit&reverse&values` → NDJSON, one `[key]` or `[key, value]` per line * - `GET /capabilities` → `{setIfAbsent, batch, deleteRange}` */ export function createKVHttpHandler(driver: KVDriver, opts: KVHttpHandlerOptions = {}): (req: Request) => Promise { const codec = opts.codec ?? JsonCodec const base = opts.basePath ?? '' const reply = (body: unknown, status = 200) => new Response(codec.encode(body), { status, headers: { 'content-type': 'application/json' } }) const noContent = () => new Response(null, { status: 204 }) const fail = (status: number, message: string) => reply({ error: message }, status) return async (req) => { const url = new URL(req.url) if (!url.pathname.startsWith(base)) return fail(404, 'Not found') const route = `${req.method} ${url.pathname.slice(base.length)}` try { const body = async () => codec.decode(await req.text()) as T switch (route) { case 'POST /getMany': { const { keys } = await body<{ keys: string[] }>() const found = await driver.getMany(keys) const missing: number[] = [] const values = found.map((v, i) => (v === undefined ? (missing.push(i), null) : v)) return reply({ values, missing }) } case 'POST /setMany': await driver.setMany((await body<{ entries: [string, unknown][] }>()).entries) return noContent() case 'POST /deleteMany': await driver.deleteMany((await body<{ keys: string[] }>()).keys) return noContent() case 'POST /setIfAbsent': if (!driver.setIfAbsent) return fail(501, 'setIfAbsent not supported') return reply({ written: await driver.setIfAbsent((await body<{ entries: [string, unknown][] }>()).entries) }) case 'POST /batch': if (!driver.batch) return fail(501, 'batch not supported') await driver.batch((await body<{ ops: BatchOp[] }>()).ops) return noContent() case 'POST /deleteRange': { if (!driver.deleteRange) return fail(501, 'deleteRange not supported') const { gte, lt } = await body<{ gte: string; lt: string }>() await driver.deleteRange(gte, lt) return noContent() } case 'GET /capabilities': return reply({ setIfAbsent: !!driver.setIfAbsent, batch: !!driver.batch, deleteRange: !!driver.deleteRange } satisfies HttpCapabilities) case 'GET /scan': return scanResponse(driver, parseScan(url.searchParams), codec) default: return fail(404, `No route ${route}`) } } catch (err) { return fail(500, err instanceof Error ? err.message : String(err)) } } } function parseScan(q: URLSearchParams): ScanOptions { const opts: ScanOptions = {} if (q.has('gte')) opts.gte = q.get('gte')! if (q.has('lt')) opts.lt = q.get('lt')! if (q.has('limit')) opts.limit = Number(q.get('limit')) if (q.get('reverse') === 'true') opts.reverse = true if (q.get('values') === 'true') opts.values = true return opts } function scanResponse(driver: KVDriver, opts: ScanOptions, codec: Codec): Response { const it = driver.scan(opts)[Symbol.asyncIterator]() const encoder = new TextEncoder() // Chunks grow so short (skip-scan) reads don't make the server run far ahead. let chunk = 16 const stream = new ReadableStream({ async pull(controller) { let buf = '' for (let i = 0; i < chunk; i++) { const r = await it.next() if (r.done) { if (buf) controller.enqueue(encoder.encode(buf)) controller.close() return } const [key, value] = r.value buf += codec.encode(opts.values ? [key, value] : [key]) + '\n' } chunk = Math.min(chunk * 2, 256) controller.enqueue(encoder.encode(buf)) }, async cancel() { await it.return?.() }, }) return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } }) }