Skip to content

Release 3.0.0 - #44

Open
jjxxs wants to merge 38 commits into
mainfrom
release/websocket-ts-3-0-0
Open

Release 3.0.0#44
jjxxs wants to merge 38 commits into
mainfrom
release/websocket-ts-3-0-0

Conversation

@jjxxs

@jjxxs jjxxs commented Jul 11, 2026

Copy link
Copy Markdown
Owner

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

  • exhausted event and reconnect() — the websocket now signals when all retries are used up and can be told to try again (4f892c2)
  • AbortSignal support for listener removaladdEventListener accepts { signal }; listener removal matches by identity like the DOM (a16c560, 7cd977a)
  • send() narrowed to string | Blob | BufferSource (918c2a0)
  • Recovery hardened against listener throws and re-entry — a throwing listener no longer breaks reconnect handling (e7c5574)
  • Dual-package exports map — proper ESM/CJS entry points, only src is compiled into the package (846a89a)

Fixes

  • Stop buffer drain when the socket leaves OPEN (c7bf598)
  • Preserve binaryType across reconnects (51094ea)
  • Keep listener mutations made during event dispatch (377e5a5)
  • Survive URL-provider errors during retry (67f555b)
  • Apply instantReconnect only to the first retry of an outage (56cf68b)
  • Backoff: return the current delay from next() before advancing; validate delays consistently (49d5413, 9371ccf)
  • RingQueue: safe for undefined elements, drop references after read (6d25323)
  • Validate maxRetries as a non-negative integer; copy lastConnection dates in getters/event detail (41b8189, a452187, 885300e)

Tooling

  • TypeScript 6, ESLint 10, Vitest 4, pinned Prettier (56ec4f7, 47477a5)
  • CI: npm ci everywhere, lint failures fail the build (24cf08c, 9dfe11c)
  • Default branch renamed to main; badges and docs-deploy trigger updated (25820d7)

Test plan

  • CI: build, lint, test, and coverage workflows must pass on this PR
  • Full test suite covers the new behavior (dedicated ports per suite, event-driven waits)

jjxxs added 30 commits July 7, 2026 17:02
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.
jjxxs added 8 commits July 9, 2026 12:02
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.
@jjxxs jjxxs added the 3-0-0 label Jul 14, 2026
@jjxxs jjxxs self-assigned this Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant