cross-tab-worker-databus 0.1.2 → 0.2.0
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 +44 -0
- package/README.md +11 -0
- package/README.zh.md +9 -0
- package/dist/centrifuge-protocol.d.ts +51 -15
- package/dist/centrifuge-protocol.d.ts.map +1 -1
- package/dist/centrifuge-session.d.ts +11 -3
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts +10 -2
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +69 -26
- package/dist/centrifuge.js.map +2 -2
- package/dist/centrifuge.shared.worker.js +73 -25
- package/dist/centrifuge.shared.worker.js.map +2 -2
- package/dist/centrifuge.worker.js +41 -12
- package/dist/centrifuge.worker.js.map +2 -2
- package/dist/{chunk-53INHVYO.js → chunk-LBXREMZA.js} +263 -158
- package/dist/chunk-LBXREMZA.js.map +7 -0
- package/dist/core/cluster.d.ts +49 -4
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +19 -2
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/environment.d.ts +16 -2
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/hash.d.ts.map +1 -1
- package/dist/core/routing.d.ts +5 -2
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/storage-batch.d.ts +12 -0
- package/dist/core/storage-batch.d.ts.map +1 -1
- package/dist/core/trace.d.ts +16 -1
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/types.d.ts +76 -21
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/worker-mode.d.ts +12 -3
- package/dist/worker-mode.d.ts.map +1 -1
- package/dist/workers/port-reaper.d.ts +21 -6
- package/dist/workers/port-reaper.d.ts.map +1 -1
- package/docs/architecture.md +18 -8
- package/docs/transports.md +144 -0
- package/docs/zh/architecture.md +18 -8
- package/docs/zh/transports.md +132 -0
- package/package.json +7 -2
- package/dist/chunk-53INHVYO.js.map +0 -7
package/dist/centrifuge.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/centrifuge-session.ts", "../src/centrifuge-protocol.ts", "../src/centrifuge.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * CentrifugeSession \u2014 a reusable wrapper around a single Centrifuge client.\n *\n * Provides a structured-clone-safe message protocol (INIT/SUBSCRIBE/UNSUBSCRIBE/\n * PUBLISH/STOP) so the same session class can run inside a Dedicated Worker,\n * a SharedWorker port, or directly on the main thread as a local fallback.\n */\nimport { Centrifuge } from 'centrifuge';\nimport type {\n PublicationContext,\n StateContext,\n Subscription,\n SubscriptionErrorContext\n} from 'centrifuge';\nimport type {\n CentrifugeWorkerConfig,\n CentrifugeWorkerInput,\n CentrifugeWorkerOutput,\n SerializedWorkerError\n} from './centrifuge-protocol';\n\n/** Callback interface for posting messages back to the transport layer. */\nexport interface CentrifugeSessionSink<TData = unknown> {\n post(message: CentrifugeWorkerOutput<TData>, transfer?: ArrayBuffer[]): void;\n}\n\n/**\n * Stateful Centrifuge client wrapper shared by Dedicated Worker, SharedWorker\n * ports and the main-thread local fallback. Each session owns one connection.\n */\nexport class CentrifugeSession<TData = unknown> {\n private client: Centrifuge | null = null;\n private readonly subscriptions = new Map<string, Subscription>();\n private transferable = false;\n\n constructor(private readonly sink: CentrifugeSessionSink<TData>) {}\n\n /** Dispatch an incoming Worker message to the matching operation. */\n handle(message: CentrifugeWorkerInput): void {\n if (message.type === 'INIT') this.initialize(message.url, message.config, message.transferable === true);\n if (message.type === 'SUBSCRIBE') this.subscribe(message.topic);\n if (message.type === 'UNSUBSCRIBE') this.unsubscribe(message.topic);\n if (message.type === 'PUBLISH') this.publish(message.topic, message.data);\n if (message.type === 'PUBLISH_BIN') this.publish(message.topic, message.data);\n if (message.type === 'STOP') this.stop();\n }\n\n /** Create the Centrifuge client, wire up lifecycle listeners, and connect. */\n private initialize(url: string, config: CentrifugeWorkerConfig, transferable: boolean): void {\n if (this.client) return;\n this.transferable = transferable;\n const client = new Centrifuge(url, config);\n this.client = client;\n client.on('state', (context: StateContext) => {\n this.post({ type: 'STATUS', status: normalizeStatus(context.newState) });\n });\n client.on('connected', () => this.post({ type: 'STATUS', status: 'connected' }));\n client.on('disconnected', () => this.post({ type: 'STATUS', status: 'disconnected' }));\n client.on('error', context => this.postError(context));\n // Client-level publications are only for server-side subscriptions (where\n // no client Subscription object exists). For topics we have an active\n // subscription for, the subscription-level 'publication' listener handles\n // dispatch \u2014 skip here to avoid delivering the same message twice.\n client.on('publication', (context: PublicationContext) => {\n const topic = context.channel || getPayloadTopic(context.data);\n if (!topic || this.subscriptions.has(topic)) return;\n this.postPublication(topic, context.data);\n });\n client.connect();\n }\n\n /** Subscribe to a Centrifuge channel. Reuses an existing subscription if one exists. */\n private subscribe(topic: string): void {\n if (!this.client) return this.postError(new Error('Centrifuge client is not initialized.'));\n let subscription = this.client.getSubscription(topic);\n if (!subscription) subscription = this.client.newSubscription(topic);\n // Remove only our own listeners so that any Centrifuge internal listeners\n // on the subscription object are preserved. Each subscription event carries\n // a single listener so removeAllListeners(\u2026) is safe here.\n subscription.removeAllListeners('publication');\n subscription.removeAllListeners('error');\n subscription.removeAllListeners('unsubscribed');\n this.subscriptions.set(topic, subscription);\n subscription.on('publication', context => {\n this.postPublication(topic, context.data);\n });\n subscription.on('error', (context: SubscriptionErrorContext) => this.postError(context));\n subscription.on('unsubscribed', () => this.subscriptions.delete(topic));\n subscription.subscribe();\n }\n\n /** Unsubscribe from a Centrifuge channel and clean up the local reference.\n * Listeners are removed before unsubscribing so a late `unsubscribed` event\n * cannot delete a subscription that a subsequent `subscribe()` re-added. */\n private unsubscribe(topic: string): void {\n const subscription = this.subscriptions.get(topic) ?? this.client?.getSubscription(topic);\n if (!subscription) return;\n subscription.removeAllListeners('publication');\n subscription.removeAllListeners('error');\n subscription.removeAllListeners('unsubscribed');\n this.subscriptions.delete(topic);\n subscription.unsubscribe();\n }\n\n /** Publish a message to the Centrifuge channel. */\n private publish(topic: string, data: unknown): void {\n if (!this.client) return this.postError(new Error('Centrifuge client is not initialized.'));\n void this.client.publish(topic, data).catch(error => this.postError(error));\n }\n\n /** Forward a publication to the transport, using Transferable for binary data when enabled. */\n private postPublication(topic: string, data: unknown): void {\n if (!topic) return;\n if (this.transferable && data instanceof ArrayBuffer) {\n this.post({ type: 'MESSAGE_BIN', topic, data }, [data]);\n return;\n }\n this.post({ type: 'MESSAGE', topic, data: data as TData });\n }\n\n /** Disconnect the client and clear all subscriptions. */\n private stop(): void {\n this.client?.disconnect();\n this.subscriptions.clear();\n this.client = null;\n this.post({ type: 'STATUS', status: 'disconnected' });\n }\n\n /** Forward a message to the sink (the transport layer). */\n private post(message: CentrifugeWorkerOutput<TData>, transfer?: ArrayBuffer[]): void {\n this.sink.post(message, transfer);\n }\n\n /** Serialise and report an error. The Centrifuge client handles reconnection\n * internally, so a transient error should not trigger a `STATUS: error` that\n * would cause `selectActiveWorkers()` to exclude this worker from routing.\n * Fatal errors are distinguished by the client eventually emitting\n * `disconnected` without a subsequent `connected`. */\n private postError(error: unknown): void {\n this.post({ type: 'ERROR', error: serializeError(error) });\n }\n}\n\n/** Extract a topic from a Centrifuge publication payload if one is present. */\nfunction getPayloadTopic(data: unknown): string {\n if (!data || typeof data !== 'object') return '';\n const payload = data as { channel?: unknown; push?: { channel?: unknown } };\n const topic = payload.push?.channel ?? payload.channel;\n return typeof topic === 'string' ? topic : '';\n}\n\n/** Map a Centrifuge state string to the DataBus's status vocabulary. */\nfunction normalizeStatus(status: string): 'connecting' | 'connected' | 'disconnected' {\n if (status === 'connecting' || status === 'connected') return status;\n return 'disconnected';\n}\n\n/** Convert an arbitrary error into a structured-cloneable form for postMessage. */\nfunction serializeError(error: unknown): SerializedWorkerError {\n if (error instanceof Error) {\n return {\n name: error.name,\n message: error.message,\n ...(error.stack ? { stack: error.stack } : {})\n };\n }\n return {\n name: 'CentrifugeError',\n message: typeof error === 'string' ? error : 'Centrifuge worker operation failed.',\n ...(error === undefined ? {} : { context: error })\n };\n}\n", "/**\n * Worker-thread protocol for Centrifuge WebSocket transport.\n *\n * Defines the message types exchanged between the main thread and a Web Worker\n * (dedicated or shared) that runs a centrifuge client. The worker is isolated\n * from the main thread so that WebSocket lifecycle, token refresh, and binary\n * data handling never block the UI.\n */\nimport type { Options } from 'centrifuge';\nimport type { WorkerStatus } from './core/types';\n\n/**\n * Default interval between main-thread PING heartbeats to a SharedWorker. The\n * SharedWorker reaps a silent port after `SESSION_TIMEOUT_MULTIPLIER` intervals.\n * Shared here so the main thread (heartbeat sender) and the SharedWorker (reaper)\n * always agree on the cadence.\n */\nexport const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;\n/** SharedWorker per-port session timeout, expressed as a multiple of that port's\n * heartbeat interval. A live port PINGs every heartbeat interval, so a timeout of\n * several intervals tolerates throttled tabs without reaping healthy sessions. */\nexport const DEFAULT_SESSION_TIMEOUT_MULTIPLIER = 3;\n\n// Centrifuge Options that reference browser APIs (WebSocket, EventSource, etc.)\n// are unavailable inside a Worker \u2014 the worker uses its own WebSocket import.\ntype WorkerUnsafeOption =\n | 'eventsource'\n | 'fetch'\n | 'getData'\n | 'getToken'\n | 'networkEventTarget'\n | 'readableStream'\n | 'sockjs'\n | 'websocket';\n\n/** Centrifuge options safe to pass into a Worker; unsafe options are explicitly excluded. */\nexport type CentrifugeWorkerConfig = Omit<Partial<Options>, WorkerUnsafeOption> & {\n [Key in WorkerUnsafeOption]?: never;\n};\n\n/** Messages sent from the main thread to the Worker. */\nexport type CentrifugeWorkerInput =\n | { type: 'INIT'; url: string; config: CentrifugeWorkerConfig; transferable?: boolean; heartbeatIntervalMs?: number }\n | { type: 'SUBSCRIBE'; topic: string }\n | { type: 'UNSUBSCRIBE'; topic: string }\n | { type: 'PUBLISH'; topic: string; data: unknown }\n | { type: 'PUBLISH_BIN'; topic: string; data: ArrayBuffer }\n | { type: 'PING' }\n | { type: 'STOP' };\n\n/** Messages sent from the Worker back to the main thread. */\nexport type CentrifugeWorkerOutput<TData = unknown> =\n | { type: 'STATUS'; status: WorkerStatus }\n | { type: 'MESSAGE'; topic: string; data: TData }\n | { type: 'MESSAGE_BIN'; topic: string; data: ArrayBuffer }\n | { type: 'ERROR'; error: SerializedWorkerError };\n\n/** Error object serialized for cross-thread transfer (Error instances cannot be cloned via postMessage). */\nexport interface SerializedWorkerError {\n name: string;\n message: string;\n stack?: string;\n context?: unknown;\n}\n", "/**\n * Centrifuge WebSocket transport that runs inside a Web Worker.\n *\n * Supports three backends, selected in order of preference:\n * 1. SharedWorker \u2014 one WebSocket per tab session, hosted in a shared process\n * 2. Dedicated Worker \u2014 one WebSocket per tab\n * 3. In-process (local) \u2014 Centrifuge runs on the main thread (fallback when\n * neither Worker type is available, e.g. in non-browser environments)\n *\n * Binary data (ArrayBuffer) can be transferred via Transferable when the\n * `transferable` option is enabled, avoiding structured-clone overhead.\n */\n\nimport { CrossTabDataBus } from './core/data-bus';\nimport type { CrossTabDataBusOptions } from './core/data-bus';\nimport type { DataBusTransport, DataBusTransportHandlers } from './core/types';\nimport { CentrifugeSession } from './centrifuge-session';\nimport { selectWorkerBackend } from './worker-mode';\nimport type { WorkerBackend, WorkerMode } from './worker-mode';\nimport type {\n CentrifugeWorkerConfig,\n CentrifugeWorkerInput,\n CentrifugeWorkerOutput,\n SerializedWorkerError\n} from './centrifuge-protocol';\nimport { DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_SESSION_TIMEOUT_MULTIPLIER } from './centrifuge-protocol';\n\nexport type { CentrifugeWorkerConfig, SerializedWorkerError } from './centrifuge-protocol';\nexport type { WorkerBackend, WorkerMode } from './worker-mode';\n\n/** WebSocket connection parameters passed to the Centrifuge Worker. */\nexport interface CentrifugeDataBusConfig {\n /** Centrifuge server WebSocket URL. */\n url: string;\n /** Centrifuge client options (token, channel params, etc.). */\n options?: CentrifugeWorkerConfig;\n}\n\n/** Options for configuring the Worker backend (dedicated, shared, or local). */\nexport interface CentrifugeWorkerTransportOptions {\n /** Custom dedicated Worker factory. Used for testing or bundler integration. */\n workerFactory?: () => Worker;\n /** Custom SharedWorker factory. */\n sharedWorkerFactory?: () => SharedWorker;\n /** Preferred Worker mode: 'dedicated', 'shared', or 'auto'. */\n workerMode?: WorkerMode;\n /** Enable transferable (ArrayBuffer) support for binary data. */\n transferable?: boolean;\n /** Interval (ms) between PING heartbeats sent to the SharedWorker. The\n * SharedWorker reaps a silent port after `DEFAULT_SESSION_TIMEOUT_MULTIPLIER`\n * \u00D7 this interval. Pass `Infinity` to disable heartbeats entirely. Defaults\n * to `DEFAULT_HEARTBEAT_INTERVAL_MS`. */\n heartbeatIntervalMs?: number;\n}\n\n/** Options for creating a fully-configured CrossTabDataBus with a Centrifuge transport. */\nexport interface CreateCentrifugeDataBusOptions<TData = unknown>\n extends Omit<\n CrossTabDataBusOptions<CentrifugeDataBusConfig, TData>,\n 'autoStart' | 'clusterKey' | 'initialConfig' | 'transport'\n >,\n CentrifugeWorkerTransportOptions {\n /** Centrifuge connection configuration. */\n connection: CentrifugeDataBusConfig;\n /** Cluster key for cross-tab coordination. Defaults to the connection URL. */\n clusterKey?: string;\n}\n\n/**\n * Transport layer that runs a Centrifuge WebSocket client inside a Web Worker.\n *\n * Delegates the actual WebSocket connection to a Worker (dedicated or shared)\n * or falls back to an in-process CentrifugeSession. The Worker is isolated from\n * the main thread so that WebSocket lifecycle, token refresh, and binary data\n * handling never block the UI.\n */\nexport class CentrifugeWorkerTransport<TData = unknown>\n implements DataBusTransport<CentrifugeDataBusConfig, TData>\n{\n private readonly workerMode: WorkerMode;\n private readonly transferable: boolean;\n private readonly heartbeatIntervalMs: number;\n private readonly workerFactory: (() => Worker) | undefined;\n private readonly sharedWorkerFactory: (() => SharedWorker) | undefined;\n private backend: WorkerBackend | null = null;\n private worker: Worker | null = null;\n private sharedWorker: SharedWorker | null = null;\n private port: MessagePort | null = null;\n private heartbeatHandle: ReturnType<typeof setInterval> | null = null;\n private localSession: CentrifugeSession<TData> | null = null;\n private handlers: DataBusTransportHandlers<TData> | null = null;\n // Monotonically increasing counter, bumped each time a backend is created.\n // Used to ignore late error events from a superseded Worker.\n private generation = 0;\n // Generation captured when the current backend was created. Error handlers\n // only act when the backend that registered them is still current.\n private backendGeneration = 0;\n\n constructor(options: CentrifugeWorkerTransportOptions = {}) {\n this.workerMode = options.workerMode ?? 'dedicated';\n this.transferable = options.transferable ?? false;\n this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;\n assertHeartbeatInterval(this.heartbeatIntervalMs);\n this.workerFactory = options.workerFactory;\n this.sharedWorkerFactory = options.sharedWorkerFactory;\n }\n\n /**\n * Start the transport: select a backend, initialise the Worker (or local\n * session), and send the INIT message with connection parameters.\n */\n start(config: CentrifugeDataBusConfig, handlers: DataBusTransportHandlers<TData>): void {\n if (this.backend) return;\n assertStructuredCloneable(config.options ?? {});\n this.handlers = handlers;\n const backend = selectWorkerBackend(this.workerMode, {\n worker: this.workerFactory !== undefined,\n sharedWorker: this.sharedWorkerFactory !== undefined\n });\n const input: CentrifugeWorkerInput = {\n type: 'INIT',\n url: config.url,\n config: config.options ?? {},\n ...(this.transferable ? { transferable: true } : {}),\n ...(this.heartbeatIntervalMs !== DEFAULT_HEARTBEAT_INTERVAL_MS\n ? { heartbeatIntervalMs: this.heartbeatIntervalMs }\n : {})\n };\n if (backend === 'shared') {\n this.startSharedWorker(input);\n this.backend = 'shared';\n return;\n }\n if (backend === 'dedicated') {\n this.startDedicatedWorker(input);\n this.backend = 'dedicated';\n return;\n }\n this.localSession = new CentrifugeSession<TData>({ post: this.handleSessionOutput });\n this.localSession.handle(input);\n this.backend = 'local';\n }\n\n subscribe(topic: string): void {\n this.post({ type: 'SUBSCRIBE', topic });\n }\n\n unsubscribe(topic: string): void {\n this.post({ type: 'UNSUBSCRIBE', topic });\n }\n\n /**\n * Publish data to `topic`. Binary data (ArrayBuffer) is sent via Transferable\n * when `transferable` is enabled, avoiding a structured-clone cycle.\n */\n publish(topic: string, data: unknown): void {\n if (this.transferable && data instanceof ArrayBuffer) {\n this.post({ type: 'PUBLISH_BIN', topic, data }, [data]);\n return;\n }\n this.post({ type: 'PUBLISH', topic, data });\n }\n\n /**\n * Gracefully stop the transport: send STOP, clean up event listeners, and\n * terminate the Worker (or close the SharedWorker port).\n */\n stop(): void {\n if (!this.backend) return;\n this.generation++;\n this.post({ type: 'STOP' });\n this.clearHeartbeat();\n if (this.worker) {\n this.worker.removeEventListener('message', this.handleMessage);\n this.worker.removeEventListener('error', this.handleWorkerError);\n this.worker.terminate();\n }\n if (this.sharedWorker) {\n this.detachSharedWorkerListeners();\n this.port?.close();\n }\n this.resetBackend();\n this.handlers = null;\n }\n\n /** Create and initialise a dedicated Worker, then send the INIT message. */\n private startDedicatedWorker(input: CentrifugeWorkerInput): void {\n this.backendGeneration = ++this.generation;\n const worker = (this.workerFactory ?? createDefaultWorker)();\n this.worker = worker;\n worker.addEventListener('message', this.handleMessage);\n worker.addEventListener('error', this.handleWorkerError);\n worker.postMessage(input);\n }\n\n /** Create and initialise a SharedWorker, open the MessagePort, and send the INIT message. */\n private startSharedWorker(input: CentrifugeWorkerInput): void {\n this.backendGeneration = ++this.generation;\n const shared = (this.sharedWorkerFactory ?? createDefaultSharedWorker)();\n this.sharedWorker = shared;\n const port = shared.port;\n this.port = port;\n port.addEventListener('message', this.handleMessage);\n port.addEventListener('messageerror', this.handlePortError);\n shared.addEventListener('error', this.handleSharedWorkerError);\n port.start();\n port.postMessage(input);\n this.startHeartbeat();\n }\n\n /** Handle a message event from the Worker (dedicated or shared). */\n private readonly handleMessage = (event: MessageEvent<CentrifugeWorkerOutput<TData>>) => {\n this.handleOutput(event.data);\n };\n\n /** Handle a message from the in-process CentrifugeSession (local fallback). */\n private readonly handleSessionOutput = (message: CentrifugeWorkerOutput<TData>) => {\n this.handleOutput(message);\n };\n\n /** Route a Worker output message to the appropriate handler callback. */\n private handleOutput(message: CentrifugeWorkerOutput<TData>): void {\n if (message.type === 'STATUS') this.handlers?.onStatus(message.status);\n if (message.type === 'MESSAGE') this.handlers?.onMessage({ topic: message.topic, data: message.data });\n if (message.type === 'MESSAGE_BIN') this.handlers?.onMessage({ topic: message.topic, data: message.data as TData });\n if (message.type === 'ERROR') this.handlers?.onError(deserializeWorkerError(message.error));\n }\n\n /** Handle a Worker-level failure (crash, message decode error). Discards the\n * dead backend so a later start()/reopen can rebuild from scratch, and\n * signals an error status so the DataBus can trigger recovery. */\n private onWorkerFailed(message: string): void {\n // Remove the message listener and release the old backend before recovery\n // reopens, so late messages from the failed Worker/port cannot be routed\n // into the freshly started session.\n this.worker?.removeEventListener('message', this.handleMessage);\n this.worker?.removeEventListener('error', this.handleWorkerError);\n this.worker?.terminate();\n this.detachSharedWorkerListeners();\n this.port?.close();\n this.clearHeartbeat();\n this.resetBackend();\n this.handlers?.onError(new Error(message));\n this.handlers?.onStatus('error');\n }\n\n /** Remove every listener attached to the current SharedWorker and its port. */\n private detachSharedWorkerListeners(): void {\n this.port?.removeEventListener('message', this.handleMessage);\n this.port?.removeEventListener('messageerror', this.handlePortError);\n this.sharedWorker?.removeEventListener('error', this.handleSharedWorkerError);\n }\n\n /** Periodically ping the SharedWorker so its session reaper can detect a dead tab. */\n private startHeartbeat(): void {\n if (this.heartbeatHandle !== null) return;\n // Infinite disables the heartbeat (e.g. for environment where the\n // SharedWorker reaper is not needed).\n if (this.heartbeatIntervalMs === Infinity) return;\n this.heartbeatHandle = setInterval(() => {\n this.post({ type: 'PING' });\n }, this.heartbeatIntervalMs);\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatHandle !== null) clearInterval(this.heartbeatHandle);\n this.heartbeatHandle = null;\n }\n\n private readonly handleWorkerError = () => {\n if (this.generation !== this.backendGeneration) return;\n this.onWorkerFailed('Centrifuge worker failed.');\n };\n\n private readonly handlePortError = () => {\n if (this.generation !== this.backendGeneration) return;\n this.onWorkerFailed('Centrifuge shared worker message decoding failed.');\n };\n\n private readonly handleSharedWorkerError = () => {\n if (this.generation !== this.backendGeneration) return;\n this.onWorkerFailed('Centrifuge shared worker failed.');\n };\n\n /** Clear the Worker/port/backend references after a failure or stop. */\n private resetBackend(): void {\n this.worker = null;\n this.sharedWorker = null;\n this.port = null;\n this.backend = null;\n this.localSession = null;\n }\n\n /**\n * Post a message to the active backend. Accepts optional Transferable buffers\n * for zero-copy ArrayBuffer transfer.\n */\n private post(message: CentrifugeWorkerInput, transfer?: ArrayBuffer[]): void {\n if (this.worker) {\n if (transfer) this.worker.postMessage(message, transfer as Transferable[]);\n else this.worker.postMessage(message);\n return;\n }\n if (this.port) {\n if (transfer) this.port.postMessage(message, transfer as Transferable[]);\n else this.port.postMessage(message);\n return;\n }\n if (this.localSession) {\n this.localSession.handle(message);\n return;\n }\n throw new Error('CentrifugeWorkerTransport.start() must be called first.');\n }\n}\n\n/**\n * Create a fully-configured CrossTabDataBus with a Centrifuge WebSocket transport.\n *\n * This is the primary entry point for consumers. It wires up the transport,\n * cluster coordination, and lifecycle management:\n *\n * ```ts\n * const bus = createCentrifugeDataBus({\n * connection: { url: 'wss://example.com/connection/websocket', options: { token: '\u2026' } },\n * trace: { enabled: true, sink: event => console.log(event) },\n * });\n * ```\n */\nexport function createCentrifugeDataBus<TData = unknown>(\n options: CreateCentrifugeDataBusOptions<TData>\n): CrossTabDataBus<CentrifugeDataBusConfig, TData> {\n const {\n clusterKey,\n connection,\n heartbeatIntervalMs,\n sharedWorkerFactory,\n transferable,\n workerFactory,\n workerMode,\n ...dataBusOptions\n } = options;\n return new CrossTabDataBus({\n ...dataBusOptions,\n autoStart: true,\n clusterKey: clusterKey ?? connection.url,\n initialConfig: connection,\n transport: new CentrifugeWorkerTransport<TData>({\n ...(workerFactory ? { workerFactory } : {}),\n ...(sharedWorkerFactory ? { sharedWorkerFactory } : {}),\n ...(transferable === undefined ? {} : { transferable }),\n ...(workerMode ? { workerMode } : {}),\n ...(heartbeatIntervalMs === undefined ? {} : { heartbeatIntervalMs })\n })\n });\n}\n\n\n/** Create the default dedicated Worker hosting the Centrifuge client. */\nfunction createDefaultWorker(): Worker {\n if (typeof Worker === 'undefined') {\n throw new Error('CentrifugeWorkerTransport requires a browser Worker implementation.');\n }\n return new Worker(new URL('./centrifuge.worker.js', import.meta.url), {\n name: 'cross-tab-worker-databus',\n type: 'module'\n });\n}\n\n/** Create the default SharedWorker. Each connecting port within the SharedWorker\n * creates its own CentrifugeSession with an independent WebSocket connection,\n * so refreshing or stopping one tab does not affect the others. */\nfunction createDefaultSharedWorker(): SharedWorker {\n if (typeof SharedWorker === 'undefined') {\n throw new Error('CentrifugeWorkerTransport requires a browser SharedWorker implementation.');\n }\n return new SharedWorker(new URL('./centrifuge.shared.worker.js', import.meta.url), {\n name: 'cross-tab-worker-databus-shared',\n type: 'module'\n });\n}\n\n/**\n * Validate the SharedWorker PING heartbeat interval. A value of `0`, a negative\n * number, or `NaN` would otherwise make `setInterval` degenerate into a 0ms busy\n * loop, driving the reaper and the main-thread PING out of control. `Infinity`\n * is allowed and disables heartbeats entirely.\n */\nfunction assertHeartbeatInterval(value: number): void {\n if (value === Infinity) return;\n if (typeof value === 'number' && Number.isFinite(value) && value > 0) return;\n throw new TypeError(\n `Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`\n );\n}\n\n/**\n * Validate that `value` is structured-cloneable. Throws early so config errors\n * surface on the main thread rather than silently failing inside the Worker.\n * Skips validation when `structuredClone` is unavailable (older browsers).\n */\nfunction assertStructuredCloneable(value: unknown): void {\n if (typeof structuredClone !== 'function') return;\n try {\n structuredClone(value);\n } catch (error) {\n throw new TypeError(\n 'Centrifuge Worker configuration and published data must be structured-cloneable.',\n { cause: error }\n );\n }\n}\n\n/** Reconstruct an Error instance from its serialised form (Error cannot be cloned via postMessage). */\nfunction deserializeWorkerError(error: SerializedWorkerError): Error {\n const result = new Error(error.message);\n result.name = error.name;\n if (error.stack) result.stack = error.stack;\n if (error.context !== undefined) Object.assign(result, { context: error.context });\n return result;\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;AAOA,SAAS,kBAAkB;AAuBpB,IAAM,oBAAN,MAAyC;AAAA,EAK9C,YAA6B,MAAoC;AAApC;AAAA,EAAqC;AAAA,EAJ1D,SAA4B;AAAA,EACnB,gBAAgB,oBAAI,IAA0B;AAAA,EACvD,eAAe;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["/**\n * CentrifugeSession \u2014 a reusable wrapper around a single Centrifuge client.\n *\n * Provides a structured-clone-safe message protocol (INIT/SUBSCRIBE/UNSUBSCRIBE/\n * PUBLISH/STOP) so the same session class can run inside a Dedicated Worker,\n * a SharedWorker port, or directly on the main thread as a local fallback.\n */\nimport { Centrifuge } from 'centrifuge';\nimport type {\n PublicationContext,\n StateContext,\n Subscription,\n SubscriptionErrorContext\n} from 'centrifuge';\nimport type {\n CentrifugeWorkerConfig,\n CentrifugeWorkerInput,\n CentrifugeWorkerOutput,\n SerializedWorkerError\n} from './centrifuge-protocol';\n\n/** Callback interface for posting messages back to the transport layer. */\nexport interface CentrifugeSessionSink<TData = unknown> {\n post(message: CentrifugeWorkerOutput<TData>, transfer?: ArrayBuffer[]): void;\n}\n\n/**\n * Stateful Centrifuge client wrapper shared by Dedicated Worker, SharedWorker\n * ports and the main-thread local fallback. Each session owns one connection.\n */\nexport class CentrifugeSession<TData = unknown> {\n private client: Centrifuge | null = null;\n private readonly subscriptions = new Map<string, Subscription>();\n private transferable = false;\n\n constructor(private readonly sink: CentrifugeSessionSink<TData>) {}\n\n /** Dispatch an incoming Worker message to the matching operation.\n * Unknown message types are ignored rather than thrown, so a future protocol\n * extension adding a new variant cannot crash an older session. */\n handle(message: CentrifugeWorkerInput): void {\n switch (message.type) {\n case 'INIT':\n this.initialize(message.url, message.config, message.transferable === true);\n return;\n case 'SUBSCRIBE':\n this.subscribe(message.topic);\n return;\n case 'UNSUBSCRIBE':\n this.unsubscribe(message.topic);\n return;\n case 'PUBLISH':\n case 'PUBLISH_BIN':\n // Binary and JSON publish share the same Centrifuge client call; the\n // transport layer decides whether to transfer the ArrayBuffer.\n this.publish(message.topic, message.data);\n return;\n case 'STOP':\n this.stop();\n return;\n default:\n return;\n }\n }\n\n /** Create the Centrifuge client, wire up lifecycle listeners, and connect. */\n private initialize(url: string, config: CentrifugeWorkerConfig, transferable: boolean): void {\n if (this.client) return;\n this.transferable = transferable;\n const client = new Centrifuge(url, config);\n this.client = client;\n client.on('state', (context: StateContext) => {\n this.post({ type: 'STATUS', status: normalizeStatus(context.newState) });\n });\n client.on('connected', () => this.post({ type: 'STATUS', status: 'connected' }));\n client.on('disconnected', () => this.post({ type: 'STATUS', status: 'disconnected' }));\n client.on('error', context => this.postError(context));\n // Client-level publications are only for server-side subscriptions (where\n // no client Subscription object exists). For topics we have an active\n // subscription for, the subscription-level 'publication' listener handles\n // dispatch \u2014 skip here to avoid delivering the same message twice.\n client.on('publication', (context: PublicationContext) => {\n const topic = context.channel || getPayloadTopic(context.data);\n if (!topic || this.subscriptions.has(topic)) return;\n this.postPublication(topic, context.data);\n });\n client.connect();\n }\n\n /** Subscribe to a Centrifuge channel. Reuses an existing subscription if one exists.\n * Listeners are only registered once per subscription object \u2014 a repeated\n * SUBSCRIBE for an already-tracked topic skips the listener wiring entirely,\n * avoiding the removeAllListeners + re-on churn on every duplicate message. */\n private subscribe(topic: string): void {\n if (!this.client) return this.postError(new Error('Centrifuge client is not initialized.'));\n // If we already track this subscription, it already has our listeners \u2014\n // a duplicate SUBSCRIBE is a no-op (idempotent), matching the transport\n // contract. Only a fresh subscription needs listener wiring.\n const existing = this.subscriptions.get(topic);\n if (existing) {\n existing.subscribe();\n return;\n }\n let subscription = this.client.getSubscription(topic);\n if (!subscription) subscription = this.client.newSubscription(topic);\n // Remove only our own listeners so that any Centrifuge internal listeners\n // on the subscription object are preserved. Each subscription event carries\n // a single listener so removeAllListeners(\u2026) is safe here.\n subscription.removeAllListeners('publication');\n subscription.removeAllListeners('error');\n subscription.removeAllListeners('unsubscribed');\n this.subscriptions.set(topic, subscription);\n subscription.on('publication', context => {\n this.postPublication(topic, context.data);\n });\n subscription.on('error', (context: SubscriptionErrorContext) => this.postError(context));\n subscription.on('unsubscribed', () => this.subscriptions.delete(topic));\n subscription.subscribe();\n }\n\n /** Unsubscribe from a Centrifuge channel and clean up the local reference.\n * Listeners are removed before unsubscribing so a late `unsubscribed` event\n * cannot delete a subscription that a subsequent `subscribe()` re-added. */\n private unsubscribe(topic: string): void {\n const subscription = this.subscriptions.get(topic) ?? this.client?.getSubscription(topic);\n if (!subscription) return;\n subscription.removeAllListeners('publication');\n subscription.removeAllListeners('error');\n subscription.removeAllListeners('unsubscribed');\n this.subscriptions.delete(topic);\n subscription.unsubscribe();\n }\n\n /** Publish a message to the Centrifuge channel. */\n private publish(topic: string, data: unknown): void {\n if (!this.client) return this.postError(new Error('Centrifuge client is not initialized.'));\n void this.client.publish(topic, data).catch(error => this.postError(error));\n }\n\n /** Forward a publication to the transport. Binary payloads take the\n * zero-copy `MESSAGE_BIN` path when `transferable` is enabled; everything\n * else is structured-cloned via `MESSAGE`. An empty topic means the\n * publication carried no channel info and is silently dropped. */\n private postPublication(topic: string, data: unknown): void {\n if (!topic) return;\n if (this.transferable && data instanceof ArrayBuffer) {\n this.post({ type: 'MESSAGE_BIN', topic, data }, [data]);\n return;\n }\n this.post({ type: 'MESSAGE', topic, data: data as TData });\n }\n\n /** Disconnect the client and clear all subscriptions. */\n private stop(): void {\n this.client?.disconnect();\n this.subscriptions.clear();\n this.client = null;\n this.post({ type: 'STATUS', status: 'disconnected' });\n }\n\n /** Forward a message to the sink (the transport layer). */\n private post(message: CentrifugeWorkerOutput<TData>, transfer?: ArrayBuffer[]): void {\n this.sink.post(message, transfer);\n }\n\n /** Serialise and report an error. The Centrifuge client handles reconnection\n * internally, so a transient error should not trigger a `STATUS: error` that\n * would cause `selectActiveWorkers()` to exclude this worker from routing.\n * Fatal errors are distinguished by the client eventually emitting\n * `disconnected` without a subsequent `connected`. */\n private postError(error: unknown): void {\n this.post({ type: 'ERROR', error: serializeError(error) });\n }\n}\n\n/** Extract a topic from a Centrifuge publication payload if one is present.\n * Handles both direct `channel` fields and the nested `push.channel` shape\n * that Centrifugo uses for some server-side push types. */\nfunction getPayloadTopic(data: unknown): string {\n if (!data || typeof data !== 'object') return '';\n const payload = data as Record<string, unknown>;\n // Prefer nested push.channel (server-side push) then fall back to top-level channel.\n const push = payload.push;\n const nested = typeof push === 'object' && push !== null ? (push as Record<string, unknown>).channel : undefined;\n const topic = nested ?? payload.channel;\n return typeof topic === 'string' ? topic : '';\n}\n\n/** Map a Centrifuge state string to the DataBus's status vocabulary.\n * 'connecting' and 'connected' pass through; anything else (e.g. 'reconnecting',\n * 'disconnected') maps to 'disconnected'. */\nconst LIVE_STATES = new Set(['connecting', 'connected']);\nfunction normalizeStatus(status: string): 'connecting' | 'connected' | 'disconnected' {\n return LIVE_STATES.has(status) ? (status as 'connecting' | 'connected') : 'disconnected';\n}\n\n/** Convert an arbitrary error into a structured-cloneable form for postMessage. */\nfunction serializeError(error: unknown): SerializedWorkerError {\n if (error instanceof Error) {\n return {\n name: error.name,\n message: error.message,\n ...(error.stack ? { stack: error.stack } : {})\n };\n }\n return {\n name: 'CentrifugeError',\n message: typeof error === 'string' ? error : 'Centrifuge worker operation failed.',\n ...(error === undefined ? {} : { context: error })\n };\n}\n", "/**\n * Worker-thread protocol for Centrifuge WebSocket transport.\n *\n * Defines the message types exchanged between the main thread and a Web Worker\n * (dedicated or shared) that runs a centrifuge client. The worker is isolated\n * from the main thread so that WebSocket lifecycle, token refresh, and binary\n * data handling never block the UI.\n */\nimport type { Options } from 'centrifuge';\nimport type { WorkerStatus } from './core/types';\n\n/**\n * Default interval between main-thread PING heartbeats to a SharedWorker. The\n * SharedWorker reaps a silent port after `SESSION_TIMEOUT_MULTIPLIER` intervals.\n * Shared here so the main thread (heartbeat sender) and the SharedWorker (reaper)\n * always agree on the cadence.\n */\nexport const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;\n/** SharedWorker per-port session timeout, expressed as a multiple of that port's\n * heartbeat interval. A live port PINGs every heartbeat interval, so a timeout of\n * several intervals tolerates throttled tabs without reaping healthy sessions. */\nexport const DEFAULT_SESSION_TIMEOUT_MULTIPLIER = 3;\n/** Default per-port session timeout in ms, derived from the heartbeat interval\n * and the multiplier. Used as the fallback when a port sends no INIT config. */\nexport const DEFAULT_SESSION_TIMEOUT_MS =\n DEFAULT_HEARTBEAT_INTERVAL_MS * DEFAULT_SESSION_TIMEOUT_MULTIPLIER;\n\n// Centrifuge Options that reference browser APIs (WebSocket, EventSource, etc.)\n// are unavailable inside a Worker \u2014 the worker uses its own WebSocket import.\n// These are stripped from the config sent to the worker so the type system\n// prevents accidentally passing a main-thread-only function (which would fail\n// structured cloning and throw a DataCloneError).\ntype WorkerUnsafeOption =\n | 'eventsource'\n | 'fetch'\n | 'getData'\n | 'getToken'\n | 'networkEventTarget'\n | 'readableStream'\n | 'sockjs'\n | 'websocket';\n\n/** Centrifuge options safe to pass into a Worker; unsafe options are explicitly\n * excluded and set to `never` so the compiler rejects them at the call site. */\nexport type CentrifugeWorkerConfig = Omit<Partial<Options>, WorkerUnsafeOption> & {\n [Key in WorkerUnsafeOption]?: never;\n};\n\n/** Messages sent from the main thread to the Worker. All variants are\n * structured-cloneable; `PUBLISH_BIN` carries an ArrayBuffer (transferable). */\nexport type CentrifugeWorkerInput =\n /** Initial connection: URL + config. Sent once per backend creation.\n * `transferable` enables ArrayBuffer zero-copy for subsequent PUBLISH_BIN.\n * `heartbeatIntervalMs` overrides the SharedWorker PING cadence. */\n | { type: 'INIT'; url: string; config: CentrifugeWorkerConfig; transferable?: boolean; heartbeatIntervalMs?: number }\n /** Subscribe to a channel. Idempotent \u2014 re-subscribing is a no-op. */\n | { type: 'SUBSCRIBE'; topic: string }\n /** Unsubscribe from a channel. Idempotent. */\n | { type: 'UNSUBSCRIBE'; topic: string }\n /** Publish a structured-cloneable payload to a channel. */\n | { type: 'PUBLISH'; topic: string; data: unknown }\n /** Publish an ArrayBuffer via Transferable (zero-copy when `transferable` is on). */\n | { type: 'PUBLISH_BIN'; topic: string; data: ArrayBuffer }\n /** Heartbeat from the main thread; the SharedWorker reaps silent ports. */\n | { type: 'PING' }\n /** Disconnect the client and clear all subscriptions. */\n | { type: 'STOP' };\n\n/** Messages sent from the Worker back to the main thread. The main thread\n * routes these to the DataBusTransportHandlers via handleOutput(). */\nexport type CentrifugeWorkerOutput<TData = unknown> =\n /** Connection status changed. Maps Centrifuge states to the DataBus vocabulary. */\n | { type: 'STATUS'; status: WorkerStatus }\n /** A JSON publication arrived. Routed to onMessage via handleOutput. */\n | { type: 'MESSAGE'; topic: string; data: TData }\n /** A binary publication arrived (Transferable). Routed to onMessage with the ArrayBuffer. */\n | { type: 'MESSAGE_BIN'; topic: string; data: ArrayBuffer }\n /** A non-fatal error occurred. Does not imply disconnection (the client retries internally). */\n | { type: 'ERROR'; error: SerializedWorkerError };\n\n/** Error object serialized for cross-thread transfer. Error instances cannot\n * be structured-cloned via postMessage, so the Worker converts them to this\n * shape and the main thread rebuilds an Error via deserializeWorkerError(). */\nexport interface SerializedWorkerError {\n /** The Error's `name` (e.g. 'TypeError', 'CentrifugeError'). */\n name: string;\n /** The Error's `message`. */\n message: string;\n /** The Error's `stack` if available (for debugging). */\n stack?: string;\n /** Arbitrary context attached by the Worker (e.g. the failing operation). */\n context?: unknown;\n}\n", "/**\n * Centrifuge WebSocket transport that runs inside a Web Worker.\n *\n * Supports three backends, selected in order of preference:\n * 1. SharedWorker \u2014 one WebSocket per tab session, hosted in a shared process\n * 2. Dedicated Worker \u2014 one WebSocket per tab\n * 3. In-process (local) \u2014 Centrifuge runs on the main thread (fallback when\n * neither Worker type is available, e.g. in non-browser environments)\n *\n * Binary data (ArrayBuffer) can be transferred via Transferable when the\n * `transferable` option is enabled, avoiding structured-clone overhead.\n */\n\nimport { CrossTabDataBus } from './core/data-bus';\nimport type { CrossTabDataBusOptions } from './core/data-bus';\nimport type { DataBusTransport, DataBusTransportHandlers } from './core/types';\nimport { CentrifugeSession } from './centrifuge-session';\nimport { selectWorkerBackend } from './worker-mode';\nimport type { WorkerBackend, WorkerMode } from './worker-mode';\nimport type {\n CentrifugeWorkerConfig,\n CentrifugeWorkerInput,\n CentrifugeWorkerOutput,\n SerializedWorkerError\n} from './centrifuge-protocol';\nimport { DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_SESSION_TIMEOUT_MULTIPLIER } from './centrifuge-protocol';\n\nexport type { CentrifugeWorkerConfig, SerializedWorkerError } from './centrifuge-protocol';\nexport type { WorkerBackend, WorkerMode } from './worker-mode';\n\n/** WebSocket connection parameters passed to the Centrifuge Worker. */\nexport interface CentrifugeDataBusConfig {\n /** Centrifuge server WebSocket URL. */\n url: string;\n /** Centrifuge client options (token, channel params, etc.). */\n options?: CentrifugeWorkerConfig;\n}\n\n/** Options for configuring the Worker backend (dedicated, shared, or local). */\nexport interface CentrifugeWorkerTransportOptions {\n /** Custom dedicated Worker factory. Used for testing or bundler integration. */\n workerFactory?: () => Worker;\n /** Custom SharedWorker factory. */\n sharedWorkerFactory?: () => SharedWorker;\n /** Preferred Worker mode: 'dedicated', 'shared', or 'auto'. */\n workerMode?: WorkerMode;\n /** Enable transferable (ArrayBuffer) support for binary data. */\n transferable?: boolean;\n /** Interval (ms) between PING heartbeats sent to the SharedWorker. The\n * SharedWorker reaps a silent port after `DEFAULT_SESSION_TIMEOUT_MULTIPLIER`\n * \u00D7 this interval. Pass `Infinity` to disable heartbeats entirely. Defaults\n * to `DEFAULT_HEARTBEAT_INTERVAL_MS`. */\n heartbeatIntervalMs?: number;\n}\n\n/** Options for creating a fully-configured CrossTabDataBus with a Centrifuge transport. */\nexport interface CreateCentrifugeDataBusOptions<TData = unknown>\n extends Omit<\n CrossTabDataBusOptions<CentrifugeDataBusConfig, TData>,\n 'autoStart' | 'clusterKey' | 'initialConfig' | 'transport'\n >,\n CentrifugeWorkerTransportOptions {\n /** Centrifuge connection configuration. */\n connection: CentrifugeDataBusConfig;\n /** Cluster key for cross-tab coordination. Defaults to the connection URL. */\n clusterKey?: string;\n}\n\n/**\n * Transport layer that runs a Centrifuge WebSocket client inside a Web Worker.\n *\n * Delegates the actual WebSocket connection to a Worker (dedicated or shared)\n * or falls back to an in-process CentrifugeSession. The Worker is isolated from\n * the main thread so that WebSocket lifecycle, token refresh, and binary data\n * handling never block the UI.\n */\nexport class CentrifugeWorkerTransport<TData = unknown>\n implements DataBusTransport<CentrifugeDataBusConfig, TData>\n{\n private readonly workerMode: WorkerMode;\n private readonly transferable: boolean;\n private readonly heartbeatIntervalMs: number;\n private readonly workerFactory: (() => Worker) | undefined;\n private readonly sharedWorkerFactory: (() => SharedWorker) | undefined;\n private backend: WorkerBackend | null = null;\n private worker: Worker | null = null;\n private sharedWorker: SharedWorker | null = null;\n private port: MessagePort | null = null;\n private heartbeatHandle: ReturnType<typeof setInterval> | null = null;\n private localSession: CentrifugeSession<TData> | null = null;\n private handlers: DataBusTransportHandlers<TData> | null = null;\n // Monotonically increasing counter, bumped each time a backend is created.\n // Used to ignore late error events from a superseded Worker.\n private generation = 0;\n // Generation captured when the current backend was created. Error handlers\n // only act when the backend that registered them is still current.\n private backendGeneration = 0;\n\n constructor(options: CentrifugeWorkerTransportOptions = {}) {\n this.workerMode = options.workerMode ?? 'dedicated';\n this.transferable = options.transferable ?? false;\n this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;\n assertHeartbeatInterval(this.heartbeatIntervalMs);\n this.workerFactory = options.workerFactory;\n this.sharedWorkerFactory = options.sharedWorkerFactory;\n }\n\n /**\n * Start the transport: select a backend, initialise the Worker (or local\n * session), and send the INIT message with connection parameters.\n */\n start(config: CentrifugeDataBusConfig, handlers: DataBusTransportHandlers<TData>): void {\n if (this.backend) return;\n assertStructuredCloneable(config.options ?? {});\n this.handlers = handlers;\n const backend = selectWorkerBackend(this.workerMode, {\n worker: this.workerFactory !== undefined,\n sharedWorker: this.sharedWorkerFactory !== undefined\n });\n const input = this.buildInitInput(config);\n if (backend === 'shared') {\n this.startSharedWorker(input);\n this.backend = 'shared';\n return;\n }\n if (backend === 'dedicated') {\n this.startDedicatedWorker(input);\n this.backend = 'dedicated';\n return;\n }\n this.localSession = new CentrifugeSession<TData>({ post: this.handleSessionOutput });\n this.localSession.handle(input);\n this.backend = 'local';\n }\n\n subscribe(topic: string): void {\n this.post({ type: 'SUBSCRIBE', topic });\n }\n\n unsubscribe(topic: string): void {\n this.post({ type: 'UNSUBSCRIBE', topic });\n }\n\n /**\n * Publish data to `topic`. Binary data (ArrayBuffer) is sent via Transferable\n * when `transferable` is enabled, avoiding a structured-clone cycle.\n */\n publish(topic: string, data: unknown): void {\n if (this.transferable && data instanceof ArrayBuffer) {\n this.post({ type: 'PUBLISH_BIN', topic, data }, [data]);\n return;\n }\n this.post({ type: 'PUBLISH', topic, data });\n }\n\n /**\n * Gracefully stop the transport: send STOP, clean up event listeners, and\n * terminate the Worker (or close the SharedWorker port).\n */\n stop(): void {\n if (!this.backend) return;\n this.generation++;\n this.post({ type: 'STOP' });\n this.clearHeartbeat();\n if (this.worker) {\n this.worker.removeEventListener('message', this.handleMessage);\n this.worker.removeEventListener('error', this.handleWorkerError);\n this.worker.terminate();\n }\n if (this.sharedWorker) {\n this.detachSharedWorkerListeners();\n this.port?.close();\n }\n this.resetBackend();\n this.handlers = null;\n }\n\n /** Build the INIT payload sent to the Worker / local session. Optional fields\n * are only included when they deviate from the defaults, so the Worker's own\n * default-resolution logic kicks in for the common case. */\n private buildInitInput(config: CentrifugeDataBusConfig): CentrifugeWorkerInput {\n return {\n type: 'INIT',\n url: config.url,\n config: config.options ?? {},\n ...(this.transferable ? { transferable: true } : {}),\n ...(this.heartbeatIntervalMs !== DEFAULT_HEARTBEAT_INTERVAL_MS\n ? { heartbeatIntervalMs: this.heartbeatIntervalMs }\n : {})\n };\n }\n\n /** Create and initialise a dedicated Worker, then send the INIT message. */\n private startDedicatedWorker(input: CentrifugeWorkerInput): void {\n this.backendGeneration = ++this.generation;\n const worker = (this.workerFactory ?? createDefaultWorker)();\n this.worker = worker;\n worker.addEventListener('message', this.handleMessage);\n worker.addEventListener('error', this.handleWorkerError);\n worker.postMessage(input);\n }\n\n /** Create and initialise a SharedWorker, open the MessagePort, and send the INIT message. */\n private startSharedWorker(input: CentrifugeWorkerInput): void {\n this.backendGeneration = ++this.generation;\n const shared = (this.sharedWorkerFactory ?? createDefaultSharedWorker)();\n this.sharedWorker = shared;\n const port = shared.port;\n this.port = port;\n port.addEventListener('message', this.handleMessage);\n port.addEventListener('messageerror', this.handlePortError);\n shared.addEventListener('error', this.handleSharedWorkerError);\n port.start();\n port.postMessage(input);\n this.startHeartbeat();\n }\n\n /** Handle a message event from the Worker (dedicated or shared). */\n private readonly handleMessage = (event: MessageEvent<CentrifugeWorkerOutput<TData>>) => {\n this.handleOutput(event.data);\n };\n\n /** Handle a message from the in-process CentrifugeSession (local fallback). */\n private readonly handleSessionOutput = (message: CentrifugeWorkerOutput<TData>) => {\n this.handleOutput(message);\n };\n\n /** Route a Worker output message to the appropriate handler callback.\n * Shared by the Worker message listener, the SharedWorker port listener,\n * and the local-session sink \u2014 all three feed into this single dispatcher. */\n private handleOutput(message: CentrifugeWorkerOutput<TData>): void {\n if (message.type === 'STATUS') this.handlers?.onStatus(message.status);\n if (message.type === 'MESSAGE') this.handlers?.onMessage({ topic: message.topic, data: message.data });\n if (message.type === 'MESSAGE_BIN') this.handlers?.onMessage({ topic: message.topic, data: message.data as TData });\n if (message.type === 'ERROR') this.handlers?.onError(deserializeWorkerError(message.error));\n }\n\n /** Handle a Worker-level failure (crash, message decode error). Discards the\n * dead backend so a later start()/reopen can rebuild from scratch, and\n * signals an error status so the DataBus can trigger recovery.\n * Only invoked when the generation guard confirms the failing backend is\n * still current \u2014 late errors from a superseded Worker are silently dropped. */\n private onWorkerFailed(message: string): void {\n // Remove the message listener and release the old backend before recovery\n // reopens, so late messages from the failed Worker/port cannot be routed\n // into the freshly started session.\n this.worker?.removeEventListener('message', this.handleMessage);\n this.worker?.removeEventListener('error', this.handleWorkerError);\n this.worker?.terminate();\n this.detachSharedWorkerListeners();\n this.port?.close();\n this.clearHeartbeat();\n this.resetBackend();\n this.handlers?.onError(new Error(message));\n this.handlers?.onStatus('error');\n }\n\n /** Remove every listener attached to the current SharedWorker and its port. */\n private detachSharedWorkerListeners(): void {\n this.port?.removeEventListener('message', this.handleMessage);\n this.port?.removeEventListener('messageerror', this.handlePortError);\n this.sharedWorker?.removeEventListener('error', this.handleSharedWorkerError);\n }\n\n /** Periodically ping the SharedWorker so its session reaper can detect a dead tab. */\n private startHeartbeat(): void {\n if (this.heartbeatHandle !== null) return;\n // Infinite disables the heartbeat (e.g. for environment where the\n // SharedWorker reaper is not needed).\n if (this.heartbeatIntervalMs === Infinity) return;\n this.heartbeatHandle = setInterval(() => {\n this.post({ type: 'PING' });\n }, this.heartbeatIntervalMs);\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatHandle !== null) clearInterval(this.heartbeatHandle);\n this.heartbeatHandle = null;\n }\n\n private readonly handleWorkerError = () => {\n if (this.generation !== this.backendGeneration) return;\n this.onWorkerFailed('Centrifuge worker failed.');\n };\n\n private readonly handlePortError = () => {\n if (this.generation !== this.backendGeneration) return;\n this.onWorkerFailed('Centrifuge shared worker message decoding failed.');\n };\n\n private readonly handleSharedWorkerError = () => {\n if (this.generation !== this.backendGeneration) return;\n this.onWorkerFailed('Centrifuge shared worker failed.');\n };\n\n /** Clear the Worker/port/backend references after a failure or stop. */\n private resetBackend(): void {\n this.worker = null;\n this.sharedWorker = null;\n this.port = null;\n this.backend = null;\n this.localSession = null;\n }\n\n /**\n * Post a message to the active backend. Accepts optional Transferable buffers\n * for zero-copy ArrayBuffer transfer.\n */\n private post(message: CentrifugeWorkerInput, transfer?: ArrayBuffer[]): void {\n if (this.worker) {\n postToPortLike(this.worker, message, transfer);\n return;\n }\n if (this.port) {\n postToPortLike(this.port, message, transfer);\n return;\n }\n if (this.localSession) {\n this.localSession.handle(message);\n return;\n }\n throw new Error('CentrifugeWorkerTransport.start() must be called first.');\n }\n}\n\n/**\n * Create a fully-configured CrossTabDataBus with a Centrifuge WebSocket transport.\n *\n * This is the primary entry point for consumers. It wires up the transport,\n * cluster coordination, and lifecycle management:\n *\n * ```ts\n * const bus = createCentrifugeDataBus({\n * connection: { url: 'wss://example.com/connection/websocket', options: { token: '\u2026' } },\n * trace: { enabled: true, sink: event => console.log(event) },\n * });\n * ```\n */\nexport function createCentrifugeDataBus<TData = unknown>(\n options: CreateCentrifugeDataBusOptions<TData>\n): CrossTabDataBus<CentrifugeDataBusConfig, TData> {\n const {\n clusterKey,\n connection,\n heartbeatIntervalMs,\n sharedWorkerFactory,\n transferable,\n workerFactory,\n workerMode,\n ...dataBusOptions\n } = options;\n return new CrossTabDataBus({\n ...dataBusOptions,\n autoStart: true,\n clusterKey: clusterKey ?? connection.url,\n initialConfig: connection,\n transport: new CentrifugeWorkerTransport<TData>({\n ...(workerFactory ? { workerFactory } : {}),\n ...(sharedWorkerFactory ? { sharedWorkerFactory } : {}),\n ...(transferable === undefined ? {} : { transferable }),\n ...(workerMode ? { workerMode } : {}),\n ...(heartbeatIntervalMs === undefined ? {} : { heartbeatIntervalMs })\n })\n });\n}\n\n\n/** Create the default dedicated Worker hosting the Centrifuge client. */\nfunction createDefaultWorker(): Worker {\n if (typeof Worker === 'undefined') {\n throw new Error('CentrifugeWorkerTransport requires a browser Worker implementation.');\n }\n return new Worker(new URL('./centrifuge.worker.js', import.meta.url), {\n name: 'cross-tab-worker-databus',\n type: 'module'\n });\n}\n\n/** Create the default SharedWorker. Each connecting port within the SharedWorker\n * creates its own CentrifugeSession with an independent WebSocket connection,\n * so refreshing or stopping one tab does not affect the others. */\nfunction createDefaultSharedWorker(): SharedWorker {\n if (typeof SharedWorker === 'undefined') {\n throw new Error('CentrifugeWorkerTransport requires a browser SharedWorker implementation.');\n }\n return new SharedWorker(new URL('./centrifuge.shared.worker.js', import.meta.url), {\n name: 'cross-tab-worker-databus-shared',\n type: 'module'\n });\n}\n\n/** Validate the SharedWorker PING heartbeat interval. A value of `0`, a negative\n * number, or `NaN` would otherwise make `setInterval` degenerate into a 0ms busy\n * loop, driving the reaper and the main-thread PING out of control. `Infinity`\n * is allowed and disables heartbeats entirely (for environments where the\n * SharedWorker reaper is not needed, e.g. a single-tab deployment).\n * @throws {TypeError} when `value` is not a positive finite number or Infinity. */\nfunction assertHeartbeatInterval(value: number): void {\n if (value === Infinity) return;\n if (typeof value === 'number' && Number.isFinite(value) && value > 0) return;\n throw new TypeError(\n `Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`\n );\n}\n\n/** Validate that `value` is structured-cloneable. Throws early so config errors\n * surface on the main thread rather than silently failing inside the Worker\n * (where a DataCloneError would be reported as a generic Worker error with no\n * actionable message). Skips validation when `structuredClone` is unavailable\n * (older browsers without the API) \u2014 the Worker will still throw on its own.\n * @throws {TypeError} when `value` contains non-cloneable members (functions,\n * Symbols, DOM nodes, etc.). */\nfunction assertStructuredCloneable(value: unknown): void {\n if (typeof structuredClone !== 'function') return;\n try {\n structuredClone(value);\n } catch (error) {\n throw new TypeError(\n 'Centrifuge Worker configuration and published data must be structured-cloneable.',\n { cause: error }\n );\n }\n}\n\n/** Post `message` to a Worker or MessagePort, forwarding `transfer` buffers\n * when present. Both targets share the `postMessage(message, transfer?)`\n * signature, so a single helper eliminates the duplicated if/else at each\n * call site. */\nfunction postToPortLike(\n target: Pick<Worker, 'postMessage'>,\n message: CentrifugeWorkerInput,\n transfer?: ArrayBuffer[]\n): void {\n if (transfer) target.postMessage(message, transfer as Transferable[]);\n else target.postMessage(message);\n}\n\n/** Reconstruct an Error instance from its serialised form. Error objects cannot\n * be structured-cloned across the Worker boundary, so the Worker serialises them\n * into {@link SerializedWorkerError} and the main thread rebuilds the Error here\n * so the caller's `onError` handler receives a real Error with name/stack. */\nfunction deserializeWorkerError(error: SerializedWorkerError): Error {\n const result = new Error(error.message);\n result.name = error.name;\n if (error.stack) result.stack = error.stack;\n if (error.context !== undefined) Object.assign(result, { context: error.context });\n return result;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4989,14 +4989,30 @@ var CentrifugeSession = class {
|
|
|
4989
4989
|
client = null;
|
|
4990
4990
|
subscriptions = /* @__PURE__ */ new Map();
|
|
4991
4991
|
transferable = false;
|
|
4992
|
-
/** Dispatch an incoming Worker message to the matching operation.
|
|
4992
|
+
/** Dispatch an incoming Worker message to the matching operation.
|
|
4993
|
+
* Unknown message types are ignored rather than thrown, so a future protocol
|
|
4994
|
+
* extension adding a new variant cannot crash an older session. */
|
|
4993
4995
|
handle(message) {
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
4996
|
+
switch (message.type) {
|
|
4997
|
+
case "INIT":
|
|
4998
|
+
this.initialize(message.url, message.config, message.transferable === true);
|
|
4999
|
+
return;
|
|
5000
|
+
case "SUBSCRIBE":
|
|
5001
|
+
this.subscribe(message.topic);
|
|
5002
|
+
return;
|
|
5003
|
+
case "UNSUBSCRIBE":
|
|
5004
|
+
this.unsubscribe(message.topic);
|
|
5005
|
+
return;
|
|
5006
|
+
case "PUBLISH":
|
|
5007
|
+
case "PUBLISH_BIN":
|
|
5008
|
+
this.publish(message.topic, message.data);
|
|
5009
|
+
return;
|
|
5010
|
+
case "STOP":
|
|
5011
|
+
this.stop();
|
|
5012
|
+
return;
|
|
5013
|
+
default:
|
|
5014
|
+
return;
|
|
5015
|
+
}
|
|
5000
5016
|
}
|
|
5001
5017
|
/** Create the Centrifuge client, wire up lifecycle listeners, and connect. */
|
|
5002
5018
|
initialize(url, config, transferable) {
|
|
@@ -5017,9 +5033,17 @@ var CentrifugeSession = class {
|
|
|
5017
5033
|
});
|
|
5018
5034
|
client.connect();
|
|
5019
5035
|
}
|
|
5020
|
-
/** Subscribe to a Centrifuge channel. Reuses an existing subscription if one exists.
|
|
5036
|
+
/** Subscribe to a Centrifuge channel. Reuses an existing subscription if one exists.
|
|
5037
|
+
* Listeners are only registered once per subscription object — a repeated
|
|
5038
|
+
* SUBSCRIBE for an already-tracked topic skips the listener wiring entirely,
|
|
5039
|
+
* avoiding the removeAllListeners + re-on churn on every duplicate message. */
|
|
5021
5040
|
subscribe(topic) {
|
|
5022
5041
|
if (!this.client) return this.postError(new Error("Centrifuge client is not initialized."));
|
|
5042
|
+
const existing = this.subscriptions.get(topic);
|
|
5043
|
+
if (existing) {
|
|
5044
|
+
existing.subscribe();
|
|
5045
|
+
return;
|
|
5046
|
+
}
|
|
5023
5047
|
let subscription = this.client.getSubscription(topic);
|
|
5024
5048
|
if (!subscription) subscription = this.client.newSubscription(topic);
|
|
5025
5049
|
subscription.removeAllListeners("publication");
|
|
@@ -5050,7 +5074,10 @@ var CentrifugeSession = class {
|
|
|
5050
5074
|
if (!this.client) return this.postError(new Error("Centrifuge client is not initialized."));
|
|
5051
5075
|
void this.client.publish(topic, data).catch((error) => this.postError(error));
|
|
5052
5076
|
}
|
|
5053
|
-
/** Forward a publication to the transport
|
|
5077
|
+
/** Forward a publication to the transport. Binary payloads take the
|
|
5078
|
+
* zero-copy `MESSAGE_BIN` path when `transferable` is enabled; everything
|
|
5079
|
+
* else is structured-cloned via `MESSAGE`. An empty topic means the
|
|
5080
|
+
* publication carried no channel info and is silently dropped. */
|
|
5054
5081
|
postPublication(topic, data) {
|
|
5055
5082
|
if (!topic) return;
|
|
5056
5083
|
if (this.transferable && data instanceof ArrayBuffer) {
|
|
@@ -5082,12 +5109,14 @@ var CentrifugeSession = class {
|
|
|
5082
5109
|
function getPayloadTopic(data) {
|
|
5083
5110
|
if (!data || typeof data !== "object") return "";
|
|
5084
5111
|
const payload = data;
|
|
5085
|
-
const
|
|
5112
|
+
const push = payload.push;
|
|
5113
|
+
const nested = typeof push === "object" && push !== null ? push.channel : void 0;
|
|
5114
|
+
const topic = nested ?? payload.channel;
|
|
5086
5115
|
return typeof topic === "string" ? topic : "";
|
|
5087
5116
|
}
|
|
5117
|
+
var LIVE_STATES = /* @__PURE__ */ new Set(["connecting", "connected"]);
|
|
5088
5118
|
function normalizeStatus(status) {
|
|
5089
|
-
|
|
5090
|
-
return "disconnected";
|
|
5119
|
+
return LIVE_STATES.has(status) ? status : "disconnected";
|
|
5091
5120
|
}
|
|
5092
5121
|
function serializeError(error) {
|
|
5093
5122
|
if (error instanceof Error) {
|
|
@@ -5107,6 +5136,7 @@ function serializeError(error) {
|
|
|
5107
5136
|
// src/centrifuge-protocol.ts
|
|
5108
5137
|
var DEFAULT_HEARTBEAT_INTERVAL_MS = 1e4;
|
|
5109
5138
|
var DEFAULT_SESSION_TIMEOUT_MULTIPLIER = 3;
|
|
5139
|
+
var DEFAULT_SESSION_TIMEOUT_MS = DEFAULT_HEARTBEAT_INTERVAL_MS * DEFAULT_SESSION_TIMEOUT_MULTIPLIER;
|
|
5110
5140
|
|
|
5111
5141
|
// src/workers/port-reaper.ts
|
|
5112
5142
|
var PortReaper = class {
|
|
@@ -5123,21 +5153,26 @@ var PortReaper = class {
|
|
|
5123
5153
|
setTimer;
|
|
5124
5154
|
clearTimer;
|
|
5125
5155
|
/** Track a newly connected port and its session. The default timeout applies
|
|
5126
|
-
* until the port sends an INIT with its own heartbeat config
|
|
5156
|
+
* until the port sends an INIT with its own heartbeat config, at which point
|
|
5157
|
+
* setTimeout() overrides it. register() always calls schedule() so the
|
|
5158
|
+
* reaper interval starts as soon as the first port connects. */
|
|
5127
5159
|
register(port, target) {
|
|
5128
5160
|
this.targets.set(port, target);
|
|
5129
5161
|
this.lastSeenAt.set(port, this.now());
|
|
5130
|
-
this.sessionTimeoutMs.set(port,
|
|
5162
|
+
this.sessionTimeoutMs.set(port, DEFAULT_SESSION_TIMEOUT_MS);
|
|
5131
5163
|
this.schedule();
|
|
5132
5164
|
}
|
|
5133
|
-
/** Record activity on a port (any incoming message). No-op for untracked
|
|
5165
|
+
/** Record activity on a port (any incoming message). No-op for untracked
|
|
5166
|
+
* ports — a late PING from a port that was already removed/STOP'd must not
|
|
5167
|
+
* resurrect it in the reaper's tracking maps. */
|
|
5134
5168
|
touch(port) {
|
|
5135
5169
|
if (!this.targets.has(port)) return;
|
|
5136
5170
|
this.lastSeenAt.set(port, this.now());
|
|
5137
5171
|
}
|
|
5138
5172
|
/** Override a port's session timeout from its INIT heartbeat config.
|
|
5139
5173
|
* A non-finite or non-positive value falls back to the default so a bad
|
|
5140
|
-
* payload cannot degenerate the reaper into a busy loop or silence it.
|
|
5174
|
+
* payload cannot degenerate the reaper into a busy loop or silence it.
|
|
5175
|
+
* No-op for untracked ports (e.g. setTimeout arrives after remove/STOP). */
|
|
5141
5176
|
setTimeout(port, heartbeatIntervalMs) {
|
|
5142
5177
|
if (!this.targets.has(port)) return;
|
|
5143
5178
|
const safe = Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs > 0 ? heartbeatIntervalMs : DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
@@ -5145,7 +5180,8 @@ var PortReaper = class {
|
|
|
5145
5180
|
this.schedule();
|
|
5146
5181
|
}
|
|
5147
5182
|
/** Remove a port (STOP message). The caller is responsible for closing the
|
|
5148
|
-
* port and stopping the session.
|
|
5183
|
+
* port and stopping the session. No-op for untracked ports so a duplicate
|
|
5184
|
+
* STOP or a STOP-after-reap cannot corrupt the reaper's bookkeeping. */
|
|
5149
5185
|
remove(port) {
|
|
5150
5186
|
if (!this.targets.has(port)) return;
|
|
5151
5187
|
this.targets.delete(port);
|
|
@@ -5169,29 +5205,41 @@ var PortReaper = class {
|
|
|
5169
5205
|
this.lastSeenAt.clear();
|
|
5170
5206
|
this.sessionTimeoutMs.clear();
|
|
5171
5207
|
}
|
|
5172
|
-
/** Recompute the reaper cadence, or stop it entirely when no ports remain.
|
|
5208
|
+
/** Recompute the reaper cadence, or stop it entirely when no ports remain.
|
|
5209
|
+
* Idempotent: if the interval is already running at the correct cadence,
|
|
5210
|
+
* no new timer is created. This guards against register/remove/setTimeout
|
|
5211
|
+
* each spawning redundant timers when the cadence hasn't changed. */
|
|
5173
5212
|
schedule() {
|
|
5174
5213
|
if (this.sessionTimeoutMs.size === 0) {
|
|
5175
5214
|
if (this.handle !== null) this.clearTimer(this.handle);
|
|
5176
5215
|
this.handle = null;
|
|
5177
5216
|
return;
|
|
5178
5217
|
}
|
|
5179
|
-
|
|
5180
|
-
for (const timeout of this.sessionTimeoutMs.values()) {
|
|
5181
|
-
minHeartbeat = Math.min(minHeartbeat, timeout / DEFAULT_SESSION_TIMEOUT_MULTIPLIER);
|
|
5182
|
-
}
|
|
5218
|
+
const minHeartbeat = this.computeMinHeartbeat();
|
|
5183
5219
|
if (this.handle !== null && this.intervalMs === minHeartbeat) return;
|
|
5184
5220
|
if (this.handle !== null) this.clearTimer(this.handle);
|
|
5185
5221
|
this.intervalMs = minHeartbeat;
|
|
5186
5222
|
this.handle = this.setTimer(() => this.reap(), minHeartbeat);
|
|
5187
5223
|
}
|
|
5188
|
-
/**
|
|
5224
|
+
/** Smallest heartbeat interval among active ports, derived from each port's
|
|
5225
|
+
* configured session timeout. Exposed as a method so the cadence logic can
|
|
5226
|
+
* be unit-tested in isolation from the timer plumbing. */
|
|
5227
|
+
computeMinHeartbeat() {
|
|
5228
|
+
let minHeartbeat = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
5229
|
+
for (const timeout of this.sessionTimeoutMs.values()) {
|
|
5230
|
+
minHeartbeat = Math.min(minHeartbeat, timeout / DEFAULT_SESSION_TIMEOUT_MULTIPLIER);
|
|
5231
|
+
}
|
|
5232
|
+
return minHeartbeat;
|
|
5233
|
+
}
|
|
5234
|
+
/** Close sessions whose port has been silent longer than its timeout.
|
|
5235
|
+
* Iterates a snapshot so closing a target (which mutates `targets`) during
|
|
5236
|
+
* the loop cannot skip a subsequent entry or visit one twice. */
|
|
5189
5237
|
reap() {
|
|
5190
5238
|
const now = this.now();
|
|
5191
5239
|
let reapedAny = false;
|
|
5192
|
-
for (const [port, target] of
|
|
5240
|
+
for (const [port, target] of Array.from(this.targets)) {
|
|
5193
5241
|
const lastSeen = this.lastSeenAt.get(port) ?? 0;
|
|
5194
|
-
const timeout = this.sessionTimeoutMs.get(port) ??
|
|
5242
|
+
const timeout = this.sessionTimeoutMs.get(port) ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
5195
5243
|
if (now - lastSeen <= timeout) continue;
|
|
5196
5244
|
this.targets.delete(port);
|
|
5197
5245
|
this.lastSeenAt.delete(port);
|