Add KVStorageComposite, IndexedDB storage and SSE change streams
CI / Node 24 (pull_request) Failing after 12s
CI / Node 22 (pull_request) Successful in 14s
CI / Node 26 (pull_request) Failing after 21s

- 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:
Claude
2026-10-23 08:41:42 +00:00
parent 08fb78721f
commit 4ffbc7e78d
36 changed files with 2679 additions and 99 deletions
@@ -27,7 +27,7 @@ export type ChildDoc<S> = ValueOf<ChildOf<S, KeyOf<S>>>
*/
export abstract class BaseDocumentStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root, which has no own document). */
path: readonly string[] = []
readonly path: readonly string[] = []
protected readonly ids = new Ulid()
abstract readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]>
@@ -40,8 +40,11 @@ export abstract class BaseDocumentStorage<S extends Spec = AnySpec> {
abstract query(parent: readonly string[], query: DocumentQuery): AsyncIterable<FindResult>
child<K extends KeyOf<S>>(key: K): BaseDocumentStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseDocumentStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)]
const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseDocumentStorage<ChildOf<S, K>>
// Frozen: child nodes share the root's state through the prototype, so an implementation that
// reassigns a field (`this.x = ...`) while running on a child would silently shadow it.
// Keep mutable state in objects (Maps, a `state` object) instead; this makes mistakes throw.
Object.freeze(node)
return node
}
+6 -3
View File
@@ -39,7 +39,7 @@ export interface StreamKeysOptions {
*/
export abstract class BaseEventStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root, which is not a stream). */
path: readonly string[] = []
readonly path: readonly string[] = []
abstract appendTo(stream: readonly string[], events: unknown[], expectedVersion: number | 'any'): Promise<AppendResult>
abstract readFrom(stream: readonly string[], options: ReadOptions): AsyncIterable<StoredEvent<unknown>>
@@ -47,8 +47,11 @@ export abstract class BaseEventStorage<S extends Spec = AnySpec> {
abstract streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncIterable<string>
child<K extends KeyOf<S>>(key: K): BaseEventStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseEventStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)]
const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseEventStorage<ChildOf<S, K>>
// Frozen: child nodes share the root's state through the prototype, so an implementation that
// reassigns a field (`this.x = ...`) while running on a child would silently shadow it.
// Keep mutable state in objects (Maps, a `state` object) instead; this makes mistakes throw.
Object.freeze(node)
return node
}
+4
View File
@@ -2,6 +2,8 @@ export * from './kv-storage/base-kv-storage.js'
export * from './kv-storage/kv-storage-in-memory.js'
export * from './kv-storage/kv-storage-http.js'
export * from './kv-storage/kv-storage-http-server.js'
export * from './kv-storage/kv-storage-indexeddb.js'
export * from './kv-storage/kv-storage-composite.js'
export * from './event-storage/base-event-storage.js'
export * from './event-storage/event-storage-kv.js'
export * from './document-storage/base-document-storage.js'
@@ -12,6 +14,8 @@ export * from './utils/document-path.js'
export * from './utils/document-query.js'
export * from './utils/document-update.js'
export * from './utils/errors.js'
export * from './utils/event-emitter.js'
export * from './utils/extended-json.js'
export * from './utils/json.js'
export * from './utils/keys.js'
export * from './utils/schema.js'
+66 -14
View File
@@ -1,3 +1,4 @@
import { EventEmitter } from '../utils/event-emitter.js'
import { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, EntryOf, KeyOf, KeyString, Spec, StringEntryOf, ValueAt, ValueOf } from '../utils/schema.js'
@@ -19,39 +20,90 @@ export interface KeysOptions {
reverse?: boolean
}
/** One key's new value; `value: undefined` means it was removed. */
export interface KVChange {
key: string
value?: unknown
}
export interface KVChangeEvent {
changes: KVChange[]
/** `local`: made through this instance. `remote`: observed from elsewhere (e.g. pushed by a server). */
origin: 'local' | 'remote'
/** Position in the source's change stream, when it has one (used to resume). */
id?: string
}
export type KVStorageEvents = {
change: KVChangeEvent
/** Changes may have been missed (e.g. a resume point was too old): consumers should reload. */
resync: { id?: string }
}
/**
* Ordered key-value storage. Implementations provide five raw primitives over absolute keys;
* everything else (navigation, self/child reads and writes, child listing) is defined here once.
* Ordered key-value storage. Implementations provide five raw primitives over absolute keys
* (`readMany`, `scan`, and the protected `apply*` writes); everything else (navigation, self/child
* reads and writes, child listing, change events) is defined here once.
*
* Every write emits a `change` event from the storage (shared by all its child nodes).
*
* Every instance is a node: `child(key)` returns a view of the same storage rooted at that key.
* Methods taking an optional `key` act on that child, or on this node's own value when omitted.
* A key that is passed but `undefined`/`null` throws rather than silently meaning "self".
*/
export abstract class BaseKVStorage<S extends Spec = AnySpec> {
export abstract class BaseKVStorage<S extends Spec = AnySpec> extends EventEmitter<KVStorageEvents> {
/** Unescaped path of this node (`[]` for the root). */
path: readonly string[] = []
readonly path: readonly string[] = []
/** This node's own key (`''` for the root). */
key = ''
readonly key: string = ''
/** Prefix of every descendant key (`''` for the root). */
prefix = ''
readonly prefix: string = ''
/** Values for absolute keys, in order; `undefined` for missing. */
abstract readMany(keys: string[]): Promise<unknown[]>
abstract writeMany(entries: [key: string, value: unknown][]): Promise<void>
abstract removeMany(keys: string[]): Promise<void>
/** Writes each entry only if its key is absent; `true` = written. Must be atomic per key. */
abstract writeIfAbsent(entries: [key: string, value: unknown][]): Promise<boolean[]>
/** Entries in key order (descending when `reverse`). */
abstract scan(options?: ScanOptions): AsyncIterable<[key: string, value: unknown]>
protected abstract applyWrite(entries: [key: string, value: unknown][]): Promise<void>
protected abstract applyRemove(keys: string[]): Promise<void>
/** Writes each entry only if its key is absent; `true` = written. Must be atomic per key. */
protected abstract applyWriteIfAbsent(entries: [key: string, value: unknown][]): Promise<boolean[]>
// ------------------------------------------------------------------ raw writes (absolute keys)
async writeMany(entries: [key: string, value: unknown][]): Promise<void> {
if (entries.length === 0) return
await this.applyWrite(entries)
this.emit('change', { changes: entries.map(([key, value]) => ({ key, value })), origin: 'local' })
}
async removeMany(keys: string[]): Promise<void> {
if (keys.length === 0) return
await this.applyRemove(keys)
this.emit('change', { changes: keys.map((key) => ({ key })), origin: 'local' })
}
async writeIfAbsent(entries: [key: string, value: unknown][]): Promise<boolean[]> {
if (entries.length === 0) return []
const written = await this.applyWriteIfAbsent(entries)
const changes = entries.filter((_, i) => written[i]).map(([key, value]) => ({ key, value }))
if (changes.length) this.emit('change', { changes, origin: 'local' })
return written
}
// ------------------------------------------------------------------ navigation
/** A view of this storage rooted at `key`. Synchronous; no I/O. */
child<K extends KeyOf<S>>(key: K): BaseKVStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseKVStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)]
node.key = this.prefix + Keys.escape(Keys.segment(key))
node.prefix = node.key + Keys.separator
const own = this.prefix + Keys.escape(Keys.segment(key))
const node = Object.create(this, {
path: { value: [...this.path, Keys.segment(key)], enumerable: true },
key: { value: own, enumerable: true },
prefix: { value: own + Keys.separator, enumerable: true },
}) as BaseKVStorage<ChildOf<S, K>>
// Frozen: child nodes share the root's state through the prototype, so an implementation that
// reassigns a field (`this.x = ...`) while running on a child would silently shadow it.
// Keep mutable state in objects (Maps, a `state` object) instead; this makes mistakes throw.
Object.freeze(node)
return node
}
+379
View File
@@ -0,0 +1,379 @@
import { StorageUnavailableError } from '../utils/errors.js'
import type { OffCallback } from '../utils/event-emitter.js'
import { Json } from '../utils/json.js'
import type { AnySpec, Spec } from '../utils/schema.js'
import { ExponentialBackoff } from '../utils/sse-session/exponential-backoff.js'
import { BaseKVStorage, type KVChange, type KVChangeEvent, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageCompositeOptions {
/** The source of truth (e.g. `KVStorageHttp`). */
source: BaseKVStorage
/**
* Local full copies, fastest first (e.g. `[inMemory, indexedDb]`). The last one should be
* persistent: it also stores the offline outbox and sync metadata, and seeds the others on start.
*/
replicas: BaseKVStorage[]
/** Delays between attempts to replay queued writes or re-sync. */
retry?: Partial<{ baseDelay: number; maxDelay: number }>
}
/** Implemented by sources that can push remote changes (e.g. `KVStorageHttp`). */
interface WatchableSource {
watch(): Promise<void>
unwatch(): Promise<void>
lastEventId: string | undefined
}
type OutboxEntry = { type: 'write'; entries: [string, unknown][] } | { type: 'remove'; keys: string[] }
interface SyncMeta {
lastEventId?: string
}
/** Internal keys in the persistent replica; hidden from reads. */
const INTERNAL_PREFIX = '~q/'
const INTERNAL_END = '~q0'
const META_KEY = '~q/meta'
const OUTBOX_PREFIX = '~q/o/'
const OUTBOX_END = '~q/o0'
const SYNC_BATCH = 500
/**
* Keeps local replicas of a source in sync, so reads (including `keys()`, `entries()` and document
* queries) are served locally, and the app keeps working offline.
*
* - **Start** (lazily, on first use): the persistent replica seeds the faster ones. If it holds a
* previous sync, reads are served from it straight away (possibly stale until caught up).
* - **Staying in sync:** remote changes pushed by the source (`watch()`, e.g. SSE) are applied to
* every replica. On restart the source resumes from the saved `lastEventId`; a full sync (diffing
* the source against the replicas) only runs the first time, or when the source says it can't
* resume (`resync`). Without `watch()`, a full sync runs on start.
* - **Writes** go to the source, then the replicas. If the source is unreachable
* (`StorageUnavailableError`), they are applied locally and queued in a persisted outbox, then
* replayed in order when it's reachable again (last writer wins on conflict). While any write is
* queued, later writes queue too, to keep their order. `writeIfAbsent` needs the source and
* fails offline.
*
* Write through the composite, not the source: changes made directly through the same source
* instance (`origin: 'local'`) are not mirrored.
*/
export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly source: BaseKVStorage
private readonly replicas: BaseKVStorage[]
/** Persistent replica: seeds the others and holds the outbox and metadata. */
private readonly persistent: BaseKVStorage
private readonly backoff: { baseDelay: number; maxDelay: number }
/** Queued-write count per key; remote changes to these keys are ignored until replayed. */
private readonly pending = new Map<string, number>()
/**
* Echoes still expected per key: one per write this client sent to a watchable source. A remote
* change to such a key is the echo or older than it (the stream is in server order), so it is
* consumed instead of applied; this avoids briefly showing a stale value.
*/
private readonly echoes = new Map<string, number>()
private readonly offs: OffCallback[] = []
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state: {
started?: Promise<void>
/** Replicas hold a complete (possibly stale) copy, so reads can be served locally. */
hasSnapshot: boolean
snapshotReady: Promise<void>
markSnapshot: () => void
/**
* Remote changes and full syncs are applied strictly in order through this chain. Changes that
* arrive during a full sync wait behind it, so the end state matches the source even if it
* changed during the scan.
*/
applying: Promise<void>
replaying?: Promise<void>
outboxSeq: number
retryTimer?: ReturnType<typeof setTimeout>
retryAttempt: number
closed: boolean
}
constructor(options: KVStorageCompositeOptions) {
super()
if (options.replicas.length === 0) throw new TypeError('KVStorageComposite needs at least one replica')
this.source = options.source
this.replicas = options.replicas
this.persistent = options.replicas.at(-1)!
this.backoff = { baseDelay: 1000, maxDelay: 30_000, ...options.retry }
let markSnapshot!: () => void
const snapshotReady = new Promise<void>((resolve) => (markSnapshot = resolve))
this.state = { hasSnapshot: false, snapshotReady, markSnapshot, applying: Promise.resolve(), outboxSeq: 0, retryAttempt: 0, closed: false }
}
/** Starts syncing (also happens on first use). Resolves once local data is loaded. */
start(): Promise<void> {
return (this.state.started ??= this.open())
}
/** Resolves once the replicas hold a complete copy and all received remote changes are applied. */
async synced(): Promise<void> {
await this.start()
await this.state.snapshotReady
await this.state.applying
}
/** Number of writes waiting to be replayed to the source. */
get queued(): number {
let total = 0
for (const count of this.pending.values()) total += count
return total
}
/** Stops watching the source and cancels retries. */
async close(): Promise<void> {
this.state.closed = true
clearTimeout(this.state.retryTimer)
for (const off of this.offs.splice(0)) off()
if (this.watchable) await (this.source as unknown as WatchableSource).unwatch()
}
async readMany(keys: string[]): Promise<unknown[]> {
await this.start()
return (this.state.hasSnapshot ? this.replicas[0]! : this.source).readMany(keys)
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
await this.start()
if (!this.state.hasSnapshot) return yield* this.source.scan(options)
// Skip internal keys before applying the limit.
let remaining = options.limit ?? Infinity
if (remaining <= 0) return
for await (const entry of this.replicas[0]!.scan({ ...options, limit: undefined })) {
if (entry[0] >= INTERNAL_PREFIX && entry[0] < INTERNAL_END) continue
yield entry
if (--remaining <= 0) return
}
}
protected async applyWrite(entries: [string, unknown][]): Promise<void> {
await this.start()
await this.writeThrough({ type: 'write', entries })
}
protected async applyRemove(keys: string[]): Promise<void> {
await this.start()
await this.writeThrough({ type: 'remove', keys })
}
protected async applyWriteIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
await this.start()
const written = await this.source.writeIfAbsent(entries)
await this.applyToReplicas(entries.filter((_, i) => written[i]).map(([key, value]) => ({ key, value })))
return written
}
// ------------------------------------------------------------------ start
private get watchable(): boolean {
return typeof (this.source as unknown as Partial<WatchableSource>).watch === 'function'
}
private async open(): Promise<void> {
const [meta] = (await this.persistent.readMany([META_KEY])) as (SyncMeta | undefined)[]
for await (const [key, value] of this.persistent.scan({ gte: OUTBOX_PREFIX, lt: OUTBOX_END, values: true })) {
this.state.outboxSeq = Number(key.slice(OUTBOX_PREFIX.length))
for (const k of KVStorageComposite.keysOf(value as OutboxEntry)) this.pending.set(k, (this.pending.get(k) ?? 0) + 1)
}
await this.seedFasterReplicas()
if (meta) this.snapshotTaken()
this.offs.push(
this.source.on('change', (event) => {
if (event.origin === 'remote') this.enqueueApply(() => this.applyRemote(event))
if (this.queued > 0) void this.replay()
}),
this.source.on('resync', () => this.enqueueApply(() => this.fullSync())),
)
if (this.watchable) {
const source = this.source as unknown as WatchableSource
source.lastEventId = meta?.lastEventId
// Without a resume point, catch up on the stream first so no change after the snapshot is missed.
const watching = source.watch()
if (!meta?.lastEventId) void watching.then(() => this.enqueueApply(() => this.fullSync()))
} else {
this.enqueueApply(() => this.fullSync())
}
if (this.queued > 0) void this.replay()
}
/** Copies the persistent replica into the faster ones. */
private async seedFasterReplicas(): Promise<void> {
const faster = this.replicas.slice(0, -1)
if (faster.length === 0) return
let batch: [string, unknown][] = []
const flush = async () => {
await Promise.all(faster.map((replica) => replica.writeMany(batch)))
batch = []
}
for await (const entry of this.persistent.scan({ values: true })) {
if (!entry[0].startsWith(INTERNAL_PREFIX) && batch.push(entry) >= SYNC_BATCH) await flush()
}
await flush()
}
private snapshotTaken(): void {
this.state.hasSnapshot = true
this.state.markSnapshot()
}
// ------------------------------------------------------------------ remote → replicas
private enqueueApply(task: () => Promise<void>): void {
this.state.applying = this.state.applying.then(task).catch((error) => {
if (!(error instanceof StorageUnavailableError)) console.error(error)
})
}
private async applyRemote(event: KVChangeEvent): Promise<void> {
const candidates = event.changes.filter(({ key }) => !this.consumeEcho(key) && !this.pending.has(key) && !key.startsWith(INTERNAL_PREFIX))
// Skip changes the replicas already reflect.
const current = await this.replicas[0]!.readMany(candidates.map(({ key }) => key))
const changes = candidates.filter(({ value }, i) => !Json.equal(value, current[i]))
await this.applyToReplicas(changes)
if (event.id) await this.persistent.writeMany([[META_KEY, { lastEventId: event.id } satisfies SyncMeta]])
if (changes.length) this.emit('change', { changes, origin: 'remote', id: event.id })
}
/** Diffs the source against the persistent replica and applies the differences to every replica. */
private async fullSync(): Promise<void> {
// The diff re-reads the source, so outstanding echoes no longer matter (and may never arrive).
this.echoes.clear()
try {
const id = this.watchable ? (this.source as unknown as WatchableSource).lastEventId : undefined
let changes: KVChange[] = []
const flush = async () => {
const applied = changes.filter(({ key }) => !this.pending.has(key))
await this.applyToReplicas(applied)
if (applied.length) this.emit('change', { changes: applied, origin: 'remote' })
changes = []
}
for await (const change of this.diff()) if (changes.push(change) >= SYNC_BATCH) await flush()
await flush()
await this.persistent.writeMany([[META_KEY, { lastEventId: id } satisfies SyncMeta]])
this.snapshotTaken()
this.state.retryAttempt = 0
} catch (error) {
if (!(error instanceof StorageUnavailableError)) throw error
this.scheduleRetry(() => this.enqueueApply(() => this.fullSync()))
}
}
/** Changes that turn the persistent replica into the source (both scanned in key order). */
private async *diff(): AsyncGenerator<KVChange> {
const remote = this.source.scan({ values: true })[Symbol.asyncIterator]()
const local = this.persistent.scan({ values: true })[Symbol.asyncIterator]()
const nextLocal = async () => {
for (let next = await local.next(); ; next = await local.next()) if (next.done || !next.value[0].startsWith(INTERNAL_PREFIX)) return next
}
let r = await remote.next()
let l = await nextLocal()
while (!r.done || !l.done) {
if (l.done || (!r.done && r.value[0] < l.value[0])) {
yield { key: r.value![0], value: r.value![1] }
r = await remote.next()
} else if (r.done || l.value[0] < r.value[0]) {
yield { key: l.value[0] }
l = await nextLocal()
} else {
if (!Json.equal(r.value[1], l.value[1])) yield { key: r.value[0], value: r.value[1] }
r = await remote.next()
l = await nextLocal()
}
}
}
private async applyToReplicas(changes: KVChange[]): Promise<void> {
const writes = changes.filter((c) => c.value !== undefined).map((c): [string, unknown] => [c.key, c.value])
const removes = changes.filter((c) => c.value === undefined).map((c) => c.key)
await Promise.all(this.replicas.map(async (replica) => (await replica.writeMany(writes), await replica.removeMany(removes))))
}
// ------------------------------------------------------------------ local writes → source
private async writeThrough(entry: OutboxEntry): Promise<void> {
if (this.queued === 0) {
try {
await this.send(entry)
return this.applyToReplicas(KVStorageComposite.changesOf(entry))
} catch (error) {
if (!(error instanceof StorageUnavailableError)) throw error
}
}
// Offline, or earlier writes are still queued: apply locally and queue for the source.
const seq = ++this.state.outboxSeq
for (const key of KVStorageComposite.keysOf(entry)) this.pending.set(key, (this.pending.get(key) ?? 0) + 1)
await this.persistent.writeMany([[OUTBOX_PREFIX + String(seq).padStart(12, '0'), entry]])
await this.applyToReplicas(KVStorageComposite.changesOf(entry))
void this.replay()
}
/** Sends queued writes to the source in order; stops (and retries later) if it's unreachable. */
private replay(): Promise<void> {
const run = async () => {
try {
for (;;) {
let next: [string, unknown] | undefined
for await (const entry of this.persistent.scan({ gte: OUTBOX_PREFIX, lt: OUTBOX_END, limit: 1, values: true })) next = entry
if (!next) return
const [key, entry] = next as [string, OutboxEntry]
await this.send(entry)
await this.persistent.removeMany([key])
for (const k of KVStorageComposite.keysOf(entry)) {
const count = this.pending.get(k)! - 1
if (count > 0) this.pending.set(k, count)
else this.pending.delete(k)
}
this.state.retryAttempt = 0
}
} catch (error) {
if (!(error instanceof StorageUnavailableError)) console.error(error)
this.scheduleRetry(() => void this.replay())
} finally {
this.state.replaying = undefined
}
}
return (this.state.replaying ??= run())
}
/** Sends a write to the source, expecting its echo when the source is watched. */
private async send(entry: OutboxEntry): Promise<void> {
const keys = this.watchable ? KVStorageComposite.keysOf(entry) : []
for (const key of keys) this.echoes.set(key, (this.echoes.get(key) ?? 0) + 1)
try {
await (entry.type === 'write' ? this.source.writeMany(entry.entries) : this.source.removeMany(entry.keys))
} catch (error) {
for (const key of keys) this.consumeEcho(key)
throw error
}
}
/** Whether `key` had an outstanding echo (now consumed). */
private consumeEcho(key: string): boolean {
const count = this.echoes.get(key)
if (!count) return false
if (count > 1) this.echoes.set(key, count - 1)
else this.echoes.delete(key)
return true
}
private scheduleRetry(task: () => void): void {
if (this.state.closed) return
clearTimeout(this.state.retryTimer)
const delay = ExponentialBackoff.calculateDelay({ ...this.backoff, growthRate: 2, jitter: 0.2, maxAttempts: 0 }, this.state.retryAttempt++)
this.state.retryTimer = setTimeout(task, delay)
;(this.state.retryTimer as { unref?: () => void }).unref?.()
}
private static keysOf(entry: OutboxEntry): string[] {
return entry.type === 'write' ? entry.entries.map(([key]) => key) : entry.keys
}
private static changesOf(entry: OutboxEntry): KVChange[] {
return entry.type === 'write' ? entry.entries.map(([key, value]) => ({ key, value })) : entry.keys.map((key) => ({ key }))
}
}
+107 -16
View File
@@ -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 })
}
+91 -25
View File
@@ -1,24 +1,48 @@
import { StorageUnavailableError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.js'
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseKVStorage, type ScanOptions } from './base-kv-storage.js'
import { SSESession } from '../utils/sse-session/sse-session.js'
import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpOptions {
/** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */
url: string
headers?: Record<string, string>
fetch?: typeof fetch
fetch?: (url: string, init: RequestInit) => Promise<Response>
/** Called when the change stream fails (it keeps reconnecting). Default: ignore. */
onWatchError?: (error: Error) => void
}
/** KV storage client for a `KVStorageHttpServer`. Same API as any other KV storage. */
/**
* KV storage client for a `KVStorageHttpServer`. Same API as any other KV storage.
*
* `watch()` subscribes to the server's change stream (SSE): changes made by anyone are emitted
* here as `change` events with `origin: 'remote'`, and `resync` is emitted when the server can't
* resume from `lastEventId`. Values travel as Extended JSON (`bigint` and `Uint8Array` survive).
*/
export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly url: string
private readonly headers: Record<string, string>
private readonly fetcher: typeof fetch
private readonly fetcher: (url: string, init: RequestInit) => Promise<Response>
private readonly onWatchError: (error: Error) => void
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state: { session?: SSESession; lastEventId?: string } = {}
constructor(options: KVStorageHttpOptions) {
super()
this.url = options.url.replace(/\/$/, '')
this.headers = options.headers ?? {}
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis)
this.fetcher = options.fetch ?? ((url, init) => globalThis.fetch(url, init))
this.onWatchError = options.onWatchError ?? (() => {})
}
/** Position in the server's change stream; sent as `Last-Event-ID` when (re)connecting. */
get lastEventId(): string | undefined {
return this.state.lastEventId
}
set lastEventId(id: string | undefined) {
this.state.lastEventId = id
}
async readMany(keys: string[]): Promise<unknown[]> {
@@ -28,18 +52,6 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
return values
}
async writeMany(entries: [string, unknown][]): Promise<void> {
if (entries.length) await this.post('writeMany', { entries })
}
async removeMany(keys: string[]): Promise<void> {
if (keys.length) await this.post('removeMany', { keys })
}
async writeIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return entries.length ? ((await this.post('writeIfAbsent', { entries })) as boolean[]) : []
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
if (options.limit !== undefined && options.limit <= 0) return
const response = await this.request('scan', options)
@@ -53,8 +65,8 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
const lines = buffer.split('\n')
buffer = lines.pop()!
for (const line of lines) {
const [key, value] = JSON.parse(line) as [string, unknown?]
yield [key, value]
const [key, entryValue] = ExtendedJson.parse(line) as [string, unknown?]
yield [key, entryValue]
}
}
} finally {
@@ -62,17 +74,71 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
}
}
/**
* Subscribes to the server's change stream. Resolves once connected and caught up (missed
* changes replayed, or `resync` emitted); keeps reconnecting and resuming from `lastEventId`
* until `unwatch()`. While the server is unreachable this stays pending, so don't await it on a
* path that must work offline.
*/
async watch(): Promise<void> {
if (this.state.session) return
const session = new SSESession(`${this.url}/events`, {
fetch: this.fetcher,
headers: this.headers,
persistent: true,
onError: this.onWatchError,
onRequest: async (request) =>
this.lastEventId ? { ...request, headers: { ...(request.headers as Record<string, string>), 'Last-Event-ID': this.lastEventId } } : request,
})
session.on('message', (message) => {
if (message.id) this.lastEventId = message.id
if (message.event === 'change') {
this.emit('change', { changes: ExtendedJson.parse(message.data) as KVChange[], origin: 'remote', id: message.id })
} else if (message.event === 'resync') {
this.emit('resync', { id: message.id })
}
})
this.state.session = session
const caughtUp = session.waitFor('message', (message) => message.event === 'ready')
await session.connect()
await caughtUp
}
async unwatch(): Promise<void> {
const session = this.state.session
this.state.session = undefined
await session?.disconnect()
}
protected async applyWrite(entries: [string, unknown][]): Promise<void> {
await this.post('writeMany', { entries })
}
protected async applyRemove(keys: string[]): Promise<void> {
await this.post('removeMany', { keys })
}
protected async applyWriteIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return (await this.post('writeIfAbsent', { entries })) as boolean[]
}
private async post(path: string, body: unknown): Promise<unknown> {
const response = await this.request(path, body)
return response.status === 204 ? undefined : response.json()
return response.status === 204 ? undefined : ExtendedJson.parse(await response.text())
}
private async request(path: string, body: unknown): Promise<Response> {
const response = await this.fetcher(`${this.url}/${path}`, {
method: 'POST',
headers: { ...this.headers, 'content-type': 'application/json' },
body: JSON.stringify(body),
})
let response: Response
try {
response = await this.fetcher(`${this.url}/${path}`, {
method: 'POST',
headers: { ...this.headers, 'content-type': 'application/json' },
body: ExtendedJson.stringify(body),
})
} catch (error) {
throw new StorageUnavailableError(`Cannot reach ${this.url}`, { cause: error })
}
if ([502, 503, 504].includes(response.status)) throw new StorageUnavailableError(`HTTP ${response.status} from ${this.url}`)
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`)
return response
}
+3 -3
View File
@@ -11,15 +11,15 @@ export class KVStorageInMemory<S extends Spec = AnySpec> extends BaseKVStorage<S
return keys.map((k) => Json.clone(this.data.get(k)))
}
async writeMany(entries: [string, unknown][]): Promise<void> {
protected async applyWrite(entries: [string, unknown][]): Promise<void> {
for (const [k, v] of entries) this.data.set(k, Json.clone(v))
}
async removeMany(keys: string[]): Promise<void> {
protected async applyRemove(keys: string[]): Promise<void> {
for (const k of keys) this.data.delete(k)
}
async writeIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
protected async applyWriteIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return entries.map(([k, v]) => !this.data.has(k) && (this.data.set(k, Json.clone(v)), true))
}
+129
View File
@@ -0,0 +1,129 @@
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseKVStorage, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageIndexedDBOptions {
/** Database name. */
name: string
/** Object store name. Default `kv`. */
storeName?: string
/** IndexedDB factory. Default `globalThis.indexedDB`. */
indexedDB?: IDBFactory
/** Entries read per transaction while scanning. Default 256. */
scanBatchSize?: number
}
/**
* KV storage in the browser's IndexedDB (one object store, string keys). Values are stored with the
* structured clone algorithm, so `bigint` and `Uint8Array` survive. IndexedDB orders string keys by
* UTF-16 code unit, like JavaScript string comparison.
*/
export class KVStorageIndexedDB<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly storeName: string
private readonly scanBatchSize: number
private readonly db: Promise<IDBDatabase>
constructor(options: KVStorageIndexedDBOptions) {
super()
this.storeName = options.storeName ?? 'kv'
this.scanBatchSize = options.scanBatchSize ?? 256
const factory = options.indexedDB ?? globalThis.indexedDB
const open = factory.open(options.name, 1)
open.onupgradeneeded = () => open.result.createObjectStore(this.storeName)
this.db = KVStorageIndexedDB.done(open)
}
async readMany(keys: string[]): Promise<unknown[]> {
const store = await this.store('readonly')
return Promise.all(keys.map((key) => KVStorageIndexedDB.done(store.get(key))))
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
let remaining = options.limit ?? Infinity
let last: string | undefined
while (remaining > 0) {
const batch = await this.readBatch(options, last, Math.min(remaining, this.scanBatchSize))
for (const entry of batch) {
yield entry
remaining--
}
if (batch.length < this.scanBatchSize) return
last = batch.at(-1)![0]
}
}
/** Closes the database connection. */
async close(): Promise<void> {
;(await this.db).close()
}
protected async applyWrite(entries: [string, unknown][]): Promise<void> {
const store = await this.store('readwrite')
for (const [key, value] of entries) store.put(value, key)
await KVStorageIndexedDB.committed(store.transaction)
}
protected async applyRemove(keys: string[]): Promise<void> {
const store = await this.store('readwrite')
for (const key of keys) store.delete(key)
await KVStorageIndexedDB.committed(store.transaction)
}
protected async applyWriteIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
// One readwrite transaction: checks and writes can't interleave with other writers.
const store = await this.store('readwrite')
const written = await Promise.all(
entries.map(async ([key, value]) => {
if ((await KVStorageIndexedDB.done(store.getKey(key))) !== undefined) return false
store.add(value, key)
return true
}),
)
await KVStorageIndexedDB.committed(store.transaction)
return written
}
/** Up to `count` entries after `last` (exclusive), in scan order, within one transaction. */
private async readBatch(options: ScanOptions, last: string | undefined, count: number): Promise<[string, unknown][]> {
const reverse = options.reverse ?? false
// The upper bound is always exclusive (`lt`, or the last key seen in reverse); the lower bound
// is exclusive only when resuming forwards after `last`.
const lower = reverse ? options.gte : (last ?? options.gte)
const upper = reverse ? (last ?? options.lt) : options.lt
const lowerOpen = !reverse && last !== undefined
if (lower !== undefined && upper !== undefined && lower >= upper) return []
let range: IDBKeyRange | undefined
if (lower !== undefined && upper !== undefined) range = IDBKeyRange.bound(lower, upper, lowerOpen, true)
else if (lower !== undefined) range = IDBKeyRange.lowerBound(lower, lowerOpen)
else if (upper !== undefined) range = IDBKeyRange.upperBound(upper, true)
const store = await this.store('readonly')
const out: [string, unknown][] = []
const request = options.values ? store.openCursor(range, reverse ? 'prev' : 'next') : store.openKeyCursor(range, reverse ? 'prev' : 'next')
return new Promise((resolve, reject) => {
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const cursor = request.result
if (!cursor || out.length >= count) return resolve(out)
out.push([cursor.key as string, options.values ? (cursor as IDBCursorWithValue).value : undefined])
cursor.continue()
}
})
}
private async store(mode: IDBTransactionMode): Promise<IDBObjectStore> {
return (await this.db).transaction(this.storeName, mode).objectStore(this.storeName)
}
private static done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
private static committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = transaction.onabort = () => reject(transaction.error)
})
}
}
+8
View File
@@ -17,3 +17,11 @@ export class InvalidKeyError extends TypeError {
this.name = 'InvalidKeyError'
}
}
/** A remote storage could not be reached (network failure or 502/503/504). Writes may be retried later. */
export class StorageUnavailableError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'StorageUnavailableError'
}
}
+134
View File
@@ -0,0 +1,134 @@
// Adapted from xo-cash-utils `source/event-emitter.ts` (branch installable-sse): payloads are passed
// through as-is (no cloning, freezing or DeeplyReadonly), and timeouts reject with a plain Error.
export type EventMap = Record<string, unknown>
type Listener<T> = (detail: T) => void
/** Lets listeners for individual payloads live in one collection typed with the union of payloads. */
type StoredListener<T> = {
bivarianceHack(detail: T): void
}['bivarianceHack']
interface ListenerEntry<T> {
listener: StoredListener<T>
wrappedListener: StoredListener<T>
cancel: () => void
}
/** Returned by {@link EventEmitter.on} and {@link EventEmitter.once} to remove the listener. */
export type OffCallback = () => void
/** A small typed event emitter. */
export class EventEmitter<T extends EventMap> {
private readonly listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map()
/**
* Adds a listener.
* @param debounceMilliseconds - When > 0, only the last event within this window is delivered.
*/
on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const { cancel, listener: cancellableListener } = this.cancellable(listener)
const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener
this.entriesFor(type).add({ listener, wrappedListener, cancel } as ListenerEntry<T[keyof T]>)
return () => this.off(type, listener)
}
/** Adds a listener that is removed after its first call. */
once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const wrappedListener: Listener<T[K]> = (detail) => {
this.off(type, listener)
listener(detail)
}
const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener)
const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener
this.entriesFor(type).add({ listener, wrappedListener: debouncedListener, cancel } as ListenerEntry<T[keyof T]>)
return () => this.off(type, listener)
}
/** Removes one listener, or all listeners for `type` when none is given. */
off<K extends keyof T>(type: K, listener?: Listener<T[K]>): void {
const listeners = this.listeners.get(type)
if (!listeners) return
for (const entry of Array.from(listeners)) {
if (listener && entry.listener !== listener && entry.wrappedListener !== listener) continue
// Cancel so a pending debounced call doesn't fire after removal.
entry.cancel()
listeners.delete(entry)
}
if (!listener || listeners.size === 0) this.listeners.delete(type)
}
/**
* Calls every listener for `type` with `payload`. Listener errors are logged, not thrown.
* @returns Whether any listener was registered.
*/
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
const listeners = this.listeners.get(type)
if (!listeners) return false
for (const entry of Array.from(listeners)) {
try {
entry.wrappedListener(payload)
} catch (error) {
console.error(error)
}
}
return listeners.size > 0
}
removeAllListeners(): void {
for (const type of Array.from(this.listeners.keys())) this.off(type)
}
/** Resolves with the first payload of `type` matching `predicate`; rejects after `timeoutMs` if given. */
waitFor<K extends keyof T>(type: K, predicate: (payload: T[K]) => boolean, timeoutMs?: number): Promise<T[K]> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = (): void => {
this.off(type, listener)
if (timeoutId !== undefined) clearTimeout(timeoutId)
}
const listener = (payload: T[K]): void => {
try {
if (!predicate(payload)) return
cleanup()
resolve(payload)
} catch (error) {
cleanup()
reject(error)
}
}
if (timeoutMs !== undefined) {
timeoutId = setTimeout(() => {
this.off(type, listener)
reject(new Error(`Timed out waiting for "${String(type)}"`))
}, timeoutMs)
}
this.on(type, listener)
})
}
private entriesFor<K extends keyof T>(type: K): Set<ListenerEntry<T[keyof T]>> {
let entries = this.listeners.get(type)
if (!entries) this.listeners.set(type, (entries = new Set()))
return entries
}
private debounce<K extends keyof T>(func: Listener<T[K]>, wait: number): Listener<T[K]> {
let timeout: ReturnType<typeof setTimeout> | undefined
return (detail) => {
if (timeout !== undefined) clearTimeout(timeout)
timeout = setTimeout(() => func(detail), wait)
}
}
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
let cancelled = false
return {
cancel: () => void (cancelled = true),
listener: (detail) => {
if (!cancelled) func(detail)
},
}
}
}
+51
View File
@@ -0,0 +1,51 @@
// Adapted from xo-cash-utils `source/extended-json.ts` (branch installable-sse), with local hex
// encoding instead of @bitauth/libauth.
/** Matches a bigint encoded as `<bigint: 123n>`. */
const BIGINT_PATTERN = /^<bigint: (?<bigint>[+-]?[0-9]+)n>$/u
/** Matches a Uint8Array encoded as `<uint8array: abcd>`. */
const UINT8ARRAY_PATTERN = /^<uint8array: (?<hex>[0-9a-f]*)>$/u
/**
* JSON that also round-trips `bigint` (`<bigint: 123n>`) and `Uint8Array` (`<uint8array: abcd>`).
* Used for every value that crosses the network.
*/
export class ExtendedJson {
static stringify(value: unknown): string {
return JSON.stringify(value, ExtendedJson.replacer)
}
static parse(text: string): unknown {
return JSON.parse(text, ExtendedJson.reviver)
}
/** `JSON.stringify` replacer. */
static replacer(this: void, _key: string, value: unknown): unknown {
if (value instanceof Uint8Array) return `<uint8array: ${ExtendedJson.toHex(value)}>`
if (typeof value === 'bigint') return `<bigint: ${value.toString()}n>`
return value
}
/** `JSON.parse` reviver. */
static reviver(this: void, _key: string, value: unknown): unknown {
if (typeof value !== 'string') return value
const bigint = value.match(BIGINT_PATTERN)
if (bigint) return BigInt(bigint.groups!.bigint!)
const bytes = value.match(UINT8ARRAY_PATTERN)
if (bytes) return ExtendedJson.fromHex(bytes.groups!.hex!)
return value
}
private static toHex(bytes: Uint8Array): string {
let hex = ''
for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
return hex
}
private static fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
return bytes
}
}
+9 -3
View File
@@ -1,8 +1,11 @@
/** Helpers for JSON-compatible values (all stored values are JSON by contract). */
/**
* Helpers for stored values: JSON plus `bigint` and `Uint8Array` (see `ExtendedJson`).
*/
export class Json {
/** Deep copy; much cheaper than `structuredClone` for plain JSON. */
/** Deep copy; much cheaper than `structuredClone` for plain data. */
static clone<T>(value: T): T {
if (value === null || typeof value !== 'object') return value
if (value instanceof Uint8Array) return value.slice() as T
if (Array.isArray(value)) return value.map((v) => Json.clone(v)) as T
const out: Record<string, unknown> = {}
for (const k in value) out[k] = Json.clone((value as Record<string, unknown>)[k])
@@ -12,6 +15,9 @@ export class Json {
static equal(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (a instanceof Uint8Array || b instanceof Uint8Array) {
return a instanceof Uint8Array && b instanceof Uint8Array && a.length === b.length && a.every((byte, i) => byte === b[i])
}
if (Array.isArray(a) !== Array.isArray(b)) return false
const ka = Object.keys(a)
const kb = Object.keys(b)
@@ -19,6 +25,6 @@ export class Json {
}
static isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array)
}
}
@@ -0,0 +1,85 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
/**
* An async iterable queue that bridges push-based producers and pull-based consumers.
*
* Composes an internal {@link ReadableStream} instead of extending it, so producers
* call {@link push} while consumers use standard async iteration (`for await...of`).
*
* ```ts
* const messages = new AsyncPushIterator<SSEvent>();
*
* // Producer (elsewhere)
* messages.push(event);
*
* // Consumer
* for await (const event of messages) {
* handle(event);
* }
* ```
*
* {@link Symbol.asyncIterator} returns `stream.values({ preventCancel: true })` so
* breaking out of `for await...of` does not cancel the underlying stream. That
* matters for long-lived sessions where the producer keeps pushing after a consumer
* stops reading early (for example, test helpers that only collect a fixed count).
*/
export class AsyncPushIterator<T> {
/** ReadableStream backing the async iterator returned from {@link Symbol.asyncIterator}. */
private readonly stream: ReadableStream<T>
/** Controller used to enqueue values and close the stream from {@link push} and {@link close}. */
private controller: ReadableStreamDefaultController<T> | undefined
/** When true, no more values are accepted and iteration eventually completes. */
public closed = false
public constructor() {
// `start`'s `this` is the underlying source object when using a plain method.
// An arrow function captures the class instance so the controller is stored here.
this.stream = new ReadableStream({
start: (controller: ReadableStreamDefaultController<T>): void => {
this.controller = controller
},
})
}
/**
* Enqueues a value for the consumer.
*
* After {@link close}, pushes are silently dropped.
*
* @param value - The next value to yield from the iterator.
*/
push(value: T): void {
if (this.closed) return
this.controller?.enqueue(value)
}
/**
* Ends the stream.
*
* Marks the iterator closed so future {@link push} calls are ignored.
* Buffered values are still yielded before iteration completes.
*/
close(): void {
this.closed = true
try {
this.controller?.close()
} catch {
// The reader may already have released or cancelled the stream.
}
}
/**
* Returns an async iterator over the composed stream.
*
* Uses `preventCancel: true` so early `break` from `for await...of` does not
* close the stream and block later pushes.
*/
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this.stream.values({ preventCancel: true })
}
}
@@ -0,0 +1,251 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
/**
* Exponential backoff is a technique used to retry a function after a delay.
*
* The delay increases exponentially with each attempt, up to a maximum delay.
*
* The jitter is a random amount of time added to the delay to prevent thundering herd problems.
*
* The growth rate is the factor by which the delay increases with each attempt.
*/
export class ExponentialBackoff {
/**
* Create a new ExponentialBackoff instance
*
* @param config - The configuration for the exponential backoff
* @returns The ExponentialBackoff instance
*/
static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config)
return backoff
}
/**
* Run the function with exponential backoff
*
* @param fn - The function to run
* @param onError - The callback to call when an error occurs
* @param options - The configuration for the exponential backoff
*
* @throws An AggregateError with all the errors that were thrown by the task function
*
* @returns The result of the function
*/
static run<T>(
fn: (context: ExpontialBackoffCallbackContext) => Promise<T>,
onError = (_error: Error, _context: ExpontialBackoffCallbackContext): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options)
return backoff.run(fn, onError)
}
private readonly options: ExponentialBackoffOptions
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Random proportional variation applied to each delay. Default: `0.1`.
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.options = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
}
ExponentialBackoff.validateOptions(this.options)
}
/**
* Run the function with exponential backoff
*
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
* and the function will be retried with an exponential delay
*
* If the function fails and we have hit the max attempts, an AggregateError will be thrown with all the errors that were thrown by the task function
*
* @param fn - The function to run
* @param onError - The callback to call when an error occurs
*
* @throws An AggregateError with all the errors that were thrown by the task function
*
* @returns The result of the function
*/
async run<T>(
fn: (context: ExpontialBackoffCallbackContext) => Promise<T>,
onError = (_error: Error, _context: ExpontialBackoffCallbackContext): void => {},
): Promise<T> {
const abortController = new AbortController()
const context: ExpontialBackoffCallbackContext = {
errors: [],
attempt: 1,
options: { ...this.options },
nextDelay: 0,
signal: abortController.signal,
abort: (reason?: unknown) => abortController.abort(reason),
}
while (context.options.maxAttempts === 0 || context.attempt <= context.options.maxAttempts) {
context.nextDelay = ExponentialBackoff.calculateDelay(context.options, context.attempt - 1)
try {
// Await the promise before returning so its execution context remains in the try-catch
// If we didn't await, this `run` function would successfully return and any errors would not be caught here.
return await fn(context)
} catch (error) {
// Store the error in case we fail every attempt
const errorInstance = error instanceof Error ? error : new Error(`${error}`)
context.errors.push(errorInstance)
await onError(errorInstance, context)
}
// If the abort controller is aborted, throw an error
if (abortController.signal.aborted) {
const reasonError = abortController.signal.reason instanceof Error ? abortController.signal.reason : new Error(`${abortController.signal.reason}`)
throw new Error('Exponential backoff aborted', { cause: reasonError })
}
// If we have hit the max attempts, break out of the loop
const unlimitedAttempts = context.options.maxAttempts === 0
if (!unlimitedAttempts && context.attempt >= context.options.maxAttempts) {
break
}
// Wait before going to the next attempt
await this.wait(context.nextDelay, abortController.signal)
context.attempt++
}
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new AggregateError(context.errors, 'Exponential backoff max retries hit')
}
private wait(delay: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
// Set a timeout to resolve the promise
const timeout = setTimeout(() => {
resolve()
}, delay)
// If the signal is aborted, reject the promise
signal.addEventListener('abort', () => {
clearTimeout(timeout)
reject(new Error('Exponential backoff aborted'))
})
})
}
/**
* Calculate the delay before we should attempt to retry
*
* NOTE: The maximum delay is (maxDelay * (1 + jitter))
*
* @param attempt
* @returns The time in milliseconds before another attempt should be made
*/
public static calculateDelay(options: ExponentialBackoffOptions, attempt: number): number {
// Get the power of the growth rate
const power = options.growthRate ** attempt
// Get the delay before jitter or limit
const rawDelay = options.baseDelay * power
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, options.maxDelay)
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random()
// Calculate the jitter
const jitter = jitterAmount * options.jitter * cappedDelay
// Subtract the jitter from the delay
return cappedDelay - jitter
}
/**
* Validate the options, ensure that no values are NaN
* @param options
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
const keys = Object.keys(options) as (keyof ExponentialBackoffOptions)[]
for (const key of keys) {
if (!isFinite(options[key])) {
throw new Error(`Invalid option: ${key} is not finite`)
}
if (key === 'jitter' && (options[key] < 0 || options[key] > 1)) {
throw new Error(`Invalid option: jitter is not between 0 and 1`)
}
if (options[key] < 0) {
throw new Error(`Invalid option: ${key} is less than 0`)
}
}
}
}
export type ExponentialBackoffOptions = {
/**
* The maximum delay between attempts in milliseconds
*/
maxDelay: number
/**
* The maximum number of attempts. Passing 0 will result in infinite attempts.
*/
maxAttempts: number
/**
* The base delay between attempts in milliseconds
*/
baseDelay: number
/**
* The growth rate of the delay
*/
growthRate: number
/**
* The jitter of the delay as a percentage of growthRate
*/
jitter: number
}
export type ExpontialBackoffCallbackContext = {
/** The errors that have occurred */
errors: Error[]
/** The current attempt */
attempt: number
/** The options for the exponential backoff */
options: ExponentialBackoffOptions
/** The next delay before the next attempt */
nextDelay: number
/** The abort signal */
signal: AbortSignal
/** The abort function */
abort: (reason?: unknown) => void
}
+5
View File
@@ -0,0 +1,5 @@
export * from './types.js'
export * from './sse-session.js'
export * from './sse-event-parser.js'
export * from './async-push-iterator.js'
export * from './exponential-backoff.js'
+214
View File
@@ -0,0 +1,214 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
import type { SSEvent } from './types.js'
/**
* Optional encoders used when decoding incoming SSE bytes and re-encoding
* any buffered remainder between chunks.
*/
export interface SSEEventParserOptions {
/** Decodes raw stream bytes into text. Defaults to a new `TextDecoder`. */
textDecoder: TextDecoder
}
/**
* Regex that splits decoded SSE text into lines.
*
* The SSE wire format is line-oriented (`field: value` per line). Servers may
* send `\r\n` (HTTP default), `\n` (Unix), or `\r` (legacy Mac). Matching all
* three keeps parsing correct regardless of platform or server implementation.
*/
export const SSE_LINE_ENDINGS = /\r\n|\r|\n/
/**
* Regex that matches the single optional leading space in an SSE field value.
*
* Per the SSE spec, `field: value` may include one space immediately after the
* colon; that space is not part of the value. Used with `.replace()` to strip
* it when parsing lines such as `data: hello` → `hello`.
*/
export const SSE_FIELD_VALUE_REGEX = /^ /
/**
* The newline character used when normalizing SSE text internally.
*
* Used to join consecutive `data:` lines into one payload and to reassemble
* buffered partial lines between streamed chunks before the next parse call.
*/
export const NEW_LINE = '\n'
/**
* Incrementally parses Server-Sent Events (SSE) from streamed byte chunks.
*
* SSE payloads are line-oriented: each event is a sequence of `field: value`
* lines terminated by a blank line. This parser accepts arbitrary chunk
* boundaries from a live HTTP response body and emits only complete events.
*
* Typical usage is one parser instance per connection, calling {@link parseEvents}
* for each chunk received from the stream:
*
* ```ts
* const parser = new SSEEventParser();
*
* for await (const chunk of response.body) {
* for (const event of parser.parseEvents(chunk)) {
* // handle event.data, event.event, event.id, event.retry
* }
* }
* ```
*
* Supported fields follow the SSE spec: `data`, `event`, `id`, and `retry`.
* Multiple `data:` lines in one event are joined with `\n`. An event is only
* emitted once a blank line is seen and at least one `data` field was collected.
*/
export class SSEEventParser {
private readonly textDecoder: TextDecoder
/** Bytes from a partial line or incomplete event, carried over to the next chunk. */
private messageBuffer: string = ''
/**
* Creates a parser for one SSE stream.
*
* Inject custom encoders in tests or when a non-default character encoding
* is required; production callers can rely on the defaults.
*
* @param options - Optional text encoders for decode/encode of stream bytes.
*/
constructor(options: Partial<SSEEventParserOptions> = {}) {
this.textDecoder = options.textDecoder ?? new TextDecoder()
}
/**
* Clears any buffered bytes from a partial line or incomplete event.
*
* Call when abandoning a transport so the next connection does not prepend
* stale bytes to incoming chunks.
*/
public reset(): void {
// Clear the message buffer
this.messageBuffer = ''
// Reset the decoder to clear any buffered bytes
this.textDecoder.decode()
}
/**
* Parses all complete SSE events contained in a newly received chunk.
*
* The chunk is appended to any bytes buffered from earlier calls. Complete
* events (blank-line delimited blocks with at least one `data` field) are
* returned immediately; any trailing partial line or in-progress event stays
* in the internal buffer until a later chunk completes it.
*
* @param chunk - Newly received SSE stream bytes.
* @returns Zero or more complete parsed SSE events from this chunk.
*/
public parseEvents(chunk: Uint8Array): SSEvent[] {
const lines = this.getBufferedLines(chunk)
// The final split item is the incomplete remainder after the final line
// ending. It must not be processed as a complete SSE line.
const completeLines = lines.slice(0, -1)
const events: SSEvent[] = []
let event: Partial<SSEvent> = {}
let processedLineCount = 0
for (const [index, line] of completeLines.entries()) {
// A blank line indicates the end of an event. If we have received data, we can complete the event
if (line === '') {
if (event.data !== undefined) {
// Cast event when pushing to an SSE Event so it changes from Partial<SSEvent> to SSEvent
// The "Partial" can safely be removed as we have guaranteed that `data` is defined
events.push(event as SSEvent)
}
event = {}
processedLineCount = index + 1
continue
}
this.parseLine(line, event)
}
this.storeRemainingLines(lines, processedLineCount)
return events
}
/**
* Appends a new chunk to the buffered bytes and splits the combined payload
* into lines.
*
* Accepts `\r\n`, `\r`, and `\n` line endings so events parse correctly
* regardless of server or platform conventions.
*/
private getBufferedLines(chunk: Uint8Array): string[] {
this.messageBuffer += this.textDecoder.decode(chunk, { stream: true })
return this.messageBuffer.split(SSE_LINE_ENDINGS)
}
/**
* Parses one SSE field line into an in-progress event.
*
* Lines starting with a colon are ignored as comments. A single optional space after the colon
* is stripped from the field value, per the SSE spec.
*/
private parseLine(line: string, event: Partial<SSEvent>): void {
// Split the line at the first colon. Before the first colon is the field, after the first colon is the value.
const [field, ...valueArray] = line.split(':')
// Join the rest of the array back together to get the value as it was before the split.
// If the value has a space at the start, remove it (according to the SSE spec)
const value = valueArray.join(':').replace(SSE_FIELD_VALUE_REGEX, '')
switch (field) {
case 'data':
event.data = event.data !== undefined ? `${event.data}${NEW_LINE}${value}` : value
return
case 'event':
event.event = value
return
case 'id':
event.id = value
return
case 'retry':
this.parseRetry(value, event)
return
}
}
/**
* Applies a numeric `retry:` field to an in-progress event.
*
* Non-numeric values are ignored rather than failing the parse.
*/
private parseRetry(value: string, event: Partial<SSEvent>): void {
const retry = parseInt(value, 10)
if (!isNaN(retry)) {
event.retry = retry
}
}
/**
* Preserves incomplete trailing lines for the next received chunk.
*
* Only lines that were fully processed (through a completed event boundary)
* are discarded; the remainder is re-encoded into {@link messageBuffer}.
*/
private storeRemainingLines(lines: string[], processedLineCount: number): void {
this.messageBuffer = lines.slice(processedLineCount).join(NEW_LINE)
}
}
+471
View File
@@ -0,0 +1,471 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
import type { SSESessionOptions, SSESessionEventMap, SSEvent } from './types.js'
import { EventEmitter } from '../event-emitter.js'
import { AsyncPushIterator } from './async-push-iterator.js'
import { ExponentialBackoff } from './exponential-backoff.js'
import { SSEEventParser } from './sse-event-parser.js'
/**
* A fetch-based Server-Sent Events (SSE) client with reconnect and optional
* browser tab visibility handling.
*
* Each session maintains one HTTP streaming connection at a time. Incoming
* bytes are parsed into {@link SSEvent} objects and delivered through two
* surfaces:
*
* - **Events** — `"connected"`, `"message"`, `"disconnected"`, `"error"`,
* and `"closed"` on the session itself (extends {@link EventEmitter}).
* - **Messages** — {@link messages}, an async iterable for `for await...of`
* consumers.
*
* Typical usage:
*
* ```ts
* const session = await SSESession.create("/events");
*
* session.on("message", (event) => console.log(event.data));
*
* for await (const event of session.messages) {
* handle(event);
* }
* ```
*
* ## Lifecycle
*
* - {@link connect} opens (or reopens) the transport. It resolves once the
* HTTP stream is established; reading continues in the background.
* - {@link abort} stops the in-flight fetch without ending the session.
* Used internally for tab visibility. The {@link messages} iterator stays
* open so an existing consumer resumes when the tab becomes visible again.
* - {@link disconnect} aborts the transport, closes {@link messages}, emits
* `"closed"`, and disables attached visibility handlers until the next
* manual {@link connect}.
*
* Automatic reconnect is controlled by {@link SSESessionOptions.persistent}
* (server closed the stream) and
* {@link SSESessionOptions.attemptReconnect} (transport error).
*
* ## Connection supersession
*
* Each {@link connect} or {@link abort} bumps an internal `connectionId`.
* Background read loops capture their id at start and exit quietly when a
* newer connection supersedes them, avoiding duplicate events or errors from
* stale transports.
*/
export class SSESession extends EventEmitter<SSESessionEventMap> {
/**
* Creates a session and waits until the first connection is established.
*
* @param url - The SSE endpoint URL.
* @param options - Configuration merged with instance defaults.
* @returns A connected session.
* @throws When the initial connection cannot be established.
*/
static async create(url: string, options: Partial<SSESessionOptions> = {}): Promise<SSESession> {
const client = new SSESession(url, options)
await client.connect()
return client
}
/**
* Enables SSE resume semantics by sending `Last-Event-ID` on reconnect.
*
* Listens for incoming `"message"` events and remembers the most recent
* {@link SSEvent.id}. On every subsequent connect or reconnect, the session's
* {@link onRequest} hook is wrapped so that header is attached when an id is
* known, allowing the server to replay only events the client has not yet
* received.
*
* The existing {@link onRequest} callback is preserved and runs after the
* header is applied, so auth or other header mutations continue to work.
*
* Attach as early in the session lifetime as possible. When added after
* {@link create}, the initial connection omits the header (no id yet);
* all later reconnects include it. To instrument before the first connect,
* call this on the session returned from {@link withBrowserVisibility}
* before awaiting a separate {@link connect} when the tab starts hidden.
*
* ```ts
* const session = await SSESession.create(url);
* await SSESession.addLastEventIdReconnect(session);
* // Reconnects send Last-Event-ID once an event with an id is received.
* ```
*
* @param client - The session to instrument.
* @returns The same session, for chaining.
*/
static async addLastEventIdReconnect(client: SSESession): Promise<SSESession> {
let lastEventId: string | undefined
client.on('message', (event) => {
// If the event has no id, we should not update the lastEventId and instead keep the old one.
lastEventId = event.id ?? lastEventId
})
const originalOnRequest = client.options.onRequest
client.options.onRequest = async (request: RequestInit): Promise<RequestInit> => {
if (lastEventId) {
request.headers = { ...request.headers, 'Last-Event-ID': lastEventId }
}
return originalOnRequest(request)
}
return client
}
/**
* Pauses and resumes a session based on browser tab visibility.
*
* Uses the Page Visibility API (`document.visibilitychange`):
*
* - **hidden** — {@link abort} stops the active fetch. {@link messages}
* stays open; `"disconnected"` fires but `"closed"` does not.
* - **visible** — {@link connect} re-establishes the stream if needed.
*
* The listener is removed when {@link disconnect} emits `"closed"`, and
* re-attached automatically on the next `"connected"` event.
*
* No-op in non-browser environments where `document` is undefined.
*
* @param client - The session to manage.
*/
static addBrowserVisibilityHandler(client: SSESession): SSESession {
if (typeof document === 'undefined') return client
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'hidden') {
void client.abort()
return
}
client.connect().catch(() => {
// connect() reports failures via onError and the "error" event.
})
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Stop managing visibility after an explicit disconnect; re-register
// when the same instance is manually connected again.
client.once('closed', () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
client.once('connected', () => {
SSESession.addBrowserVisibilityHandler(client)
})
})
return client
}
/** SSE endpoint URL for this session. */
private readonly url: string
/**
* Per-instance configuration.
*
* Defaults live on the instance field (not a shared static) so each session
* gets its own {@link SSEEventParser} and {@link ExponentialBackoff}.
*/
public options: SSESessionOptions = {
fetch: (...args) => fetch(...args),
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: new FormData(),
onRequest: (request) => Promise.resolve(request),
onConnected: () => {},
onDisconnected: () => {},
onError: (error) => console.error('SSEClient error:', error),
// Retry the initial fetch until it succeeds (maxAttempts: 0 = unlimited).
retry: new ExponentialBackoff({
baseDelay: 1000,
maxDelay: 10000,
maxAttempts: 0,
growthRate: 1.3,
jitter: 0.3,
}),
attemptReconnect: true,
persistent: false,
eventParser: new SSEEventParser(),
}
/** AbortController for the currently active fetch, if any. */
private controller: AbortController | null = null
/**
* Asynchronous stream of parsed SSE events for the active connection.
*
* Stays open across {@link abort} and automatic reconnects so an existing
* `for await` consumer keeps receiving events after visibility resumes.
*
* Closes when:
* - the server ends the stream and {@link SSESessionOptions.persistent}
* is false,
* - {@link disconnect} is called, or
* - a transport error occurs with
* {@link SSESessionOptions.attemptReconnect} disabled.
*
* A later {@link connect} replaces this with a new iterator when the
* previous one was closed. Consumers should read from `session.messages`
* rather than caching a reference across terminal disconnects.
*/
public messages: AsyncPushIterator<SSEvent> = new AsyncPushIterator<SSEvent>()
public constructor(url: string, options: Partial<SSESessionOptions> = {}) {
super()
this.url = url
this.options = {
...this.options,
...options,
// Shallow merge would drop default headers when options.headers is set.
headers: { ...this.options.headers, ...options.headers },
}
}
/**
* Connects or reconnects to the SSE endpoint.
*
* Resolves once the HTTP stream is established and `"connected"` has been
* emitted. Body reading continues asynchronously in the background via
* {@link readStream}.
*
* @throws When the fetch retry policy exhausts attempts or the connection
* is superseded before the reader is handed off (in the latter case the
* promise resolves without throwing).
*/
public async connect(): Promise<void> {
// If there is already a controller present, we are already connected.
if (this.controller) return
// Prepare for a fresh transport. Parser state from an abandoned connection
// must not bleed into the next one; reopen messages if a prior terminal
// close ended the consumer's iteration loop.
// NOTE: The resetEventParser may be out of spec and should not be done here: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
this.options.eventParser.reset()
// If the last messages iterator wasnt closed, we keep it around as this is likely a reconnect.
if (this.messages.closed) {
this.messages = new AsyncPushIterator<SSEvent>()
}
const controller = new AbortController()
this.controller = controller
const { method, headers, body } = this.options
const fetchBody = method === 'POST' ? body : null
const fetchOptions: RequestInit = {
method,
headers: headers || {},
body: fetchBody ?? null,
signal: controller.signal,
cache: 'no-store',
}
let reader: ReadableStreamDefaultReader<Uint8Array>
try {
reader = await this.options.retry.run(() => this.createReader(fetchOptions))
} catch (error) {
// A newer abort/connect superseded this attempt — leave state to the winner.
if (this.controller !== controller) return
this.controller = null
await this.notifyDisconnected()
await this.notifyError(error)
this.messages.close()
throw error
}
// Connection succeeded but was already replaced (for example abort during fetch).
if (this.controller !== controller) {
await reader.cancel()
return
}
await SSESession.tryAsync(
() => this.options.onConnected(),
(error) => this.notifyError(error),
)
this.emit('connected', undefined)
// Fire-and-forget: connect() resolves while the stream is consumed.
this.readStream(reader, controller).catch((error) => {
this.options.onError(error)
})
}
/**
* Aborts only the currently active transport.
*
* The session remains reusable: {@link messages} stays open, visibility
* handling stays attached, and {@link connect} can reopen the stream.
* Partial parser state from the abandoned transport is discarded.
*
* Emits `"disconnected"` but not `"closed"`.
*/
public async abort(): Promise<void> {
if (!this.controller) return
// Grab the current controller to ensure we are aborting the correct one.
const controller = this.controller
this.controller = null
// Invalidate any in-flight read loop and fetch for this transport.
controller.abort()
this.options.eventParser.reset()
await this.notifyDisconnected()
}
/**
* Terminates the session and disables attached visibility handling until
* the same instance is manually {@link connect connected} again.
*
* Closes {@link messages} and emits `"closed"`.
*/
public async disconnect(): Promise<void> {
this.messages.close()
this.emit('closed', undefined)
if (this.controller) {
await this.abort()
} else {
this.options.eventParser.reset()
}
}
/**
* Performs the HTTP request and returns a reader for the response body.
*
* {@link SSESessionOptions.onRequest} may mutate headers (for example auth
* tokens or `Last-Event-ID`) before the fetch runs.
*/
private async createReader(fetchOptions: RequestInit): Promise<ReadableStreamDefaultReader<Uint8Array>> {
const requestOptions = await this.options.onRequest(fetchOptions)
const response = await this.options.fetch(this.url, requestOptions)
if (!response.ok) {
const responseCode = response.status
const responseText = await response.text()
const error = new Error(`HTTP error: ${responseCode} ${responseText}`)
void this.notifyError(error)
throw error
}
if (!response.body) {
const error = new Error('HTTP error: Response body is null')
void this.notifyError(error)
throw error
}
return response.body.getReader()
}
/**
* Reads bytes from an established stream until it ends, errors, or is
* superseded by a newer connection.
*/
private async readStream(reader: ReadableStreamDefaultReader<Uint8Array>, controller: AbortController): Promise<void> {
try {
while (this.controller === controller) {
const { done, value } = await reader.read()
// abort() or a newer connect() may have landed while we were awaiting.
if (this.controller !== controller) return
if (done) {
this.controller = null
await this.notifyDisconnected()
if (this.options.persistent) {
// Server closed gracefully — reopen unless the consumer opted out.
await this.connect()
} else {
this.messages.close()
}
return
}
// Some environments yield `{ done: false, value: undefined }`.
if (!value) continue
for (const event of this.options.eventParser.parseEvents(value)) {
this.emit('message', event)
this.messages.push(event)
}
}
} catch (error) {
// If the controller is different, we already started a new connection and it would be confusing to handle this error.
if (controller !== this.controller) return
// Invalidate the current controller to allow for reconnection if needed
this.controller = null
await this.notifyDisconnected()
// Expected path for abort() — do not treat as an error or reconnect.
if (controller.signal.aborted) return
await this.notifyError(error)
if (this.options.attemptReconnect) {
await this.connect()
} else {
this.messages.close()
}
}
}
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
private async notifyDisconnected(): Promise<void> {
await SSESession.tryAsync(
() => this.options.onDisconnected(),
(error) => this.options.onError(error),
)
this.emit('disconnected', undefined)
}
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
private async notifyError(error: unknown): Promise<void> {
const errorInstance = error instanceof Error ? error : new Error(String(error))
await SSESession.tryAsync(
() => this.options.onError(errorInstance),
(callbackError) => console.error('SSESession error:', callbackError),
)
this.emit('error', errorInstance)
}
/** Runs `fn`, passing any error to `onError` instead of throwing. */
private static async tryAsync(fn: () => unknown, onError?: (error: Error) => void): Promise<void> {
try {
await fn()
} catch (error) {
onError?.(error instanceof Error ? error : new Error(`${error}`))
}
}
}
+145
View File
@@ -0,0 +1,145 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
export type SSERequestInit = {
/**
* The HTTP method to use.
*/
method: 'GET' | 'POST'
/**
* Request headers sent on every connect and reconnect.
*/
headers?: Record<string, string>
/**
* Request body for POST-based SSE endpoints.
*/
body?: string | FormData
}
/**
* The fetch function to use.
*
* NOTE: This is compatible with Browser/Node's native "fetch" function.
* We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here).
* For example, a LibP2P adapter might not use a standardized URL format (and might only include "path").
* This would cause a type error as native fetch expects type "URL".
*/
export type SSERequestFunction = {
fetch: (url: string, options: RequestInit) => Promise<Response>
}
/**
* Lifecycle hooks invoked by {@link SSESession} during connect, read, and teardown.
*/
export type SSESessionCallbacks = {
/**
* Called before each fetch so callers can attach auth headers, cookies, or
* a `Last-Event-ID` for resume semantics.
*/
onRequest: (request: RequestInit) => Promise<RequestInit>
/**
* Called after the HTTP stream is established and before body reading begins.
*/
onConnected: () => void
/**
* Called when the active transport ends — including {@link SSESession.abort},
* server stream completion, and errors. Not paired with {@link SSESessionCallbacks.onConnected}
* when the initial connect never succeeds.
*/
onDisconnected: () => void
/**
* Called on fetch or read failures. Not invoked for intentional
* {@link SSESession.abort} aborts.
*/
onError: (error: Error) => void
}
export type SSESessionRetryInterface = {
/**
* Retry policy used while establishing the HTTP connection in
* {@link SSESession.connect}. Defaults to {@link ExponentialBackoff} with
* unlimited attempts.
*/
retry: {
run<T>(fn: () => Promise<T>, onError?: (error: Error) => void): Promise<T>
}
}
export interface SSEParser {
/**
* Incremental SSE frame parser for the response body.
*
* {@link SSEEventParser.reset} is called by the session when abandoning a
* transport so partial frames do not carry over to the next connection.
*/
eventParser: {
parseEvents(buffer: Uint8Array): SSEvent[]
reset(): void
}
}
export type SSELifecycleOptions = {
/**
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* a transport **error** (not an intentional abort).
*/
attemptReconnect: boolean
/**
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* the **server** closes the stream normally (`done`).
*/
persistent: boolean
}
/**
* Events emitted by {@link SSESession}.
*
* - `"connected"` — HTTP stream established.
* - `"message"` — A complete SSE event was parsed.
* - `"disconnected"` — The active transport ended (including {@link SSESession.abort}).
* - `"error"` — An unexpected fetch or read failure.
* - `"closed"` — {@link SSESession.disconnect} was called; visibility handling is detached.
*/
export type SSESessionEventMap = {
connected: void
disconnected: void
error: Error
message: SSEvent
closed: void
}
/**
* Configuration for {@link SSESession}.
*/
export type SSESessionOptions = SSESessionCallbacks & SSERequestInit & SSERequestFunction & SSESessionRetryInterface & SSELifecycleOptions & SSEParser
/**
* Represents a Server-Sent Event.
*/
export interface SSEvent {
/**
* Event data.
*/
data: string
/**
* Event type.
*/
event?: string
/**
* Event ID.
*/
id?: string
/**
* Reconnection time in milliseconds.
*/
retry?: number
}