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
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
node: [20, 22] node: [22, 24, 26]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
+6 -2
View File
@@ -10,7 +10,7 @@ Layered TypeScript storage library: KV → Events → Documents. The design is i
- `npm run typecheck`, `npm run build`, `npm run bench` - `npm run typecheck`, `npm run build`, `npm run bench`
- `npm run format` / `npm run format:check`: Prettier - `npm run format` / `npm run format:check`: Prettier
CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck, tests and build on Node 20 and 22. CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck, tests and build on Node 22, 24 and 26.
## Conventions ## Conventions
@@ -18,7 +18,11 @@ CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck,
primitives, and the user-facing API lives in the base class. primitives, and the user-facing API lives in the base class.
- Classes over loose functions; TS `private`, not `#`; no overloads (use `param?` or tuple-union - Classes over loose functions; TS `private`, not `#`; no overloads (use `param?` or tuple-union
rest params); don't override base methods in subclasses. rest params); don't override base methods in subclasses.
- No runtime dependencies. Tests live only in `test/`, mirroring `src/`. - **Never reassign a field after construction.** `child()` nodes are frozen `Object.create(root)`
views, so `this.x = ...` on a child throws. Keep mutable state in a `private readonly state`
object or in Maps (see `KVStorageComposite`, `KVStorageHttp`).
- No runtime dependencies; third-party code is vendored (`src/utils/sse-session/`, `event-emitter.ts`,
`extended-json.ts`, with a header naming the source). Tests live only in `test/`, mirroring `src/`.
- New KV/document implementations must pass `test/kv-storage/kv-storage.conformance.ts` / - New KV/document implementations must pass `test/kv-storage/kv-storage.conformance.ts` /
`test/document-storage/document-storage.suite.ts`. `test/document-storage/document-storage.suite.ts`.
- Ask the owner before pushing. - Ask the owner before pushing.
+87 -14
View File
@@ -14,6 +14,9 @@ section 7 lists what changed and why.
that delegates the primitives. that delegates the primitives.
2. **One navigation primitive: `child(key)`.** Every instance is a node; `child` returns a view of 2. **One navigation primitive: `child(key)`.** Every instance is a node; `child` returns a view of
the same storage rooted deeper (via `Object.create`, so it shares the instance's state). Sync, no I/O. the same storage rooted deeper (via `Object.create`, so it shares the instance's state). Sync, no I/O.
Child nodes are **frozen**: an implementation that reassigns a field while running on a child
would silently shadow the root's value, so that throws instead. Implementations keep mutable
state in a `readonly state` object or in Maps.
3. **Self or child by argument.** `get()` reads this node, `get(key)` a child. A key that is passed 3. **Self or child by argument.** `get()` reads this node, `get(key)` a child. A key that is passed
but `undefined`/`null` throws (`InvalidKeyError`) instead of silently meaning "self". Methods use but `undefined`/`null` throws (`InvalidKeyError`) instead of silently meaning "self". Methods use
optional parameters or tuple-union rest parameters, not overloads. optional parameters or tuple-union rest parameters, not overloads.
@@ -21,16 +24,21 @@ section 7 lists what changed and why.
suits the backend (in-memory equality maps, native Mongo/SQLite/IndexedDB indexes, or nothing). suits the backend (in-memory equality maps, native Mongo/SQLite/IndexedDB indexes, or nothing).
5. **Simple over clever.** Code style: classes, TS `private` (not `#`), no method overrides in 5. **Simple over clever.** Code style: classes, TS `private` (not `#`), no method overrides in
subclasses, tests only under `test/`. subclasses, tests only under `test/`.
6. **No runtime dependencies.** Third-party code is vendored into `src/utils/` with a header naming
its source: `sse-session/` (Harvmaster/sse-session), `event-emitter.ts` and `extended-json.ts`
(xo-cash-utils, branch installable-sse). Node ≥ 22; CI runs Node 22, 24 and 26.
## 2. Layout ## 2. Layout
``` ```
src/ src/
kv-storage/ base-kv-storage.ts, kv-storage-in-memory.ts, kv-storage-http.ts, kv-storage-http-server.ts kv-storage/ base-kv-storage.ts, kv-storage-in-memory.ts, kv-storage-indexeddb.ts,
kv-storage-http.ts, kv-storage-http-server.ts, kv-storage-composite.ts
event-storage/ base-event-storage.ts, event-storage-kv.ts event-storage/ base-event-storage.ts, event-storage-kv.ts
document-storage/ base-document-storage.ts, document-storage-in-memory.ts, document-storage-kv.ts, document-storage/ base-document-storage.ts, document-storage-in-memory.ts, document-storage-kv.ts,
event-sourced-document-storage.ts event-sourced-document-storage.ts
utils/ keys, sorted-map, json, ulid, errors, schema (types), document-path/query/update utils/ keys, sorted-map, json, extended-json, event-emitter, ulid, errors, schema (types),
document-path/query/update, sse-session/ (vendored)
test/ mirrors src/; shared suites: kv-storage.conformance.ts, document-storage.suite.ts test/ mirrors src/; shared suites: kv-storage.conformance.ts, document-storage.suite.ts
bench/ bench/
``` ```
@@ -49,15 +57,24 @@ bench/
## 4. KV storage ## 4. KV storage
`BaseKVStorage` primitives (absolute keys): `BaseKVStorage` primitives (absolute keys). Implementations provide the reads and the protected
`apply*` writes; the public `writeMany` / `removeMany` / `writeIfAbsent` live in the base class and
emit a `change` event after each write, so every store emits change events.
| Primitive | | | Primitive | |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `readMany(keys)` | values in order, `undefined` for missing | | `readMany(keys)` | values in order, `undefined` for missing |
| `writeMany(entries)` | | | `scan({ gte, lt, limit, reverse, values })` | key-ordered; must keep working if keys change mid-scan (keys present throughout appear exactly once) |
| `removeMany(keys)` | | | `applyWrite(entries)` → public `writeMany` | |
| `writeIfAbsent(entries)` | `true` = written; atomic per key (needed by events) | | `applyRemove(keys)` → public `removeMany` | |
| `scan({ gte, lt, limit, reverse, values })` | key-ordered; must keep working if keys change mid-scan (keys present throughout appear exactly once) | | `applyWriteIfAbsent(entries)` → public `writeIfAbsent` | `true` = written; atomic per key (needed by events) |
**Values** are JSON plus `bigint` and `Uint8Array`. Anything crossing the network is encoded as
Extended JSON (`<bigint: 123n>`, `<uint8array: abcd>`); IndexedDB stores them natively.
**Events** (`EventEmitter`, shared by all nodes of a storage): `change` →
`{ changes: { key, value? }[], origin: 'local' | 'remote', id? }` (no `value` = removed), and
`resync` → changes may have been missed, so reload.
Node API (defined once): `child`, `as`, `get(key?)`, `getMany`, `set(value)` / `set(key, value)`, Node API (defined once): `child`, `as`, `get(key?)`, `getMany`, `set(value)` / `set(key, value)`,
`setMany`, `delete(key?)`, `deleteMany`, `deleteTree(key?)`, `keys({ limit, after, reverse })`, `setMany`, `delete(key?)`, `deleteMany`, `deleteTree(key?)`, `keys({ limit, after, reverse })`,
@@ -68,14 +85,66 @@ Node API (defined once): `child`, `as`, `get(key?)`, `getMany`, `set(value)` / `
reserved segments. reserved segments.
- **The only KV "index" is the ordered key space.** There is no `find`: with KV you know where to look, - **The only KV "index" is the ordered key space.** There is no `find`: with KV you know where to look,
or you list a node's children. or you list a node's children.
- **Implementations:** `KVStorageInMemory` (`SortedMap`: sorted chunks of ≤1024 keys; values deep-copied); - **Implementations:**
`KVStorageHttp` (client) + `KVStorageHttpServer` (wraps any KV storage; `handle(Request) → Response`, - `KVStorageInMemory`: `SortedMap` (sorted chunks of ≤1024 keys); values deep-copied.
`POST /<primitive>` with JSON bodies, `scan` streamed as NDJSON). The server exposes the whole - `KVStorageIndexedDB`: one object store with string keys. Scans read in batches, each in its own
underlying store (primitives use absolute keys). transaction, resuming after the last key (IndexedDB transactions close when the code awaits
anything else). `writeIfAbsent` runs in one readwrite transaction.
- `KVStorageHttp` (client) and `KVStorageHttpServer` (wraps any KV storage;
`handle(Request) → Response`): `POST /<primitive>` with Extended JSON bodies; `scan` streams
NDJSON. Unreachable server (fetch failure, 502/503/504) → `StorageUnavailableError`. The server
exposes the whole underlying store (primitives use absolute keys).
- `KVStorageComposite`: see 4.1.
- **Change stream (SSE).** `GET /events` on the server streams every change to the wrapped storage
as `event: change` with id `<epoch>-<seq>` (epoch = server instance). The server keeps the last
`historySize` (10 000) change batches.
- A client reconnecting with `Last-Event-ID` gets the batches it missed, or `event: resync` if it
can't be resumed (another epoch, or too old).
- Every catch-up ends with `event: ready`.
- Keep-alive comments are sent every 15 s.
- `KVStorageHttp.watch()` subscribes (via the vendored `SSESession`, which reconnects with
backoff), emits `change` events with `origin: 'remote'` and `resync`, and resolves once caught up.
- Limitation: a server only sees writes made through its own storage instance, so several server
processes over one database need a shared pub/sub (not built).
- **Wrappers** (e.g. a future encryption layer) extend `BaseKVStorage` and delegate the primitives. - **Wrappers** (e.g. a future encryption layer) extend `BaseKVStorage` and delegate the primitives.
Keys must stay plaintext (or be deterministically encrypted) for prefix scans to work; values can be Keys must stay plaintext (or be deterministically encrypted) for prefix scans to work; values can be
encrypted freely. encrypted freely.
### 4.1 `KVStorageComposite({ source, replicas, retry? })`
Keeps full local copies of a source (e.g. `KVStorageHttp`) so reads are local and the app works
offline. Replicas are listed fastest first (e.g. `[inMemory, indexedDb]`). The last replica is the
persistent one: it seeds the others on start and stores the composite's internal keys under `~q/`
(hidden from reads): `~q/meta` (`{ lastEventId }`, present once a sync has completed) and the
outbox `~q/o/<seq:12>`.
- **Start** (lazy, on first use):
1. Load the outbox and copy the persistent replica into the faster ones.
2. If a previous sync exists, serve reads locally straight away.
3. Watch the source, resuming from `lastEventId`.
4. Run a full sync only when there's no resume point, when the source sends `resync`, or when the
source can't be watched.
5. Before the first sync completes, reads go to the source.
- **Remote changes** (and full syncs) are applied in order through one queue. A change that arrives
during a full sync waits behind it, so the end state always matches the source. `lastEventId` is
saved after each applied batch. Changes the replicas already hold are skipped.
- **Full sync** merge-scans the source against the persistent replica (both key-ordered) and applies
the difference to every replica in batches of 500.
- **Writes** go to the source, then every replica.
- On `StorageUnavailableError` (or while anything is queued, to keep order), the write is applied
locally and appended to the outbox. The outbox is replayed in order with exponential backoff,
and also whenever the source shows signs of life. Last writer wins on conflicts.
- Remote changes to keys with queued writes are ignored until those writes are replayed.
- `writeIfAbsent` always needs the source.
- **Echo tracking.** Each write sent to a watched source expects one echo per key. A remote change
to a key with an outstanding echo is consumed instead of applied. The stream is in server order,
so that change is either the echo or older than it, and this avoids briefly showing a stale value.
A full sync clears the counts.
- Write through the composite, not directly through the same source instance (its `local`-origin
changes are not mirrored).
- `start()`, `synced()` (complete copy, all received changes applied), `queued` (outbox size),
`close()`.
## 5. Event storage ## 5. Event storage
`BaseEventStorage` primitives over stream paths: `appendTo`, `readFrom`, `versionOf`, `streamsOf`. `BaseEventStorage` primitives over stream paths: `appendTo`, `readFrom`, `versionOf`, `streamsOf`.
@@ -153,6 +222,10 @@ Node API: `get(key?)`, `getMany`, `set(doc)` / `set(key, doc)`, `insert(doc)`
## 8. Open questions ## 8. Open questions
- Composite: replicate a subtree instead of the whole store; conflict policies beyond last writer
wins for offline writes.
- Multi-process servers need a shared change feed (pub/sub) for SSE.
- Hard deletes from immutable event logs (privacy requests): compaction vs crypto-shredding. The - Hard deletes from immutable event logs (privacy requests): compaction vs crypto-shredding. The
encryption wrapper could support per-subject keys. encryption wrapper could support per-subject keys.
- Order-preserving numeric keys (`9` before `10`) as an opt-in key encoding. - Order-preserving numeric keys (`9` before `10`) as an opt-in key encoding.
+11 -8
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.5", "@types/node": "^22.20.5",
"fake-indexeddb": "^6.2.6",
"fast-check": "^4.10.2", "fast-check": "^4.10.2",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -16,14 +17,6 @@
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"peerDependencies": {
"vitest": ">=3"
},
"peerDependenciesMeta": {
"vitest": {
"optional": true
}
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -1136,6 +1129,16 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/fake-indexeddb": {
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.6.tgz",
"integrity": "sha512-ihhmr0KoET51jT8R4YhbUYuLLvr1qqO6INISygtN16i8loR8kAxtAUr4+EKHTSMjL+lggaAn4OC7DRqzrNgnuQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-check": { "node_modules/fast-check": {
"version": "4.10.2", "version": "4.10.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
+2 -1
View File
@@ -4,7 +4,7 @@
"description": "Layered TypeScript storage: KV -> Events -> Documents", "description": "Layered TypeScript storage: KV -> Events -> Documents",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=22"
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -29,6 +29,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.5", "@types/node": "^22.20.5",
"fake-indexeddb": "^6.2.6",
"fast-check": "^4.10.2", "fast-check": "^4.10.2",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -27,7 +27,7 @@ export type ChildDoc<S> = ValueOf<ChildOf<S, KeyOf<S>>>
*/ */
export abstract class BaseDocumentStorage<S extends Spec = AnySpec> { export abstract class BaseDocumentStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root, which has no own document). */ /** 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() protected readonly ids = new Ulid()
abstract readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> 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> abstract query(parent: readonly string[], query: DocumentQuery): AsyncIterable<FindResult>
child<K extends KeyOf<S>>(key: K): BaseDocumentStorage<ChildOf<S, K>> { child<K extends KeyOf<S>>(key: K): BaseDocumentStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseDocumentStorage<ChildOf<S, K>> const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseDocumentStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)] // 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 return node
} }
+6 -3
View File
@@ -39,7 +39,7 @@ export interface StreamKeysOptions {
*/ */
export abstract class BaseEventStorage<S extends Spec = AnySpec> { export abstract class BaseEventStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root, which is not a stream). */ /** 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 appendTo(stream: readonly string[], events: unknown[], expectedVersion: number | 'any'): Promise<AppendResult>
abstract readFrom(stream: readonly string[], options: ReadOptions): AsyncIterable<StoredEvent<unknown>> 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> abstract streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncIterable<string>
child<K extends KeyOf<S>>(key: K): BaseEventStorage<ChildOf<S, K>> { child<K extends KeyOf<S>>(key: K): BaseEventStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseEventStorage<ChildOf<S, K>> const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseEventStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)] // 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 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-in-memory.js'
export * from './kv-storage/kv-storage-http.js' export * from './kv-storage/kv-storage-http.js'
export * from './kv-storage/kv-storage-http-server.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/base-event-storage.js'
export * from './event-storage/event-storage-kv.js' export * from './event-storage/event-storage-kv.js'
export * from './document-storage/base-document-storage.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-query.js'
export * from './utils/document-update.js' export * from './utils/document-update.js'
export * from './utils/errors.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/json.js'
export * from './utils/keys.js' export * from './utils/keys.js'
export * from './utils/schema.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 { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, EntryOf, KeyOf, KeyString, Spec, StringEntryOf, ValueAt, ValueOf } from '../utils/schema.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 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; * 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. * (`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. * 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. * 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". * 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). */ /** Unescaped path of this node (`[]` for the root). */
path: readonly string[] = [] readonly path: readonly string[] = []
/** This node's own key (`''` for the root). */ /** This node's own key (`''` for the root). */
key = '' readonly key: string = ''
/** Prefix of every descendant key (`''` for the root). */ /** Prefix of every descendant key (`''` for the root). */
prefix = '' readonly prefix: string = ''
/** Values for absolute keys, in order; `undefined` for missing. */ /** Values for absolute keys, in order; `undefined` for missing. */
abstract readMany(keys: string[]): Promise<unknown[]> 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`). */ /** Entries in key order (descending when `reverse`). */
abstract scan(options?: ScanOptions): AsyncIterable<[key: string, value: unknown]> 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 // ------------------------------------------------------------------ navigation
/** A view of this storage rooted at `key`. Synchronous; no I/O. */ /** A view of this storage rooted at `key`. Synchronous; no I/O. */
child<K extends KeyOf<S>>(key: K): BaseKVStorage<ChildOf<S, K>> { child<K extends KeyOf<S>>(key: K): BaseKVStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseKVStorage<ChildOf<S, K>> const own = this.prefix + Keys.escape(Keys.segment(key))
node.path = [...this.path, Keys.segment(key)] const node = Object.create(this, {
node.key = this.prefix + Keys.escape(Keys.segment(key)) path: { value: [...this.path, Keys.segment(key)], enumerable: true },
node.prefix = node.key + Keys.separator 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 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)` * 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. * returns a `Response`.
* The raw primitives use absolute keys, so this exposes the whole store behind `storage`. *
* - `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 { 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( constructor(
private readonly storage: BaseKVStorage, 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> { async handle(request: Request): Promise<Response> {
const path = new URL(request.url).pathname 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 { try {
const body = (await request.json()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions const body = ExtendedJson.parse(await request.text()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions
switch (path.slice(this.basePath.length)) { switch (route) {
case '/readMany': { case 'POST /readMany': {
const values = await this.storage.readMany(body.keys) const values = await this.storage.readMany(body.keys)
const missing = values.flatMap((v, i) => (v === undefined ? [i] : [])) 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) await this.storage.writeMany(body.entries)
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
case '/removeMany': case 'POST /removeMany':
await this.storage.removeMany(body.keys) await this.storage.removeMany(body.keys)
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
case '/writeIfAbsent': case 'POST /writeIfAbsent':
return Response.json(await this.storage.writeIfAbsent(body.entries)) return KVStorageHttpServer.json(await this.storage.writeIfAbsent(body.entries))
case '/scan': case 'POST /scan':
return this.scan(body) return this.scan(body)
default: default:
return KVStorageHttpServer.error(404, 'Not found') 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 { private scan(options: ScanOptions): Response {
const iterator = this.storage.scan(options)[Symbol.asyncIterator]() const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
const encoder = new TextEncoder() const encoder = new TextEncoder()
@@ -53,7 +140,7 @@ export class KVStorageHttpServer {
if (chunk) controller.enqueue(encoder.encode(chunk)) if (chunk) controller.enqueue(encoder.encode(chunk))
return controller.close() 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)) controller.enqueue(encoder.encode(chunk))
}, },
@@ -64,6 +151,10 @@ export class KVStorageHttpServer {
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } }) 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 { private static error(status: number, message: string): Response {
return Response.json({ error: message }, { status }) 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 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 { export interface KVStorageHttpOptions {
/** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */ /** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */
url: string url: string
headers?: Record<string, 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> { export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly url: string private readonly url: string
private readonly headers: Record<string, 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) { constructor(options: KVStorageHttpOptions) {
super() super()
this.url = options.url.replace(/\/$/, '') this.url = options.url.replace(/\/$/, '')
this.headers = options.headers ?? {} 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[]> { async readMany(keys: string[]): Promise<unknown[]> {
@@ -28,18 +52,6 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
return values 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]> { async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
if (options.limit !== undefined && options.limit <= 0) return if (options.limit !== undefined && options.limit <= 0) return
const response = await this.request('scan', options) 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') const lines = buffer.split('\n')
buffer = lines.pop()! buffer = lines.pop()!
for (const line of lines) { for (const line of lines) {
const [key, value] = JSON.parse(line) as [string, unknown?] const [key, entryValue] = ExtendedJson.parse(line) as [string, unknown?]
yield [key, value] yield [key, entryValue]
} }
} }
} finally { } 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> { private async post(path: string, body: unknown): Promise<unknown> {
const response = await this.request(path, body) 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> { private async request(path: string, body: unknown): Promise<Response> {
const response = await this.fetcher(`${this.url}/${path}`, { let response: Response
method: 'POST', try {
headers: { ...this.headers, 'content-type': 'application/json' }, response = await this.fetcher(`${this.url}/${path}`, {
body: JSON.stringify(body), 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()}`) if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`)
return response 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))) 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)) 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) 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)) 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' 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 { 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 { static clone<T>(value: T): T {
if (value === null || typeof value !== 'object') return value 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 if (Array.isArray(value)) return value.map((v) => Json.clone(v)) as T
const out: Record<string, unknown> = {} const out: Record<string, unknown> = {}
for (const k in value) out[k] = Json.clone((value as Record<string, unknown>)[k]) 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 { static equal(a: unknown, b: unknown): boolean {
if (a === b) return true if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false 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 if (Array.isArray(a) !== Array.isArray(b)) return false
const ka = Object.keys(a) const ka = Object.keys(a)
const kb = Object.keys(b) const kb = Object.keys(b)
@@ -19,6 +25,6 @@ export class Json {
} }
static isObject(value: unknown): value is Record<string, unknown> { 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
}
+4 -3
View File
@@ -10,6 +10,7 @@ export async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
/** /**
* Wraps another KV storage and counts primitive calls. Also shows the wrapper pattern * Wraps another KV storage and counts primitive calls. Also shows the wrapper pattern
* (e.g. an encryption layer): extend BaseKVStorage and delegate the five primitives. * (e.g. an encryption layer): extend BaseKVStorage and delegate the five primitives.
* Counts are reported under the public method names (writeMany, removeMany, writeIfAbsent).
*/ */
export class KVStorageCounting extends BaseKVStorage { export class KVStorageCounting extends BaseKVStorage {
calls: Record<string, number> = {} calls: Record<string, number> = {}
@@ -25,13 +26,13 @@ export class KVStorageCounting extends BaseKVStorage {
readMany(keys: string[]) { readMany(keys: string[]) {
return this.count('readMany', () => this.inner.readMany(keys)) return this.count('readMany', () => this.inner.readMany(keys))
} }
writeMany(entries: [string, unknown][]) { protected applyWrite(entries: [string, unknown][]) {
return this.count('writeMany', () => this.inner.writeMany(entries)) return this.count('writeMany', () => this.inner.writeMany(entries))
} }
removeMany(keys: string[]) { protected applyRemove(keys: string[]) {
return this.count('removeMany', () => this.inner.removeMany(keys)) return this.count('removeMany', () => this.inner.removeMany(keys))
} }
writeIfAbsent(entries: [string, unknown][]) { protected applyWriteIfAbsent(entries: [string, unknown][]) {
return this.count('writeIfAbsent', () => this.inner.writeIfAbsent(entries)) return this.count('writeIfAbsent', () => this.inner.writeIfAbsent(entries))
} }
scan(options?: ScanOptions) { scan(options?: ScanOptions) {
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js'
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
import { collect } from '../helpers.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
import { TestNetwork } from './sync-helpers.js'
runKVStorageConformance(
'KVStorageComposite (in-memory source, 1 replica)',
() => new KVStorageComposite({ source: new KVStorageInMemory(), replicas: [new KVStorageInMemory()] }),
)
runKVStorageConformance('KVStorageComposite (http source, 2 replicas)', () => {
network = new TestNetwork()
return new KVStorageComposite({ source: network.client(), replicas: [new KVStorageInMemory(), new KVStorageInMemory()] })
})
let network: TestNetwork
const composites: KVStorageComposite[] = []
afterEach(async () => {
await Promise.all(composites.splice(0).map((c) => c.close()))
await network?.close()
})
function composite(persistent: BaseKVStorage = new KVStorageInMemory()) {
const c = new KVStorageComposite({ source: network.client(), replicas: [new KVStorageInMemory(), persistent], retry: { baseDelay: 5, maxDelay: 20 } })
composites.push(c)
return c
}
describe('KVStorageComposite sync', () => {
it('loads existing data with one full sync, then serves reads locally', async () => {
network = new TestNetwork()
await network.store.writeMany([
['users/1', { name: 'Ada' }],
['users/2', { name: 'Bob' }],
])
const db = composite()
await db.synced()
network.store.reset()
expect(await db.child('users').get('1')).toEqual({ name: 'Ada' })
expect(await collect(db.child('users').keys())).toEqual(['1', '2'])
expect(network.store.calls).toEqual({})
})
it('applies changes pushed from other clients to every replica', async () => {
network = new TestNetwork()
const memory = new KVStorageInMemory()
const db = composite(memory)
await db.synced()
const seen = db.waitFor('change', (e) => e.origin === 'remote')
await network.client().child('users').set('9', { name: 'Remote' })
expect((await seen).changes).toEqual([{ key: 'users/9', value: { name: 'Remote' } }])
expect(await db.child('users').get('9')).toEqual({ name: 'Remote' })
expect(await memory.readMany(['users/9'])).toEqual([{ name: 'Remote' }])
})
it('queues writes while offline and replays them in order when back online', async () => {
network = new TestNetwork()
const db = composite()
await db.synced()
network.online = false
await db.child('users').set('1', { v: 1 })
await db.child('users').set('1', { v: 2 })
await db.child('users').delete('1')
await db.child('users').set('2', { v: 3 })
expect(db.queued).toBe(4)
expect(await db.child('users').get('2')).toEqual({ v: 3 }) // served locally
await expect(db.writeIfAbsent([['x', 1]])).rejects.toThrow(/Cannot reach/)
network.online = true
await vi.waitFor(() => expect(db.queued).toBe(0))
expect(await network.store.readMany(['users/1', 'users/2'])).toEqual([undefined, { v: 3 }])
})
it('keeps queued local values when the server pushes an older value for the same key', async () => {
network = new TestNetwork()
const db = composite()
await db.synced()
network.online = false
await db.child('k').set({ from: 'local' })
await network.store.writeMany([['k', { from: 'remote' }]]) // pushed over the still-open stream
await new Promise((r) => setTimeout(r, 20))
expect(await db.child('k').get()).toEqual({ from: 'local' })
network.online = true
await vi.waitFor(() => expect(db.queued).toBe(0))
expect(await network.store.readMany(['k'])).toEqual([{ from: 'local' }])
})
it('restarts from the persistent replica and resumes without a full sync', async () => {
network = new TestNetwork()
const persistent = new KVStorageInMemory()
const first = composite(persistent)
await first.child('a').set(1)
await first.synced()
await first.close()
await network.client().writeMany([['b', 2]]) // missed while "closed"
network.store.reset()
const second = composite(persistent)
expect(await second.child('a').get()).toBe(1) // available before contacting the server
await vi.waitFor(async () => expect(await second.child('b').get()).toBe(2))
expect(network.store.calls.scan).toBeUndefined() // resumed via Last-Event-ID, no full scan
})
it('falls back to a full sync when the server cannot resume', async () => {
network = new TestNetwork({ historySize: 1 })
const persistent = new KVStorageInMemory()
const first = composite(persistent)
await first.child('a').set(1)
await first.synced()
await first.close()
await network.client().writeMany([['b', 2]])
await network.client().writeMany([['c', 3]])
await network.client().removeMany(['a'])
const second = composite(persistent)
await vi.waitFor(async () => expect(await collect(second.keys())).toEqual(['b', 'c']))
})
it('replays an outbox left by a previous session', async () => {
network = new TestNetwork()
const persistent = new KVStorageInMemory()
const first = composite(persistent)
await first.synced()
network.online = false
await first.child('offline').set('queued')
await first.close()
network.online = true
const second = composite(persistent)
await second.start()
await vi.waitFor(async () => expect(await network.store.readMany(['offline'])).toEqual(['queued']))
})
it('serves document queries from the local replica, offline too', async () => {
network = new TestNetwork()
const db = composite()
const users = new DocumentStorageKV(db).child('users')
await users.set('1', { age: 30 })
await users.set('2', { age: 40 })
await db.synced()
network.online = false
expect((await collect(users.find({ age: { $gt: 35 } }))).map((r) => r.key)).toEqual(['2'])
})
})
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { KVChangeEvent } from '../../src/kv-storage/base-kv-storage.js'
import { StorageUnavailableError } from '../../src/utils/errors.js'
import { TestNetwork } from './sync-helpers.js'
let network: TestNetwork
afterEach(() => network?.close())
describe('KVStorageHttp.watch (SSE)', () => {
it('receives changes made by other clients as remote change events', async () => {
network = new TestNetwork()
const watcher = network.client()
await watcher.watch()
const received = watcher.waitFor('change', (e) => e.origin === 'remote')
await network.client().child('users').set('1', { name: 'Ada', n: 1n })
const event = await received
expect(event.changes).toEqual([{ key: 'users/1', value: { name: 'Ada', n: 1n } }])
expect(event.id).toMatch(/^\w+-1$/)
expect(watcher.lastEventId).toBe(event.id)
})
it('resumes from lastEventId and replays missed changes', async () => {
network = new TestNetwork()
const writer = network.client()
const watcher = network.client()
await watcher.watch()
const first = watcher.waitFor('change', (e) => e.origin === 'remote')
await writer.writeMany([['a', 1]])
await first
await watcher.unwatch()
await writer.writeMany([['b', 2]])
await writer.writeMany([['c', 3]])
const replayed: KVChangeEvent[] = []
watcher.on('change', (e) => replayed.push(e))
await watcher.watch() // resolves once caught up, so the missed changes have been emitted
expect(replayed.map((e) => e.changes)).toEqual([[{ key: 'b', value: 2 }], [{ key: 'c', value: 3 }]])
})
it('asks for a resync when the missed changes are no longer in the history', async () => {
network = new TestNetwork({ historySize: 1 })
const writer = network.client()
const watcher = network.client()
await watcher.watch()
const seen = watcher.waitFor('change', () => true)
await writer.writeMany([['a', 1]])
await seen
await watcher.unwatch()
await writer.writeMany([['b', 2]])
await writer.writeMany([['c', 3]])
const resync = watcher.waitFor('resync', () => true)
await watcher.watch()
expect(await resync).toEqual({ id: expect.stringMatching(/-3$/) })
})
it('reports an unreachable server as StorageUnavailableError', async () => {
network = new TestNetwork()
const client = network.client()
network.online = false
await expect(client.readMany(['x'])).rejects.toBeInstanceOf(StorageUnavailableError)
})
})
+2 -2
View File
@@ -9,7 +9,7 @@ import { runKVStorageConformance } from './kv-storage.conformance.js'
import { serve } from './node-server.js' import { serve } from './node-server.js'
runKVStorageConformance('KVStorageHttp (in-process)', () => { runKVStorageConformance('KVStorageHttp (in-process)', () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), '/kv') const server = new KVStorageHttpServer(new KVStorageInMemory(), { basePath: '/kv' })
return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) }) return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) })
}) })
@@ -22,7 +22,7 @@ describe('over a real socket', () => {
}) })
afterAll(() => server.close()) afterAll(() => server.close())
const fresh = () => { const fresh = () => {
stores.push(new KVStorageHttpServer(new KVStorageInMemory(), `/${stores.length}`)) stores.push(new KVStorageHttpServer(new KVStorageInMemory(), { basePath: `/${stores.length}` }))
return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` }) return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` })
} }
@@ -76,3 +76,12 @@ describe('model-based: random writes vs a reference map', () => {
) )
}) })
}) })
describe('child nodes', () => {
it('are frozen, so implementations cannot accidentally shadow shared state', () => {
const child = new KVStorageInMemory().child('a').child('b')
expect(Object.isFrozen(child)).toBe(true)
expect(() => ((child as unknown as { state: number }).state = 1)).toThrow(TypeError)
expect(child.key).toBe('a/b')
})
})
@@ -0,0 +1,8 @@
import 'fake-indexeddb/auto'
import { KVStorageIndexedDB } from '../../src/kv-storage/kv-storage-indexeddb.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
let n = 0
runKVStorageConformance('KVStorageIndexedDB', () => new KVStorageIndexedDB({ name: `db-${n++}` }))
// Tiny batches exercise resuming scans across transactions.
runKVStorageConformance('KVStorageIndexedDB (scan batches of 2)', () => new KVStorageIndexedDB({ name: `db-${n++}`, scanBatchSize: 2 }))
+22
View File
@@ -71,6 +71,28 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
expect(seen.filter((k) => k !== 'a/1')).toEqual(['10', '9', 'a', 'a/2', 'a0', 'b', 'c']) expect(seen.filter((k) => k !== 'a/1')).toEqual(['10', '9', 'a', 'a/2', 'a0', 'b', 'c'])
}) })
it('emits change events for writes and removals', async () => {
const events: unknown[] = []
const off = db.on('change', (event) => events.push(event))
await db.writeMany([['x', 1]])
await db.removeMany(['x'])
await db.writeIfAbsent([
['a', 'no'],
['y', 2],
])
off()
expect(events).toEqual([
{ changes: [{ key: 'x', value: 1 }], origin: 'local' },
{ changes: [{ key: 'x' }], origin: 'local' },
{ changes: [{ key: 'y', value: 2 }], origin: 'local' },
])
})
it('stores bigint and Uint8Array values', async () => {
await db.writeMany([['bin', { n: 10n ** 20n, bytes: new Uint8Array([0, 255]) }]])
expect(await db.readMany(['bin'])).toEqual([{ n: 10n ** 20n, bytes: new Uint8Array([0, 255]) }])
})
it('does not share references with callers', async () => { it('does not share references with callers', async () => {
const [value] = (await db.readMany(['a/2'])) as [{ n: string }] const [value] = (await db.readMany(['a/2'])) as [{ n: string }]
value.n = 'changed' value.n = 'changed'
+35
View File
@@ -0,0 +1,35 @@
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
import { KVStorageHttpServer, type KVStorageHttpServerOptions } from '../../src/kv-storage/kv-storage-http-server.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { KVStorageCounting } from '../helpers.js'
/**
* An in-process server with a network switch. While `online` is false, new requests fail like a
* dropped connection (open SSE streams stay up, so pushes can still be simulated).
*/
export class TestNetwork {
online = true
readonly store = new KVStorageCounting(new KVStorageInMemory())
readonly server: KVStorageHttpServer
private readonly clients: KVStorageHttp[] = []
constructor(options: KVStorageHttpServerOptions = {}) {
this.server = new KVStorageHttpServer(this.store, options)
}
client(): KVStorageHttp {
const client = new KVStorageHttp({
url: 'http://server',
fetch: async (url, init) => {
if (!this.online) throw new TypeError('fetch failed')
return this.server.handle(new Request(url, init))
},
})
this.clients.push(client)
return client
}
async close(): Promise<void> {
await Promise.all(this.clients.map((c) => c.unwatch()))
}
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from '../../src/utils/event-emitter.js'
type Events = { tick: number; done: void }
describe('EventEmitter', () => {
it('delivers payloads as-is (no cloning or freezing) and supports off', () => {
const emitter = new EventEmitter<Events & { obj: { n: number } }>()
const seen: unknown[] = []
const payload = { n: 1 }
const off = emitter.on('obj', (p) => seen.push(p))
expect(emitter.emit('obj', payload)).toBe(true)
off()
expect(emitter.emit('obj', payload)).toBe(false)
expect(seen[0]).toBe(payload)
expect(Object.isFrozen(payload)).toBe(false)
})
it('once fires a single time; listener errors are logged, not thrown', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const emitter = new EventEmitter<Events>()
const once = vi.fn()
emitter.once('tick', once)
emitter.on('tick', () => {
throw new Error('boom')
})
emitter.emit('tick', 1)
emitter.emit('tick', 2)
expect(once).toHaveBeenCalledTimes(1)
expect(error).toHaveBeenCalledTimes(2)
error.mockRestore()
})
it('debounces and cancels pending debounced calls on off', async () => {
vi.useFakeTimers()
const emitter = new EventEmitter<Events>()
const listener = vi.fn()
const off = emitter.on('tick', listener, 50)
emitter.emit('tick', 1)
emitter.emit('tick', 2)
vi.advanceTimersByTime(60)
expect(listener).toHaveBeenCalledExactlyOnceWith(2)
emitter.emit('tick', 3)
off()
vi.advanceTimersByTime(60)
expect(listener).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('waitFor resolves on a matching payload and rejects on timeout', async () => {
const emitter = new EventEmitter<Events>()
const waiting = emitter.waitFor('tick', (n) => n > 1)
emitter.emit('tick', 1)
emitter.emit('tick', 2)
expect(await waiting).toBe(2)
await expect(emitter.waitFor('done', () => true, 5)).rejects.toThrow(/Timed out waiting for "done"/)
})
})
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { ExtendedJson } from '../../src/utils/extended-json.js'
describe('ExtendedJson', () => {
it('round-trips bigint and Uint8Array, nested anywhere', () => {
const value = { a: 1n, b: [new Uint8Array([0, 1, 255]), -(2n ** 70n)], c: { d: new Uint8Array(0), e: 'plain', f: null } }
expect(ExtendedJson.stringify({ n: 5n, bytes: new Uint8Array([171, 205]) })).toBe('{"n":"<bigint: 5n>","bytes":"<uint8array: abcd>"}')
expect(ExtendedJson.parse(ExtendedJson.stringify(value))).toEqual(value)
})
it('round-trips arbitrary data (property)', () => {
const data = fc.letrec((tie) => ({
value: fc.oneof(
{ depthSize: 'small' },
fc.bigInt(),
fc.uint8Array(),
fc.string(),
fc.double({ noNaN: true, noDefaultInfinity: true }),
fc.boolean(),
fc.constant(null),
fc.array(tie('value')),
fc.dictionary(fc.string(), tie('value')),
),
})).value
fc.assert(
fc.property(data, (v) => {
expect(ExtendedJson.parse(ExtendedJson.stringify(v))).toEqual(v)
}),
)
})
})
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { SSEEventParser } from '../../src/utils/sse-session/sse-event-parser.js'
import { SSESession } from '../../src/utils/sse-session/sse-session.js'
const encode = (text: string) => new TextEncoder().encode(text)
describe('vendored sse-session', () => {
it('parses events across arbitrary chunk boundaries', () => {
const parser = new SSEEventParser()
const text = 'id: 1\nevent: change\ndata: {"a":1}\n\n: comment\ndata: line1\ndata: line2\n\n'
const events = []
for (let i = 0; i < text.length; i += 3) events.push(...parser.parseEvents(encode(text.slice(i, i + 3))))
expect(events).toEqual([{ id: '1', event: 'change', data: '{"a":1}' }, { data: 'line1\nline2' }])
})
it('connects, delivers messages and reconnects when persistent', async () => {
let connections = 0
const session = new SSESession('http://test/events', {
persistent: true,
fetch: async () => {
connections++
return new Response(encode(`data: hello ${connections}\n\n`))
},
})
const received: string[] = []
session.on('message', (m) => received.push(m.data))
await session.connect()
await session.waitFor('message', (m) => m.data === 'hello 2')
await session.disconnect()
expect(received.slice(0, 2)).toEqual(['hello 1', 'hello 2'])
})
})
+1 -1
View File
@@ -3,7 +3,7 @@
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"lib": ["ES2022"], "lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"types": ["node"], "types": ["node"],
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,