first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
# @suaveplan/network-transport-websockets - WebSocket client utilities: typed message framing, connection lifecycle, heartbeat, backpressure queue, and injectable transport adapters
## Summary
WebSocket client utilities with typed message framing, connection lifecycle management, heartbeat, backpressure queue, and injectable transport adapters.
<!-- begin:auto-docs -->
## Documentation tree
- [adapters](./src/adapters/native-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [native-adapter](./src/adapters/native-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen error-code registry for the `@suaveplan/network-transport-websockets` package. Centralises every failure classif…
<!-- end:auto-docs --> - [native-adapter](./src/adapters/native-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen error-code registry for the `@suaveplan/network-transport-websockets` package. Centralises every failure classif…
- [native-adapter](./src/adapters/native-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen error-code registry for the `@suaveplan/network-transport-websockets` package. Centralises every failure classif…
- [native-adapter](./src/adapters/native-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen error-code registry for the `@suaveplan/network-transport-websockets` package. Centralises every failure classif…
- [native-adapter](./src/adapters/native-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [client](./src/core/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_ - [heartbeat](./src/core/heartbeat/heartbeat.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue](./src/core/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen error-code registry for the `@suaveplan/network-transport-websockets` package. Centralises every failure classif…
## Purpose
`@suaveplan/network-transport-websockets` provides a robust, enterprise-grade WebSocket client for browser and Node.js environments. It handles connection establishment, automatic reconnection with exponential backoff, heartbeat/ping-pong keepalive, a bounded message queue for backpressure, and channel-based message multiplexing over a single WebSocket connection. All timing is injectable via a `Clock` interface for deterministic testing.
## Features
- **Connection lifecycle** — connect, disconnect, and state tracking (`disconnected` | `connecting` | `connected` | `reconnecting` | `closed`)
- **Automatic reconnect** — configurable exponential backoff with max attempts and capped delay
- **Heartbeat** — configurable ping interval and pong timeout; disconnects on missed pong
- **Message queue** — bounded queue that drops oldest messages when full, with rejections for dropped items
- **Channel multiplexing** — subscribe to named channels and receive typed messages
- **Injectable adapter** — swap the native WebSocket transport for custom implementations (tests, mocks, alternate runtimes)
- **Injectable clock** — drive timers deterministically in tests using `FakeClock`
- **Event bus integration** — emits `websocket:connected` and `websocket:disconnected` events
- **AbortSignal support** — cancel `connect()` and `send()` via `AbortSignal`
- **Structured errors** — all errors use `@suaveplan/error` with stable `WS.*` error codes
## Usage
### Basic Example
```ts
import { createWebSocketClient } from "@suaveplan/network-transport-websockets";
const client = createWebSocketClient({
url: "wss://example.com/socket",
heartbeat: { enabled: true, intervalMs: 30000, timeoutMs: 10000 },
reconnect: { enabled: true, maxAttempts: 5, initialDelayMs: 1000, maxDelayMs: 30000 },
});
await client.connect();
client.subscribe("chat", (msg, meta) => {
console.log(`[${meta.channel}] ${msg}`);
});
await client.send({ type: "chat", channel: "chat", payload: { text: "hello" } });
client.disconnect();
```
### Advanced Example
```ts
import { createWebSocketClient } from "@suaveplan/network-transport-websockets";
const client = createWebSocketClient({
url: "wss://example.com/socket",
clock: fakeClock, // injectable for tests
adapter: customAdapter, // custom WebSocket transport
eventBus: appEventBus, // emit connection events
maxQueueSize: 100,
serialization: "json",
});
client.on("connect", () => console.log("connected"));
client.on("disconnect", ({ code, reason }) => console.log("disconnected", code, reason));
client.on("reconnect", ({ attempt }) => console.log("reconnecting, attempt", attempt));
const ctrl = new AbortController();
await client.connect(ctrl.signal);
// Send with abort support
await client.send({ type: "ping", payload: null }, ctrl.signal);
```
## API Reference
See the per-module documentation in `src/`:
- `src/core/client/` — `createWebSocketClient`, `WebSocketClient`, connection lifecycle
- `src/core/heartbeat/` — `createHeartbeat`, `HeartbeatController`
- `src/core/queue/` — `MessageQueue`, bounded message queue
- `src/adapters/` — `nativeWebSocketAdapter`, `WebSocketAdapter` contract
- `src/types/` — `WebSocketClientOptions`, `WebSocketMessage`, `MessageMeta`, `Clock`
- `src/error-codes.ts` — `WS_CODES` and `WsError`
## Implementation Notes
The client uses private fields for all internal state and exposes a minimal public API. The native adapter wraps the global `WebSocket` with safe `close()` handling for reserved close codes. The heartbeat controller is fully decoupled from the client and can be tested in isolation. All timer-based behavior uses an injectable `Clock` so tests can advance time deterministically without real waits.

View File

@@ -0,0 +1,94 @@
# native-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Default `WebSocketAdapter` implementation that wires the platform's
native `WebSocket` global into the package's injectable adapter
contract. Consumers who don't need a custom transport (test doubles,
proxied connections, instrumented sockets) get a working WebSocket
out of the box without configuration.
The adapter is used by `createWebSocketClient` whenever
`WebSocketClientOptions.adapter` is omitted, and is exported so
consumers can wrap it (e.g. add tracing) before re-injecting.
## Features
- `nativeWebSocketAdapter: WebSocketAdapter` singleton
- `connect(url, protocols?)` → `WebSocketHandle` wrapping
`globalThis.WebSocket`
- Deferred `onclose` dispatch via `queueMicrotask` so handlers
attached after `close()` still see the event (Bun runtime quirk)
- Pass-through of `code` / `reason` from `CloseEvent`
## Usage: Basic Example
```ts
import { nativeWebSocketAdapter, createWebSocketClient }
from "@suaveplan/network-transport-websockets";
const client = createWebSocketClient({
url: "ws://localhost:9001",
adapter: nativeWebSocketAdapter, // optional — this is the default
});
```
## Usage: Advanced Example
```ts
import type { WebSocketAdapter, WebSocketHandle }
from "@suaveplan/network-transport-websockets";
import { nativeWebSocketAdapter, createWebSocketClient }
from "@suaveplan/network-transport-websockets";
const tracingAdapter: WebSocketAdapter = {
connect(url, protocols): WebSocketHandle {
const handle = nativeWebSocketAdapter.connect(url, protocols);
const original = handle.send.bind(handle);
handle.send = (data) => {
console.log("ws-out", data.length);
original(data);
};
return handle;
},
};
const client = createWebSocketClient({
url: "ws://localhost:9001",
adapter: tracingAdapter,
});
```
## API Reference
- `const nativeWebSocketAdapter: WebSocketAdapter` — adapter object
with a single `connect(url, protocols?)` method.
- `connect(url, protocols?)` — returns a `WebSocketHandle`. The
returned handle exposes `readyState`, `send`, `close`, and four
mutable event slots (`onopen` / `onclose` / `onmessage` /
`onerror`). Each slot is `null` until the consumer assigns a
handler.
- `WebSocketHandle.send(data)` — delegates to the platform
`WebSocket.send`; throws synchronously if the socket is not yet
open (consumers should gate on `readyState`).
- `WebSocketHandle.close(code?, reason?)` — delegates to
`WebSocket.close`; the `onclose` callback fires on a microtask so
late-bound consumers see the event.
## Implementation Notes
The microtask deferral for `onclose` is a Bun-runtime workaround:
Bun's `WebSocket.close()` synchronously dispatches `CloseEvent`
before `close()` returns, so a consumer that assigns
`handle.onclose = …` after calling `close()` would miss the event
entirely. Wrapping the dispatch in `queueMicrotask` is portable and
zero-cost on Node and browser runtimes.
The handle is intentionally minimal — no buffering, retry, or
serialisation — those concerns live in `WebSocketClient`.
Implementing a custom adapter only requires returning an object
satisfying `WebSocketHandle`; the client never assumes a particular
underlying socket library.

View File

@@ -0,0 +1,160 @@
# client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebSocketClient` — the single-connection client at the heart of the
package. Wraps an injectable `WebSocketAdapter` (defaulting to the
platform's native `WebSocket`) and adds a typed framing envelope,
heartbeat / pong loop, exponential-backoff reconnect, bounded
backpressure queue, channel-based subscriptions, and observable
connection events. Consumers that need a one-to-one connection
(signalling channels, telemetry uplinks, fixed-endpoint RPC) use this
directly; consumers needing a multi-peer transport compose it through
`WebSocketTransport`.
The client surfaces a `Promise`-based `connect` / `send`, a
`subscribe(channel, handler)` for inbound channel-routed messages, and
an `on(event, handler)` for raw lifecycle events. Every async path
honours an optional `AbortSignal`.
## Features
- `createWebSocketClient(options)` factory + `WebSocketClient`
interface
- Connect lifecycle: `disconnected` → `connecting` → `connected` →
(`reconnecting` →) `disconnected` / `closed`
- Auto-reconnect with exponential backoff (configurable
`initialDelayMs`, `maxDelayMs`, `maxAttempts`)
- Heartbeat with ping / pong timeout (configurable `intervalMs`,
`timeoutMs`)
- Bounded outbound backpressure queue with `WS.QUEUE_FULL` overflow
- Channel-based subscriptions (`subscribe(channel, handler)`)
- Raw event subscriptions (`on("connect"/"disconnect"/"reconnect"/
"error"/"message", handler)`)
- Connect timeout with `WS.TIMEOUT`
- AbortSignal threaded through `connect` and `send`
- Optional `EventBusLike` emission for cross-component observability
- Injectable `Clock`, `WebSocketAdapter`, `LoggerLike`
- `defaultClock` export for production use
## Usage: Basic Example
```ts
import { createWebSocketClient }
from "@suaveplan/network-transport-websockets";
const client = createWebSocketClient({ url: "ws://localhost:9001" });
await client.connect();
const off = client.subscribe<{ price: number }>("ticker", (msg) => {
console.log("price", msg.price);
});
await client.send({ type: "ping", payload: null });
off();
client.disconnect();
```
## Usage: Advanced Example
```ts
import { FakeClock } from "@suaveplan/testing";
import { createWebSocketClient, type WebSocketAdapter }
from "@suaveplan/network-transport-websockets";
const clock = new FakeClock();
const adapter: WebSocketAdapter = mySpyAdapter;
const client = createWebSocketClient({
url: "ws://node-a:9001",
adapter,
clock,
serialization: "json",
connectTimeoutMs: 5_000,
maxQueueSize: 500,
reconnect: {
enabled: true,
maxAttempts: 5,
initialDelayMs: 1_000,
maxDelayMs: 30_000,
},
heartbeat: {
enabled: true,
intervalMs: 15_000,
timeoutMs: 5_000,
},
});
const offEvent = client.on("reconnect", (data) => {
console.warn("reconnect attempt", data);
});
const ac = new AbortController();
await client.connect(ac.signal);
await client.send(
{ type: "rpc", id: "r1", payload: { method: "echo", args: ["hi"] } },
ac.signal,
);
offEvent();
client.disconnect(1000, "shutdown");
```
## API Reference
- `createWebSocketClient(options: WebSocketClientOptions):
WebSocketClient` — factory.
- `defaultClock: Clock` — production-grade `Date.now` / `setInterval`
/ `setTimeout`-backed clock. Used when `options.clock` is omitted.
- `interface WebSocketClient`
- `id: string` — unique per-client identifier (`ws-client-<n>`)
- `url: string`
- `state: ConnectionState`
- `connect(signal?): Promise<void>` — opens the connection; idempotent
while connecting / connected; throws `WS.CONNECTION_FAILED` /
`WS.TIMEOUT` / `WS.ABORT` on failure
- `disconnect(code?, reason?): void` — closes the socket, cancels
reconnect/heartbeat, rejects every queued message with
`WS.ABORT`
- `send(message, signal?): Promise<void>` — sends or queues; throws
`WS.SEND_FAILED` (not connected & reconnect disabled) /
`WS.ABORT` (signal aborted) / `WS.PROTOCOL_ERROR` (msgpack
requested — not implemented)
- `subscribe<T>(channel, handler): () => void` — registers a
channel handler; returns an unsubscribe function
- `on(event, handler): () => void` — registers a lifecycle-event
handler
## Implementation Notes
The client treats `closed` as a terminal state — after the reconnect
budget is exhausted (`maxAttempts` reached), the state goes to
`closed` and every subsequent `connect()` throws synchronously. This
is the right contract: if the consumer wants to retry past the
budget, they instantiate a new client.
Send-while-disconnected behaviour depends on `reconnect.enabled`: when
enabled, the message is appended to the bounded queue and flushed on
reconnect; when disabled, the send fails fast with `WS.SEND_FAILED`.
This split matches the user mental model — a one-shot RPC should fail
immediately if the connection is gone; a long-lived telemetry uplink
should buffer.
Reconnect uses pure exponential backoff (`initialDelay * 2^(n-1)`
capped at `maxDelay`) without jitter. Consumers that need
decorrelated jitter (to avoid thundering herd against a single
server) can wrap the adapter to randomise reconnect timing externally.
Heartbeat is bidirectional in spirit — the client emits pings but
does not interpret pong frames automatically; the consumer is
expected to subscribe to the application-level pong message and
return liveness through their own flow. The heartbeat timeout fires
unconditionally on the configured deadline, prompting a disconnect
with code 1001.
Message handlers (both `subscribe` and `on`) are invoked in
registration order and their throws are caught + logged via the
injected `LoggerLike` so one bad handler does not break the chain.

View File

@@ -0,0 +1,98 @@
# heartbeat
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Heartbeat controller for the WebSocket client — periodically sends a
ping frame and triggers a timeout callback if the matching pong does
not arrive within the configured window. Used by `WebSocketClient` to
detect silently-dead connections (half-open sockets) that the
underlying transport cannot surface on its own.
The controller is decoupled from the wire protocol — it issues
ping / pong via injected callbacks — so the same machinery works
whether the application speaks RFC 6455 control frames, a custom JSON
keepalive, or an upper-layer protocol's keepalive.
## Features
- `createHeartbeat(intervalMs, timeoutMs, clock)` factory
- `start(sendPing, onTimeout)` — begins the loop
- `stop()` — cancels every timer and resets pending state
- `pongReceived()` — cancels the pending timeout when a pong arrives
- Injectable `Clock` so tests can advance time deterministically with
`FakeClock`
- Idempotent restart — `start()` clears any prior timers
## Usage: Basic Example
```ts
import { createHeartbeat, defaultClock }
from "@suaveplan/network-transport-websockets";
const hb = createHeartbeat(30_000, 10_000, defaultClock);
hb.start(
() => client.send({ type: "ping", payload: null }),
() => client.disconnect(1001, "heartbeat-timeout"),
);
```
## Usage: Advanced Example
```ts
import { FakeClock } from "@suaveplan/testing";
import { createHeartbeat } from "@suaveplan/network-transport-websockets";
const clock = new FakeClock();
const sent: number[] = [];
const hb = createHeartbeat(1_000, 500, clock);
hb.start(
() => sent.push(clock.now()),
() => {
throw new Error("connection assumed dead");
},
);
clock.advance(1_000); // pings once
hb.pongReceived(); // cancels the timeout
clock.advance(1_000); // pings again
clock.advance(500); // would-have-timeout, but...
hb.pongReceived(); // ...consumer beats it
hb.stop();
```
## API Reference
- `createHeartbeat(intervalMs, timeoutMs, clock): HeartbeatController`
- `HeartbeatController.start(sendPing, onTimeout): void` — sets up
the periodic ping timer (`intervalMs`) plus a one-shot timeout
timer (`timeoutMs`) that fires `onTimeout` if `pongReceived()` is
not called in time.
- `HeartbeatController.stop(): void` — clears every timer and
resets pending state. Safe to call on an already-stopped
controller.
- `HeartbeatController.pongReceived(): void` — clears the pending
timeout timer (the next ping interval starts a new one).
## Implementation Notes
The controller maintains a single pending-timeout timer rather than a
sliding window of outstanding pings — if the consumer is sending
faster than the server is responding, only the most recent ping's
timeout is tracked. This is the right behaviour for a keepalive: any
late pong satisfies liveness, and a wholly-silent peer is detected
within at most `intervalMs + timeoutMs`.
The `_pending` flag is recorded for observability but is not exposed
on the public surface — consumers needing per-ping correlation should
implement their own request/response tracker on top of
`WebSocketClient.subscribe`.
The controller never owns the wire protocol — `sendPing` is injected
so applications can choose any ping payload (RFC 6455 ping frame
through a custom adapter, an application-level `{ type: "ping" }`
message, etc.) without re-implementing the timer machinery.

View File

@@ -0,0 +1,100 @@
# queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bounded FIFO backpressure queue for outbound WebSocket messages while
the underlying connection is not yet open (initial connect, reconnect,
or transient disconnect). Holds pending `WebSocketMessage` envelopes
plus their associated `resolve` / `reject` callbacks so the caller's
`await client.send(...)` semantics remain coherent: each enqueued
message resolves exactly once when flushed to the wire or rejects
exactly once on overflow / clear.
The queue is owned by `WebSocketClient` and is intentionally simple —
no priority, no deduplication, no persistence. Applications needing
those behaviours layer them on top.
## Features
- Fixed `maxSize` bound at construction
- `enqueue(item)` — appends; on overflow drops the oldest item and
rejects it with `WS.QUEUE_FULL`
- `dequeueAll()` — atomic drain (used by client when the connection
reopens)
- `clear(reason)` — rejects every pending item with `WS.ABORT` and
empties the queue (used on hard disconnect)
- `size` getter for observability
## Usage: Basic Example
```ts
import { MessageQueue }
from "@suaveplan/network-transport-websockets";
const queue = new MessageQueue(1000);
queue.enqueue({
message: { type: "tick", payload: 1 },
timestamp: Date.now(),
resolve: () => console.log("flushed"),
reject: (err) => console.error("rejected", err),
});
console.log(queue.size); // 1
```
## Usage: Advanced Example
```ts
import { MessageQueue }
from "@suaveplan/network-transport-websockets";
const queue = new MessageQueue(2);
const drops: string[] = [];
const wrap = (id: string) => ({
message: { type: "ev", payload: id },
timestamp: Date.now(),
resolve: () => {},
reject: (err: Error) => drops.push(`${id}: ${err.message}`),
});
queue.enqueue(wrap("a"));
queue.enqueue(wrap("b"));
queue.enqueue(wrap("c")); // drops "a" with WS.QUEUE_FULL
console.log(drops); // ["a: Message dropped: queue overflow..."]
const flushed = queue.dequeueAll();
console.log(flushed.length); // 2 — b, c
queue.clear("client-disconnect");
```
## API Reference
- `class MessageQueue` — constructor `(maxSize: number)`
- `size: number` — number of currently queued items
- `enqueue(item: QueuedMessage): void` — appends; if `size >=
maxSize`, shifts the oldest item and rejects it with
`WS.QUEUE_FULL`
- `dequeueAll(): QueuedMessage[]` — returns a fresh array containing
every item and resets internal storage to empty
- `clear(reason: string): void` — rejects every pending item with
`WS.ABORT` carrying the supplied reason, then empties the queue
- `interface QueuedMessage` — `{ message: WebSocketMessage;
timestamp: number; resolve: () => void; reject: (Error) => void }`
## Implementation Notes
The overflow policy is "drop-oldest" rather than "drop-newest" or
"reject-new" because the typical use case (telemetry, presence
updates, chat) treats the most recent message as more valuable than
the oldest. Applications that need a different policy can wrap the
queue (e.g. a priority queue around `enqueue` that pre-shifts a
lower-priority slot before delegating).
Rejection paths always synthesise a `WsError` with a stable code so
consumers can distinguish queue-overflow from connection-failure
without parsing strings. `dequeueAll` returns a fresh array and
resets internal length in one shot — the caller is responsible for
calling `resolve`/`reject` on each returned item exactly once after
processing.

View File

@@ -0,0 +1,85 @@
# error-codes
## Purpose
Frozen error-code registry for the
`@suaveplan/network-transport-websockets` package. Centralises every
failure classification under a single `WS.*` namespace so consumers
can match on `WsError.code` instead of parsing message strings, and so
external observability pipelines can label metrics by stable codes
that never change shape.
The registry is consumed throughout the package — by the
`WebSocketClient` for connect / send / queue / heartbeat failures, by
the multi-peer `WebSocketTransport` for peer-registry invariants, and
by the conformance test suite that asserts the registry is frozen
and namespaced.
## Features
- `WS_CODES` — frozen `defineCodes("WS", [...])` registry
- `WsError` — `SuaveplanError<WsErrorCode>` subclass with public
constructor (widens the protected base constructor)
- 10 codes covering connect / send / queue / protocol / timeout /
abort failures + 4 transport-level peer-registry invariants
- Stable code values prefixed `WS.` for cross-process matching
## Usage: Basic Example
```ts
import { WS_CODES, WsError }
from "@suaveplan/network-transport-websockets";
throw new WsError(WS_CODES.CONNECTION_FAILED, "ws.connect-failed");
```
## Usage: Advanced Example
```ts
import { WS_CODES, WsError }
from "@suaveplan/network-transport-websockets";
try {
await transport.connect("peer-1", "ws://example.invalid");
} catch (err) {
if (err instanceof WsError) {
switch (err.code) {
case WS_CODES.PEER_ALREADY_CONNECTED:
console.warn("dedup: already connected to peer-1");
break;
case WS_CODES.MAX_PEERS_EXCEEDED:
console.error("capacity: drop peer or raise maxPeers");
break;
case WS_CODES.CONNECTION_FAILED:
metrics.increment("ws.connect.failure", {
cause: err.cause?.message ?? "unknown",
});
break;
}
}
}
```
## API Reference
- `WS_CODES` — readonly frozen object `{ <NAME>: "WS.<NAME>" }` with
all 10 codes:
- `CONNECTION_FAILED`, `SEND_FAILED`, `QUEUE_FULL`,
`PROTOCOL_ERROR`, `TIMEOUT`, `ABORT` (client-level)
- `PEER_ALREADY_CONNECTED`, `PEER_UNKNOWN`, `TRANSPORT_CLOSED`,
`MAX_PEERS_EXCEEDED` (transport-level)
- `WsErrorCode` — TypeScript union of every code value
- `class WsError extends SuaveplanError<WsErrorCode>` — constructor
`(code, message, options?)`. `options` accepts `cause` and `context`
per `@suaveplan/error`.
## Implementation Notes
The registry uses `defineCodes` so every code value is automatically
prefixed with the `WS.` namespace at compile time — consumers should
never assemble these strings manually. The constructor is widened
(via `noUselessConstructor` suppression on the legacy migration) only
to expose the protected base constructor as public — no extra
behaviour. Codes are append-only by convention: removing or renaming
a code is a breaking change for downstream consumers that pattern-
match on the literal string.

View File

@@ -0,0 +1,93 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation surface for `WebSocketTransport` and
`WebSocketPlugin`. Centralises the tracer + meter so the rest of the
package does not import `@opentelemetry/api` directly and so every
span / counter / gauge is named consistently against the
`@suaveplan/network-transport-websockets` instrumentation library.
This module wraps three exports: `withTransportSpan` (active-span
wrapper for `ws_transport.connect` / `disconnect` / `send`), three
recorder helpers (`recordPeerConnect`, `recordMessage`,
`adjustActivePeers`), and indirectly the tracer + meter handles used
by the transport. It is internal-by-convention — consumers do not call
it directly; they observe the resulting telemetry through their OTel
SDK pipeline.
## Features
- Tracer + meter scoped to `@suaveplan/network-transport-websockets`
- `withTransportSpan(name, fn)` active-span wrapper for async ops
- `peers_connected{outcome}` counter
- `messages_total{direction,outcome}` counter
- `active_peers` up/down counter (used as a gauge)
- Zero overhead when no SDK is registered (Noop tracer + meter)
## Usage: Basic Example
```ts
import { withTransportSpan, recordPeerConnect, adjustActivePeers }
from "@suaveplan/network-transport-websockets/otel";
await withTransportSpan("ws_transport.connect", async () => {
// ... open the underlying WebSocketClient
recordPeerConnect("ok");
adjustActivePeers(+1);
});
```
## Usage: Advanced Example
```ts
import {
withTransportSpan,
recordMessage,
recordPeerConnect,
} from "@suaveplan/network-transport-websockets/otel";
async function sendEnvelope(peerId: string, envelope: unknown) {
return withTransportSpan("ws_transport.send", async (span) => {
span.setAttribute("peer.id", peerId);
try {
await transmit(envelope);
recordMessage("outbound", "ok");
} catch (err) {
recordMessage("outbound", "error");
recordPeerConnect("error");
throw err;
}
});
}
```
## API Reference
- `withTransportSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T>` —
starts an active span, awaits `fn`, ends the span unconditionally.
- `recordPeerConnect(outcome: "ok" | "error"): void` — increments the
`ws_transport.peers_connected` counter labelled by outcome.
- `recordMessage(direction: "inbound" | "outbound", outcome: "ok" | "error"): void` —
increments the `ws_transport.messages_total` counter labelled by
direction and outcome.
- `adjustActivePeers(delta: number): void` — adjusts the
`ws_transport.active_peers` up/down counter. Pass `+1` on connect,
`-1` on disconnect, and a negative count equal to the live peer
count on `close()`.
## Implementation Notes
The active-peer instrument is modelled as an `UpDownCounter` rather
than an `ObservableGauge` so the transport does not have to
register a callback that races with `close()`. The trade-off is the
caller has to keep delta arithmetic balanced — `close()` must emit a
matching `-N` for the `+N` accumulated during the transport's
lifetime. The transport enforces this by adjusting in only three
places: `connect` (`+1` on success), `disconnect` (`-1` if a peer was
removed), and `close` (`-currentPeerCount`). The `messages_total`
counter is labelled by direction so dashboards can split
inbound/outbound throughput without parsing span names.

View File

@@ -0,0 +1,94 @@
# WebSocketPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NetworkPlugin` implementation that registers a `WebSocketTransport`
with a host `NetworkNode`. Provides the canonical install / start /
stop / dispose lifecycle expected by the `@suaveplan/network` plugin
manager. Mirrors the shape of
`@suaveplan/network-transport-webrtc`'s `WebRTCPlugin` so the two
transports can be plugged in side-by-side under
`network-plugin-transport-manager`.
The plugin itself holds no networking state — it is a thin wrapper
that hands its inner `WebSocketTransport` to the node on install,
then closes + unregisters the transport on stop. Connections are
opened lazily by callers through the transport API.
## Features
- `NetworkPlugin` contract: `name`, `install`, `start`, `stop`,
`dispose`
- Exposes the inner `transport` for direct consumer access
- Idempotent `dispose` — releases the node reference exactly once
- Lifecycle independent of peer state — `start()` is a no-op so the
surrounding node can `start()` cheaply
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { WebSocketPlugin } from
"@suaveplan/network-transport-websockets/plugin";
const node = new NetworkNode();
node.use(new WebSocketPlugin());
await node.start();
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { WebSocketPlugin } from
"@suaveplan/network-transport-websockets/plugin";
const plugin = new WebSocketPlugin({
maxPeers: 16,
clientDefaults: {
adapter: myInjectableAdapter,
heartbeat: { enabled: true },
},
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
await plugin.transport.connect("peer-1", "ws://node-a:9001");
await plugin.transport.connect("peer-2", "ws://node-b:9001");
await node.stop(); // closes the transport + unregisters it
plugin.dispose(); // idempotent
```
## API Reference
- `new WebSocketPlugin(config?: WebSocketTransportConfig)` — creates
the plugin and its inner `WebSocketTransport`.
- `name: "websockets"` — canonical plugin identifier.
- `transport: WebSocketTransport` — the inner transport, exposed so
consumers can `connect` / `disconnect` peers without going through
the router.
- `install(node)` — records the host node and calls
`node.registerTransport(this.transport)`.
- `start()` — no-op (lazy connect).
- `stop()` — `await this.transport.close()`, then
`node.unregisterTransport("websockets")`. Safe to call when no
node was installed.
- `dispose()` — releases the host-node reference. Idempotent.
## Implementation Notes
The plugin keeps the host `NetworkNode` reference private so the
transport can be re-registered cleanly after `stop()` — calling
`install()` a second time will overwrite the reference and re-add the
transport. `dispose()` flips an internal flag and clears the
reference; a second `dispose()` returns immediately. Because the
transport is created in the constructor (not on `install`), tests can
inspect `plugin.transport` directly before any node is involved —
useful for asserting `transport.id === "websockets"` in conformance
suites.

View File

@@ -0,0 +1,126 @@
# WebSocketTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Peer-oriented WebSocket transport implementing the
`@suaveplan/network` `Transport` contract. Composes per-peer
`WebSocketClient` instances behind a single multi-peer registry so
the host `NetworkNode` can fan messages in and out across an
arbitrary number of concurrent WebSocket connections.
The underlying `WebSocketClient` continues to own per-peer typed
framing, heartbeat, reconnect and backpressure-queue behaviour; the
transport adds peer-keyed lifecycle, message fan-in, and the
`getConnectedPeers()` / `close()` semantics required by the
`network-plugin-transport-manager`. Existing consumers that wired the
package as a single-client API can keep doing so via the `./client`
subpath — this transport is additive.
## Features
- `Transport` contract: `connect`, `disconnect`, `send`, `onMessage`,
`onPeerEvent`, `getConnectedPeers`, `close`
- Per-peer `WebSocketClient` composition with shared
`clientDefaults`
- Optional `maxPeers` cap with `WS.MAX_PEERS_EXCEEDED` enforcement
- Duplicate `peerId` rejection (`WS.PEER_ALREADY_CONNECTED`)
- AbortSignal threaded through `connect`
- OTel spans (`ws_transport.connect|disconnect|send`) +
`peers_connected{outcome}` / `messages_total{direction,outcome}`
counters + `active_peers` gauge
## Usage: Basic Example
```ts
import { WebSocketTransport } from
"@suaveplan/network-transport-websockets/transport";
const transport = new WebSocketTransport();
await transport.connect("peer-1", "ws://localhost:9001");
await transport.send("peer-1", {
id: "m1",
type: "ping",
senderId: "self",
payload: { ok: true },
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
});
```
## Usage: Advanced Example
```ts
import { WebSocketTransport } from
"@suaveplan/network-transport-websockets/transport";
const transport = new WebSocketTransport({
maxPeers: 32,
clientDefaults: {
reconnect: { enabled: true, maxAttempts: 5 },
heartbeat: { enabled: true, intervalMs: 15_000 },
adapter: myInjectableAdapter,
},
});
const offMsg = transport.onMessage((peerId, envelope) => {
console.log("inbound", peerId, envelope.type);
});
const offEvt = transport.onPeerEvent((peerId, ev) => {
console.log("peer", peerId, ev);
});
const ac = new AbortController();
await transport.connect("peer-1", "ws://node-a:9001", ac.signal);
offMsg();
offEvt();
await transport.close();
```
## API Reference
- `new WebSocketTransport(config?: WebSocketTransportConfig)` — creates
an empty transport. `maxPeers` defaults to `Infinity`;
`clientDefaults` are merged into every per-peer
`WebSocketClientOptions` (with the `address` argument supplying
`url`).
- `connect(peerId, address, signal?)` — opens a new per-peer client.
Throws `WS.PEER_ALREADY_CONNECTED` on duplicate id,
`WS.MAX_PEERS_EXCEEDED` past the cap, `WS.TRANSPORT_CLOSED` after
`close()`, `WS.ABORT` on pre-aborted signal, and wraps any
underlying connect failure as `WS.CONNECTION_FAILED` (with `cause`).
- `disconnect(peerId)` — closes the per-peer client, removes it from
the registry and fires `onPeerEvent(peerId, "disconnected")`. No-op
for unknown peers.
- `send(peerId, envelope)` — wraps the envelope as a
`WebSocketMessage` (`type`/`id`/`payload: envelope`) and dispatches
through the per-peer client. Throws `WS.PEER_UNKNOWN`,
`WS.TRANSPORT_CLOSED`, or `WS.SEND_FAILED` (with `cause`).
- `onMessage(handler)` / `onPeerEvent(handler)` — fan-in registration;
returns an unsubscribe function.
- `getConnectedPeers()` — snapshot of peers whose per-peer client is
currently in `"connected"` state.
- `close()` — idempotent. Removes every peer (firing `disconnected`)
and clears handler registries. After close, every `connect` /
`send` throws `WS.TRANSPORT_CLOSED`.
## Implementation Notes
The transport owns its peers by id, not by address — connecting twice
to the same peer is a programmer error and is rejected, mirroring the
WebRTC transport's invariant. Address strings, by contrast, may repeat
freely (multiple peers behind the same gateway). Each per-peer client
is wired with `on("message")` and `on("disconnect")` listeners; the
`disconnect` listener auto-prunes the peer so reconnect failures or
remote-initiated closes do not leak slots. Inbound envelopes are
extracted from the existing client's `WebSocketMessage.payload` field
without re-serialising; the assumption is that the remote end speaks
the same wire shape (i.e. another `WebSocketTransport`). The OTel
`active_peers` instrument is an `UpDownCounter` so accounting stays
balanced across the three mutation paths (`connect` `+1`, every
`peers.delete` `-1`).

View File

@@ -0,0 +1,132 @@
# types/common
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared structural types for the
`@suaveplan/network-transport-websockets` package: the abstract
`WebSocketAdapter` / `WebSocketHandle` boundary, the injectable
`Clock` primitive, the typed `WebSocketMessage` envelope, the
configuration shapes consumed by both `WebSocketClient` and
`WebSocketTransport`, and connection-state / event enums.
Centralising these in one module keeps the type surface coherent —
downstream consumers import once from `@suaveplan/network-transport-
websockets/types` to assemble their own clients, mocks, or adapters
without reaching into private modules.
## Features
- `Clock` — injectable wall-clock + timer scheduler for tests
- `WebSocketAdapter` + `WebSocketHandle` — pluggable transport
boundary
- `WebSocketMessage` — typed framing envelope (`type`, `payload`,
optional `channel` / `id` / `correlationId`)
- `WebSocketClientOptions` — full client configuration with
`reconnect`, `heartbeat`, `maxQueueSize`, `serialization`,
`logger`, `clock`, `eventBus`, `adapter`
- `WebSocketTransportConfig` — multi-peer transport configuration
(`maxPeers`, `clientDefaults`)
- `ConnectionState` / `ConnectionEvent` enums
## Usage: Basic Example
```ts
import type {
WebSocketClientOptions,
WebSocketMessage,
} from "@suaveplan/network-transport-websockets/types";
const opts: WebSocketClientOptions = {
url: "ws://localhost:9001",
};
const message: WebSocketMessage = {
type: "ping",
payload: { ts: Date.now() },
};
```
## Usage: Advanced Example
```ts
import type {
Clock,
WebSocketAdapter,
WebSocketHandle,
WebSocketTransportConfig,
} from "@suaveplan/network-transport-websockets/types";
const fakeClock: Clock = {
now: () => 1_700_000_000_000,
setInterval: () => 0,
clearInterval: () => {},
setTimeout: () => 0,
clearTimeout: () => {},
};
const fakeAdapter: WebSocketAdapter = {
connect(): WebSocketHandle {
return {
readyState: 0,
send() {},
close() {},
onopen: null,
onclose: null,
onmessage: null,
onerror: null,
};
},
};
const config: WebSocketTransportConfig = {
maxPeers: 64,
clientDefaults: {
clock: fakeClock,
adapter: fakeAdapter,
heartbeat: { enabled: true, intervalMs: 15_000 },
},
};
```
## API Reference
- `Clock` — `{ now(): number; setInterval(cb, ms): number;
clearInterval(id): void; setTimeout(cb, ms): number;
clearTimeout(id): void }`. Tests inject `FakeClock` from
`@suaveplan/testing`.
- `ConnectionState` — `"disconnected" | "connecting" | "connected" |
"reconnecting" | "closed"`.
- `ConnectionEvent` — `"connect" | "disconnect" | "reconnect" |
"error" | "message"`.
- `MessageHandler<T>` — `(message: T, meta: MessageMeta) => void`.
**Scope-local to subscriptions**; not the same as the transport's
inbound-message handler.
- `WebSocketMessage` — `{ channel?, type, payload, id?,
correlationId? }`.
- `MessageMeta` — `{ receivedAt, channel?, type }`.
- `WebSocketClientOptions` — full configuration for
`createWebSocketClient`.
- `ReconnectOptions` / `HeartbeatOptions` — sub-bundles.
- `WebSocketAdapter` — `{ connect(url, protocols?): WebSocketHandle }`.
- `WebSocketHandle` — mutable event slots + `send` / `close` /
`readyState`.
- `WebSocketTransportConfig` — `{ maxPeers?, clientDefaults? }`
consumed by `WebSocketTransport`.
## Implementation Notes
The `MessageHandler` exported from this module is the client-level
subscription handler (`(message, meta) => void`); the multi-peer
`WebSocketTransport` uses its own internal `TransportMessageHandler`
type with the signature `(peerId, envelope) => void` to avoid name
collision at the public surface. Consumers writing custom transports
should standardise on the `@suaveplan/network` `Transport` interface's
handler shape directly.
`WebSocketTransportConfig.clientDefaults` omits `url` because each
per-peer client receives its URL from the `address` argument passed to
`Transport.connect(peerId, address)` — supplying a URL at config time
would be wrong by construction.