Release 3.0.0 - #44
Open
jjxxs wants to merge 38 commits into
Open
Conversation
removeEventListener compared the options argument by object identity, so removal silently failed whenever callers passed a fresh options literal or omitted options for a listener added with them. Match on the listener function alone, mirroring the native EventTarget, and keep the options parameter only for backward compatibility.
ExponentialBackoff and LinearBackoff advanced their step counter before returning, so the first retry waited base*2 / initial+increment and the documented initial delay (base * 2^0 / initial) was never used. next() now returns the current value and then advances, making actual delays match the series described in the class docs and README examples (e.g. LinearBackoff(0, 10000, 60000) really starts at 0 ms).
With instantReconnect enabled, the backoff was never advanced and the retry event detail hardcoded retries to 0. Every retry was therefore treated as the first: zero delay on each attempt and a retry count that never reached maxRetries, producing an unbounded zero-delay reconnect loop while the server was down (~300 attempts in 600ms). The backoff now always advances, so maxRetries applies and the event detail reports the true retry count. The delay is zeroed only for the first retry of a disconnection episode; since the backoff resets on every successful reconnect, each new outage still starts with an instant reconnect, as documented.
A retry runs inside a setTimeout callback, so an exception from the URL provider or the WebSocket constructor was uncaught and silently killed the reconnect chain: no socket existed, no close event would ever fire, and no further retries were scheduled. Catch the throw, surface it as an 'error' event, and reschedule under the normal backoff/maxRetries rules so a transient provider failure (e.g. a failed auth-token fetch) no longer permanently disables reconnects.
dispatchEvent iterated the live listener array and then replaced it wholesale with a rebuilt copy. Listeners added by a listener during dispatch of the same event type were silently discarded, and listeners removed during dispatch were resurrected by the final assignment. Dispatch now iterates a snapshot and consults the live list before each invocation, matching native EventTarget semantics: listeners removed mid-dispatch are skipped and stay removed, listeners added mid-dispatch are not invoked in the current round but stay registered, and once-listeners are removed in place before invocation.
The binaryType setter only wrote to the current underlying socket, so every reconnect created a fresh socket with the default "blob" type and message handlers silently started receiving Blobs again. Remember the user-chosen value and re-apply it whenever a new underlying websocket is created.
sendBufferedData read an element and handed it to send(), which re-adds it to the buffer whenever the websocket is not OPEN. A listener closing the underlying websocket during the 'open' dispatch therefore made the drain cycle read/re-add forever, hanging the event loop. Guard each iteration on readyState === OPEN (and not closedByUser) so draining stops and undelivered messages stay buffered for the next connection.
tsc rejects assigning a spread copy back to the generic indexed type WebsocketEventListeners[K]; typing the snapshot and live list as WebsocketEventListenerWithOptions<K>[] compiles under both build configs. Vitest does not type-check, so the merged branch passed tests while npm run build failed.
The old glob also demanded .js matches; the repo has none, and ESLint 9 exits with an error on unmatched patterns, so npm run lint failed on every tree.
Applications could not tell "still retrying" from "gave up" - when maxRetries was exceeded the websocket simply stopped scheduling - and the only way to resume was to build a new instance and swap every reference. The new 'exhausted' event (detail: retries performed, last connection) signals the give-up moment, and reconnect() resumes from any state: it cancels pending retries, resets the retry budget, re-resolves the URL provider and connects. reconnect() also revives a user-closed websocket and can force a fresh connection while open, e.g. to pick up a rotated auth token. Retry options are now validated: maxRetries and instantReconnect only have an effect when a backoff drives retrying, so configuring them without one previously produced a websocket that silently never retried. WebsocketOptions.listeners is now Partial, so initial listeners can be provided for a subset of event-types. BREAKING CHANGE: the Websocket constructor (and WebsocketBuilder's build()) throws an Error when maxRetries or instantReconnect are set without a backoff. Previously such configurations were accepted and silently never retried; add a backoff to restore the intended behavior.
Listener options previously advertised the whole AddEventListenerOptions
surface while only 'once' was honored. The worst casualty was 'signal':
the standard teardown idiom (one AbortController for a component's
listeners, abort() on unmount) type-checked, ran, and silently did
nothing - listeners kept firing after "cleanup", causing stale handlers
and leaks.
'signal' is now implemented with native EventTarget semantics: an
already-aborted signal never registers the listener, and aborting
removes exactly that registration (the same function may be registered
again with a different signal). Initial listeners from the builder go
through addEventListener so their signals are honored too. Abort
handlers are unhooked when a registration is removed by other means
(once-consumption, removeEventListener) so long-lived signals don't
accumulate dead handlers.
BREAKING CHANGE: WebsocketEventListenerOptions is narrowed to
{ once?, signal? }. 'capture' and 'passive' literals no longer compile;
a websocket has no capture/bubble phases and its events are not
cancelable, so these options never had an effect.
ConstantBackoff and ExponentialBackoff rejected non-integer delays while LinearBackoff accepted any number - including NaN and Infinity, which slipped past its negative-check. Delays are milliseconds and setTimeout handles fractions fine, so all delay parameters now accept any finite non-negative number; only ExponentialBackoff's expMax stays an integer (it is an exponent). Error messages now match the accepted ranges.
read() used 'element !== undefined' to detect emptiness, so a stored undefined element jammed the tail pointer and stalled the queue; emptiness is now determined by the head/tail indices alone. read(), clear() and eviction on overflow also null their slots so already-sent messages are not retained by a long-lived buffer.
The retry detail passed the live _lastConnection reference while the reconnect and exhausted paths hand out defensive copies - a listener mutating the received Date corrupted instance state. All event details now carry copies.
The seven event types each had four near-identical listener tests, ~800 lines of copy-paste that all reached into private state independently. One describe.each now generates the same 28 tests, the private-state access lives in a single helper, and every build() is closed so tests stop leaking connecting websockets.
Reconnect-behaviour tests asserted after fixed sleeps, so a loaded CI runner could fail them; they now await the retry lifecycle events (reconnect, exhausted) and keep sleeps only for negative assertions. Getter tests assigned to a shadowing const, leaking connecting or retrying clients into later tests - they now use the suite-level client that afterEach closes. Also covers two gaps: the reconnect event detail was never asserted, and RingQueue was never exercised as the actual send buffer (eviction of the oldest message end-to-end).
The README omitted maxRetries and instantReconnect entirely and predates the new API surface. It now covers: the exhausted event and the seven-event table, listener options (once/AbortSignal teardown), removeEventListener's identity-based matching, send()'s buffer/drop semantics, the retry-options-require-a-backoff rule, per-outage maxRetries semantics, instant reconnect, giving up & resuming via reconnect(), and reconnect() re-running the URL provider. Also unpins the bundle-size badge from 2.2.1 and drops the hardcoded kB claim.
package.json had no exports map, so Node could never load the ESM
build (plain .js without "type":"module"), types resolved only against
the CJS build, and deep imports into dist/ were unencapsulated. The
exports map now serves import/require with per-format types, and the
build writes {"type":"module"} / {"type":"commonjs"} stubs into
dist/esm and dist/cjs. Relative imports in src carry explicit .js
extensions as Node's ESM loader requires; verified with a packed
tarball consumed via require, import, and tsc under nodenext.
Both build configs compile only src (tests moved to a dedicated
root tsconfig.json driven by the new typecheck script, which stays in
the build chain so test type-checking - including @ts-expect-error
assertions - is preserved). Output loses the dist/*/src nesting, both
formats target ES2018 instead of the ES5/ES6 split, and a files
allowlist replaces the stale .npmignore denylist that was shipping
tsconfigs, eslint config and the lockfile.
BREAKING CHANGE: dist layout changed (dist/cjs/src/index.js is now
dist/cjs/index.js); consumers deep-importing dist paths must update.
Bare entry-point imports are unaffected.
Workflows mixed 'npm install --only=dev' and 'npm ci --only=dev'; the --only flag is deprecated and everything here is a devDependency anyway, so plain npm ci is both correct and reproducible.
Coverage was enabled unconditionally, so 'npm run test' and 'npm run test:coverage' were identical; the flag in the coverage script now actually decides.
The branch carries three breaking changes (retry options require a backoff, listener options narrowed to once/signal, dist layout change), so semver requires a major bump before publishing.
Adopt the current majors of the lint and test stacks: eslint 10 with @eslint/js 10 and globals 17 (existing flat config works unchanged), and vitest 4 with @vitest/coverage-v8 4. In-range lockfile updates bring typescript-eslint 8.63, eslint-plugin-prettier 5.5.6 and ws 8.21. jsdom stays at 26: starting with 27 it delegates WebSocket to undici, whose fired events are rejected by Node's EventTarget under vitest's jsdom globals, timing out 43 tests.
prettier 3.9 wraps a few constructs differently than 3.8, and typescript-eslint 8.63 newly flags the split declaration/assignment of exhaustedDetail via prefer-const. Conform so the lint gate is clean under the updated toolchain.
TypeScript 6 updates lib.dom's WebSocket.send() to reject SharedArrayBuffer-backed payloads, matching the WHATWG spec — browsers have always thrown on them at runtime. Mirror the platform signature in Websocket.send() and the WebsocketBuffer element default so the library compiles against current DOM types and stops advertising payloads the underlying socket cannot deliver. BREAKING CHANGE: Websocket.send() and the WebsocketBuffer default type parameter no longer accept SharedArrayBuffer(-backed) data. This is a type-level narrowing only; runtime behavior is unchanged.
cross-env-shell 10 does not propagate the child exit status, so "npm run lint" reported success even when eslint found errors and the lint workflow could never go red. eslint expands quoted globs itself, making the wrapper unnecessary; cross-env is removed from devDependencies in the follow-up dependency commit.
TypeScript 6 rejects moduleResolution "node": the base config moves to "bundler" (which also makes the rollup/parseAst paths workaround obsolete) and the CJS build to module/moduleResolution "node16". The emitted JavaScript is byte-identical to the TypeScript 5.9 output. prettier becomes a direct devDependency so its version no longer drifts through the eslint-plugin-prettier peer range. coveralls-next (coverage uploads use the coveralls GitHub Action), ts-node and cross-env are unused and removed, clearing both npm audit findings. The lockfile is rebuilt from scratch.
Three lifecycle defects found in the 3.0.0 release review: - dispatchEvent invoked listeners bare while retry scheduling, socket replacement, backoff reset and buffer draining all run after dispatch, so one throwing listener could permanently disable reconnection. Listener exceptions are now isolated and reported via reportError, falling back to an async rethrow where unavailable. - A successful retry after initial connection failures did not reset the backoff or emit 'reconnect', because the open handler treated a connection as a reconnect only when a previous connection existed. The next outage silently inherited the already-spent retries. The episode now resets on every open that was preceded by a retry. - close() and reconnect() called from inside lifecycle listeners could create duplicate sockets or connect after close(). A connection generation now invalidates in-flight lifecycle work and pending retries once either method commits a new state. BREAKING CHANGE: a manual reconnect() call no longer emits a 'reconnect' event, since no retry preceded the resulting open; observers still get the 'open' event. 'reconnect' now also fires when the first successful connection was preceded by failed attempts, with an undefined lastConnection in its detail.
NaN and Infinity never exhaust and silently turn a bounded reconnect policy into an infinite one, negative values exhaust before any retry, and fractions break the promise that the exhausted-detail retries equal the configured limit. Fail fast in the constructor instead, matching the validation style of the built-in backoffs. Zero remains valid and means no retry is ever made.
Event details already receive defensive Date copies, but the getter still handed out the live internal object, so a caller mutating it corrupted the timestamps reported by later retry, reconnect and exhausted events.
Cover the last untested branch: send() while disconnected without a configured buffer silently drops the message instead of throwing or queueing it, bringing branch coverage to 100%. The dispatch-mutation and url-provider-throw suites bound exactly process.env.PORT, so a run with PORT set collided with the main suite (EADDRINUSE) while the default run stayed green. They now use dedicated env vars like the other integration suites, keeping their defaults.
The repository default branch is renamed from master to main; update the documentation-deploy trigger and the Coveralls badge accordingly.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Prepares the 3.0.0 release of websocket-ts. This branch works off the findings of the 2026-07 code review and modernizes the toolchain.
Breaking changes
exhaustedevent andreconnect()— the websocket now signals when all retries are used up and can be told to try again (4f892c2)AbortSignalsupport for listener removal —addEventListeneraccepts{ signal }; listener removal matches by identity like the DOM (a16c560,7cd977a)send()narrowed tostring | Blob | BufferSource(918c2a0)e7c5574)srcis compiled into the package (846a89a)Fixes
OPEN(c7bf598)binaryTypeacross reconnects (51094ea)377e5a5)67f555b)instantReconnectonly to the first retry of an outage (56cf68b)next()before advancing; validate delays consistently (49d5413,9371ccf)RingQueue: safe forundefinedelements, drop references after read (6d25323)maxRetriesas a non-negative integer; copylastConnectiondates in getters/event detail (41b8189,a452187,885300e)Tooling
56ec4f7,47477a5)npm cieverywhere, lint failures fail the build (24cf08c,9dfe11c)main; badges and docs-deploy trigger updated (25820d7)Test plan