From e40a3893196ef0cf3744f9ba09e361eb3577ed6c Mon Sep 17 00:00:00 2001 From: JC Brand Date: Thu, 2 Jul 2026 17:04:26 +0200 Subject: [PATCH 1/9] feat: Add XEP-0198 Stream Management core engine Stream management engine which can be used either from the page-bound Connection or from inside a SharedWorker. This will let us do SM inside a SharedWorker, solving the problem of different connections using a shared Websocket connection not keeping track of the same SM counts. --- eslint.config.mjs | 2 +- package.json | 4 +- src/constants.ts | 22 +- src/index.ts | 20 +- src/stream-management/engine.ts | 437 +++++++++++++++++++++++++++++++ src/stream-management/index.ts | 12 + src/stream-management/storage.ts | 87 ++++++ src/stream-management/types.ts | 65 +++++ src/stream-management/utils.ts | 95 +++++++ src/utils.ts | 14 +- tests/stream-management.test.ts | 424 ++++++++++++++++++++++++++++++ 11 files changed, 1161 insertions(+), 21 deletions(-) create mode 100644 src/stream-management/engine.ts create mode 100644 src/stream-management/index.ts create mode 100644 src/stream-management/storage.ts create mode 100644 src/stream-management/types.ts create mode 100644 src/stream-management/utils.ts create mode 100644 tests/stream-management.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 45e75322..890071ec 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -264,7 +264,7 @@ export default [...compat.extends("eslint:recommended"), { "valid-jsdoc": "off", "vars-on-top": "off", "wrap-iife": ["error", "any"], - "wrap-regex": "error", + "wrap-regex": "off", "yield-star-spacing": "error", yoda: "off", }, diff --git a/package.json b/package.json index ed4b65b4..205b6d21 100644 --- a/package.json +++ b/package.json @@ -62,11 +62,11 @@ "scripts": { "types": "tsc", "build": "tsc && rollup -c", - "lint": "eslint src/*.ts", + "lint": "eslint src", "clean": "make clean", "doc": "make doc", "test": "npm run build && vitest run && npm run lint", - "prettier": "prettier --write src/*.ts" + "prettier": "prettier --write \"src/**/*.ts\"" }, "volo": { "url": "https://raw.githubusercontent.com/strophe/strophejs/release-{version}/strophe.js" diff --git a/src/constants.ts b/src/constants.ts index 1c64ae20..dbdcebd4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,25 +2,27 @@ * Common namespace constants from the XMPP RFCs and XEPs. */ export const NS = { - HTTPBIND: 'http://jabber.org/protocol/httpbind', + AUTH: 'jabber:iq:auth', + BIND: 'urn:ietf:params:xml:ns:xmpp-bind', BOSH: 'urn:xmpp:xbosh', CLIENT: 'jabber:client', - SERVER: 'jabber:server', - AUTH: 'jabber:iq:auth', - ROSTER: 'jabber:iq:roster', - PROFILE: 'jabber:iq:profile', DISCO_INFO: 'http://jabber.org/protocol/disco#info', DISCO_ITEMS: 'http://jabber.org/protocol/disco#items', + DELAY: 'urn:xmpp:delay' /** XEP-0203 */, + FRAMING: 'urn:ietf:params:xml:ns:xmpp-framing', + HTTPBIND: 'http://jabber.org/protocol/httpbind', MUC: 'http://jabber.org/protocol/muc', + PROFILE: 'jabber:iq:profile', + ROSTER: 'jabber:iq:roster', SASL: 'urn:ietf:params:xml:ns:xmpp-sasl', - STREAM: 'http://etherx.jabber.org/streams', - FRAMING: 'urn:ietf:params:xml:ns:xmpp-framing', - BIND: 'urn:ietf:params:xml:ns:xmpp-bind', + SERVER: 'jabber:server', SESSION: 'urn:ietf:params:xml:ns:xmpp-session', - VERSION: 'jabber:iq:version', + SM: 'urn:xmpp:sm:3', STANZAS: 'urn:ietf:params:xml:ns:xmpp-stanzas', - XHTML_IM: 'http://jabber.org/protocol/xhtml-im', + STREAM: 'http://etherx.jabber.org/streams', + VERSION: 'jabber:iq:version', XHTML: 'http://www.w3.org/1999/xhtml', + XHTML_IM: 'http://jabber.org/protocol/xhtml-im', } as const; export const PARSE_ERROR_NS = 'http://www.w3.org/1999/xhtml'; diff --git a/src/index.ts b/src/index.ts index 4114ab74..f6aab9b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import SASLSHA256 from './sasl-sha256'; import SASLSHA384 from './sasl-sha384'; import SASLSHA512 from './sasl-sha512'; import SASLXOAuth2 from './sasl-xoauth2'; +import StreamManagement, { MemoryStorageBackend, SessionStorageBackend } from './stream-management'; import TimedHandler from './timed-handler'; import Websocket from './websocket'; import WorkerWebsocket from './worker-websocket'; @@ -41,6 +42,7 @@ type StropheType = { SASLExternal: typeof SASLExternal; SASLXOAuth2: typeof SASLXOAuth2; Stanza: typeof Stanza; + StreamManagement: typeof StreamManagement; Builder: typeof Builder; ElementType: typeof ElementType; ErrorCondition: typeof ErrorCondition; @@ -101,6 +103,7 @@ const Strophe: StropheType = { SASLXOAuth2, Stanza, + StreamManagement, Builder, ElementType, ErrorCondition, @@ -144,4 +147,19 @@ const Strophe: StropheType = { const toStanza = Stanza.toElement; (globalThis as any).toStanza = Stanza.toElement; -export { Builder, $build, $iq, $msg, $pres, Strophe, Stanza, stx, toStanza, Request }; +export { + Builder, + $build, + $iq, + $msg, + $pres, + Strophe, + Stanza, + stx, + toStanza, + Request, + StreamManagement, + MemoryStorageBackend, + SessionStorageBackend, +}; +export type { StanzaView, QueuedStanza, SMState, SMStorageBackend, StreamManagementOptions } from './stream-management'; diff --git a/src/stream-management/engine.ts b/src/stream-management/engine.ts new file mode 100644 index 00000000..a88085eb --- /dev/null +++ b/src/stream-management/engine.ts @@ -0,0 +1,437 @@ +/** + * XEP-0198 Stream Management — the 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. 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. + */ +import log from '../log'; +import { NS } from '../constants'; +import { QueuedStanza, SMState, SMStorageBackend, StanzaView, StreamManagementOptions } from './types'; +import { H_WRAP, freshState, isCountableStanza, parseH, stampDelay, xmlEscape } from './utils'; +import { MemoryStorageBackend } from './storage'; + +/** SM nonza names in the urn:xmpp:sm:3 namespace. */ +const NONZAS = ['r', 'a', 'enabled', 'resumed', 'failed']; + +/** + * 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 — on the page that appends to the + * connection's send queue (never a direct socket write, to preserve stanza + * ordering), in the worker it writes to the socket. + */ +class StreamManagement { + /** Whether the current stream's features advertised . Not persisted. */ + serverSupported: boolean; + + private _sendRaw: (data: string) => void; + private _options: StreamManagementOptions; + private _storage: SMStorageBackend; + private _storageKey: string; + private _state: SMState; + /** + * Whether a has been sent and not yet answered. Deliberately + * not part of the persisted state: it describes the current stream's + * negotiation, not the resumable session, so a reloaded page must not + * inherit it. + */ + private _resumePending: boolean; + /** + * Stanzas salvaged from a failed resumption, awaiting re-send once the + * fresh session reaches . Deliberately not persisted: storage is + * cleared on , and this buffer is best-effort redelivery. + */ + private _pendingResend: QueuedStanza[]; + + /** + * @param sendRaw - Emits a serialized nonza (or re-sent stanza) towards the server. + * @param options + */ + constructor(sendRaw: (data: string) => void, options: StreamManagementOptions = {}) { + this._sendRaw = sendRaw; + this._options = { + 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(): SMState { + return this._state; + } + + /** Whether has been received and the SM session is active. */ + get enabled(): boolean { + return this._state.enabled; + } + + /** Whether the current session was established by resumption. */ + get resumed(): boolean { + return this._state.resumed; + } + + /** The full JID bound when the SM session was enabled. */ + get boundJid(): string { + return this._state.boundJid; + } + + /** Whether a has been sent and not yet answered. */ + get resumePending(): boolean { + 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: string): void { + this._storageKey = `strophe-sm:${jid.split('/')[0].toLowerCase()}`; + const stored = this._storage.load(this._storageKey); + if (stored) { + this._state = { ...freshState(), ...stored }; + } + } + + /** + * @returns true if persisted state allows attempting . + */ + hasResumableState(): boolean { + 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(): void { + this._state = freshState(); + this._resumePending = false; + this._pendingResend = []; + this.serverSupported = false; + } + + /** Remove persisted state (clean close, logout, failed resume). */ + clearPersistedState(): void { + if (this._storageKey) { + this._storage.clear(this._storageKey); + } + } + + /** + * Send to start a new SM session. Call after resource binding, + * at the point CONNECTED is emitted. At most one 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: string): void { + const s = this._state; + if (s.enableSent) { + log.warn('StreamManagement.sendEnable called but 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(``); + this._persist(); + } + + /** + * Send for the persisted previous session. Call instead of + * binding, once the post-SASL stream features advertise SM support. + */ + sendResume(): void { + const s = this._state; + if (!this.hasResumableState()) { + log.warn('StreamManagement.sendResume called without resumable state'); + return; + } + this._resumePending = true; + this._sendRaw(``); + } + + /** + * 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 is sent (not from receipt — + * XEP-0198 starts the outbound counter at enable-send). + * @param view + */ + trackOutbound(view: StanzaView): void { + 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 an inbound top-level element. + * @param view + * @returns true if the element was an SM nonza (consumed by the engine), + * false if it's a regular stanza (counted here when the session is + * enabled, but still to be dispatched to handlers by the caller). + */ + onInbound(view: StanzaView): boolean { + if (isCountableStanza(view.name)) { + if (this._state.enabled) { + this._state.hIn = (this._state.hIn + 1) % H_WRAP; + this._persist(); + } + return false; + } + switch (view.name) { + case 'r': + if (this._state.enabled) this.sendAck(); + return true; + case 'a': + this._handleAck(view); + return true; + case 'enabled': + this._handleEnabled(view); + return true; + case 'resumed': + this._handleResumed(view); + return true; + case 'failed': + this._handleFailed(view); + return true; + default: + return false; + } + } + + /** + * @param name - A top-level element's local tag name. + * @returns true if the element is an SM nonza. + */ + isNonza(name: string): boolean { + return NONZAS.includes(name); + } + + /** Send an unrequested ack request to the server. */ + requestAck(): void { + this._sendRaw(``); + } + + /** + * Send an ack with the current inbound count. Used to answer , + * and RECOMMENDED right before gracefully closing the stream (XEP-0198 §4) + * so the server doesn't redeliver stanzas that were actually received. + */ + sendAck(): void { + this._sendRaw(``); + } + + /** Overridable event hook: an SM session was established via . */ + onEnabled(): void { + return; + } + + /** Overridable event hook: the previous session was resumed via . */ + onResumed(): void { + return; + } + + /** + * Overridable event hook: or failed. + * @param _view - The nonza (inspect e.g. for ). + * @param _resumeFailed - true when the failure answered a (the + * unacked queue was salvaged for re-send on the next session), false + * when it answered an . + */ + onFailed(_view?: StanzaView, _resumeFailed?: boolean): void { + 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 stream close — a client + * killing the stream over a server bug only hurts the user. + * @param h + */ + private _reconcile(h: number): void { + 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 + */ + private _handleAck(view: StanzaView): void { + const s = this._state; + if (!s.enableSent && !s.enabled) { + return; + } + const h = parseH(view.attrs.h); + if (h === null) { + log.error('StreamManagement: received without a valid h attribute'); + return; + } + this._reconcile(h); + s.sinceLastAck = 0; + this._persist(); + } + + /** + * @param view + */ + private _handleEnabled(view: StanzaView): void { + const s = this._state; + if (!s.enableSent) { + log.warn('StreamManagement: received but 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 + */ + private _handleResumed(view: StanzaView): void { + const s = this._state; + if (!s.id) { + log.warn('StreamManagement: received 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 entries stay in `unacked` — they're still unacknowledged — + // and are not re-tracked. + for (const entry of s.unacked) { + this._sendRaw(entry.stanza); + } + if (s.unacked.length) { + this.requestAck(); + } + this._persist(); + this.onResumed(); + } + + /** + * or failed. Trim the queue by the optional 'h' on + * , 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 (a SHOULD, XEP-0198 §4). When + * answers an the stream is alive and bound — everything in + * `unacked` was delivered normally; re-sending it later would duplicate + * it. + * @param view + */ + private _handleFailed(view: StanzaView): void { + 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 carrying + * their original send time. The re-sent stanzas enter the new session's + * unacked queue in wire order. + */ + private _resendPending(): void { + const pending = this._pendingResend; + if (!pending.length) { + return; + } + this._pendingResend = []; + const s = this._state; + for (const entry of pending) { + const stanza = + entry.name === 'message' ? stampDelay(entry.stanza, entry.name, entry.queuedAt) : entry.stanza; + this._sendRaw(stanza); + s.unacked.push({ ...entry, stanza }); + s.sinceLastAck += 1; + } + this.requestAck(); + } + + /** Persist the current state, if a storage key has been configured. */ + private _persist(): void { + if (this._storageKey) { + this._storage.save(this._storageKey, this._state); + } + } +} + +export default StreamManagement; diff --git a/src/stream-management/index.ts b/src/stream-management/index.ts new file mode 100644 index 00000000..8688f8c3 --- /dev/null +++ b/src/stream-management/index.ts @@ -0,0 +1,12 @@ +/** + * XEP-0198 Stream Management. + * + * The environment-free {@link StreamManagement} engine (engine.ts) holds all + * SM state and logic; it consumes minimal {@link StanzaView}s produced by a + * thin adapter on whichever side hosts it, so the same implementation can run + * on a page or inside a SharedWorker. + */ +export { default } from './engine'; +export { MemoryStorageBackend, SessionStorageBackend } from './storage'; +export { isCountableStanza, stampDelay } from './utils'; +export type { StanzaView, QueuedStanza, SMState, SMStorageBackend, StreamManagementOptions } from './types'; diff --git a/src/stream-management/storage.ts b/src/stream-management/storage.ts new file mode 100644 index 00000000..721efcb7 --- /dev/null +++ b/src/stream-management/storage.ts @@ -0,0 +1,87 @@ +/** + * 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. + */ +import log from '../log'; +import { SMState, SMStorageBackend } from './types'; + +/** + * In-memory storage backend (Node, tests, or non-resumable setups). + * State is serialized on save so callers can't mutate stored state by reference. + */ +export class MemoryStorageBackend implements SMStorageBackend { + private store: Map; + + constructor() { + this.store = new Map(); + } + + /** + * @param key + */ + load(key: string): SMState { + const stored = this.store.get(key); + return stored ? JSON.parse(stored) : null; + } + + /** + * @param key + * @param state + */ + save(key: string, state: SMState): void { + this.store.set(key, JSON.stringify(state)); + } + + /** + * @param key + */ + clear(key: string): void { + this.store.delete(key); + } +} + +/** + * sessionStorage-backed storage backend (browser pages). + * Survives page reloads within a tab, which is exactly the resumption window. + */ +export class SessionStorageBackend implements SMStorageBackend { + constructor() { + if (typeof sessionStorage === 'undefined') { + throw new Error('SessionStorageBackend requires a sessionStorage global (browser page context)'); + } + } + + /** + * @param key + */ + load(key: string): SMState { + const stored = sessionStorage.getItem(key); + if (!stored) return null; + try { + return JSON.parse(stored); + } catch (e) { + log.warn(`Discarding unparseable SM state for ${key}: ${e.message}`); + return null; + } + } + + /** + * @param key + * @param state + */ + save(key: string, state: SMState): void { + sessionStorage.setItem(key, JSON.stringify(state)); + } + + /** + * @param key + */ + clear(key: string): void { + sessionStorage.removeItem(key); + } +} diff --git a/src/stream-management/types.ts b/src/stream-management/types.ts new file mode 100644 index 00000000..21dc7cc3 --- /dev/null +++ b/src/stream-management/types.ts @@ -0,0 +1,65 @@ +/** + * XEP-0198 Stream Management — shared types. + * + * Everything in this module is environment-free (no DOM, no worker globals) + * so it can be imported from both the page bundle and the standalone + * shared-connection-worker bundle. + */ + +/** + * A minimal, environment-free view of a top-level stanza or nonza. + * + * The page adapter builds it from a DOM Element (see dom.ts), the worker + * adapter from a raw frame string (see parse.ts). + */ +export interface StanzaView { + name: string; /** The local (unprefixed) tag name of the top-level element. */ + attrs: Record; /** The attributes of the top-level element. */ + serialized: string; /** The serialized element — used as the unacked queue payload. */ +} + +/** An entry in the unacked queue. */ +export interface QueuedStanza { + name: string; /** The local tag name (needed to decide XEP-0203 delay-stamping on re-send). */ + stanza: string; /** The serialized stanza. */ + queuedAt: number; /** When the stanza was first queued (ms since epoch) the XEP-0203 delay stamp value. */ +} + +/** The serialisable Stream Management session state. */ +export interface SMState { + /** + * Whether has been sent on the current stream. The XEP starts the + * two counters asymmetrically: outbound tracking begins at enable-*send*, + * inbound counting at -*receipt* (XEP-0198 §4, note). + */ + enableSent: boolean; + enabled: boolean; /** Whether has been received; inbound counting is active. */ + id: string; /** The SM-ID from ; sent as `previd` when resuming. */ + boundJid: string; /** The full JID that was bound when was sent (re-applied resume). */ + max: number; /** Server's preferred max resumption time in seconds (from ). */ + location: string; /** Server's preferred reconnection location (from ). Stored but not acted upon. */ + resumeSupported: boolean; /** Whether the server allows this session to be resumed. */ + resumed: boolean; /** Whether the current session was established by resuming a previous one. */ + hIn: number; /** Count of inbound stanzas handled by us. The client's 'h', mod 2^32. */ + hOutAcked: number; /** The last 'h' value acknowledged by the server. */ + unacked: QueuedStanza[]; /** Outbound stanzas sent but not yet acknowledged by the server. */ + sinceLastAck: number; /** Outbound countable stanzas since the server's last . */ +} + +/** + * Pluggable persistence for resumable SM state. + * Implementations must be synchronous. The handler runs inside the + * synchronous stanza dispatch and state must be consistent immediately after. + */ +export interface SMStorageBackend { + load(key: string): SMState | null; + save(key: string, state: SMState): void; + clear(key: string): void; +} + +export interface StreamManagementOptions { + maxUnacked?: number; /** Request an ack via after this many outbound stanzas. 0 disables. Default: 5. */ + requestResume?: boolean; /** Ask for a resumable session (resume="true" on ). Default: true. */ + max?: number; /** The client's preferred max resumption time in seconds (max attribute on ). */ + storage?: SMStorageBackend; /** Storage backend for resumable state. Default: per-instance MemoryStorageBackend. */ +} diff --git a/src/stream-management/utils.ts b/src/stream-management/utils.ts new file mode 100644 index 00000000..f7b7670c --- /dev/null +++ b/src/stream-management/utils.ts @@ -0,0 +1,95 @@ +/** + * 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). + */ +import { NS } from '../constants'; +import { SMState } from './types'; + +/** The 'h' counter is an unsignedInt that wraps back to zero (XEP-0198 §4). */ +export 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 + */ +export function xmlEscape(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"'); +} + +/** + * @returns A fresh, inactive SM state object. + */ +export function freshState(): SMState { + 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. + */ +export function parseH(value: string): number { + 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. + */ +export function isCountableStanza(name: string): boolean { + return COUNTABLE.includes(name); +} + +/** + * Insert a XEP-0203 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 child, or the unmodified input if it + * already carries one (or is malformed). + */ +export function stampDelay(serialized: string, name: string, queuedAt: number): string { + if (serialized.includes(NS.DELAY)) { + return serialized; + } + const delay = ``; + if (serialized.endsWith('/>')) { + return `${serialized.slice(0, -2)}>${delay}`; + } + const idx = serialized.lastIndexOf(`/g, '>'); - text = text.replace(/'/g, '''); - text = text.replace(/"/g, '"'); - return text; + return text + .replace(/\&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"'); } /** diff --git a/tests/stream-management.test.ts b/tests/stream-management.test.ts new file mode 100644 index 00000000..a20efc8b --- /dev/null +++ b/tests/stream-management.test.ts @@ -0,0 +1,424 @@ +import { StreamManagement, MemoryStorageBackend, SessionStorageBackend } from '../dist/strophe.node.esm.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +const SM_NS = 'urn:xmpp:sm:3'; + +/** + * Build a StanzaView the way an adapter would. + */ +function view(name: string, attrs: Record = {}, serialized?: string) { + return { name, attrs, serialized: serialized ?? `<${name}/>` }; +} + +/** + * Create an engine with a capturing sendRaw and a memory backend. + */ +function make(options: Record = {}) { + const sent: string[] = []; + const storage = new MemoryStorageBackend(); + const sm = new StreamManagement((data: string) => sent.push(data), { storage, ...options }); + return { sm, sent, storage }; +} + +/** + * Drive an engine into an enabled session. + */ +function enable(sm: InstanceType, enabledAttrs: Record = {}) { + sm.sendEnable('romeo@example.net/orchard'); + sm.onInbound(view('enabled', { id: 'some-long-sm-id', resume: 'true', ...enabledAttrs })); +} + +describe('StreamManagement core', () => { + describe('enabling', () => { + it('is inert before is sent', () => { + const { sm, sent } = make(); + sm.trackOutbound(view('message', {}, 'hi')); + sm.onInbound(view('message')); + sm.onInbound(view('r')); + expect(sm.state.unacked.length).toBe(0); + expect(sm.state.hIn).toBe(0); + expect(sent.length).toBe(0); + }); + + it('emits with resume and max', () => { + const { sm, sent } = make({ max: 300 }); + sm.sendEnable('romeo@example.net/orchard'); + expect(sent).toEqual([``]); + expect(sm.state.enableSent).toBe(true); + expect(sm.state.enabled).toBe(false); + expect(sm.boundJid).toBe('romeo@example.net/orchard'); + }); + + it('omits resume when requestResume is false', () => { + const { sm, sent } = make({ requestResume: false }); + sm.sendEnable('romeo@example.net/orchard'); + expect(sent).toEqual([``]); + }); + + it('sends at most once per session', () => { + const { sm, sent } = make(); + sm.sendEnable('romeo@example.net/orchard'); + sm.sendEnable('romeo@example.net/orchard'); + expect(sent.length).toBe(1); + }); + + it('stores id/max/location/resumeSupported from and fires onEnabled', () => { + const { sm } = make(); + const spy = vi.fn(); + sm.onEnabled = spy; + sm.sendEnable('romeo@example.net/orchard'); + sm.onInbound(view('enabled', { id: 'abc', resume: '1', max: '600', location: '[::1]:5222' })); + expect(sm.enabled).toBe(true); + expect(sm.state.id).toBe('abc'); + expect(sm.state.max).toBe(600); + expect(sm.state.location).toBe('[::1]:5222'); + expect(sm.state.resumeSupported).toBe(true); + expect(spy).toHaveBeenCalledOnce(); + }); + + it('ignores when was not sent', () => { + const { sm } = make(); + sm.onInbound(view('enabled', { id: 'abc', resume: 'true' })); + expect(sm.enabled).toBe(false); + }); + }); + + describe('counter-start asymmetry (XEP-0198 §4 note)', () => { + it('tracks outbound from enable-send, before arrives', () => { + const { sm } = make(); + sm.sendEnable('romeo@example.net/orchard'); + sm.trackOutbound(view('message', {}, 'early')); + expect(sm.state.unacked.length).toBe(1); + }); + + it('does not count inbound until is received', () => { + const { sm } = make(); + sm.sendEnable('romeo@example.net/orchard'); + sm.onInbound(view('message')); + expect(sm.state.hIn).toBe(0); + sm.onInbound(view('enabled', { id: 'abc', resume: 'true' })); + sm.onInbound(view('message')); + sm.onInbound(view('iq')); + expect(sm.state.hIn).toBe(2); + }); + }); + + describe('counting and acks', () => { + it('only counts iq/presence/message', () => { + const { sm } = make(); + enable(sm); + for (const name of ['iq', 'presence', 'message']) { + sm.onInbound(view(name)); + sm.trackOutbound(view(name)); + } + for (const name of ['open', 'close', 'features', 'challenge', 'r', 'a']) { + sm.onInbound(view(name)); + sm.trackOutbound(view(name)); + } + expect(sm.state.hIn).toBe(3); + expect(sm.state.unacked.length).toBe(3); + }); + + it('answers with ', () => { + const { sm, sent } = make(); + enable(sm); + sm.onInbound(view('message')); + sm.onInbound(view('message')); + expect(sm.onInbound(view('r'))).toBe(true); + expect(sent.at(-1)).toBe(``); + }); + + it('sendAck emits the current inbound count (final ack before close)', () => { + const { sm, sent } = make(); + enable(sm); + sm.onInbound(view('presence')); + sm.sendAck(); + expect(sent.at(-1)).toBe(``); + }); + + it('trims the unacked queue on and resets sinceLastAck', () => { + const { sm } = make(); + enable(sm); + sm.trackOutbound(view('message', {}, '1')); + sm.trackOutbound(view('message', {}, '2')); + sm.trackOutbound(view('message', {}, '3')); + sm.onInbound(view('a', { h: '2' })); + expect(sm.state.unacked.map((e: { stanza: string }) => e.stanza)).toEqual(['3']); + expect(sm.state.hOutAcked).toBe(2); + expect(sm.state.sinceLastAck).toBe(0); + }); + + it('clamps when the server acks more than was sent', () => { + const { sm } = make(); + enable(sm); + sm.trackOutbound(view('message', {}, '1')); + sm.onInbound(view('a', { h: '10' })); + expect(sm.state.unacked.length).toBe(0); + expect(sm.state.hOutAcked).toBe(10); + }); + + it('handles the 2^32 wraparound in reconciliation', () => { + const { sm } = make(); + enable(sm); + sm.state.hOutAcked = 4294967294; + sm.trackOutbound(view('message', {}, '1')); + sm.trackOutbound(view('message', {}, '2')); + sm.trackOutbound(view('message', {}, '3')); + sm.onInbound(view('a', { h: '1' })); + expect(sm.state.unacked.length).toBe(0); + expect(sm.state.hOutAcked).toBe(1); + }); + + it('wraps the inbound counter at 2^32', () => { + const { sm, sent } = make(); + enable(sm); + sm.state.hIn = 4294967295; + sm.onInbound(view('message')); + expect(sm.state.hIn).toBe(0); + sm.sendAck(); + expect(sent.at(-1)).toBe(``); + }); + + it('requests an ack every maxUnacked outbound stanzas', () => { + const { sm, sent } = make({ maxUnacked: 3 }); + enable(sm); + const countAckRequests = () => sent.filter((s) => s === ``).length; + for (let i = 0; i < 5; i++) sm.trackOutbound(view('message', {}, `${i}`)); + expect(countAckRequests()).toBe(1); + sm.trackOutbound(view('message', {}, '6')); + expect(countAckRequests()).toBe(2); + sm.onInbound(view('a', { h: '6' })); + for (let i = 0; i < 3; i++) sm.trackOutbound(view('message', {}, `${7 + i}`)); + expect(countAckRequests()).toBe(3); + }); + + it('never requests acks when maxUnacked is 0', () => { + const { sm, sent } = make({ maxUnacked: 0 }); + enable(sm); + for (let i = 0; i < 10; i++) sm.trackOutbound(view('message', {}, `${i}`)); + expect(sent.filter((s) => s === ``).length).toBe(0); + }); + }); + + describe('resumption', () => { + it('reports resumable state only when the server allowed resumption', () => { + const { sm } = make(); + sm.sendEnable('romeo@example.net/orchard'); + sm.onInbound(view('enabled', { id: 'abc' })); + expect(sm.hasResumableState()).toBe(false); + const other = make().sm; + enable(other); + expect(other.hasResumableState()).toBe(true); + }); + + it('emits with h and an escaped previd', () => { + const { sm, sent } = make(); + sm.sendEnable('romeo@example.net/orchard'); + sm.onInbound(view('enabled', { id: 'ab"&`); + }); + + it('on reconciles h, resends the remainder and keeps it queued', () => { + const { sm, sent } = make(); + const spy = vi.fn(); + sm.onResumed = spy; + enable(sm); + sm.trackOutbound(view('message', {}, '1')); + sm.trackOutbound(view('message', {}, '2')); + sm.trackOutbound(view('message', {}, '3')); + const before = sent.length; + sm.onInbound(view('resumed', { h: '1', previd: 'some-long-sm-id' })); + expect(sent.slice(before)).toEqual([ + '2', + '3', + ``, + ]); + expect(sm.state.unacked.length).toBe(2); + expect(sm.resumed).toBe(true); + expect(sm.enabled).toBe(true); + expect(spy).toHaveBeenCalledOnce(); + }); + + it('ignores without resumable state', () => { + const { sm } = make(); + sm.onInbound(view('resumed', { h: '1', previd: 'bogus' })); + expect(sm.resumed).toBe(false); + }); + }); + + describe('failed resumption (queue salvage)', () => { + it('trims by the h on , resets state and clears storage', () => { + const { sm, storage } = make(); + const spy = vi.fn(); + sm.onFailed = spy; + sm.initialize('romeo@example.net'); + enable(sm); + sm.onInbound(view('message')); + sm.trackOutbound(view('message', {}, '1')); + sm.trackOutbound(view('message', {}, '2')); + expect(storage.load('strophe-sm:romeo@example.net')).not.toBeNull(); + sm.onInbound(view('failed', { h: '1' }, ``)); + expect(sm.state.enableSent).toBe(false); + expect(sm.state.enabled).toBe(false); + expect(sm.state.id).toBe(null); + expect(sm.state.hIn).toBe(0); + expect(sm.state.unacked.length).toBe(0); + expect(storage.load('strophe-sm:romeo@example.net')).toBeNull(); + expect(spy).toHaveBeenCalledOnce(); + }); + + it('resends the salvaged queue once a fresh session reaches ', () => { + const { sm, sent } = make(); + enable(sm); + sm.trackOutbound(view('message', {}, 'lost?')); + sm.trackOutbound(view('iq', {}, '')); + sm.sendResume(); // a reconnect attempts to resume... + sm.onInbound(view('failed', {})); // ...which the server refuses + const before = sent.length; + enable(sm); + const resent = sent.slice(before + 1); // skip the itself + expect(resent.length).toBe(3); + expect(resent[0]).toMatch( + /^lost\?<\/body><\/message>$/ + ); + expect(resent[1]).toBe(''); // only messages are delay-stamped + expect(resent[2]).toBe(``); + expect(sm.state.unacked.length).toBe(2); // requeued on the new session, in wire order + }); + + it('does not salvage when a fresh (not a ) is refused', () => { + const { sm, sent } = make(); + enable(sm); + // The stream is alive and bound; this stanza was delivered normally. + sm.trackOutbound(view('message', {}, 'delivered')); + sm.onInbound(view('failed', {})); // the server refuses the + const before = sent.length; + enable(sm); // a later fresh session + // nothing is re-sent: re-sending a delivered stanza would duplicate it + expect(sent.slice(before + 1)).toEqual([]); + expect(sm.state.unacked.length).toBe(0); + }); + }); + + describe('resumePending', () => { + it('is set by sendResume and cleared by ', () => { + const { sm } = make(); + enable(sm); + expect(sm.resumePending).toBe(false); + sm.sendResume(); + expect(sm.resumePending).toBe(true); + sm.onInbound(view('resumed', { h: '0', previd: 'some-long-sm-id' })); + expect(sm.resumePending).toBe(false); + }); + + it('is cleared by and by reset()', () => { + const { sm } = make(); + enable(sm); + sm.sendResume(); + sm.onInbound(view('failed', {})); + expect(sm.resumePending).toBe(false); + enable(sm); + sm.sendResume(); + sm.reset(); + expect(sm.resumePending).toBe(false); + }); + }); + + describe('delay stamping', () => { + it('handles self-closing stanzas', () => { + const { sm, sent } = make(); + enable(sm); + sm.trackOutbound(view('message', {}, '')); + sm.sendResume(); + sm.onInbound(view('failed', {})); + enable(sm); + expect(sent.find((s) => s.startsWith(''))).toMatch( + /^<\/message>$/ + ); + }); + + it('does not stamp twice', () => { + const { sm, sent } = make(); + enable(sm); + const stamped = ''; + sm.trackOutbound(view('message', {}, stamped)); + sm.sendResume(); + sm.onInbound(view('failed', {})); + enable(sm); + expect(sent.filter((s) => s === stamped).length).toBe(1); + }); + }); + + describe('persistence', () => { + it('persists across engine instances (page reload scenario)', () => { + const { sm, storage } = make(); + sm.initialize('Romeo@Example.Net/orchard'); // keyed by lowercased bare JID + enable(sm); + sm.onInbound(view('message')); + sm.onInbound(view('message')); + sm.trackOutbound(view('message', {}, 'unacked')); + + const sent2: string[] = []; + const sm2 = new StreamManagement((data: string) => sent2.push(data), { storage }); + sm2.initialize('romeo@example.net'); + expect(sm2.hasResumableState()).toBe(true); + expect(sm2.state.hIn).toBe(2); + expect(sm2.state.id).toBe('some-long-sm-id'); + expect(sm2.boundJid).toBe('romeo@example.net/orchard'); + sm2.sendResume(); + expect(sent2).toEqual([``]); + sm2.onInbound(view('resumed', { h: '0', previd: 'some-long-sm-id' })); + expect(sent2.at(-2)).toBe('unacked'); + }); + + it('does not persist without initialize()', () => { + const { sm, storage } = make(); + enable(sm); + sm.onInbound(view('message')); + const sm2 = new StreamManagement(() => {}, { storage }); + sm2.initialize('romeo@example.net'); + expect(sm2.hasResumableState()).toBe(false); + }); + + it('reset() clears memory but not storage; clearPersistedState() clears storage', () => { + const { sm, storage } = make(); + sm.initialize('romeo@example.net'); + enable(sm); + sm.reset(); + expect(sm.enabled).toBe(false); + expect(storage.load('strophe-sm:romeo@example.net')).not.toBeNull(); + sm.initialize('romeo@example.net'); + expect(sm.hasResumableState()).toBe(true); + sm.clearPersistedState(); + expect(storage.load('strophe-sm:romeo@example.net')).toBeNull(); + }); + }); + + describe('SessionStorageBackend', () => { + afterEach(() => { + delete (globalThis as any).sessionStorage; + }); + + it('throws when sessionStorage is unavailable', () => { + expect(() => new SessionStorageBackend()).toThrow(); + }); + + it('round-trips via a sessionStorage global', () => { + const store = new Map(); + (globalThis as any).sessionStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }; + const backend = new SessionStorageBackend(); + const { sm } = make({ storage: backend }); + sm.initialize('romeo@example.net'); + enable(sm); + expect(backend.load('strophe-sm:romeo@example.net').id).toBe('some-long-sm-id'); + backend.clear('strophe-sm:romeo@example.net'); + expect(backend.load('strophe-sm:romeo@example.net')).toBeNull(); + }); + }); +}); From 140732185168cceb7bd6167b9fcb704c2e8aaef8 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Thu, 2 Jul 2026 17:15:05 +0200 Subject: [PATCH 2/9] feat: Add multi-tab arbitration to the shared connection worker Groundwork for worker-resident XEP-0198 stream management: exactly one tab (port) holds the 'primary' role, all others are secondaries that attach to the shared session. --- rollup.config.js | 13 + src/connection.ts | 141 ++++--- src/constants.ts | 10 + src/shared-connection-worker.ts | 619 +++++++++++++++++++++++++++---- src/worker-websocket.ts | 66 +++- tests/worker-arbitration.test.ts | 443 ++++++++++++++++++++++ 6 files changed, 1148 insertions(+), 144 deletions(-) create mode 100644 tests/worker-arbitration.test.ts diff --git a/rollup.config.js b/rollup.config.js index 17bc2e8d..0eceb175 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -110,4 +110,17 @@ export default [ }, plugins: [typescript(tsConfig)], }, + // Shared-connection worker: a self-contained classic (non-module) worker + // script; point the Connection `worker` option at this file's URL. + { + input: 'src/shared-connection-worker.ts', + output: { + name: 'StropheSharedConnectionWorker', + file: 'dist/shared-connection-worker.js', + format: 'iife', + exports: 'named', + sourcemap: true, + }, + plugins: [typescript(tsConfig)], + }, ]; diff --git a/src/connection.ts b/src/connection.ts index 71306cc8..29dd2741 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -115,7 +115,7 @@ export interface ConnectionOptions { * This allows you to share a single websocket-based connection between * multiple Connection instances, for example one per browser tab. * - * The script to use is the one in `src/shared-connection-worker.js`. + * The script to use is `dist/shared-connection-worker.js`. */ worker?: string; /** @@ -248,6 +248,12 @@ class Connection { do_authentication: boolean; paused: boolean; restored: boolean; + /** + * This connection's role in a shared-worker setup: the 'primary' tab + * drives the stream, 'secondary' tabs share it. undefined outside worker + * mode. Assigned by the worker (see {@link Connection#onRoleChanged}). + */ + role?: 'primary' | 'secondary'; _data: (Element | 'restart')[]; _uniqueId: number; _sasl_success_handler: Handler | null; @@ -356,7 +362,7 @@ class Connection { this.send( $iq({ type: 'error', id: iq.getAttribute('id') }) .c('error', { 'type': 'cancel' }) - .c('service-unavailable', { 'xmlns': NS.STANZAS }) + .c('service-unavailable', { 'xmlns': NS.STANZAS }), ); return false; }, @@ -364,7 +370,7 @@ class Connection { null, ['get', 'set'], null, - null + null, ); // initialize plugins @@ -574,7 +580,7 @@ class Connection { hold?: number, route?: string, authcid?: string, - disconnection_timeout = 3000 + disconnection_timeout = 3000, ): void { this.jid = jid; /** Authorization identity */ @@ -635,7 +641,7 @@ class Connection { callback?: ConnectCallback, wait?: number, hold?: number, - wind?: number + wind?: number, ): void { if (this._proto instanceof Bosh && typeof jid === 'string') { return this._proto._attach(jid, sid, rid, callback, wait, hold, wind); @@ -706,11 +712,6 @@ class Connection { * User overrideable function that receives XML data coming into the * connection. * - * The default function does nothing. User code can override this with - * > Connection.xmlInput = function (elem) { - * > (user code) - * > }; - * * Due to limitations of current Browsers' XML-Parsers the opening and closing * tag for WebSocket-Connoctions will be passed as selfclosing here. * @@ -727,11 +728,6 @@ class Connection { * User overrideable function that receives XML data sent to the * connection. * - * The default function does nothing. User code can override this with - * > Connection.xmlOutput = function (elem) { - * > (user code) - * > }; - * * Due to limitations of current Browsers' XML-Parsers the opening and closing * tag for WebSocket-Connoctions will be passed as selfclosing here. * @@ -748,11 +744,6 @@ class Connection { * User overrideable function that receives raw data coming into the * connection. * - * The default function does nothing. User code can override this with - * > Connection.rawInput = function (data) { - * > (user code) - * > }; - * * @param _data - The data received by the connection. */ rawInput(_data: string): void { @@ -763,11 +754,6 @@ class Connection { * User overrideable function that receives raw data sent to the * connection. * - * The default function does nothing. User code can override this with - * > Connection.rawOutput = function (data) { - * > (user code) - * > }; - * * @param _data - The data sent by the connection. */ rawOutput(_data: string): void { @@ -777,17 +763,26 @@ class Connection { /** * User overrideable function that receives the new valid rid. * - * The default function does nothing. User code can override this with - * > Connection.nextValidRid = function (rid) { - * > (user code) - * > }; - * * @param _rid - The next valid rid */ nextValidRid(_rid: number): void { return; } + /** + * User overrideable function that receives the new role of this + * connection in a shared-worker setup. + * + * Called when the shared worker assigns or changes this tab's role, + * for example when this tab is promoted to 'primary' after the previous + * primary tab went away. + * + * @param _role - The new role ('primary' or 'secondary') + */ + onRoleChanged(_role: 'primary' | 'secondary'): void { + return; + } + /** * Send a stanza. * @@ -841,7 +836,7 @@ class Connection { stanza: Element | Builder, callback?: (stanza: Element) => void, errback?: (stanza: Element | null) => void, - timeout?: number + timeout?: number, ): string { let timeoutHandler: TimedHandler | null = null; @@ -870,7 +865,7 @@ class Connection { null, 'presence', null, - id + id, ); // if timeout specified, set up a timeout handler. @@ -903,7 +898,7 @@ class Connection { stanza: Element | Builder, callback?: (stanza: Element) => void, errback?: (stanza: Element | null) => void, - timeout?: number + timeout?: number, ): string { let timeoutHandler: TimedHandler | null = null; @@ -937,7 +932,7 @@ class Connection { null, 'iq', ['error', 'result'], - id + id, ); // if timeout specified, set up a timeout handler. @@ -1088,7 +1083,7 @@ class Connection { type: string | string[] | null, id?: string | null, from?: string | null, - options?: HandlerOptions + options?: HandlerOptions, ): Handler { const hand = new Handler(handler, ns, name, type, id, from, options); this.addHandlers.push(hand); @@ -1181,7 +1176,7 @@ class Connection { // setup timeout handler this._disconnectTimeout = this._addSysTimedHandler( this.disconnection_timeout, - this._onDisconnectTimeout.bind(this) + this._onDisconnectTimeout.bind(this), ); this._proto._disconnect(pres); } else { @@ -1274,9 +1269,7 @@ class Connection { * @param raw - The stanza as raw string. */ _dataRecv(req: Element | Request, raw?: string): void { - const elem = ( - '_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req - ) as Element | null; + const elem = ('_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req) as Element | null; if (elem === null) { return; } @@ -1338,36 +1331,32 @@ class Connection { } // send each incoming stanza through the handler chain - forEachChild( - elem, - null, - (child: Element) => { - const matches: Handler[] = []; - this.handlers = this.handlers.reduce((handlers, handler) => { - try { - if (handler.isMatch(child) && (this.authenticated || !handler.user)) { - if (handler.run(child)) { - handlers.push(handler); - } - matches.push(handler); - } else { + forEachChild(elem, null, (child: Element) => { + const matches: Handler[] = []; + this.handlers = this.handlers.reduce((handlers, handler) => { + try { + if (handler.isMatch(child) && (this.authenticated || !handler.user)) { + if (handler.run(child)) { handlers.push(handler); } - } catch (e) { - // if the handler throws an exception, we consider it as false - log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message); + matches.push(handler); + } else { + handlers.push(handler); } + } catch (e) { + // if the handler throws an exception, we consider it as false + log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message); + } - return handlers; - }, []); + return handlers; + }, []); - // If no handler was fired for an incoming IQ with type="set", - // then we return an IQ error stanza with service-unavailable. - if (!matches.length && this.iqFallbackHandler.isMatch(child)) { - this.iqFallbackHandler.run(child); - } + // If no handler was fired for an incoming IQ with type="set", + // then we return an IQ error stanza with service-unavailable. + if (!matches.length && this.iqFallbackHandler.isMatch(child)) { + this.iqFallbackHandler.run(child); } - ); + }); } /** @@ -1392,9 +1381,7 @@ class Connection { let bodyWrap: Element | null; try { - bodyWrap = ( - '_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req - ) as Element | null; + bodyWrap = ('_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req) as Element | null; } catch (e) { if (e.name !== ErrorCondition.BAD_FORMAT) { throw e; @@ -1518,21 +1505,21 @@ class Connection { null, 'success', null, - null + null, ); this._sasl_failure_handler = this._addSysHandler( this._sasl_failure_cb.bind(this), null, 'failure', null, - null + null, ); this._sasl_challenge_handler = this._addSysHandler( this._sasl_challenge_cb.bind(this), null, 'challenge', null, - null + null, ); this._sasl_mechanism = mechanisms[i]; @@ -1590,7 +1577,7 @@ class Connection { .c('query', { xmlns: NS.AUTH }) .c('username', {}) .t(getNodeFromJid(this.jid)) - .tree() + .tree(), ); } } @@ -1689,8 +1676,8 @@ class Connection { null, 'stream:features', null, - null - ) + null, + ), ); streamfeature_handlers.push( @@ -1699,8 +1686,8 @@ class Connection { NS.STREAM, 'features', null, - null - ) + null, + ), ); // we must send an xmpp:restart now @@ -1763,7 +1750,7 @@ class Connection { .c('bind', { xmlns: NS.BIND }) .c('resource', {}) .t(resource) - .tree() + .tree(), ); } else { this.send($iq({ type: 'set', id: '_bind_auth_2' }).c('bind', { xmlns: NS.BIND }).tree()); @@ -1820,7 +1807,7 @@ class Connection { if (!this.do_session) { throw new Error( `Connection.prototype._establishSession ` + - `called but apparently ${NS.SESSION} wasn't advertised by the server` + `called but apparently ${NS.SESSION} wasn't advertised by the server`, ); } this._addSysHandler(this._onSessionResultIQ.bind(this), null, null, null, '_session_auth_2'); @@ -1930,7 +1917,7 @@ class Connection { ns: string | null, name: string | null, type: string | null, - id: string | null + id: string | null, ): Handler { const hand = new Handler(handler, ns, name, type, id, null); hand.user = false; diff --git a/src/constants.ts b/src/constants.ts index dbdcebd4..7958b302 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -27,6 +27,16 @@ export const NS = { export const PARSE_ERROR_NS = 'http://www.w3.org/1999/xhtml'; +/** + * 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. + */ +export const SHARED_WORKER_PROTOCOL_VERSION = 1; + /** * Contains allowed tags, tag attributes, and css properties. * Used in the {@link Strophe.createHtml} function to filter incoming html into the allowed XHTML-IM subset. diff --git a/src/shared-connection-worker.ts b/src/shared-connection-worker.ts index cec238b7..d9c8203d 100644 --- a/src/shared-connection-worker.ts +++ b/src/shared-connection-worker.ts @@ -1,118 +1,609 @@ -let manager: ConnectionManager | null = null; - -const Status = { - ERROR: 0, - CONNECTING: 1, - CONNFAIL: 2, - AUTHENTICATING: 3, - AUTHFAIL: 4, - CONNECTED: 5, - DISCONNECTED: 6, - DISCONNECTING: 7, - ATTACHED: 8, - REDIRECT: 9, - CONNTIMEOUT: 10, - BINDREQUIRED: 11, - ATTACHFAIL: 12, -}; - -/** Class: ConnectionManager +/** + * 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 — but only once that session is *established* (resource bound or + * resumed): a join that arrives while the handshake is still in flight is + * parked and answered when it completes, so no tab is ever told ATTACHED on + * an unauthenticated stream or handed a pre-bind JID. When the primary goes + * away (graceful `_bye`, freeze-time `_relinquish`, or missed pongs) the next + * live port is promoted on the same socket. + * + * Port liveness is worker-probed: the worker pings, pages answer from their + * message handler — which browsers do not throttle, unlike timers, which a + * hidden tab may only run once every ten minutes. Delivery is never gated on + * liveness: 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. * + * 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'; + +/** 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; + +/** The page→worker methods that may be invoked via postMessage. */ +const METHODS = ['_connect', '_attach', '_pong', '_relinquish', 'send', 'close', '_closeSocket', '_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', '_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: MessagePort[]; - jid: string | undefined; + ports: Map; + 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); + * only in 'established' are join requests answered — earlier ones wait + * in _pendingJoins. + */ + session: 'none' | 'connecting' | 'established'; + /** Ports whose join arrived while the handshake was still in flight. */ + private _pendingJoins: MessagePort[]; + private _sweepTimer: ReturnType; + private _graceTimer: ReturnType; constructor() { - this.ports = []; + this.ports = new Map(); this.socket = null; + this.session = 'none'; + this._pendingJoins = []; + this._sweepTimer = null; + this._graceTimer = null; } + /** + * Register a newly connected port. It is admitted (and given a role) when + * it first speaks. + */ addPort(port: MessagePort): void { - this.ports.push(port); - port.addEventListener('message', (e: MessageEvent) => { - const method = e.data[0]; - try { - (this as unknown as Record)[method](e.data.splice(1)); - } catch (e) { - console?.error(e); - } - }); + 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. Make + // reality visible instead of dropping the message into the void + // (a send() on a CONNECTING socket would throw and lose the frame). + this._post(port, '_onClose', 'The shared socket is gone'); + return; + } + this._cancelGrace(); + try { + (this as unknown as Record void>)[method](port, ...args); + } catch (err) { + console?.error(err); + } } - _connect(data: [string, string]): void { - this.jid = data[1]; + /** + * 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) { + this._joinShared(port); + return; + } + this.service = service; + this.jid = jid; + + // 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.socket = new WebSocket(data[0], 'xmpp'); + this.session = 'connecting'; + this.socket = new WebSocket(service, 'xmpp'); this.socket.onopen = () => this._onOpen(); this.socket.onerror = (e) => this._onError(e); this.socket.onclose = (e) => this._onClose(e); this.socket.onmessage = (message) => this._onMessage(message); } - _attach(): void { - if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { - this.ports.forEach((p) => p.postMessage(['_attachCallback', Status.ATTACHED, this.jid])); + /** + * 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._joinShared(port); } else { - this.ports.forEach((p) => p.postMessage(['_attachCallback', Status.ATTACHFAIL])); + this._post(port, '_attachCallback', Status.ATTACHFAIL); } } - send(str: string): void { - this.socket!.send(str); + /** + * 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, and release any joins parked + * on the handshake. + * @param _port + * @param jid - The server-assigned full JID. + */ + _bound(_port: MessagePort, jid: string): void { + this.jid = jid; + this._sessionEstablished(); + } + + /** + * 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); } - close(str: string): void { + /** + * 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. + * @param _port + * @param data + */ + send(_port: MessagePort, data: string): void { + this.socket.send(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.socket.send(str); + this.socket.send(data); } catch (e) { - this.ports.forEach((p) => p.postMessage(['log', 'error', e])); - this.ports.forEach((p) => p.postMessage(['log', 'error', "Couldn't send tag."])); + this._post(port, 'log', 'error', e); + this._post(port, 'log', 'error', "Couldn't send 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._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; + } + _onOpen(): void { - this.ports.forEach((p) => p.postMessage(['_onOpen'])); + this._broadcast('_onOpen'); } + /** + * @param e + */ _onClose(e: CloseEvent): void { - this.ports.forEach((p) => p.postMessage(['_onClose', e.reason])); + this.session = 'none'; + this._failPendingJoins(); + this._broadcast('_onClose', e.reason); } + /** + * Deliver an inbound frame to the tabs. + * + * 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 { - const o = { 'data': message.data }; - this.ports.forEach((p) => p.postMessage(['_onMessage', o])); + 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.ports.forEach((p) => p.postMessage(['_onError', error])); + this._broadcast('_onError', error); } - _closeSocket(): void { - if (this.socket) { - try { - this.socket.onclose = null; - this.socket.onerror = null; - this.socket.onmessage = null; - this.socket.close(); - } catch (e) { - this.ports.forEach((p) => p.postMessage(['log', 'error', e])); + /** + * 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`, + ); + 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: answering ATTACHED now would + * hand out a pre-bind JID on a stream that hasn't authenticated yet, and + * the tab could start sending stanzas into it. + * @param port + */ + private _joinShared(port: MessagePort): void { + if (this.session !== 'established') { + if (!this._pendingJoins.includes(port)) { + this._pendingJoins.push(port); } + return; } - this.socket = null; + 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); + 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 = []; + pending.filter((p) => this.ports.has(p)).forEach((p) => this._joinShared(p)); + } + + /** + * Answer parked joins with ATTACHFAIL — the session they were waiting + * for died before it was established. + */ + private _failPendingJoins(): void { + const pending = this._pendingJoins; + this._pendingJoins = []; + pending.forEach((p) => this._post(p, '_attachCallback', Status.ATTACHFAIL)); + } + + /** + * 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; + } + + /** + * 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(); + } + } + + /** + * 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)); } } -addEventListener( - 'connect', - (e: MessageEvent) => { +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; diff --git a/src/worker-websocket.ts b/src/worker-websocket.ts index e73996d8..6126a224 100644 --- a/src/worker-websocket.ts +++ b/src/worker-websocket.ts @@ -2,7 +2,7 @@ import type Connection from './connection'; import Websocket from './websocket'; import log from './log'; import Builder, { $build } from './builder'; -import { LOG_LEVELS, NS, Status } from './constants'; +import { LOG_LEVELS, NS, SHARED_WORKER_PROTOCOL_VERSION, Status } from './constants'; import { WebsocketLike } from 'types'; /** @@ -12,6 +12,7 @@ class WorkerWebsocket extends Websocket { worker: SharedWorker; _messageHandler: (m: MessageEvent) => void; socket: WebsocketLike | null; + private _lifecycleAttached: boolean; /** * Create and initialize a WorkerWebsocket object. @@ -47,7 +48,8 @@ class WorkerWebsocket extends Websocket { 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]); + this.worker.port.postMessage(['_connect', this._conn.service, this._conn.jid, SHARED_WORKER_PROTOCOL_VERSION]); + this._attachLifecycleListeners(); } /** @@ -59,7 +61,65 @@ class WorkerWebsocket extends Websocket { 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]); + 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'); + } + + /** + * 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']); + } + + /** + * 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; } /** diff --git a/tests/worker-arbitration.test.ts b/tests/worker-arbitration.test.ts new file mode 100644 index 00000000..65c91e41 --- /dev/null +++ b/tests/worker-arbitration.test.ts @@ -0,0 +1,443 @@ +import ConnectionManager, { + PING_INTERVAL, + PORT_GC_TIMEOUT, + PRIMARY_TIMEOUT, + SOCKET_GRACE, +} from '../src/shared-connection-worker'; +import { SHARED_WORKER_PROTOCOL_VERSION, Status } from '../src/constants'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const SERVICE = 'wss://example.org/xmpp-websocket'; +const JID = 'romeo@example.net/orchard'; +const VERSION = SHARED_WORKER_PROTOCOL_VERSION; + +/** + * A page-side MessagePort double: records worker→page messages and lets the + * test emit page→worker messages. + */ +class MockPort { + sent: unknown[][] = []; + private listeners: ((e: { data: unknown[] }) => void)[] = []; + + addEventListener(type: string, fn: (e: { data: unknown[] }) => void): void { + if (type === 'message') this.listeners.push(fn); + } + + start(): void {} + + postMessage(msg: unknown[]): void { + this.sent.push(msg); + } + + /** Simulate a page→worker message. */ + emit(...data: unknown[]): void { + this.listeners.forEach((fn) => fn({ data })); + } + + /** All received messages with the given method name. */ + msgs(name: string): unknown[][] { + return this.sent.filter((m) => m[0] === name); + } +} + +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + static instances: FakeWebSocket[] = []; + + url: string; + // Frames are only relayed once the socket is OPEN, which it is by the + // time the primary reports _bound (that follows _onOpen → SASL → bind). + readyState = FakeWebSocket.OPEN; + sent: string[] = []; + onopen: () => void; + onerror: (e: unknown) => void; + onclose: (e: unknown) => void; + onmessage: (m: unknown) => void; + + constructor(url: string, _protocol: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = FakeWebSocket.CLOSED; + } +} + +function makeManager() { + const manager = new ConnectionManager(); + const join = () => { + const port = new MockPort(); + manager.addPort(port as unknown as MessagePort); + return port; + }; + return { manager, join }; +} + +/** Join a port and let it drive/share the connection via _connect. */ +function connect(port: MockPort, jid = JID) { + port.emit('_connect', SERVICE, jid, VERSION); +} + +/** Report bind completion, establishing the session (see _bound). */ +function bind(port: MockPort, jid = JID) { + port.emit('_bound', jid); +} + +/** _attach with the protocol version, as the page sends it. */ +function attach(port: MockPort) { + port.emit('_attach', SERVICE, VERSION); +} + +describe('shared-connection-worker arbitration', () => { + beforeEach(() => { + vi.useFakeTimers(); + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('makes the first connecting port primary and opens the socket', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + expect(FakeWebSocket.instances.length).toBe(1); + expect(p1.msgs('_role')).toEqual([['_role', 'primary', JID]]); + }); + + it('joins a second _connect as secondary over the established session (no reconnect)', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + connect(p2); + expect(FakeWebSocket.instances.length).toBe(1); // idempotent _connect + expect(p2.msgs('_role')).toEqual([['_role', 'secondary', JID]]); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]); + // per-port addressing: the primary got no attach callback + expect(p1.msgs('_attachCallback')).toEqual([]); + }); + + it('reconnects when a different user connects', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + connect(p2, 'juliet@example.net/balcony'); + expect(FakeWebSocket.instances.length).toBe(2); + expect(p2.msgs('_role')).toEqual([['_role', 'primary', 'juliet@example.net/balcony']]); + expect(p1.msgs('_role').at(-1)).toEqual(['_role', 'secondary', 'juliet@example.net/balcony']); + }); + + it('attaches only the requesting port, and fails without a socket', () => { + const { join } = makeManager(); + const p1 = join(); + attach(p1); + expect(p1.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]); + + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]); + expect(p2.msgs('_role')).toEqual([['_role', 'secondary', JID]]); + // per-port addressing: p1 only ever saw its own (failed) attach reply + expect(p1.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]); + }); + + it('parks joins that arrive during the handshake and answers them with the bound JID', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1, 'romeo@example.net'); // pre-bind JID, no resource yet + const p2 = join(); + attach(p2); + const p3 = join(); + p3.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + // the handshake is still in flight: nobody is told ATTACHED (which + // would hand out a pre-bind JID on an unauthenticated stream) + expect(p2.msgs('_attachCallback')).toEqual([]); + expect(p3.msgs('_attachCallback')).toEqual([]); + expect(FakeWebSocket.instances.length).toBe(1); + + bind(p1, JID); + expect(p2.msgs('_role')).toEqual([['_role', 'secondary', JID]]); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]); + expect(p3.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]); + }); + + it('fails parked joins when the socket dies mid-handshake', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + const p2 = join(); + attach(p2); + FakeWebSocket.instances[0].onclose({ reason: 'boom' }); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]); + }); + + it('closes the socket when the connecting primary leaves mid-handshake', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + const p2 = join(); + attach(p2); + p1.emit('_bye'); + // nobody else can finish the handshake: the socket is killed, the + // parked join fails, and the remaining tab is told to reconnect + expect(FakeWebSocket.instances[0].readyState).toBe(FakeWebSocket.CLOSED); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]); + expect(p2.msgs('_onClose').length).toBe(1); + }); + + it('rejects a page speaking a different protocol version', () => { + const { manager, join } = makeManager(); + const p1 = join(); + p1.emit('_connect', SERVICE, JID, VERSION + 1); + expect(FakeWebSocket.instances.length).toBe(0); + expect(p1.msgs('_onClose').length).toBe(1); + expect(p1.msgs('log').some((m) => m[1] === 'fatal')).toBe(true); + expect(manager.ports.size).toBe(0); // not admitted + }); + + it('broadcasts inbound traffic to all live ports', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + FakeWebSocket.instances[0].onmessage({ data: '' }); + expect(p1.msgs('_onMessage')).toEqual([['_onMessage', { 'data': '' }]]); + expect(p2.msgs('_onMessage')).toEqual([['_onMessage', { 'data': '' }]]); + }); + + it('targets handshake frames at the primary only — a concurrently connecting tab hears nothing', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1, 'romeo@example.net'); + const p2 = join(); + connect(p2, 'romeo@example.net'); // races in mid-handshake and is parked + const socket = FakeWebSocket.instances[0]; + expect(FakeWebSocket.instances.length).toBe(1); + + socket.onmessage({ data: "" }); + socket.onmessage({ data: '' }); + // only the driving primary hears the handshake: the parked tab would + // treat these frames as its own handshake and start a second SASL + // negotiation on the shared socket + expect(p1.msgs('_onMessage').length).toBe(2); + expect(p2.msgs('_onMessage')).toEqual([]); + + bind(p1); + socket.onmessage({ data: '' }); + expect(p1.msgs('_onMessage').length).toBe(3); + expect(p2.msgs('_onMessage')).toEqual([['_onMessage', { 'data': '' }]]); + // the parked tab was told its role (which swaps its message handler) + // before it received its first raw frame + const roleIdx = p2.sent.findIndex((m) => m[0] === '_role'); + const frameIdx = p2.sent.findIndex((m) => m[0] === '_onMessage'); + expect(roleIdx).toBeGreaterThan(-1); + expect(roleIdx).toBeLessThan(frameIdx); + }); + + it('relays send from any port to the socket', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + p2.emit('send', ''); + expect(FakeWebSocket.instances[0].sent).toEqual(['']); + }); + + it('promotes a secondary when the primary says _bye — same socket', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + p1.emit('_bye'); + expect(p2.msgs('_promote')).toEqual([['_promote', JID]]); + expect(FakeWebSocket.instances.length).toBe(1); // no reconnect + // the departed port no longer receives broadcasts + FakeWebSocket.instances[0].onmessage({ data: '' }); + expect(p1.msgs('_onMessage')).toEqual([]); + expect(p2.msgs('_onMessage').length).toBe(1); + }); + + it('fails a silent primary over to a fresh port but keeps delivering to it', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + // p1 goes silent (e.g. a background-throttled tab); p2 answers pings + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(PING_INTERVAL); + p2.emit('_pong'); + } + expect(p2.msgs('_promote')).toEqual([['_promote', JID]]); + expect(p1.msgs('_role').at(-1)).toEqual(['_role', 'secondary', JID]); + // ...but the silent port still receives everything: silence means + // throttled-or-frozen, not gone, and delivery is not gated on it + FakeWebSocket.instances[0].onmessage({ data: '' }); + expect(p1.msgs('_onMessage').length).toBe(1); + expect(p2.msgs('_onMessage').length).toBe(1); + }); + + it('does not fail over when no fresher port exists', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + vi.advanceTimersByTime(PRIMARY_TIMEOUT + 2 * PING_INTERVAL); + // sole (silent) port: it keeps the primary role + expect(p1.msgs('_role').filter((m) => m[1] === 'secondary')).toEqual([]); + expect(p1.msgs('_promote')).toEqual([]); + }); + + it('fails over immediately when the primary announces a freeze (_relinquish)', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + p1.emit('_relinquish'); + expect(p1.msgs('_role').at(-1)).toEqual(['_role', 'secondary', JID]); + expect(p2.msgs('_promote')).toEqual([['_promote', JID]]); + expect(FakeWebSocket.instances.length).toBe(1); // same socket + }); + + it('keeps a sole port primary on _relinquish', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + p1.emit('_relinquish'); + expect(p1.msgs('_role').filter((m) => m[1] === 'secondary')).toEqual([]); + }); + + it('drops a port only after PORT_GC_TIMEOUT and re-admits it when it speaks', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + const socket = FakeWebSocket.instances[0]; + // p2 (a secondary) goes completely silent; p1 stays live + const sweeps = Math.ceil(PORT_GC_TIMEOUT / PING_INTERVAL) + 1; + for (let i = 0; i < sweeps; i++) { + vi.advanceTimersByTime(PING_INTERVAL); + p1.emit('_pong'); + } + socket.onmessage({ data: '' }); + expect(p2.msgs('_onMessage')).toEqual([]); // dropped for good + expect(p1.msgs('_onMessage').length).toBe(1); + + // it speaks again: re-admitted (as secondary) and receives again + p2.emit('_pong'); + expect(p2.msgs('_role').at(-1)).toEqual(['_role', 'secondary', JID]); + socket.onmessage({ data: '' }); + expect(p2.msgs('_onMessage').length).toBe(1); + }); + + it('answers messages sent into a dead socket with _onClose instead of dropping them', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + p1.emit('_closeSocket'); + p1.emit('send', ''); + expect(p1.msgs('_onClose').length).toBe(1); + }); + + it('answers a send on a still-connecting socket with _onClose instead of throwing', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + const socket = FakeWebSocket.instances[0]; + socket.readyState = FakeWebSocket.CONNECTING; // handshake not finished + p1.emit('send', ''); + // a send() on a CONNECTING socket throws and loses the frame, so the + // dispatcher intercepts it: nothing hits the wire, the tab is told + expect(socket.sent).toEqual([]); + expect(p1.msgs('_onClose').length).toBe(1); + }); + + it('tells a returning port about a dead socket instead of assigning it a role', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const socket = FakeWebSocket.instances[0]; + socket.readyState = FakeWebSocket.OPEN; + p1.emit('_bye'); + vi.advanceTimersByTime(SOCKET_GRACE + 1); + expect(socket.readyState).toBe(FakeWebSocket.CLOSED); + + // a tab returns (e.g. thawed after everything else went away): it is + // told the session is gone, not handed a role on a dead socket + const p2 = join(); + p2.emit('_pong'); + expect(p2.msgs('_onClose').length).toBe(1); + expect(p2.msgs('_role')).toEqual([]); + }); + + it('closes the socket only after a grace period once the last port leaves', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const socket = FakeWebSocket.instances[0]; + socket.readyState = FakeWebSocket.OPEN; + p1.emit('_bye'); + vi.advanceTimersByTime(SOCKET_GRACE - 1); + expect(socket.readyState).toBe(FakeWebSocket.OPEN); + vi.advanceTimersByTime(1); + expect(socket.readyState).toBe(FakeWebSocket.CLOSED); + }); + + it('a port joining during the grace period keeps the socket alive', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const socket = FakeWebSocket.instances[0]; + socket.readyState = FakeWebSocket.OPEN; + p1.emit('_bye'); + vi.advanceTimersByTime(SOCKET_GRACE - 1); + const p2 = join(); + attach(p2); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]); + expect(p2.msgs('_role')).toEqual([['_role', 'primary', JID]]); // nobody else left + vi.advanceTimersByTime(SOCKET_GRACE * 2); + expect(socket.readyState).toBe(FakeWebSocket.OPEN); + }); + + it('rejects unknown methods without touching state', () => { + const { join } = makeManager(); + const p1 = join(); + p1.emit('constructor', 'x'); + p1.emit('_promoteNext'); + expect(p1.msgs('log').length).toBe(2); + expect(FakeWebSocket.instances.length).toBe(0); + }); +}); From 3842187cf3a49cf21a4cc43efe1a64eec430e4d4 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Thu, 2 Jul 2026 21:11:16 +0200 Subject: [PATCH 3/9] feat: Integrate XEP-0198 Stream Management into Connection Wires the SM engine into the websocket connect flow via a page-side adapter. Opt in with the enableStreamManagement option (plus optional streamManagement tuning). sessionStorage is used automatically in browsers. The engine is constructed for any non-worker transport, but SM is only negotiated over websocket, decided per stream on the live transport: embedders can swap _proto after construction (e.g. via XEP-0156 discovery, or a BOSH-websocket fallback on reconnect), so a construction-time check would silently disable SM for exactly those setups. Over BOSH the engine simply stays inert. --- src/connection.ts | 193 +++++++++++++++- src/stream-management/dom.ts | 26 +++ src/stream-management/engine.ts | 37 ++- src/stream-management/index.ts | 1 + tests/stream-management-connection.test.ts | 254 +++++++++++++++++++++ 5 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 src/stream-management/dom.ts create mode 100644 tests/stream-management-connection.test.ts diff --git a/src/connection.ts b/src/connection.ts index 29dd2741..d1c4e61e 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -23,12 +23,19 @@ import { getResourceFromJid, getText, handleError, + toElement, type Cookies, } from './utils'; import { SessionError } from './errors'; import Bosh from './bosh'; import WorkerWebsocket from './worker-websocket'; import Websocket from './websocket'; +import StreamManagement, { + SessionStorageBackend, + isCountableStanza, + toStanzaView, + type StreamManagementOptions, +} from './stream-management'; export interface ConnectionOptions { /** @@ -85,6 +92,20 @@ export interface ConnectionOptions { * session, before binding the JID resource for this session. */ explicitResourceBinding?: boolean; + /** + * If `enableStreamManagement` is set to `true`, Strophe negotiates + * XEP-0198 Stream Management on websocket connections. + * + * Defaults to `false`. Not available over BOSH, nor (yet) in + * combination with the `worker` option. + */ + enableStreamManagement?: boolean; + /** + * Fine-tuning options for XEP-0198 Stream Management — see + * {@link StreamManagementOptions}. Only relevant together with + * {@link ConnectionOptions.enableStreamManagement}. + */ + streamManagement?: StreamManagementOptions; /** * _Note: This option is only relevant to Websocket connections, and not BOSH_ * @@ -223,6 +244,7 @@ const connectionPlugins: Record = {}; * * @memberof Strophe */ + class Connection { service: string; options: ConnectionOptions; @@ -270,6 +292,13 @@ class Connection { _proto: Bosh | Websocket | WorkerWebsocket; _sasl_mechanism: SASLMechanism | null; _requests: Request[]; + /** + * The XEP-0198 Stream Management engine. Only present when the + * `enableStreamManagement` option is set on a non-worker websocket + * connection. + */ + sm?: StreamManagement; + _smHandlers: Handler[]; /** * Create and initialize a {@link Connection} object. @@ -295,6 +324,25 @@ class Connection { this.setProtocol(); + this._smHandlers = []; + if (options.enableStreamManagement && !options.worker) { + // The engine is constructed regardless of the current + // transport since embedders may swap `_proto` after construction. + const smOptions = { ...(options.streamManagement || {}) }; + if (!smOptions.storage && typeof sessionStorage !== 'undefined') { + smOptions.storage = new SessionStorageBackend(); + } + + // The SM engine emits nonzas (and re-sends queued stanzas) as + // strings. They are pushed directly onto the send queue and + // they ride the same FIFO, so an goes out after the + // stanzas it covers. + this.sm = new StreamManagement((data: string) => { + this._data.push(toElement(data)); + this._proto._send(); + }, smOptions); + } + /* The connected JID. */ this.jid = ''; /* the JIDs domain */ @@ -421,6 +469,9 @@ class Connection { */ reset(): void { this._proto._reset(); + // In-memory SM state only. Persisted (resumable) state is kept and + // reloaded when the next stream advertises SM support. + this.sm?.reset(); // SASL this.do_session = false; @@ -443,6 +494,24 @@ class Connection { this._uniqueId = 0; } + /** + * @returns true if the current session was established by resuming a + * previous one via XEP-0198 Stream Management (in which case the + * previously bound resource is still valid and roster/presence + * state was retained by the server). + */ + hasResumed(): boolean { + return !!this.sm?.resumed; + } + + /** + * @returns true if a XEP-0198 Stream Management session is currently + * active (i.e. was received or a session was resumed). + */ + isStreamManagementEnabled(): boolean { + return !!this.sm?.enabled; + } + /** * Pause the request manager. * @@ -602,6 +671,9 @@ class Connection { this.authenticated = false; this.restored = false; this.disconnection_timeout = disconnection_timeout; + // Per-stream SM state starts fresh; resumable state is reloaded from + // storage once the server advertises SM (_onStreamFeaturesAfterSASL). + this.sm?.reset(); // parse jid for domain this.domain = getDomainFromJid(this.jid); @@ -963,6 +1035,13 @@ class Connection { throw error; } this._data.push(element); + // XEP-0198: every countable outbound stanza is queued here, after the + // push, so that an emitted by the engine lands behind it in the + // send FIFO. Hooking _queueData (rather than send/sendIQ/sendPresence) + // means raw send() calls can't escape the counting. + if (this.sm?.state.enableSent && isCountableStanza(element.tagName)) { + this.sm.trackOutbound(toStanzaView(element)); + } } /** @@ -1173,6 +1252,10 @@ class Connection { 'type': 'unavailable', }).tree(); } + // A cleanly closed stream is not resumable: send a final so + // the server doesn't redeliver stanzas we already received, and + // drop the persisted XEP-0198 state. + this.sm?.onGracefulClose(); // setup timeout handler this._disconnectTimeout = this._addSysTimedHandler( this.disconnection_timeout, @@ -1332,6 +1415,9 @@ class Connection { // send each incoming stanza through the handler chain forEachChild(elem, null, (child: Element) => { + // XEP-0198: count inbound stanzas here in the dispatch loop — + // exactly-once, in order, and immune to handler churn. + this.sm?.onInboundStanza(child.nodeName); const matches: Handler[] = []; this.handlers = this.handlers.reduce((handlers, handler) => { try { @@ -1347,7 +1433,6 @@ class Connection { // if the handler throws an exception, we consider it as false log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message); } - return handlers; }, []); @@ -1711,6 +1796,22 @@ class Connection { if (child.nodeName === 'session') { this.do_session = true; } + if (this.sm && child.nodeName === 'sm' && (child as Element).namespaceURI === NS.SM) { + this.sm.serverSupported = true; + } + } + + // SM is only negotiated over websocket. Checked here on the + // live transport because the transport can be swapped after + // construction (e.g. XEP-0156 discovery). + if (this.sm?.serverSupported && this._proto instanceof Websocket) { + this.sm.initialize(this.jid); + if (this.sm.hasResumableState()) { + // Attempt XEP-0198 stream resumption instead of binding a resource. + this._registerSMHandlers(); + this.sm.sendResume(); + return false; + } } if (!this.do_bind) { @@ -1724,6 +1825,94 @@ class Connection { return false; } + /** + * Register the XEP-0198 nonza handlers (idempotently) + * They are system handlers, so they run before authentication completes, + * which the resume flow requires. + */ + _registerSMHandlers(): void { + this._smHandlers.forEach((h) => this.deleteHandler(h)); + const delegate = (el: Element): boolean => { + this.sm.onInbound(toStanzaView(el)); + return true; + }; + this._smHandlers = [ + this._addSysHandler(delegate, NS.SM, 'r', null, null), + this._addSysHandler(delegate, NS.SM, 'a', null, null), + this._addSysHandler(delegate, NS.SM, 'enabled', null, null), + this._addSysHandler((el) => this._onStreamResumed(el), NS.SM, 'resumed', null, null), + this._addSysHandler((el) => this._onStreamResumptionFailed(el), NS.SM, 'failed', null, null), + ]; + } + + /** + * _Private_ handler for a successful XEP-0198 stream resumption. + * + * The engine reconciles the server's 'h' and re-sends whatever the + * server didn't acknowledge. + * + * The connection state is restored, `this.jid` is set back to the JID that + * was bound when the SM session was established, *before* CONNECTED is emitted, + * so no stanza can ever be stamped with a resource the server doesn't know. + * @param elem - The nonza. + * @return `true` to keep the handler. + */ + _onStreamResumed(elem: Element): boolean { + this.sm.onInbound(toStanzaView(elem)); + this.do_bind = false; + this.authenticated = true; + this.restored = true; + this.jid = this.sm.boundJid; + this._changeConnectStatus(Status.CONNECTED, null); + return true; + } + + /** + * _Private_ handler for a failed or . + * + * The engine resets its state; after a failed *resumption* it also + * salvages the unacked queue (re-sent once a fresh session reaches + * ), and the connection falls back to binding a resource on + * this same stream, per XEP-0198 ("the server SHOULD allow the client + * to bind a resource at this point"). A refused needs neither: + * the stream is alive and bound, it just runs without SM. + * @param elem - The nonza. + * @return `true` to keep the handler. + */ + _onStreamResumptionFailed(elem: Element): boolean { + // Read before feeding the nonza to the engine, which clears the flag. + const resuming = this.sm.resumePending; + this.sm.onInbound(toStanzaView(elem)); + if (resuming) { + this.do_bind = true; + if (this.options.explicitResourceBinding) { + this._changeConnectStatus(Status.BINDREQUIRED, null); + } else { + this.bind(); + } + } + return true; + } + + /** + * Start a fresh XEP-0198 session if the server supports it and no + * session was resumed. Called at the CONNECTED-emission points of the + * connect flow (after resource binding, or after legacy session + * establishment when the server requires it). The XEP allows + * any time after binding. + */ + _maybeEnableStreamManagement(): void { + if ( + this.sm?.serverSupported && + !this.sm.resumed && + !this.sm.state.enableSent && + this._proto instanceof Websocket + ) { + this._registerSMHandlers(); + this.sm.sendEnable(this.jid); + } + } + /** * Sends an IQ to the XMPP server to bind a JID resource for this session. * @@ -1784,6 +1973,7 @@ class Connection { if (this.do_session) { this._establishSession(); } else { + this._maybeEnableStreamManagement(); this._changeConnectStatus(Status.CONNECTED, null); } } @@ -1833,6 +2023,7 @@ class Connection { _onSessionResultIQ(elem: Element): boolean { if (elem.getAttribute('type') === 'result') { this.authenticated = true; + this._maybeEnableStreamManagement(); this._changeConnectStatus(Status.CONNECTED, null); } else if (elem.getAttribute('type') === 'error') { this.authenticated = false; diff --git a/src/stream-management/dom.ts b/src/stream-management/dom.ts new file mode 100644 index 00000000..47d8682a --- /dev/null +++ b/src/stream-management/dom.ts @@ -0,0 +1,26 @@ +/** + * XEP-0198 Stream Management + * + * The page-side {@link StanzaView} adapter. + * + * The page counterpart of parse.ts: where the worker builds views from raw + * frame strings, the page builds them from the DOM Elements it already has. + * Page-only: XMLSerializer is a DOM global, so this module must never be + * imported by the shared-connection-worker bundle. Keeping it in its own + * file makes that boundary structural instead of comment-enforced. + */ +import { StanzaView } from './types'; + +/** + * Build the environment-free {@link StanzaView} consumed by the XEP-0198 + * engine from a DOM Element. + * @param el + */ +export function toStanzaView(el: Element): StanzaView { + const attrs: Record = {}; + for (let i = 0; i < el.attributes.length; i++) { + const attr = el.attributes[i]; + attrs[attr.name] = attr.value; + } + return { name: el.tagName, attrs, serialized: new XMLSerializer().serializeToString(el) }; +} diff --git a/src/stream-management/engine.ts b/src/stream-management/engine.ts index a88085eb..bc9f2ebe 100644 --- a/src/stream-management/engine.ts +++ b/src/stream-management/engine.ts @@ -212,10 +212,7 @@ class StreamManagement { */ onInbound(view: StanzaView): boolean { if (isCountableStanza(view.name)) { - if (this._state.enabled) { - this._state.hIn = (this._state.hIn + 1) % H_WRAP; - this._persist(); - } + this.onInboundStanza(view.name); return false; } switch (view.name) { @@ -261,6 +258,38 @@ class StreamManagement { this._sendRaw(``); } + /** + * 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: string): void { + 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 + * 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(): void { + 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 . */ onEnabled(): void { return; diff --git a/src/stream-management/index.ts b/src/stream-management/index.ts index 8688f8c3..e8159f21 100644 --- a/src/stream-management/index.ts +++ b/src/stream-management/index.ts @@ -9,4 +9,5 @@ export { default } from './engine'; export { MemoryStorageBackend, SessionStorageBackend } from './storage'; export { isCountableStanza, stampDelay } from './utils'; +export { toStanzaView } from './dom'; export type { StanzaView, QueuedStanza, SMState, SMStorageBackend, StreamManagementOptions } from './types'; diff --git a/tests/stream-management-connection.test.ts b/tests/stream-management-connection.test.ts new file mode 100644 index 00000000..84090e00 --- /dev/null +++ b/tests/stream-management-connection.test.ts @@ -0,0 +1,254 @@ +import { Strophe, $msg, MemoryStorageBackend } from '../dist/strophe.node.esm.js'; +import { describe, it, expect } from 'vitest'; + +const SM_NS = 'urn:xmpp:sm:3'; +const BIND_NS = 'urn:ietf:params:xml:ns:xmpp-bind'; +const { Status } = Strophe; + +function parse(xml: string): Element { + return new DOMParser().parseFromString(xml, 'text/xml').documentElement; +} + +/** + * Nonzas on the wire are re-serialized from parsed Elements, so attribute + * order isn't stable — match them semantically instead of by string. + */ +function isNonza(s: string | undefined, name: string, attrs: Record = {}): boolean { + if (!s || !s.startsWith(`<${name}`)) return false; + const el = parse(s); + return ( + el.tagName === name && + el.getAttribute('xmlns') === SM_NS && + Object.entries(attrs).every(([k, v]) => el.getAttribute(k) === v) + ); +} + +/** + * A websocket connection with a stubbed socket that records the wire. + */ +function makeConn(options: Record = {}) { + const wire: string[] = []; + const statuses: number[] = []; + const conn = new Strophe.Connection('wss://example.org/xmpp-websocket', { + enableStreamManagement: true, + ...options, + }); + (conn._proto as any).socket = { send: (s: string) => wire.push(s), readyState: 1 }; + conn.connected = true; + conn.connect_callback = (status: number) => { + statuses.push(status); + }; + conn.jid = 'romeo@example.net'; + return { conn, wire, statuses }; +} + +/** Feed an inbound frame through the connection's dispatch. */ +function recv(conn: InstanceType, xml: string) { + conn._dataRecv(parse(`${xml}`)); +} + +/** Simulate the post-SASL stream features. */ +function sendFeatures(conn: InstanceType, extra = ``) { + (conn as any)._onStreamFeaturesAfterSASL(parse(`${extra}`)); +} + +/** Answer the bind IQ with a server-assigned full JID. */ +function bindResult(conn: InstanceType, jid = 'romeo@example.net/orchard') { + recv(conn, `${jid}`); +} + +/** Drive a connection through bind + enable + enabled. */ +function establish(conn: InstanceType, id = 'sm-id-1') { + sendFeatures(conn); + bindResult(conn); + recv(conn, ``); +} + +describe('Stream Management connection integration', () => { + it('creates the engine when enabled, but only negotiates over websocket', () => { + expect(makeConn().conn.sm).toBeDefined(); + expect(new Strophe.Connection('wss://example.org/ws').sm).toBeUndefined(); + + // The transport can be swapped after construction (e.g. XEP-0156 + // discovery), so the engine exists for a BOSH-constructed connection + // too — but the post-SASL features must not start SM negotiation on + // a BOSH stream. + const bosh = new Strophe.Connection('http://fake', { enableStreamManagement: true }); + expect(bosh.sm).toBeDefined(); + const sent: string[] = []; + bosh.rawOutput = (data: string) => sent.push(data); + (bosh as any)._onStreamFeaturesAfterSASL( + new DOMParser().parseFromString( + ``, + 'text/xml', + ).documentElement, + ); + expect(bosh.sm.serverSupported).toBe(true); + (bosh as any)._maybeEnableStreamManagement(); + expect(bosh.sm.state.enableSent).toBe(false); // no sent over BOSH + }); + + it('negotiates a fresh SM session at the CONNECTED-emission point', () => { + const { conn, wire, statuses } = makeConn(); + sendFeatures(conn); + expect(conn.sm.serverSupported).toBe(true); + // no resumable state → the normal bind flow ran + expect(wire.some((s) => s.includes(BIND_NS))).toBe(true); + bindResult(conn); + expect(conn.jid).toBe('romeo@example.net/orchard'); + expect(isNonza(wire.at(-1), 'enable', { resume: 'true' })).toBe(true); + expect(statuses).toContain(Status.CONNECTED); + expect(conn.sm.boundJid).toBe('romeo@example.net/orchard'); + + // outbound tracking is active from enable-send (before ) + conn.send($msg({ to: 'juliet@example.net' }).c('body').t('early')); + expect(conn.sm.state.unacked.length).toBe(1); + // ...but inbound counting only starts at receipt + recv(conn, 'too early'); + expect(conn.sm.state.hIn).toBe(0); + + recv(conn, ``); + expect(conn.isStreamManagementEnabled()).toBe(true); + expect(conn.hasResumed()).toBe(false); + expect(conn.sm.state.id).toBe('sm-id-1'); + }); + + it('does not negotiate SM when the server omits the feature', () => { + const { conn, wire } = makeConn(); + sendFeatures(conn, ''); + bindResult(conn); + expect(conn.sm.serverSupported).toBe(false); + expect(wire.some((s) => s.includes('', () => { + const { conn, wire } = makeConn(); + establish(conn); + recv(conn, '1'); + recv(conn, ''); + recv(conn, ``); + expect(isNonza(wire.at(-1), 'a', { h: '2' })).toBe(true); + }); + + it('tracks all outbound stanzas via _queueData and requests acks in FIFO order', () => { + const { conn, wire } = makeConn({ streamManagement: { maxUnacked: 3 } }); + establish(conn); + const before = wire.length; + for (let i = 1; i <= 3; i++) { + conn.send($msg({ to: 'juliet@example.net' }).c('body').t(`m${i}`)); + } + const sent = wire.slice(before); + expect(conn.sm.state.unacked.length).toBe(3); + // the went out *after* the stanza that triggered it + expect(sent.findIndex((s) => isNonza(s, 'r'))).toBe(sent.findIndex((s) => s.includes('m3')) + 1); + recv(conn, ``); + expect(conn.sm.state.unacked.length).toBe(0); + }); + + it('resumes a dropped session and restores the bound JID (invalid-from regression)', () => { + const storage = new MemoryStorageBackend(); + const first = makeConn({ streamManagement: { storage } }); + establish(first.conn); + recv(first.conn, '1'); + recv(first.conn, '2'); + first.conn.send($msg({ to: 'juliet@example.net' }).c('body').t('lost')); + // the socket dies uncleanly here — resumable state is persisted + + const second = makeConn({ streamManagement: { storage } }); + // simulate what a naive reconnect does: a freshly generated resource + second.conn.jid = 'romeo@example.net/fresh-resource'; + sendFeatures(second.conn); + // resumption is attempted instead of binding + expect(second.wire.length).toBe(1); + expect(isNonza(second.wire[0], 'resume', { h: '2', previd: 'sm-id-1' })).toBe(true); + expect(second.statuses).not.toContain(Status.BINDREQUIRED); + + recv(second.conn, ``); + // the resumed session's bound JID is re-applied before CONNECTED: + expect(second.conn.jid).toBe('romeo@example.net/orchard'); + expect(second.conn.hasResumed()).toBe(true); + expect(second.conn.do_bind).toBe(false); + expect(second.conn.authenticated).toBe(true); + expect(second.conn.restored).toBe(true); + expect(second.statuses).toContain(Status.CONNECTED); + // the unacknowledged stanza was re-sent, followed by an ack request + expect(second.wire.filter((s) => s.includes('lost')).length).toBe(1); + expect(isNonza(second.wire.at(-1), 'r')).toBe(true); + expect(second.conn.sm.state.hIn).toBe(2); // counters carried over + }); + + it('falls back to binding when resumption fails and salvages the queue', () => { + const storage = new MemoryStorageBackend(); + const first = makeConn({ streamManagement: { storage } }); + establish(first.conn); + first.conn.send($msg({ to: 'juliet@example.net' }).c('body').t('acked')); + first.conn.send($msg({ to: 'juliet@example.net' }).c('body').t('salvaged')); + + const second = makeConn({ streamManagement: { storage }, explicitResourceBinding: true }); + sendFeatures(second.conn); + expect(isNonza(second.wire.at(-1), 'resume', { h: '0', previd: 'sm-id-1' })).toBe(true); + recv( + second.conn, + `` + ); + // per the XEP the client may bind on this same stream + expect(second.statuses).toContain(Status.BINDREQUIRED); + expect(second.conn.do_bind).toBe(true); + expect(storage.load('strophe-sm:romeo@example.net')).toBeNull(); + + second.conn.bind(); + bindResult(second.conn, 'romeo@example.net/newresource'); + expect(isNonza(second.wire.at(-1), 'enable', { resume: 'true' })).toBe(true); + recv(second.conn, ``); + // acked the first message; the second was salvaged, + // re-sent delay-stamped on the new session and queued again + expect(second.wire.some((s) => s.includes('acked'))).toBe(false); + const resent = second.wire.find((s) => s.includes('salvaged')); + expect(resent).toContain('urn:xmpp:delay'); + expect(resent).toContain('stamp="'); + expect(isNonza(second.wire.at(-1), 'r')).toBe(true); + expect(second.conn.sm.state.unacked.length).toBe(1); + expect(second.conn.hasResumed()).toBe(false); + }); + + it('runs on without rebinding when the server refuses a fresh ', () => { + const { conn, wire, statuses } = makeConn(); + sendFeatures(conn); + bindResult(conn); + expect(isNonza(wire.at(-1), 'enable', { resume: 'true' })).toBe(true); + expect(statuses).toContain(Status.CONNECTED); + const statusesBefore = [...statuses]; + conn.send($msg({ to: 'juliet@example.net' }).c('body').t('delivered')); + expect(conn.sm.state.unacked.length).toBe(1); + + // is refused (not a ): the stream is already bound, + // so SM just goes inactive — no fallback bind, no BINDREQUIRED. + recv(conn, ``); + expect(statuses).toEqual(statusesBefore); + expect(conn.isStreamManagementEnabled()).toBe(false); + + // the already-delivered stanza is not salvaged onto a later session + conn.sm.serverSupported = true; + conn.sm.sendEnable('romeo@example.net/orchard'); + const before = wire.length; + recv(conn, ``); + expect(wire.slice(before).some((s) => s.includes('delivered'))).toBe(false); + }); + + it('sends a final and clears persisted state on graceful disconnect', () => { + const storage = new MemoryStorageBackend(); + const { conn, wire } = makeConn({ streamManagement: { storage } }); + establish(conn); + recv(conn, '1'); + expect(storage.load('strophe-sm:romeo@example.net')).not.toBeNull(); + + conn.disconnect('done'); + const ackIdx = wire.findIndex((s) => isNonza(s, 'a', { h: '1' })); + const closeIdx = wire.findIndex((s) => s.includes('urn:ietf:params:xml:ns:xmpp-framing')); + expect(ackIdx).toBeGreaterThan(-1); + expect(closeIdx).toBeGreaterThan(ackIdx); + expect(storage.load('strophe-sm:romeo@example.net')).toBeNull(); + // the teardown presence was not tracked into a (now dead) queue + expect(conn.sm.state.unacked.length).toBe(0); + }); +}); From e97c5a3e9266388ac84dbe6fa8666bc2b7870de0 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 3 Jul 2026 11:18:47 +0200 Subject: [PATCH 4/9] feat: Host the XEP-0198 engine inside the shared connection worker The worker is the only party that sees every tab's traffic (one socket, all sends relayed through it), so it now owns the SM session: counting, the unacked queue, -> replies (no page round-trip), reconciliation and resend-on-resume. A stanza sent from a secondary tab survives resumption, and failover to another tab needs no reconnect. --- src/connection.ts | 145 +++++--- src/index.ts | 12 +- src/shared-connection-worker.ts | 238 ++++++++++-- src/stream-management/engine.ts | 70 ++-- src/stream-management/index.ts | 26 +- src/stream-management/mirror.ts | 134 +++++++ src/stream-management/parse.ts | 55 +++ src/stream-management/types.ts | 56 ++- src/worker-websocket.ts | 74 ++++ tests/sm-parse.test.ts | 58 +++ tests/stream-management-connection.test.ts | 4 +- tests/stream-management.test.ts | 2 +- tests/worker-handshake.test.ts | 175 +++++++++ tests/worker-sm.test.ts | 413 +++++++++++++++++++++ vitest.config.ts | 9 + 15 files changed, 1339 insertions(+), 132 deletions(-) create mode 100644 src/stream-management/mirror.ts create mode 100644 src/stream-management/parse.ts create mode 100644 tests/sm-parse.test.ts create mode 100644 tests/worker-handshake.test.ts create mode 100644 tests/worker-sm.test.ts diff --git a/src/connection.ts b/src/connection.ts index d1c4e61e..b2546547 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -32,8 +32,10 @@ import WorkerWebsocket from './worker-websocket'; import Websocket from './websocket'; import StreamManagement, { SessionStorageBackend, + StreamManagementMirror, isCountableStanza, toStanzaView, + type StreamManagementController, type StreamManagementOptions, } from './stream-management'; @@ -96,8 +98,9 @@ export interface ConnectionOptions { * If `enableStreamManagement` is set to `true`, Strophe negotiates * XEP-0198 Stream Management on websocket connections. * - * Defaults to `false`. Not available over BOSH, nor (yet) in - * combination with the `worker` option. + * Defaults to `false`. Not available over BOSH. In combination with the + * `worker` option, the SM session lives inside the shared worker (which + * sees every tab's traffic) and the connection only mirrors its state. */ enableStreamManagement?: boolean; /** @@ -294,10 +297,14 @@ class Connection { _requests: Request[]; /** * The XEP-0198 Stream Management engine. Only present when the - * `enableStreamManagement` option is set on a non-worker websocket - * connection. - */ - sm?: StreamManagement; + * `enableStreamManagement` option is set on a websocket connection. + * Under the `worker` option this is a {@link StreamManagementMirror}: + * the engine itself lives inside the shared worker and the mirror only + * reflects the session state. Typed against the shared + * {@link StreamManagementController} interface so the two are + * interchangeable here. + */ + sm?: StreamManagementController; _smHandlers: Handler[]; /** @@ -325,22 +332,30 @@ class Connection { this.setProtocol(); this._smHandlers = []; - if (options.enableStreamManagement && !options.worker) { - // The engine is constructed regardless of the current - // transport since embedders may swap `_proto` after construction. - const smOptions = { ...(options.streamManagement || {}) }; - if (!smOptions.storage && typeof sessionStorage !== 'undefined') { - smOptions.storage = new SessionStorageBackend(); - } + if (options.enableStreamManagement) { + if (options.worker) { + // Under a shared worker the page hosts no SM engine since + // the worker owns all counting and queueing. The mirror + // only reflects the session state so that hasResumed() + // and friends keep working in every tab. + this.sm = new StreamManagementMirror(); + } else { + // The engine is constructed regardless of the current + // transport since embedders may swap `_proto` after construction. + const smOptions = { ...(options.streamManagement || {}) }; + if (!smOptions.storage && typeof sessionStorage !== 'undefined') { + smOptions.storage = new SessionStorageBackend(); + } - // The SM engine emits nonzas (and re-sends queued stanzas) as - // strings. They are pushed directly onto the send queue and - // they ride the same FIFO, so an goes out after the - // stanzas it covers. - this.sm = new StreamManagement((data: string) => { - this._data.push(toElement(data)); - this._proto._send(); - }, smOptions); + // The SM engine emits nonzas (and re-sends queued stanzas) as + // strings. They are pushed directly onto the send queue and + // they ride the same FIFO, so an goes out after the + // stanzas it covers. + this.sm = new StreamManagement((data: string) => { + this._data.push(toElement(data)); + this._proto._send(); + }, smOptions); + } } /* The connected JID. */ @@ -1039,7 +1054,7 @@ class Connection { // push, so that an emitted by the engine lands behind it in the // send FIFO. Hooking _queueData (rather than send/sendIQ/sendPresence) // means raw send() calls can't escape the counting. - if (this.sm?.state.enableSent && isCountableStanza(element.tagName)) { + if (this.sm?.isTracking() && isCountableStanza(element.tagName)) { this.sm.trackOutbound(toStanzaView(element)); } } @@ -1801,28 +1816,47 @@ class Connection { } } - // SM is only negotiated over websocket. Checked here on the - // live transport because the transport can be swapped after - // construction (e.g. XEP-0156 discovery). - if (this.sm?.serverSupported && this._proto instanceof Websocket) { - this.sm.initialize(this.jid); - if (this.sm.hasResumableState()) { - // Attempt XEP-0198 stream resumption instead of binding a resource. - this._registerSMHandlers(); - this.sm.sendResume(); + if (this.sm?.serverSupported) { + if (this.options.worker) { + // Only the worker knows whether resumable state exists: it + // either sends itself (answering _smResumed or + // _smFailed) or replies _smNoState, upon which the + // connection proceeds to bind (see WorkerWebsocket). + (this._proto as WorkerWebsocket)._smFeatures(); return false; } + + // SM is only negotiated over websocket. Checked here on the + // live transport because the transport can be swapped after + // construction (e.g. XEP-0156 discovery). + if (this._proto instanceof Websocket) { + this.sm.initialize(this.jid); + if (this.sm.hasResumableState()) { + // Attempt XEP-0198 stream resumption instead of binding a resource. + this._registerSMHandlers(); + this.sm.sendResume(); + return false; + } + } } + this._proceedToBind(); + return false; + } + + /** + * Continue the connect flow with resource binding, once it is clear no + * XEP-0198 resumption will happen (no SM support, no resumable state, a + * failed , or the shared worker reporting _smNoState). + */ + _proceedToBind(): void { if (!this.do_bind) { this._changeConnectStatus(Status.AUTHFAIL, null); - return false; } else if (!this.options.explicitResourceBinding) { this.bind(); } else { this._changeConnectStatus(Status.BINDREQUIRED, null); } - return false; } /** @@ -1885,29 +1919,35 @@ class Connection { this.sm.onInbound(toStanzaView(elem)); if (resuming) { this.do_bind = true; - if (this.options.explicitResourceBinding) { - this._changeConnectStatus(Status.BINDREQUIRED, null); - } else { - this.bind(); - } + this._proceedToBind(); } return true; } /** - * Start a fresh XEP-0198 session if the server supports it and no - * session was resumed. Called at the CONNECTED-emission points of the - * connect flow (after resource binding, or after legacy session - * establishment when the server requires it). The XEP allows - * any time after binding. + * Called at the CONNECTED-emission points of the connect flow, just + * before CONNECTED is emitted i.e. once the full JID is final (resource + * bound, legacy session established, or legacy auth completed). + * + * Under the `worker` option the bound JID is always reported to the + * shared worker — with or without Stream Management — so it can hand the + * right JID to attaching tabs, release joins parked on the handshake, + * and (when this stream's features advertised SM) start a fresh SM + * session itself (it answers with _smEnabled, which updates the mirror). + * + * Otherwise, if the server supports XEP-0198 and nothing was resumed, + * a fresh SM session is started from here. The XEP allows any + * time after binding. */ - _maybeEnableStreamManagement(): void { - if ( - this.sm?.serverSupported && - !this.sm.resumed && - !this.sm.state.enableSent && - this._proto instanceof Websocket - ) { + _onSessionReady(): void { + if (this.options.worker) { + (this._proto as WorkerWebsocket)._bound(this.jid); + return; + } + if (!this.sm?.serverSupported || this.sm.resumed || !(this._proto instanceof Websocket)) { + return; + } + if (!this.sm.isTracking()) { this._registerSMHandlers(); this.sm.sendEnable(this.jid); } @@ -1973,7 +2013,7 @@ class Connection { if (this.do_session) { this._establishSession(); } else { - this._maybeEnableStreamManagement(); + this._onSessionReady(); this._changeConnectStatus(Status.CONNECTED, null); } } @@ -2023,7 +2063,7 @@ class Connection { _onSessionResultIQ(elem: Element): boolean { if (elem.getAttribute('type') === 'result') { this.authenticated = true; - this._maybeEnableStreamManagement(); + this._onSessionReady(); this._changeConnectStatus(Status.CONNECTED, null); } else if (elem.getAttribute('type') === 'error') { this.authenticated = false; @@ -2067,6 +2107,7 @@ class Connection { _auth2_cb(elem: Element): boolean { if (elem.getAttribute('type') === 'result') { this.authenticated = true; + this._onSessionReady(); this._changeConnectStatus(Status.CONNECTED, null); } else if (elem.getAttribute('type') === 'error') { this._changeConnectStatus(Status.AUTHFAIL, null, elem); diff --git a/src/index.ts b/src/index.ts index f6aab9b6..08e1f05c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import SASLSHA256 from './sasl-sha256'; import SASLSHA384 from './sasl-sha384'; import SASLSHA512 from './sasl-sha512'; import SASLXOAuth2 from './sasl-xoauth2'; -import StreamManagement, { MemoryStorageBackend, SessionStorageBackend } from './stream-management'; +import StreamManagement, { MemoryStorageBackend, SessionStorageBackend, StreamManagementMirror } from './stream-management'; import TimedHandler from './timed-handler'; import Websocket from './websocket'; import WorkerWebsocket from './worker-websocket'; @@ -159,7 +159,15 @@ export { toStanza, Request, StreamManagement, + StreamManagementMirror, MemoryStorageBackend, SessionStorageBackend, }; -export type { StanzaView, QueuedStanza, SMState, SMStorageBackend, StreamManagementOptions } from './stream-management'; +export type { + StanzaView, + QueuedStanza, + SMState, + SMStorageBackend, + StreamManagementController, + StreamManagementOptions, +} from './stream-management'; diff --git a/src/shared-connection-worker.ts b/src/shared-connection-worker.ts index d9c8203d..f9666c56 100644 --- a/src/shared-connection-worker.ts +++ b/src/shared-connection-worker.ts @@ -5,24 +5,19 @@ * 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 — but only once that session is *established* (resource bound or - * resumed): a join that arrives while the handshake is still in flight is - * parked and answered when it completes, so no tab is ever told ATTACHED on - * an unauthenticated stream or handed a pre-bind JID. When the primary goes - * away (graceful `_bye`, freeze-time `_relinquish`, or missed pongs) the next - * live port is promoted on the same socket. + * session. * - * Port liveness is worker-probed: the worker pings, pages answer from their - * message handler — which browsers do not throttle, unlike timers, which a - * hidden tab may only run once every ten minutes. Delivery is never gated on - * liveness: 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. + * 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; @@ -47,14 +42,24 @@ export const PORT_GC_TIMEOUT = 30 * 60_000; export const SOCKET_GRACE = 10_000; /** The page→worker methods that may be invoked via postMessage. */ -const METHODS = ['_connect', '_attach', '_pong', '_relinquish', 'send', 'close', '_closeSocket', '_bound']; +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', '_bound']; +const SESSION_METHODS = ['send', 'close', '_smFeatures', '_bound']; type Role = 'primary' | 'secondary'; @@ -74,11 +79,20 @@ class ConnectionManager { socket: WebSocket | null; /** * Where the shared stream is in its lifecycle. 'connecting' spans from - * socket creation until the primary reports the bound JID (_bound); - * only in 'established' are join requests answered — earlier ones wait - * in _pendingJoins. + * 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. */ private _pendingJoins: MessagePort[]; private _sweepTimer: ReturnType; @@ -88,6 +102,7 @@ class ConnectionManager { this.ports = new Map(); this.socket = null; this.session = 'none'; + this.sm = null; this._pendingJoins = []; this._sweepTimer = null; this._graceTimer = null; @@ -142,11 +157,15 @@ class ConnectionManager { } } 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. Make - // reality visible instead of dropping the message into the void - // (a send() on a CONNECTING socket would throw and lose the frame). + // 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(args[0] as string); + } this._post(port, '_onClose', 'The shared socket is gone'); return; } @@ -175,6 +194,11 @@ class ConnectionManager { 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') { @@ -187,7 +211,10 @@ class ConnectionManager { this._closeSocket(); this.session = 'connecting'; this.socket = new WebSocket(service, 'xmpp'); - this.socket.onopen = () => this._onOpen(); + // _onOpen goes only to the connecting (primary) port: every tab's + // Websocket layer reacts to it by sending a stream-, 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); @@ -210,20 +237,77 @@ class ConnectionManager { } } + /** + * 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 + * 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, and release any joins parked - * on the handshake. + * 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 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; + // The resumed session is established: release parked joins + // (with the bound JID) before the mirrors hear about it. + this._sessionEstablished(); + this._broadcast('_smResumed', sm.boundJid); + }; + // Only a failed *resumption* concerns the tabs (the primary must + // fall back to binding); a refused 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 @@ -271,12 +355,35 @@ class ConnectionManager { } /** - * Relay an outbound frame to the socket. + * Relay an outbound frame to the socket, feeding it to the XEP-0198 + * engine too. * @param _port * @param data */ send(_port: MessagePort, data: string): void { + if (!this.sm) { + this.socket.send(data); + return; + } + const view = peekElement(data); + if (view?.name === 'close') { + // A stream-closing triggers the final ack + state clearing, + // and the engine writes that final 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 (view) { + // A countable stanza is tracked *after* it is written, because + // trackOutbound() may emit an which should ride behind the + // stanza it covers. + this.sm.trackOutbound(view); + } else { + this._warnUnpeekable('outbound', data); + } } /** @@ -287,6 +394,7 @@ class ConnectionManager { 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); @@ -318,10 +426,6 @@ class ConnectionManager { this.socket = null; } - _onOpen(): void { - this._broadcast('_onOpen'); - } - /** * @param e */ @@ -332,7 +436,10 @@ class ConnectionManager { } /** - * Deliver an inbound frame to the tabs. + * Deliver an inbound frame to the tabs, feeding it to the XEP-0198 + * engine first: counting and replies happen exactly once. + * Lifecycle transitions (//) 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. @@ -340,6 +447,14 @@ class ConnectionManager { * @param message */ _onMessage(message: MessageEvent): void { + 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 { @@ -420,10 +535,7 @@ class ConnectionManager { /** * 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: answering ATTACHED now would - * hand out a pre-bind JID on a stream that hasn't authenticated yet, and - * the tab could start sending stanzas into it. - * @param port + * handshake is still in flight are parked. */ private _joinShared(port: MessagePort): void { if (this.session !== 'established') { @@ -528,6 +640,62 @@ class ConnectionManager { 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 + * still ends the session: the final 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 data - The raw outbound frame. + */ + private _salvageFrame(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); + } 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: diff --git a/src/stream-management/engine.ts b/src/stream-management/engine.ts index bc9f2ebe..339130ba 100644 --- a/src/stream-management/engine.ts +++ b/src/stream-management/engine.ts @@ -1,37 +1,40 @@ /** - * XEP-0198 Stream Management — the engine. + * 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. Nonzas are emitted - * as strings through the injected `sendRaw` function. + * {@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 + * beyond log/constants and its worker-safe siblings, in particular no * ../utils.ts or ../builder.ts, and no module-level DOM access. */ import log from '../log'; import { NS } from '../constants'; -import { QueuedStanza, SMState, SMStorageBackend, StanzaView, StreamManagementOptions } from './types'; +import { + QueuedStanza, + SMState, + SMStorageBackend, + StanzaView, + StreamManagementController, + StreamManagementOptions, +} from './types'; import { H_WRAP, freshState, isCountableStanza, parseH, stampDelay, xmlEscape } from './utils'; import { MemoryStorageBackend } from './storage'; -/** SM nonza names in the urn:xmpp:sm:3 namespace. */ -const NONZAS = ['r', 'a', 'enabled', 'resumed', 'failed']; - /** * 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 — on the page that appends to the - * connection's send queue (never a direct socket write, to preserve stanza - * ordering), in the worker it writes to the socket. + * the injected `sendRaw` function. */ -class StreamManagement { +class StreamManagement implements StreamManagementController { /** Whether the current stream's features advertised . Not persisted. */ serverSupported: boolean; @@ -182,6 +185,16 @@ class StreamManagement { this._sendRaw(``); } + /** + * @returns true once 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(): boolean { + 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. @@ -204,46 +217,37 @@ class StreamManagement { } /** - * Process an inbound top-level element. + * 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 (////). Unrecognised elements are + * ignored. Each host dispatches inbound elements to its own handlers + * independently of this call, so nothing is returned. * @param view - * @returns true if the element was an SM nonza (consumed by the engine), - * false if it's a regular stanza (counted here when the session is - * enabled, but still to be dispatched to handlers by the caller). */ - onInbound(view: StanzaView): boolean { + onInbound(view: StanzaView): void { if (isCountableStanza(view.name)) { this.onInboundStanza(view.name); - return false; + return; } switch (view.name) { case 'r': if (this._state.enabled) this.sendAck(); - return true; + break; case 'a': this._handleAck(view); - return true; + break; case 'enabled': this._handleEnabled(view); - return true; + break; case 'resumed': this._handleResumed(view); - return true; + break; case 'failed': this._handleFailed(view); - return true; - default: - return false; + break; } } - /** - * @param name - A top-level element's local tag name. - * @returns true if the element is an SM nonza. - */ - isNonza(name: string): boolean { - return NONZAS.includes(name); - } - /** Send an unrequested ack request to the server. */ requestAck(): void { this._sendRaw(``); diff --git a/src/stream-management/index.ts b/src/stream-management/index.ts index e8159f21..3285fcb4 100644 --- a/src/stream-management/index.ts +++ b/src/stream-management/index.ts @@ -1,13 +1,29 @@ /** * XEP-0198 Stream Management. * - * The environment-free {@link StreamManagement} engine (engine.ts) holds all - * SM state and logic; it consumes minimal {@link StanzaView}s produced by a - * thin adapter on whichever side hosts it, so the same implementation can run - * on a page or inside a SharedWorker. + * One engine, two hosts: the environment-free {@link StreamManagement} core + * (engine.ts) is owned by the page-side Connection for plain websocket + * connections, or by the shared connection worker when the `worker` option + * is set, in which case the page holds only a {@link StreamManagementMirror}. + * The engine consumes {@link StanzaView}s produced by a thin per-environment + * adapter: dom.ts (Element → view, page) or parse.ts (frame string → view, + * worker). + * + * The worker bundle (dist/shared-connection-worker.js) imports engine.ts and + * parse.ts directly so the page-only modules (dom.ts, mirror.ts) stay out of + * it by construction. */ export { default } from './engine'; export { MemoryStorageBackend, SessionStorageBackend } from './storage'; +export { StreamManagementMirror } from './mirror'; export { isCountableStanza, stampDelay } from './utils'; export { toStanzaView } from './dom'; -export type { StanzaView, QueuedStanza, SMState, SMStorageBackend, StreamManagementOptions } from './types'; +export { peekElement } from './parse'; +export type { + StanzaView, + QueuedStanza, + SMState, + SMStorageBackend, + StreamManagementController, + StreamManagementOptions, +} from './types'; diff --git a/src/stream-management/mirror.ts b/src/stream-management/mirror.ts new file mode 100644 index 00000000..b3284146 --- /dev/null +++ b/src/stream-management/mirror.ts @@ -0,0 +1,134 @@ +/** + * XEP-0198 Stream Management + * + * The page-side state mirror for worker mode. + * + * Page-only: never imported by the shared-connection-worker bundle. + */ +import { freshState } from './utils'; +import { SMState, StanzaView, StreamManagementController } from './types'; + +/** + * A page-side stand-in for the worker-resident engine: when the connection + * runs through a SharedWorker, the worker owns all counting, queueing and + * nonza handling, and the page only mirrors the session state so that + * `Connection.hasResumed()` and friends keep working. + */ +export class StreamManagementMirror implements StreamManagementController { + serverSupported = false; + private _state: SMState = freshState(); + + /* Read surface. Reflects the worker-owned session state. */ + + get state(): SMState { + return this._state; + } + + get enabled(): boolean { + return this._state.enabled; + } + + get resumed(): boolean { + return this._state.resumed; + } + + get boundJid(): string { + return this._state.boundJid; + } + + get resumePending(): boolean { + // The worker owns resumption; the page never has a in flight. + return false; + } + + /* Inert active surface. The worker owns all counting/queueing/nonzas. */ + + initialize(_jid: string): void { + return; + } + + hasResumableState(): boolean { + return false; + } + + reset(): void { + this._state = freshState(); + this.serverSupported = false; + } + + sendResume(): void { + return; + } + + sendEnable(_boundJid: string): void { + return; + } + + isTracking(): boolean { + return false; + } + + trackOutbound(_view: StanzaView): void { + return; + } + + onInbound(_view: StanzaView): void { + return; + } + + onInboundStanza(_name: string): void { + return; + } + + onGracefulClose(): void { + return; + } + + /* Overridable event hooks (a shim overrides these to re-emit on its bus). */ + + onEnabled(): void { + return; + } + + onResumed(): void { + return; + } + + onFailed(_view?: StanzaView, _resumeFailed?: boolean): void { + return; + } + + /* Mirror updates, driven by the worker's lifecycle messages. */ + + /** + * @param id - The SM-ID of the fresh session. + * @param max - The server's preferred maximum resumption time. + * @param boundJid - The JID that was bound when the session was enabled. + */ + _onEnabled(id: string, max: number, boundJid: string): void { + const s = this._state; + s.enabled = true; + s.id = id; + s.max = max; + s.boundJid = boundJid; + this.onEnabled(); + } + + /** + * @param boundJid - The worker's boundJid for the resumed session. + */ + _onResumed(boundJid: string): void { + const s = this._state; + s.resumed = true; + s.enabled = true; + s.boundJid = boundJid; + this.onResumed(); + } + + _onFailed(): void { + this._state = freshState(); + // _smFailed is only broadcast for a failed *resumption* (a refused + // stays inside the worker), so resumeFailed is always true. + this.onFailed(undefined, true); + } +} diff --git a/src/stream-management/parse.ts b/src/stream-management/parse.ts new file mode 100644 index 00000000..9d794964 --- /dev/null +++ b/src/stream-management/parse.ts @@ -0,0 +1,55 @@ +/** + * XEP-0198 Stream Management + * + * The worker-side {@link StanzaView} adapter: + * DOM-free peeking at the opening tag of a websocket frame. + * + * `DOMParser` is a Window interface that doesn't exist in a SharedWorker + * global, so the worker-resident XEP-0198 engine can't parse frames the way + * the page does (see dom.ts). It doesn't need to: RFC 7395 delivers exactly + * one top-level stanza or nonza per websocket frame, so the engine's + * {@link StanzaView} (tag name plus a few attributes) can be extracted from + * the opening tag with a regex. Divergence between the page and worker + * environments is prevented by sharing the engine, not the parser. + */ +import type { StanzaView } from './types'; + +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: string): string { + 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. + */ +export function peekElement(frame: string): StanzaView | null { + 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: Record = {}; + 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 }; +} diff --git a/src/stream-management/types.ts b/src/stream-management/types.ts index 21dc7cc3..964df4f7 100644 --- a/src/stream-management/types.ts +++ b/src/stream-management/types.ts @@ -1,5 +1,5 @@ /** - * XEP-0198 Stream Management — shared types. + * XEP-0198 Stream Management * * Everything in this module is environment-free (no DOM, no worker globals) * so it can be imported from both the page bundle and the standalone @@ -15,7 +15,7 @@ export interface StanzaView { name: string; /** The local (unprefixed) tag name of the top-level element. */ attrs: Record; /** The attributes of the top-level element. */ - serialized: string; /** The serialized element — used as the unacked queue payload. */ + serialized: string; /** The serialized element used as the unacked queue payload. */ } /** An entry in the unacked queue. */ @@ -63,3 +63,55 @@ export interface StreamManagementOptions { max?: number; /** The client's preferred max resumption time in seconds (max attribute on ). */ storage?: SMStorageBackend; /** Storage backend for resumable state. Default: per-instance MemoryStorageBackend. */ } + +/** + * The Stream Management surface a page-side Connection depends on, implemented + * by both the worker-free engine (engine.ts) and the worker-mode page mirror + * (mirror.ts). Typing `Connection.sm` against this (rather than the concrete + * engine class) is what lets the mirror be a peer implementation instead of a + * subclass full of neutralised overrides: any member added here must be + * implemented by both sides, so the mirror can never silently inherit a live + * engine method it was meant to render inert. + */ +export interface StreamManagementController { + /** Whether the current stream's features advertised . */ + serverSupported: boolean; + /** The live SM session state (fields are mutable; the reference is not reassigned). */ + readonly state: SMState; + /** Whether has been received and the session is active. */ + readonly enabled: boolean; + /** Whether the current session was established by resumption. */ + readonly resumed: boolean; + /** The full JID bound when the session was enabled. */ + readonly boundJid: string; + /** Whether a has been sent and not yet answered. */ + readonly resumePending: boolean; + + /** Load persisted resumable state for the given (bare) JID. */ + initialize(jid: string): void; + /** Whether persisted state allows attempting . */ + hasResumableState(): boolean; + /** Reset the in-memory session state. */ + reset(): void; + /** Send for the persisted previous session. */ + sendResume(): void; + /** Send to start a new session. */ + sendEnable(boundJid: string): void; + /** Whether outbound stanzas are currently being tracked (i.e. was sent). */ + isTracking(): boolean; + /** Track an outbound top-level element. */ + trackOutbound(view: StanzaView): void; + /** Process an inbound top-level element (count a stanza or handle a nonza). */ + onInbound(view: StanzaView): void; + /** Count an inbound top-level stanza by name. */ + onInboundStanza(name: string): void; + /** End the session cleanly: final and clear persisted state. */ + onGracefulClose(): void; + + /** Overridable event hook: a session was established via . */ + onEnabled(): void; + /** Overridable event hook: the previous session was resumed via . */ + onResumed(): void; + /** Overridable event hook: or failed. */ + onFailed(view?: StanzaView, resumeFailed?: boolean): void; +} diff --git a/src/worker-websocket.ts b/src/worker-websocket.ts index 6126a224..e6c5205a 100644 --- a/src/worker-websocket.ts +++ b/src/worker-websocket.ts @@ -4,6 +4,7 @@ import log from './log'; import Builder, { $build } from './builder'; import { LOG_LEVELS, NS, SHARED_WORKER_PROTOCOL_VERSION, Status } from './constants'; import { WebsocketLike } from 'types'; +import type { StreamManagementMirror } from './stream-management'; /** * Helper class that handles a websocket connection inside a shared worker. @@ -92,6 +93,28 @@ class WorkerWebsocket extends Websocket { this._conn.onRoleChanged('primary'); } + /** + * Ask the worker to negotiate XEP-0198 for the freshly authenticated + * stream (§2.3): it sends 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 @@ -101,6 +124,57 @@ class WorkerWebsocket extends Websocket { this.worker.port.postMessage(['_pong']); } + /** + * 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 ). + * @param jid - The worker's boundJid. + */ + _smResumed(jid: string): void { + const conn = this._conn; + (conn.sm as StreamManagementMirror)?._onResumed(jid); + 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 ). + */ + _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 diff --git a/tests/sm-parse.test.ts b/tests/sm-parse.test.ts new file mode 100644 index 00000000..5ffb3e7f --- /dev/null +++ b/tests/sm-parse.test.ts @@ -0,0 +1,58 @@ +import { peekElement } from '../src/stream-management/parse'; +import { describe, it, expect } from 'vitest'; + +describe('peekElement', () => { + it('parses stanzas with attributes and children', () => { + const frame = + "hi"; + const view = peekElement(frame); + expect(view.name).toBe('message'); + expect(view.attrs).toEqual({ + from: 'juliet@example.net/balcony', + to: 'romeo@example.net', + type: 'chat', + }); + expect(view.serialized).toBe(frame); + }); + + it('parses real SM nonza frames', () => { + // verbatim Prosody frames + expect(peekElement("")).toMatchObject( + { name: 'enabled', attrs: { xmlns: 'urn:xmpp:sm:3', max: '600', resume: 'true', id: 'uaojrU5jIfN7' } }, + ); + expect(peekElement("")).toMatchObject({ name: 'a', attrs: { h: '1' } }); + expect(peekElement("").name).toBe('r'); + expect(peekElement("").attrs.previd).toBe( + 'uaojrU5jIfN7', + ); + }); + + it('tolerates leading whitespace and newlines between attributes', () => { + const view = peekElement('\n '); + expect(view.name).toBe('presence'); + expect(view.attrs).toEqual({ from: 'a@b/c', type: 'unavailable' }); + }); + + it('strips namespace prefixes from the tag name', () => { + const view = peekElement( + "", + ); + expect(view.name).toBe('features'); + }); + + it('unescapes attribute values', () => { + const view = peekElement(''); + expect(view.attrs.previd).toBe('ab"& { + expect(peekElement('')).toMatchObject({ name: 'iq', attrs: {} }); + }); + + it('returns null for junk', () => { + expect(peekElement('')).toBeNull(); + expect(peekElement('plain text')).toBeNull(); + expect(peekElement('')).toBeNull(); + expect(peekElement('')).toBeNull(); + }); +}); diff --git a/tests/stream-management-connection.test.ts b/tests/stream-management-connection.test.ts index 84090e00..8013e636 100644 --- a/tests/stream-management-connection.test.ts +++ b/tests/stream-management-connection.test.ts @@ -84,8 +84,8 @@ describe('Stream Management connection integration', () => { ).documentElement, ); expect(bosh.sm.serverSupported).toBe(true); - (bosh as any)._maybeEnableStreamManagement(); - expect(bosh.sm.state.enableSent).toBe(false); // no sent over BOSH + (bosh as any)._onSessionReady(); + expect(bosh.sm.isTracking()).toBe(false); // no sent over BOSH }); it('negotiates a fresh SM session at the CONNECTED-emission point', () => { diff --git a/tests/stream-management.test.ts b/tests/stream-management.test.ts index a20efc8b..6f5d0956 100644 --- a/tests/stream-management.test.ts +++ b/tests/stream-management.test.ts @@ -124,7 +124,7 @@ describe('StreamManagement core', () => { enable(sm); sm.onInbound(view('message')); sm.onInbound(view('message')); - expect(sm.onInbound(view('r'))).toBe(true); + sm.onInbound(view('r')); expect(sent.at(-1)).toBe(``); }); diff --git a/tests/worker-handshake.test.ts b/tests/worker-handshake.test.ts new file mode 100644 index 00000000..069bb107 --- /dev/null +++ b/tests/worker-handshake.test.ts @@ -0,0 +1,175 @@ +/** + * End-to-end multi-tab handshake: two real Connections (page side) bridged to + * one real ConnectionManager (worker side) over one fake socket. + * + * This is the regression test for the concurrent-connect race: when two tabs + * connect at the same time (e.g. a session-restored window), exactly ONE of + * them may drive the handshake. The other join is parked by the worker and + * must not hear the handshake frames — a tab whose _messageHandler is still + * _onInitialMessage would treat them as its *own* handshake and start a + * second SASL negotiation on the shared socket. + */ +import ConnectionManager from '../src/shared-connection-worker'; +import { Strophe } from '../dist/strophe.node.esm.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const SERVICE = 'wss://example.org/xmpp-websocket'; +const { Status } = Strophe; + +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + static instances: FakeWebSocket[] = []; + + url: string; + readyState = FakeWebSocket.OPEN; + sent: string[] = []; + onopen: () => void; + onerror: (e: unknown) => void; + onclose: (e: unknown) => void; + onmessage: (m: { data: string }) => void; + + constructor(url: string, _protocol: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = FakeWebSocket.CLOSED; + } +} + +/** The worker side shared by all FakeSharedWorkers in a test. */ +let manager: ConnectionManager; + +/** + * The page's end of a synchronous in-memory MessagePort pair. Each + * FakeSharedWorker wires one of these to a worker-side counterpart that is + * registered with the shared ConnectionManager, so page and worker run the + * real protocol against each other. + */ +class PagePort { + onmessage: ((ev: { data: unknown[] }) => void) | null = null; + received: unknown[][] = []; + private _toWorker: (msg: unknown[]) => void; + + constructor(toWorker: (msg: unknown[]) => void) { + this._toWorker = toWorker; + } + + start(): void {} + + postMessage(msg: unknown[]): void { + this._toWorker(msg); + } + + /** Deliver a worker→page message. */ + _deliver(msg: unknown[]): void { + this.received.push(msg); + this.onmessage?.({ data: msg }); + } +} + +class FakeSharedWorker { + port: PagePort; + onerror: unknown; + + constructor(_url: string, _name?: string) { + const listeners: ((e: { data: unknown[] }) => void)[] = []; + const workerPort = { + addEventListener: (type: string, fn: (e: { data: unknown[] }) => void) => { + if (type === 'message') listeners.push(fn); + }, + start: () => {}, + postMessage: (msg: unknown[]) => this.port._deliver(msg), + }; + this.port = new PagePort((msg) => listeners.forEach((fn) => fn({ data: msg }))); + manager.addPort(workerPort as unknown as MessagePort); + } +} + +describe('concurrent connect through the shared worker', () => { + beforeEach(() => { + vi.useFakeTimers(); + manager = new ConnectionManager(); + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.stubGlobal('SharedWorker', FakeSharedWorker); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('lets exactly one of two concurrently connecting tabs drive the handshake', () => { + const statusesA: number[] = []; + const statusesB: number[] = []; + const connA = new Strophe.Connection(SERVICE, { worker: 'shared-connection-worker.js' }); + const connB = new Strophe.Connection(SERVICE, { worker: 'shared-connection-worker.js' }); + connA.connect('romeo@example.net', 'secret', (s: number) => statusesA.push(s)); + connB.connect('romeo@example.net', 'secret', (s: number) => statusesB.push(s)); + + // both tabs share one socket; the second join was parked, not a reconnect + expect(FakeWebSocket.instances.length).toBe(1); + const socket = FakeWebSocket.instances[0]; + const server = (xml: string) => socket.onmessage({ data: xml }); + + socket.onopen(); + // _onOpen is port-targeted: only the primary opened the stream + expect(socket.sent.filter((s) => s.startsWith(''); + server( + '' + + 'PLAIN' + + '', + ); + + // THE regression assertion: exactly one tab reacted to the handshake + // frames with a SASL negotiation + expect(socket.sent.filter((s) => s.startsWith(''); + // the stream restart likewise came from the primary alone + expect(socket.sent.filter((s) => s.startsWith(''); + server( + '' + + '' + + '', + ); + expect(socket.sent.filter((s) => s.includes('xmpp-bind')).length).toBe(1); + + server( + '' + + 'romeo@example.net/orchard' + + '', + ); + + // A drove the handshake to CONNECTED; B was attached to the shared + // session with the bound JID (not its own pre-bind one) + expect(statusesA).toContain(Status.CONNECTED); + expect(connA.role).toBe('primary'); + expect(statusesB).toContain(Status.ATTACHED); + expect(statusesB).not.toContain(Status.CONNECTED); + expect(connB.role).toBe('secondary'); + expect(connB.jid).toBe('romeo@example.net/orchard'); + + // live traffic now reaches both tabs + let gotA = 0; + let gotB = 0; + connA.addHandler(() => ++gotA && true, null, 'message'); + connB.addHandler(() => ++gotB && true, null, 'message'); + server('hi'); + expect(gotA).toBe(1); + expect(gotB).toBe(1); + }); +}); diff --git a/tests/worker-sm.test.ts b/tests/worker-sm.test.ts new file mode 100644 index 00000000..5c12c1b1 --- /dev/null +++ b/tests/worker-sm.test.ts @@ -0,0 +1,413 @@ +import ConnectionManager from '../src/shared-connection-worker'; +import { SHARED_WORKER_PROTOCOL_VERSION } from '../src/constants'; +import { Strophe, $msg } from '../dist/strophe.node.esm.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const SM_NS = 'urn:xmpp:sm:3'; +const SERVICE = 'wss://example.org/xmpp-websocket'; +const VERSION = SHARED_WORKER_PROTOCOL_VERSION; +const { Status } = Strophe; + +/** A page-side MessagePort double (see worker-arbitration.test.ts). */ +class MockPort { + sent: unknown[][] = []; + private listeners: ((e: { data: unknown[] }) => void)[] = []; + + addEventListener(type: string, fn: (e: { data: unknown[] }) => void): void { + if (type === 'message') this.listeners.push(fn); + } + + start(): void {} + + postMessage(msg: unknown[]): void { + this.sent.push(msg); + } + + emit(...data: unknown[]): void { + this.listeners.forEach((fn) => fn({ data })); + } + + msgs(name: string): unknown[][] { + return this.sent.filter((m) => m[0] === name); + } +} + +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + static instances: FakeWebSocket[] = []; + + url: string; + readyState = FakeWebSocket.OPEN; + sent: string[] = []; + onopen: () => void; + onerror: (e: unknown) => void; + onclose: (e: unknown) => void; + onmessage: (m: { data: string }) => void; + + constructor(url: string, _protocol: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = FakeWebSocket.CLOSED; + } +} + +describe('worker-resident stream management', () => { + beforeEach(() => { + vi.useFakeTimers(); + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + /** Drive a manager to an enabled SM session with one primary port. */ + function establish() { + const manager = new ConnectionManager(); + const p1 = new MockPort(); + manager.addPort(p1 as unknown as MessagePort); + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket = FakeWebSocket.instances.at(-1); + p1.emit('_smFeatures'); + p1.emit('_bound', 'romeo@example.net/orchard'); + socket.onmessage({ data: `` }); + return { manager, p1, socket }; + } + + it('negotiates, counts and answers at the socket, with no page round-trip', () => { + const manager = new ConnectionManager(); + const p1 = new MockPort(); + manager.addPort(p1 as unknown as MessagePort); + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket = FakeWebSocket.instances[0]; + + p1.emit('_smFeatures'); + expect(p1.msgs('_smNoState').length).toBe(1); // nothing to resume yet + + p1.emit('_bound', 'romeo@example.net/orchard'); + expect(manager.jid).toBe('romeo@example.net/orchard'); // shared JID tracks the bound one + expect(socket.sent.at(-1)).toBe(``); + + socket.onmessage({ data: `` }); + expect(p1.msgs('_smEnabled')).toEqual([['_smEnabled', 'sm-1', 600, 'romeo@example.net/orchard']]); + + const framesBefore = p1.msgs('_onMessage').length; + socket.onmessage({ data: "1" }); + socket.onmessage({ data: `` }); + expect(socket.sent.at(-1)).toBe(``); + // frames still reach the tabs + expect(p1.msgs('_onMessage').length).toBe(framesBefore + 2); + }); + + it('writes the periodic behind the stanza that triggers it, not ahead of it', () => { + const { manager, p1, socket } = establish(); + // maxUnacked defaults to 5: the 5th outbound stanza triggers an , + // which must land on the wire *after* that stanza (matching the page's + // _queueData FIFO), so the server counts it before answering. + for (let i = 1; i <= 5; i++) { + p1.emit('send', `${i}`); + } + const fifthIdx = socket.sent.findIndex((s) => s.includes('5')); + const rIdx = socket.sent.findIndex((s) => s === ``); + expect(fifthIdx).toBeGreaterThan(-1); + expect(rIdx).toBe(fifthIdx + 1); + expect(manager.sm.state.unacked.length).toBe(5); + }); + + it('replays a stanza sent from a secondary tab after resumption', () => { + const { manager, p1, socket } = establish(); + + // a secondary attaches and receives the *bound* JID + const p2 = new MockPort(); + manager.addPort(p2 as unknown as MessagePort); + p2.emit('_attach', SERVICE, VERSION); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, 'romeo@example.net/orchard']]); + + // the secondary sends; the stanza enters the worker-owned queue + p2.emit('send', "from p2"); + expect(socket.sent.at(-1)).toContain('from p2'); + expect(manager.sm.state.unacked.length).toBe(1); + + // the primary tab dies — the secondary is promoted with the bound JID + p1.emit('_bye'); + expect(p2.msgs('_promote')).toEqual([['_promote', 'romeo@example.net/orchard']]); + + // the socket dies uncleanly; the promoted tab reconnects + socket.readyState = FakeWebSocket.CLOSED; + p2.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket2 = FakeWebSocket.instances[1]; + p2.emit('_smFeatures'); + const resume = socket2.sent.find((s) => s.startsWith('` }); + expect(p2.msgs('_smResumed')).toEqual([['_smResumed', 'romeo@example.net/orchard']]); + // the secondary's stanza was replayed from the worker queue on the new socket + expect(socket2.sent.filter((s) => s.includes('from p2')).length).toBe(1); + expect(socket2.sent.at(-1)).toBe(``); + expect(manager.sm.state.unacked.length).toBe(1); // still unacknowledged + socket2.onmessage({ data: `` }); + expect(manager.sm.state.unacked.length).toBe(0); + }); + + it('broadcasts _smFailed on failed resumption and salvages the queue', () => { + const { manager, p1, socket } = establish(); + p1.emit('send', "salvaged"); + expect(manager.sm.state.unacked.length).toBe(1); + + socket.readyState = FakeWebSocket.CLOSED; + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket2 = FakeWebSocket.instances[1]; + p1.emit('_smFeatures'); + expect(socket2.sent.some((s) => s.startsWith('`, + }); + expect(p1.msgs('_smFailed').length).toBe(1); + + // the primary binds again and reports; the fresh session resends the salvaged stanza + p1.emit('_bound', 'romeo@example.net/fresh'); + expect(manager.jid).toBe('romeo@example.net/fresh'); + socket2.onmessage({ data: `` }); + const resent = socket2.sent.find((s) => s.includes('salvaged')); + expect(resent).toContain('urn:xmpp:delay'); + expect(p1.msgs('_smEnabled').at(-1)).toEqual(['_smEnabled', 'sm-2', null, 'romeo@example.net/fresh']); + }); + + it('does not broadcast _smFailed when a fresh is refused', () => { + const manager = new ConnectionManager(); + const p1 = new MockPort(); + manager.addPort(p1 as unknown as MessagePort); + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket = FakeWebSocket.instances.at(-1); + p1.emit('_smFeatures'); // nothing to resume → _smNoState, no + p1.emit('_bound', 'romeo@example.net/orchard'); // sends a fresh + + socket.onmessage({ data: `` }); // the server refuses it + // answering an is not the tabs' concern (the stream + // is bound and runs without SM); only a failed *resume* triggers rebind + expect(p1.msgs('_smFailed').length).toBe(0); + }); + + it('clears resume-pending on reconnect so a dropped resume cannot leave a stale flag', () => { + const { manager, p1, socket } = establish(); + socket.readyState = FakeWebSocket.CLOSED; + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket2 = FakeWebSocket.instances[1]; + p1.emit('_smFeatures'); // resumable → sent, resume-pending set + expect(socket2.sent.some((s) => s.startsWith('/ reply; the + // reconnect resets the engine, so the flag does not survive to make a + // later refused look like a failed resume + socket2.readyState = FakeWebSocket.CLOSED; + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + expect(manager.sm.resumePending).toBe(false); + }); + + it('reports frames the peeker cannot parse while SM is active (counter-drift guard)', () => { + const { p1, socket } = establish(); + const before = p1.msgs('log').length; + + // both look like stanzas but the regex peeker returns null (unquoted + // attribute), so they pass through uncounted/untracked — surface it + socket.onmessage({ data: 'secret body' }); // inbound + p1.emit('send', ''); // outbound + + const logs = p1.msgs('log').slice(before); + expect(logs.length).toBe(2); + expect(logs[0][1]).toBe('error'); + expect(logs[1][1]).toBe('error'); + // the opening tag is logged (so the peeker can be fixed)... + expect(logs[0][2]).toContain('inbound'); + expect(logs[0][2]).toContain(''); + expect(logs[1][2]).toContain('outbound'); + expect(logs[1][2]).toContain(''); + // ...but the payload after the opening tag is not + expect(logs[0][2]).not.toContain('secret body'); + }); + + it('stays quiet for benign non-element frames (prolog, whitespace, empty)', () => { + const { p1, socket } = establish(); + const before = p1.msgs('log').length; + socket.onmessage({ data: '' }); + socket.onmessage({ data: ' ' }); + socket.onmessage({ data: '' }); + expect(p1.msgs('log').length).toBe(before); + }); + + it('salvages a stanza sent into a dead socket and replays it after resumption', () => { + const { manager, p1, socket } = establish(); + socket.readyState = FakeWebSocket.CLOSED; + p1.emit('send', "rescued"); + // the tab is told the truth instead of a silent drop... + expect(p1.msgs('_onClose').length).toBe(1); + expect(socket.sent.some((s) => s.includes('rescued'))).toBe(false); + // ...and the stanza survives in the worker-owned SM queue + expect(manager.sm.state.unacked.length).toBe(1); + + // the tab reconnects; the worker resumes and replays the stanza + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket2 = FakeWebSocket.instances[1]; + p1.emit('_smFeatures'); + expect(socket2.sent.some((s) => s.startsWith('` }); + expect(socket2.sent.filter((s) => s.includes('rescued')).length).toBe(1); + }); + + it('a sent into a dead socket still ends the resumable session', () => { + const { manager, p1, socket } = establish(); + socket.readyState = FakeWebSocket.CLOSED; + p1.emit('send', ''); + expect(p1.msgs('_onClose').length).toBe(1); + expect(manager.sm.state.enabled).toBe(false); + + // the deliberately-ended session is not resumed on the next stream + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + p1.emit('_smFeatures'); + expect(p1.msgs('_smNoState').length).toBe(2); // one from establish(), one now + expect(FakeWebSocket.instances[1].sent.some((s) => s.startsWith(' before relaying and clears the session', () => { + const { manager, p1, socket } = establish(); + socket.onmessage({ data: "1" }); + + p1.emit('send', ''); + const ackIdx = socket.sent.findIndex((s) => s === ``); + const closeIdx = socket.sent.findIndex((s) => s.includes('xmpp-framing')); + expect(ackIdx).toBeGreaterThan(-1); + expect(closeIdx).toBeGreaterThan(ackIdx); + expect(manager.sm.state.enabled).toBe(false); + + // a later stream has nothing to resume (one _smNoState from the + // initial negotiation in establish(), one from this reconnect) + socket.readyState = FakeWebSocket.CLOSED; + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + p1.emit('_smFeatures'); + expect(p1.msgs('_smNoState').length).toBe(2); + expect(FakeWebSocket.instances[1].sent.some((s) => s.startsWith(' { + class FakeSharedWorkerPort { + posted: unknown[][] = []; + onmessage: ((ev: { data: unknown[] }) => void) | null = null; + start(): void {} + postMessage(msg: unknown[]): void { + this.posted.push(msg); + } + } + class FakeSharedWorker { + port = new FakeSharedWorkerPort(); + onerror: unknown; + constructor(_url: string, _name?: string) {} + } + + beforeEach(() => vi.stubGlobal('SharedWorker', FakeSharedWorker)); + afterEach(() => vi.unstubAllGlobals()); + + function parse(xml: string): Element { + return new DOMParser().parseFromString(xml, 'text/xml').documentElement; + } + + function makeConn(options: Record = {}) { + const statuses: number[] = []; + const conn = new Strophe.Connection(SERVICE, { + worker: '/dist/shared-connection-worker.js', + enableStreamManagement: true, + ...options, + }); + (conn._proto as any)._setSocket(); + conn.connected = true; + conn.connect_callback = (s: number) => statuses.push(s); + conn.jid = 'romeo@example.net'; + const port = (conn._proto as any).worker.port as InstanceType; + const fromWorker = (...msg: unknown[]) => (conn._proto as any)._onWorkerMessage({ data: msg }); + fromWorker('_role', 'primary', 'romeo@example.net'); + return { conn, port, statuses, fromWorker }; + } + + function sendFeatures(conn: InstanceType) { + (conn as any)._onStreamFeaturesAfterSASL( + parse(``), + ); + } + + it('delegates the resume decision to the worker and applies _smResumed', () => { + const { conn, port, statuses, fromWorker } = makeConn(); + sendFeatures(conn); + expect(port.posted.at(-1)).toEqual(['_smFeatures']); + expect(statuses).not.toContain(Status.BINDREQUIRED); // flow paused, no bind attempted + + fromWorker('_smResumed', 'romeo@example.net/orchard'); + expect(conn.jid).toBe('romeo@example.net/orchard'); + expect(conn.hasResumed()).toBe(true); + expect(conn.do_bind).toBe(false); + expect(conn.restored).toBe(true); + expect(statuses).toContain(Status.CONNECTED); + }); + + it('proceeds to bind on _smNoState and reports the bound JID', () => { + const { conn, port, statuses, fromWorker } = makeConn(); + sendFeatures(conn); + fromWorker('_smNoState'); + // the bind IQ went out through the worker + expect(port.posted.some((m) => m[0] === 'send' && String(m[1]).includes('xmpp-bind'))).toBe(true); + (conn as any)._dataRecv( + parse( + '' + + 'romeo@example.net/orchard', + ), + ); + expect(port.posted.at(-1)).toEqual(['_bound', 'romeo@example.net/orchard']); + expect(statuses).toContain(Status.CONNECTED); + + fromWorker('_smEnabled', 'sm-1', 600, 'romeo@example.net/orchard'); + expect(conn.isStreamManagementEnabled()).toBe(true); + expect(conn.sm.boundJid).toBe('romeo@example.net/orchard'); + expect(conn.hasResumed()).toBe(false); + }); + + it('falls back to binding on _smFailed', () => { + const { conn, statuses, fromWorker } = makeConn({ explicitResourceBinding: true }); + sendFeatures(conn); + fromWorker('_smFailed'); + expect(conn.do_bind).toBe(true); + expect(statuses).toContain(Status.BINDREQUIRED); + expect(conn.sm.enabled).toBe(false); + }); + + it('answers the worker ping with a pong', () => { + const { port, fromWorker } = makeConn(); + fromWorker('_ping'); + expect(port.posted.at(-1)).toEqual(['_pong']); + }); + + it('the mirror is inert: the page neither counts nor queues', () => { + const { conn, fromWorker } = makeConn(); + sendFeatures(conn); + fromWorker('_smResumed', 'romeo@example.net/orchard'); + conn.send($msg({ to: 'juliet@example.net' }).c('body').t('hi')); + expect(conn.sm.state.unacked.length).toBe(0); // the worker owns the queue + (conn as any)._dataRecv(parse('')); + expect(conn.sm.state.hIn).toBe(0); // the worker owns the counters + expect(conn.hasResumed()).toBe(true); // ...but the state is mirrored + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9700162b..da9bd5c7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,15 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ + resolve: { + // tsconfig has baseUrl: "./src", so src modules import each other via + // bare specifiers (e.g. `from 'utils'`); mirror that for vitest. + alias: { + 'utils': fileURLToPath(new URL('./src/utils.ts', import.meta.url)), + 'types': fileURLToPath(new URL('./src/types.ts', import.meta.url)), + }, + }, test: { include: ['tests/*.test.ts'], setupFiles: ['./tests/setup.ts'], From 413fa22c515792def63a1d0e4f2d65013855dfce Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 6 Jul 2026 14:43:06 +0200 Subject: [PATCH 5/9] feat: Reflect sent messages and presences to other tabs sharing a connection A message or presence sent from one tab was invisible to the other tabs of a shared (worker) connection. Inbound frames are broadcast, but outbound frames were only relayed to the socket. The worker now reflects outbound message/presence frames to every port except the sender (including stanzas salvaged into the SM queue from a send into a dead socket, since those are replayed on the next resume). On the page they surface through the new overridable Connection.onForeignStanzaSent(elem) hook IQs are not reflected, since they're request/response traffic private to the sending tab. --- CHANGELOG.md | 390 ++++++++++++++++--------------- README.md | 42 ++++ src/connection.ts | 16 ++ src/constants.ts | 2 +- src/shared-connection-worker.ts | 61 +++-- src/worker-websocket.ts | 13 ++ tests/worker-arbitration.test.ts | 36 +++ tests/worker-sm.test.ts | 38 +++ 8 files changed, 398 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1cb98a..46538450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Version 4.1.0 - (Unreleased) - Migrate to full TypeScript +- Add native [XEP-0198 Stream Management](https://xmpp.org/extensions/xep-0198.html) support for + websocket connections. Opt in with the new `enableStreamManagement` connection option and tune it + via the `streamManagement` option (see the README). ## Version 4.0.0 - (2026-06-03) @@ -31,23 +34,24 @@ ## Version 3.0.1 - (2024-08-15) -* Bugfix: `Package path . is not exported from package` -* #708 Properly set exports value in package.json -* #710 Fix types minor errors -* #711 Error with Builder.up depending on context -* #712 fix: export node and default modules -* #715 Fix the error when the attrs field is null +- Bugfix: `Package path . is not exported from package` +- #708 Properly set exports value in package.json +- #710 Fix types minor errors +- #711 Error with Builder.up depending on context +- #712 fix: export node and default modules +- #715 Fix the error when the attrs field is null Dependency updates: -* Bump @rollup/plugin-commonjs from 24.1.0 to 26.0.1 -* Bump @xmldom/xmldom from 0.8.8 to 0.8.10 -* Bump prettier from 2.8.8 to 3.3.3 -* Bump sinon from 15.0.4 to 18.0.0 + +- Bump @rollup/plugin-commonjs from 24.1.0 to 26.0.1 +- Bump @xmldom/xmldom from 0.8.8 to 0.8.10 +- Bump prettier from 2.8.8 to 3.3.3 +- Bump sinon from 15.0.4 to 18.0.0 ## Version 3.0.0 - (2024-05-07) -* #704 Cannot use with NodeJS -* #706 TypeError when receiving a `stream:error` IQ message +- #704 Cannot use with NodeJS +- #706 TypeError when receiving a `stream:error` IQ message Out of an abundance of caution, making a major version bump, since there was some internal refactoring of the Strophe files to remove circular @@ -55,67 +59,68 @@ dependencies. So certain deep imports used by integrators might no longer work. Instead of deep imports, everything should be imported from `strophe.js`. For example: + ``` import { Strophe, $build, stx } from strophe.js; ``` ## Version 2.0.0 - (2024-02-21) -* Type checking via TypeScript and JSDoc typing annotations -* Types definitions are now generated and placed in `./dist/types/`. -* Remove the deprecated `matchBare` option for `Strophe.Handler`. Use `matchBareFromJid` instead. -* Add the ability to create stanzas via a tagged template literal (`stx`). -* Bugfix: Ignore unknown SCRAM attributes instead of aborting the connection +- Type checking via TypeScript and JSDoc typing annotations +- Types definitions are now generated and placed in `./dist/types/`. +- Remove the deprecated `matchBare` option for `Strophe.Handler`. Use `matchBareFromJid` instead. +- Add the ability to create stanzas via a tagged template literal (`stx`). +- Bugfix: Ignore unknown SCRAM attributes instead of aborting the connection ## Version 1.6.2 - (2023-06-23) -* #613 TypeError: XHTML.validTag is not a function -* Re-add NodeJS support and add the ability to run tests on NodeJS to avoid regressions +- #613 TypeError: XHTML.validTag is not a function +- Re-add NodeJS support and add the ability to run tests on NodeJS to avoid regressions ## Version 1.6.1 - (2023-05-15) -* #602 Websocket connection times out instead of being disconnected +- #602 Websocket connection times out instead of being disconnected ## Version 1.6.0 - (2022-10-29) -* #314 Support for SCRAM-SHA-256 and SCRAM-SHA-512 authentication +- #314 Support for SCRAM-SHA-256 and SCRAM-SHA-512 authentication ## Version 1.5.0 - (2022-04-28) -* Add an automatic fallback handler for unhandled IQ "set" and "get" stanzas +- Add an automatic fallback handler for unhandled IQ "set" and "get" stanzas that returns an IQ error with `service-unavailable`. -* Update various 3rd party dependencies -* #390 Replace deprecated String.prototype.substr() -* #418 BOSH fix: mark first request dead when second is done +- Update various 3rd party dependencies +- #390 Replace deprecated String.prototype.substr() +- #418 BOSH fix: mark first request dead when second is done ## Version 1.4.4 - (2022-01-21) -* #388 Properly import xmldom +- #388 Properly import xmldom ## Version 1.4.3 - (2021-12-16) -* Update xmldom to version 0.7.5 -* Add disconnection_timeout setting: an optional timeout in milliseconds before `_doDisconnect` is called. -* Update ws optional dependency to fix security issue https://github.com/websockets/ws/commit/00c425ec77993773d823f018f64a5c44e17023ff +- Update xmldom to version 0.7.5 +- Add disconnection_timeout setting: an optional timeout in milliseconds before `_doDisconnect` is called. +- Update ws optional dependency to fix security issue https://github.com/websockets/ws/commit/00c425ec77993773d823f018f64a5c44e17023ff ## Version 1.4.2 - (2021-04-28) -* Update optional NodeJS-specific dependency xmldom to version 0.5.0 which includes a security fix. -* #369 Add `clientChallenge` SASL mechanism method to avoid having to monkey patch `onChallenge`, +- Update optional NodeJS-specific dependency xmldom to version 0.5.0 which includes a security fix. +- #369 Add `clientChallenge` SASL mechanism method to avoid having to monkey patch `onChallenge`, which prevents reconnection when using SCRAM-SHA1. ## Version 1.4.1 - (2020-12-02) -* #201: NodeJS related fixes: `window` and `WebSocket` are `undefined` -* New method `Strophe.Connection.prototype.setProtocol` which can be used to +- #201: NodeJS related fixes: `window` and `WebSocket` are `undefined` +- New method `Strophe.Connection.prototype.setProtocol` which can be used to determine the protocol used after the connection has been constructed. ## Version 1.4.0 - (2020-09-10) -* #347: Bugfix. Reconnection fails when SessionResultIQ arrives too late -* #354: Strophe.js sends an authzid during PLAIN when not acting on behalf of another entity -* #359: Bugfix: WebSocket connection failed: Data frame received after close -* Add support for running a websocket connection inside a shared worker +- #347: Bugfix. Reconnection fails when SessionResultIQ arrives too late +- #354: Strophe.js sends an authzid during PLAIN when not acting on behalf of another entity +- #359: Bugfix: WebSocket connection failed: Data frame received after close +- Add support for running a websocket connection inside a shared worker ## Version 1.3.6 - (2020-06-15) @@ -124,236 +129,255 @@ import { Strophe, $build, stx } from strophe.js; ## Version 1.3.5 - (2020-04-29) -* Remove support for obselete SASL DIGEST-MD5 auth -* #329 Varous compatibility fixes to make Strophe.js work in NodeJS -* #344 Properly set Strophe.VERSION +- Remove support for obselete SASL DIGEST-MD5 auth +- #329 Varous compatibility fixes to make Strophe.js work in NodeJS +- #344 Properly set Strophe.VERSION ## Version 1.3.4 - (2019-08-08) -* Replace webpack with rollup -* `TypeError: this._changeConnectStatus is not a function` -* Bugfix. Remove handlers on closed socket -* Add new Strophe.Connection option `explicitResourceBinding`. +- Replace webpack with rollup +- `TypeError: this._changeConnectStatus is not a function` +- Bugfix. Remove handlers on closed socket +- Add new Strophe.Connection option `explicitResourceBinding`. If is set to true the XMPP client needs to explicitly call `Strophe.Connection.prototype.bind` once the XMPP server has advertised the "urn:ietf:params:xml:ns:xmpp-bind" feature. ## Version 1.3.3 - (2019-05-13) -* The dist files are no longer included in the repo, but generated by NPM/Yarn -* Moved some log statements from INFO to DEBUG level -* Don't break when a falsy value is passed to `getResourceFromJid` +- The dist files are no longer included in the repo, but generated by NPM/Yarn +- Moved some log statements from INFO to DEBUG level +- Don't break when a falsy value is passed to `getResourceFromJid` ## Version 1.3.2 - (2019-03-21) -* #320 Fix error on SCRAM-SHA-1 client nonce generation +- #320 Fix error on SCRAM-SHA-1 client nonce generation ## Version 1.3.1 - (2018-11-15) -* #311 Expose `Strophe`, `$build`, `$msg` and `$iq` as globals +- #311 Expose `Strophe`, `$build`, `$msg` and `$iq` as globals ## Version 1.3.0 - (2018-10-21) -* Use ES2015 modules -* Drop support for Internet Explorer < 11 +- Use ES2015 modules +- Drop support for Internet Explorer < 11 ## Version 1.2.16 - (2018-09-16) -* #299 'no-auth-mech' error. Server did not offer a supported authentication mechanism -* #306 Fix websocket close handler exception and reporting + +- #299 'no-auth-mech' error. Server did not offer a supported authentication mechanism +- #306 Fix websocket close handler exception and reporting ## Version 1.2.15 - (2018-05-21) -* #259 XML element should be sent to xmlOutput -* #266 Support Browserify/CommonJS. `require('strophe.js/src/wrapper')` -* #296 Remove error handler from old websocket before closing -* #271 SASL X-OAUTH2 authentication mechanism implemented -* #288 Strophe now logs fatal errors by default. -* Run tests with headless Chromium instead of Phantomjs + +- #259 XML element should be sent to xmlOutput +- #266 Support Browserify/CommonJS. `require('strophe.js/src/wrapper')` +- #296 Remove error handler from old websocket before closing +- #271 SASL X-OAUTH2 authentication mechanism implemented +- #288 Strophe now logs fatal errors by default. +- Run tests with headless Chromium instead of Phantomjs ## Version 1.2.14 - 2017-06-15 -* #231 SASL OAuth Bearer authentication should not require a JID node, when a user identifer + +- #231 SASL OAuth Bearer authentication should not require a JID node, when a user identifer can be retreived from the bearer token. -* #250 Show XHR error message -* #254 Set connection status to CONNFAIL after max retries -* #255 Set CONNFAIL error status when connection fails on Safari 10 +- #250 Show XHR error message +- #254 Set connection status to CONNFAIL after max retries +- #255 Set CONNFAIL error status when connection fails on Safari 10 ## Version 1.2.13 - 2017-02-25 -* Use almond to create the build. This means that the build itself is an AMD +- Use almond to create the build. This means that the build itself is an AMD module and can be loaded via `require`. -* Remove Grunt as a build tool. +- Remove Grunt as a build tool. ## Version 1.2.12 - 2017-01-15 -* Reduce the priority of the SASL-EXTERNAL auth mechanism. OpenFire 4.1.1 +- Reduce the priority of the SASL-EXTERNAL auth mechanism. OpenFire 4.1.1 advertises support for SASL-EXTERNAL and the vast majority of Strophe.js installs are not set up to support SASL-EXTERNAl, causing them to fail logging users in. ## Version 1.2.11 - 2016-12-13 -* 189 Strophe never reaches DISCONNECTED status after .connect(..) and + +- 189 Strophe never reaches DISCONNECTED status after .connect(..) and .disconnect(..) calls while offline. -* Add `sendPresence` method, similar to `sendIQ`, i.e. for cases where you expect a +- Add `sendPresence` method, similar to `sendIQ`, i.e. for cases where you expect a responding presence (e.g. when leaving a MUC room). ## Version 1.2.10 - 2016-11-30 -* #172 and #215: Strophe shouldn't require `from` attribute in iq response -* #216 Get inactivity attribute from session creation response -* Enable session restoration for anonymous logins + +- #172 and #215: Strophe shouldn't require `from` attribute in iq response +- #216 Get inactivity attribute from session creation response +- Enable session restoration for anonymous logins ## Version 1.2.9 - 2016-10-24 -* Allow SASL mechanisms to be supported to be passed in as option to `Strophe.Connection` constructor. -* Add new matching option to `Strophe.Handler`, namely `ignoreNamespaceFragment`. -* The `matchBare` matching option for `Strophe.Handler` has been renamed to + +- Allow SASL mechanisms to be supported to be passed in as option to `Strophe.Connection` constructor. +- Add new matching option to `Strophe.Handler`, namely `ignoreNamespaceFragment`. +- The `matchBare` matching option for `Strophe.Handler` has been renamed to `matchBareFromJid`. The old name will still work in this release but is deprecated and will be removed in a future release. -* #114 Add an error handler for HTTP calls -* #213 "XHR open failed." in BOSH in IE9 -* #214 Add function to move Strophe.Builder pointer back to the root node -* #172, #215 Don't compare `to` and `to` values of sent and received IQ stanzas +- #114 Add an error handler for HTTP calls +- #213 "XHR open failed." in BOSH in IE9 +- #214 Add function to move Strophe.Builder pointer back to the root node +- #172, #215 Don't compare `to` and `to` values of sent and received IQ stanzas to determine correctness (we rely on UUIDs for that). ## Version 1.2.8 - 2016-09-16 -* #200 Fix for webpack -* #203 Allow custom Content-Type header for requests -* #206 XML stanza attributes: there is no 'quot' escape inside 'serialize' method -* The files in `./src` are now also included in the NPM distribution. -* Add support for SASL-EXTERNAL + +- #200 Fix for webpack +- #203 Allow custom Content-Type header for requests +- #206 XML stanza attributes: there is no 'quot' escape inside 'serialize' method +- The files in `./src` are now also included in the NPM distribution. +- Add support for SASL-EXTERNAL ## Version 1.2.7 - 2016-06-17 -* #193 Move phantomjs dependencies to devDependencies + +- #193 Move phantomjs dependencies to devDependencies ## Version 1.2.6 - 2016-06-06 -* #178 Added new value (CONNTIMEOUT) to Strophe.Status -* #180 bosh: check sessionStorage support before using it -* #182 Adding SASL OAuth Bearer authentication -* #190 Fix .c() to accept both text and numbers as text for the child element -* #192 User requirejs instead of require for node compat + +- #178 Added new value (CONNTIMEOUT) to Strophe.Status +- #180 bosh: check sessionStorage support before using it +- #182 Adding SASL OAuth Bearer authentication +- #190 Fix .c() to accept both text and numbers as text for the child element +- #192 User requirejs instead of require for node compat ## Version 1.2.5 - 2016-02-09 -* Add a new Strophe.Connection option to add cookies -* Add new Strophe.Connection option "withCredentials" + +- Add a new Strophe.Connection option to add cookies +- Add new Strophe.Connection option "withCredentials" ## Version 1.2.4 - 2016-01-28 -* #147 Support for UTF-16 encoded usernames (e.g. Chinese) -* #162 allow empty expectedFrom according to W3C DOM 3 Specification -* #171 Improve invalid BOSH URL handling + +- #147 Support for UTF-16 encoded usernames (e.g. Chinese) +- #162 allow empty expectedFrom according to W3C DOM 3 Specification +- #171 Improve invalid BOSH URL handling ## Version 1.2.3 - 2015-09-01 -* Bugfix. Check if JID is null when restoring a session. -* #127 IE-Fix: error on setting null value with setAttributes -* #138 New stub method nextValidRid -* #144 Change ID generator to generate UUIDs + +- Bugfix. Check if JID is null when restoring a session. +- #127 IE-Fix: error on setting null value with setAttributes +- #138 New stub method nextValidRid +- #144 Change ID generator to generate UUIDs ## Version 1.2.2 - 2015-06-20 -* #109 Explicitly define AMD modules to prevent errors with AlmondJS and AngularJS. -* #111 Fixed IE9 compatibility. -* #113 Permit connecting with an alternative authcid. -* #116 tree.attrs() now removes Elements when they are set to undefined -* #119 Provide the 'keepalive' option to keep a BOSH session alive across page loads. -* #121 Ensure that the node names of HTML elements copied into XHTML are lower case. -* #124 Strophe's Builder will swallow elements if given a blank string as a 'text' parameter. + +- #109 Explicitly define AMD modules to prevent errors with AlmondJS and AngularJS. +- #111 Fixed IE9 compatibility. +- #113 Permit connecting with an alternative authcid. +- #116 tree.attrs() now removes Elements when they are set to undefined +- #119 Provide the 'keepalive' option to keep a BOSH session alive across page loads. +- #121 Ensure that the node names of HTML elements copied into XHTML are lower case. +- #124 Strophe's Builder will swallow elements if given a blank string as a 'text' parameter. ## Version 1.2.1 - 2015-02-22 -* Rerelease of 1.2.0 but with a semver tag and proper formatting of bower.json + +- Rerelease of 1.2.0 but with a semver tag and proper formatting of bower.json for usage with Bower.io. ## Version 1.2.0 - 2015-02-21 -* Add bower package manager support. -* Add commandline testing support via qunit-phantomjs-runner -* Add integrated testing via TravisCI. -* Fix Websocket connections now use the current XMPP-over-WebSockets RFC -* #25 Item-not-found-error caused by long term request. -* #29 Add support for the Asynchronous Module Definition (AMD) and require.js -* #30 Base64 encoding problem in some older browsers. -* #45 Move xhlr plugin to strophejs-plugins repo. -* #60 Fixed deletion of handlers in websocket connections -* #62 Add `xmlunescape` method. -* #67 Use correct Content-Type in BOSH -* #70 `_onDisconnectTimeout` never tiggers because maxRetries is undefined. -* #71 switched to case sensitive handling of XML elements -* #73 `getElementsByTagName` problem with namespaced elements. -* #76 respect "Invalid SID" message -* #79 connect.pause work correctly again -* #90 The queue data was not reset in .reset() method. -* #104 Websocket connections with MongooseIM work now + +- Add bower package manager support. +- Add commandline testing support via qunit-phantomjs-runner +- Add integrated testing via TravisCI. +- Fix Websocket connections now use the current XMPP-over-WebSockets RFC +- #25 Item-not-found-error caused by long term request. +- #29 Add support for the Asynchronous Module Definition (AMD) and require.js +- #30 Base64 encoding problem in some older browsers. +- #45 Move xhlr plugin to strophejs-plugins repo. +- #60 Fixed deletion of handlers in websocket connections +- #62 Add `xmlunescape` method. +- #67 Use correct Content-Type in BOSH +- #70 `_onDisconnectTimeout` never tiggers because maxRetries is undefined. +- #71 switched to case sensitive handling of XML elements +- #73 `getElementsByTagName` problem with namespaced elements. +- #76 respect "Invalid SID" message +- #79 connect.pause work correctly again +- #90 The queue data was not reset in .reset() method. +- #104 Websocket connections with MongooseIM work now ## Version 1.1.3 - 2014-01-20 -* Fix SCRAM-SHA1 auth now works for multiple connections at the same time -* Fix Connecting to a different server with the same connection after disconnect -* Add Gruntfile so StropheJS can now also be built using grunt -* Fix change in sha1.js that broke the caps plugin -* Fix all warnings from jshint. + +- Fix SCRAM-SHA1 auth now works for multiple connections at the same time +- Fix Connecting to a different server with the same connection after disconnect +- Add Gruntfile so StropheJS can now also be built using grunt +- Fix change in sha1.js that broke the caps plugin +- Fix all warnings from jshint. ## Version 1.1.2 - 2014-01-04 -* Add option for synchronous BOSH connections -* moved bower.json to other repository -* Remove unused code in sha1 and md5 modules + +- Add option for synchronous BOSH connections +- moved bower.json to other repository +- Remove unused code in sha1 and md5 modules ## Version 1.1.1 - 2013-12-16 -* Fix BOSH attach is working again + +- Fix BOSH attach is working again ## Version 1.1.0 - 2013-12-11 -* Add Support for XMPP-over-WebSocket -* Authentication mechanisms are now modular and can be toggled -* Transport protocols are now modular and will be chosen based on the + +- Add Support for XMPP-over-WebSocket +- Authentication mechanisms are now modular and can be toggled +- Transport protocols are now modular and will be chosen based on the connection URL. Currently supported protocols are BOSH and WebSocket -* Add Strings to some disconnects that indicate the reason -* Add option to strip tags before passing to xmlInput/xmlOutput -* Fix Connection status staying at CONNFAIL or CONNECTING in certain +- Add Strings to some disconnects that indicate the reason +- Add option to strip tags before passing to xmlInput/xmlOutput +- Fix Connection status staying at CONNFAIL or CONNECTING in certain error scenarios -* Add package.json for use with npm -* Add bower.json for use with bower -* Fix handlers not being removed after disconnect -* Fix legacy non-sasl authentication -* Add better tests for BOSH bind -* Fix use of deprecated functions in tests -* Remove some dead code -* Remove deprecated documentation -* Fix Memory leak in IE9 -* Add An options object can be passed to a Connection constructor now -* Add "Route" Parameter for BOSH Connections -* Add Maximum number of connection attempts before disconnecting -* Add conflict condition for AUTHFAIL -* Add XHTML message support -* Fix parsing chat messages in IE -* Add SCRAM-SHA-1 SASL mechanism -* Fix escaping of messages +- Add package.json for use with npm +- Add bower.json for use with bower +- Fix handlers not being removed after disconnect +- Fix legacy non-sasl authentication +- Add better tests for BOSH bind +- Fix use of deprecated functions in tests +- Remove some dead code +- Remove deprecated documentation +- Fix Memory leak in IE9 +- Add An options object can be passed to a Connection constructor now +- Add "Route" Parameter for BOSH Connections +- Add Maximum number of connection attempts before disconnecting +- Add conflict condition for AUTHFAIL +- Add XHTML message support +- Fix parsing chat messages in IE +- Add SCRAM-SHA-1 SASL mechanism +- Fix escaping of messages ## Version 1.0.2 - 2011-06-19 -* Fix security bug where DIGEST-MD5 client nonce was not properly randomized. -* Fix double escaping in copyElement. -* Fix IE errors related to importNode. -* Add ability to pass text into Builder.c(). -* Improve performance by skipping debugging callbacks when not +- Fix security bug where DIGEST-MD5 client nonce was not properly randomized. +- Fix double escaping in copyElement. +- Fix IE errors related to importNode. +- Add ability to pass text into Builder.c(). +- Improve performance by skipping debugging callbacks when not overridden. -* Wrap handler runs in try/catch so they don't affect or remove later +- Wrap handler runs in try/catch so they don't affect or remove later handlers. -* Add ' and " to escaped characters and other escaping fixes. -* Fix _throttledRequestHandler to use proper window size. -* Fix timed handler management. -* Fix flXHR plugin to better deal with errors. -* Fix bind() to be ECMAScript 5 compatible. -* Use bosh.metajack.im in examples so they work out of the box. -* Add simple XHR tests. -* Update basic example to HTML5. -* Move community plugins to their own repository. -* Fix bug causing infinite retries. -* Fix 5xx error handling. -* Store stream:features for later use by plugins. -* Fix to prevent passing stanzas during disconnect. -* Fix handling of disconnect responses. -* Fix getBareJidFromJid to return null on error. -* Fix equality testing in matchers so that string literals and string +- Add ' and " to escaped characters and other escaping fixes. +- Fix \_throttledRequestHandler to use proper window size. +- Fix timed handler management. +- Fix flXHR plugin to better deal with errors. +- Fix bind() to be ECMAScript 5 compatible. +- Use bosh.metajack.im in examples so they work out of the box. +- Add simple XHR tests. +- Update basic example to HTML5. +- Move community plugins to their own repository. +- Fix bug causing infinite retries. +- Fix 5xx error handling. +- Store stream:features for later use by plugins. +- Fix to prevent passing stanzas during disconnect. +- Fix handling of disconnect responses. +- Fix getBareJidFromJid to return null on error. +- Fix equality testing in matchers so that string literals and string objects both match. -* Fix bare matching on missing from attributes. -* Remove use of reserved word self. -* Fix various documentation errors. +- Fix bare matching on missing from attributes. +- Remove use of reserved word self. +- Fix various documentation errors. ## Version 1.0.1 - 2010-01-27 -* Fix handling of window, hold, and wait attributes. Bug #75. +- Fix handling of window, hold, and wait attributes. Bug #75. ## Version 1.0 - 2010-01-01 -* First release. +- First release. diff --git a/README.md b/README.md index d769b0be..9275f794 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,48 @@ XMPP applications. * [Mailing list](https://groups.google.com/g/strophe) * [Community Plugins](https://github.com/strophe/strophejs-plugins) +## Stream Management (XEP-0198) + +Since version 4.1.0, Strophe.js natively supports +[XEP-0198 Stream Management](https://xmpp.org/extensions/xep-0198.html) on websocket +connections: sent stanzas are acknowledged by the server, and a dropped connection can be +resumed without losing them. + +It is off by default; opt in when creating the connection: + +```javascript +const conn = new Strophe.Connection(service, { + enableStreamManagement: true, + // Optional fine-tuning: + streamManagement: { + maxUnacked: 5, // request an ack every N sent stanzas + requestResume: true, // ask the server for a resumable session + }, +}); +``` + +After connecting, `conn.hasResumed()` tells you whether the previous session was resumed +(skip re-fetching the roster, re-joining rooms etc.) or a fresh session was established. +Resumable state is kept in `sessionStorage` by default; pass a custom +`streamManagement.storage` backend to change that. + +### Sharing a connection between tabs + +With the `worker` connection option, all tabs of your application share a single websocket +connection through a SharedWorker. Point it at `dist/shared-connection-worker.js` (the page +and the worker script must come from the same build — a version check enforces this). + +One tab is assigned the `primary` role and drives the connection; the others attach to it +as `secondary` and are promoted automatically if the primary tab goes away (see +`Connection.onRoleChanged`). When Stream Management is enabled, the XEP-0198 engine runs +inside the worker itself, so a single SM session covers all tabs and a stanza sent from +any tab survives resumption. + +Messages and presences sent from one tab are reflected to all the other tabs, so every tab +can render what any tab sent — override `Connection.onForeignStanzaSent` to receive them. +They are deliberately kept out of the regular stanza handlers, which only see *received* +traffic. + ## Support in different environments ### Browsers diff --git a/src/connection.ts b/src/connection.ts index b2546547..1b332693 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -870,6 +870,22 @@ class Connection { return; } + /** + * User overrideable function that receives message and presence stanzas + * sent by *another* tab sharing this connection (via the `worker` + * option), so every tab can render what any tab sent. + * + * Deliberately separate from the inbound handler pipeline: these stanzas + * were sent, not received, so they must not trigger stanza handlers. + * IQs are not reflected — they are request/response traffic private to + * the sending tab. + * + * @param _elem - The sent stanza. + */ + onForeignStanzaSent(_elem: Element): void { + return; + } + /** * Send a stanza. * diff --git a/src/constants.ts b/src/constants.ts index 7958b302..5e197061 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,7 +35,7 @@ export const PARSE_ERROR_NS = 'http://www.w3.org/1999/xhtml'; * exchanged on _connect/_attach so that a mismatch fails loudly instead of * silently misbehaving. */ -export const SHARED_WORKER_PROTOCOL_VERSION = 1; +export const SHARED_WORKER_PROTOCOL_VERSION = 2; /** * Contains allowed tags, tag attributes, and css properties. diff --git a/src/shared-connection-worker.ts b/src/shared-connection-worker.ts index f9666c56..37c50768 100644 --- a/src/shared-connection-worker.ts +++ b/src/shared-connection-worker.ts @@ -164,7 +164,7 @@ class ConnectionManager { // dropping the message into the void (a send() on a CONNECTING // socket would throw and lose the frame). if (method === 'send') { - this._salvageFrame(args[0] as string); + this._salvageFrame(port, args[0] as string); } this._post(port, '_onClose', 'The shared socket is gone'); return; @@ -356,33 +356,56 @@ class ConnectionManager { /** * Relay an outbound frame to the socket, feeding it to the XEP-0198 - * engine too. - * @param _port + * engine and reflecting messages/presences to the other tabs. + * @param port * @param data */ - send(_port: MessagePort, data: string): void { - if (!this.sm) { - this.socket.send(data); - return; - } + send(port: MessagePort, data: string): void { const view = peekElement(data); if (view?.name === 'close') { // A stream-closing triggers the final ack + state clearing, // and the engine writes that final straight to the socket, so // onGracefulClose() must run *before* the close frame goes out. - this.sm.onGracefulClose(); + 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 which should ride behind the + // stanza it covers. + this.sm.trackOutbound(view); + } else { + this._warnUnpeekable('outbound', data); + } + } if (view) { - // A countable stanza is tracked *after* it is written, because - // trackOutbound() may emit an which should ride behind the - // stanza it covers. - this.sm.trackOutbound(view); - } else { - this._warnUnpeekable('outbound', data); + 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); + } } } @@ -648,15 +671,21 @@ class ConnectionManager { * still ends the session: the final 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(data: string): void { + 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); } diff --git a/src/worker-websocket.ts b/src/worker-websocket.ts index e6c5205a..8c90d200 100644 --- a/src/worker-websocket.ts +++ b/src/worker-websocket.ts @@ -3,6 +3,7 @@ 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 } from 'types'; import type { StreamManagementMirror } from './stream-management'; @@ -124,6 +125,18 @@ class WorkerWebsocket extends Websocket { 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. diff --git a/tests/worker-arbitration.test.ts b/tests/worker-arbitration.test.ts index 65c91e41..a1d6191d 100644 --- a/tests/worker-arbitration.test.ts +++ b/tests/worker-arbitration.test.ts @@ -264,6 +264,42 @@ describe('shared-connection-worker arbitration', () => { expect(FakeWebSocket.instances[0].sent).toEqual(['']); }); + it('reflects sent messages and presences to the other tabs, never the sender', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + const p3 = join(); + attach(p3); + // reflection is an arbitration feature: it works without SM (no + // engine exists in this test) + const msg = "hi"; + p1.emit('send', msg); + expect(p1.msgs('_onStanzaSent')).toEqual([]); + expect(p2.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', msg]]); + expect(p3.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', msg]]); + // symmetric: a secondary's send reaches the primary and the other secondary + p2.emit('send', ""); + expect(p1.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', ""]]); + expect(p3.msgs('_onStanzaSent').length).toBe(2); + // p2 only ever saw p1's message, not its own presence + expect(p2.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', msg]]); + }); + + it('does not reflect IQs, nonzas or close frames', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + bind(p1); + const p2 = join(); + attach(p2); + p1.emit('send', ""); + p1.emit('send', ''); + expect(p2.msgs('_onStanzaSent')).toEqual([]); + }); + it('promotes a secondary when the primary says _bye — same socket', () => { const { join } = makeManager(); const p1 = join(); diff --git a/tests/worker-sm.test.ts b/tests/worker-sm.test.ts index 5c12c1b1..81d20097 100644 --- a/tests/worker-sm.test.ts +++ b/tests/worker-sm.test.ts @@ -270,6 +270,22 @@ describe('worker-resident stream management', () => { expect(socket2.sent.filter((s) => s.includes('rescued')).length).toBe(1); }); + it('reflects a salvaged stanza to the other tabs (it will be replayed on resume)', () => { + const { manager, p1, socket } = establish(); + const p2 = new MockPort(); + manager.addPort(p2 as unknown as MessagePort); + p2.emit('_attach', SERVICE, VERSION); + + socket.readyState = FakeWebSocket.CLOSED; + p1.emit('send', "rescued"); + // the sender is told the socket is gone, but the other tab still + // learns of the send: the stanza sits in the SM queue and will be + // replayed on the next resume + expect(p1.msgs('_onClose').length).toBe(1); + expect(p2.msgs('_onStanzaSent').length).toBe(1); + expect(p1.msgs('_onStanzaSent')).toEqual([]); + }); + it('a sent into a dead socket still ends the resumable session', () => { const { manager, p1, socket } = establish(); socket.readyState = FakeWebSocket.CLOSED; @@ -400,6 +416,28 @@ describe('page-side state mirror under options.worker', () => { expect(port.posted.at(-1)).toEqual(['_pong']); }); + it('routes reflected stanzas to onForeignStanzaSent, not the inbound pipeline', () => { + const { conn, fromWorker } = makeConn(); + const seen: Element[] = []; + conn.onForeignStanzaSent = (el: Element) => seen.push(el); + let inbound = 0; + conn.addHandler( + () => { + inbound += 1; + return true; + }, + null, + 'message', + ); + fromWorker('_onStanzaSent', "from another tab"); + expect(seen.length).toBe(1); + expect(seen[0].tagName).toBe('message'); + expect(seen[0].getAttribute('to')).toBe('juliet@example.net'); + // a sent stanza must not look like received traffic + expect(inbound).toBe(0); + expect(conn.sm.state.hIn).toBe(0); + }); + it('the mirror is inert: the page neither counts nor queues', () => { const { conn, fromWorker } = makeConn(); sendFeatures(conn); From 22f8dc3be1aded45c28c906ecc0d1b002386d3af Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 7 Jul 2026 13:11:34 +0200 Subject: [PATCH 6/9] fix: Make the package surface usable for embedders that type-check Found by compiling Converse.js against this package: - Use relative internal imports. tsc emits bare specifiers like 'types' verbatim into the .d.ts files, and a consumer with its own baseUrl resolves them against its own project root. - Give NS a string index signature, since it is extended at runtime via Strophe.addNamespace. - Add the missing log/error methods to the Strophe namespace type. - Type the callback that attach() accepts in place of a JID as ConnectCallback instead of () => void. - Expose the SM storage backends on the Strophe namespace and export ConnectionOptions and ConnectCallback from the package root. --- src/connection.ts | 4 ++-- src/constants.ts | 12 ++++++++---- src/index.ts | 8 +++++++- src/websocket.ts | 2 +- src/worker-websocket.ts | 2 +- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index 1b332693..e5639104 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -212,7 +212,7 @@ type ProtocolErrorHandlers = { websocket: Record; }; -type ConnectCallback = (status: number, condition: string | null, elem?: Element) => void; +export type ConnectCallback = (status: number, condition: string | null, elem?: Element) => void; /** * _Private_ variable Used to store plugin names that need @@ -722,7 +722,7 @@ class Connection { * allowed range of request ids that are valid. The default is 5. */ attach( - jid: string | (() => void), + jid: string | ConnectCallback, sid?: string, rid?: number, callback?: ConnectCallback, diff --git a/src/constants.ts b/src/constants.ts index 5e197061..22f2fa16 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,4 @@ -/** - * Common namespace constants from the XMPP RFCs and XEPs. - */ -export const NS = { +const _NS = { AUTH: 'jabber:iq:auth', BIND: 'urn:ietf:params:xml:ns:xmpp-bind', BOSH: 'urn:xmpp:xbosh', @@ -25,6 +22,13 @@ export const NS = { XHTML_IM: 'http://jabber.org/protocol/xhtml-im', } as const; +/** + * Common namespace constants from the XMPP RFCs and XEPs. + * Extensible at runtime via {@link Strophe.addNamespace}, hence the string + * index signature. + */ +export const NS: typeof _NS & Record = _NS; + export const PARSE_ERROR_NS = 'http://www.w3.org/1999/xhtml'; /** diff --git a/src/index.ts b/src/index.ts index 08e1f05c..41105df5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,8 @@ type StropheType = { SASLXOAuth2: typeof SASLXOAuth2; Stanza: typeof Stanza; StreamManagement: typeof StreamManagement; + MemoryStorageBackend: typeof MemoryStorageBackend; + SessionStorageBackend: typeof SessionStorageBackend; Builder: typeof Builder; ElementType: typeof ElementType; ErrorCondition: typeof ErrorCondition; @@ -60,7 +62,8 @@ type StropheType = { setLogLevel(level: LogLevel): void; addNamespace(name: string, value: string): void; addConnectionPlugin(name: string, ptype: object): void; -} & typeof utils; +} & typeof utils & + typeof log; const Strophe: StropheType = { VERSION: '4.0.0', @@ -104,6 +107,8 @@ const Strophe: StropheType = { Stanza, StreamManagement, + MemoryStorageBackend, + SessionStorageBackend, Builder, ElementType, ErrorCondition, @@ -171,3 +176,4 @@ export type { StreamManagementController, StreamManagementOptions, } from './stream-management'; +export type { ConnectCallback, ConnectionOptions } from './connection'; diff --git a/src/websocket.ts b/src/websocket.ts index 6cba2ec3..4da983e9 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -11,7 +11,7 @@ import type Connection from './connection'; import Builder, { $build } from './builder'; import log from './log'; import { NS, ErrorCondition, Status } from './constants'; -import {WebsocketLike} from 'types'; +import {WebsocketLike} from './types'; /** * Helper class that handles WebSocket Connections diff --git a/src/worker-websocket.ts b/src/worker-websocket.ts index 8c90d200..2a2b584f 100644 --- a/src/worker-websocket.ts +++ b/src/worker-websocket.ts @@ -4,7 +4,7 @@ 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 } from 'types'; +import { WebsocketLike } from './types'; import type { StreamManagementMirror } from './stream-management'; /** From e8b8d0c8316ecd0963ee3fac5509534df493ff83 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 7 Jul 2026 14:36:50 +0200 Subject: [PATCH 7/9] feat: Probe quiet shared-worker connections with XEP-0198 ack requests A silently dead socket (NAT timeout, sleep/wake) keeps reporting OPEN, and under the shared worker no page-side recovery could reach it: a reloading or reconnecting tab is idempotently joined onto the existing session while the socket looks live, so the only way out was the OS's TCP timeout, which can take many minutes. The worker's sweep now sends an when an SM-enabled stream has been quiet for a full PING_INTERVAL (doubling as a keepalive), and treats no inbound traffic within PROBE_TIMEOUT as socket death: the zombie is closed and the tabs are told, so they reconnect and the worker resumes the session with the unacked queue replayed. A tab joining the established session triggers an immediate probe, so a page reload recovers within seconds. --- README.md | 17 ++++----- src/shared-connection-worker.ts | 67 ++++++++++++++++++++++++++++++++ tests/worker-sm.test.ts | 68 ++++++++++++++++++++++++++++++++- 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9275f794..d7a95b2d 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ XMPP applications. ## Quick Links -* [Homepage](https://strophe.im/strophejs/) -* [Documentation](https://strophe.im/strophejs) -* [Mailing list](https://groups.google.com/g/strophe) -* [Community Plugins](https://github.com/strophe/strophejs-plugins) +- [Homepage](https://strophe.im/strophejs/) +- [Documentation](https://strophe.im/strophejs) +- [Mailing list](https://groups.google.com/g/strophe) +- [Community Plugins](https://github.com/strophe/strophejs-plugins) ## Stream Management (XEP-0198) @@ -31,7 +31,7 @@ const conn = new Strophe.Connection(service, { enableStreamManagement: true, // Optional fine-tuning: streamManagement: { - maxUnacked: 5, // request an ack every N sent stanzas + maxUnacked: 5, // request an ack every N sent stanzas requestResume: true, // ask the server for a resumable session }, }); @@ -45,8 +45,7 @@ Resumable state is kept in `sessionStorage` by default; pass a custom ### Sharing a connection between tabs With the `worker` connection option, all tabs of your application share a single websocket -connection through a SharedWorker. Point it at `dist/shared-connection-worker.js` (the page -and the worker script must come from the same build — a version check enforces this). +connection through a SharedWorker. Point it at `dist/shared-connection-worker.js`. One tab is assigned the `primary` role and drives the connection; the others attach to it as `secondary` and are promoted automatically if the primary tab goes away (see @@ -55,8 +54,8 @@ inside the worker itself, so a single SM session covers all tabs and a stanza se any tab survives resumption. Messages and presences sent from one tab are reflected to all the other tabs, so every tab -can render what any tab sent — override `Connection.onForeignStanzaSent` to receive them. -They are deliberately kept out of the regular stanza handlers, which only see *received* +can render what any other tab sent (override `Connection.onForeignStanzaSent` to receive them). +They are deliberately kept out of the regular stanza handlers, which only see _received_ traffic. ## Support in different environments diff --git a/src/shared-connection-worker.ts b/src/shared-connection-worker.ts index 37c50768..5484c5ad 100644 --- a/src/shared-connection-worker.ts +++ b/src/shared-connection-worker.ts @@ -41,6 +41,16 @@ 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 () + * 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', @@ -97,6 +107,10 @@ class ConnectionManager { private _pendingJoins: MessagePort[]; private _sweepTimer: ReturnType; private _graceTimer: ReturnType; + /** Pending death sentence for an unanswered ack probe (see _probe). */ + private _probeTimer: ReturnType; + /** When the socket last delivered anything (probes count inbound only). */ + private _lastInbound: number; constructor() { this.ports = new Map(); @@ -106,6 +120,8 @@ class ConnectionManager { this._pendingJoins = []; this._sweepTimer = null; this._graceTimer = null; + this._probeTimer = null; + this._lastInbound = 0; } /** @@ -188,6 +204,11 @@ class ConnectionManager { } 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); return; } @@ -231,6 +252,7 @@ class ConnectionManager { return; } if (this._socketLive()) { + this._probe(); this._joinShared(port); } else { this._post(port, '_attachCallback', Status.ATTACHFAIL); @@ -435,6 +457,7 @@ class ConnectionManager { */ _closeSocket(_port?: MessagePort): void { this.session = 'none'; + this._cancelProbe(); this._failPendingJoins(); if (this.socket) { try { @@ -454,6 +477,7 @@ class ConnectionManager { */ _onClose(e: CloseEvent): void { this.session = 'none'; + this._cancelProbe(); this._failPendingJoins(); this._broadcast('_onClose', e.reason); } @@ -470,6 +494,9 @@ class ConnectionManager { * @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) { @@ -755,6 +782,46 @@ class ConnectionManager { 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 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'); } /** diff --git a/tests/worker-sm.test.ts b/tests/worker-sm.test.ts index 81d20097..5f8ceff6 100644 --- a/tests/worker-sm.test.ts +++ b/tests/worker-sm.test.ts @@ -1,4 +1,4 @@ -import ConnectionManager from '../src/shared-connection-worker'; +import ConnectionManager, { PING_INTERVAL, PROBE_TIMEOUT } from '../src/shared-connection-worker'; import { SHARED_WORKER_PROTOCOL_VERSION } from '../src/constants'; import { Strophe, $msg } from '../dist/strophe.node.esm.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -300,6 +300,72 @@ describe('worker-resident stream management', () => { expect(FakeWebSocket.instances[1].sent.some((s) => s.startsWith(' and declares the socket dead when unanswered', () => { + const { manager, p1, socket } = establish(); + const before = socket.sent.length; + + // the stream goes quiet; the sweep sends an ack probe + vi.advanceTimersByTime(PING_INTERVAL); + expect(socket.sent.slice(before)).toEqual([``]); + + // port liveness traffic is not socket traffic and must not save it + p1.emit('_pong'); + vi.advanceTimersByTime(PROBE_TIMEOUT); + expect(manager.socket).toBe(null); // the zombie was closed + expect(p1.msgs('_onClose').length).toBe(1); + + // the engine kept its state: the tab reconnects and the worker resumes + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + p1.emit('_smFeatures'); + const socket2 = FakeWebSocket.instances.at(-1); + expect(socket2.sent.some((s) => s.startsWith(' { + const { manager, p1, socket } = establish(); + vi.advanceTimersByTime(PING_INTERVAL); + expect(socket.sent.at(-1)).toBe(``); + + socket.onmessage({ data: `` }); + vi.advanceTimersByTime(PROBE_TIMEOUT + 1000); + expect(manager.socket).not.toBe(null); + expect(p1.msgs('_onClose').length).toBe(0); + + // still quiet: the next sweep probes again (doubles as a keepalive) + vi.advanceTimersByTime(PING_INTERVAL); + expect(socket.sent.filter((s) => s === ``).length).toBe(2); + }); + + it('probes immediately when a tab joins the established session', () => { + const { manager, p1, socket } = establish(); + const before = socket.sent.length; + + const p2 = new MockPort(); + manager.addPort(p2 as unknown as MessagePort); + p2.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, 'romeo@example.net/orchard']]); + // the joining tab put the socket's liveness to the test right away + expect(socket.sent.slice(before)).toEqual([``]); + + vi.advanceTimersByTime(PROBE_TIMEOUT); + expect(p1.msgs('_onClose').length).toBe(1); + expect(p2.msgs('_onClose').length).toBe(1); + }); + + it('does not probe without an SM-enabled stream', () => { + const manager = new ConnectionManager(); + const p1 = new MockPort(); + manager.addPort(p1 as unknown as MessagePort); + p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION); + const socket = FakeWebSocket.instances.at(-1); + p1.emit('_bound', 'romeo@example.net/orchard'); // no _smFeatures → no engine + + vi.advanceTimersByTime(3 * PING_INTERVAL + PROBE_TIMEOUT); + expect(socket.sent.some((s) => s.startsWith(' before relaying and clears the session', () => { const { manager, p1, socket } = establish(); socket.onmessage({ data: "1" }); From 259ddcf5b71137af018cc647a19971bd382eff3c Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 7 Jul 2026 15:10:02 +0200 Subject: [PATCH 8/9] fix: Shared-worker fixes found by testing against a paused server The new liveness probe correctly declared the socket dead, but the recovery afterwards wedged every tab, and a related mirror gap surfaced: - The tab driving the reconnect never learned that the worker's new connection attempt failed: the worker broadcasts _onClose with a reason string, and the base Websocket._onClose ignores closes while not connected unless given a real CloseEvent with code 1006. The tab sat in CONNECTING forever. WorkerWebsocket now fails the attempt with CONNFAIL and disconnects, so the embedder's reconnect logic retries. - A tab whose _connect join was parked on the failed handshake was answered with ATTACHFAIL, which a connect()-initiated page treats as terminal. Parked joins now remember how they joined: _attach joins keep the ATTACHFAIL contract, _connect joins are told the socket closed and retry like the driver. - A tab joining after missed the _smEnabled broadcast, so its SM mirror reported no Stream Management. The worker now seeds the mirror when handing out ATTACHED. --- src/shared-connection-worker.ts | 49 +++++++++++++++++++++++--------- src/worker-websocket.ts | 26 +++++++++++++++++ tests/worker-arbitration.test.ts | 14 +++++++++ tests/worker-sm.test.ts | 30 ++++++++++++++++++- 4 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/shared-connection-worker.ts b/src/shared-connection-worker.ts index 5484c5ad..0ef77656 100644 --- a/src/shared-connection-worker.ts +++ b/src/shared-connection-worker.ts @@ -103,8 +103,14 @@ class ConnectionManager { * a secondary tab survives resumption, and failover needs no reconnect. */ sm: StreamManagement | null; - /** Ports whose join arrived while the handshake was still in flight. */ - private _pendingJoins: MessagePort[]; + /** + * 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; private _sweepTimer: ReturnType; private _graceTimer: ReturnType; /** Pending death sentence for an unanswered ack probe (see _probe). */ @@ -117,7 +123,7 @@ class ConnectionManager { this.socket = null; this.session = 'none'; this.sm = null; - this._pendingJoins = []; + this._pendingJoins = new Map(); this._sweepTimer = null; this._graceTimer = null; this._probeTimer = null; @@ -209,7 +215,7 @@ class ConnectionManager { // zombie session would otherwise only find out at the next // sweep-driven probe. this._probe(); - this._joinShared(port); + this._joinShared(port, '_connect'); return; } this.service = service; @@ -253,7 +259,7 @@ class ConnectionManager { } if (this._socketLive()) { this._probe(); - this._joinShared(port); + this._joinShared(port, '_attach'); } else { this._post(port, '_attachCallback', Status.ATTACHFAIL); } @@ -586,11 +592,13 @@ class ConnectionManager { * 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): void { + private _joinShared(port: MessagePort, origin: '_connect' | '_attach'): void { if (this.session !== 'established') { - if (!this._pendingJoins.includes(port)) { - this._pendingJoins.push(port); + if (!this._pendingJoins.has(port)) { + this._pendingJoins.set(port, origin); } return; } @@ -604,6 +612,11 @@ class ConnectionManager { } 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); } @@ -614,18 +627,26 @@ class ConnectionManager { private _sessionEstablished(): void { this.session = 'established'; const pending = this._pendingJoins; - this._pendingJoins = []; - pending.filter((p) => this.ports.has(p)).forEach((p) => this._joinShared(p)); + this._pendingJoins = new Map(); + pending.forEach((origin, p) => this.ports.has(p) && this._joinShared(p, origin)); } /** - * Answer parked joins with ATTACHFAIL — the session they were waiting - * for died before it was established. + * 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 = []; - pending.forEach((p) => this._post(p, '_attachCallback', Status.ATTACHFAIL)); + 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'); + } + }); } /** diff --git a/src/worker-websocket.ts b/src/worker-websocket.ts index 2a2b584f..ad3e6692 100644 --- a/src/worker-websocket.ts +++ b/src/worker-websocket.ts @@ -209,6 +209,32 @@ class WorkerWebsocket extends Websocket { 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 diff --git a/tests/worker-arbitration.test.ts b/tests/worker-arbitration.test.ts index a1d6191d..d3774c0b 100644 --- a/tests/worker-arbitration.test.ts +++ b/tests/worker-arbitration.test.ts @@ -188,6 +188,20 @@ describe('shared-connection-worker arbitration', () => { expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]); }); + it('fails a parked _connect join with _onClose instead of ATTACHFAIL, so the page retries', () => { + const { join } = makeManager(); + const p1 = join(); + connect(p1); + const p2 = join(); + connect(p2); // same user, handshake in flight: parked + FakeWebSocket.instances[0].onclose({ reason: 'boom' }); + // ATTACHFAIL is the attach() contract; a connect()-initiated page + // would treat it as terminal and wedge, so it gets _onClose, which + // fails the connect attempt and lets the embedder reconnect. + expect(p2.msgs('_attachCallback')).toEqual([]); + expect(p2.msgs('_onClose').length).toBeGreaterThanOrEqual(1); + }); + it('closes the socket when the connecting primary leaves mid-handshake', () => { const { join } = makeManager(); const p1 = join(); diff --git a/tests/worker-sm.test.ts b/tests/worker-sm.test.ts index 5f8ceff6..bd16cf4b 100644 --- a/tests/worker-sm.test.ts +++ b/tests/worker-sm.test.ts @@ -300,6 +300,20 @@ describe('worker-resident stream management', () => { expect(FakeWebSocket.instances[1].sent.some((s) => s.startsWith(' { + const { manager } = establish(); + const p2 = new MockPort(); + manager.addPort(p2 as unknown as MessagePort); + p2.emit('_attach', SERVICE, VERSION); + // the joiner missed the _smEnabled broadcast, so it is seeded, + // before ATTACHED, so the mirror is consistent when the page's + // attach callback runs + const smIdx = p2.sent.findIndex((m) => m[0] === '_smEnabled'); + const attachedIdx = p2.sent.findIndex((m) => m[0] === '_attachCallback'); + expect(p2.msgs('_smEnabled')).toEqual([['_smEnabled', 'sm-1', 600, 'romeo@example.net/orchard']]); + expect(smIdx).toBeLessThan(attachedIdx); + }); + it('probes a quiet stream with and declares the socket dead when unanswered', () => { const { manager, p1, socket } = establish(); const before = socket.sent.length; @@ -482,6 +496,17 @@ describe('page-side state mirror under options.worker', () => { expect(port.posted.at(-1)).toEqual(['_pong']); }); + it('fails the connect attempt when the worker reports the socket gone while connecting', () => { + const { conn, statuses, fromWorker } = makeConn(); + conn.connected = false; // the connect attempt is still in flight + fromWorker('_onClose', 'The shared connection could not be established'); + // The base Websocket ignores closes while not connected, which would + // leave the tab in CONNECTING forever; the worker transport must + // fail the attempt so the embedder's reconnect logic can retry. + expect(statuses).toContain(Status.CONNFAIL); + expect(statuses).toContain(Status.DISCONNECTED); + }); + it('routes reflected stanzas to onForeignStanzaSent, not the inbound pipeline', () => { const { conn, fromWorker } = makeConn(); const seen: Element[] = []; @@ -495,7 +520,10 @@ describe('page-side state mirror under options.worker', () => { null, 'message', ); - fromWorker('_onStanzaSent', "from another tab"); + fromWorker( + '_onStanzaSent', + "from another tab", + ); expect(seen.length).toBe(1); expect(seen[0].tagName).toBe('message'); expect(seen[0].getAttribute('to')).toBe('juliet@example.net'); From c67ef462d3ba2fe6da655f8eed9eacfd01929cfb Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 7 Jul 2026 15:46:56 +0200 Subject: [PATCH 9/9] docs: Mention the new hooks and exports in the changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46538450..81e8d6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - Add native [XEP-0198 Stream Management](https://xmpp.org/extensions/xep-0198.html) support for websocket connections. Opt in with the new `enableStreamManagement` connection option and tune it via the `streamManagement` option (see the README). +- New `Connection.onForeignStanzaSent` hook: under the `worker` option, messages and presences + sent from one tab are reflected to all other tabs. +- `Strophe.Status.RECONNECTING` is now defined. +- Export `MemoryStorageBackend`, `SessionStorageBackend`, `ConnectionOptions` and + `ConnectCallback` from the package root. ## Version 4.0.0 - (2026-06-03)