Add KVStorageComposite, IndexedDB storage and SSE change streams
- Every KV storage emits change events (base class owns writeMany/removeMany/ writeIfAbsent; implementations provide protected apply* methods) - KVStorageHttpServer streams changes over SSE (GET /events) with a resumable history; KVStorageHttp.watch() subscribes and resumes via Last-Event-ID - KVStorageIndexedDB for local persistence - KVStorageComposite keeps replicas of a source in sync: local reads, resume instead of full sync, offline outbox replayed in order, echo tracking - Values may contain bigint/Uint8Array (Extended JSON over the network) - Vendor sse-session, event-emitter and extended-json (no runtime deps) - Freeze child nodes so shadowed state on children fails loudly - CI on Node 22, 24 and 26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -1,36 +1,75 @@
|
||||
import type { BaseKVStorage, ScanOptions } from './base-kv-storage.js'
|
||||
import { ExtendedJson } from '../utils/extended-json.js'
|
||||
import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
|
||||
|
||||
export interface KVStorageHttpServerOptions {
|
||||
/** Path prefix to strip, e.g. `/kv`. Default `''`. */
|
||||
basePath?: string
|
||||
/** Recent change batches kept for clients resuming with `Last-Event-ID`. Default 10 000. */
|
||||
historySize?: number
|
||||
/** Interval of SSE keep-alive comments. Default 15 000 ms. */
|
||||
keepAliveMs?: number
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
seq: number
|
||||
changes: KVChange[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves any KV storage over HTTP for `KVStorageHttp`. Framework-agnostic: `handle(Request)`
|
||||
* returns a `Response`. All routes are `POST /<primitive>` with a JSON body; `scan` streams NDJSON.
|
||||
* The raw primitives use absolute keys, so this exposes the whole store behind `storage`.
|
||||
* returns a `Response`.
|
||||
*
|
||||
* - `POST /<primitive>` with an Extended JSON body (`scan` streams NDJSON).
|
||||
* - `GET /events`: Server-Sent Events of every change to the storage. Event ids are
|
||||
* `<epoch>-<seq>` (epoch = this server instance). A client sending `Last-Event-ID` gets the
|
||||
* changes it missed if they are still in the history; otherwise it gets a `resync` event.
|
||||
* A client without one gets a `ready` event carrying the current id.
|
||||
*
|
||||
* Only changes made through the wrapped storage instance are seen; several server processes over
|
||||
* one database each see only their own writes.
|
||||
*/
|
||||
export class KVStorageHttpServer {
|
||||
private readonly basePath: string
|
||||
private readonly historySize: number
|
||||
private readonly keepAliveMs: number
|
||||
private readonly epoch = Math.random().toString(36).slice(2, 10)
|
||||
private seq = 0
|
||||
private readonly history: HistoryEntry[] = []
|
||||
private readonly subscribers = new Set<(frame: string) => void>()
|
||||
|
||||
constructor(
|
||||
private readonly storage: BaseKVStorage,
|
||||
private readonly basePath = '',
|
||||
) {}
|
||||
options: KVStorageHttpServerOptions = {},
|
||||
) {
|
||||
this.basePath = options.basePath ?? ''
|
||||
this.historySize = options.historySize ?? 10_000
|
||||
this.keepAliveMs = options.keepAliveMs ?? 15_000
|
||||
storage.on('change', ({ changes }) => this.publish(changes))
|
||||
}
|
||||
|
||||
async handle(request: Request): Promise<Response> {
|
||||
const path = new URL(request.url).pathname
|
||||
if (request.method !== 'POST' || !path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found')
|
||||
if (!path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found')
|
||||
const route = `${request.method} ${path.slice(this.basePath.length)}`
|
||||
if (route === 'GET /events') return this.events(request.headers.get('last-event-id'))
|
||||
if (request.method !== 'POST') return KVStorageHttpServer.error(404, 'Not found')
|
||||
try {
|
||||
const body = (await request.json()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions
|
||||
switch (path.slice(this.basePath.length)) {
|
||||
case '/readMany': {
|
||||
const body = ExtendedJson.parse(await request.text()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions
|
||||
switch (route) {
|
||||
case 'POST /readMany': {
|
||||
const values = await this.storage.readMany(body.keys)
|
||||
const missing = values.flatMap((v, i) => (v === undefined ? [i] : []))
|
||||
return Response.json({ values: values.map((v) => v ?? null), missing })
|
||||
return KVStorageHttpServer.json({ values: values.map((v) => v ?? null), missing })
|
||||
}
|
||||
case '/writeMany':
|
||||
case 'POST /writeMany':
|
||||
await this.storage.writeMany(body.entries)
|
||||
return new Response(null, { status: 204 })
|
||||
case '/removeMany':
|
||||
case 'POST /removeMany':
|
||||
await this.storage.removeMany(body.keys)
|
||||
return new Response(null, { status: 204 })
|
||||
case '/writeIfAbsent':
|
||||
return Response.json(await this.storage.writeIfAbsent(body.entries))
|
||||
case '/scan':
|
||||
case 'POST /writeIfAbsent':
|
||||
return KVStorageHttpServer.json(await this.storage.writeIfAbsent(body.entries))
|
||||
case 'POST /scan':
|
||||
return this.scan(body)
|
||||
default:
|
||||
return KVStorageHttpServer.error(404, 'Not found')
|
||||
@@ -40,6 +79,54 @@ export class KVStorageHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
private publish(changes: KVChange[]): void {
|
||||
const entry = { seq: ++this.seq, changes }
|
||||
this.history.push(entry)
|
||||
if (this.history.length > this.historySize) this.history.splice(0, this.history.length - this.historySize)
|
||||
const frame = this.frame('change', entry.seq, changes)
|
||||
for (const send of this.subscribers) send(frame)
|
||||
}
|
||||
|
||||
private events(lastEventId: string | null): Response {
|
||||
const encoder = new TextEncoder()
|
||||
let send: ((frame: string) => void) | undefined
|
||||
let keepAlive: ReturnType<typeof setInterval> | undefined
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
send = (frame) => controller.enqueue(encoder.encode(frame))
|
||||
send(this.catchUpFrames(lastEventId))
|
||||
this.subscribers.add(send)
|
||||
keepAlive = setInterval(() => send!(': keep-alive\n\n'), this.keepAliveMs)
|
||||
;(keepAlive as { unref?: () => void }).unref?.()
|
||||
},
|
||||
cancel: () => {
|
||||
if (send) this.subscribers.delete(send)
|
||||
clearInterval(keepAlive)
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' } })
|
||||
}
|
||||
|
||||
/**
|
||||
* What a (re)connecting client gets before live changes: the changes it missed, or `resync` when
|
||||
* they can't be replayed; then always `ready` (the client is caught up as of that id).
|
||||
*/
|
||||
private catchUpFrames(lastEventId: string | null): string {
|
||||
const ready = this.frame('ready', this.seq, null)
|
||||
if (lastEventId === null) return ready
|
||||
const [epoch, seqText] = lastEventId.split('-')
|
||||
const seq = Number(seqText)
|
||||
const oldest = this.history[0]?.seq ?? this.seq + 1
|
||||
// Resumable only if it's our epoch and nothing after `seq` has been dropped from the history.
|
||||
if (epoch !== this.epoch || !Number.isInteger(seq) || seq > this.seq || seq < oldest - 1) return this.frame('resync', this.seq, null) + ready
|
||||
const missed = this.history.filter((entry) => entry.seq > seq).map((entry) => this.frame('change', entry.seq, entry.changes))
|
||||
return missed.join('') + ready
|
||||
}
|
||||
|
||||
private frame(event: string, seq: number, data: unknown): string {
|
||||
return `id: ${this.epoch}-${seq}\nevent: ${event}\ndata: ${ExtendedJson.stringify(data)}\n\n`
|
||||
}
|
||||
|
||||
private scan(options: ScanOptions): Response {
|
||||
const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
|
||||
const encoder = new TextEncoder()
|
||||
@@ -53,7 +140,7 @@ export class KVStorageHttpServer {
|
||||
if (chunk) controller.enqueue(encoder.encode(chunk))
|
||||
return controller.close()
|
||||
}
|
||||
chunk += JSON.stringify(options.values ? next.value : [next.value[0]]) + '\n'
|
||||
chunk += ExtendedJson.stringify(options.values ? next.value : [next.value[0]]) + '\n'
|
||||
}
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
},
|
||||
@@ -64,6 +151,10 @@ export class KVStorageHttpServer {
|
||||
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } })
|
||||
}
|
||||
|
||||
private static json(body: unknown): Response {
|
||||
return new Response(ExtendedJson.stringify(body), { headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
|
||||
private static error(status: number, message: string): Response {
|
||||
return Response.json({ error: message }, { status })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user