What's Changed
This release makes connections and subscriptions reliable under network trouble, process suspension, re-entrant event handlers and server-side changes. There are no API removals. A few behaviours change, listed below; most apps need no code changes.
Highlights:
- Back/forward cache: when a browser page enters the back/forward cache, the client now closes its connection right away and reconnects, with recovery, when the page is restored (#445). Before, the frozen page kept its WebSocket open: the server saw the user online until the ping timeout (
3012 no pong), and kept the presence and subscriptions of a page nobody was looking at. - Reconnects: no more stuck
connectingstate, zero-delay reconnect loops or events in the wrong order after reconnects,disconnect()andconnect()from event handlers. - Delivery: publications arrive in order, and nothing is delivered after
unsubscribe()ordisconnect(). Recovery positions are correct for stream, map and shared poll subscriptions. - Resume: a suspended laptop or backgrounded tab resumes without false timeouts, "no ping" reconnects or dropped handshakes.
- Streaming transports: http_stream, SSE and WebTransport handle split protobuf replies, captive portals, failing emulation requests and large replies.
- Map and shared poll subscriptions: many correctness fixes, including token expiry, delta and signature handling.
- Memory: a disconnected client no longer stays reachable from
windowthrough its network listeners.
Notable changes
Read these four first: the client disconnects when a page enters the back/forward cache (#445); positions over the protobuf protocol (#436) are the only change that can require a code change; connect() now throws on a configuration that never worked (#374); and an exception in an event handler, or while handling a message, now stops the client visibly (#394, #444). Everything else in this section changes behaviour that was wrong before.
- The client disconnects when the page enters the back/forward cache (#445). On
pagehidewithpersistedset, a connected or connecting client closes its transport and moves toconnectingwith code1and reasonpage entered back/forward cache; subscriptions move tosubscribing. Onpageshowwithpersistedset, it reconnects at once and subscriptions recover as on any reconnect. Normal page loads and unloads (persistedfalse) are unchanged.- What the server sees: the disconnect and the presence leave happen when the user navigates away, instead of after the ping timeout.
- Your own
pagehidehandler: apublish()or other call made from it no longer reaches the server when the page enters the cache, because the connection is already closed. - Opting out: the new option
pageEventTarget(likenetworkEventTarget) defaults toglobalThiswhere it supportsaddEventListener–windowin browsers, nothing in Node or React Native. Pass a target which never fires these events, such asnew EventTarget(), to turn the feature off. A custom target must dispatch the events withpersisted: true.
- Positions and shared poll versions are numbers over the protobuf protocol (#436). The
offsetof astreamPosition, of a publication context and of the position the library keeps is a number, as the public types declare, and so is theversionof a shared poll update. Before, it was aLongobject where long.js is available (it comes with protobufjs), and an app that stored such a position got{low, high, unsigned}back, which compares and adds as NaN. An app that worked around this, e.g. withoffset.toNumber(), uses the number now. A position stored by an earlier version and passed back throughsinceis converted. Offsets above 2^53 lose exactness, as the type implies. - Exception in an event handler. An exception thrown by an app event handler of the client or of a subscription now stops the client visibly, whatever caused the event: a received message, a call such as
connect()orsubscribe(), a timer or a teardown (#394, #444).- What happens: the client disconnects without reconnecting, with code
2(badProtocol) and reasonexception in event handler: <message>. The exception is reported as an unhandled rejection, andconnect()works again afterwards. The library's own work completes as if the handler had returned, so the client is left consistent. - Publications: a publication of a stream or server-side subscription whose handler threw is not counted as delivered, so a recovery after
connect()delivers it again. - Other exceptions while handling a message stop the client the same way, with reason
exception during message handling: <message>(#394). - Before: an exception while handling a received message silently stopped processing while
statestayedconnected; on a timeout, a teardown or an error event it was only reported and the client kept reconnecting and retrying (#420, #426, #431); fromconnect()it was thrown to the caller. Throwing from event handlers is still unsupported: catch errors in handlers.
- What happens: the client disconnects without reconnecting, with code
- Undecodable data closes the transport, and the client reconnects with the usual backoff and transport fallback (#396, #421). Examples are a captive portal page, or a proxy answering an http_stream request. Before, every line threw from the transport callback, which ends a Node process without an
uncaughtExceptionhandler. - Emulation requests (http_stream, SSE):
- Transport close: closing the transport aborts pending emulation requests (#397). A network failure or a
404/5xxresponse now closes the transport, so the client reconnects at once instead of waiting for the command timeout (#393, #397). - Other statuses: statuses such as
413still reject only that command.
- Transport close: closing the transport aborts pending emulation requests (#397). A network failure or a
- Configuration errors are thrown from
connect()(#374). Examples are no supported transport, no WebSocket implementation, or anhttp(s)endpoint given as a string. Before, the client stayedconnecting, or retried forever reporting token or data errors. subscribe()on a removed subscription throws (#424), asnewSubscription()does for a channel that already has one. Before, it created a server subscription the client didn't track, so its publications were dropped.removeSubscription()removes the subscription before itsunsubscribedevent (#427). A handler of that event can create a new subscription to the same channel. CallingremoveSubscription()again with an old object no longer removes a newer subscription of that channel (#424).- State invalidation without
getTokenkeeps the token. This applies to disconnect code3014and unsubscribe code2502(#401, #403). A client or subscription with a static token (or none) reconnects or resubscribes with it, instead of stopping on a configuration error or resubscribing without a token. - Subscription token expired without
getToken(#443). A subscription with a static token that gets109now ends unsubscribed with code1(unauthorized) and a configuration error, like the connection does. Before, it cleared the token and subscribed again without one. disconnect()fails calls made while disconnected (#443), as it already did for calls made while connecting. Before, they waited for a connection until their timeout.subscribe()on a subscription already subscribing keeps its backoff (#443). Only a subscribe fromunsubscribedresets it.setToken()after a token expired error. WithoutgetToken,setToken()now clears the pending token refresh, so the nextconnect()uses the new token (#422, #426). WithgetToken, the next attempt still calls it.- Overdue timers wait for data already received. This covers call timeouts, the no-ping timer and the connect timeout (#398, #417, #423, #426). A timer that runs late, e.g. after a laptop sleep, waits one more event loop turn, or 1 s when it ran at least 1 s late, so a reply, ping or open event already received is processed first.
- Server-side subscription pushes go to the server-side subscription while a client-side subscription object of the same channel isn't subscribed (#425). Before, they were dropped.
- Fewer duplicate error events. A transport failing to initialize, or a refused SSE/http_stream handshake, is reported as one
transporterror, without an extraconnecterror (#426, #432). - Subscription state events stay in order.
subscribingandunsubscribedaren't emitted when astatehandler has already changed the state again (#431, #433). - Shared poll:
- Tracking during the subscribe:
track()calls made while a subscribe completes, e.g. from asubscribedhandler, are sent with the replay that follows (#430). - Stored signatures: a new signature replaces stored ones whose keys it fully covers (#433).
- Duplicate versions: an update with a version not newer than the one held for the key is dropped. Before, a replay and its retry could deliver the same version twice, and an older version could replace a newer one (#443).
- Permanently failed track: keys of a track failing with a permanent error (e.g. limit exceeded) are untracked, with a removal update each. Before, only an error was emitted, and the keys stayed tracked by the client and were replayed on every reconnect (#443).
- Tracking during the subscribe:
- Reconnect backoff. The zero-delay first round over a transport list no longer counts towards the backoff (#432).
- A channel the server doesn't recover no longer reports a failed recovery (#436). With positioning enabled and recovery disabled, a subscription given a position by
sinceorgetStatekept sendingrecover: trueon every resubscribe, so the app was toldwasRecovering: true, recovered: falseeach time although nothing was lost.recoveredstaysfalse, so apps that reload their state on it are unaffected. - Stream transports. A close of http_stream, SSE or WebTransport is handled after data already received is dispatched, so a disconnect push the server writes just before closing is applied (#429). WebSocket and SockJS are unchanged.
Pull requests
- Close the connection when the page enters the back/forward cache by @FZambia in #445
- Catch publish errors from nested debounce timer by @FZambia in #388
- Remove network event listeners when client becomes disconnected by @FZambia in #390
- Fix reconnect races, Safari online workaround and message order after reconnect by @FZambia in #391
- Drop outdated pushes, keep dispatching after errors, fix map delta removals by @FZambia in #392
- Stop recovered publications after teardown, close transport on failed emulation request by @FZambia in #393
- Stop the client on an exception during message handling by @FZambia in #394
- Wait for more data when a protobuf stream read ends inside a reply by @FZambia in #395
- Close the transport on data that cannot be decoded by @FZambia in #396
- Abort pending emulation requests on close, close the transport on error responses by @FZambia in #397
- Process received data before failing an overdue call timeout by @FZambia in #398
- Get a subscription token before recovering a map subscription by @FZambia in #399
- Reconnect with the current token after state invalidation without getToken by @FZambia in #401
- Do not close a new connection attempt on an outdated unsubscribe rejection by @FZambia in #402
- Keep a subscription token after state invalidation without getToken by @FZambia in #403
- Stop a map subscribe flow after unsubscribe from an event handler by @FZambia in #404
- Handle a transport failing to initialize as a closed transport by @FZambia in #405
- Validate transports in connect(), fail a hanging attempt without its transport by @FZambia in #374
- Keep a map subscription's position before what the app didn't receive by @FZambia in #406
- Back off after a connect error returned over an emulation transport by @FZambia in #407
- Back off after a disconnect pushed during an emulation connect by @FZambia in #408
- Keep the reconnect backoff growing while an emulation endpoint fails by @FZambia in #409
- Complete a subscribe consistently when a handler unsubscribes or resets it by @FZambia in #410
- Ignore replies of an earlier map subscribe flow, page without moving the position by @FZambia in #411
- Let a ping waiting in the socket keep the connection on resume by @FZambia in #423
- Wait for the unsubscribe reply in every resubscribe, keep removal to its own subscription by @FZambia in #424
- Recover after a publication a handler resubscribed from, and other subscription state fixes by @FZambia in #425
- Keep a handshake that completed while suspended, fall back from a silent socket, and other lifecycle fixes by @FZambia in #426
- Remove a subscription before its unsubscribed event, keep earlier pending unsubscribes by @FZambia in #427
- Keep shared poll versions of undelivered updates, drop outdated subscription token refreshes by @FZambia in #428
- Send the connect command despite a throwing callback, apply a disconnect push read with a stream end, and other lifecycle fixes by @FZambia in #429
- Ignore shared poll signatures obtained for an earlier subscription, track once from subscribe handlers by @FZambia in #430
- Keep subscription events in order, stop refresh retries a handler ended, survive callbacks throwing by @FZambia in #431
- Keep 5.7.4 fallback semantics for an opened socket, and other lifecycle fixes by @FZambia in #432
- Refresh an expired shared poll signature, replay without covered signatures, and other subscription fixes by @FZambia in #433
- Recover server-side subscriptions with the epoch of the first publication by @FZambia in #434
- Keep the client id of a connection across its token refreshes by @FZambia in #435
- Take the position of a reply as a number, stop asking to recover a channel the server does not recover by @FZambia in #436
- Fix subscription state races, retry backoff and emulation ordering by @FZambia in #443
- Stop the client when an event handler throws by @FZambia in #444
- Bump handlebars from 4.7.9 to 4.7.10 by @dependabot[bot] in #446
- Bump undici from 6.28.0 to 6.28.1 by @dependabot[bot] in #447
- Update dev dependencies by @FZambia in #448
Full Changelog: 5.7.4...5.8.0