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