UNPKG

strophe.js

Version:

Strophe.js is an XMPP library for JavaScript

903 lines (846 loc) 34.6 kB
/** * The SharedWorker side of Strophe's worker-based connection sharing: * multiple tabs (ports) share the single websocket connection managed here. * * Exactly one live port holds the 'primary' role and it drives the XMPP stream * (handshake, and at the application layer it is the tab that should respond * to stanzas). All other ports are 'secondary' and attach to the shared * session. * * The worker sends pings to probe port liveness. Pages answer from their * message handler, which browsers do not throttle. A silent port keeps * receiving broadcasts (a frozen tab replays them on thaw) until it has * been quiet for so long that it must be gone for good (30 mins). * * This file is built as a self-contained classic-worker script * (`dist/shared-connection-worker.js`) keep it free of DOM dependencies. */ import { SHARED_WORKER_PROTOCOL_VERSION, Status } from './constants'; import StreamManagement from './stream-management/engine'; import { peekElement } from './stream-management/parse'; /** How often the worker pings its ports (and sweeps for gone ones). */ export const PING_INTERVAL = 30_000; /** * How long the primary may stay silent before the worker fails over to the * freshest other port. Pongs are posted from the page's message handler, * which runs even in background-throttled tabs, so silence this long means * the tab is frozen, crashed or gone — not merely backgrounded. */ export const PRIMARY_TIMEOUT = 70_000; /** * How long any port may stay silent before it is dropped entirely. Generous: * a frozen tab posts nothing but keeps its MessagePort, and every broadcast * posted to it while frozen is delivered when it thaws — dropping it early * would forfeit that replay. A dropped port that speaks again is re-admitted. */ export const PORT_GC_TIMEOUT = 30 * 60_000; /** How long the socket is kept open after the last port has gone away. */ export const SOCKET_GRACE = 10_000; /** * How long the worker waits for the server to answer an ack probe (<r/>) * before presuming the socket dead. Probes are sent from the sweep when an * SM-enabled stream has been quiet for a full PING_INTERVAL, and when a * tab (re)joins the established session. Without them, a silently dead * socket (NAT timeout, sleep/wake) is only discovered by the OS's TCP * timeout, which can take many minutes. */ export const PROBE_TIMEOUT = 10_000; /** The page→worker methods that may be invoked via postMessage. */ const METHODS = [ '_connect', '_attach', '_pong', '_relinquish', 'send', 'close', '_closeSocket', '_smFeatures', '_bound', ]; /** * The subset of METHODS that presumes a live socket. When one arrives after * the socket died, the sender is answered with _onClose instead of having * its message dropped into the void (see _onPortMessage). */ const SESSION_METHODS = ['send', 'close', '_smFeatures', '_bound']; type Role = 'primary' | 'secondary'; interface PortInfo { role: Role; lastSeen: number; } /** * Manages the shared websocket connection as well as the ports of the * connected tabs. */ class ConnectionManager { ports: Map<MessagePort, PortInfo>; jid: string; service: string; socket: WebSocket | null; /** * Where the shared stream is in its lifecycle. 'connecting' spans from * socket creation until the primary reports the bound JID (_bound) or a * worker-driven resumption succeeds; only in 'established' are join * requests answered — earlier ones wait in _pendingJoins. */ session: 'none' | 'connecting' | 'established'; /** * The worker-resident XEP-0198 engine (created lazily on the first SM * negotiation). The worker is the only party that sees all inbound * traffic (one socket) and all outbound traffic (every port's send * relays through here), so it is the only correct owner of the SM * counters and queue once more than one tab sends — a stanza sent from * a secondary tab survives resumption, and failover needs no reconnect. */ sm: StreamManagement | null; /** * Ports whose join arrived while the handshake was still in flight, * with the method they joined through: on failure, an '_attach' join is * answered with ATTACHFAIL (the attach() contract), while a '_connect' * join is told the socket closed, so its page fails the connect attempt * and the embedder's reconnect logic takes over. */ private _pendingJoins: Map<MessagePort, '_connect' | '_attach'>; private _sweepTimer: ReturnType<typeof setInterval>; private _graceTimer: ReturnType<typeof setTimeout>; /** Pending death sentence for an unanswered ack probe (see _probe). */ private _probeTimer: ReturnType<typeof setTimeout>; /** When the socket last delivered anything (probes count inbound only). */ private _lastInbound: number; constructor() { this.ports = new Map(); this.socket = null; this.session = 'none'; this.sm = null; this._pendingJoins = new Map(); this._sweepTimer = null; this._graceTimer = null; this._probeTimer = null; this._lastInbound = 0; } /** * Register a newly connected port. It is admitted (and given a role) when * it first speaks. */ addPort(port: MessagePort): void { port.addEventListener('message', (e: MessageEvent) => this._onPortMessage(port, e)); port.start(); if (!this._sweepTimer) { this._sweepTimer = setInterval(() => this._sweep(), PING_INTERVAL); } } /** * Dispatch a message from a port. Any message counts as liveness; a port * that was dropped (e.g. a long-frozen tab) is re-admitted here. */ private _onPortMessage(port: MessagePort, e: MessageEvent): void { const [method, ...args] = e.data as [string, ...unknown[]]; if (method === '_bye') { this._bye(port); return; } if (!METHODS.includes(method)) { this._post(port, 'log', 'error', `Found unhandled service worker message: ${method}`); return; } const info = this.ports.get(port); if (info) { info.lastSeen = Date.now(); } else { // New port, or a dropped one speaking again (§ re-admission). const role: Role = this._hasPrimary() ? 'secondary' : 'primary'; this.ports.set(port, { role, lastSeen: Date.now() }); if (method !== '_connect' && method !== '_attach') { if (this._socketLive()) { // Keep the page's role in sync — this demotes a stale // primary (e.g. a frozen tab that was failed over while // suspended). this._post(port, '_role', role, this.jid); } else { // The tab believes it belongs to a session that no longer // exists (the socket died or was grace-closed while it was // away). Tell it, so it reconnects instead of staying // wedged in a connected-looking state. this._post(port, '_onClose', 'The shared socket is gone'); } } } if (SESSION_METHODS.includes(method) && !this._socketReady()) { // The tab is talking to a dead (or still-connecting) socket — // no legitimate sender exists before the socket opens, since the // primary only starts the stream in reaction to _onOpen. Salvage // what can be salvaged and make reality visible instead of // dropping the message into the void (a send() on a CONNECTING // socket would throw and lose the frame). if (method === 'send') { this._salvageFrame(port, args[0] as string); } this._post(port, '_onClose', 'The shared socket is gone'); return; } this._cancelGrace(); try { (this as unknown as Record<string, (port: MessagePort, ...args: unknown[]) => void>)[method](port, ...args); } catch (err) { console?.error(err); } } /** * Connect, or (when a live socket for the same user already exists) * join the shared session as a secondary instead of reconnecting (§2.5). * The first joiner becomes primary and drives the connection. */ _connect(port: MessagePort, service: string, jid: string, version?: number): void { if (!this._checkVersion(port, version)) { return; } const sameUser = !this.jid || !jid || jid.split('/')[0].toLowerCase() === this.jid.split('/')[0].toLowerCase(); if (this._socketLive() && sameUser) { // A tab (re)connecting is a good moment to verify that the // shared socket is actually alive: a reloaded page joining a // zombie session would otherwise only find out at the next // sweep-driven probe. this._probe(); this._joinShared(port, '_connect'); return; } this.service = service; this.jid = jid; // Per-stream SM state starts fresh; resumable state is reloaded from // the engine's storage when the primary reports SM support // (_smFeatures), mirroring the page-side connect() flow. this.sm?.reset(); // This port drives the new connection and becomes primary. for (const [p, info] of this.ports) { if (p !== port && info.role === 'primary') { info.role = 'secondary'; this._post(p, '_role', 'secondary', this.jid); } } this.ports.get(port).role = 'primary'; this._post(port, '_role', 'primary', this.jid); this._closeSocket(); this.session = 'connecting'; this.socket = new WebSocket(service, 'xmpp'); // _onOpen goes only to the connecting (primary) port: every tab's // Websocket layer reacts to it by sending a stream-<open/>, and a // still-attached secondary doing so would corrupt the new stream. this.socket.onopen = () => this._post(port, '_onOpen'); this.socket.onerror = (e) => this._onError(e); this.socket.onclose = (e) => this._onClose(e); this.socket.onmessage = (message) => this._onMessage(message); } /** * Attach to the shared session, if there is one. * @param port * @param _service * @param version - The page's SHARED_WORKER_PROTOCOL_VERSION. */ _attach(port: MessagePort, _service?: string, version?: number): void { if (!this._checkVersion(port, version)) { return; } if (this._socketLive()) { this._probe(); this._joinShared(port, '_attach'); } else { this._post(port, '_attachCallback', Status.ATTACHFAIL); } } /** * The primary reports that the post-SASL stream features advertise * XEP-0198 (§2.3). This is the resume-vs-bind decision point: only the * worker knows whether resumable state exists, so it either sends * <resume/> itself (answering with _smResumed/_smFailed once the server * replies) or tells the requesting port to proceed with binding. * @param port */ _smFeatures(port: MessagePort): void { const sm = this._ensureSM(); sm.serverSupported = true; sm.initialize(this.jid); if (sm.hasResumableState()) { sm.sendResume(); } else { this._post(port, '_smNoState'); } } /** * The primary reports that the connect flow completed (resource bound, * or legacy auth/session establishment finished): adopt the bound JID as * the shared one so attaching secondaries and promotions hand out a * resource the server actually knows about, start a fresh SM session if * this stream's features advertised support (_smFeatures), and release * any joins parked on the handshake. The <enable/> is written before the * parked joins drain, so it precedes anything a newly attached tab sends. * @param _port * @param jid - The server-assigned full JID. */ _bound(_port: MessagePort, jid: string): void { this.jid = jid; if (this.sm?.serverSupported) { this.sm.sendEnable(jid); } this._sessionEstablished(); } /** * Lazily create the worker-resident engine. Its nonzas go straight to * the socket (outbound frames relay in receipt order, so nothing can be * overtaken), and its lifecycle events are forwarded to the tabs as the * _smEnabled/_smResumed/_smFailed messages that feed the page-side * mirrors and drive the primary's connect flow. */ private _ensureSM(): StreamManagement { if (!this.sm) { const sm = new StreamManagement((data: string) => { this.socket?.send(data); }); sm.onEnabled = () => this._broadcast('_smEnabled', sm.state.id, sm.state.max, sm.state.boundJid); sm.onResumed = () => { this.jid = sm.boundJid; this._sessionEstablished(); this._broadcast('_smResumed', sm.boundJid, sm.state.id, sm.state.max); }; // Only a failed *resumption* concerns the tabs (the primary must // fall back to binding); a refused <enable/> just means this // stream runs without SM. sm.onFailed = (_view, resumeFailed) => { if (resumeFailed) { this._broadcast('_smFailed'); } }; this.sm = sm; } return this.sm; } /** * Liveness reply to the worker's _ping. The lastSeen update happens in * the dispatcher, so any message from a port counts. This method only * needs to exist. * @param _port */ _pong(_port: MessagePort): void { return; } /** * The page announces it is about to be frozen (its CPU stops): hand the * primary role to the freshest other port right away instead of making * the worker discover the freeze through missed pongs. * @param port */ _relinquish(port: MessagePort): void { const info = this.ports.get(port); if (info?.role !== 'primary') { return; } if (this.session === 'connecting') { this._primaryLost(); return; } // With no other port, the tab keeps the role and resumes as primary. this._failover(port); } /** * Graceful removal (sent from pagehide). If the primary leaves, the next * live port is promoted on the same socket. * @param port */ _bye(port: MessagePort): void { const info = this.ports.get(port); if (!info) return; this.ports.delete(port); if (info.role === 'primary') { this._primaryLost(); } if (this.ports.size === 0) { this._startGrace(); } } /** * Relay an outbound frame to the socket, feeding it to the XEP-0198 * engine and reflecting messages/presences to the other tabs. * @param port * @param data */ send(port: MessagePort, data: string): void { const view = peekElement(data); if (view?.name === 'close') { // A stream-closing <close/> triggers the final ack + state clearing, // and the engine writes that final <a/> straight to the socket, so // onGracefulClose() must run *before* the close frame goes out. this.sm?.onGracefulClose(); this.socket.send(data); return; } this.socket.send(data); if (this.sm) { if (view) { // A countable stanza is tracked *after* it is written, because // trackOutbound() may emit an <r/> which should ride behind the // stanza it covers. this.sm.trackOutbound(view); } else { this._warnUnpeekable('outbound', data); } } if (view) { this._reflect(port, view.name, data); } } /** * Reflect an outbound message/presence to every tab except the sender, * so all tabs can render what any one of them sent. Delivered as its own * `_onStanzaSent` page message — never as `_onMessage` — because a sent * stanza must not enter the receiving tabs' inbound pipeline (stanza * handlers, SM counting, xmlInput). IQs are not reflected: they are * request/response traffic private to the sending tab. * @param sender - The port the stanza came from (gets no reflection). * @param name - The frame's tag name (from the peeker). * @param data - The raw outbound frame. */ private _reflect(sender: MessagePort, name: string, data: string): void { if (name !== 'message' && name !== 'presence') { return; } for (const [port] of this.ports) { if (port !== sender) { this._post(port, '_onStanzaSent', data); } } } /** * Send the stream-closing frame before disconnecting. * @param port * @param data */ close(port: MessagePort, data: string): void { if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { try { this.sm?.onGracefulClose(); this.socket.send(data); } catch (e) { this._post(port, 'log', 'error', e); this._post(port, 'log', 'error', "Couldn't send <close /> tag."); } } } /** * Explicitly close the shared socket (a deliberate disconnect/logout — * a mere tab close sends `_bye` instead). Joins parked on the handshake * are answered with ATTACHFAIL: the session they were waiting for is * not going to happen. * @param _port */ _closeSocket(_port?: MessagePort): void { this.session = 'none'; this._cancelProbe(); this._failPendingJoins(); if (this.socket) { try { this.socket.onclose = null; this.socket.onerror = null; this.socket.onmessage = null; this.socket.close(); } catch (e) { this.ports.forEach((_info, p) => this._post(p, 'log', 'error', e)); } } this.socket = null; } /** * @param e */ _onClose(e: CloseEvent): void { this.session = 'none'; this._cancelProbe(); this._failPendingJoins(); this._broadcast('_onClose', e.reason); } /** * Deliver an inbound frame to the tabs, feeding it to the XEP-0198 * engine first: counting and <r/> → <a/> replies happen exactly once. * Lifecycle transitions (<enabled/>/<resumed/>/<failed/>) surface * through the engine's event hooks (see _ensureSM). * * Until the session is established, frames go only to the primary, * since only it is responsible for SASL negotiation and session setup. * Once established, frames are broadcast to all tabs. * @param message */ _onMessage(message: MessageEvent): void { // Any inbound traffic proves the socket alive. this._lastInbound = Date.now(); this._cancelProbe(); if (this.sm && typeof message.data === 'string') { const view = peekElement(message.data); if (view) { this.sm.onInbound(view); } else { this._warnUnpeekable('inbound', message.data); } } if (this.session === 'established') { this._broadcast('_onMessage', { data: message.data }); } else { const primary = this._primaryPort(); if (primary) { this._post(primary, '_onMessage', { data: message.data }); } } } /** * @param error */ _onError(error: Event): void { this._broadcast('_onError', error); } /** * Verify that the page and this worker were built against the same * page↔worker protocol. On mismatch the port is refused (and told the * socket is closed, so the page fails loudly rather than misbehaving). * @param port * @param version - The version the page sent along with _connect/_attach. * @returns true when the page speaks this worker's protocol. */ private _checkVersion(port: MessagePort, version?: number): boolean { if (version === SHARED_WORKER_PROTOCOL_VERSION) { return true; } this._post( port, 'log', 'fatal', `Shared connection worker protocol mismatch (page: ${version}, worker: ` + `${SHARED_WORKER_PROTOCOL_VERSION}) — the page and dist/shared-connection-worker.js ` + `are from different builds`, ); if (version > SHARED_WORKER_PROTOCOL_VERSION) { // The page comes from a newer build than this worker. // Shut down, so that the pages' next connection attempt spawns a fresh worker. this._broadcast('_onClose', 'The shared connection worker is outdated and has shut down'); this._closeSocket(); (globalThis as { close?: () => void }).close?.(); return false; } // The page is older than this worker (a stale tab after a deploy): // refuse it so that it has to be reloaded to speak this protocol. this._post(port, '_onClose', 'Shared connection worker protocol mismatch'); this.ports.delete(port); return false; } /** * @returns true when the socket exists and is not closing/closed. A * CONNECTING socket counts: joins arriving during the handshake must * be parked, not refused. */ private _socketLive(): boolean { return ( this.socket && this.socket.readyState !== WebSocket.CLOSED && this.socket.readyState !== WebSocket.CLOSING ); } /** * @returns true when the socket is OPEN, i.e. ready to carry frames. */ private _socketReady(): boolean { return this.socket?.readyState === WebSocket.OPEN; } /** * @returns true if any live port currently holds the primary role. */ private _hasPrimary(): boolean { return this._primaryPort() !== null; } /** * @returns The port currently holding the primary role, or null. */ private _primaryPort(): MessagePort | null { for (const [port, info] of this.ports) { if (info.role === 'primary') return port; } return null; } /** * Join a port onto the shared session: it becomes secondary (or primary, * if no primary is left) and is attached. Joins that arrive while the * handshake is still in flight are parked. * @param port * @param origin - The method the join arrived through (see _pendingJoins). */ private _joinShared(port: MessagePort, origin: '_connect' | '_attach'): void { if (this.session !== 'established') { if (!this._pendingJoins.has(port)) { this._pendingJoins.set(port, origin); } return; } const info = this.ports.get(port); let hasOtherPrimary = false; for (const [p, i] of this.ports) { if (p !== port && i.role === 'primary') { hasOtherPrimary = true; break; } } info.role = hasOtherPrimary ? 'secondary' : info.role; this._post(port, '_role', info.role, this.jid); if (this.sm?.enabled) { // Seed the joining tab's page-side SM mirror with the running // session (it missed the _smEnabled/_smResumed broadcast). this._post(port, '_smEnabled', this.sm.state.id, this.sm.state.max, this.sm.state.boundJid); } this._post(port, '_attachCallback', Status.ATTACHED, this.jid); } /** * The stream reached its usable state (resource bound or resumed): * record it and answer the joins that were parked on the handshake. */ private _sessionEstablished(): void { this.session = 'established'; const pending = this._pendingJoins; this._pendingJoins = new Map(); pending.forEach((origin, p) => this.ports.has(p) && this._joinShared(p, origin)); } /** * Fail parked joins. The session they were waiting for died before it * was established. '_attach' joins get ATTACHFAIL; '_connect' joins are * told the socket closed, which fails the page's connect attempt so the * embedder's reconnect logic can retry. */ private _failPendingJoins(): void { const pending = this._pendingJoins; this._pendingJoins = new Map(); pending.forEach((origin, p) => { if (origin === '_attach') { this._post(p, '_attachCallback', Status.ATTACHFAIL); } else { this._post(p, '_onClose', 'The shared connection could not be established'); } }); } /** * The primary is gone (bye, GC or freeze). If the session is established * the freshest remaining port takes over on the same socket. If the * handshake was still in flight, nobody else can finish it (only the * primary holds credentials): kill the socket so the remaining tabs * reconnect and elect a new primary. */ private _primaryLost(): void { if (this.session === 'connecting') { this._closeSocket(); this._broadcast('_onClose', 'The connection-driving tab went away during the handshake'); } else { this._promoteNext(); } } /** * Promote the freshest port to primary — same socket, no reconnect. * @param exclude - A port that must not be picked (e.g. the one * relinquishing the role). */ private _promoteNext(exclude?: MessagePort): void { const next = this._freshestPort(exclude); if (!next) return; this.ports.get(next).role = 'primary'; this._post(next, '_promote', this.jid); } /** * Hand the primary role from `port` to the freshest other port, if any. * @param port - The current primary. * @param minLastSeen - Only consider candidates seen at or after this * timestamp (so a failover never picks an equally-dead port). * @returns true if a failover happened. */ private _failover(port: MessagePort, minLastSeen = 0): boolean { const next = this._freshestPort(port, minLastSeen); if (!next) return false; this.ports.get(port).role = 'secondary'; this._post(port, '_role', 'secondary', this.jid); this.ports.get(next).role = 'primary'; this._post(next, '_promote', this.jid); return true; } /** * @param exclude - A port to skip. * @param minLastSeen - Minimum lastSeen for a port to qualify. * @returns The most recently seen qualifying port, or null. */ private _freshestPort(exclude?: MessagePort, minLastSeen = 0): MessagePort | null { let best: MessagePort | null = null; let bestSeen = minLastSeen - 1; for (const [port, info] of this.ports) { if (port !== exclude && info.lastSeen > bestSeen) { best = port; bestSeen = info.lastSeen; } } return best; } /** * A frame was sent into a dead socket. If an SM session was active, feed * a stanza to the engine anyway: it lands in the unacked queue and is * replayed when the session is resumed over the next socket, so the * message the user just sent isn't lost to the bad timing. A <close/> * still ends the session: the final <a/> can't be delivered anymore * (the engine's sendRaw is null-guarded), but the persisted state is * cleared so the deliberately-ended session isn't resumed later. * @param port - The port the frame came from. * @param data - The raw outbound frame. */ private _salvageFrame(port: MessagePort, data: string): void { if (!this.sm) return; const view = peekElement(data); if (view?.name === 'close') { this.sm.onGracefulClose(); } else if (view) { this.sm.trackOutbound(view); if (this.sm.isTracking()) { // The stanza sits in the queue and will be replayed on the // next resume, so reflect it like any sent stanza. this._reflect(port, view.name, data); } } else { this._warnUnpeekable('outbound', data); } } /** * Report a frame the regex peeker (see parse.ts) could not parse while an * SM session was active. Such a frame passes through uncounted and * untracked, so the XEP-0198 handled-stanza counters drift and stanzas * get duplicated (inbound) or lost (outbound) on the next resume — * exactly the silent corruption SM exists to prevent, so make it loud. * * Only frames that look like an element open are reported: the stream * prolog, whitespace keepalives and empty frames legitimately don't peek * and are never countable stanzas. * * The opening tag is included so the peeker can actually be fixed — it is * where the parse failed (peekElement only ever inspects the opening tag) * and is the whole diagnostic. The report stops at the first '>' (capped), * which keeps the stanza payload — message bodies and the like — out of * the logs. * @param direction * @param frame */ private _warnUnpeekable(direction: 'inbound' | 'outbound', frame: string): void { if (!/^\s*<[a-zA-Z]/.test(frame)) { return; } const MAX = 200; const tagEnd = frame.indexOf('>'); const openTag = tagEnd !== -1 && tagEnd + 1 <= MAX ? frame.slice(0, tagEnd + 1) : frame.slice(0, MAX) + '…'; this._broadcast( 'log', 'error', `StreamManagement: could not parse an ${direction} frame; it was not counted, ` + `so the handled-stanza counters may drift. Opening tag: ${openTag}`, ); } /** * Ping every port and act on prolonged silence. A live page answers * pings from its message handler even when its timers are throttled, so: * a primary silent past PRIMARY_TIMEOUT is failed over (but keeps * receiving — it may merely be frozen and will learn of its demotion on * thaw), and a port silent past PORT_GC_TIMEOUT is dropped for good. */ private _sweep(): void { const now = Date.now(); for (const [port, info] of this.ports) { if (now - info.lastSeen > PORT_GC_TIMEOUT) { this.ports.delete(port); if (info.role === 'primary') { this._primaryLost(); } continue; } this._post(port, '_ping'); if (info.role === 'primary' && now - info.lastSeen > PRIMARY_TIMEOUT) { if (this.session === 'connecting') { this._primaryLost(); } else { // Only fail over to a port that is provably fresher. this._failover(port, now - PRIMARY_TIMEOUT); } } } if (this.ports.size === 0 && this._socketLive()) { this._startGrace(); } if (this.session === 'established' && now - this._lastInbound >= PING_INTERVAL) { this._probe(); } } /** * Ask the server for an ack and treat a missing reply as socket death. * The <r/> doubles as a keepalive on quiet connections. */ private _probe(): void { if (this._probeTimer || !this.sm?.enabled || !this._socketReady()) { return; } this.sm.requestAck(); this._probeTimer = setTimeout(() => { this._probeTimer = null; this._onSocketDead(); }, PROBE_TIMEOUT); } private _cancelProbe(): void { if (this._probeTimer) { clearTimeout(this._probeTimer); this._probeTimer = null; } } /** * The probe went unanswered: the socket is transmitting into the void. * Close it and tell the tabs, so they reconnect. The engine's state is * untouched, so the next stream resumes and replays the unacked queue. */ private _onSocketDead(): void { this._broadcast( 'log', 'warn', `StreamManagement: ack probe unanswered for ${PROBE_TIMEOUT}ms; presuming the socket dead`, ); this._closeSocket(); this._broadcast('_onClose', 'The connection stopped responding'); } /** * Close the socket if no port shows up within the grace period. */ private _startGrace(): void { if (this._graceTimer) return; this._graceTimer = setTimeout(() => { this._graceTimer = null; this._closeSocket(); }, SOCKET_GRACE); } private _cancelGrace(): void { if (this._graceTimer) { clearTimeout(this._graceTimer); this._graceTimer = null; } } /** * Send a message to a single port (worker→page traffic is port-targeted; * only genuine broadcasts use _broadcast). * @param port * @param msg */ private _post(port: MessagePort, ...msg: unknown[]): void { port.postMessage(msg); } /** * Send a message to all live ports. * @param msg */ private _broadcast(...msg: unknown[]): void { this.ports.forEach((_info, port) => port.postMessage(msg)); } } let manager: ConnectionManager | null = null; if (typeof addEventListener === 'function') { addEventListener('connect', (e: MessageEvent) => { manager = manager || new ConnectionManager(); manager.addPort(e.ports[0]); }); } export default ConnectionManager;