M8: HTTP driver (framework-agnostic handler + HttpKVDriver client) passing conformance end-to-end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 07:21:09 +00:00
parent f6541507ac
commit 76f791f54b
7 changed files with 384 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import { BaseKVDriver, JsonCodec, type BatchOp, type Codec, type ScanOptions } from '../kv/driver.js'
import type { HttpCapabilities } from './handler.js'
export interface HttpKVDriverOptions {
/** Base URL of a `createKVHttpHandler` endpoint, e.g. `https://host/kv`. */
url: string
/** Optional capabilities the server's driver supports. Default: all. `connect()` discovers them. */
capabilities?: Partial<HttpCapabilities>
codec?: Codec
headers?: Record<string, string>
fetch?: typeof fetch
}
/**
* Driver client for `createKVHttpHandler`. Optional methods exist only when the server
* supports them, so stores' feature detection works across HTTP.
*/
export class HttpKVDriver extends BaseKVDriver {
readonly #url: string
readonly #codec: Codec
readonly #headers: Record<string, string>
readonly #fetch: typeof fetch
readonly #nativeDeleteRange: boolean
setIfAbsent?: (entries: [string, unknown][]) => Promise<boolean[]>
batch?: (ops: BatchOp[]) => Promise<void>
/** Creates a client after asking the server which optional capabilities it has. */
static async connect(opts: Omit<HttpKVDriverOptions, 'capabilities'>): Promise<HttpKVDriver> {
const probe = new HttpKVDriver({ ...opts, capabilities: {} })
const capabilities = (await probe.#request('GET', '/capabilities')) as HttpCapabilities
return new HttpKVDriver({ ...opts, capabilities })
}
constructor(opts: HttpKVDriverOptions) {
super()
this.#url = opts.url.replace(/\/$/, '')
this.#codec = opts.codec ?? JsonCodec
this.#headers = opts.headers ?? {}
this.#fetch = opts.fetch ?? globalThis.fetch.bind(globalThis)
const caps = { setIfAbsent: true, batch: true, deleteRange: true, ...opts.capabilities }
this.#nativeDeleteRange = caps.deleteRange
if (caps.setIfAbsent) this.setIfAbsent = async (entries) => ((await this.#request('POST', '/setIfAbsent', { entries })) as { written: boolean[] }).written
if (caps.batch) this.batch = async (ops) => void (await this.#request('POST', '/batch', { ops }))
}
async getMany(keys: string[]): Promise<unknown[]> {
if (keys.length === 0) return []
const { values, missing } = (await this.#request('POST', '/getMany', { keys })) as { values: unknown[]; missing: number[] }
for (const i of missing) values[i] = undefined
return values
}
async setMany(entries: [string, unknown][]): Promise<void> {
if (entries.length) await this.#request('POST', '/setMany', { entries })
}
async deleteMany(keys: string[]): Promise<void> {
if (keys.length) await this.#request('POST', '/deleteMany', { keys })
}
override async deleteRange(gte: string, lt: string): Promise<void> {
if (!this.#nativeDeleteRange) return super.deleteRange(gte, lt)
await this.#request('POST', '/deleteRange', { gte, lt })
}
async *scan(opts: ScanOptions): AsyncGenerator<[string, unknown]> {
if (opts.limit !== undefined && opts.limit <= 0) return
const q = new URLSearchParams()
if (opts.gte !== undefined) q.set('gte', opts.gte)
if (opts.lt !== undefined) q.set('lt', opts.lt)
if (opts.limit !== undefined && opts.limit !== Infinity) q.set('limit', String(opts.limit))
if (opts.reverse) q.set('reverse', 'true')
if (opts.values) q.set('values', 'true')
const res = await this.#fetch(`${this.#url}/scan?${q}`, { headers: this.#headers })
if (!res.ok || !res.body) throw await httpError(res)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let nl: number
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const [key, v] = this.#codec.decode(line) as [string, unknown?]
yield [key, v]
}
}
} finally {
await reader.cancel().catch(() => {})
}
}
async #request(method: 'GET' | 'POST', path: string, body?: unknown): Promise<unknown> {
const res = await this.#fetch(this.#url + path, {
method,
headers: { ...this.#headers, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
body: body === undefined ? undefined : this.#codec.encode(body),
})
if (!res.ok) throw await httpError(res)
return res.status === 204 ? undefined : this.#codec.decode(await res.text())
}
}
async function httpError(res: Response): Promise<Error> {
let detail = ''
try {
detail = await res.text()
} catch {}
return new Error(`HTTP ${res.status}: ${detail}`)
}
+115
View File
@@ -0,0 +1,115 @@
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&lt&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' } })
}
+2
View File
@@ -0,0 +1,2 @@
export * from './handler.js'
export * from './client.js'
+1
View File
@@ -5,3 +5,4 @@ export * from './kv/index.js'
export * from './types/schema.js'
export * from './events/index.js'
export * from './documents/index.js'
export * from './http/index.js'