Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
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<Response> {
|
|
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 <T>() => 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<Uint8Array>({
|
|
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' } })
|
|
}
|