cross-tab-worker-databus 0.21.45 → 0.21.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -1
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-36TEEZQ5.js → chunk-ZBA6SUFN.js} +2 -2
- package/dist/cjs/centrifuge.cjs +1 -1
- package/dist/cjs/index.cjs +7 -2
- package/dist/cjs/index.cjs.map +2 -2
- package/dist/core/replay-persistence.d.ts +9 -0
- package/dist/core/replay-persistence.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +2 -2
- package/docs/api.md +1 -1
- package/docs/benchmarks.md +7 -7
- package/docs/configuration.md +1 -1
- package/docs/release-checklist.md +6 -1
- package/docs/roadmap.md +1 -1
- package/docs/zh/api.md +1 -1
- package/docs/zh/benchmarks.md +7 -7
- package/docs/zh/configuration.md +1 -1
- package/docs/zh/release-checklist.md +6 -1
- package/docs/zh/roadmap.md +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-36TEEZQ5.js.map → chunk-ZBA6SUFN.js.map} +0 -0
|
@@ -14,6 +14,15 @@ export interface DataBusReplayPersistence<TData = unknown> {
|
|
|
14
14
|
clearBefore?(timestamp: number): Promise<void>;
|
|
15
15
|
}
|
|
16
16
|
export interface IndexedDbReplayPersistenceOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Namespace this store by the cluster it belongs to, deriving the database
|
|
19
|
+
* name the same way the coordination plane derives its storage keys.
|
|
20
|
+
*
|
|
21
|
+
* Pass the same value you pass the bus. Omitting it keeps the historical
|
|
22
|
+
* behavior — one database per origin, shared by every cluster — so this is
|
|
23
|
+
* additive: no existing caller's rows move.
|
|
24
|
+
*/
|
|
25
|
+
clusterKey?: string;
|
|
17
26
|
dbName?: string;
|
|
18
27
|
maxPerTopic: number;
|
|
19
28
|
pruneStrategy?: (typeof PRUNE_STRATEGY)[keyof typeof PRUNE_STRATEGY];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay-persistence.d.ts","sourceRoot":"","sources":["../../src/core/replay-persistence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"replay-persistence.d.ts","sourceRoot":"","sources":["../../src/core/replay-persistence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAG9C,OAAO,EAA0B,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAG5E,uDAAuD;AACvD,MAAM,WAAW,wBAAwB,CAAC,KAAK,GAAG,OAAO;IACvD,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACtD,MAAM,CAAC,OAAO,EAAE,cAAc,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,4EAA4E;IAC5E,WAAW,CAAC,CAAC,QAAQ,EAAE,aAAa,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,2CAA2C;IAC3C,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,2DAA2D;IAC3D,UAAU,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,+EAA+E;IAC/E,WAAW,CAAC,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,iCAAiC;IAChD;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,OAAO,cAAc,CAAC,CAAC;IACrE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAwBD,sDAAsD;AACtD,wBAAgB,gCAAgC,CAAC,KAAK,GAAG,OAAO,EAC9D,OAAO,EAAE,iCAAiC,GACzC,wBAAwB,CAAC,KAAK,CAAC,CA6UjC"}
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
selectRebalanceTarget,
|
|
21
21
|
selectWorkerBackend,
|
|
22
22
|
topicMatchesPattern
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-ZBA6SUFN.js";
|
|
24
24
|
import {
|
|
25
25
|
DEFAULT_STORAGE_PREFIX,
|
|
26
26
|
PRUNE_STRATEGY,
|
|
@@ -29,10 +29,15 @@ import {
|
|
|
29
29
|
} from "./chunk-NPFTD7YE.js";
|
|
30
30
|
|
|
31
31
|
// src/core/replay-persistence.ts
|
|
32
|
+
function resolveDbName(options) {
|
|
33
|
+
if (options.dbName !== void 0) return options.dbName;
|
|
34
|
+
if (options.clusterKey === void 0) return DEFAULT_STORAGE_PREFIX;
|
|
35
|
+
return `${DEFAULT_STORAGE_PREFIX}:${createOpaqueKey(options.clusterKey || "__default__")}`;
|
|
36
|
+
}
|
|
32
37
|
function createIndexedDbReplayPersistence(options) {
|
|
33
38
|
const indexedDb = globalThis.indexedDB;
|
|
34
39
|
if (!indexedDb) throw new Error("IndexedDB is unavailable in this environment.");
|
|
35
|
-
const dbName = options
|
|
40
|
+
const dbName = resolveDbName(options);
|
|
36
41
|
const storeName = "replay";
|
|
37
42
|
const maxPerTopic = options.maxPerTopic;
|
|
38
43
|
const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;
|
package/dist/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/core/replay-persistence.ts", "../src/websocket.ts"],
|
|
4
|
-
"sourcesContent": ["import type { DataBusMessage } from './types';\nimport { pruneReplayHistory } from './replay-pruning';\nimport { DEFAULT_STORAGE_PREFIX, PRUNE_STRATEGY } from '../utils/constants';\nimport { assertPositiveFiniteNumber, assertPositiveSafeInteger, assertPruneStrategy } from '../utils/validation';\n\n/** Optional persistence backend for replay history. */\nexport interface DataBusReplayPersistence<TData = unknown> {\n load(): Promise<ReadonlyArray<DataBusMessage<TData>>>;\n append(message: DataBusMessage<TData>): Promise<void>;\n /** Optional bulk append used to amortize IndexedDB transaction overhead. */\n appendBatch?(messages: ReadonlyArray<DataBusMessage<TData>>): Promise<void>;\n /** Remove all persisted replay history. */\n clear?(): Promise<void>;\n /** Remove persisted replay history for one exact topic. */\n clearTopic?(topic: string): Promise<void>;\n /** Remove persisted messages older than the given epoch-millisecond cutoff. */\n clearBefore?(timestamp: number): Promise<void>;\n}\n\nexport interface IndexedDbReplayPersistenceOptions {\n dbName?: string;\n maxPerTopic: number;\n pruneStrategy?: (typeof PRUNE_STRATEGY)[keyof typeof PRUNE_STRATEGY];\n retentionMs?: number;\n}\n\n/** Create a browser IndexedDB-backed replay store. */\nexport function createIndexedDbReplayPersistence<TData = unknown>(\n options: IndexedDbReplayPersistenceOptions\n): DataBusReplayPersistence<TData> {\n const indexedDb = globalThis.indexedDB;\n if (!indexedDb) throw new Error('IndexedDB is unavailable in this environment.');\n const dbName = options.dbName ?? DEFAULT_STORAGE_PREFIX;\n const storeName = 'replay';\n const maxPerTopic = options.maxPerTopic;\n const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;\n const retentionMs = options.retentionMs;\n assertPruneStrategy(pruneStrategy);\n if (retentionMs !== undefined) assertPositiveFiniteNumber(retentionMs, 'retentionMs');\n assertPositiveSafeInteger(maxPerTopic, 'maxPerTopic');\n let dbPromise: Promise<IDBDatabase> | null = null;\n const invalidate = (db: IDBDatabase): void => {\n if (dbPromise) {\n // The rejection arm is what keeps this total: `invalidate` can chain onto a\n // *replacement* open that is still in flight, and `.then(current => \u2026)`\n // alone would derive a rejected promise nobody observes, turning that\n // open's failure into an unhandled rejection on top of the caller's. No\n // test reaches it \u2014 it needs a scheduled failure racing a rejecting reopen\n // \u2014 and it is not the `open()` catch below, which is registered on the\n // promise it guards and only clears the cache.\n void dbPromise.then(current => {\n if (current === db) {\n current.close();\n dbPromise = null;\n }\n }, () => undefined);\n }\n };\n // IndexedDB transactions are atomic, but a read-modify-write append can\n // still lose updates when callers start several appends concurrently.\n // Serialize all mutations per adapter instance while keeping reads free.\n // Consecutive append/appendBatch entries are coalesced into a single\n // transaction at the head of the queue, so a burst spanning many microtask\n // flushes issues one transaction instead of one per flush. Ordering against\n // clears is preserved: coalescing only merges adjacent batch entries and\n // never reorders them relative to a clear.\n type QueuedMutation =\n | { kind: 'batch'; messages: ReadonlyArray<DataBusMessage<TData>> }\n | { kind: 'run'; run: () => Promise<void> };\n const pending: Array<{\n mutation: QueuedMutation;\n resolve: () => void;\n reject: (error: unknown) => void;\n }> = [];\n let draining = false;\n\n const drain = async (): Promise<void> => {\n if (draining) return;\n draining = true;\n try {\n // Let a burst of synchronous enqueues accumulate into `pending` before\n // the first coalescing pass, so a single flush cycle's batches merge\n // into one transaction instead of two.\n await Promise.resolve();\n while (pending.length > 0) {\n const head = pending[0]!.mutation;\n if (head.kind === 'batch') {\n // Merge every adjacent batch entry into one transaction.\n const merged: DataBusMessage<TData>[] = [];\n const entries: Array<{ resolve: () => void; reject: (error: unknown) => void }> = [];\n while (pending.length > 0) {\n const next = pending[0]!.mutation;\n if (next.kind !== 'batch') break;\n merged.push(...next.messages);\n entries.push({ resolve: pending[0]!.resolve, reject: pending[0]!.reject });\n pending.shift();\n }\n try {\n await appendTransaction(merged);\n for (const entry of entries) entry.resolve();\n } catch (error) {\n for (const entry of entries) entry.reject(error);\n }\n } else {\n const entry = pending.shift()!;\n try {\n await head.run();\n entry.resolve();\n } catch (error) {\n entry.reject(error);\n }\n }\n }\n } finally {\n draining = false;\n }\n };\n\n const enqueue = (mutation: QueuedMutation): Promise<void> =>\n new Promise<void>((resolve, reject) => {\n pending.push({ mutation, resolve, reject });\n void drain();\n });\n\n /** Read-modify-write one topic-batched append inside a single transaction. */\n const appendTransaction = (messages: ReadonlyArray<DataBusMessage<TData>>): Promise<void> =>\n (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try {\n transaction = db.transaction(storeName, 'readwrite');\n } catch (error) {\n invalidate(db);\n reject(error);\n return;\n }\n const store = transaction.objectStore(storeName);\n const grouped = new Map<string, DataBusMessage<TData>[]>();\n for (const message of messages) {\n grouped.set(message.topic, [...(grouped.get(message.topic) ?? []), message]);\n }\n let hasError = false;\n const fail = (error: unknown): void => {\n if (hasError) return;\n hasError = true;\n invalidate(db);\n reject(error);\n };\n for (const [topic, topicMessages] of grouped) {\n const request = store.get(topic);\n request.onsuccess = () => {\n if (hasError) return;\n const history = pruneReplayHistory(\n ((request.result?.messages ?? []) as DataBusMessage<TData>[]).concat(topicMessages),\n { maxPerTopic, pruneStrategy, retentionMs, now: Date.now() }\n );\n store.put({ topic, messages: history });\n };\n request.onerror = () => fail(request.error ?? new Error('Failed to read replay history.'));\n }\n transaction.oncomplete = () => {\n if (!hasError) resolve();\n };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to persist replay history.'));\n // A connection loss can abort a transaction without first dispatching a\n // request error. Without this path, the serialized mutation queue would\n // stay blocked forever after the promise never settles.\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to persist replay history.'));\n });\n })();\n const open = (): Promise<IDBDatabase> => {\n // The two `if (dbPromise \u2026)` guards inside have never had their false arm\n // execute. Reaching either needs a connection that is live but no longer\n // cached, so a versionchange could land on an empty slot, or a slot replaced\n // before its own rejection catch runs \u2014 and both are excluded here because a\n // replacement can only start from a cleared slot, which is what the clearing\n // path itself performs. Defensive, so recorded rather than pinned.\n if (dbPromise) return dbPromise;\n const pending = new Promise<IDBDatabase>((resolve, reject) => {\n const request = indexedDb.open(dbName, 1);\n request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: 'topic' });\n request.onsuccess = () => {\n const db = request.result;\n // A schema upgrade in another tab invalidates this connection. Close\n // it and clear the cached promise so the next operation reopens a\n // usable connection instead of repeatedly targeting a dead database.\n db.onversionchange = () => {\n db.close();\n if (dbPromise) dbPromise = null;\n };\n resolve(db);\n };\n request.onerror = () => reject(request.error ?? new Error('Failed to open replay database.'));\n });\n dbPromise = pending;\n // Do not permanently cache a rejected open promise. IndexedDB can fail\n // transiently (quota, private-mode initialization, a closing connection,\n // or a browser shutdown); the next operation must be able to retry.\n void pending.catch(() => {\n // The fall-through \u2014 a rejection that arrives with the cache already\n // pointing somewhere else \u2014 has zero counts, and no writer fits in its\n // window: this catch is attached by the statement immediately after\n // `dbPromise = pending`, with nothing but comments between them,\n // so it is the first reaction that rejection can run, and nothing can register\n // a reaction on `pending` earlier \u2014 `invalidate()` is reached only with a\n // connection from a *resolved* open, and its chained read takes the\n // `() => undefined` arm on a rejected promise anyway, while `onversionchange`\n // is installed inside `onsuccess`, which an errored request never reaches.\n // Kept, because what dropping the condition costs is asymmetric: an\n // unconditional clear would evict a newer open's cached connection and the\n // next operation would open a second database connection while the first is\n // still usable.\n if (dbPromise === pending) dbPromise = null;\n });\n return pending;\n };\n return {\n async load() {\n const db = await open();\n return new Promise((resolve, reject) => {\n let transaction: IDBTransaction;\n let request: IDBRequest;\n try {\n transaction = db.transaction(storeName, 'readonly');\n request = transaction.objectStore(storeName).getAll();\n } catch (error) {\n invalidate(db);\n reject(error);\n return;\n }\n // One-shot settlement. There are four latch *variables* here and five\n // guarded closures: the four `invalidate`+`reject` ones, plus this block's\n // `oncomplete`, which shares the same latch as its `fail` \u2014 so \"delete the\n // latches\" is a five-statement edit, not a four-statement one. Entering a\n // latch twice is reachable \u2014 one aborting transaction dispatches `error`\n // and then `abort` \u2014 but measured harmless: `reject` on a settled promise\n // is a no-op, and the extra `invalidate` cannot see a replaced cache\n // because both signals are dispatched inside one microtask batch, so their\n // callbacks run before the serialized queue reopens. Deleting all five\n // guards leaves the suite green and the connection count unchanged (that\n // is the measured form, re-run statement by statement rather than by the\n // count in this sentence); they stay because that proof rests on dispatch\n // timing outside this file. The stale-signal case that is inside this file\n // is pinned by tests/replay-persistence.test.ts's\n // 'keeps the connection a later operation reopened when an older signal\n // lands late'.\n let settled = false;\n const fail = (error: unknown): void => {\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n let records: Array<{ messages: DataBusMessage<TData>[] }> = [];\n request.onsuccess = () => {\n records = request.result as Array<{ messages: DataBusMessage<TData>[] }>;\n };\n request.onerror = () => fail(request.error ?? new Error('Failed to load replay history.'));\n transaction.oncomplete = () => {\n if (settled) return;\n settled = true;\n resolve(records.flatMap(record => record.messages));\n };\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to load replay history.'));\n });\n },\n append(message) {\n return enqueue({ kind: 'batch', messages: [message] });\n },\n appendBatch(messages) {\n if (messages.length === 0) return Promise.resolve();\n return enqueue({ kind: 'batch', messages });\n },\n clear() {\n return enqueue({\n kind: 'run',\n run: () => (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try { transaction = db.transaction(storeName, 'readwrite'); }\n catch (error) { invalidate(db); reject(error); return; }\n let settled = false;\n const fail = (error: unknown): void => {\n // One of the four `invalidate`+`reject` latches whose zero-count arm is\n // enumerated in `load()`'s closure above.\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n transaction.objectStore(storeName).clear();\n transaction.oncomplete = () => { settled = true; resolve(); };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to clear replay history.'));\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to clear replay history.'));\n });\n })()\n });\n },\n clearTopic(topic) {\n return enqueue({\n kind: 'run',\n run: () => (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try { transaction = db.transaction(storeName, 'readwrite'); }\n catch (error) { invalidate(db); reject(error); return; }\n let settled = false;\n const fail = (error: unknown): void => {\n // One of the four `invalidate`+`reject` latches whose zero-count arm is\n // enumerated in `load()`'s closure above.\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n transaction.objectStore(storeName).delete(topic);\n transaction.oncomplete = () => { settled = true; resolve(); };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to clear topic replay history.'));\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to clear topic replay history.'));\n });\n })()\n });\n },\n clearBefore(timestamp) {\n return enqueue({\n kind: 'run',\n run: () => (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try { transaction = db.transaction(storeName, 'readwrite'); }\n catch (error) { invalidate(db); reject(error); return; }\n let settled = false;\n const fail = (error: unknown): void => {\n // One of the four `invalidate`+`reject` latches whose zero-count arm is\n // enumerated in `load()`'s closure above.\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n const store = transaction.objectStore(storeName);\n const request = store.getAll();\n request.onsuccess = () => {\n for (const record of request.result as Array<{ topic: string; messages: DataBusMessage<TData>[] }>) {\n const messages = record.messages.filter(message => message.timestamp === undefined || message.timestamp >= timestamp);\n if (messages.length === 0) store.delete(record.topic);\n else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });\n }\n };\n request.onerror = () => fail(request.error ?? new Error('Failed to read replay history.'));\n transaction.oncomplete = () => { settled = true; resolve(); };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to prune replay history.'));\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to prune replay history.'));\n });\n })()\n });\n }\n };\n}\n", "/**\n * WebSocketTransport \u2014 a dependency-free transport over a plain WebSocket.\n *\n * Validates the `DataBusTransport` abstraction with a second, minimal backend:\n * any WebSocket server that speaks the tiny JSON protocol below can back the\n * same cross-tab clustering stack (owner dedup, sticky routes, EVENT fan-out)\n * that the Centrifuge backend uses.\n *\n * Wire protocol \u2014 two frame formats, both over text/binary WebSocket messages:\n * - client \u2192 server: `{\"op\":\"subscribe\"|\"unsubscribe\"|\"publish\",\"topic\":...,\"data\":...}`\n * - client \u2192 server (batched): `{\"op\":\"publishBatch\",\"topic\":...,\"items\":[{data,...}]}`\n * - server \u2192 client: `{\"topic\":...,\"data\":...}` for publications; anything\n * without a string `topic` field is ignored (forward-compatible).\n * - binary publications use a *separate* tagged frame, not JSON:\n * `0xc7 | uint16 topicLength | topic UTF-8 | raw payload`, written by\n * `sendBinaryFrame` when `publish()` receives an `ArrayBuffer` with no\n * `messageId`/`timestamp` (with metadata it degrades to a JSON PUBLISH whose\n * `data` is a number array, so an id is never lost to the compact shape), and\n * parsed by the matching reader. A server that implements only the JSON arm\n * above silently drops those \u2014 see `scripts/demo-ws-server.mjs`, which\n * implements both directions.\n */\nimport { CrossTabDataBus } from './core/data-bus';\nimport { parseDataBusPublication } from './core/publication';\nimport type { CrossTabDataBusOptions } from './core/data-bus';\nimport { WS_OP, WORKER_STATUS } from './utils/constants';\nimport type {\n DataBusTransport,\n DataBusTransportHandlers,\n DataBusPublishOptions,\n DataBusPublicationItem,\n DataBusMessage,\n MaybePromise,\n WorkerStatus\n} from './core/types';\n\n/** Minimal WebSocket surface used by the transport. Matches the browser\n * `WebSocket` subset the transport touches; injectable for tests and runtimes. */\nexport interface WebSocketLike {\n /** Current connection state; 1 (OPEN) means frames may be sent. */\n readonly readyState?: number;\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n onopen: (() => void) | null;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: { data: unknown }) => void) | null;\n}\n\n/** Connection configuration for {@link WebSocketTransport}. */\nexport interface WebSocketDataBusConfig {\n /** WebSocket endpoint, e.g. `wss://example.test/ws`. */\n url: string;\n /** Subprotocol(s) passed to the WebSocket handshake. */\n protocols?: string | string[];\n /** Custom socket factory. Defaults to the global `WebSocket`; injectable\n * for tests and non-browser runtimes. */\n webSocketFactory?: (url: string, protocols?: string | string[]) => WebSocketLike;\n /** Milliseconds to wait for the handshake before reporting `error` and\n * failing the start. Defaults to 30000 ms; pass\n * `0` or `Infinity` to wait indefinitely. The timeout exists because\n * `start()` resolves on connect, so a socket that never opens and never\n * errors would otherwise leave the DataBus start gate (and every operation\n * queued behind it) pending forever. */\n connectTimeoutMs?: number;\n}\n\n/** Options for creating a fully-configured CrossTabDataBus with a WebSocket transport. */\nexport interface CreateWebSocketDataBusOptions<TData = unknown>\n extends Omit<\n CrossTabDataBusOptions<WebSocketDataBusConfig, TData>,\n 'autoStart' | 'clusterKey' | 'initialConfig' | 'transport'\n > {\n /** WebSocket connection configuration. */\n connection: WebSocketDataBusConfig;\n /** Cluster key for cross-tab coordination. Defaults to the connection URL. */\n clusterKey?: string;\n}\n\n/** Default handshake budget. A socket that never opens and never fires\n * error/close would otherwise keep a started transport stuck in `connecting`\n * forever, with every queued operation parked behind an unsettled `start()`. */\nconst DEFAULT_CONNECT_TIMEOUT_MS = 30_000;\n\nconst WS_OPEN = 1;\n\n/** Transport that talks a minimal JSON protocol over a plain WebSocket.\n * Connection lifecycle maps directly to the DataBus status vocabulary:\n * open \u2192 `connected`, close \u2192 `disconnected`, error \u2192 `error` (which the\n * DataBus treats as its auto-recovery trigger). The transport holds no\n * reconnection logic of its own \u2014 reopening is the DataBus's job. */\nexport class WebSocketTransport<TData = unknown>\n implements DataBusTransport<WebSocketDataBusConfig, TData>\n{\n readonly diagnosticsName = 'websocket';\n readonly diagnosticsBackend = 'native-websocket';\n private socket: WebSocketLike | null = null;\n private socketActive = false;\n private handlers: DataBusTransportHandlers<TData> | null = null;\n private readonly subscribedTopics = new Set<string>();\n // Handshake gate for the current start(). Resolves once the socket opens,\n // rejects when the attempt fails, so the DataBus start Promise \u2014 and every\n // operation parked behind it \u2014 settles at the real connection boundary.\n private connectPromise: Promise<void> | null = null;\n private connectResolve: (() => void) | null = null;\n private connectReject: ((error: unknown) => void) | null = null;\n private connectTimer: ReturnType<typeof setTimeout> | null = null;\n\n constructor(private readonly connection: WebSocketDataBusConfig) {}\n\n /** Open the WebSocket and wire lifecycle listeners. Resolves once the\n * handshake completes and rejects when the attempt fails, matching the\n * `DataBusTransport.start` contract (\"resolves on connect or rejects on\n * failure\"). A factory failure is reported through `onStatus('error')` so\n * the DataBus can recover. */\n start(config: WebSocketDataBusConfig, handlers: DataBusTransportHandlers<TData>): MaybePromise<void> {\n if (this.socket && this.socketActive) {\n // Reuse the live socket instead of orphaning it. While the first\n // attempt is still connecting, share its handshake gate so a duplicate\n // start() cannot report readiness before the socket is usable.\n // The `?? undefined` side of this has zero counts, and it is a type\n // conversion rather than a behavior branch: `MaybePromise<void>` admits\n // `void | Promise<void>` and not `null`, so writing `return this.connectPromise`\n // is TS2322, not a passing edit (measured). The state it would have to report\n // \u2014 a live socket with no gate \u2014 is excluded by the field's whole write set:\n // `connectPromise` is assigned in exactly two statements, `start()`'s\n // `= opening` immediately after the promise executor that sets `socket` and\n // `socketActive`, and `stop()`'s `= null` in the same frame as the two clears\n // that un-satisfy the test above. Neither pair yields to anything between its\n // members, so no observer \u2014 including a re-entrant `start()` \u2014 can see the\n // premise standing while the gate is gone. The statement order inside each\n // frame is not what carries this; any reorder within a frame is equally\n // unobservable, and the `settleConnect()`/`failConnect()` pair that does reach\n // across frames never touches the field.\n return this.connectPromise ?? undefined;\n }\n // A failed or closed socket is one-shot; retain its object only long\n // enough for a transparent same-object reopen to fire, but replace it\n // whenever start() is called again. Clearing the reference here also\n // makes every late callback from the old socket a no-op.\n this.socket = null;\n this.socketActive = false;\n this.handlers = handlers;\n // The factory may live on the constructor connection (createWebSocketDataBus\n // path) or on the runtime config (direct transport use) \u2014 accept both.\n const factory = config.webSocketFactory ?? this.connection.webSocketFactory ?? defaultWebSocketFactory;\n const protocols = config.protocols ?? this.connection.protocols;\n let socket: WebSocketLike;\n try {\n socket = factory(config.url, protocols);\n } catch (error) {\n handlers.onStatus(WORKER_STATUS.ERROR);\n handlers.onError(error);\n return;\n }\n const opening = new Promise<void>((resolve, reject) => {\n this.connectResolve = resolve;\n this.connectReject = reject;\n // Per-attempt handshake state. A socket that opens, then closes and\n // re-opens in place (a protocol-level recovery) may reuse the same\n // attempt; a timeout or a close/error before the first open permanently\n // invalidates it so a late onopen cannot report readiness.\n let handshakeCompleted = false;\n let handshakeFailed = false;\n const timeoutMs =\n config.connectTimeoutMs ?? this.connection.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;\n if (Number.isFinite(timeoutMs) && timeoutMs > 0) {\n this.connectTimer = setTimeout(() => {\n // This early return has never executed in any run of the suite, and the\n // reason is the same for all three terms: no attempt can leave a timer\n // armed behind it. Term 3 (`handshakeCompleted`) because `onopen` cancels\n // here directly and again through `settleConnect()`; terms 1 and 2 because\n // `this.socket` and `this.handlers` are reassigned only in `start()` and\n // `stop()`, reaching either assignment in `start()` needs `socketActive`\n // already false, and every path that lowers `socketActive` clears the\n // armed timer first \u2014 `onclose` and `onerror` through `failConnect()`,\n // `stop()` through `settleConnect()`, and this callback by nulling the\n // handle as it fires. So the guard cannot distinguish anything, which is\n // what its coverage arm records.\n //\n // It stays as the transport's statement that a superseded attempt must\n // never act, because the domination is a property of those cancels rather\n // than of this condition, and the asymmetry is measured: deleting only the\n // `handshakeCompleted` term leaves the suite green, deleting only `failConnect()`'s cancel lets a dead attempt's\n // timeout report a second failure for a connection the application was\n // already told had failed, and the first pin's own comment records the\n // third case \u2014 deleting the term *and* both cancels makes a healthy socket\n // report a phantom `onStatus('error')` and get aborted. The two pins name\n // the legs they hold: tests/websocket.test.ts's 'never lets the connect\n // timer tear down a handshake that already completed', and 'leaves no\n // connect timer armed when the handshake fails on a close'.\n if (this.socket !== socket || this.handlers !== handlers || handshakeCompleted) return;\n handshakeFailed = true;\n this.connectTimer = null;\n this.socketActive = false;\n const error = new Error(`WebSocket did not open within ${timeoutMs}ms.`);\n handlers.onStatus(WORKER_STATUS.ERROR);\n handlers.onError(error);\n this.failConnect(error);\n // Abort the half-open handshake so the timed-out attempt cannot\n // linger in CONNECTING or deliver a late onopen.\n this.abortSocket(socket);\n }, timeoutMs);\n }\n socket.onopen = () => {\n if (this.socket !== socket || this.handlers !== handlers || handshakeFailed) return;\n this.socketActive = true;\n this.clearConnectTimer();\n // Re-assert every topic so a reopened socket (recovery path) restores\n // the server-side subscriptions without DataBus involvement.\n for (const topic of this.subscribedTopics) {\n this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });\n }\n handlers.onStatus(WORKER_STATUS.CONNECTED);\n if (!handshakeCompleted) {\n handshakeCompleted = true;\n this.settleConnect();\n }\n };\n // Each guard below ends in an operand that the first two cannot see: a socket\n // that died without being replaced. `stop()` clears `this.socket`, so a\n // teardown is caught by the first operand anywhere; a timeout, a close and an\n // error each lower `socketActive` while leaving `this.socket` naming the same\n // object, so for anything that socket delivers afterwards only that last\n // operand can decide. Measured one operand at a time against the whole suite:\n // `onclose`'s was already pinned (three lifecycle cases), while `onerror`'s,\n // `onmessage`'s and `handleMessage`'s Blob-path one each killed nothing until\n // tests/websocket.test.ts's 'drops a late error from the socket whose connect\n // timeout already reported one', 'drops a server frame that arrives after the\n // connect attempt timed out' and 'drops a Blob frame whose conversion finishes\n // after its connection closed'. `onopen`'s third term is a different flag,\n // `handshakeFailed`, set by the timeout and by the close/error paths below\n // rather than read from the field.\n socket.onclose = () => {\n if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;\n this.socketActive = false;\n handlers.onStatus(WORKER_STATUS.DISCONNECTED);\n if (!handshakeCompleted) {\n handshakeFailed = true;\n this.failConnect(new Error('WebSocket closed before the handshake completed.'));\n }\n };\n socket.onerror = () => {\n if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;\n this.socketActive = false;\n handlers.onStatus(WORKER_STATUS.ERROR);\n if (!handshakeCompleted) {\n handshakeFailed = true;\n this.failConnect(new Error('WebSocket failed to open.'));\n }\n // `error` is not guaranteed to be followed by `close` (especially for\n // injected WebSocket implementations). Abort here so DataBus recovery\n // cannot orphan a socket that this transport has already marked dead.\n this.abortSocket(socket);\n };\n socket.onmessage = event => {\n if (this.socket === socket && this.handlers === handlers && this.socketActive) {\n void this.handleMessage(event.data);\n }\n };\n this.socket = socket;\n this.socketActive = true;\n });\n this.connectPromise = opening;\n return opening;\n }\n\n /** Idempotent: re-subscribing an active topic re-sends the frame but does\n * not duplicate the local tracking entry. */\n subscribe(topic: string): MaybePromise<void> {\n this.subscribedTopics.add(topic);\n this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });\n }\n\n /** Idempotent for local tracking: an unknown topic has nothing to delete, but\n * the UNSUBSCRIBE frame is still sent, exactly as SUBSCRIBE is re-sent above. */\n unsubscribe(topic: string): MaybePromise<void> {\n this.subscribedTopics.delete(topic);\n this.sendFrame({ op: WS_OP.UNSUBSCRIBE, topic });\n }\n\n /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */\n publish(topic: string, data: unknown, options?: DataBusPublishOptions): MaybePromise<void> {\n if (data instanceof ArrayBuffer) {\n this.sendBinaryFrame(topic, data, options?.messageId, options?.timestamp);\n return;\n }\n this.sendFrame({\n op: WS_OP.PUBLISH,\n topic,\n data,\n ...(options?.messageId === undefined ? {} : { messageId: options.messageId }),\n ...(options?.timestamp === undefined ? {} : { timestamp: options.timestamp })\n });\n }\n\n /** Publish many items for one topic as a single wire frame. One-item\n * batches delegate to `publish` so the legacy single-publication frame\n * shape (including binary framing) is preserved. */\n publishBatch(topic: string, items: ReadonlyArray<DataBusPublicationItem>): MaybePromise<void> {\n if (items.length === 0) return;\n if (items.length === 1) {\n const single = items[0]!;\n return this.publish(topic, single.data, {\n ...(single.messageId === undefined ? {} : { messageId: single.messageId }),\n ...(single.timestamp === undefined ? {} : { timestamp: single.timestamp })\n });\n }\n if (this.socket?.readyState !== WS_OPEN) {\n this.handlers?.onError(new Error('WebSocket is not open; dropped \"publishBatch\" frame.'));\n return;\n }\n // Binary payloads are embedded as byte arrays so the whole batch stays in\n // one JSON frame; the server re-fans them out as individual publications.\n this.socket.send(JSON.stringify({\n op: WS_OP.PUBLISH_BATCH,\n topic,\n items: items.map(item => ({\n data: item.data instanceof ArrayBuffer ? Array.from(new Uint8Array(item.data)) : item.data,\n ...(item.messageId === undefined ? {} : { messageId: item.messageId }),\n ...(item.timestamp === undefined ? {} : { timestamp: item.timestamp })\n }))\n }));\n }\n\n /** Close the socket and drop all state. Safe to call multiple times. */\n stop(): MaybePromise<void> {\n const socket = this.socket;\n const shouldClose = this.socketActive;\n this.socket = null;\n this.socketActive = false;\n this.handlers = null;\n this.subscribedTopics.clear();\n // Settle an in-flight handshake gate: a DataBus stop() awaits the start\n // Promise, so leaving it pending would hang teardown. Resolving (rather\n // than rejecting) keeps an intentional stop from surfacing as an error.\n this.settleConnect();\n this.connectPromise = null;\n if (shouldClose) socket?.close();\n }\n\n /** Resolve the in-flight handshake gate. Idempotent: once the socket has\n * opened (or a newer attempt replaced it) later calls are no-ops. */\n private settleConnect(): void {\n this.clearConnectTimer();\n const resolve = this.connectResolve;\n this.connectResolve = null;\n this.connectReject = null;\n resolve?.();\n }\n\n /** Reject the in-flight handshake gate. Idempotent on the same terms as\n * {@link settleConnect}. */\n private failConnect(error: unknown): void {\n this.clearConnectTimer();\n const reject = this.connectReject;\n this.connectResolve = null;\n this.connectReject = null;\n reject?.(error);\n }\n\n /** Best-effort close for a socket that can no longer carry transport data.\n * The error that invalidated it has already been reported by the caller. */\n private abortSocket(socket: WebSocketLike): void {\n try {\n socket.close();\n } catch {\n // Some injected implementations throw when close races a failed\n // handshake. The original failure remains the actionable error.\n }\n }\n\n private clearConnectTimer(): void {\n if (this.connectTimer !== null) {\n clearTimeout(this.connectTimer);\n this.connectTimer = null;\n }\n }\n\n /** Send one JSON frame. Frames are dropped with an `onError` report when\n * the socket is not open. A dropped SUBSCRIBE is recovered \u2014 `onopen`\n * re-asserts every topic in `subscribedTopics` \u2014 but the other two are not:\n * a dropped PUBLISH is the documented at-most-once loss, and a dropped\n * UNSUBSCRIBE is the quieter one, because `unsubscribe()` has already deleted\n * the topic from that set, so no later re-assert resends it and the server\n * keeps streaming a channel the app left. Through the DataBus neither of the\n * two is reachable while closed \u2014 `runTransport()` queues both operations\n * behind recovery \u2014 so this reads as a direct-transport-contract note. */\n private sendFrame(payload: { op: string; topic: string; data?: unknown; messageId?: string; timestamp?: number }): void {\n if (this.socket?.readyState !== WS_OPEN) {\n this.handlers?.onError(new Error(`WebSocket is not open; dropped \"${payload.op}\" frame.`));\n return;\n }\n this.socket.send(JSON.stringify(payload));\n }\n\n private sendBinaryFrame(topic: string, data: ArrayBuffer, messageId?: string, timestamp?: number): void {\n if (messageId !== undefined || timestamp !== undefined) {\n // Binary frames retain their compact legacy shape; metadata is sent as a\n // JSON envelope so IDs are never silently lost.\n this.sendFrame({\n op: WS_OP.PUBLISH,\n topic,\n data: Array.from(new Uint8Array(data)),\n ...(messageId === undefined ? {} : { messageId }),\n ...(timestamp === undefined ? {} : { timestamp })\n });\n return;\n }\n if (this.socket?.readyState !== WS_OPEN) {\n this.handlers?.onError(new Error('WebSocket is not open; dropped \"publish\" frame.'));\n return;\n }\n const topicBytes = new TextEncoder().encode(topic);\n if (topicBytes.length > 0xffff) {\n this.handlers?.onError(new Error('WebSocket topic is too long for a binary frame.'));\n return;\n }\n const frame = new Uint8Array(3 + topicBytes.length + data.byteLength);\n frame[0] = 0xc7;\n new DataView(frame.buffer).setUint16(1, topicBytes.length);\n frame.set(topicBytes, 3);\n frame.set(new Uint8Array(data), 3 + topicBytes.length);\n this.socket.send(frame.buffer);\n }\n\n /** Parse a server frame. Only objects carrying a string `topic` are\n * publications, and unknown shapes are dropped silently so a chatty server\n * cannot crash the message path. Malformed JSON is *not* dropped silently: it\n * goes to `handlers.onError` as \"WebSocket server sent a non-JSON frame.\",\n * which also records it in the bus's last-failure diagnostics. Neither case\n * throws out of the socket listener. */\n private async handleMessage(raw: unknown): Promise<void> {\n // Browser WebSockets may deliver binary frames as Blob unless\n // `binaryType = 'arraybuffer'` is explicitly configured by the host.\n // Normalize Blob asynchronously and reuse the exact ArrayBuffer parser.\n if (typeof Blob !== 'undefined' && raw instanceof Blob) {\n // Blob conversion is asynchronous. Capture the socket/handler pair so a\n // stop/start that replaces the connection while arrayBuffer() is pending\n // cannot let the stale frame leak into the new connection's handlers.\n const socket = this.socket;\n const handlers = this.handlers;\n try {\n const buffer = await raw.arrayBuffer();\n if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;\n await this.handleMessage(buffer);\n } catch (error) {\n if (this.socket === socket && this.handlers === handlers && this.socketActive) {\n this.handlers?.onError(error);\n }\n }\n return;\n }\n let parsed: unknown;\n if (raw instanceof ArrayBuffer) {\n const bytes = new Uint8Array(raw);\n if (bytes[0] !== 0xc7 || bytes.length < 3) return;\n const topicLength = new DataView(raw).getUint16(1);\n if (bytes.length < 3 + topicLength) return;\n const topic = new TextDecoder().decode(bytes.subarray(3, 3 + topicLength));\n const data = bytes.slice(3 + topicLength).buffer;\n this.handlers?.onMessage({ topic, data: data as TData });\n return;\n }\n if (typeof raw !== 'string') return;\n try {\n parsed = JSON.parse(raw);\n } catch {\n this.handlers?.onError(new Error('WebSocket server sent a non-JSON frame.'));\n return;\n }\n if (!parsed || typeof parsed !== 'object') return;\n const publication = parseDataBusPublication<TData>(parsed);\n if (publication) this.handlers?.onMessage(publication as DataBusMessage<TData>);\n }\n}\n\n/** Resolve the platform WebSocket. Throws in a runtime without one (SSR/Node);\n * it has no null arm, and `start()`'s catch is what turns that throw into an\n * `onStatus('error')` + `onError` report. */\nfunction defaultWebSocketFactory(url: string, protocols?: string | string[]): WebSocketLike {\n if (typeof WebSocket === 'undefined') {\n throw new Error('WebSocketTransport requires a WebSocket implementation.');\n }\n return new WebSocket(url, protocols) as unknown as WebSocketLike;\n}\n\n/** Create a CrossTabDataBus backed by a plain WebSocket transport.\n * Cross-tab clustering (owner dedup, sticky routes, failover) works identically\n * to the Centrifuge backend \u2014 only the transport I/O differs. */\nexport function createWebSocketDataBus<TData = unknown>(\n options: CreateWebSocketDataBusOptions<TData>\n): CrossTabDataBus<WebSocketDataBusConfig, TData> {\n const { clusterKey, connection, ...dataBusOptions } = options;\n return new CrossTabDataBus({\n ...dataBusOptions,\n autoStart: true,\n clusterKey: clusterKey ?? connection.url,\n initialConfig: connection,\n transport: new WebSocketTransport<TData>(connection)\n });\n}\n\n/** Re-export for convenience: the status type used by the transport. */\nexport type { WorkerStatus };\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import type { DataBusMessage } from './types';\nimport { createOpaqueKey } from './hash';\nimport { pruneReplayHistory } from './replay-pruning';\nimport { DEFAULT_STORAGE_PREFIX, PRUNE_STRATEGY } from '../utils/constants';\nimport { assertPositiveFiniteNumber, assertPositiveSafeInteger, assertPruneStrategy } from '../utils/validation';\n\n/** Optional persistence backend for replay history. */\nexport interface DataBusReplayPersistence<TData = unknown> {\n load(): Promise<ReadonlyArray<DataBusMessage<TData>>>;\n append(message: DataBusMessage<TData>): Promise<void>;\n /** Optional bulk append used to amortize IndexedDB transaction overhead. */\n appendBatch?(messages: ReadonlyArray<DataBusMessage<TData>>): Promise<void>;\n /** Remove all persisted replay history. */\n clear?(): Promise<void>;\n /** Remove persisted replay history for one exact topic. */\n clearTopic?(topic: string): Promise<void>;\n /** Remove persisted messages older than the given epoch-millisecond cutoff. */\n clearBefore?(timestamp: number): Promise<void>;\n}\n\nexport interface IndexedDbReplayPersistenceOptions {\n /**\n * Namespace this store by the cluster it belongs to, deriving the database\n * name the same way the coordination plane derives its storage keys.\n *\n * Pass the same value you pass the bus. Omitting it keeps the historical\n * behavior \u2014 one database per origin, shared by every cluster \u2014 so this is\n * additive: no existing caller's rows move.\n */\n clusterKey?: string;\n dbName?: string;\n maxPerTopic: number;\n pruneStrategy?: (typeof PRUNE_STRATEGY)[keyof typeof PRUNE_STRATEGY];\n retentionMs?: number;\n}\n\n/**\n * The database name a store uses for a given cluster.\n *\n * `clusterKey` is hashed with the *same* function and the *same* empty-key\n * fallback the coordination plane uses (`clusterKey || '__default__'`), so a\n * store built with `clusterKey: ''` lands in the default cluster's namespace\n * rather than one of its own \u2014 an empty key and the default key are the same\n * cluster everywhere else in the library, and a store is a namespace like any\n * other. Getting that wrong would produce a database no bus ever opens, which\n * reads as silently empty replay rather than as an error.\n *\n * An explicit `dbName` still wins, and it wins over a supplied `clusterKey`:\n * it is the documented escape hatch for an app that wants its own naming, and\n * silently overriding a name the caller typed would be worse than the\n * inconsistency this option exists to remove.\n */\nfunction resolveDbName(options: IndexedDbReplayPersistenceOptions): string {\n if (options.dbName !== undefined) return options.dbName;\n if (options.clusterKey === undefined) return DEFAULT_STORAGE_PREFIX;\n return `${DEFAULT_STORAGE_PREFIX}:${createOpaqueKey(options.clusterKey || '__default__')}`;\n}\n\n/** Create a browser IndexedDB-backed replay store. */\nexport function createIndexedDbReplayPersistence<TData = unknown>(\n options: IndexedDbReplayPersistenceOptions\n): DataBusReplayPersistence<TData> {\n const indexedDb = globalThis.indexedDB;\n if (!indexedDb) throw new Error('IndexedDB is unavailable in this environment.');\n const dbName = resolveDbName(options);\n const storeName = 'replay';\n const maxPerTopic = options.maxPerTopic;\n const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;\n const retentionMs = options.retentionMs;\n assertPruneStrategy(pruneStrategy);\n if (retentionMs !== undefined) assertPositiveFiniteNumber(retentionMs, 'retentionMs');\n assertPositiveSafeInteger(maxPerTopic, 'maxPerTopic');\n let dbPromise: Promise<IDBDatabase> | null = null;\n const invalidate = (db: IDBDatabase): void => {\n if (dbPromise) {\n // The rejection arm is what keeps this total: `invalidate` can chain onto a\n // *replacement* open that is still in flight, and `.then(current => \u2026)`\n // alone would derive a rejected promise nobody observes, turning that\n // open's failure into an unhandled rejection on top of the caller's. No\n // test reaches it \u2014 it needs a scheduled failure racing a rejecting reopen\n // \u2014 and it is not the `open()` catch below, which is registered on the\n // promise it guards and only clears the cache.\n void dbPromise.then(current => {\n if (current === db) {\n current.close();\n dbPromise = null;\n }\n }, () => undefined);\n }\n };\n // IndexedDB transactions are atomic, but a read-modify-write append can\n // still lose updates when callers start several appends concurrently.\n // Serialize all mutations per adapter instance while keeping reads free.\n // Consecutive append/appendBatch entries are coalesced into a single\n // transaction at the head of the queue, so a burst spanning many microtask\n // flushes issues one transaction instead of one per flush. Ordering against\n // clears is preserved: coalescing only merges adjacent batch entries and\n // never reorders them relative to a clear.\n type QueuedMutation =\n | { kind: 'batch'; messages: ReadonlyArray<DataBusMessage<TData>> }\n | { kind: 'run'; run: () => Promise<void> };\n const pending: Array<{\n mutation: QueuedMutation;\n resolve: () => void;\n reject: (error: unknown) => void;\n }> = [];\n let draining = false;\n\n const drain = async (): Promise<void> => {\n if (draining) return;\n draining = true;\n try {\n // Let a burst of synchronous enqueues accumulate into `pending` before\n // the first coalescing pass, so a single flush cycle's batches merge\n // into one transaction instead of two.\n await Promise.resolve();\n while (pending.length > 0) {\n const head = pending[0]!.mutation;\n if (head.kind === 'batch') {\n // Merge every adjacent batch entry into one transaction.\n const merged: DataBusMessage<TData>[] = [];\n const entries: Array<{ resolve: () => void; reject: (error: unknown) => void }> = [];\n while (pending.length > 0) {\n const next = pending[0]!.mutation;\n if (next.kind !== 'batch') break;\n merged.push(...next.messages);\n entries.push({ resolve: pending[0]!.resolve, reject: pending[0]!.reject });\n pending.shift();\n }\n try {\n await appendTransaction(merged);\n for (const entry of entries) entry.resolve();\n } catch (error) {\n for (const entry of entries) entry.reject(error);\n }\n } else {\n const entry = pending.shift()!;\n try {\n await head.run();\n entry.resolve();\n } catch (error) {\n entry.reject(error);\n }\n }\n }\n } finally {\n draining = false;\n }\n };\n\n const enqueue = (mutation: QueuedMutation): Promise<void> =>\n new Promise<void>((resolve, reject) => {\n pending.push({ mutation, resolve, reject });\n void drain();\n });\n\n /** Read-modify-write one topic-batched append inside a single transaction. */\n const appendTransaction = (messages: ReadonlyArray<DataBusMessage<TData>>): Promise<void> =>\n (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try {\n transaction = db.transaction(storeName, 'readwrite');\n } catch (error) {\n invalidate(db);\n reject(error);\n return;\n }\n const store = transaction.objectStore(storeName);\n const grouped = new Map<string, DataBusMessage<TData>[]>();\n for (const message of messages) {\n grouped.set(message.topic, [...(grouped.get(message.topic) ?? []), message]);\n }\n let hasError = false;\n const fail = (error: unknown): void => {\n if (hasError) return;\n hasError = true;\n invalidate(db);\n reject(error);\n };\n for (const [topic, topicMessages] of grouped) {\n const request = store.get(topic);\n request.onsuccess = () => {\n if (hasError) return;\n const history = pruneReplayHistory(\n ((request.result?.messages ?? []) as DataBusMessage<TData>[]).concat(topicMessages),\n { maxPerTopic, pruneStrategy, retentionMs, now: Date.now() }\n );\n store.put({ topic, messages: history });\n };\n request.onerror = () => fail(request.error ?? new Error('Failed to read replay history.'));\n }\n transaction.oncomplete = () => {\n if (!hasError) resolve();\n };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to persist replay history.'));\n // A connection loss can abort a transaction without first dispatching a\n // request error. Without this path, the serialized mutation queue would\n // stay blocked forever after the promise never settles.\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to persist replay history.'));\n });\n })();\n const open = (): Promise<IDBDatabase> => {\n // The two `if (dbPromise \u2026)` guards inside have never had their false arm\n // execute. Reaching either needs a connection that is live but no longer\n // cached, so a versionchange could land on an empty slot, or a slot replaced\n // before its own rejection catch runs \u2014 and both are excluded here because a\n // replacement can only start from a cleared slot, which is what the clearing\n // path itself performs. Defensive, so recorded rather than pinned.\n if (dbPromise) return dbPromise;\n const pending = new Promise<IDBDatabase>((resolve, reject) => {\n const request = indexedDb.open(dbName, 1);\n request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: 'topic' });\n request.onsuccess = () => {\n const db = request.result;\n // A schema upgrade in another tab invalidates this connection. Close\n // it and clear the cached promise so the next operation reopens a\n // usable connection instead of repeatedly targeting a dead database.\n db.onversionchange = () => {\n db.close();\n if (dbPromise) dbPromise = null;\n };\n resolve(db);\n };\n request.onerror = () => reject(request.error ?? new Error('Failed to open replay database.'));\n });\n dbPromise = pending;\n // Do not permanently cache a rejected open promise. IndexedDB can fail\n // transiently (quota, private-mode initialization, a closing connection,\n // or a browser shutdown); the next operation must be able to retry.\n void pending.catch(() => {\n // The fall-through \u2014 a rejection that arrives with the cache already\n // pointing somewhere else \u2014 has zero counts, and no writer fits in its\n // window: this catch is attached by the statement immediately after\n // `dbPromise = pending`, with nothing but comments between them,\n // so it is the first reaction that rejection can run, and nothing can register\n // a reaction on `pending` earlier \u2014 `invalidate()` is reached only with a\n // connection from a *resolved* open, and its chained read takes the\n // `() => undefined` arm on a rejected promise anyway, while `onversionchange`\n // is installed inside `onsuccess`, which an errored request never reaches.\n // Kept, because what dropping the condition costs is asymmetric: an\n // unconditional clear would evict a newer open's cached connection and the\n // next operation would open a second database connection while the first is\n // still usable.\n if (dbPromise === pending) dbPromise = null;\n });\n return pending;\n };\n return {\n async load() {\n const db = await open();\n return new Promise((resolve, reject) => {\n let transaction: IDBTransaction;\n let request: IDBRequest;\n try {\n transaction = db.transaction(storeName, 'readonly');\n request = transaction.objectStore(storeName).getAll();\n } catch (error) {\n invalidate(db);\n reject(error);\n return;\n }\n // One-shot settlement. There are four latch *variables* here and five\n // guarded closures: the four `invalidate`+`reject` ones, plus this block's\n // `oncomplete`, which shares the same latch as its `fail` \u2014 so \"delete the\n // latches\" is a five-statement edit, not a four-statement one. Entering a\n // latch twice is reachable \u2014 one aborting transaction dispatches `error`\n // and then `abort` \u2014 but measured harmless: `reject` on a settled promise\n // is a no-op, and the extra `invalidate` cannot see a replaced cache\n // because both signals are dispatched inside one microtask batch, so their\n // callbacks run before the serialized queue reopens. Deleting all five\n // guards leaves the suite green and the connection count unchanged (that\n // is the measured form, re-run statement by statement rather than by the\n // count in this sentence); they stay because that proof rests on dispatch\n // timing outside this file. The stale-signal case that is inside this file\n // is pinned by tests/replay-persistence.test.ts's\n // 'keeps the connection a later operation reopened when an older signal\n // lands late'.\n let settled = false;\n const fail = (error: unknown): void => {\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n let records: Array<{ messages: DataBusMessage<TData>[] }> = [];\n request.onsuccess = () => {\n records = request.result as Array<{ messages: DataBusMessage<TData>[] }>;\n };\n request.onerror = () => fail(request.error ?? new Error('Failed to load replay history.'));\n transaction.oncomplete = () => {\n if (settled) return;\n settled = true;\n resolve(records.flatMap(record => record.messages));\n };\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to load replay history.'));\n });\n },\n append(message) {\n return enqueue({ kind: 'batch', messages: [message] });\n },\n appendBatch(messages) {\n if (messages.length === 0) return Promise.resolve();\n return enqueue({ kind: 'batch', messages });\n },\n clear() {\n return enqueue({\n kind: 'run',\n run: () => (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try { transaction = db.transaction(storeName, 'readwrite'); }\n catch (error) { invalidate(db); reject(error); return; }\n let settled = false;\n const fail = (error: unknown): void => {\n // One of the four `invalidate`+`reject` latches whose zero-count arm is\n // enumerated in `load()`'s closure above.\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n transaction.objectStore(storeName).clear();\n transaction.oncomplete = () => { settled = true; resolve(); };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to clear replay history.'));\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to clear replay history.'));\n });\n })()\n });\n },\n clearTopic(topic) {\n return enqueue({\n kind: 'run',\n run: () => (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try { transaction = db.transaction(storeName, 'readwrite'); }\n catch (error) { invalidate(db); reject(error); return; }\n let settled = false;\n const fail = (error: unknown): void => {\n // One of the four `invalidate`+`reject` latches whose zero-count arm is\n // enumerated in `load()`'s closure above.\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n transaction.objectStore(storeName).delete(topic);\n transaction.oncomplete = () => { settled = true; resolve(); };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to clear topic replay history.'));\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to clear topic replay history.'));\n });\n })()\n });\n },\n clearBefore(timestamp) {\n return enqueue({\n kind: 'run',\n run: () => (async () => {\n const db = await open();\n return new Promise<void>((resolve, reject) => {\n let transaction: IDBTransaction;\n try { transaction = db.transaction(storeName, 'readwrite'); }\n catch (error) { invalidate(db); reject(error); return; }\n let settled = false;\n const fail = (error: unknown): void => {\n // One of the four `invalidate`+`reject` latches whose zero-count arm is\n // enumerated in `load()`'s closure above.\n if (settled) return;\n settled = true;\n invalidate(db);\n reject(error);\n };\n const store = transaction.objectStore(storeName);\n const request = store.getAll();\n request.onsuccess = () => {\n for (const record of request.result as Array<{ topic: string; messages: DataBusMessage<TData>[] }>) {\n const messages = record.messages.filter(message => message.timestamp === undefined || message.timestamp >= timestamp);\n if (messages.length === 0) store.delete(record.topic);\n else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });\n }\n };\n request.onerror = () => fail(request.error ?? new Error('Failed to read replay history.'));\n transaction.oncomplete = () => { settled = true; resolve(); };\n transaction.onerror = () => fail(transaction.error ?? new Error('Failed to prune replay history.'));\n transaction.onabort = () => fail(transaction.error ?? new Error('Failed to prune replay history.'));\n });\n })()\n });\n }\n };\n}\n", "/**\n * WebSocketTransport \u2014 a dependency-free transport over a plain WebSocket.\n *\n * Validates the `DataBusTransport` abstraction with a second, minimal backend:\n * any WebSocket server that speaks the tiny JSON protocol below can back the\n * same cross-tab clustering stack (owner dedup, sticky routes, EVENT fan-out)\n * that the Centrifuge backend uses.\n *\n * Wire protocol \u2014 two frame formats, both over text/binary WebSocket messages:\n * - client \u2192 server: `{\"op\":\"subscribe\"|\"unsubscribe\"|\"publish\",\"topic\":...,\"data\":...}`\n * - client \u2192 server (batched): `{\"op\":\"publishBatch\",\"topic\":...,\"items\":[{data,...}]}`\n * - server \u2192 client: `{\"topic\":...,\"data\":...}` for publications; anything\n * without a string `topic` field is ignored (forward-compatible).\n * - binary publications use a *separate* tagged frame, not JSON:\n * `0xc7 | uint16 topicLength | topic UTF-8 | raw payload`, written by\n * `sendBinaryFrame` when `publish()` receives an `ArrayBuffer` with no\n * `messageId`/`timestamp` (with metadata it degrades to a JSON PUBLISH whose\n * `data` is a number array, so an id is never lost to the compact shape), and\n * parsed by the matching reader. A server that implements only the JSON arm\n * above silently drops those \u2014 see `scripts/demo-ws-server.mjs`, which\n * implements both directions.\n */\nimport { CrossTabDataBus } from './core/data-bus';\nimport { parseDataBusPublication } from './core/publication';\nimport type { CrossTabDataBusOptions } from './core/data-bus';\nimport { WS_OP, WORKER_STATUS } from './utils/constants';\nimport type {\n DataBusTransport,\n DataBusTransportHandlers,\n DataBusPublishOptions,\n DataBusPublicationItem,\n DataBusMessage,\n MaybePromise,\n WorkerStatus\n} from './core/types';\n\n/** Minimal WebSocket surface used by the transport. Matches the browser\n * `WebSocket` subset the transport touches; injectable for tests and runtimes. */\nexport interface WebSocketLike {\n /** Current connection state; 1 (OPEN) means frames may be sent. */\n readonly readyState?: number;\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n onopen: (() => void) | null;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: { data: unknown }) => void) | null;\n}\n\n/** Connection configuration for {@link WebSocketTransport}. */\nexport interface WebSocketDataBusConfig {\n /** WebSocket endpoint, e.g. `wss://example.test/ws`. */\n url: string;\n /** Subprotocol(s) passed to the WebSocket handshake. */\n protocols?: string | string[];\n /** Custom socket factory. Defaults to the global `WebSocket`; injectable\n * for tests and non-browser runtimes. */\n webSocketFactory?: (url: string, protocols?: string | string[]) => WebSocketLike;\n /** Milliseconds to wait for the handshake before reporting `error` and\n * failing the start. Defaults to 30000 ms; pass\n * `0` or `Infinity` to wait indefinitely. The timeout exists because\n * `start()` resolves on connect, so a socket that never opens and never\n * errors would otherwise leave the DataBus start gate (and every operation\n * queued behind it) pending forever. */\n connectTimeoutMs?: number;\n}\n\n/** Options for creating a fully-configured CrossTabDataBus with a WebSocket transport. */\nexport interface CreateWebSocketDataBusOptions<TData = unknown>\n extends Omit<\n CrossTabDataBusOptions<WebSocketDataBusConfig, TData>,\n 'autoStart' | 'clusterKey' | 'initialConfig' | 'transport'\n > {\n /** WebSocket connection configuration. */\n connection: WebSocketDataBusConfig;\n /** Cluster key for cross-tab coordination. Defaults to the connection URL. */\n clusterKey?: string;\n}\n\n/** Default handshake budget. A socket that never opens and never fires\n * error/close would otherwise keep a started transport stuck in `connecting`\n * forever, with every queued operation parked behind an unsettled `start()`. */\nconst DEFAULT_CONNECT_TIMEOUT_MS = 30_000;\n\nconst WS_OPEN = 1;\n\n/** Transport that talks a minimal JSON protocol over a plain WebSocket.\n * Connection lifecycle maps directly to the DataBus status vocabulary:\n * open \u2192 `connected`, close \u2192 `disconnected`, error \u2192 `error` (which the\n * DataBus treats as its auto-recovery trigger). The transport holds no\n * reconnection logic of its own \u2014 reopening is the DataBus's job. */\nexport class WebSocketTransport<TData = unknown>\n implements DataBusTransport<WebSocketDataBusConfig, TData>\n{\n readonly diagnosticsName = 'websocket';\n readonly diagnosticsBackend = 'native-websocket';\n private socket: WebSocketLike | null = null;\n private socketActive = false;\n private handlers: DataBusTransportHandlers<TData> | null = null;\n private readonly subscribedTopics = new Set<string>();\n // Handshake gate for the current start(). Resolves once the socket opens,\n // rejects when the attempt fails, so the DataBus start Promise \u2014 and every\n // operation parked behind it \u2014 settles at the real connection boundary.\n private connectPromise: Promise<void> | null = null;\n private connectResolve: (() => void) | null = null;\n private connectReject: ((error: unknown) => void) | null = null;\n private connectTimer: ReturnType<typeof setTimeout> | null = null;\n\n constructor(private readonly connection: WebSocketDataBusConfig) {}\n\n /** Open the WebSocket and wire lifecycle listeners. Resolves once the\n * handshake completes and rejects when the attempt fails, matching the\n * `DataBusTransport.start` contract (\"resolves on connect or rejects on\n * failure\"). A factory failure is reported through `onStatus('error')` so\n * the DataBus can recover. */\n start(config: WebSocketDataBusConfig, handlers: DataBusTransportHandlers<TData>): MaybePromise<void> {\n if (this.socket && this.socketActive) {\n // Reuse the live socket instead of orphaning it. While the first\n // attempt is still connecting, share its handshake gate so a duplicate\n // start() cannot report readiness before the socket is usable.\n // The `?? undefined` side of this has zero counts, and it is a type\n // conversion rather than a behavior branch: `MaybePromise<void>` admits\n // `void | Promise<void>` and not `null`, so writing `return this.connectPromise`\n // is TS2322, not a passing edit (measured). The state it would have to report\n // \u2014 a live socket with no gate \u2014 is excluded by the field's whole write set:\n // `connectPromise` is assigned in exactly two statements, `start()`'s\n // `= opening` immediately after the promise executor that sets `socket` and\n // `socketActive`, and `stop()`'s `= null` in the same frame as the two clears\n // that un-satisfy the test above. Neither pair yields to anything between its\n // members, so no observer \u2014 including a re-entrant `start()` \u2014 can see the\n // premise standing while the gate is gone. The statement order inside each\n // frame is not what carries this; any reorder within a frame is equally\n // unobservable, and the `settleConnect()`/`failConnect()` pair that does reach\n // across frames never touches the field.\n return this.connectPromise ?? undefined;\n }\n // A failed or closed socket is one-shot; retain its object only long\n // enough for a transparent same-object reopen to fire, but replace it\n // whenever start() is called again. Clearing the reference here also\n // makes every late callback from the old socket a no-op.\n this.socket = null;\n this.socketActive = false;\n this.handlers = handlers;\n // The factory may live on the constructor connection (createWebSocketDataBus\n // path) or on the runtime config (direct transport use) \u2014 accept both.\n const factory = config.webSocketFactory ?? this.connection.webSocketFactory ?? defaultWebSocketFactory;\n const protocols = config.protocols ?? this.connection.protocols;\n let socket: WebSocketLike;\n try {\n socket = factory(config.url, protocols);\n } catch (error) {\n handlers.onStatus(WORKER_STATUS.ERROR);\n handlers.onError(error);\n return;\n }\n const opening = new Promise<void>((resolve, reject) => {\n this.connectResolve = resolve;\n this.connectReject = reject;\n // Per-attempt handshake state. A socket that opens, then closes and\n // re-opens in place (a protocol-level recovery) may reuse the same\n // attempt; a timeout or a close/error before the first open permanently\n // invalidates it so a late onopen cannot report readiness.\n let handshakeCompleted = false;\n let handshakeFailed = false;\n const timeoutMs =\n config.connectTimeoutMs ?? this.connection.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;\n if (Number.isFinite(timeoutMs) && timeoutMs > 0) {\n this.connectTimer = setTimeout(() => {\n // This early return has never executed in any run of the suite, and the\n // reason is the same for all three terms: no attempt can leave a timer\n // armed behind it. Term 3 (`handshakeCompleted`) because `onopen` cancels\n // here directly and again through `settleConnect()`; terms 1 and 2 because\n // `this.socket` and `this.handlers` are reassigned only in `start()` and\n // `stop()`, reaching either assignment in `start()` needs `socketActive`\n // already false, and every path that lowers `socketActive` clears the\n // armed timer first \u2014 `onclose` and `onerror` through `failConnect()`,\n // `stop()` through `settleConnect()`, and this callback by nulling the\n // handle as it fires. So the guard cannot distinguish anything, which is\n // what its coverage arm records.\n //\n // It stays as the transport's statement that a superseded attempt must\n // never act, because the domination is a property of those cancels rather\n // than of this condition, and the asymmetry is measured: deleting only the\n // `handshakeCompleted` term leaves the suite green, deleting only `failConnect()`'s cancel lets a dead attempt's\n // timeout report a second failure for a connection the application was\n // already told had failed, and the first pin's own comment records the\n // third case \u2014 deleting the term *and* both cancels makes a healthy socket\n // report a phantom `onStatus('error')` and get aborted. The two pins name\n // the legs they hold: tests/websocket.test.ts's 'never lets the connect\n // timer tear down a handshake that already completed', and 'leaves no\n // connect timer armed when the handshake fails on a close'.\n if (this.socket !== socket || this.handlers !== handlers || handshakeCompleted) return;\n handshakeFailed = true;\n this.connectTimer = null;\n this.socketActive = false;\n const error = new Error(`WebSocket did not open within ${timeoutMs}ms.`);\n handlers.onStatus(WORKER_STATUS.ERROR);\n handlers.onError(error);\n this.failConnect(error);\n // Abort the half-open handshake so the timed-out attempt cannot\n // linger in CONNECTING or deliver a late onopen.\n this.abortSocket(socket);\n }, timeoutMs);\n }\n socket.onopen = () => {\n if (this.socket !== socket || this.handlers !== handlers || handshakeFailed) return;\n this.socketActive = true;\n this.clearConnectTimer();\n // Re-assert every topic so a reopened socket (recovery path) restores\n // the server-side subscriptions without DataBus involvement.\n for (const topic of this.subscribedTopics) {\n this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });\n }\n handlers.onStatus(WORKER_STATUS.CONNECTED);\n if (!handshakeCompleted) {\n handshakeCompleted = true;\n this.settleConnect();\n }\n };\n // Each guard below ends in an operand that the first two cannot see: a socket\n // that died without being replaced. `stop()` clears `this.socket`, so a\n // teardown is caught by the first operand anywhere; a timeout, a close and an\n // error each lower `socketActive` while leaving `this.socket` naming the same\n // object, so for anything that socket delivers afterwards only that last\n // operand can decide. Measured one operand at a time against the whole suite:\n // `onclose`'s was already pinned (three lifecycle cases), while `onerror`'s,\n // `onmessage`'s and `handleMessage`'s Blob-path one each killed nothing until\n // tests/websocket.test.ts's 'drops a late error from the socket whose connect\n // timeout already reported one', 'drops a server frame that arrives after the\n // connect attempt timed out' and 'drops a Blob frame whose conversion finishes\n // after its connection closed'. `onopen`'s third term is a different flag,\n // `handshakeFailed`, set by the timeout and by the close/error paths below\n // rather than read from the field.\n socket.onclose = () => {\n if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;\n this.socketActive = false;\n handlers.onStatus(WORKER_STATUS.DISCONNECTED);\n if (!handshakeCompleted) {\n handshakeFailed = true;\n this.failConnect(new Error('WebSocket closed before the handshake completed.'));\n }\n };\n socket.onerror = () => {\n if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;\n this.socketActive = false;\n handlers.onStatus(WORKER_STATUS.ERROR);\n if (!handshakeCompleted) {\n handshakeFailed = true;\n this.failConnect(new Error('WebSocket failed to open.'));\n }\n // `error` is not guaranteed to be followed by `close` (especially for\n // injected WebSocket implementations). Abort here so DataBus recovery\n // cannot orphan a socket that this transport has already marked dead.\n this.abortSocket(socket);\n };\n socket.onmessage = event => {\n if (this.socket === socket && this.handlers === handlers && this.socketActive) {\n void this.handleMessage(event.data);\n }\n };\n this.socket = socket;\n this.socketActive = true;\n });\n this.connectPromise = opening;\n return opening;\n }\n\n /** Idempotent: re-subscribing an active topic re-sends the frame but does\n * not duplicate the local tracking entry. */\n subscribe(topic: string): MaybePromise<void> {\n this.subscribedTopics.add(topic);\n this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });\n }\n\n /** Idempotent for local tracking: an unknown topic has nothing to delete, but\n * the UNSUBSCRIBE frame is still sent, exactly as SUBSCRIBE is re-sent above. */\n unsubscribe(topic: string): MaybePromise<void> {\n this.subscribedTopics.delete(topic);\n this.sendFrame({ op: WS_OP.UNSUBSCRIBE, topic });\n }\n\n /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */\n publish(topic: string, data: unknown, options?: DataBusPublishOptions): MaybePromise<void> {\n if (data instanceof ArrayBuffer) {\n this.sendBinaryFrame(topic, data, options?.messageId, options?.timestamp);\n return;\n }\n this.sendFrame({\n op: WS_OP.PUBLISH,\n topic,\n data,\n ...(options?.messageId === undefined ? {} : { messageId: options.messageId }),\n ...(options?.timestamp === undefined ? {} : { timestamp: options.timestamp })\n });\n }\n\n /** Publish many items for one topic as a single wire frame. One-item\n * batches delegate to `publish` so the legacy single-publication frame\n * shape (including binary framing) is preserved. */\n publishBatch(topic: string, items: ReadonlyArray<DataBusPublicationItem>): MaybePromise<void> {\n if (items.length === 0) return;\n if (items.length === 1) {\n const single = items[0]!;\n return this.publish(topic, single.data, {\n ...(single.messageId === undefined ? {} : { messageId: single.messageId }),\n ...(single.timestamp === undefined ? {} : { timestamp: single.timestamp })\n });\n }\n if (this.socket?.readyState !== WS_OPEN) {\n this.handlers?.onError(new Error('WebSocket is not open; dropped \"publishBatch\" frame.'));\n return;\n }\n // Binary payloads are embedded as byte arrays so the whole batch stays in\n // one JSON frame; the server re-fans them out as individual publications.\n this.socket.send(JSON.stringify({\n op: WS_OP.PUBLISH_BATCH,\n topic,\n items: items.map(item => ({\n data: item.data instanceof ArrayBuffer ? Array.from(new Uint8Array(item.data)) : item.data,\n ...(item.messageId === undefined ? {} : { messageId: item.messageId }),\n ...(item.timestamp === undefined ? {} : { timestamp: item.timestamp })\n }))\n }));\n }\n\n /** Close the socket and drop all state. Safe to call multiple times. */\n stop(): MaybePromise<void> {\n const socket = this.socket;\n const shouldClose = this.socketActive;\n this.socket = null;\n this.socketActive = false;\n this.handlers = null;\n this.subscribedTopics.clear();\n // Settle an in-flight handshake gate: a DataBus stop() awaits the start\n // Promise, so leaving it pending would hang teardown. Resolving (rather\n // than rejecting) keeps an intentional stop from surfacing as an error.\n this.settleConnect();\n this.connectPromise = null;\n if (shouldClose) socket?.close();\n }\n\n /** Resolve the in-flight handshake gate. Idempotent: once the socket has\n * opened (or a newer attempt replaced it) later calls are no-ops. */\n private settleConnect(): void {\n this.clearConnectTimer();\n const resolve = this.connectResolve;\n this.connectResolve = null;\n this.connectReject = null;\n resolve?.();\n }\n\n /** Reject the in-flight handshake gate. Idempotent on the same terms as\n * {@link settleConnect}. */\n private failConnect(error: unknown): void {\n this.clearConnectTimer();\n const reject = this.connectReject;\n this.connectResolve = null;\n this.connectReject = null;\n reject?.(error);\n }\n\n /** Best-effort close for a socket that can no longer carry transport data.\n * The error that invalidated it has already been reported by the caller. */\n private abortSocket(socket: WebSocketLike): void {\n try {\n socket.close();\n } catch {\n // Some injected implementations throw when close races a failed\n // handshake. The original failure remains the actionable error.\n }\n }\n\n private clearConnectTimer(): void {\n if (this.connectTimer !== null) {\n clearTimeout(this.connectTimer);\n this.connectTimer = null;\n }\n }\n\n /** Send one JSON frame. Frames are dropped with an `onError` report when\n * the socket is not open. A dropped SUBSCRIBE is recovered \u2014 `onopen`\n * re-asserts every topic in `subscribedTopics` \u2014 but the other two are not:\n * a dropped PUBLISH is the documented at-most-once loss, and a dropped\n * UNSUBSCRIBE is the quieter one, because `unsubscribe()` has already deleted\n * the topic from that set, so no later re-assert resends it and the server\n * keeps streaming a channel the app left. Through the DataBus neither of the\n * two is reachable while closed \u2014 `runTransport()` queues both operations\n * behind recovery \u2014 so this reads as a direct-transport-contract note. */\n private sendFrame(payload: { op: string; topic: string; data?: unknown; messageId?: string; timestamp?: number }): void {\n if (this.socket?.readyState !== WS_OPEN) {\n this.handlers?.onError(new Error(`WebSocket is not open; dropped \"${payload.op}\" frame.`));\n return;\n }\n this.socket.send(JSON.stringify(payload));\n }\n\n private sendBinaryFrame(topic: string, data: ArrayBuffer, messageId?: string, timestamp?: number): void {\n if (messageId !== undefined || timestamp !== undefined) {\n // Binary frames retain their compact legacy shape; metadata is sent as a\n // JSON envelope so IDs are never silently lost.\n this.sendFrame({\n op: WS_OP.PUBLISH,\n topic,\n data: Array.from(new Uint8Array(data)),\n ...(messageId === undefined ? {} : { messageId }),\n ...(timestamp === undefined ? {} : { timestamp })\n });\n return;\n }\n if (this.socket?.readyState !== WS_OPEN) {\n this.handlers?.onError(new Error('WebSocket is not open; dropped \"publish\" frame.'));\n return;\n }\n const topicBytes = new TextEncoder().encode(topic);\n if (topicBytes.length > 0xffff) {\n this.handlers?.onError(new Error('WebSocket topic is too long for a binary frame.'));\n return;\n }\n const frame = new Uint8Array(3 + topicBytes.length + data.byteLength);\n frame[0] = 0xc7;\n new DataView(frame.buffer).setUint16(1, topicBytes.length);\n frame.set(topicBytes, 3);\n frame.set(new Uint8Array(data), 3 + topicBytes.length);\n this.socket.send(frame.buffer);\n }\n\n /** Parse a server frame. Only objects carrying a string `topic` are\n * publications, and unknown shapes are dropped silently so a chatty server\n * cannot crash the message path. Malformed JSON is *not* dropped silently: it\n * goes to `handlers.onError` as \"WebSocket server sent a non-JSON frame.\",\n * which also records it in the bus's last-failure diagnostics. Neither case\n * throws out of the socket listener. */\n private async handleMessage(raw: unknown): Promise<void> {\n // Browser WebSockets may deliver binary frames as Blob unless\n // `binaryType = 'arraybuffer'` is explicitly configured by the host.\n // Normalize Blob asynchronously and reuse the exact ArrayBuffer parser.\n if (typeof Blob !== 'undefined' && raw instanceof Blob) {\n // Blob conversion is asynchronous. Capture the socket/handler pair so a\n // stop/start that replaces the connection while arrayBuffer() is pending\n // cannot let the stale frame leak into the new connection's handlers.\n const socket = this.socket;\n const handlers = this.handlers;\n try {\n const buffer = await raw.arrayBuffer();\n if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;\n await this.handleMessage(buffer);\n } catch (error) {\n if (this.socket === socket && this.handlers === handlers && this.socketActive) {\n this.handlers?.onError(error);\n }\n }\n return;\n }\n let parsed: unknown;\n if (raw instanceof ArrayBuffer) {\n const bytes = new Uint8Array(raw);\n if (bytes[0] !== 0xc7 || bytes.length < 3) return;\n const topicLength = new DataView(raw).getUint16(1);\n if (bytes.length < 3 + topicLength) return;\n const topic = new TextDecoder().decode(bytes.subarray(3, 3 + topicLength));\n const data = bytes.slice(3 + topicLength).buffer;\n this.handlers?.onMessage({ topic, data: data as TData });\n return;\n }\n if (typeof raw !== 'string') return;\n try {\n parsed = JSON.parse(raw);\n } catch {\n this.handlers?.onError(new Error('WebSocket server sent a non-JSON frame.'));\n return;\n }\n if (!parsed || typeof parsed !== 'object') return;\n const publication = parseDataBusPublication<TData>(parsed);\n if (publication) this.handlers?.onMessage(publication as DataBusMessage<TData>);\n }\n}\n\n/** Resolve the platform WebSocket. Throws in a runtime without one (SSR/Node);\n * it has no null arm, and `start()`'s catch is what turns that throw into an\n * `onStatus('error')` + `onError` report. */\nfunction defaultWebSocketFactory(url: string, protocols?: string | string[]): WebSocketLike {\n if (typeof WebSocket === 'undefined') {\n throw new Error('WebSocketTransport requires a WebSocket implementation.');\n }\n return new WebSocket(url, protocols) as unknown as WebSocketLike;\n}\n\n/** Create a CrossTabDataBus backed by a plain WebSocket transport.\n * Cross-tab clustering (owner dedup, sticky routes, failover) works identically\n * to the Centrifuge backend \u2014 only the transport I/O differs. */\nexport function createWebSocketDataBus<TData = unknown>(\n options: CreateWebSocketDataBusOptions<TData>\n): CrossTabDataBus<WebSocketDataBusConfig, TData> {\n const { clusterKey, connection, ...dataBusOptions } = options;\n return new CrossTabDataBus({\n ...dataBusOptions,\n autoStart: true,\n clusterKey: clusterKey ?? connection.url,\n initialConfig: connection,\n transport: new WebSocketTransport<TData>(connection)\n });\n}\n\n/** Re-export for convenience: the status type used by the transport. */\nexport type { WorkerStatus };\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoDA,SAAS,cAAc,SAAoD;AACzE,MAAI,QAAQ,WAAW,OAAW,QAAO,QAAQ;AACjD,MAAI,QAAQ,eAAe,OAAW,QAAO;AAC7C,SAAO,GAAG,sBAAsB,IAAI,gBAAgB,QAAQ,cAAc,aAAa,CAAC;AAC1F;AAGO,SAAS,iCACd,SACiC;AACjC,QAAM,YAAY,WAAW;AAC7B,MAAI,CAAC,UAAW,OAAM,IAAI,MAAM,+CAA+C;AAC/E,QAAM,SAAS,cAAc,OAAO;AACpC,QAAM,YAAY;AAClB,QAAM,cAAc,QAAQ;AAC5B,QAAM,gBAAgB,QAAQ,iBAAiB,eAAe;AAC9D,QAAM,cAAc,QAAQ;AAC5B,sBAAoB,aAAa;AACjC,MAAI,gBAAgB,OAAW,4BAA2B,aAAa,aAAa;AACpF,4BAA0B,aAAa,aAAa;AACpD,MAAI,YAAyC;AAC7C,QAAM,aAAa,CAAC,OAA0B;AAC5C,QAAI,WAAW;AAQb,WAAK,UAAU,KAAK,aAAW;AAC7B,YAAI,YAAY,IAAI;AAClB,kBAAQ,MAAM;AACd,sBAAY;AAAA,QACd;AAAA,MACF,GAAG,MAAM,MAAS;AAAA,IACpB;AAAA,EACF;AAYA,QAAM,UAID,CAAC;AACN,MAAI,WAAW;AAEf,QAAM,QAAQ,YAA2B;AACvC,QAAI,SAAU;AACd,eAAW;AACX,QAAI;AAIF,YAAM,QAAQ,QAAQ;AACtB,aAAO,QAAQ,SAAS,GAAG;AACzB,cAAM,OAAO,QAAQ,CAAC,EAAG;AACzB,YAAI,KAAK,SAAS,SAAS;AAEzB,gBAAM,SAAkC,CAAC;AACzC,gBAAM,UAA4E,CAAC;AACnF,iBAAO,QAAQ,SAAS,GAAG;AACzB,kBAAM,OAAO,QAAQ,CAAC,EAAG;AACzB,gBAAI,KAAK,SAAS,QAAS;AAC3B,mBAAO,KAAK,GAAG,KAAK,QAAQ;AAC5B,oBAAQ,KAAK,EAAE,SAAS,QAAQ,CAAC,EAAG,SAAS,QAAQ,QAAQ,CAAC,EAAG,OAAO,CAAC;AACzE,oBAAQ,MAAM;AAAA,UAChB;AACA,cAAI;AACF,kBAAM,kBAAkB,MAAM;AAC9B,uBAAW,SAAS,QAAS,OAAM,QAAQ;AAAA,UAC7C,SAAS,OAAO;AACd,uBAAW,SAAS,QAAS,OAAM,OAAO,KAAK;AAAA,UACjD;AAAA,QACF,OAAO;AACL,gBAAM,QAAQ,QAAQ,MAAM;AAC5B,cAAI;AACF,kBAAM,KAAK,IAAI;AACf,kBAAM,QAAQ;AAAA,UAChB,SAAS,OAAO;AACd,kBAAM,OAAO,KAAK;AAAA,UACpB;AAAA,QACF;AAAA,MACF;AAAA,IACF,UAAE;AACA,iBAAW;AAAA,IACb;AAAA,EACF;AAEA,QAAM,UAAU,CAAC,aACf,IAAI,QAAc,CAAC,SAAS,WAAW;AACrC,YAAQ,KAAK,EAAE,UAAU,SAAS,OAAO,CAAC;AAC1C,SAAK,MAAM;AAAA,EACb,CAAC;AAGH,QAAM,oBAAoB,CAAC,cACxB,YAAY;AACX,UAAM,KAAK,MAAM,KAAK;AACtB,WAAO,IAAI,QAAc,CAAC,SAAS,WAAW;AAC5C,UAAI;AACJ,UAAI;AACF,sBAAc,GAAG,YAAY,WAAW,WAAW;AAAA,MACrD,SAAS,OAAO;AACd,mBAAW,EAAE;AACb,eAAO,KAAK;AACZ;AAAA,MACF;AACA,YAAM,QAAQ,YAAY,YAAY,SAAS;AAC/C,YAAM,UAAU,oBAAI,IAAqC;AACzD,iBAAW,WAAW,UAAU;AAC9B,gBAAQ,IAAI,QAAQ,OAAO,CAAC,GAAI,QAAQ,IAAI,QAAQ,KAAK,KAAK,CAAC,GAAI,OAAO,CAAC;AAAA,MAC7E;AACA,UAAI,WAAW;AACf,YAAM,OAAO,CAAC,UAAyB;AACrC,YAAI,SAAU;AACd,mBAAW;AACX,mBAAW,EAAE;AACb,eAAO,KAAK;AAAA,MACd;AACA,iBAAW,CAAC,OAAO,aAAa,KAAK,SAAS;AAC5C,cAAM,UAAU,MAAM,IAAI,KAAK;AAC/B,gBAAQ,YAAY,MAAM;AACxB,cAAI,SAAU;AACd,gBAAM,UAAU;AAAA,aACZ,QAAQ,QAAQ,YAAY,CAAC,GAA+B,OAAO,aAAa;AAAA,YAClF,EAAE,aAAa,eAAe,aAAa,KAAK,KAAK,IAAI,EAAE;AAAA,UAC7D;AACA,gBAAM,IAAI,EAAE,OAAO,UAAU,QAAQ,CAAC;AAAA,QACxC;AACA,gBAAQ,UAAU,MAAM,KAAK,QAAQ,SAAS,IAAI,MAAM,gCAAgC,CAAC;AAAA,MAC3F;AACA,kBAAY,aAAa,MAAM;AAC7B,YAAI,CAAC,SAAU,SAAQ;AAAA,MACzB;AACA,kBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,mCAAmC,CAAC;AAIpG,kBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,mCAAmC,CAAC;AAAA,IACtG,CAAC;AAAA,EACH,GAAG;AACL,QAAM,OAAO,MAA4B;AAOvC,QAAI,UAAW,QAAO;AACtB,UAAMA,WAAU,IAAI,QAAqB,CAAC,SAAS,WAAW;AAC5D,YAAM,UAAU,UAAU,KAAK,QAAQ,CAAC;AACxC,cAAQ,kBAAkB,MAAM,QAAQ,OAAO,kBAAkB,WAAW,EAAE,SAAS,QAAQ,CAAC;AAChG,cAAQ,YAAY,MAAM;AACxB,cAAM,KAAK,QAAQ;AAInB,WAAG,kBAAkB,MAAM;AACzB,aAAG,MAAM;AACT,cAAI,UAAW,aAAY;AAAA,QAC7B;AACA,gBAAQ,EAAE;AAAA,MACZ;AACA,cAAQ,UAAU,MAAM,OAAO,QAAQ,SAAS,IAAI,MAAM,iCAAiC,CAAC;AAAA,IAC9F,CAAC;AACD,gBAAYA;AAIZ,SAAKA,SAAQ,MAAM,MAAM;AAcvB,UAAI,cAAcA,SAAS,aAAY;AAAA,IACzC,CAAC;AACD,WAAOA;AAAA,EACT;AACA,SAAO;AAAA,IACL,MAAM,OAAO;AACX,YAAM,KAAK,MAAM,KAAK;AACtB,aAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,YAAI;AACJ,YAAI;AACJ,YAAI;AACF,wBAAc,GAAG,YAAY,WAAW,UAAU;AAClD,oBAAU,YAAY,YAAY,SAAS,EAAE,OAAO;AAAA,QACtD,SAAS,OAAO;AACd,qBAAW,EAAE;AACb,iBAAO,KAAK;AACZ;AAAA,QACF;AAiBA,YAAI,UAAU;AACd,cAAM,OAAO,CAAC,UAAyB;AACrC,cAAI,QAAS;AACb,oBAAU;AACV,qBAAW,EAAE;AACb,iBAAO,KAAK;AAAA,QACd;AACA,YAAI,UAAwD,CAAC;AAC7D,gBAAQ,YAAY,MAAM;AACxB,oBAAU,QAAQ;AAAA,QACpB;AACA,gBAAQ,UAAU,MAAM,KAAK,QAAQ,SAAS,IAAI,MAAM,gCAAgC,CAAC;AACzF,oBAAY,aAAa,MAAM;AAC7B,cAAI,QAAS;AACb,oBAAU;AACV,kBAAQ,QAAQ,QAAQ,YAAU,OAAO,QAAQ,CAAC;AAAA,QACpD;AACA,oBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,gCAAgC,CAAC;AAAA,MACnG,CAAC;AAAA,IACH;AAAA,IACA,OAAO,SAAS;AACd,aAAO,QAAQ,EAAE,MAAM,SAAS,UAAU,CAAC,OAAO,EAAE,CAAC;AAAA,IACvD;AAAA,IACA,YAAY,UAAU;AACpB,UAAI,SAAS,WAAW,EAAG,QAAO,QAAQ,QAAQ;AAClD,aAAO,QAAQ,EAAE,MAAM,SAAS,SAAS,CAAC;AAAA,IAC5C;AAAA,IACA,QAAQ;AACN,aAAO,QAAQ;AAAA,QACb,MAAM;AAAA,QACN,KAAK,OAAO,YAAY;AACxB,gBAAM,KAAK,MAAM,KAAK;AACtB,iBAAO,IAAI,QAAc,CAAC,SAAS,WAAW;AAC9C,gBAAI;AACJ,gBAAI;AAAE,4BAAc,GAAG,YAAY,WAAW,WAAW;AAAA,YAAG,SACrD,OAAO;AAAE,yBAAW,EAAE;AAAG,qBAAO,KAAK;AAAG;AAAA,YAAQ;AACvD,gBAAI,UAAU;AACd,kBAAM,OAAO,CAAC,UAAyB;AAGrC,kBAAI,QAAS;AACb,wBAAU;AACV,yBAAW,EAAE;AACb,qBAAO,KAAK;AAAA,YACd;AACA,wBAAY,YAAY,SAAS,EAAE,MAAM;AACzC,wBAAY,aAAa,MAAM;AAAE,wBAAU;AAAM,sBAAQ;AAAA,YAAG;AAC5D,wBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,iCAAiC,CAAC;AAClG,wBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,iCAAiC,CAAC;AAAA,UAClG,CAAC;AAAA,QACD,GAAG;AAAA,MACL,CAAC;AAAA,IACH;AAAA,IACA,WAAW,OAAO;AAChB,aAAO,QAAQ;AAAA,QACb,MAAM;AAAA,QACN,KAAK,OAAO,YAAY;AACxB,gBAAM,KAAK,MAAM,KAAK;AACtB,iBAAO,IAAI,QAAc,CAAC,SAAS,WAAW;AAC9C,gBAAI;AACJ,gBAAI;AAAE,4BAAc,GAAG,YAAY,WAAW,WAAW;AAAA,YAAG,SACrD,OAAO;AAAE,yBAAW,EAAE;AAAG,qBAAO,KAAK;AAAG;AAAA,YAAQ;AACvD,gBAAI,UAAU;AACd,kBAAM,OAAO,CAAC,UAAyB;AAGrC,kBAAI,QAAS;AACb,wBAAU;AACV,yBAAW,EAAE;AACb,qBAAO,KAAK;AAAA,YACd;AACA,wBAAY,YAAY,SAAS,EAAE,OAAO,KAAK;AAC/C,wBAAY,aAAa,MAAM;AAAE,wBAAU;AAAM,sBAAQ;AAAA,YAAG;AAC5D,wBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,uCAAuC,CAAC;AACxG,wBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,uCAAuC,CAAC;AAAA,UACxG,CAAC;AAAA,QACD,GAAG;AAAA,MACL,CAAC;AAAA,IACH;AAAA,IACA,YAAY,WAAW;AACrB,aAAO,QAAQ;AAAA,QACb,MAAM;AAAA,QACN,KAAK,OAAO,YAAY;AACxB,gBAAM,KAAK,MAAM,KAAK;AACtB,iBAAO,IAAI,QAAc,CAAC,SAAS,WAAW;AAC9C,gBAAI;AACJ,gBAAI;AAAE,4BAAc,GAAG,YAAY,WAAW,WAAW;AAAA,YAAG,SACrD,OAAO;AAAE,yBAAW,EAAE;AAAG,qBAAO,KAAK;AAAG;AAAA,YAAQ;AACvD,gBAAI,UAAU;AACd,kBAAM,OAAO,CAAC,UAAyB;AAGrC,kBAAI,QAAS;AACb,wBAAU;AACV,yBAAW,EAAE;AACb,qBAAO,KAAK;AAAA,YACd;AACA,kBAAM,QAAQ,YAAY,YAAY,SAAS;AAC/C,kBAAM,UAAU,MAAM,OAAO;AAC7B,oBAAQ,YAAY,MAAM;AACxB,yBAAW,UAAU,QAAQ,QAAuE;AAClG,sBAAM,WAAW,OAAO,SAAS,OAAO,aAAW,QAAQ,cAAc,UAAa,QAAQ,aAAa,SAAS;AACpH,oBAAI,SAAS,WAAW,EAAG,OAAM,OAAO,OAAO,KAAK;AAAA,yBAC3C,SAAS,WAAW,OAAO,SAAS,OAAQ,OAAM,IAAI,EAAE,OAAO,OAAO,OAAO,SAAS,CAAC;AAAA,cAClG;AAAA,YACF;AACA,oBAAQ,UAAU,MAAM,KAAK,QAAQ,SAAS,IAAI,MAAM,gCAAgC,CAAC;AACzF,wBAAY,aAAa,MAAM;AAAE,wBAAU;AAAM,sBAAQ;AAAA,YAAG;AAC5D,wBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,iCAAiC,CAAC;AAClG,wBAAY,UAAU,MAAM,KAAK,YAAY,SAAS,IAAI,MAAM,iCAAiC,CAAC;AAAA,UAClG,CAAC;AAAA,QACD,GAAG;AAAA,MACL,CAAC;AAAA,IACH;AAAA,EACF;AACF;;;ACxTA,IAAM,6BAA6B;AAEnC,IAAM,UAAU;AAOT,IAAM,qBAAN,MAEP;AAAA,EAeE,YAA6B,YAAoC;AAApC;AAAA,EAAqC;AAAA,EAArC;AAAA,EAdpB,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACtB,SAA+B;AAAA,EAC/B,eAAe;AAAA,EACf,WAAmD;AAAA,EAC1C,mBAAmB,oBAAI,IAAY;AAAA;AAAA;AAAA;AAAA,EAI5C,iBAAuC;AAAA,EACvC,iBAAsC;AAAA,EACtC,gBAAmD;AAAA,EACnD,eAAqD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAS7D,MAAM,QAAgC,UAA+D;AACnG,QAAI,KAAK,UAAU,KAAK,cAAc;AAkBpC,aAAO,KAAK,kBAAkB;AAAA,IAChC;AAKA,SAAK,SAAS;AACd,SAAK,eAAe;AACpB,SAAK,WAAW;AAGhB,UAAM,UAAU,OAAO,oBAAoB,KAAK,WAAW,oBAAoB;AAC/E,UAAM,YAAY,OAAO,aAAa,KAAK,WAAW;AACtD,QAAI;AACJ,QAAI;AACF,eAAS,QAAQ,OAAO,KAAK,SAAS;AAAA,IACxC,SAAS,OAAO;AACd,eAAS,SAAS,cAAc,KAAK;AACrC,eAAS,QAAQ,KAAK;AACtB;AAAA,IACF;AACA,UAAM,UAAU,IAAI,QAAc,CAAC,SAAS,WAAW;AACrD,WAAK,iBAAiB;AACtB,WAAK,gBAAgB;AAKrB,UAAI,qBAAqB;AACzB,UAAI,kBAAkB;AACtB,YAAM,YACJ,OAAO,oBAAoB,KAAK,WAAW,oBAAoB;AACjE,UAAI,OAAO,SAAS,SAAS,KAAK,YAAY,GAAG;AAC/C,aAAK,eAAe,WAAW,MAAM;AAwBnC,cAAI,KAAK,WAAW,UAAU,KAAK,aAAa,YAAY,mBAAoB;AAChF,4BAAkB;AAClB,eAAK,eAAe;AACpB,eAAK,eAAe;AACpB,gBAAM,QAAQ,IAAI,MAAM,iCAAiC,SAAS,KAAK;AACvE,mBAAS,SAAS,cAAc,KAAK;AACrC,mBAAS,QAAQ,KAAK;AACtB,eAAK,YAAY,KAAK;AAGtB,eAAK,YAAY,MAAM;AAAA,QACzB,GAAG,SAAS;AAAA,MACd;AACA,aAAO,SAAS,MAAM;AACpB,YAAI,KAAK,WAAW,UAAU,KAAK,aAAa,YAAY,gBAAiB;AAC7E,aAAK,eAAe;AACpB,aAAK,kBAAkB;AAGvB,mBAAW,SAAS,KAAK,kBAAkB;AACzC,eAAK,UAAU,EAAE,IAAI,MAAM,WAAW,MAAM,CAAC;AAAA,QAC/C;AACA,iBAAS,SAAS,cAAc,SAAS;AACzC,YAAI,CAAC,oBAAoB;AACvB,+BAAqB;AACrB,eAAK,cAAc;AAAA,QACrB;AAAA,MACF;AAeA,aAAO,UAAU,MAAM;AACrB,YAAI,KAAK,WAAW,UAAU,KAAK,aAAa,YAAY,CAAC,KAAK,aAAc;AAChF,aAAK,eAAe;AACpB,iBAAS,SAAS,cAAc,YAAY;AAC5C,YAAI,CAAC,oBAAoB;AACvB,4BAAkB;AAClB,eAAK,YAAY,IAAI,MAAM,kDAAkD,CAAC;AAAA,QAChF;AAAA,MACF;AACA,aAAO,UAAU,MAAM;AACrB,YAAI,KAAK,WAAW,UAAU,KAAK,aAAa,YAAY,CAAC,KAAK,aAAc;AAChF,aAAK,eAAe;AACpB,iBAAS,SAAS,cAAc,KAAK;AACrC,YAAI,CAAC,oBAAoB;AACvB,4BAAkB;AAClB,eAAK,YAAY,IAAI,MAAM,2BAA2B,CAAC;AAAA,QACzD;AAIA,aAAK,YAAY,MAAM;AAAA,MACzB;AACA,aAAO,YAAY,WAAS;AAC1B,YAAI,KAAK,WAAW,UAAU,KAAK,aAAa,YAAY,KAAK,cAAc;AAC7E,eAAK,KAAK,cAAc,MAAM,IAAI;AAAA,QACpC;AAAA,MACF;AACA,WAAK,SAAS;AACd,WAAK,eAAe;AAAA,IACtB,CAAC;AACD,SAAK,iBAAiB;AACtB,WAAO;AAAA,EACT;AAAA;AAAA;AAAA,EAIA,UAAU,OAAmC;AAC3C,SAAK,iBAAiB,IAAI,KAAK;AAC/B,SAAK,UAAU,EAAE,IAAI,MAAM,WAAW,MAAM,CAAC;AAAA,EAC/C;AAAA;AAAA;AAAA,EAIA,YAAY,OAAmC;AAC7C,SAAK,iBAAiB,OAAO,KAAK;AAClC,SAAK,UAAU,EAAE,IAAI,MAAM,aAAa,MAAM,CAAC;AAAA,EACjD;AAAA;AAAA,EAGA,QAAQ,OAAe,MAAe,SAAqD;AACzF,QAAI,gBAAgB,aAAa;AAC/B,WAAK,gBAAgB,OAAO,MAAM,SAAS,WAAW,SAAS,SAAS;AACxE;AAAA,IACF;AACA,SAAK,UAAU;AAAA,MACb,IAAI,MAAM;AAAA,MACV;AAAA,MACA;AAAA,MACA,GAAI,SAAS,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,QAAQ,UAAU;AAAA,MAC3E,GAAI,SAAS,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,QAAQ,UAAU;AAAA,IAC7E,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,aAAa,OAAe,OAAkE;AAC5F,QAAI,MAAM,WAAW,EAAG;AACxB,QAAI,MAAM,WAAW,GAAG;AACtB,YAAM,SAAS,MAAM,CAAC;AACtB,aAAO,KAAK,QAAQ,OAAO,OAAO,MAAM;AAAA,QACtC,GAAI,OAAO,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,OAAO,UAAU;AAAA,QACxE,GAAI,OAAO,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,OAAO,UAAU;AAAA,MAC1E,CAAC;AAAA,IACH;AACA,QAAI,KAAK,QAAQ,eAAe,SAAS;AACvC,WAAK,UAAU,QAAQ,IAAI,MAAM,sDAAsD,CAAC;AACxF;AAAA,IACF;AAGA,SAAK,OAAO,KAAK,KAAK,UAAU;AAAA,MAC9B,IAAI,MAAM;AAAA,MACV;AAAA,MACA,OAAO,MAAM,IAAI,WAAS;AAAA,QACxB,MAAM,KAAK,gBAAgB,cAAc,MAAM,KAAK,IAAI,WAAW,KAAK,IAAI,CAAC,IAAI,KAAK;AAAA,QACtF,GAAI,KAAK,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,KAAK,UAAU;AAAA,QACpE,GAAI,KAAK,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,KAAK,UAAU;AAAA,MACtE,EAAE;AAAA,IACJ,CAAC,CAAC;AAAA,EACJ;AAAA;AAAA,EAGA,OAA2B;AACzB,UAAM,SAAS,KAAK;AACpB,UAAM,cAAc,KAAK;AACzB,SAAK,SAAS;AACd,SAAK,eAAe;AACpB,SAAK,WAAW;AAChB,SAAK,iBAAiB,MAAM;AAI5B,SAAK,cAAc;AACnB,SAAK,iBAAiB;AACtB,QAAI,YAAa,SAAQ,MAAM;AAAA,EACjC;AAAA;AAAA;AAAA,EAIQ,gBAAsB;AAC5B,SAAK,kBAAkB;AACvB,UAAM,UAAU,KAAK;AACrB,SAAK,iBAAiB;AACtB,SAAK,gBAAgB;AACrB,cAAU;AAAA,EACZ;AAAA;AAAA;AAAA,EAIQ,YAAY,OAAsB;AACxC,SAAK,kBAAkB;AACvB,UAAM,SAAS,KAAK;AACpB,SAAK,iBAAiB;AACtB,SAAK,gBAAgB;AACrB,aAAS,KAAK;AAAA,EAChB;AAAA;AAAA;AAAA,EAIQ,YAAY,QAA6B;AAC/C,QAAI;AACF,aAAO,MAAM;AAAA,IACf,QAAQ;AAAA,IAGR;AAAA,EACF;AAAA,EAEQ,oBAA0B;AAChC,QAAI,KAAK,iBAAiB,MAAM;AAC9B,mBAAa,KAAK,YAAY;AAC9B,WAAK,eAAe;AAAA,IACtB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWQ,UAAU,SAAsG;AACtH,QAAI,KAAK,QAAQ,eAAe,SAAS;AACvC,WAAK,UAAU,QAAQ,IAAI,MAAM,mCAAmC,QAAQ,EAAE,UAAU,CAAC;AACzF;AAAA,IACF;AACA,SAAK,OAAO,KAAK,KAAK,UAAU,OAAO,CAAC;AAAA,EAC1C;AAAA,EAEQ,gBAAgB,OAAe,MAAmB,WAAoB,WAA0B;AACtG,QAAI,cAAc,UAAa,cAAc,QAAW;AAGtD,WAAK,UAAU;AAAA,QACb,IAAI,MAAM;AAAA,QACV;AAAA,QACA,MAAM,MAAM,KAAK,IAAI,WAAW,IAAI,CAAC;AAAA,QACrC,GAAI,cAAc,SAAY,CAAC,IAAI,EAAE,UAAU;AAAA,QAC/C,GAAI,cAAc,SAAY,CAAC,IAAI,EAAE,UAAU;AAAA,MACjD,CAAC;AACD;AAAA,IACF;AACA,QAAI,KAAK,QAAQ,eAAe,SAAS;AACvC,WAAK,UAAU,QAAQ,IAAI,MAAM,iDAAiD,CAAC;AACnF;AAAA,IACF;AACA,UAAM,aAAa,IAAI,YAAY,EAAE,OAAO,KAAK;AACjD,QAAI,WAAW,SAAS,OAAQ;AAC9B,WAAK,UAAU,QAAQ,IAAI,MAAM,iDAAiD,CAAC;AACnF;AAAA,IACF;AACA,UAAM,QAAQ,IAAI,WAAW,IAAI,WAAW,SAAS,KAAK,UAAU;AACpE,UAAM,CAAC,IAAI;AACX,QAAI,SAAS,MAAM,MAAM,EAAE,UAAU,GAAG,WAAW,MAAM;AACzD,UAAM,IAAI,YAAY,CAAC;AACvB,UAAM,IAAI,IAAI,WAAW,IAAI,GAAG,IAAI,WAAW,MAAM;AACrD,SAAK,OAAO,KAAK,MAAM,MAAM;AAAA,EAC/B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAc,cAAc,KAA6B;AAIvD,QAAI,OAAO,SAAS,eAAe,eAAe,MAAM;AAItD,YAAM,SAAS,KAAK;AACpB,YAAM,WAAW,KAAK;AACtB,UAAI;AACF,cAAM,SAAS,MAAM,IAAI,YAAY;AACrC,YAAI,KAAK,WAAW,UAAU,KAAK,aAAa,YAAY,CAAC,KAAK,aAAc;AAChF,cAAM,KAAK,cAAc,MAAM;AAAA,MACjC,SAAS,OAAO;AACd,YAAI,KAAK,WAAW,UAAU,KAAK,aAAa,YAAY,KAAK,cAAc;AAC7E,eAAK,UAAU,QAAQ,KAAK;AAAA,QAC9B;AAAA,MACF;AACA;AAAA,IACF;AACA,QAAI;AACJ,QAAI,eAAe,aAAa;AAC9B,YAAM,QAAQ,IAAI,WAAW,GAAG;AAChC,UAAI,MAAM,CAAC,MAAM,OAAQ,MAAM,SAAS,EAAG;AAC3C,YAAM,cAAc,IAAI,SAAS,GAAG,EAAE,UAAU,CAAC;AACjD,UAAI,MAAM,SAAS,IAAI,YAAa;AACpC,YAAM,QAAQ,IAAI,YAAY,EAAE,OAAO,MAAM,SAAS,GAAG,IAAI,WAAW,CAAC;AACzE,YAAM,OAAO,MAAM,MAAM,IAAI,WAAW,EAAE;AAC1C,WAAK,UAAU,UAAU,EAAE,OAAO,KAAoB,CAAC;AACvD;AAAA,IACF;AACA,QAAI,OAAO,QAAQ,SAAU;AAC7B,QAAI;AACF,eAAS,KAAK,MAAM,GAAG;AAAA,IACzB,QAAQ;AACN,WAAK,UAAU,QAAQ,IAAI,MAAM,yCAAyC,CAAC;AAC3E;AAAA,IACF;AACA,QAAI,CAAC,UAAU,OAAO,WAAW,SAAU;AAC3C,UAAM,cAAc,wBAA+B,MAAM;AACzD,QAAI,YAAa,MAAK,UAAU,UAAU,WAAoC;AAAA,EAChF;AACF;AAKA,SAAS,wBAAwB,KAAa,WAA8C;AAC1F,MAAI,OAAO,cAAc,aAAa;AACpC,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AACA,SAAO,IAAI,UAAU,KAAK,SAAS;AACrC;AAKO,SAAS,uBACd,SACgD;AAChD,QAAM,EAAE,YAAY,YAAY,GAAG,eAAe,IAAI;AACtD,SAAO,IAAI,gBAAgB;AAAA,IACzB,GAAG;AAAA,IACH,WAAW;AAAA,IACX,YAAY,cAAc,WAAW;AAAA,IACrC,eAAe;AAAA,IACf,WAAW,IAAI,mBAA0B,UAAU;AAAA,EACrD,CAAC;AACH;",
|
|
6
6
|
"names": ["pending"]
|
|
7
7
|
}
|
package/docs/api.md
CHANGED
|
@@ -97,7 +97,7 @@ Registers a local subscription and returns a cleanup function.
|
|
|
97
97
|
- A subscription requested while an explicit `stop()` is settling is not registered: `subscribe()` reports the rejection through `onError` and returns a no-op cleanup function. Wait for `stop()` to settle, then call `start()` before subscribing again.
|
|
98
98
|
- Wildcard subscriptions: a topic ending in `.*` (`chat.*`) matches any remainder, and `*` matches everything. The pattern is routed, owned, and transport-subscribed as a literal channel; publications tagged with a matching concrete topic (or with the pattern itself) are delivered to wildcard handlers. See `topicMatchesPattern` below.
|
|
99
99
|
- Topics must be non-empty: `subscribe()`, `publish()` and `publishBatch()` throw a `TypeError` when passed `''`, before any other effect (no start is requested and no handler is registered). No transport can address an empty channel, so a subscription to it could never receive anything, and a publication to it was dropped without a trace. `''` warned once per instance from 0.20.96 and became a rejection in 0.21.0, following the pre-1.0 deprecation policy; the guard sits with the option-validation asserts, so an empty topic fails the same way a bad `replay.maxPerTopic` does. `publishBatch('', [])` throws too — the invalid argument is checked ahead of the empty-array no-op.
|
|
100
|
-
- Replay (opt-in): construct the bus with `replay: { maxPerTopic }` and pass `{ replay: true | n }` as the third `subscribe()` argument. `maxPerTopic` must be a positive safe integer. The new handler immediately receives the buffered history (up to `n`, capped by `maxPerTopic`, default 100) with `message.replayed: true`, so late joiners do not miss earlier publications. Only dispatched publications are buffered (a topic with no local subscriber drops them as unowned); buffers are in-memory and cleared when the last handler for the topic unsubscribes. Wildcard subscriptions replay across every buffered topic matching the pattern — and note that a pattern is not a topic: the rings it fills stay keyed by the concrete publication topics, so unsubscribing `chat.*` clears none of them (its durable cleanup is asked for `chat.*`, which names no stored row), and re-subscribing the pattern later replays whatever is still buffered. `maxPerTopic` caps each ring's depth, never the number of rings a wide pattern creates, so an unbounded topic space under `*` or `prefix.*` grows the buffer map until you bound it — drop it with `clearReplay()` / `clearReplayTopic(topic)` or cap it in time with `retentionMs`. For reload/BFCache persistence, pass an optional `persistence` created by `createIndexedDbReplayPersistence({ maxPerTopic })`; persistence is asynchronous and failures are reported through `onError` without breaking live delivery. Set `retentionMs` to prune expired producer-timestamped history in memory and to sweep adapters that implement `clearBefore` during hydration and after appends. Set `persistenceRetry: { maxAttempts, backoffMs }` to retry transient persistence failures; defaults preserve one-attempt behavior. Set `pruneStrategy` to `'count'` (default), `'age'`, or `'both'` to cap by `maxPerTopic`, prune timestamped history by `retentionMs`, or apply both. Under `age`, timestamp-less legacy entries are retained but capped by `maxPerTopic`; timestamped entries are bounded by the retention window.
|
|
100
|
+
- Replay (opt-in): construct the bus with `replay: { maxPerTopic }` and pass `{ replay: true | n }` as the third `subscribe()` argument. `maxPerTopic` must be a positive safe integer. The new handler immediately receives the buffered history (up to `n`, capped by `maxPerTopic`, default 100) with `message.replayed: true`, so late joiners do not miss earlier publications. Only dispatched publications are buffered (a topic with no local subscriber drops them as unowned); buffers are in-memory and cleared when the last handler for the topic unsubscribes. Wildcard subscriptions replay across every buffered topic matching the pattern — and note that a pattern is not a topic: the rings it fills stay keyed by the concrete publication topics, so unsubscribing `chat.*` clears none of them (its durable cleanup is asked for `chat.*`, which names no stored row), and re-subscribing the pattern later replays whatever is still buffered. `maxPerTopic` caps each ring's depth, never the number of rings a wide pattern creates, so an unbounded topic space under `*` or `prefix.*` grows the buffer map until you bound it — drop it with `clearReplay()` / `clearReplayTopic(topic)` or cap it in time with `retentionMs`. For reload/BFCache persistence, pass an optional `persistence` created by `createIndexedDbReplayPersistence({ maxPerTopic })`; persistence is asynchronous and failures are reported through `onError` without breaking live delivery. The factory also takes `clusterKey` — pass the same value you pass the bus, and it derives the IndexedDB database name the way the coordination plane derives its storage keys, so an application running more than one cluster gets one namespace per cluster instead of sharing rows; `dbName` overrides it with a name of your own choosing. Set `retentionMs` to prune expired producer-timestamped history in memory and to sweep adapters that implement `clearBefore` during hydration and after appends. Set `persistenceRetry: { maxAttempts, backoffMs }` to retry transient persistence failures; defaults preserve one-attempt behavior. Set `pruneStrategy` to `'count'` (default), `'age'`, or `'both'` to cap by `maxPerTopic`, prune timestamped history by `retentionMs`, or apply both. Under `age`, timestamp-less legacy entries are retained but capped by `maxPerTopic`; timestamped entries are bounded by the retention window.
|
|
101
101
|
When tracing is enabled, retries emit `reliability` events with `operation: 'persistence_retry'`, a bounded `persistenceOperation`, and `attempt`.
|
|
102
102
|
|
|
103
103
|
The WebSocket transport accepts binary publications delivered as either `ArrayBuffer` or browser `Blob` frames.
|
package/docs/benchmarks.md
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
# Browser Benchmark Trend
|
|
4
4
|
|
|
5
|
-
> Data through 2026-09-
|
|
5
|
+
> Data through 2026-09-30, from the 92 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
|
|
6
6
|
|
|
7
7
|
The comparison baseline for release gating is `pnpm bench:compare --fail-above-pct 50`, which compares the newest report against the median of the same metric in the preceding reports *and* requires the new value to exceed the highest of those samples (50% ceiling absorbs shared-runner noise; a single previous report was a fragile baseline because the in-page hot-path metrics alternate between a fast and a slow mode on identical code — and a median over an alternating sample is only stable while the modes are mixed, so a percentage alone can still fail on nothing but a mode shift. Metrics excused by that second leg are listed as "within-baseline, not gated" rather than passing silently). This doc records the long-run picture: values are per-metric latencies where lower is better, and the last column is the best run inside the most recent 5 reports — not an all-time record, because the in-page matrix changed measurement semantics in early September 2026 and older reports are not comparable.
|
|
8
8
|
|
|
9
9
|
<!-- BENCH-TREND:BEGIN (machine-generated table) -->
|
|
10
10
|
| Metric | Previous (ms) | Latest (ms) | Δ | Best of last 5 runs (ms) |
|
|
11
11
|
|---|---|---|---|---|
|
|
12
|
-
| publish per-message (ms, lower is better) — dedicated |
|
|
13
|
-
| publish per-message (ms, lower is better) — shared |
|
|
14
|
-
| wildcard dispatch ×1000 (ms, lower is better) | 6.
|
|
15
|
-
| publishBatch ×1000 (ms, lower is better) | 4.
|
|
16
|
-
| dedup ×1000 (ms, lower is better) |
|
|
17
|
-
| trace + publish ×1000 (ms, lower is better) |
|
|
12
|
+
| publish per-message (ms, lower is better) — dedicated | 41.3311 | 44.6795 | +3.35 | 37.1566 |
|
|
13
|
+
| publish per-message (ms, lower is better) — shared | 34.1115 | 37.8574 | +3.75 | 33.8184 |
|
|
14
|
+
| wildcard dispatch ×1000 (ms, lower is better) | 6.7 | 7.4 | +0.70 | 6.3 |
|
|
15
|
+
| publishBatch ×1000 (ms, lower is better) | 4.4 | 5 | +0.60 | 3.7 |
|
|
16
|
+
| dedup ×1000 (ms, lower is better) | 19 | 18.3 | -0.70 | 15.6 |
|
|
17
|
+
| trace + publish ×1000 (ms, lower is better) | 6.6 | 5.7 | -0.90 | 4.6 |
|
|
18
18
|
| first-packet cold dispatch (ms, lower is better) | 0 | 0 | +0.00 | 0 |
|
|
19
19
|
<!-- BENCH-TREND:END -->
|
|
20
20
|
|
package/docs/configuration.md
CHANGED
|
@@ -273,7 +273,7 @@ Those records never hold:
|
|
|
273
273
|
That is a boundary around *those keys*, not around browser storage in general. Two opt-ins put Topic plaintext and payloads into it:
|
|
274
274
|
|
|
275
275
|
- `channelFallback: 'storage-event'` writes every coordination **frame** whole under `cross-tab-worker-databus:channel:*`, and a `CONTROL/PUBLISH` frame carries its Topic name and payload — so they sit in `localStorage` until the channel is closed, and indefinitely if the tab is killed first.
|
|
276
|
-
- `replay.persistence` (e.g. `createIndexedDbReplayPersistence`) keeps replay history in IndexedDB in an object store keyed by the plaintext Topic, with the payloads in its rows.
|
|
276
|
+
- `replay.persistence` (e.g. `createIndexedDbReplayPersistence`) keeps replay history in IndexedDB in an object store keyed by the plaintext Topic, with the payloads in its rows. Within one database the store is keyed by the Topic and nothing else — never by tab — so two clusters sharing a database read each other's rows for any Topic they share. The database name is what separates clusters: it defaults to the package's storage prefix, and passing `clusterKey` to the factory derives it the same way the coordination plane derives its storage keys (an empty `clusterKey` lands in the default cluster, as everywhere else). Omitting both leaves every cluster in the origin on one database, so an application running more than one should pass `clusterKey` — or `dbName` for a name of its own choosing, which takes precedence.
|
|
277
277
|
|
|
278
278
|
Those two are the complete list, and it is checked rather than asserted: every durable write in `src/` is enumerated in `tests/storage-writers.test.ts`, and a new one — or a new row that carries a Topic without naming one of these two options — fails the suite.
|
|
279
279
|
|
|
@@ -20,7 +20,12 @@ Only `pnpm bench:browser` / `pnpm bench:compare` / `pnpm bench:trend` stay local
|
|
|
20
20
|
1. Update `package.json`, `CHANGELOG.md`, and both roadmap files.
|
|
21
21
|
2. Run `pnpm check`, `pnpm lint`, `pnpm test:coverage`, `pnpm bench`, `pnpm test:e2e`, `pnpm bench:browser`, `pnpm verify:pack`, `pnpm verify:compat`, `pnpm verify:types`, and `git diff --check`. (`verify:compat` asserts the package `exports` subpaths and type fields present in the `COMPAT_BASE_TAG` baseline still exist; `verify:pack` steam-imports the full root public surface plus every subpath in ESM and CJS from the packed tarball; `verify:types` freezes every public entry's declared export names against the same baseline and asserts no public signature names a type that no entry exports — a type-only export emits no runtime key, so neither the `Object.keys()` freeze in `tests/dual-format.test.ts` nor `verify:compat` can see one change. `verify:compat` and `verify:types` resolve their baseline from the most recent release tag, so a shallow or tag-less clone must first run `git fetch --tags` or they fail with "no version tag found".)
|
|
22
22
|
3. Gate dependency security: `pnpm audit --registry=https://registry.npmjs.org` (the configured mirror registry lacks the audit endpoint; CI runs it on the public registry in the verify job). Fail the release on any known-vulnerability advisory; `pnpm-workspace.yaml` overrides pin patched ranges.
|
|
23
|
-
4. Gate browser benchmark regressions: run `pnpm bench:browser` (twice at minimum; the gate compares the newest report against the median of the same metric in up to the five preceding archived reports, **and additionally requires the new value to exceed the highest of those samples**) and then `pnpm bench:compare --fail-above-pct 50`. The median baseline exists because the in-page hot-path metrics alternate between a fast and a slow mode on identical code — two consecutive runs of `dedup1000Ms` measured 12.7 ms then 25.6 ms, and a single noisy previous report could fail the documented ceiling with no code change at all. The second leg exists because a median over an alternating sample is only stable while the modes stay mixed: when several consecutive reports land in the same mode the median itself jumps, which is how one comment-only tree read +121% against one baseline and −56.7% against another minutes apart. A metric excused by that leg is printed under `within-baseline, not gated` rather than passing silently — measured on the current archive, `publish/dedicated/perMessageMs`, `publish/shared/perMessageMs` and `databus/publishBatch1000Ms` were all inside their own five-report range, so treat that list as the metric's real resolution, not as a clean bill. `publish/dedicated/perMessageMs` is noisy the same way: two consecutive runs of identical code measured 49.9 ms then 71.1 ms, failing the gate at +75.7% while `dedup1000Ms` and `traceAndPublish1000Ms` *improved* in the same report — that single-metric-up-everything-else-down shape is the noise signature, and the next run reported 49.4 ms and passed. Before accepting or acting on a failure, re-run and check the spread; if the change touches a hot path, size it directly (a micro-benchmark of the 0.20.95 bounded-map rewrites measured 80–86 ns/op across all three variants, i.e. inside run-to-run variance). A baseline shift (e.g. a metric becoming real instead of a no-op) is an expected one-time failure. Refresh the long-run trend doc with `pnpm bench:trend` and commit it when the tables change. **Re-running a failure can manufacture its own excuse, and that was measured.** `bench:compare`'s baseline — both the median and the maximum leg — is drawn from the most recent archived reports, and every `bench:browser` run appends to that same archive, so the samples you add while investigating a failure become the baseline that judges the next attempt. On 2026-09-24, at host load averages 7.7–15.1 with a zero non-comment `src/` diff in the tree, the first two runs measured `publish/dedicated 77.3 ms` and `publish/shared 67.7 ms` against a ~38/~34 ms fast mode and failed the 50% ceiling; five further re-runs reported **OK**, because the baseline median had slid to `70.6`. One clean run after taking those samples out of the archive showed every metric rising together (`dedicated +92.5%`, `shared +99.2%`, `dedup +43.4%`, `trace +21.0%`), which is the contention signature and the inverse of the single-metric-up shape above. So read the load average *before* re-running, and when a suspect failure occurs on a loaded host, record it as deferred with its numbers and move the off-mode samples aside instead of letting them into the baseline — and do not regenerate `docs/benchmarks.md` from them. **The standing decision, recorded at 0.21.17 (the fifth release in that state): when no quiet window exists across a cycle, defer the sample with its load numbers instead of taking it on a loaded host.** The deferral is what keeps `docs/benchmarks.md` stale, and staleness-by-recorded-opportunity is the intended outcome of this step, not a gate that passed quietly; what would not be acceptable is a loaded sample entering the archive, because that becomes the baseline the next release is judged against. **As of 0.21.45 that rule is enforced rather than remembered.** Every archived report now records the host it
|
|
23
|
+
4. Gate browser benchmark regressions: run `pnpm bench:browser` (twice at minimum; the gate compares the newest report against the median of the same metric in up to the five preceding archived reports, **and additionally requires the new value to exceed the highest of those samples**) and then `pnpm bench:compare --fail-above-pct 50`. The median baseline exists because the in-page hot-path metrics alternate between a fast and a slow mode on identical code — two consecutive runs of `dedup1000Ms` measured 12.7 ms then 25.6 ms, and a single noisy previous report could fail the documented ceiling with no code change at all. The second leg exists because a median over an alternating sample is only stable while the modes stay mixed: when several consecutive reports land in the same mode the median itself jumps, which is how one comment-only tree read +121% against one baseline and −56.7% against another minutes apart. A metric excused by that leg is printed under `within-baseline, not gated` rather than passing silently — measured on the current archive, `publish/dedicated/perMessageMs`, `publish/shared/perMessageMs` and `databus/publishBatch1000Ms` were all inside their own five-report range, so treat that list as the metric's real resolution, not as a clean bill. `publish/dedicated/perMessageMs` is noisy the same way: two consecutive runs of identical code measured 49.9 ms then 71.1 ms, failing the gate at +75.7% while `dedup1000Ms` and `traceAndPublish1000Ms` *improved* in the same report — that single-metric-up-everything-else-down shape is the noise signature, and the next run reported 49.4 ms and passed. Before accepting or acting on a failure, re-run and check the spread; if the change touches a hot path, size it directly (a micro-benchmark of the 0.20.95 bounded-map rewrites measured 80–86 ns/op across all three variants, i.e. inside run-to-run variance). A baseline shift (e.g. a metric becoming real instead of a no-op) is an expected one-time failure. Refresh the long-run trend doc with `pnpm bench:trend` and commit it when the tables change. **Re-running a failure can manufacture its own excuse, and that was measured.** `bench:compare`'s baseline — both the median and the maximum leg — is drawn from the most recent archived reports, and every `bench:browser` run appends to that same archive, so the samples you add while investigating a failure become the baseline that judges the next attempt. On 2026-09-24, at host load averages 7.7–15.1 with a zero non-comment `src/` diff in the tree, the first two runs measured `publish/dedicated 77.3 ms` and `publish/shared 67.7 ms` against a ~38/~34 ms fast mode and failed the 50% ceiling; five further re-runs reported **OK**, because the baseline median had slid to `70.6`. One clean run after taking those samples out of the archive showed every metric rising together (`dedicated +92.5%`, `shared +99.2%`, `dedup +43.4%`, `trace +21.0%`), which is the contention signature and the inverse of the single-metric-up shape above. So read the load average *before* re-running, and when a suspect failure occurs on a loaded host, record it as deferred with its numbers and move the off-mode samples aside instead of letting them into the baseline — and do not regenerate `docs/benchmarks.md` from them. **The standing decision, recorded at 0.21.17 (the fifth release in that state): when no quiet window exists across a cycle, defer the sample with its load numbers instead of taking it on a loaded host.** The deferral is what keeps `docs/benchmarks.md` stale, and staleness-by-recorded-opportunity is the intended outcome of this step, not a gate that passed quietly; what would not be acceptable is a loaded sample entering the archive, because that becomes the baseline the next release is judged against. **As of 0.21.45 that rule is enforced rather than remembered, and the refusal happens before the write rather than after it.** Every archived report now records the host it ran on (`host.loadavg1m` with its core count) — read **before** the run starts, because the benchmark's own CPU cost is larger than the ceiling it is judged against: measured, an idle 8-core host at 7.23 was at 11.95 by the time a sample completed, so a reading taken afterwards measures the benchmark rather than the machine and refuses a genuinely idle host every time. `bench:browser` judges the sample it has just taken and **exits non-zero without archiving it**, printing the reason; a sample that reached the disk would be the baseline the next five comparisons are judged against, so a verdict printed about a file that already exists decides nothing. Two thresholds, and which one applies depends on the archive:
|
|
24
|
+
|
|
25
|
+
- **With a recorded baseline**, a reading above four times the baseline's own median is refused. The threshold is a ratio rather than an absolute number because the archive spans machines and a load average means nothing without its core count.
|
|
26
|
+
- **With no recorded baseline** — which is the archive's entire current state, since every report predates the field — a reading above the host's own core count is refused, because more runnable work than cores is oversubscribed by definition and needs no history to establish. Without this leg the guard is weakest exactly where the archive is weakest, and the first sample taken on a busy host is the one that would end a twenty-five-tag deferral streak.
|
|
27
|
+
|
|
28
|
+
Both boundaries are inclusive and both are a refusal to *trust* rather than a claim the sample is invalid, so a false positive costs one deferred run rather than a release. A failed *write* is deliberately not treated as a refusal: the numbers were produced, a full disk is not a judgment about the host, and conflating the two would fail a release over a sample that was correctly taken. A platform that publishes neither a finite reading nor a core count admits the sample, since there is nothing to judge it on.
|
|
24
29
|
5. Confirm the package contains only intended files with `npm pack --dry-run --json`.
|
|
25
30
|
6. Commit on a feature branch, push that branch, and merge its green PR using squash or fast-forward (no merge commit). Fetch the merged commit, tag that exact commit, and push only the specific version tag (with the local tag present, run the gate set once more before that push — see *Tagged-release workflow* below for why that moment is the one no earlier CI sees); never push `main`/`master` directly or force-push. The workflow runs `node scripts/verify-release-version.mjs` to require `RELEASE_TAG` to equal `v` + the package version and to require exactly one non-empty CHANGELOG section. The same step compares every version heading in `CHANGELOG.md` against the registry's own `time` map and refuses a release record that names a version npm never had — but only from `0.20.85` upward, because below that floor 40 sections name versions the registry never recorded, 27 of them tagged (both re-derived 2026-09-25; the command to re-derive them intersects three sets — `grep -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md`, `npm view cross-tab-worker-databus time --json --registry https://registry.npmjs.org`, and `git tag -l` — because the registry read alone answers neither count, let alone the tagged half), and the gate exists to stop a new hole rather than to litigate history. The version being tagged is exempt, since it legitimately cannot be on npm yet, and a registry read that fails prints a skip warning and exits 0: refusing a good release over one transient HTTP call is the failure mode the published-consumer ceiling was sized to avoid, so only a *confirmed* absence is fatal. Note the `--registry` in that re-derive command, and that the gate passes it too: a configured **mirror** can answer successfully while 3,418 s behind. Measured while preparing `0.21.35` on a machine whose `npm` points at `registry.npmmirror.com` — its packument still reported `modified: 00:32:51Z` (the `0.21.33` publish) when the gate ran at `03:41:18Z` — 3,207 s (53 min) after npmjs had recorded `0.21.34` at `02:47:50Z`, and the mirror's own `modified` did not move until `03:44:48.778Z`, 3,418 s after the record — so the gate declared a public version "never recorded", from a read that *succeeded*. A mirror's sync lag is not HTTP caching, so `cache-control: no-cache` returns the same stale document; naming the registry is the only thing that fixes it. `verify-published-consumer.mjs` had already pinned it for both of its reads; this was the last npm-based registry read in the repository that did not.
|
|
26
31
|
|
package/docs/roadmap.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
0.21.
|
|
3
|
+
0.21.46 was released on September 30, 2026. The project is intentionally continuing through reliability-focused releases before a 1.0.0 stability freeze.
|
|
4
4
|
|
|
5
5
|
## 0.21.44 delivered scope
|
|
6
6
|
|
package/docs/zh/api.md
CHANGED
|
@@ -97,7 +97,7 @@ subscribe(
|
|
|
97
97
|
- 显式 `stop()` 尚未 settle 时发起的订阅不会登记:`subscribe()` 通过 `onError` 上报并返回 no-op 释放函数。调用方应等待 `stop()` settle,再调用 `start()` 后重新订阅。
|
|
98
98
|
- 通配符订阅:以 `.*` 结尾的 Topic(如 `chat.*`)匹配任意后缀,`*` 匹配全部。pattern 以字面量参与路由、归属与传输订阅;携带匹配的具体 topic(或 pattern 本身)的发布都会投递给通配 handler。匹配规则见下方 `topicMatchesPattern`。
|
|
99
99
|
- Topic 必须非空:`subscribe()`、`publish()`、`publishBatch()` 传入 `''` 时会抛出 `TypeError`,且在任何其他副作用之前(不会请求 start,也不会登记 handler)。没有任何 transport 能寻址空 channel,因此对它的订阅永远收不到消息,向它的发布也会被无声丢弃。`''` 自 0.20.96 起每个实例告警一次,自 0.21.0 起直接拒绝——遵循 1.0 前的弃用策略;该守卫与选项校验断言放在一起,空 topic 与非法 `replay.maxPerTopic` 以同样方式失败。`publishBatch('', [])` 同样抛错:非法参数先于空数组 no-op 检查。
|
|
100
|
-
- 重放(可选):构造 bus 时传 `replay: { maxPerTopic }` 开启缓冲,`maxPerTopic` 必须是正安全整数;`subscribe()` 第三个参数传 `{ replay: true | n }` 后,新 handler 会立即收到缓冲历史(最多 `n` 条,受 `maxPerTopic` 上限约束,默认 100),消息带 `message.replayed: true` 标记——晚加入的 handler 不会错过更早的发布。只有被分发过的消息才入缓冲(无本地订阅者的 topic 会被 owner 丢弃);缓冲仅存内存,该 topic 最后一个 handler 退订时清空。通配订阅会对所有匹配 pattern 的已缓冲 topic 做回放——但 pattern 本身不是一个 topic:它填上的缓冲环仍按具体发布 topic 归档,所以退订 `chat.*` 不会清空其中任何一条(durable 清理会以 `chat.*` 这个键调用,而存储里并没有对应行),重新订阅该 pattern 时仍会回放尚存的缓冲。`maxPerTopic` 限制每条环的深度,不限制宽 pattern 产生的环数量,因此 `*` 或 `prefix.*` 面对无界 topic 空间会让缓冲 map 持续增长,直到你主动约束——用 `clearReplay()` / `clearReplayTopic(topic)` 清理,或用 `retentionMs` 按时间收敛。需要跨 reload/BFCache 持久化时,可传入 `createIndexedDbReplayPersistence({ maxPerTopic })` 创建的 `persistence`;持久化为异步操作,失败会通过 `onError`
|
|
100
|
+
- 重放(可选):构造 bus 时传 `replay: { maxPerTopic }` 开启缓冲,`maxPerTopic` 必须是正安全整数;`subscribe()` 第三个参数传 `{ replay: true | n }` 后,新 handler 会立即收到缓冲历史(最多 `n` 条,受 `maxPerTopic` 上限约束,默认 100),消息带 `message.replayed: true` 标记——晚加入的 handler 不会错过更早的发布。只有被分发过的消息才入缓冲(无本地订阅者的 topic 会被 owner 丢弃);缓冲仅存内存,该 topic 最后一个 handler 退订时清空。通配订阅会对所有匹配 pattern 的已缓冲 topic 做回放——但 pattern 本身不是一个 topic:它填上的缓冲环仍按具体发布 topic 归档,所以退订 `chat.*` 不会清空其中任何一条(durable 清理会以 `chat.*` 这个键调用,而存储里并没有对应行),重新订阅该 pattern 时仍会回放尚存的缓冲。`maxPerTopic` 限制每条环的深度,不限制宽 pattern 产生的环数量,因此 `*` 或 `prefix.*` 面对无界 topic 空间会让缓冲 map 持续增长,直到你主动约束——用 `clearReplay()` / `clearReplayTopic(topic)` 清理,或用 `retentionMs` 按时间收敛。需要跨 reload/BFCache 持久化时,可传入 `createIndexedDbReplayPersistence({ maxPerTopic })` 创建的 `persistence`;持久化为异步操作,失败会通过 `onError` 报告,不影响实时投递。该 factory 还接受 `clusterKey`——传入与 bus 相同的值,IndexedDB 数据库名的推导方式便与协调层推导 storage key 一致,于是运行不止一个 cluster 的应用可按 cluster 各得一个命名空间,而不必共用行;若想自选名称,用 `dbName` 覆盖它。设置 `retentionMs` 后会清理内存中过期的 producer-timestamped 历史,并通过实现 `clearBefore` 的 adapter 在 hydrate 和追加后清理 durable 历史。设置 `persistenceRetry: { maxAttempts, backoffMs }` 可重试瞬时持久化失败;默认仍保持单次尝试。设置 `pruneStrategy` 为 `'count'`(默认)、`'age'` 或 `'both'`,分别表示按 `maxPerTopic` 截断、按 `retentionMs` 清理带时间戳历史,或两者都应用。`age` 下无时间戳的 legacy 条目会保留,但受 `maxPerTopic` 限制;带时间戳条目由 retention 窗口约束。
|
|
101
101
|
启用 trace 后,重试会发出 `reliability` 事件,包含 `operation: 'persistence_retry'`、有界的 `persistenceOperation` 和 `attempt`。
|
|
102
102
|
|
|
103
103
|
WebSocket transport 支持以 `ArrayBuffer` 或浏览器 `Blob` 帧接收二进制 publication。
|
package/docs/zh/benchmarks.md
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
# 浏览器基准趋势
|
|
4
4
|
|
|
5
|
-
> 数据截至 2026-09-
|
|
5
|
+
> 数据截至 2026-09-30,基于 92 份归档的 `bench-results/browser-*.json` 报告(运行 `pnpm bench:browser` 追加一份;用 `node scripts/bench-trend.mjs` 重新生成本文档)。
|
|
6
6
|
|
|
7
7
|
发布门禁的对比基线是 `pnpm bench:compare --fail-above-pct 50`:将最新报告与其之前若干报告中同一指标的中位数比较,*并且*要求新值超过这些样本中的最大值(50% 上限用于吸收共享 runner 的噪声;此前只看"上一份报告"并不稳定,因为页内热路径指标在同一份代码上会在快/慢两档之间来回跳;而在两档交替的样本上,中位数只有在这两档混合分布时才稳定,因此单看百分比仍可能纯粹因为档位切换而失败)。被第二个条件放过的指标会显式列在 “within-baseline, not gated” 里,而不是悄悄算作通过。本文记录长期趋势:数值为逐指标延迟,越低越好;最后一列是最近 5 份报告内的最优值,而不是历史纪录——页内基准矩阵在 2026 年 9 月初变更过测量语义,更早的报告不可比。
|
|
8
8
|
|
|
9
9
|
<!-- BENCH-TREND:BEGIN (machine-generated table) -->
|
|
10
10
|
| 指标 | 上次 (ms) | 本次 (ms) | Δ | 近 5 次最优 (ms) |
|
|
11
11
|
|---|---|---|---|---|
|
|
12
|
-
| publish per-message (ms, lower is better) — dedicated |
|
|
13
|
-
| publish per-message (ms, lower is better) — shared |
|
|
14
|
-
| wildcard dispatch ×1000 (ms, lower is better) | 6.
|
|
15
|
-
| publishBatch ×1000 (ms, lower is better) | 4.
|
|
16
|
-
| dedup ×1000 (ms, lower is better) |
|
|
17
|
-
| trace + publish ×1000 (ms, lower is better) |
|
|
12
|
+
| publish per-message (ms, lower is better) — dedicated | 41.3311 | 44.6795 | +3.35 | 37.1566 |
|
|
13
|
+
| publish per-message (ms, lower is better) — shared | 34.1115 | 37.8574 | +3.75 | 33.8184 |
|
|
14
|
+
| wildcard dispatch ×1000 (ms, lower is better) | 6.7 | 7.4 | +0.70 | 6.3 |
|
|
15
|
+
| publishBatch ×1000 (ms, lower is better) | 4.4 | 5 | +0.60 | 3.7 |
|
|
16
|
+
| dedup ×1000 (ms, lower is better) | 19 | 18.3 | -0.70 | 15.6 |
|
|
17
|
+
| trace + publish ×1000 (ms, lower is better) | 6.6 | 5.7 | -0.90 | 4.6 |
|
|
18
18
|
| first-packet cold dispatch (ms, lower is better) | 0 | 0 | +0.00 | 0 |
|
|
19
19
|
<!-- BENCH-TREND:END -->
|
|
20
20
|
|
package/docs/zh/configuration.md
CHANGED
|
@@ -266,7 +266,7 @@ Worker 模式下配置通过 `Worker` / `SharedWorker` 的 `postMessage` 发送
|
|
|
266
266
|
这是对**这些键**的约束,不是对浏览器存储整体的约束。有两个开关会把 Topic 明文和 payload 写进去:
|
|
267
267
|
|
|
268
268
|
- `channelFallback: 'storage-event'` 把每一帧协调消息整体写到 `cross-tab-worker-databus:channel:*` 之下,而一条 `CONTROL/PUBLISH` 帧同时携带它的 Topic 名称与 payload——因此这些数据会留在 `localStorage` 直到该 channel 关闭,若 tab 先退出则无限期保留。
|
|
269
|
-
- `replay.persistence`(如 `createIndexedDbReplayPersistence`)把回放历史存在 IndexedDB 的一个 object store 里,该 store 以 Topic 明文为 key,payload
|
|
269
|
+
- `replay.persistence`(如 `createIndexedDbReplayPersistence`)把回放历史存在 IndexedDB 的一个 object store 里,该 store 以 Topic 明文为 key,payload 就在其中的行里。在同一个数据库内,这个 store 只以 Topic 为 key,别无其他——从不按 tab 划分——因此共用一个数据库的两个 cluster 会读到彼此在共同 Topic 上的行。划分 cluster 的是**数据库名**:它默认取本包的 storage 前缀;向 factory 传入 `clusterKey` 时,数据库名的推导方式与协调层推导 storage key 完全一致(`clusterKey` 为空串时落入默认 cluster,与库中其他各处相同)。两者都不传时,同一 origin 下的所有 cluster 共用一个数据库,因此运行不止一个 cluster 的应用应传入 `clusterKey`;若想自选名称则传入 `dbName`,它的优先级更高。
|
|
270
270
|
|
|
271
271
|
这两个就是全部,而且是核对出来的而非声称的:`src/` 中每一处持久化写入都在 `tests/storage-writers.test.ts` 里列成表,新增一处写入——或新增一行不归属于上面两个开关却携带 Topic 的记录——都会让测试失败。
|
|
272
272
|
|
|
@@ -21,7 +21,12 @@
|
|
|
21
21
|
2. 运行 `pnpm check`、`pnpm lint`、`pnpm test:coverage`、`pnpm bench`、`pnpm test:e2e`、`pnpm bench:browser`、`pnpm verify:pack`、`pnpm verify:compat`、`pnpm verify:types` 以及 `git diff --check`(`verify:compat` 断言 `COMPAT_BASE_TAG` 基线中的 package `exports` 子路径与类型字段仍然存在;`verify:pack` 从打包产物冒烟导入完整根公共面与全部子路径的 ESM/CJS;`verify:types` 用同一基线冻结每个公共入口声明层导出的名字,并断言没有任何公共签名引用了「任何入口都未导出」的类型——纯类型导出不会在运行时产生任何键,因此 `tests/dual-format.test.ts` 的 `Object.keys()` 冻结和 `verify:compat` 都看不到它的变化。`verify:compat` 与 `verify:types` 从最近的发布 tag 解析基线,因此浅克隆或缺少 tag 的克隆需先执行 `git fetch --tags`,否则会以 "no version tag found" 失败)。
|
|
22
22
|
3. 依赖安全门禁:`pnpm audit --registry=https://registry.npmjs.org`(配置的镜像 registry 缺少 audit 端点;CI 在 verify job 中于公共 registry 运行)。任一已知漏洞公告即视为发布失败;`pnpm-workspace.yaml` overrides 钉住补丁版本。
|
|
23
23
|
4. 浏览器基准回归门禁:运行 `pnpm bench:browser`(至少两次;门禁将最新报告与之前最多五份归档报告中同一指标的中位数比较,**并且额外要求新值超过这些样本中的最大值**),随后执行 `pnpm bench:compare --fail-above-pct 50`。采用中位数基线是因为页内热路径指标在同一份代码上会在快/慢两档之间来回跳——`dedup1000Ms` 相邻两次实测分别为 12.7 ms 与 25.6 ms,只看"上一份报告"时,一次偶发噪声就能在无代码改动的情况下击穿既定上限。第二个条件则是因为:在两档交替的样本上,中位数只有当两档保持混合时才稳定——一旦连续几份报告落在同一档,中位数本身就会跳变,这正是同一份纯注释改动在一版基线前读出 +121%、几分钟后在另一版基线前读出 −56.7% 的原因。被该条件放过的指标会打印在 `within-baseline, not gated` 下,而不是静默通过——在当前归档上实测,`publish/dedicated/perMessageMs`、`publish/shared/perMessageMs` 与 `databus/publishBatch1000Ms` 三项都落在各自五份报告的区间内,因此应把这份清单读作该指标的真实分辨率,而不是一张合格证。`publish/dedicated/perMessageMs` 同样如此:同一份代码相邻两次实测为 49.9 ms 与 71.1 ms,令门禁以 +75.7% 失败,而同一份报告里 `dedup1000Ms` 与 `traceAndPublish1000Ms` 反而*变快*——"单指标上涨、其余下降"正是噪声的特征,第三次实测为 49.4 ms 并通过。因此在采信或处理失败前先复跑并观察离散度;若改动涉及热路径,就直接量化其开销(0.20.95 有界映射改写的微基准显示三种写法均为 80–86 ns/op,落在运行间波动之内)。基线迁移(例如某指标从空操作变为真实路径)属预期内的一次性失败。用 `pnpm bench:trend` 刷新长期趋势文档,表格变化时一并提交。**复跑失败可能亲手制造出让它通过的基线,这一点已被实测。** `bench:compare` 的基线(中位数与最大值两个条件)取自最近若干份归档报告,而每一次 `pnpm bench:browser` 都会往同一个归档里追加——于是排查失败时新增的样本,就变成了裁决下一次尝试的基线。2026-09-24:主机负载均值 7.7–15.1、待发布树的非注释 `src/` 差异为 0 行,前两次实测 `publish/dedicated 77.3 ms`、`publish/shared 67.7 ms`,相对约 38/34 ms 的快档在 50% 上限上失败;随后五次复跑报告 **OK**,因为基线中位数已经滑到 `70.6`。把这些样本移出归档后的一次干净实测显示所有指标同时上涨(`dedicated +92.5%`、`shared +99.2%`、`dedup +43.4%`、`trace +21.0%`),那是资源竞争的形态,与上文"单指标上涨、其余下降"的噪声形态相反。因此:复跑之前先看负载(`uptime`);在满载主机上遇到可疑失败,就把数字连同负载一起记为"本轮推迟",并将偏离常态的样本移出基线——也不要据它们重新生成 `docs/benchmarks.md`。
|
|
24
|
-
**这条既定决策在 0.21.17 被写下,那是处于该状态下的第五个发布周期:一个发布周期里始终没有安静的窗口时,就把这份采样连同当时的负载数字一起记为"本轮推迟",而不是在满载主机上取它。** 推迟正是 `docs/benchmarks.md` 保持陈旧的原因,而"按机会更新"的这份陈旧是本步骤期望的结果,不是一道悄悄通过的门禁;真正不可接受的,是把一个受资源竞争的样本放进归档——那会变成下一次发布用来判定回归的基线。 **自 0.21.45
|
|
24
|
+
**这条既定决策在 0.21.17 被写下,那是处于该状态下的第五个发布周期:一个发布周期里始终没有安静的窗口时,就把这份采样连同当时的负载数字一起记为"本轮推迟",而不是在满载主机上取它。** 推迟正是 `docs/benchmarks.md` 保持陈旧的原因,而"按机会更新"的这份陈旧是本步骤期望的结果,不是一道悄悄通过的门禁;真正不可接受的,是把一个受资源竞争的样本放进归档——那会变成下一次发布用来判定回归的基线。 **自 0.21.45 起,这条规则由工具执行,而不再依赖记忆,并且拒绝发生在写入之前,而不是之后。** 每份归档报告现在都会记录采样时的主机状态(`host.loadavg1m` 及其核心数)——在**运行开始之前**读取,因为基准测试自身的 CPU 开销比它被判定所依据的上限还大:实测一台空闲的 8 核主机在运行开始时读数为 7.23,采样完成时已是 11.95,也就是说事后再读,量到的是基准测试而不是机器,于是每次都会拒绝一台真正空闲的主机。`bench:browser` 会判定刚采到的样本并**非零退出且不归档**,同时打印原因;已经落到磁盘上的样本,会成为此后五次比较的基线,所以针对一个已存在文件打印出的判定等于没有判定。阈值有两条,适用哪一条取决于归档本身:
|
|
25
|
+
|
|
26
|
+
- **归档中已有可比较的读数时**,超过基线中位数四倍的读数被拒绝。取比值而非绝对值,是因为归档跨机器,而负载均值脱离核心数没有意义。
|
|
27
|
+
- **归档中没有任何读数时**——这正是当前归档的全部状态,因为每份报告都早于该字段——超过主机自身核心数的读数被拒绝,因为可运行负载超过核心数按定义就是超订,且这不需要任何历史即可成立。没有这一条,守卫恰好在归档最弱的地方也最弱,而第一个在繁忙主机上采到的样本,正是那个会让二十五次发布推迟 streak 结束的样本。
|
|
28
|
+
|
|
29
|
+
两条边界都取闭区间,且都是"不予*采信*"而不是"样本无效"的判定,因此误报只代价一次推迟,而不是一次发布。写入*失败*被刻意区别对待、不算拒绝:数字已经产出,磁盘写满不是对主机的判断,把两者混为一谈会让一个采样正确、只是存不下来的样本卡住一次发布。既不发布有限读数也不发布核心数的平台会放行该样本,因为没有任何东西可以据以判定它。
|
|
25
30
|
5. 用 `npm pack --dry-run --json` 确认发布包只包含预期文件。
|
|
26
31
|
6. 在功能分支提交并推送该分支,PR 验证通过后使用 squash 或 fast-forward 合入(不创建 merge commit)。获取合入后的精确提交并打 tag,只推送该版本 tag(推送之前,带着本地已存在的 tag 再把门禁跑一遍——原因见下文“打 tag 的发布工作流”,那是此前的 CI 覆盖不到的构建状态);禁止直接推送 `main`/`master` 或 force-push。工作流运行 `node scripts/verify-release-version.mjs`,要求 `RELEASE_TAG` 等于 `v` 加 package 版本,且 CHANGELOG 中恰好有一个非空的对应版本章节。同一步骤还会把 `CHANGELOG.md` 里每个版本标题与 registry 自己的 `time` 映射相对照,拒绝一份"点名了 npm 从未有过的版本"的发布记录——但只从 `0.20.85` 往上要求:在那之下有 40 个章节点名了 registry 从未记录过的版本,其中 27 个还带着 git tag(两个数都在 2026-09-25 重新核过;重算的命令要把三个集合求交——`grep -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md`、`npm view cross-tab-worker-databus time --json --registry https://registry.npmjs.org`、`git tag -l`——因为只读 registry 既得不出 40,更得不出其中带 tag 的 27),这道门禁要拦的是新的漏洞,不是去翻历史旧账。而 registry 读取失败时,这一半会被跳过并打印一行警告、退出 0,而不是把一次好发布判红:为一个瞬时的 HTTP 失败拒绝一次成功发布,正是已发布消费者上限当初要避免的那类失败,所以只有*确认存在*的缺失才会中止发布。请注意上面那条重算命令里的 `--registry`,也请注意门禁自己也会带上它:一台配置了**镜像**的机器可以成功应答,却落后 3,418 秒。这是准备 `0.21.35` 时在本机量到的——这台机器的 `npm` 指向 `registry.npmmirror.com`,它的 packument 仍报 `modified: 00:32:51Z`(也就是 `0.21.33` 那次发布),而门禁运行在 `03:41:18Z`——比 npmjs 在 `02:47:50Z` 记下 `0.21.34` 晚 3,207 秒(53 分钟)——镜像自己的 `modified` 直到 `03:44:48.778Z` 才动,比那条记录晚 3,418 秒,于是门禁把一个已经公开的版本判成"registry 从未记录",而这一次读取是**成功**的。镜像的同步滞后不是 HTTP 缓存,所以 `cache-control: no-cache` 拿回来的还是同一份旧文档;只有显式点名 registry 才能纠正。`verify-published-consumer.mjs` 早已为它的两次读取钉住了 registry,这里是仓库里最后一处没有钉的 npm 读取。正在打 tag 的那个版本本身被豁免,因为它此刻本来就不该在 npm 上。
|
|
27
32
|
|
package/docs/zh/roadmap.md
CHANGED
package/package.json
CHANGED
|
File without changes
|