UNPKG

strophe.js

Version:

Strophe.js is an XMPP library for JavaScript

340 lines (315 loc) 13.2 kB
import type Connection from '../connection'; import Websocket from './websocket'; import log from '../log'; import Builder, { $build } from '../builder'; import { LOG_LEVELS, NS, SHARED_WORKER_PROTOCOL_VERSION, Status } from '../constants'; import { toElement } from '../utils'; import { WebsocketLike, Transport } from './types'; import type { StreamManagementMirror } from '../stream-management'; /** * Helper class that handles a websocket connection inside a shared worker. */ class WorkerWebsocket extends Websocket implements Transport { worker: SharedWorker; _messageHandler: (m: MessageEvent) => void; socket: WebsocketLike | null; private _lifecycleAttached: boolean; /** * Create and initialize a WorkerWebsocket object. * @param connection - The Connection */ constructor(connection: Connection) { super(connection); this._conn = connection; this._initWorker(); } /** * (Re)create the SharedWorker. Called for every connection attempt: if * the worker for this URL is still running, the browser just opens * another port to it (the previous port is said goodbye to and closed), * but if it terminated (it shuts itself down when it detects a page from * a newer build, and the browser reclaims it when the last tab goes away * or it crashes), a fresh worker running the *current* script is * spawned. Ports to a dead worker fail silently, so re-creating per * attempt is the only reliable way to recover from worker death. */ private _initWorker(): void { if (this.worker) { this.worker.port.postMessage(['_bye']); this.worker.port.close(); } this.worker = new SharedWorker(this._conn.options.worker, 'Strophe XMPP Connection'); this.worker.onerror = (e) => { console?.error(e); log.error(`Shared Worker Error: ${e}`); }; } /** * @private */ _setSocket(): void { this.socket = { send: (str: string) => this.worker.port.postMessage(['send', str]), close: () => this.worker.port.postMessage(['_closeSocket']), onopen: () => {}, onerror: (e: Event) => this._onError(e), onclose: (e: CloseEvent) => this._onClose(e), onmessage: () => {}, readyState: null, }; } _connect(): void { this._initWorker(); this._setSocket(); this._messageHandler = (m: MessageEvent) => this._onInitialMessage(m); this.worker.port.start(); this.worker.port.onmessage = (ev) => this._onWorkerMessage(ev); this.worker.port.postMessage(['_connect', this._conn.service, this._conn.jid, SHARED_WORKER_PROTOCOL_VERSION]); this._attachLifecycleListeners(); } /** * @param callback */ _attach(callback: (status: number, condition?: string | Element, response?: Element | string) => void): void { this._initWorker(); this._setSocket(); this._messageHandler = (m: MessageEvent) => this._onMessage(m); this._conn.connect_callback = callback; this.worker.port.start(); this.worker.port.onmessage = (ev) => this._onWorkerMessage(ev); this.worker.port.postMessage(['_attach', this._conn.service, SHARED_WORKER_PROTOCOL_VERSION]); this._attachLifecycleListeners(); } /** * Called by the worker to assign this tab's role. A secondary shares the * already-established session, so it must not treat inbound frames as its * own connection handshake. * @param role * @param jid - The shared connection's JID. */ _role(role: 'primary' | 'secondary', jid?: string): void { this._conn.role = role; if (role === 'secondary') { this._messageHandler = (m: MessageEvent) => this._onMessage(m); if (jid) this._conn.jid = jid; } this._conn.onRoleChanged(role); } /** * Called by the worker when this tab is promoted to primary after the * previous primary went away. Same socket — no reconnect happens. * @param jid - The shared connection's JID. */ _promote(jid?: string): void { this._conn.role = 'primary'; if (jid) this._conn.jid = jid; this._conn.onRoleChanged('primary'); } /** * Ask the worker to negotiate XEP-0198 for the freshly authenticated * stream (§2.3): it sends <resume/> if it holds resumable state, * otherwise it replies with _smNoState and the connection proceeds to * bind a resource. */ _smFeatures(): void { this.worker.port.postMessage(['_smFeatures']); } /** * Report that the connect flow completed (resource bound, or legacy * auth/session establishment finished). Sent with or without SM: the * worker adopts the bound JID as the shared one, releases tabs parked on * the handshake, and starts a fresh SM session itself when this stream's * features advertised support. * @param jid - The server-assigned full JID. */ _bound(jid: string): void { this.worker.port.postMessage(['_bound', jid]); } /** * Liveness probe from the worker. Answered from this message handler — * which runs even when the browser throttles this tab's timers — so a * merely-backgrounded tab never looks dead to the worker. */ _ping(): void { this.worker.port.postMessage(['_pong']); } /** * Called by the worker when another tab sent a message or presence * stanza over the shared connection. Handed to its own overridable hook * — never into the inbound pipeline (_dataRecv), where it would hit * stanza handlers, SM counting and xmlInput as if it were received * traffic. * @param data - The serialized stanza. */ _onStanzaSent(data: string): void { this._conn.onForeignStanzaSent(toElement(data)); } /** * Called by the worker when it has no resumable state: continue the * connect flow with resource binding. */ _smNoState(): void { this._conn._proceedToBind(); } /** * Called by the worker when a fresh SM session was established. * @param id - The SM-ID. * @param max - The server's preferred maximum resumption time. * @param boundJid - The JID that was bound when the session was enabled. */ _smEnabled(id: string, max: number, boundJid: string): void { (this._conn.sm as StreamManagementMirror)?._onEnabled(id, max, boundJid); } /** * Called by the worker when the previous session was resumed. Every tab * adopts the originally bound JID; the primary — which drove the connect * flow — additionally restores the connection state and emits CONNECTED * (the same actions a non-worker connection applies on <resumed/>). * @param jid - The worker's boundJid. * @param id - The SM-ID of the resumed session. * @param max - The server's preferred maximum resumption time. */ _smResumed(jid: string, id?: string, max?: number): void { const conn = this._conn; (conn.sm as StreamManagementMirror)?._onResumed(jid, id, max); conn.jid = jid; if (conn.role === 'primary') { conn.do_bind = false; conn.authenticated = true; conn.restored = true; conn._changeConnectStatus(Status.CONNECTED, null); } } /** * Called by the worker when resumption failed: the primary falls back * to binding a resource on this same stream (the salvaged queue stays * in the worker and is re-sent after the next <enabled/>). */ _smFailed(): void { const conn = this._conn; (conn.sm as StreamManagementMirror)?._onFailed(); if (conn.role === 'primary') { conn.do_bind = true; conn._proceedToBind(); } } /** * Wire the page lifecycle into the worker's port bookkeeping: `_bye` on * pagehide (graceful removal + failover), `_relinquish` on freeze (hand * the primary role over *before* this tab's CPU stops), and a `_pong` * when the page comes back (which also re-admits this port if the worker * dropped it while we were away). Routine liveness is worker-driven * ping/pong (see {@link WorkerWebsocket#_ping}) — deliberately no * page-side timers, because hidden tabs may only run timers once every * ten minutes. */ private _attachLifecycleListeners(): void { if (this._lifecycleAttached || typeof window === 'undefined') { return; } window.addEventListener('pagehide', () => this.worker.port.postMessage(['_bye'])); window.addEventListener('pageshow', () => this.worker.port.postMessage(['_pong'])); document.addEventListener('freeze', () => this.worker.port.postMessage(['_relinquish'])); document.addEventListener('resume', () => this.worker.port.postMessage(['_pong'])); this._lifecycleAttached = true; } /** * Called when the worker reports that the shared socket closed or died. * Unlike a page-owned websocket this can arrive while this tab believes * it is still connecting. The worker owns the socket, and e.g. its * connection attempt to the server can fail. The base implementation * ignores closes in that state (it only knows real CloseEvents), which * would leave this tab stuck in CONNECTING forever, so fail the * connection attempt instead: the embedder's reconnect logic takes it * from there. * @param e - The close reason from the worker, or a close event. */ _onClose(e?: CloseEvent | string): void { if (this._conn.connected && !this._conn.disconnecting) { log.error('Websocket closed unexpectedly'); this._conn._doDisconnect(); } else if (!this._conn.connected && !this._conn.disconnecting) { const reason = typeof e === 'string' ? e : 'The shared websocket connection could not be established or was lost.'; log.error(`Shared websocket closed while connecting: ${reason}`); this._conn._changeConnectStatus(Status.CONNFAIL, reason); this._conn._doDisconnect(); } else { log.debug('Websocket closed'); } } /** * @param status * @param jid */ _attachCallback(status: number, jid?: string, _condition?: string): void { if (status === Status.ATTACHED) { this._conn.jid = jid; this._conn.authenticated = true; this._conn.connected = true; this._conn.restored = true; this._conn._changeConnectStatus(Status.ATTACHED); } else if (status === Status.ATTACHFAIL) { this._conn.authenticated = false; this._conn.connected = false; this._conn.restored = false; this._conn._changeConnectStatus(Status.ATTACHFAIL); } } /** * @param pres - This stanza will be sent before disconnecting. */ _disconnect(pres?: Element | Builder): void { pres && this._conn.send(pres); const close = $build('close', { 'xmlns': NS.FRAMING }); this._conn.xmlOutput(close.tree()); const closeString = Builder.serialize(close); this._conn.rawOutput(closeString); this.worker.port.postMessage(['send', closeString]); this._conn._doDisconnect(); } _closeSocket(): void { this.socket!.close(); } /** * Called by _onInitialMessage in order to replace itself with the general message handler. * This method is overridden by WorkerWebsocket, which manages a * websocket connection via a service worker and doesn't have direct access * to the socket. */ _replaceMessageHandler(): void { this._messageHandler = (m: MessageEvent) => this._onMessage(m); } /** * function that handles messages received from the service worker * @private * @param ev */ _onWorkerMessage(ev: MessageEvent): void { const { data } = ev; const method_name = data[0]; if (method_name === '_onMessage') { this._messageHandler(data[1]); } else if (method_name in this) { try { (this as unknown as Record<string, Function>)[method_name].apply(this, ev.data.slice(1)); } catch (e) { log.error(String(e)); } } else if (method_name === 'log') { const lmap: Record<string, number> = { debug: LOG_LEVELS.DEBUG, info: LOG_LEVELS.INFO, warn: LOG_LEVELS.WARN, error: LOG_LEVELS.ERROR, fatal: LOG_LEVELS.FATAL, }; const level = data[1]; const msg = data[2]; log.log(lmap[level], msg); } else { log.error(`Found unhandled service worker message: ${data}`); } } } export default WorkerWebsocket;