first commit
This commit is contained in:
111
docs/networking/network/network-transport-websockets/README.md
Normal file
111
docs/networking/network/network-transport-websockets/README.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user