strophe.js
Version:
Strophe.js is an XMPP library for JavaScript
1,219 lines (1,212 loc) • 63.8 kB
JavaScript
var StropheSharedConnectionWorker = (function (exports) {
'use strict';
const _NS = {
DELAY: 'urn:xmpp:delay' /** XEP-0203 */,
SM: 'urn:xmpp:sm:3'};
/**
* Common namespace constants from the XMPP RFCs and XEPs.
* Extensible at runtime via {@link Strophe.addNamespace}, hence the string
* index signature.
*/
const NS = _NS;
/**
* The version of the page↔worker message protocol spoken between
* WorkerWebsocket and dist/shared-connection-worker.js. A SharedWorker can
* outlive the pages that spawned it, so after a deploy a freshly loaded page
* may attach to a worker from an older build (or vice versa). The version is
* exchanged on _connect/_attach so that a mismatch fails loudly instead of
* silently misbehaving.
*/
const SHARED_WORKER_PROTOCOL_VERSION = 3;
/**
* Connection status constants for use by the connection handler
* callback.
*/
const Status = {
ATTACHED: 8,
ATTACHFAIL: 12};
const LOG_LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
FATAL: 4,
};
let logLevel = LOG_LEVELS.DEBUG;
const log = {
/**
* Library consumers can use this function to set the log level of Strophe.
* The default log level is Strophe.LogLevel.INFO.
* @param level
* @example Strophe.setLogLevel(Strophe.LogLevel.DEBUG);
*/
setLogLevel(level) {
if (level < LOG_LEVELS.DEBUG || level > LOG_LEVELS.FATAL) {
throw new Error("Invalid log level supplied to setLogLevel");
}
logLevel = level;
},
/**
*
* Please note that data sent and received over the wire is logged
* via {@link Strophe.Connection#rawInput|Strophe.Connection.rawInput()}
* and {@link Strophe.Connection#rawOutput|Strophe.Connection.rawOutput()}.
*
* The different levels and their meanings are
*
* DEBUG - Messages useful for debugging purposes.
* INFO - Informational messages. This is mostly information like
* 'disconnect was called' or 'SASL auth succeeded'.
* WARN - Warnings about potential problems. This is mostly used
* to report transient connection errors like request timeouts.
* ERROR - Some error occurred.
* FATAL - A non-recoverable fatal error occurred.
*
* @param level - The log level of the log message.
* This will be one of the values in Strophe.LOG_LEVELS.
* @param msg - The log message.
*/
log(level, msg) {
if (level < logLevel) {
return;
}
if (level >= LOG_LEVELS.ERROR) {
console === null || console === void 0 ? void 0 : console.error(msg);
}
else if (level === LOG_LEVELS.INFO) {
console === null || console === void 0 ? void 0 : console.info(msg);
}
else if (level === LOG_LEVELS.WARN) {
console === null || console === void 0 ? void 0 : console.warn(msg);
}
else if (level === LOG_LEVELS.DEBUG) {
console === null || console === void 0 ? void 0 : console.debug(msg);
}
},
/**
* Log a message at the Strophe.LOG_LEVELS.DEBUG level.
* @param msg - The log message.
*/
debug(msg) {
this.log(LOG_LEVELS.DEBUG, msg);
},
/**
* Log a message at the Strophe.LOG_LEVELS.INFO level.
* @param msg - The log message.
*/
info(msg) {
this.log(LOG_LEVELS.INFO, msg);
},
/**
* Log a message at the Strophe.LOG_LEVELS.WARN level.
* @param msg - The log message.
*/
warn(msg) {
this.log(LOG_LEVELS.WARN, msg);
},
/**
* Log a message at the Strophe.LOG_LEVELS.ERROR level.
* @param msg - The log message.
*/
error(msg) {
this.log(LOG_LEVELS.ERROR, msg);
},
/**
* Log a message at the Strophe.LOG_LEVELS.FATAL level.
* @param msg - The log message.
*/
fatal(msg) {
this.log(LOG_LEVELS.FATAL, msg);
},
};
/**
* XEP-0198 Stream Management
*
* Environment-free helpers.
*
* IMPORTANT: this module must be loadable in a SharedWorker global (it is
* bundled into dist/shared-connection-worker.js), so keep it free of imports
* beyond constants (no ../utils.ts or ../builder.ts, and no *module-level DOM access).
*/
/** The 'h' counter is an unsignedInt that wraps back to zero (XEP-0198 §4). */
const H_WRAP = 2 ** 32;
/** Stanza names that count towards the 'h' counters (XEP-0198 §4). */
const COUNTABLE = ['iq', 'presence', 'message'];
/**
* Escapes invalid xml characters (duplicated from ../utils.ts so the worker
* bundle doesn't pull in utils and its dependencies).
* @param text
*/
function xmlEscape(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/'/g, ''')
.replace(/"/g, '"');
}
/**
* @returns A fresh, inactive SM state object.
*/
function freshState() {
return {
boundJid: null,
enableSent: false,
enabled: false,
hIn: 0,
hOutAcked: 0,
id: null,
location: null,
max: null,
resumeSupported: false,
resumed: false,
sinceLastAck: 0,
unacked: [],
};
}
/**
* Parse an 'h' attribute value into a wrapped unsigned 32-bit counter value.
* @param value - The attribute value (may be undefined).
* @returns The parsed value, or null if missing or invalid.
*/
function parseH(value) {
if (typeof value !== 'string')
return null;
const h = parseInt(value, 10);
return Number.isNaN(h) || h < 0 ? null : h % H_WRAP;
}
/**
* @param name - A top-level element's local tag name.
* @returns true if the element counts towards the SM 'h' counters.
*/
function isCountableStanza(name) {
return COUNTABLE.includes(name);
}
/**
* Remove the `from` attribute from a serialized stanza's root element, so a
* re-sent stanza cannot carry a stale resource after a failed resumption. Only
* the root opening tag is touched; nested `from` attributes (forwarded stanzas,
* MUC addresses) are preserved. The server stamps the authoritative c2s `from`.
* DOM-free string surgery, mirroring stampDelay.
* @param serialized - The serialized stanza.
* @returns The stanza with the root `from` removed (unchanged if none).
*/
function stripFrom(serialized) {
const end = serialized.indexOf('>');
if (end === -1) {
return serialized;
}
const openTag = serialized.slice(0, end);
const rest = serialized.slice(end);
// Values cannot contain their own delimiter quote (the serializer escapes it),
// so a non-greedy match to the matching quote is safe. The leading \s is
// consumed too, leaving no double space. Only the first (root) from is removed.
const stripped = openTag.replace(/\sfrom=(["'])[\s\S]*?\1/, '');
return stripped === openTag ? serialized : stripped + rest;
}
/**
* Insert a XEP-0203 <delay/> child into a serialized stanza (DOM-free string
* surgery). Used when re-sending salvaged stanzas after a failed resumption,
* so the receiving client can show the original send time.
* @param serialized - The serialized stanza.
* @param name - The stanza's local tag name.
* @param queuedAt - The original queueing time (ms since epoch).
* @returns The stanza with a <delay/> child, or the unmodified input if it
* already carries one (or is malformed).
*/
function stampDelay(serialized, name, queuedAt) {
if (serialized.includes(NS.DELAY)) {
return serialized;
}
const delay = `<delay xmlns="${NS.DELAY}" stamp="${new Date(queuedAt).toISOString()}"/>`;
if (serialized.endsWith('/>')) {
return `${serialized.slice(0, -2)}>${delay}</${name}>`;
}
const idx = serialized.lastIndexOf(`</${name}`);
if (idx === -1) {
return serialized;
}
return serialized.slice(0, idx) + delay + serialized.slice(idx);
}
/**
* XEP-0198 Stream Management
*
* Storage backends for resumable session state.
*
* MemoryStorageBackend is environment-free and safe in the worker bundle.
* SessionStorageBackend requires a browser page context, but only at
* construction time, there is no module-level DOM access here.
*/
/**
* In-memory storage backend (Node, tests, or non-resumable setups).
* State is serialized on save so callers can't mutate stored state by reference.
*/
class MemoryStorageBackend {
constructor() {
this.store = new Map();
}
/**
* @param key
*/
load(key) {
const stored = this.store.get(key);
return stored ? JSON.parse(stored) : null;
}
/**
* @param key
* @param state
*/
save(key, state) {
this.store.set(key, JSON.stringify(state));
}
/**
* @param key
*/
clear(key) {
this.store.delete(key);
}
}
/**
* XEP-0198 Stream Management engine.
*
* This class holds all SM state and logic (counters, the unacked queue, the
* enable/resume lifecycle) and never touches a DOM Element or a raw websocket
* frame, so the same class can be hosted by a page-side Connection or inside a
* SharedWorker. Everything it needs from a stanza is captured in a minimal
* {@link StanzaView}, produced by whichever side hosts it (dom.ts on the page,
* parse.ts in the worker). Nonzas are emitted as strings through the injected
* `sendRaw` function.
*
* IMPORTANT: this module must be loadable in a SharedWorker global (it is
* bundled into dist/shared-connection-worker.js), so keep it free of imports
* beyond log/constants and its worker-safe siblings, in particular no
* ../utils.ts or ../builder.ts, and no module-level DOM access.
*/
/**
* The XEP-0198 Stream Management engine.
*
* Hosted by {@link Connection} (page) or by the shared-connection worker; fed
* {@link StanzaView}s by a thin per-environment adapter. Emits nonzas through
* the injected `sendRaw` function.
*/
class StreamManagement {
/**
* @param sendRaw - Emits a serialized nonza (or re-sent stanza) towards the server.
* @param options
*/
constructor(sendRaw, options = {}) {
this._sendRaw = sendRaw;
this._options = Object.assign({ maxUnacked: 5, requestResume: true }, options);
this._storage = this._options.storage || new MemoryStorageBackend();
this._storageKey = null;
this._state = freshState();
this._resumePending = false;
this._pendingResend = [];
this.serverSupported = false;
}
/** The live SM state. Treat as read-only outside of tests. */
get state() {
return this._state;
}
/** Whether <enabled/> has been received and the SM session is active. */
get enabled() {
return this._state.enabled;
}
/** Whether the current session was established by resumption. */
get resumed() {
return this._state.resumed;
}
/** The full JID bound when the SM session was enabled. */
get boundJid() {
return this._state.boundJid;
}
/** Whether a <resume/> has been sent and not yet answered. */
get resumePending() {
return this._resumePending;
}
/**
* Set the storage key (from the user's bare JID) and load any persisted
* resumable state. Call before deciding between resume and fresh bind.
* @param jid - The user's JID (a full JID is reduced to its bare form).
*/
initialize(jid) {
this._storageKey = `strophe-sm:${jid.split('/')[0].toLowerCase()}`;
const stored = this._storage.load(this._storageKey);
if (stored) {
this._state = Object.assign(Object.assign({}, freshState()), stored);
}
}
/**
* @returns true if persisted state allows attempting <resume/>.
*/
hasResumableState() {
return !!(this._state.id && this._state.resumeSupported);
}
/**
* Reset the in-memory engine (e.g. from Connection.reset()).
* Persisted state is NOT touched — clearing storage is tied to intent
* (clean close, logout, failed resume), not to connection reuse.
*/
reset() {
this._state = freshState();
this._resumePending = false;
this._pendingResend = [];
this.serverSupported = false;
}
/** Remove persisted state (clean close, logout, failed resume). */
clearPersistedState() {
if (this._storageKey) {
this._storage.clear(this._storageKey);
}
}
/**
* Send <enable/> to start a new SM session. Call after resource binding,
* at the point CONNECTED is emitted. At most one <enable/> is sent per
* stream — a second attempt SHOULD get the stream killed by the server
* (XEP-0198 §3).
* @param boundJid - The full JID that was just bound.
*/
sendEnable(boundJid) {
const s = this._state;
if (s.enableSent) {
log.warn('StreamManagement.sendEnable called but <enable/> was already sent for this session');
return;
}
s.enableSent = true;
s.enabled = false;
s.resumed = false;
s.id = null;
s.max = null;
s.location = null;
s.resumeSupported = false;
s.hIn = 0;
s.hOutAcked = 0;
s.sinceLastAck = 0;
s.unacked = [];
s.boundJid = boundJid;
const resume = this._options.requestResume ? ' resume="true"' : '';
const max = this._options.max ? ` max="${this._options.max}"` : '';
this._sendRaw(`<enable xmlns="${NS.SM}"${resume}${max}/>`);
this._persist();
}
/**
* Send <resume/> for the persisted previous session. Call instead of
* binding, once the post-SASL stream features advertise SM support.
*/
sendResume() {
const s = this._state;
if (!this.hasResumableState()) {
log.warn('StreamManagement.sendResume called without resumable state');
return;
}
this._resumePending = true;
this._sendRaw(`<resume xmlns="${NS.SM}" h="${s.hIn}" previd="${xmlEscape(s.id)}"/>`);
}
/**
* @returns true once <enable/> has been sent, i.e. outbound stanzas are
* being tracked. Lets the caller skip building a {@link StanzaView}
* for {@link trackOutbound} when tracking is inactive, without
* reaching into the engine's state.
*/
isTracking() {
return this._state.enableSent;
}
/**
* Track an outbound top-level element. Called for every element that
* enters the send queue; non-countable elements are ignored here.
* Active from the moment <enable/> is sent (not from <enabled/> receipt —
* XEP-0198 starts the outbound counter at enable-send).
* @param view
*/
trackOutbound(view) {
const s = this._state;
if (!s.enableSent || !isCountableStanza(view.name)) {
return;
}
s.unacked.push({ name: view.name, stanza: view.serialized, queuedAt: Date.now() });
s.sinceLastAck += 1;
const max = this._options.maxUnacked;
if (max > 0 && s.sinceLastAck % max === 0) {
this.requestAck();
}
this._persist();
}
/**
* Process one inbound top-level element: count it if it is a countable
* stanza (when the session is enabled), otherwise handle it as an SM
* nonza (<r>/<a>/<enabled>/<resumed>/<failed>). Unrecognised elements are
* ignored. Each host dispatches inbound elements to its own handlers
* independently of this call, so nothing is returned.
* @param view
*/
onInbound(view) {
if (isCountableStanza(view.name)) {
this.onInboundStanza(view.name);
return;
}
switch (view.name) {
case 'r':
if (this._state.enabled)
this.sendAck();
break;
case 'a':
this._handleAck(view);
break;
case 'enabled':
this._handleEnabled(view);
break;
case 'resumed':
this._handleResumed(view);
break;
case 'failed':
this._handleFailed(view);
break;
}
}
/** Send an unrequested ack request <r/> to the server. */
requestAck() {
this._sendRaw(`<r xmlns="${NS.SM}"/>`);
}
/**
* Send an ack <a/> with the current inbound count. Used to answer <r/>,
* and RECOMMENDED right before gracefully closing the stream (XEP-0198 §4)
* so the server doesn't redeliver stanzas that were actually received.
*/
sendAck() {
this._sendRaw(`<a xmlns="${NS.SM}" h="${this._state.hIn}"/>`);
}
/**
* Count an inbound top-level stanza by name. Adapters call this from
* their dispatch loop for every inbound child element — non-countable
* names and inactive sessions are no-ops, so no StanzaView needs to be
* built for the common case.
* @param name - The element's local tag name.
*/
onInboundStanza(name) {
if (this._state.enabled && isCountableStanza(name)) {
this._state.hIn = (this._state.hIn + 1) % H_WRAP;
this._persist();
}
}
/**
* Call when the stream is about to be closed cleanly: sends a final
* <a/> so the server doesn't redeliver stanzas that were actually
* received (RECOMMENDED, XEP-0198 §4), clears persisted state (a
* cleanly closed stream is not resumable, XEP-0198 §Stream Closure) and
* deactivates the engine, so nothing sent during teardown is tracked or
* re-persisted.
*/
onGracefulClose() {
if (this._state.enabled) {
this.sendAck();
}
this.clearPersistedState();
this._state = freshState();
this._resumePending = false;
this._pendingResend = [];
}
/** Overridable event hook: an SM session was established via <enabled/>. */
onEnabled() {
return;
}
/** Overridable event hook: the previous session was resumed via <resumed/>. */
onResumed() {
return;
}
/**
* Overridable event hook: <enable/> or <resume/> failed.
* @param _view - The <failed/> nonza (inspect e.g. for <item-not-found/>).
* @param _resumeFailed - true when the failure answered a <resume/> (the
* unacked queue was salvaged for re-send on the next session), false
* when it answered an <enable/>.
*/
onFailed(_view, _resumeFailed) {
return;
}
/**
* Reconcile the server-reported 'h' against the unacked queue (mod 2^32).
* An 'h' above our send count is logged and clamped rather than answered
* with the spec's <handled-count-too-high/> stream close — a client
* killing the stream over a server bug only hurts the user.
* @param h
*/
_reconcile(h) {
const s = this._state;
let delta = (h - s.hOutAcked + H_WRAP) % H_WRAP;
if (delta > s.unacked.length) {
log.error(`StreamManagement: server acked ${delta} stanzas but only ` +
`${s.unacked.length} are unacknowledged (h=${h}, previous h=${s.hOutAcked})`);
delta = s.unacked.length;
}
s.unacked = s.unacked.slice(delta);
s.hOutAcked = h;
}
/**
* @param view
*/
_handleAck(view) {
const s = this._state;
if (!s.enableSent && !s.enabled) {
return;
}
const h = parseH(view.attrs.h);
if (h === null) {
log.error('StreamManagement: received <a/> without a valid h attribute');
return;
}
this._reconcile(h);
s.sinceLastAck = 0;
this._persist();
}
/**
* @param view
*/
_handleEnabled(view) {
const s = this._state;
if (!s.enableSent) {
log.warn('StreamManagement: received <enabled/> but <enable/> was not sent; ignoring');
return;
}
s.enabled = true;
s.id = view.attrs.id || null;
const max = parseInt(view.attrs.max, 10);
s.max = Number.isNaN(max) ? null : max;
s.location = view.attrs.location || null;
s.resumeSupported = ['true', '1'].includes(view.attrs.resume);
this._resendPending();
this._persist();
this.onEnabled();
}
/**
* @param view
*/
_handleResumed(view) {
const s = this._state;
if (!s.id) {
log.warn('StreamManagement: received <resumed/> without resumable state; ignoring');
return;
}
this._resumePending = false;
s.resumed = true;
s.enabled = true;
s.enableSent = true;
const h = parseH(view.attrs.h);
if (h !== null) {
this._reconcile(h);
}
s.sinceLastAck = 0;
// Re-send whatever the server didn't acknowledge (a MUST, XEP-0198
// §5).The root `from` is stripped for symmetry with the failed-resume path;
// on a resume the resource is unchanged, so this is a no-op for a valid
// `from`. No delay stamp here since these go out on the same live session.
for (const entry of s.unacked) {
this._sendRaw(stripFrom(entry.stanza));
}
if (s.unacked.length) {
this.requestAck();
}
this._persist();
this.onResumed();
}
/**
* <enable/> or <resume/> failed. Trim the queue by the optional 'h' on
* <failed/>, reset the dead session and clear its persisted state.
*
* Only a failed *resumption* strands sent-but-undelivered stanzas, so
* only then is the remaining queue salvaged for re-send once a fresh
* session reaches <enabled/> (a SHOULD, XEP-0198 §4). When <failed/>
* answers an <enable/> the stream is alive and bound — everything in
* `unacked` was delivered normally; re-sending it later would duplicate
* it.
* @param view
*/
_handleFailed(view) {
const s = this._state;
if (!s.enableSent && !s.id) {
return;
}
const resumeFailed = this._resumePending;
this._resumePending = false;
const h = parseH(view.attrs.h);
if (h !== null) {
this._reconcile(h);
}
if (resumeFailed) {
this._pendingResend = this._pendingResend.concat(s.unacked);
}
this._state = freshState();
this.clearPersistedState();
this.onFailed(view, resumeFailed);
}
/**
* Re-send stanzas salvaged from a failed resumption on the freshly
* enabled session. Messages are stamped with a XEP-0203 <delay/> carrying
* their original send time. The re-sent stanzas enter the new session's
* unacked queue in wire order.
*/
_resendPending() {
const pending = this._pendingResend;
if (!pending.length) {
return;
}
this._pendingResend = [];
const s = this._state;
for (const entry of pending) {
// Strip the root `from` first: the salvaged strings still carry the
// dead session's resource, which the freshly bound session would
// reject as invalid-from. Then apply the message delay stamp.
const stripped = stripFrom(entry.stanza);
const stanza = entry.name === 'message' ? stampDelay(stripped, entry.name, entry.queuedAt) : stripped;
this._sendRaw(stanza);
s.unacked.push(Object.assign(Object.assign({}, entry), { stanza }));
s.sinceLastAck += 1;
}
this.requestAck();
}
/** Persist the current state, if a storage key has been configured. */
_persist() {
if (this._storageKey) {
this._storage.save(this._storageKey, this._state);
}
}
}
const OPENING_TAG = /^\s*<([a-zA-Z][^\s/>]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/;
const ATTRIBUTE = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
/**
* Unescapes invalid xml characters (duplicated from ../utils.ts so the worker
* bundle doesn't pull in utils and its dependencies).
* @param text
*/
function xmlUnescape(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/'/g, "'")
.replace(/"/g, '"');
}
/**
* Extract a {@link StanzaView} from a raw websocket frame by inspecting its
* opening tag. Namespace prefixes are stripped from the tag name and
* attribute values are XML-unescaped; the frame itself becomes the view's
* `serialized` payload.
* @param frame - The raw frame string.
* @returns The view, or null if the frame doesn't start with a parseable tag.
*/
function peekElement(frame) {
const m = OPENING_TAG.exec(frame);
if (!m) {
return null;
}
const name = m[1].includes(':') ? m[1].slice(m[1].indexOf(':') + 1) : m[1];
const attrs = {};
ATTRIBUTE.lastIndex = 0;
let attr;
while ((attr = ATTRIBUTE.exec(m[2])) !== null) {
attrs[attr[1]] = xmlUnescape(attr[2] !== undefined ? attr[2] : attr[3]);
}
return { name, attrs, serialized: frame };
}
/**
* 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.
*/
/** How often the worker pings its ports (and sweeps for gone ones). */
const PING_INTERVAL = 30000;
/**
* 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.
*/
const PRIMARY_TIMEOUT = 70000;
/**
* 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.
*/
const PORT_GC_TIMEOUT = 30 * 60000;
/** How long the socket is kept open after the last port has gone away. */
const SOCKET_GRACE = 10000;
/**
* 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.
*/
const PROBE_TIMEOUT = 10000;
/** 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'];
/**
* Manages the shared websocket connection as well as the ports of the
* connected tabs.
*/
class ConnectionManager {
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) {
port.addEventListener('message', (e) => 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.
*/
_onPortMessage(port, e) {
const [method, ...args] = e.data;
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 = 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]);
}
this._post(port, '_onClose', 'The shared socket is gone');
return;
}
this._cancelGrace();
try {
this[method](port, ...args);
}
catch (err) {
console === null || console === void 0 ? void 0 : 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, service, jid, version) {
var _a;
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.
(_a = this.sm) === null || _a === void 0 ? void 0 : _a.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, _service, version) {
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) {
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, jid) {
var _a;
this.jid = jid;
if ((_a = this.sm) === null || _a === void 0 ? void 0 : _a.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.
*/
_ensureSM() {
if (!this.sm) {
const sm = new StreamManagement((data) => {
var _a;
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.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) {
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) {
const info = this.ports.get(port);
if ((info === null || info === void 0 ? void 0 : 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) {
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, data) {
var _a;
const view = peekElement(data);
if ((view === null || view === void 0 ? void 0 : 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.
(_a = this.sm) === null || _a === void 0 ? void 0 : _a.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.
*/
_reflect(sender, name, data) {
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, data) {
var _a;
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
try {
(_a = this.sm) === null || _a === void 0 ? void 0 : _a.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) {
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) {
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) {
// 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) {
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.
*/
_checkVersion(port, version) {
var _a, _b;
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();
(_b = (_a = globalThis).close) === null || _b === void 0 ? void 0 : _b.call(_a);
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
* CON