Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
# @suaveplan/network-transport-webrtc - WebRTC transport plugin for mesh networking: RTCPeerConnection data-channel transport with injectable constructors
## Summary
WebRTC transport plugin for mesh networking.
<!-- begin:auto-docs -->
## Documentation tree
- [data-channel](./src/data-channel/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [data-channel-manager](./src/data-channel/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ice](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ice-manager](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webrtc-mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ping](./src/ping/ping-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ping-manager](./src/ping/ping-manager.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 -->
- [signaling-client](./src/signaling/signaling-client.md) — Defines the structural contract for any signaling channel used by the WebRTC plugin to exchange offers, answers, and IC…
- [websocket-signaling-client](./src/signaling/websocket-signaling-client.md) — Concrete `SignalingClient` implementation backed by a WebSocket. Handles the boring-but-load-bearing pieces of any sign…
- [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/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-transport-webrtc` package. All runtime failures with…
<!-- end:auto-docs --> - [data-channel-manager](./src/data-channel/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice-manager](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [ping](./src/ping/ping-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ping-manager](./src/ping/ping-manager.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)_- [signaling-client](./src/signaling/signaling-client.md) — Defines the structural contract for any signaling channel used by the WebRTC plugin to exchange offers, answers, and IC…
- [websocket-signaling-client](./src/signaling/websocket-signaling-client.md) — Concrete `SignalingClient` implementation backed by a WebSocket. Handles the boring-but-load-bearing pieces of any sign…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-transport-webrtc` package. All runtime failures with…
- [data-channel-manager](./src/data-channel/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice-manager](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [ping](./src/ping/ping-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ping-manager](./src/ping/ping-manager.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)_- [signaling-client](./src/signaling/signaling-client.md) — Defines the structural contract for any signaling channel used by the WebRTC plugin to exchange offers, answers, and IC…
- [websocket-signaling-client](./src/signaling/websocket-signaling-client.md) — Concrete `SignalingClient` implementation backed by a WebSocket. Handles the boring-but-load-bearing pieces of any sign…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-transport-webrtc` package. All runtime failures with…
- [data-channel-manager](./src/data-channel/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice-manager](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [ping](./src/ping/ping-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ping-manager](./src/ping/ping-manager.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)_- [signaling-client](./src/signaling/signaling-client.md) — Defines the structural contract for any signaling channel used by the WebRTC plugin to exchange offers, answers, and IC…
- [websocket-signaling-client](./src/signaling/websocket-signaling-client.md) — Concrete `SignalingClient` implementation backed by a WebSocket. Handles the boring-but-load-bearing pieces of any sign…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-transport-webrtc` package. All runtime failures with…
- [data-channel-manager](./src/data-channel/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ice-manager](./src/ice/ice-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ - [webrtc-mesh](./src/mesh/webrtc-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [ping](./src/ping/ping-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ping-manager](./src/ping/ping-manager.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)_- [signaling-client](./src/signaling/signaling-client.md) — Defines the structural contract for any signaling channel used by the WebRTC plugin to exchange offers, answers, and IC…
- [websocket-signaling-client](./src/signaling/websocket-signaling-client.md) — Concrete `SignalingClient` implementation backed by a WebSocket. Handles the boring-but-load-bearing pieces of any sign…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-transport-webrtc` package. All runtime failures with…
## Purpose
Provides a WebRTC data-channel transport implementation for the `@suaveplan/network` mesh system. This plugin enables peer-to-peer messaging without requiring a central relay server for data. The transport implements the universal `Transport` interface and is fully mockable for Node.js testing.
## Features
- **WebRTCTransport** — full `Transport` interface via `RTCPeerConnection` + `RTCDataChannel`
- **Injectable factories** — mockable `PeerConnectionLike` for unit testing
- **WebRTCPlugin** — `NetworkPlugin` integration with lifecycle management
- **JSON serialization** — uses `@suaveplan/serialize` for envelope encoding
- **Peer events** — automatic connected/disconnected notifications
## Usage
### Basic Example
```ts
import { WebRTCPlugin } from "@suaveplan/network-transport-webrtc";
const plugin = new WebRTCPlugin();
node.use(plugin);
await node.start();
await plugin.transport.connect("peer-1", "signal-address");
```
### Advanced Example
```ts
import { WebRTCPlugin } from "@suaveplan/network-transport-webrtc";
const plugin = new WebRTCPlugin({
dataChannelLabel: "mesh",
});
node.use(plugin);
plugin.transport.onMessage((peerId, envelope) => {
console.log("From", peerId, envelope);
});
```
## API Reference
See per-module documentation in `src/`:
- `src/transport/` — `WebRTCTransport`
- `src/plugin/` — `WebRTCPlugin`
- `src/types/` — mockable interfaces and configuration types
- `src/error-codes.ts` — `NetworkWebRTCError`, `NETWORK_WEBRTC_CODES`
## Implementation Notes
The transport is designed for testability. In a browser environment, you provide a factory that creates real `RTCPeerConnection` instances. In Node.js tests, you provide mock implementations of `PeerConnectionLike` and `DataChannelLike`. This decouples the transport logic from the browser WebRTC stack, making the package suitable for universal use despite WebRTC being a browser API.

View File

@@ -0,0 +1,59 @@
{
"name": "@suaveplan/network-transport-webrtc",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "WebRTC transport plugin for mesh networking: RTCPeerConnection data-channel transport with injectable constructors",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./transport": {
"import": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" },
"require": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" }
},
"./plugin": {
"import": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" },
"require": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/serialize": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type BackpressureDataChannel,
DataChannelManager,
} from "./data-channel-manager.js";
function makeChannel(
opts: { bufferedAmount?: number; readyState?: string } = {}
): BackpressureDataChannel & {
fireMessage(data: string | ArrayBufferLike): void;
sentBytes: number;
} {
const sent: number[] = [];
let onmessage:
| ((
this: BackpressureDataChannel,
ev: { data: string | ArrayBufferLike }
) => unknown)
| null = null;
return {
readyState: opts.readyState ?? "open",
bufferedAmount: opts.bufferedAmount ?? 0,
get onmessage() {
return onmessage;
},
set onmessage(fn) {
onmessage = fn;
},
onopen: null,
onclose: null,
onerror: null,
send(data: string | ArrayBufferLike): void {
sent.push(typeof data === "string" ? data.length : data.byteLength);
},
close(): void {},
fireMessage(data: string | ArrayBufferLike): void {
onmessage?.call(this, { data });
},
get sentBytes(): number {
return sent.reduce((a, b) => a + b, 0);
},
} as never;
}
describe("DataChannelManager backpressure (NET-04 / send pipeline)", () => {
it("sends when channel is healthy", () => {
const mgr = new DataChannelManager();
const ch = makeChannel();
mgr.register("alice", ch);
mgr.send("alice", new Uint8Array([1, 2, 3]));
expect(ch.sentBytes).toBe(3);
});
it("rejects send when peer is not registered", () => {
const mgr = new DataChannelManager();
expect(() => mgr.send("nope", new Uint8Array())).toThrow(
"data-channel.peer-not-connected"
);
});
it("rejects send when channel is not open", () => {
const mgr = new DataChannelManager();
const ch = makeChannel({ readyState: "closing" });
mgr.register("alice", ch);
expect(() => mgr.send("alice", new Uint8Array())).toThrow(
"data-channel.closed"
);
});
it("rejects send when bufferedAmount exceeds the high-water mark", () => {
const mgr = new DataChannelManager({
bufferedAmountHighWaterMark: 100,
});
const ch = makeChannel({ bufferedAmount: 200 });
mgr.register("alice", ch);
expect(() => mgr.send("alice", new Uint8Array(10))).toThrow(
"data-channel.backpressure"
);
});
it("delivers Uint8Array payloads through onMessage", () => {
const mgr = new DataChannelManager();
const ch = makeChannel();
mgr.register("alice", ch);
const seen: { peerId: string; bytes: Uint8Array }[] = [];
mgr.onMessage((peerId, bytes) => seen.push({ peerId, bytes }));
ch.fireMessage(new Uint8Array([7, 8, 9]).buffer);
expect(seen).toHaveLength(1);
expect(Array.from(seen[0]!.bytes)).toEqual([7, 8, 9]);
});
it("converts string payloads to Uint8Array via TextEncoder", () => {
const mgr = new DataChannelManager();
const ch = makeChannel();
mgr.register("alice", ch);
const seen: Uint8Array[] = [];
mgr.onMessage((_, bytes) => seen.push(bytes));
ch.fireMessage("hi");
expect(seen).toHaveLength(1);
expect(seen[0]?.byteLength).toBeGreaterThan(0);
});
it("unregister removes the channel and stops dispatching", () => {
const mgr = new DataChannelManager();
const ch = makeChannel();
mgr.register("alice", ch);
mgr.unregister("alice");
expect(mgr.has("alice")).toBe(false);
expect(() => mgr.send("alice", new Uint8Array())).toThrow();
});
it("unregister of unknown peer is a no-op", () => {
const mgr = new DataChannelManager();
expect(() => mgr.unregister("ghost")).not.toThrow();
});
it("clear tears down all channels and subscribers", () => {
const mgr = new DataChannelManager();
const a = makeChannel();
const b = makeChannel();
mgr.register("alice", a);
mgr.register("bob", b);
const seen: string[] = [];
mgr.onMessage((peerId) => seen.push(peerId));
mgr.clear();
a.fireMessage("post-clear");
b.fireMessage("post-clear");
expect(seen).toEqual([]);
});
it("exposes the configured high-water mark", () => {
const mgr = new DataChannelManager({
bufferedAmountHighWaterMark: 4242,
});
expect(mgr.getHighWaterMark()).toBe(4242);
});
it("onMessage returns an unsubscribe handle", () => {
const mgr = new DataChannelManager();
const ch = makeChannel();
mgr.register("alice", ch);
const seen: number[] = [];
const off = mgr.onMessage((_, bytes) => seen.push(bytes.byteLength));
off();
ch.fireMessage("x");
expect(seen).toEqual([]);
});
});

View File

@@ -0,0 +1,67 @@
# DataChannelManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the lifecycle of every WebRTC data channel for the mesh transport. Surfaces a single backpressure-aware `send(peerId, bytes)` API and a unified `onMessage` subscriber so the rest of the plugin never touches raw `RTCDataChannel` events. Centralising channel state here means there is exactly one place that decides what "the channel is healthy" means.
## Features
- `register(peerId, channel)` and `unregister(peerId)` for explicit lifecycle.
- Backpressure: `send` throws `SEND_FAILED` when `bufferedAmount` exceeds `bufferedAmountHighWaterMark` (default 1 MiB).
- `PEER_NOT_CONNECTED` and `DATA_CHANNEL_CLOSED` codes for the other failure modes.
- Unified `onMessage` subscriber receives raw `Uint8Array` payloads regardless of whether the wire payload was a string or `ArrayBuffer`.
- `clear()` for full teardown without per-peer iteration in callers.
## Usage: Basic Example
```ts
import { DataChannelManager } from "@suaveplan/network-transport-webrtc";
const manager = new DataChannelManager();
manager.register("peer-a", channel);
manager.onMessage((peerId, bytes) => {
console.log("from", peerId, bytes.byteLength, "bytes");
});
manager.send("peer-a", new Uint8Array([1, 2, 3]));
```
## Usage: Advanced Example
```ts
import { DataChannelManager } from "@suaveplan/network-transport-webrtc";
const manager = new DataChannelManager({
bufferedAmountHighWaterMark: 256 * 1024,
});
manager.register("peer-a", busyChannel);
try {
manager.send("peer-a", payload);
} catch (err) {
if (
(err as { code: string }).code === "NET_WEBRTC.SEND_FAILED" &&
(err as { context?: { highWaterMark?: number } }).context?.highWaterMark
) {
// backpressure — drop, queue, or wait for buffered amount to drain
}
}
```
## API Reference
- `new DataChannelManager(config?)` — `bufferedAmountHighWaterMark`.
- `register(peerId, channel: BackpressureDataChannel)` — installs `onmessage`.
- `unregister(peerId)` — clears the handler and forgets the channel.
- `has(peerId): boolean`.
- `send(peerId, payload: Uint8Array)` — backpressure-aware send; throws on missing/closed/busy.
- `onMessage(handler) → () => void` — global inbound subscriber.
- `getHighWaterMark(): number`.
- `clear()` — unregister every channel and drop subscribers.
- `interface BackpressureDataChannel` — `DataChannelLike` + `bufferedAmount`.
## Implementation Notes
The manager intentionally does not inspect message ordering or sequence numbers — those concerns belong to higher layers (`network-plugin-protocol`). Backpressure failures are not silently retried; callers that need a queue can wrap `send` in their own envelope and pause on failure. The dispatcher converts string payloads to `Uint8Array` via `TextEncoder` so subscribers always see binary bytes.

View File

@@ -0,0 +1,124 @@
import type { PeerId } from "@suaveplan/network";
import { NETWORK_WEBRTC_CODES, NetworkWebRTCError } from "../error-codes.js";
import type { DataChannelLike, MessageEventLike } from "../types/types.js";
/**
* Data-channel shape extended with the `bufferedAmount` backpressure
* counter. The mesh-side data channel always has this in browsers; we
* declare it explicitly so test fakes opt in.
*/
export interface BackpressureDataChannel extends DataChannelLike {
readonly bufferedAmount: number;
}
/**
* Configuration for `DataChannelManager`.
*/
export interface DataChannelManagerConfig {
readonly bufferedAmountHighWaterMark?: number;
}
/**
* Inbound message handler.
*/
export type DataChannelMessageHandler = (
peerId: PeerId,
payload: Uint8Array
) => void;
/**
* Owns lifecycle of N data channels keyed by `peerId`. Surfaces a single
* backpressure-aware `send` API that decoder-decoder pairs can build on.
*
* `bufferedAmount` is consulted before every send: if the channel reports
* a buffered amount above the configured high-water mark, the send fails
* with `DATA_CHANNEL_CLOSED`-flavoured backpressure (`SEND_FAILED`).
*/
export class DataChannelManager {
private readonly channels = new Map<PeerId, BackpressureDataChannel>();
private readonly handlers = new Set<DataChannelMessageHandler>();
private readonly highWaterMark: number;
public constructor(config: DataChannelManagerConfig = {}) {
this.highWaterMark = config.bufferedAmountHighWaterMark ?? 1_048_576;
}
public register(peerId: PeerId, channel: BackpressureDataChannel): void {
this.channels.set(peerId, channel);
channel.onmessage = (ev: MessageEventLike) => {
this.dispatch(peerId, ev.data);
};
}
public unregister(peerId: PeerId): void {
const ch = this.channels.get(peerId);
if (ch !== undefined) {
ch.onmessage = null;
this.channels.delete(peerId);
}
}
public has(peerId: PeerId): boolean {
return this.channels.has(peerId);
}
public send(peerId: PeerId, payload: Uint8Array): void {
const ch = this.channels.get(peerId);
if (ch === undefined) {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.PEER_NOT_CONNECTED,
"data-channel.peer-not-connected",
{ context: { peerId } }
);
}
if (ch.readyState !== "open") {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
"data-channel.closed",
{ context: { peerId } }
);
}
if (ch.bufferedAmount > this.highWaterMark) {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.SEND_FAILED,
"data-channel.backpressure",
{
context: {
peerId,
bufferedAmount: ch.bufferedAmount,
highWaterMark: this.highWaterMark,
},
}
);
}
ch.send(payload.buffer);
}
public onMessage(handler: DataChannelMessageHandler): () => void {
this.handlers.add(handler);
return () => {
this.handlers.delete(handler);
};
}
public getHighWaterMark(): number {
return this.highWaterMark;
}
public clear(): void {
for (const peerId of [...this.channels.keys()]) {
this.unregister(peerId);
}
this.handlers.clear();
}
private dispatch(peerId: PeerId, data: string | ArrayBufferLike): void {
const bytes =
typeof data === "string"
? new TextEncoder().encode(data)
: new Uint8Array(data);
for (const handler of this.handlers) {
handler(peerId, bytes);
}
}
}

View File

@@ -0,0 +1,51 @@
# WebRTC Error Codes
## Purpose
Defines the stable error-code registry for the `@suaveplan/network-transport-webrtc` package. All runtime failures within the WebRTC transport layer are tagged with deterministic codes so callers can handle them programmatically. WebRTC is inherently unreliable — connections drop, data channels close unexpectedly, and signaling can fail. Clear error semantics are essential for building resilient mesh networks on top of this transport.
## Features
- Deterministic code generation via `defineCodes`
- Typed error class extending `SuaveplanError`
- Full OpenTelemetry span-event compatibility
- Tree-shake safe code definitions
- Machine-readable error categorization
- Human-readable message keys for i18n
## Usage
### Basic Example
```ts
import { NetworkWebRTCError, NETWORK_WEBRTC_CODES } from "@suaveplan/network-transport-webrtc";
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.CONNECTION_FAILED,
"webrtc.connection-failed"
);
```
### Advanced Example
```ts
import { NetworkWebRTCError, NETWORK_WEBRTC_CODES } from "@suaveplan/network-transport-webrtc";
try {
await transport.connect(peerId, address);
} catch (err) {
if (err instanceof NetworkWebRTCError && err.code === NETWORK_WEBRTC_CODES.CONNECTION_FAILED) {
console.log("Connection failed, retrying...");
}
}
```
## API Reference
- `NETWORK_WEBRTC_CODES` — frozen registry of WebRTC error codes
- `NetworkWebRTCError` — typed error class
- `NetworkWebRTCErrorCode` — union type of valid codes
## Implementation Notes
Codes are namespaced under `NET_WEBRTC` to avoid collisions with other network subsystems. The registry includes codes for connection failures (`CONNECTION_FAILED`), data channel closure (`DATA_CHANNEL_CLOSED`), send failures (`SEND_FAILED`), missing peer connections (`PEER_NOT_CONNECTED`), transport shutdown (`TRANSPORT_CLOSED`), and signaling errors (`SIGNALING_ERROR`). Each code maps to a human-readable message key that can be resolved for internationalization. The `NetworkWebRTCError` class extends `SuaveplanError`, which provides automatic stack trace capture, cause chaining, and OpenTelemetry span event conversion. Error codes are tree-shake safe, meaning unused codes are removed from production bundles by the bundler.

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_WEBRTC_CODES, NetworkWebRTCError } from "./error-codes.js";
describe("NETWORK_WEBRTC_CODES", () => {
it("registers under NET_WEBRTC", () => {
for (const value of Object.values(NETWORK_WEBRTC_CODES)) {
expect(value).toMatch(/^NET_WEBRTC\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_WEBRTC_CODES)).toBe(true);
});
it("contains the documented codes", () => {
const expected = [
"CONNECTION_FAILED",
"DATA_CHANNEL_CLOSED",
"SEND_FAILED",
"PEER_NOT_CONNECTED",
"TRANSPORT_CLOSED",
"SIGNALING_ERROR",
] as const;
for (const key of expected) {
expect(NETWORK_WEBRTC_CODES).toHaveProperty(key);
}
});
});
describe("NetworkWebRTCError", () => {
it("extends SuaveplanError with the WebRTC code type", () => {
const err = new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.SEND_FAILED,
"test.send-failed",
{ context: { peerId: "x" } }
);
expect(err.name).toBe("NetworkWebRTCError");
expect(err.code).toBe(NETWORK_WEBRTC_CODES.SEND_FAILED);
expect(err).toBeInstanceOf(Error);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.CONNECTION_FAILED,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,24 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const NETWORK_WEBRTC_CODES = defineCodes("NET_WEBRTC", [
"CONNECTION_FAILED",
"DATA_CHANNEL_CLOSED",
"SEND_FAILED",
"PEER_NOT_CONNECTED",
"TRANSPORT_CLOSED",
"SIGNALING_ERROR",
] as const);
export type NetworkWebRTCErrorCode =
(typeof NETWORK_WEBRTC_CODES)[keyof typeof NETWORK_WEBRTC_CODES];
export class NetworkWebRTCError extends SuaveplanError<NetworkWebRTCErrorCode> {
public constructor(
code: NetworkWebRTCErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkWebRTCError";
}
}

View File

@@ -0,0 +1,66 @@
# IceManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Watches the ICE connection state of a single `RTCPeerConnection` and triggers `restartIce()` whenever the state transitions to `failed`. Caps restart attempts so a permanently broken peer cannot loop forever. Holds the configured `iceServers` (STUN + TURN) so callers can construct new peer connections from the same source of truth without copying configuration around.
## Features
- WeakMap-backed per-connection restart counter; no leaks across reconnects.
- Configurable `maxRestarts` (default 3).
- Injectable `IceCapablePeerConnection` shape so the manager works against fakes.
- `tryRestart` is the explicit imperative path used by the auto-handler and by callers that want to force a restart on demand.
## Usage: Basic Example
```ts
import { IceManager } from "@suaveplan/network-transport-webrtc";
const ice = new IceManager({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
ice.attach(peerConnection);
```
## Usage: Advanced Example
```ts
import { IceManager, type IceCapablePeerConnection } from "@suaveplan/network-transport-webrtc";
const ice = new IceManager({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{
urls: ["turn:turn.example.com:3478"],
username: "u",
credential: "p",
},
],
maxRestarts: 5,
});
ice.attach(peerConnection);
// Force a restart manually after a network handoff.
const restarted = ice.tryRestart(peerConnection);
if (!restarted) {
// the cap was already reached; tear down and reconnect
}
const attempts = ice.getRestartCount(peerConnection);
```
## API Reference
- `new IceManager(config: IceManagerConfig)` — `iceServers`, optional `maxRestarts`.
- `attach(connection)` — wires `oniceconnectionstatechange` so `failed` triggers a restart.
- `tryRestart(connection): boolean` — returns `true` if restart was attempted, `false` once cap is reached.
- `getRestartCount(connection): number` — current attempt count.
- `getIceServers(): readonly IceServer[]` — the configured server list, useful when constructing fresh peer connections.
## Implementation Notes
The manager uses `WeakMap` so released `RTCPeerConnection` instances drop their counter without leaking. The `attach`-installed handler unconditionally clears any user-supplied `oniceconnectionstatechange` callback — callers that need to observe state transitions for their own purposes MUST chain off the manager (via the underlying `connection.oniceconnectionstatechange` after `attach`).

View File

@@ -0,0 +1,76 @@
/**
* ICE server entry shared with the underlying RTC peer connection.
*/
export interface IceServer {
readonly urls: string | readonly string[];
readonly username?: string;
readonly credential?: string;
}
/**
* Minimal `RTCPeerConnection` shape needed by the ICE manager.
*/
export interface IceCapablePeerConnection {
iceConnectionState: string;
oniceconnectionstatechange:
| ((this: IceCapablePeerConnection, ev: unknown) => unknown)
| null;
restartIce(): void;
}
/**
* Configuration for `IceManager`.
*/
export interface IceManagerConfig {
readonly iceServers: readonly IceServer[];
readonly maxRestarts?: number;
}
/**
* Monitors the ICE connection state of a single peer connection and
* triggers an ICE restart when the state transitions to `failed`.
*
* Restart attempts are capped by `maxRestarts` (default 3) per
* connection; once exhausted the manager stops restarting and exposes
* the count via `getRestartCount()` for upstream observability.
*/
export class IceManager {
private readonly maxRestarts: number;
private readonly iceServers: readonly IceServer[];
private readonly restartCounts = new WeakMap<
IceCapablePeerConnection,
number
>();
public constructor(config: IceManagerConfig) {
this.iceServers = config.iceServers;
this.maxRestarts = config.maxRestarts ?? 3;
}
public getIceServers(): readonly IceServer[] {
return this.iceServers;
}
public attach(connection: IceCapablePeerConnection): void {
this.restartCounts.set(connection, 0);
connection.oniceconnectionstatechange = () => {
if (connection.iceConnectionState === "failed") {
this.tryRestart(connection);
}
};
}
public tryRestart(connection: IceCapablePeerConnection): boolean {
const count = this.restartCounts.get(connection) ?? 0;
if (count >= this.maxRestarts) {
return false;
}
this.restartCounts.set(connection, count + 1);
connection.restartIce();
return true;
}
public getRestartCount(connection: IceCapablePeerConnection): number {
return this.restartCounts.get(connection) ?? 0;
}
}

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { type IceCapablePeerConnection, IceManager } from "./ice-manager.js";
function makeConnection(): IceCapablePeerConnection & {
fire(state: string): void;
restartCalls: number;
} {
let stateChange:
| ((this: IceCapablePeerConnection, ev: unknown) => unknown)
| null = null;
let state = "new";
let restartCalls = 0;
return {
get iceConnectionState() {
return state;
},
set iceConnectionState(v: string) {
state = v;
},
get oniceconnectionstatechange() {
return stateChange;
},
set oniceconnectionstatechange(fn) {
stateChange = fn;
},
restartIce(): void {
restartCalls += 1;
},
fire(next: string): void {
state = next;
stateChange?.call(this, undefined);
},
get restartCalls(): number {
return restartCalls;
},
};
}
describe("IceManager", () => {
it("triggers restart when state transitions to failed", () => {
const ice = new IceManager({
iceServers: [{ urls: "stun:stun.example.com" }],
});
const conn = makeConnection();
ice.attach(conn);
conn.fire("failed");
expect(conn.restartCalls).toBe(1);
expect(ice.getRestartCount(conn)).toBe(1);
});
it("caps restarts at maxRestarts", () => {
const ice = new IceManager({
iceServers: [{ urls: "stun:stun.example.com" }],
maxRestarts: 2,
});
const conn = makeConnection();
ice.attach(conn);
conn.fire("failed");
conn.fire("failed");
conn.fire("failed");
expect(conn.restartCalls).toBe(2);
expect(ice.getRestartCount(conn)).toBe(2);
});
it("does not restart on non-failed transitions", () => {
const ice = new IceManager({ iceServers: [] });
const conn = makeConnection();
ice.attach(conn);
conn.fire("connected");
conn.fire("disconnected");
expect(conn.restartCalls).toBe(0);
});
it("exposes the configured iceServers", () => {
const servers = [
{ urls: "stun:stun1.example.com" },
{
urls: ["turn:turn.example.com"],
username: "u",
credential: "p",
},
];
const ice = new IceManager({ iceServers: servers });
expect(ice.getIceServers()).toBe(servers);
});
it("getRestartCount returns 0 for unattached connections", () => {
const ice = new IceManager({ iceServers: [] });
const conn = makeConnection();
expect(ice.getRestartCount(conn)).toBe(0);
});
it("tryRestart works on a connection that was never attached", () => {
const ice = new IceManager({ iceServers: [], maxRestarts: 2 });
const conn = makeConnection();
const ok = ice.tryRestart(conn);
expect(ok).toBe(true);
expect(conn.restartCalls).toBe(1);
});
it("tryRestart returns false once the cap is reached", () => {
const ice = new IceManager({ iceServers: [], maxRestarts: 1 });
const conn = makeConnection();
ice.attach(conn);
expect(ice.tryRestart(conn)).toBe(true);
expect(ice.tryRestart(conn)).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
// Error codes
import "./error-codes.js";
// Data channel
export {
type BackpressureDataChannel,
DataChannelManager,
type DataChannelManagerConfig,
type DataChannelMessageHandler,
} from "./data-channel/data-channel-manager.js";
// Errors
export {
NETWORK_WEBRTC_CODES,
NetworkWebRTCError,
type NetworkWebRTCErrorCode,
} from "./error-codes.js";
// ICE
export {
type IceCapablePeerConnection,
IceManager,
type IceManagerConfig,
type IceServer,
} from "./ice/ice-manager.js";
// Mesh
export {
type DiscoveredPeer,
type MeshClock,
WebRTCMesh,
type WebRTCMeshConfig,
} from "./mesh/webrtc-mesh.js";
// Ping
export {
type LatencyRecord,
type PingBus,
type PingClock,
PingManager,
type PingManagerConfig,
} from "./ping/ping-manager.js";
// Plugin
export { WebRTCPlugin } from "./plugin/plugin.js";
// Signaling
export type {
SignalingClient,
SignalingHandler,
SignalingMessage,
SignalType,
} from "./signaling/signaling-client.js";
export {
type WebSocketFactory,
type WebSocketLike,
WebSocketSignalingClient,
type WebSocketSignalingConfig,
} from "./signaling/websocket-signaling-client.js";
// Transport
export { WebRTCTransport } from "./transport/transport.js";
// Types
export type {
DataChannelLike,
MessageEventLike,
PeerConnectionFactory,
PeerConnectionLike,
PeerConnectionState,
WebRTCConfig,
} from "./types/types.js";

View File

@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { WebRTCTransport } from "../transport/transport.js";
import type { DataChannelLike, PeerConnectionLike } from "../types/types.js";
import { WebRTCMesh } from "./webrtc-mesh.js";
function makeChannel(): DataChannelLike & { fireOpen(): void } {
let onopen: ((this: DataChannelLike, ev: unknown) => unknown) | null = null;
return {
readyState: "open",
send: vi.fn(),
close: vi.fn(),
get onopen() {
return onopen;
},
set onopen(fn) {
onopen = fn;
},
onmessage: null,
onclose: null,
onerror: null,
fireOpen(): void {
onopen?.call(this, undefined);
},
};
}
function makeConnection(): PeerConnectionLike {
return {
connectionState: "connected",
createDataChannel: vi.fn(() => makeChannel()),
close: vi.fn(),
};
}
describe("WebRTCMesh", () => {
function setupMesh(
opts: { timeoutMs?: number; clock?: () => number } = {}
) {
const transport = new WebRTCTransport({
peerConnectionFactory: () => makeConnection(),
});
const clock = opts.clock ?? (() => 0);
const mesh = new WebRTCMesh({
transport,
heartbeatTimeoutMs: opts.timeoutMs ?? 10,
clock,
});
return { transport, mesh, clock };
}
it("connects to discovered peers via addPeer", async () => {
const { mesh, transport } = setupMesh();
await mesh.addPeer({ peerId: "alice", address: "addr-a" });
expect(transport.getConnectedPeers()).toContain("alice");
});
it("does not reconnect if peer is already connected", async () => {
const { mesh, transport } = setupMesh();
await mesh.addPeer({ peerId: "alice", address: "addr-a" });
const first = transport.getConnectedPeers().length;
await mesh.addPeer({ peerId: "alice", address: "addr-a" });
expect(transport.getConnectedPeers().length).toBe(first);
});
it("addPeers connects to each entry", async () => {
const { mesh, transport } = setupMesh();
await mesh.addPeers([
{ peerId: "alice", address: "a" },
{ peerId: "bob", address: "b" },
]);
expect(transport.getConnectedPeers().length).toBe(2);
});
it("evicts peers whose heartbeats are stale", async () => {
let now = 0;
const { mesh } = setupMesh({ timeoutMs: 5, clock: () => now });
mesh.start();
await mesh.addPeer({ peerId: "alice", address: "a" });
mesh.markHeartbeat("alice");
now += 100;
const evicted = await mesh.sweep();
expect(evicted).toEqual(["alice"]);
mesh.stop();
});
it("retains peers that beat within the window", async () => {
let now = 0;
const { mesh } = setupMesh({ timeoutMs: 100, clock: () => now });
mesh.start();
await mesh.addPeer({ peerId: "alice", address: "a" });
now += 10;
mesh.markHeartbeat("alice");
now += 50;
const evicted = await mesh.sweep();
expect(evicted).toEqual([]);
expect(mesh.getKnownPeers()).toContain("alice");
mesh.stop();
});
it("reflects transport disconnect events", async () => {
const { mesh, transport } = setupMesh();
mesh.start();
await mesh.addPeer({ peerId: "alice", address: "a" });
await transport.disconnect("alice");
expect(mesh.getKnownPeers()).not.toContain("alice");
mesh.stop();
});
it("removePeer disconnects and forgets", async () => {
const { mesh, transport } = setupMesh();
await mesh.addPeer({ peerId: "alice", address: "a" });
await mesh.removePeer("alice");
expect(transport.getConnectedPeers()).not.toContain("alice");
expect(mesh.getKnownPeers()).not.toContain("alice");
});
it("getLastSeen returns undefined for unknown peers", async () => {
const { mesh } = setupMesh();
expect(mesh.getLastSeen("ghost")).toBeUndefined();
});
it("uses default 30 s heartbeat timeout when not configured", async () => {
const transport = new WebRTCTransport({
peerConnectionFactory: () => makeConnection(),
});
const mesh = new WebRTCMesh({ transport });
await mesh.addPeer({ peerId: "alice", address: "a" });
const evicted = await mesh.sweep();
expect(evicted).toEqual([]);
});
it("registers the connected timestamp from transport events", async () => {
let now = 0;
const { mesh, transport } = setupMesh({ clock: () => now });
mesh.start();
const peerEventHandlers = (
transport as unknown as {
peerEventHandlers: Set<
(
peerId: string,
event: "connected" | "disconnected"
) => void
>;
}
).peerEventHandlers;
for (const handler of peerEventHandlers) {
now = 42;
handler("alice", "connected");
}
expect(mesh.getLastSeen("alice")).toBe(42);
mesh.stop();
});
});

View File

@@ -0,0 +1,68 @@
# WebRTCMesh
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maintains a full-mesh WebRTC topology on top of `WebRTCTransport`. Owns peer discovery → connect, heartbeat tracking, and stale-peer eviction. Decoupling these from the transport itself means the transport stays a thin wrapper around `RTCPeerConnection` while higher-level mesh concerns (when does a peer count as gone?) live here.
## Features
- `addPeer` / `addPeers` for connecting to discovered peers.
- Auto-tracks `connected`/`disconnected` transport events into the last-seen map.
- `markHeartbeat(peerId)` for liveness updates from heartbeat traffic.
- `sweep()` returns the list of evicted peer ids, driven by a caller-owned interval.
- Injectable `clock` (default `Date.now`) for deterministic tests.
- `getKnownPeers()` and `getLastSeen(peerId)` expose mesh state for observability.
## Usage: Basic Example
```ts
import { WebRTCMesh, WebRTCTransport } from "@suaveplan/network-transport-webrtc";
const transport = new WebRTCTransport({ peerConnectionFactory });
const mesh = new WebRTCMesh({ transport });
mesh.start();
await mesh.addPeers([
{ peerId: "alice", address: "..." },
{ peerId: "bob", address: "..." },
]);
```
## Usage: Advanced Example
```ts
import { WebRTCMesh, WebRTCTransport } from "@suaveplan/network-transport-webrtc";
let now = 0;
const mesh = new WebRTCMesh({
transport,
heartbeatTimeoutMs: 5000,
clock: () => now,
});
mesh.start();
await mesh.addPeer({ peerId: "alice", address: "..." });
mesh.markHeartbeat("alice");
now += 6000;
const evicted = await mesh.sweep();
// evicted === ["alice"]
mesh.stop();
```
## API Reference
- `new WebRTCMesh(config)` — `transport`, optional `heartbeatTimeoutMs` (default 30 s) and `clock`.
- `start()` / `stop()` — wire/unwire transport peer-event subscription.
- `addPeer(peer)` / `addPeers(peers)` — connect through the underlying transport.
- `markHeartbeat(peerId)` — record liveness.
- `removePeer(peerId)` — disconnect and forget.
- `sweep(): Promise<readonly PeerId[]>` — evict every peer past the heartbeat timeout, returns evicted ids.
- `getKnownPeers()`, `getLastSeen(peerId)`.
## Implementation Notes
`sweep()` performs the disconnect serially so failures bubble up directly; if a transport disconnect rejects, the sweep aborts (the caller can retry on the next interval). The mesh does not own its own timer — it expects the consumer (`WebRTCPlugin` or a test harness) to drive `sweep()` so the cadence stays explicit and testable. Heartbeat semantics (frame layout, frequency) live in `PingManager`; this class only consumes the resulting timestamps.

View File

@@ -0,0 +1,110 @@
import type { PeerId } from "@suaveplan/network";
import type { WebRTCTransport } from "../transport/transport.js";
/**
* Heartbeat clock used by the mesh — injectable for tests.
*/
export type MeshClock = () => number;
/**
* Configuration for `WebRTCMesh`.
*/
export interface WebRTCMeshConfig {
readonly transport: WebRTCTransport;
readonly heartbeatTimeoutMs?: number;
readonly clock?: MeshClock;
}
/**
* Discovered peer announcement coming from a discovery plugin.
*/
export interface DiscoveredPeer {
readonly peerId: PeerId;
readonly address: string;
}
/**
* Maintains a full-mesh of WebRTC connections.
*
* The mesh:
*
* 1. On `addPeer`, opens a transport connection if not already connected.
* 2. On `markHeartbeat(peerId)`, records the last-seen timestamp.
* 3. On `sweep()` (driven by a caller-owned interval), removes any peer
* whose last heartbeat is older than `heartbeatTimeoutMs`.
* 4. On transport `disconnected`, eagerly forgets the peer.
*/
export class WebRTCMesh {
private readonly transport: WebRTCTransport;
private readonly heartbeatTimeoutMs: number;
private readonly clock: MeshClock;
private readonly lastSeen = new Map<PeerId, number>();
private offPeerEvent: (() => void) | null = null;
public constructor(config: WebRTCMeshConfig) {
this.transport = config.transport;
this.heartbeatTimeoutMs = config.heartbeatTimeoutMs ?? 30_000;
this.clock = config.clock ?? (() => Date.now());
}
public start(): void {
this.offPeerEvent = this.transport.onPeerEvent((peerId, event) => {
if (event === "disconnected") {
this.lastSeen.delete(peerId);
return;
}
this.lastSeen.set(peerId, this.clock());
});
}
public stop(): void {
this.offPeerEvent?.();
this.offPeerEvent = null;
this.lastSeen.clear();
}
public async addPeer(peer: DiscoveredPeer): Promise<void> {
if (this.transport.getConnectedPeers().includes(peer.peerId)) {
return;
}
await this.transport.connect(peer.peerId, peer.address);
this.lastSeen.set(peer.peerId, this.clock());
}
public async addPeers(peers: readonly DiscoveredPeer[]): Promise<void> {
for (const peer of peers) {
await this.addPeer(peer);
}
}
public markHeartbeat(peerId: PeerId): void {
this.lastSeen.set(peerId, this.clock());
}
public async removePeer(peerId: PeerId): Promise<void> {
await this.transport.disconnect(peerId);
this.lastSeen.delete(peerId);
}
public async sweep(): Promise<readonly PeerId[]> {
const now = this.clock();
const stale: PeerId[] = [];
for (const [peerId, last] of this.lastSeen) {
if (now - last > this.heartbeatTimeoutMs) {
stale.push(peerId);
}
}
for (const peerId of stale) {
await this.removePeer(peerId);
}
return stale;
}
public getKnownPeers(): readonly PeerId[] {
return [...this.lastSeen.keys()];
}
public getLastSeen(peerId: PeerId): number | undefined {
return this.lastSeen.get(peerId);
}
}

View File

@@ -0,0 +1,67 @@
# PingManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks correlated echo frames between peers and computes EMA-smoothed RTTs. The manager is transport-agnostic — callers feed it `recordSent`/`recordReceived` events with a stable `pingId` correlator and the manager handles smoothing, in-flight cleanup, and `peer:latency` bus emission.
## Features
- Per-peer `LatencyRecord` with `lastRttMs`, EMA-smoothed `avgRttMs`, and `samples` count.
- Configurable EMA window (default 10) → `α = 2 / (window + 1)`.
- Bus emission on every successful pong via `peer:latency`.
- Late-arrival safety: pongs whose `pingId` was never sent are silently ignored (return `undefined`).
- Negative RTT clamping: any clock skew that would yield negative RTT is floored at 0.
- `reset(peerId)` for forgetting one peer's history; `clear()` for full teardown.
## Usage: Basic Example
```ts
import { PingManager } from "@suaveplan/network-transport-webrtc";
const ping = new PingManager({ bus });
ping.recordSent("alice", "ping-1");
// ...later, on pong receive:
ping.recordReceived("alice", "ping-1");
const r = ping.getRecord("alice");
```
## Usage: Advanced Example
```ts
import { PingManager } from "@suaveplan/network-transport-webrtc";
let now = 0;
const ping = new PingManager({
bus,
emaWindow: 5,
clock: () => now,
});
for (let i = 0; i < 10; i++) {
const id = `p${i}`;
ping.recordSent("alice", id);
now += 50 + (i % 2 === 0 ? 0 : 20);
ping.recordReceived("alice", id);
}
const record = ping.getRecord("alice")!;
// record.avgRttMs has converged toward ~60 ms
```
## API Reference
- `new PingManager({ bus, emaWindow?, clock? })`.
- `recordSent(peerId, pingId)` — note the outbound ping.
- `recordReceived(peerId, pingId): number | undefined` — returns raw RTT, or `undefined` if the id was never sent.
- `getRecord(peerId): LatencyRecord | undefined`.
- `reset(peerId)` — drops in-flight pings + record for one peer.
- `clear()` — full teardown.
- `interface PingBus { emit("peer:latency", { peerId, rttMs }) }`.
- `interface LatencyRecord { lastRttMs, avgRttMs, samples }`.
## Implementation Notes
The manager keeps in-flight pings in a `Map` keyed by `${peerId}::${pingId}` so two peers can use the same ping id without colliding. The bus payload uses the _smoothed_ `avgRttMs` so subscribers see a stable trend rather than per-sample noise — callers that need raw RTT can pull it from the `recordReceived` return value or `getRecord(peerId).lastRttMs`. EMA initialisation seeds the average to the first sample so a fresh peer does not need a long warm-up before the value reflects reality.

View File

@@ -0,0 +1,117 @@
import type { PeerId } from "@suaveplan/network";
/**
* Ping clock — injectable for tests.
*/
export type PingClock = () => number;
/**
* Bus shape consumed by `PingManager` — accepts the canonical
* `peer:latency` event with `{ peerId, rttMs }`.
*/
export interface PingBus {
emit(
event: "peer:latency",
payload: { peerId: PeerId; rttMs: number }
): void;
}
/**
* Configuration for `PingManager`.
*/
export interface PingManagerConfig {
readonly bus: PingBus;
readonly emaWindow?: number;
readonly clock?: PingClock;
}
/**
* Per-peer latency record: most recent RTT and the EMA-smoothed value.
*/
export interface LatencyRecord {
readonly lastRttMs: number;
readonly avgRttMs: number;
readonly samples: number;
}
interface InFlightPing {
readonly peerId: PeerId;
readonly sentAt: number;
}
/**
* Tracks correlated echo frames and computes EMA-smoothed RTTs per peer.
*
* The manager itself is transport-agnostic: callers `recordSent(peerId, id)`
* when they emit a ping frame and `recordReceived(peerId, id)` when the
* matching pong returns. RTT is computed against the injectable clock and
* smoothed with `α = 2 / (window + 1)` (default window = 10).
*/
export class PingManager {
private readonly inFlight = new Map<string, InFlightPing>();
private readonly records = new Map<PeerId, LatencyRecord>();
private readonly bus: PingBus;
private readonly clock: PingClock;
private readonly alpha: number;
public constructor(config: PingManagerConfig) {
this.bus = config.bus;
this.clock = config.clock ?? (() => Date.now());
const window = config.emaWindow ?? 10;
this.alpha = 2 / (window + 1);
}
public recordSent(peerId: PeerId, pingId: string): void {
this.inFlight.set(this.key(peerId, pingId), {
peerId,
sentAt: this.clock(),
});
}
public recordReceived(peerId: PeerId, pingId: string): number | undefined {
const k = this.key(peerId, pingId);
const ping = this.inFlight.get(k);
if (ping === undefined) return undefined;
this.inFlight.delete(k);
const rttMs = Math.max(0, this.clock() - ping.sentAt);
const next = this.smooth(peerId, rttMs);
this.records.set(peerId, next);
this.bus.emit("peer:latency", { peerId, rttMs: next.avgRttMs });
return rttMs;
}
public getRecord(peerId: PeerId): LatencyRecord | undefined {
return this.records.get(peerId);
}
public reset(peerId: PeerId): void {
this.records.delete(peerId);
for (const [k, ping] of [...this.inFlight.entries()]) {
if (ping.peerId === peerId) {
this.inFlight.delete(k);
}
}
}
public clear(): void {
this.inFlight.clear();
this.records.clear();
}
private smooth(peerId: PeerId, rttMs: number): LatencyRecord {
const prev = this.records.get(peerId);
if (prev === undefined) {
return { lastRttMs: rttMs, avgRttMs: rttMs, samples: 1 };
}
const avg = this.alpha * rttMs + (1 - this.alpha) * prev.avgRttMs;
return {
lastRttMs: rttMs,
avgRttMs: avg,
samples: prev.samples + 1,
};
}
private key(peerId: PeerId, pingId: string): string {
return `${peerId}::${pingId}`;
}
}

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { type PingBus, PingManager } from "./ping-manager.js";
function makeBus(): PingBus & {
events: Array<{ peerId: string; rttMs: number }>;
} {
const events: Array<{ peerId: string; rttMs: number }> = [];
return {
events,
emit(_event, payload): void {
events.push(payload);
},
};
}
describe("PingManager (NET-07)", () => {
it("computes RTT and emits peer:latency on pong", () => {
let now = 0;
const bus = makeBus();
const ping = new PingManager({ bus, clock: () => now });
ping.recordSent("alice", "p1");
now = 50;
const rtt = ping.recordReceived("alice", "p1");
expect(rtt).toBe(50);
expect(bus.events).toHaveLength(1);
expect(bus.events[0]?.peerId).toBe("alice");
expect(bus.events[0]?.rttMs).toBe(50);
});
it("returns undefined for an unknown ping id", () => {
const ping = new PingManager({ bus: makeBus() });
expect(ping.recordReceived("alice", "ghost")).toBeUndefined();
});
it("smooths RTT via EMA across multiple samples", () => {
let now = 0;
const bus = makeBus();
const ping = new PingManager({ bus, emaWindow: 5, clock: () => now });
for (let i = 0; i < 20; i++) {
ping.recordSent("alice", `p${i}`);
now += 100;
ping.recordReceived("alice", `p${i}`);
}
const record = ping.getRecord("alice")!;
expect(record.avgRttMs).toBeCloseTo(100, 0);
expect(record.lastRttMs).toBe(100);
expect(record.samples).toBe(20);
});
it("clamps negative RTT to 0", () => {
let now = 100;
const ping = new PingManager({ bus: makeBus(), clock: () => now });
ping.recordSent("alice", "p1");
now = 50; // clock went backwards
const rtt = ping.recordReceived("alice", "p1");
expect(rtt).toBe(0);
});
it("reset clears one peer's record and in-flight pings", () => {
let now = 0;
const ping = new PingManager({ bus: makeBus(), clock: () => now });
// alice sample → record
ping.recordSent("alice", "p1");
now = 10;
ping.recordReceived("alice", "p1");
// alice still has an open ping, plus bob has one
ping.recordSent("alice", "p2");
ping.recordSent("bob", "p1");
ping.reset("alice");
expect(ping.getRecord("alice")).toBeUndefined();
// alice's in-flight p2 was cleared
expect(ping.recordReceived("alice", "p2")).toBeUndefined();
// bob's in-flight ping was preserved
now = 30;
const bobRtt = ping.recordReceived("bob", "p1");
expect(bobRtt).toBe(20);
});
it("clear drops all state", () => {
const ping = new PingManager({ bus: makeBus() });
ping.recordSent("alice", "p1");
ping.clear();
expect(ping.recordReceived("alice", "p1")).toBeUndefined();
});
it("uses default emaWindow when not configured", () => {
let now = 0;
const ping = new PingManager({ bus: makeBus(), clock: () => now });
ping.recordSent("alice", "p1");
now = 100;
ping.recordReceived("alice", "p1");
ping.recordSent("alice", "p2");
now = 300; // 200 ms RTT
ping.recordReceived("alice", "p2");
const record = ping.getRecord("alice")!;
expect(record.avgRttMs).toBeGreaterThan(100);
expect(record.avgRttMs).toBeLessThan(200);
});
it("seeds the EMA with the first sample", () => {
let now = 0;
const ping = new PingManager({ bus: makeBus(), clock: () => now });
ping.recordSent("alice", "p1");
now = 75;
ping.recordReceived("alice", "p1");
expect(ping.getRecord("alice")?.avgRttMs).toBe(75);
});
it("uses Date.now-based clock when none injected", () => {
const ping = new PingManager({ bus: makeBus() });
ping.recordSent("alice", "p1");
const rtt = ping.recordReceived("alice", "p1");
expect(rtt).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,52 @@
# WebRTCPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Integrates `WebRTCTransport` into a `NetworkNode` as a `NetworkPlugin`. This plugin exposes the WebRTC transport as a public property and manages its lifecycle according to the node's start/stop/dispose sequence.
## Features
- Implements `NetworkPlugin` for automatic lifecycle management
- Exposes `WebRTCTransport` as a public readonly property
- Clean shutdown via `stop()` and `dispose()`
- Configurable via `WebRTCConfig`
## Usage
### Basic Example
```ts
import { WebRTCPlugin } from "@suaveplan/network-transport-webrtc";
const plugin = new WebRTCPlugin();
node.use(plugin);
```
### Advanced Example
```ts
import { WebRTCPlugin } from "@suaveplan/network-transport-webrtc";
const plugin = new WebRTCPlugin({
dataChannelLabel: "custom",
});
node.use(plugin);
await node.start();
await plugin.transport.connect("peer-1", "signal");
```
## API Reference
- `name` — always `"webrtc"`
- `transport` — `WebRTCTransport` instance
- `install(node)` — store node reference and register the transport with the node
- `start()` — no-op (transport is ready)
- `stop()` — close transport and unregister it from the node
- `dispose()` — release node reference
## Implementation Notes
The plugin creates the transport eagerly in the constructor so callers can configure it before the node starts. The `start()` method is intentionally a no-op because the transport does not automatically open connections — callers must explicitly invoke `transport.connect()`. This gives applications full control over when and to whom connections are established. The `stop()` method closes all active connections and cleans up resources. `dispose()` releases any internal references to prevent memory leaks during long-running application shutdown. Because the plugin does not store a reference to the node, it can be safely reused across multiple node instances as long as a fresh plugin instance is created for each node. The transport property is public and readonly, allowing external code to inspect its state or register event handlers directly without going through the plugin abstraction.

View File

@@ -0,0 +1,88 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { PeerConnectionLike } from "../types/types.js";
import { WebRTCPlugin } from "./plugin.js";
function makeConnection(): PeerConnectionLike {
return {
connectionState: "connected",
createDataChannel: vi.fn(() => ({
readyState: "open",
send: vi.fn(),
close: vi.fn(),
onopen: null,
onmessage: null,
onclose: null,
onerror: null,
})),
close: vi.fn(),
};
}
describe("WebRTCPlugin (NET-01 / NET-04)", () => {
it("exposes the canonical plugin name", () => {
const p = new WebRTCPlugin();
expect(p.name).toBe("webrtc");
});
it("registers its transport with the node on install", () => {
const node = new NetworkNode();
const plugin = new WebRTCPlugin({
peerConnectionFactory: () => makeConnection(),
});
node.use(plugin);
const transports = node.messageRouter.getTransports();
expect(transports.map((t) => t.id)).toContain("webrtc");
});
it("start() is a no-op", async () => {
const node = new NetworkNode();
const plugin = new WebRTCPlugin({
peerConnectionFactory: () => makeConnection(),
});
node.use(plugin);
await node.start();
expect(node.isStarted).toBe(true);
await node.stop();
});
it("stop() closes the transport and unregisters it from the node", async () => {
const node = new NetworkNode();
const plugin = new WebRTCPlugin({
peerConnectionFactory: () => makeConnection(),
});
node.use(plugin);
await node.start();
await node.stop();
expect(
node.messageRouter.getTransports().map((t) => t.id)
).not.toContain("webrtc");
});
it("dispose releases the node reference", async () => {
const plugin = new WebRTCPlugin({
peerConnectionFactory: () => makeConnection(),
});
const node = new NetworkNode();
plugin.install(node);
plugin.dispose();
// dispose is idempotent — calling again is harmless
plugin.dispose();
});
it("forwards the WebRTCConfig to the transport", () => {
const factory = vi.fn(() => makeConnection());
const plugin = new WebRTCPlugin({
peerConnectionFactory: factory,
dataChannelLabel: "custom",
});
expect(plugin.transport.id).toBe("webrtc");
});
it("stop is safe to call when no node was installed", async () => {
const plugin = new WebRTCPlugin({
peerConnectionFactory: () => makeConnection(),
});
await plugin.stop();
});
});

View File

@@ -0,0 +1,43 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { WebRTCTransport } from "../transport/transport.js";
import type { WebRTCConfig } from "../types/types.js";
/**
* Network plugin that exposes a `WebRTCTransport` to the host
* `NetworkNode`. The plugin is a thin lifecycle wrapper:
*
* - `install(node)` registers the transport with the node so the
* router can find it by id.
* - `start()` is a no-op — connections are opened on demand by the
* surrounding mesh / discovery plugins.
* - `stop()` closes the transport and unregisters it from the node so
* the next `install` is idempotent.
*/
export class WebRTCPlugin implements NetworkPlugin {
public readonly name = "webrtc";
public readonly transport: WebRTCTransport;
private node: NetworkNode | null = null;
public constructor(config: WebRTCConfig = {}) {
this.transport = new WebRTCTransport(config);
}
public install(node: NetworkNode): void {
this.node = node;
node.registerTransport(this.transport);
}
public start(): void {
// Transport is already registered; consumers open connections
// through the transport API directly.
}
public async stop(): Promise<void> {
await this.transport.close();
this.node?.unregisterTransport(this.transport.id);
}
public dispose(): void {
this.node = null;
}
}

View File

@@ -0,0 +1,82 @@
# SignalingClient
## Purpose
Defines the structural contract for any signaling channel used by the WebRTC plugin to exchange offers, answers, and ICE candidates between peers. The transport is intentionally pluggable: WebRTC does not specify how peers exchange SDP, so this package abstracts that entirely. Consumers can wire a WebSocket signaling server, a pubsub channel, or any custom relay without changing the rest of the plugin.
## Features
- Strict `SignalType` enum (`offer` / `answer` / `ice-candidate`).
- `SignalingMessage` shape with explicit `from` / `to` peer IDs and a UTF-8 `payload` string holding the SDP or candidate JSON.
- `SignalingHandler` subscription model with unsubscribe-returning `onMessage`.
- Lifecycle: explicit `connect()` / `close()` plus an `isOpen` getter so callers can see the wire state.
## Usage: Basic Example
```ts
import type { SignalingClient } from "@suaveplan/network-transport-webrtc";
declare const client: SignalingClient;
await client.connect();
const off = client.onMessage((msg) => {
if (msg.type === "offer") {
// hand the SDP to your peer-connection
}
});
await client.send({
type: "offer",
from: "peer-a",
to: "peer-b",
payload: JSON.stringify({ sdp: "..." }),
});
off();
await client.close();
```
## Usage: Advanced Example
```ts
import type { SignalingClient, SignalingMessage } from "@suaveplan/network-transport-webrtc";
class InMemorySignalingClient implements SignalingClient {
private handlers = new Set<(m: SignalingMessage) => void>();
public isOpen = false;
constructor(private readonly bus: { emit(m: SignalingMessage): void; on(fn: (m: SignalingMessage) => void): () => void }) {}
public connect(): void {
this.isOpen = true;
}
public close(): void {
this.isOpen = false;
this.handlers.clear();
}
public send(message: SignalingMessage): void {
this.bus.emit(message);
}
public onMessage(handler: (m: SignalingMessage) => void): () => void {
this.handlers.add(handler);
const off = this.bus.on((m) => {
for (const h of this.handlers) h(m);
});
return () => {
this.handlers.delete(handler);
off();
};
}
}
```
## API Reference
- `type SignalType = "offer" | "answer" | "ice-candidate"`.
- `interface SignalingMessage { type, from, to, payload }`.
- `type SignalingHandler = (message: SignalingMessage) => void`.
- `interface SignalingClient { send, onMessage, connect, close, isOpen }`.
## Implementation Notes
The contract intentionally does not include peer identity verification. Callers that need authenticated signaling MUST sign or wrap messages at a higher layer (e.g. via `network-plugin-crypto-messages`). Errors during `send`/`connect` MUST be raised as `NetworkWebRTCError` so the rest of the plugin treats them uniformly.
The `payload` is typed as `string` rather than a structured SDP object so the contract stays schema-agnostic — the wrapping protocol (raw SDP JSON, length-prefixed CBOR, etc.) is an implementation detail of the chosen relay. Implementations MAY choose to expose the underlying socket/channel via a sibling property if observability tooling needs raw frame counts, but doing so is explicitly outside the contract surface and consumers MUST NOT rely on it for correctness. The `isOpen` getter is a read-only view of the live wire state — a `true` reading is best-effort because the underlying socket can fail at any moment between checking `isOpen` and a subsequent `send`. Callers that care about strict-once delivery MUST treat `send` failures as the source of truth and retry through the upper-layer protocol, not by polling `isOpen`.

View File

@@ -0,0 +1,56 @@
import type { PeerId } from "@suaveplan/network";
/**
* Signal type enumeration for SDP exchange.
*/
export type SignalType = "offer" | "answer" | "ice-candidate";
/**
* One signaling message exchanged between peers via the signaling server.
*/
export interface SignalingMessage {
readonly type: SignalType;
readonly from: PeerId;
readonly to: PeerId;
readonly payload: string;
}
/**
* Handler invoked when a signaling message arrives for the local peer.
*/
export type SignalingHandler = (message: SignalingMessage) => void;
/**
* Pluggable signaling channel. WebRTC SDP/ICE candidate exchange MUST
* happen out-of-band; the channel itself is not specified by WebRTC.
*
* Implementations include `WebSocketSignalingClient` (ships in this
* package) and any future pubsub/channel-based variant.
*/
export interface SignalingClient {
/**
* Send a single signaling message to a remote peer.
*/
send(message: SignalingMessage): void | Promise<void>;
/**
* Subscribe to incoming signaling messages addressed to the local peer.
* Returns an unsubscribe function.
*/
onMessage(handler: SignalingHandler): () => void;
/**
* Open the signaling connection.
*/
connect(): void | Promise<void>;
/**
* Close the signaling connection.
*/
close(): void | Promise<void>;
/**
* Whether the signaling connection is currently open.
*/
readonly isOpen: boolean;
}

View File

@@ -0,0 +1,278 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { SignalingClient, SignalingMessage } from "./signaling-client.js";
import {
type WebSocketLike,
WebSocketSignalingClient,
} from "./websocket-signaling-client.js";
class FakeSocket implements WebSocketLike {
public readyState = 0;
public onopen: WebSocketLike["onopen"] = null;
public onmessage: WebSocketLike["onmessage"] = null;
public onclose: WebSocketLike["onclose"] = null;
public onerror: WebSocketLike["onerror"] = null;
public sent: string[] = [];
public closed = false;
public open(): void {
this.readyState = 1;
this.onopen?.call(this, undefined);
}
public errorBeforeOpen(): void {
this.onerror?.call(this, undefined);
}
public emit(data: string): void {
this.onmessage?.call(this, { data });
}
public send(data: string): void {
this.sent.push(data);
}
public close(): void {
this.closed = true;
this.readyState = 3;
this.onclose?.call(this, undefined);
}
}
describe("SignalingClient interface", () => {
it("requires send/onMessage/connect/close + isOpen", () => {
const client: SignalingClient = {
isOpen: false,
connect: vi.fn(),
close: vi.fn(),
send: vi.fn(),
onMessage: vi.fn(() => () => {}),
};
expect(typeof client.send).toBe("function");
expect(typeof client.onMessage).toBe("function");
expect(typeof client.connect).toBe("function");
expect(typeof client.close).toBe("function");
expect(typeof client.isOpen).toBe("boolean");
});
});
describe("WebSocketSignalingClient", () => {
function makeClient(): {
client: WebSocketSignalingClient;
socket: FakeSocket;
} {
const socket = new FakeSocket();
const client = new WebSocketSignalingClient({
url: "ws://test",
webSocketFactory: () => socket,
reconnectDelayMs: 5,
maxReconnects: 2,
});
return { client, socket };
}
it("connects and resolves on onopen", async () => {
const { client, socket } = makeClient();
const promise = client.connect();
socket.open();
await promise;
expect(client.isOpen).toBe(true);
});
it("sends JSON-encoded messages", async () => {
const { client, socket } = makeClient();
const promise = client.connect();
socket.open();
await promise;
const msg: SignalingMessage = {
type: "offer",
from: "a",
to: "b",
payload: "{}",
};
client.send(msg);
expect(socket.sent).toEqual([JSON.stringify(msg)]);
});
it("rejects send when not open", () => {
const { client } = makeClient();
expect(() =>
client.send({
type: "offer",
from: "a",
to: "b",
payload: "{}",
})
).toThrow("signaling.not-open");
});
it("delivers parsed messages to handlers", async () => {
const { client, socket } = makeClient();
const promise = client.connect();
socket.open();
await promise;
const seen: SignalingMessage[] = [];
client.onMessage((m) => seen.push(m));
socket.emit(
JSON.stringify({
type: "answer",
from: "b",
to: "a",
payload: "{}",
})
);
expect(seen).toHaveLength(1);
expect(seen[0]?.type).toBe("answer");
});
it("drops malformed payloads silently", async () => {
const { client, socket } = makeClient();
const promise = client.connect();
socket.open();
await promise;
const seen: SignalingMessage[] = [];
client.onMessage((m) => seen.push(m));
socket.emit("not json");
socket.emit(
new TextEncoder().encode("binary").buffer as unknown as string
);
expect(seen).toHaveLength(0);
});
it("rejects connect when socket errors before open", async () => {
const { client, socket } = makeClient();
const promise = client.connect();
socket.errorBeforeOpen();
await expect(promise).rejects.toThrow("signaling.connect-failed");
});
it("schedules reconnects up to maxReconnects on close", async () => {
let count = 0;
const sockets: FakeSocket[] = [];
const client = new WebSocketSignalingClient({
url: "ws://t",
webSocketFactory: () => {
count += 1;
const s = new FakeSocket();
sockets.push(s);
queueMicrotask(() => s.open());
return s;
},
reconnectDelayMs: 1,
maxReconnects: 2,
});
await client.connect();
sockets[0]!.close();
await new Promise((r) => setTimeout(r, 5));
sockets[1]!.close();
await new Promise((r) => setTimeout(r, 5));
expect(count).toBeGreaterThanOrEqual(3);
client.close();
});
it("reconnect failure surfaces via the openOnce catch handler", async () => {
let count = 0;
const sockets: FakeSocket[] = [];
const client = new WebSocketSignalingClient({
url: "ws://t",
webSocketFactory: () => {
count += 1;
const s = new FakeSocket();
sockets.push(s);
if (count === 1) {
queueMicrotask(() => s.open());
} else {
queueMicrotask(() => s.errorBeforeOpen());
}
return s;
},
reconnectDelayMs: 1,
maxReconnects: 1,
});
await client.connect();
sockets[0]!.close();
// wait long enough for the single reconnect attempt to fire
await new Promise((r) => setTimeout(r, 15));
expect(count).toBe(2);
expect(client.isOpen).toBe(false);
client.close();
});
it("stops scheduling reconnects once maxReconnects is reached", async () => {
let count = 0;
const sockets: FakeSocket[] = [];
const client = new WebSocketSignalingClient({
url: "ws://t",
webSocketFactory: () => {
count += 1;
const s = new FakeSocket();
sockets.push(s);
if (count === 1) {
queueMicrotask(() => s.open());
} else {
// every reconnect fails: error then close
queueMicrotask(() => {
s.errorBeforeOpen();
s.close();
});
}
return s;
},
reconnectDelayMs: 1,
maxReconnects: 1,
});
await client.connect();
sockets[0]!.close();
await new Promise((r) => setTimeout(r, 30));
// first reconnect attempt fails; the second schedule call hits the cap
expect(count).toBe(2);
client.close();
});
it("close cancels pending reconnect timers", async () => {
const { client, socket } = makeClient();
const promise = client.connect();
socket.open();
await promise;
socket.close();
client.close();
// No assertion: passing means no unhandled timer kept the test alive.
expect(client.isOpen).toBe(false);
});
it("uses default reconnect/cap when not configured", () => {
const client = new WebSocketSignalingClient({
url: "ws://t",
webSocketFactory: () => new FakeSocket(),
});
expect(client.isOpen).toBe(false);
});
it("ignores onerror events fired after the socket is open", async () => {
const socket = new FakeSocket();
const client = new WebSocketSignalingClient({
url: "ws://t",
webSocketFactory: () => socket,
});
const promise = client.connect();
socket.open();
await promise;
// socket is open — a stray onerror should not reject anything
socket.onerror?.call(socket, undefined);
expect(client.isOpen).toBe(true);
});
it("returns an unsubscribe handle from onMessage", async () => {
const { client, socket } = makeClient();
const promise = client.connect();
socket.open();
await promise;
const seen: SignalingMessage[] = [];
const off = client.onMessage((m) => seen.push(m));
off();
socket.emit(
JSON.stringify({
type: "ice-candidate",
from: "b",
to: "a",
payload: "{}",
})
);
expect(seen).toHaveLength(0);
});
});

View File

@@ -0,0 +1,92 @@
# WebSocketSignalingClient
## Purpose
Concrete `SignalingClient` implementation backed by a WebSocket. Handles the boring-but-load-bearing pieces of any signaling rig: open lifecycle, JSON envelope encode/decode, error surfacing, and reconnection with a hard attempt cap so a flaky relay does not spin forever. The WebSocket itself is injected through a factory so tests can swap in any structurally-compatible mock.
## Features
- Promise-based `connect()` resolves on first `onopen` and rejects on early `onerror`.
- Configurable `reconnectDelayMs` (default 1000 ms) and `maxReconnects` (default 5).
- Auto-reconnect on involuntary close; honours `close()` as an explicit teardown that suppresses reconnects.
- JSON envelope encode/decode with silent drop on malformed messages.
- `WebSocketFactory` injection for deterministic tests.
- Throws `SIGNALING_ERROR` on `send` while not open, and on `connect` when the socket errors before opening.
## Usage: Basic Example
```ts
import { WebSocketSignalingClient } from "@suaveplan/network-transport-webrtc";
const client = new WebSocketSignalingClient({
url: "wss://signaling.example.com/mesh",
webSocketFactory: (url) => new WebSocket(url) as never,
});
await client.connect();
const off = client.onMessage((msg) => {
console.log(msg.type, msg.from, "→", msg.to);
});
client.send({
type: "offer",
from: "alice",
to: "bob",
payload: JSON.stringify({ sdp: "..." }),
});
off();
client.close();
```
## Usage: Advanced Example
```ts
import {
WebSocketSignalingClient,
type WebSocketLike,
} from "@suaveplan/network-transport-webrtc";
class FakeSocket implements WebSocketLike {
public readyState = 1;
public onopen: WebSocketLike["onopen"] = null;
public onmessage: WebSocketLike["onmessage"] = null;
public onclose: WebSocketLike["onclose"] = null;
public onerror: WebSocketLike["onerror"] = null;
public sent: string[] = [];
public send(data: string): void {
this.sent.push(data);
}
public close(): void {
this.onclose?.call(this, undefined);
}
}
const sockets: FakeSocket[] = [];
const client = new WebSocketSignalingClient({
url: "ws://test",
webSocketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
queueMicrotask(() => s.onopen?.call(s, undefined));
return s;
},
reconnectDelayMs: 5,
maxReconnects: 2,
});
await client.connect();
sockets[0]!.close();
// reconnects up to 2 times after 5 ms each
```
## API Reference
- `new WebSocketSignalingClient(config: WebSocketSignalingConfig)` — `url`, `webSocketFactory`, optional `reconnectDelayMs`, `maxReconnects`.
- `connect(): Promise<void>` — opens the socket; resolves on `onopen`.
- `close(): void` — explicit teardown, cancels pending reconnects.
- `send(message)` — JSON-encodes and writes to the socket; throws `NET_WEBRTC.SIGNALING_ERROR` if not open.
- `onMessage(handler) → () => void` — register/unregister inbound handler.
- `isOpen: boolean` — true iff `readyState === 1`.
- `interface WebSocketLike` and `type WebSocketFactory` for injection.
## Implementation Notes
A failed reconnect chains via the `onclose` handler: `openOnce` rejection triggers another `scheduleReconnect`. The total number of reconnect attempts is bounded by `maxReconnects`. The reconnect timer is cancelled on explicit `close` so callers do not see ghost connection attempts after teardown. Malformed payloads are dropped silently because signaling is best-effort and the source SDP can always be retried by the higher layer.

View File

@@ -0,0 +1,165 @@
import { NETWORK_WEBRTC_CODES, NetworkWebRTCError } from "../error-codes.js";
import type {
SignalingClient,
SignalingHandler,
SignalingMessage,
} from "./signaling-client.js";
/**
* Minimal `WebSocket` shape needed by `WebSocketSignalingClient`.
*/
export interface WebSocketLike {
send(data: string): void;
close(): void;
readonly readyState: number;
onopen: ((this: WebSocketLike, ev: unknown) => unknown) | null;
onmessage:
| ((this: WebSocketLike, ev: { data: string | ArrayBuffer }) => unknown)
| null;
onclose: ((this: WebSocketLike, ev: unknown) => unknown) | null;
onerror: ((this: WebSocketLike, ev: unknown) => unknown) | null;
}
/**
* Factory that yields a fresh `WebSocketLike` for a given URL.
*/
export type WebSocketFactory = (url: string) => WebSocketLike;
/**
* Configuration for the WebSocket-backed signaling client.
*/
export interface WebSocketSignalingConfig {
readonly url: string;
readonly webSocketFactory: WebSocketFactory;
readonly reconnectDelayMs?: number;
readonly maxReconnects?: number;
}
const WS_OPEN = 1;
/**
* WebSocket-backed signaling client with auto-reconnect.
*
* Connection lifecycle:
*
* 1. `connect()` opens the socket and resolves once `onopen` fires
* (or rejects if the socket errors before opening).
* 2. Each inbound `message` event is JSON-parsed and dispatched to
* every registered `SignalingHandler`. Malformed messages are
* silently dropped — they are equivalent to dropped packets.
* 3. On `close` the client schedules a reconnect with the configured
* delay, up to `maxReconnects` attempts. After the cap, the client
* stays closed and emits no further messages.
*/
export class WebSocketSignalingClient implements SignalingClient {
private socket: WebSocketLike | null = null;
private readonly handlers = new Set<SignalingHandler>();
private readonly url: string;
private readonly factory: WebSocketFactory;
private readonly reconnectDelayMs: number;
private readonly maxReconnects: number;
private reconnectCount = 0;
private explicitClose = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
public constructor(config: WebSocketSignalingConfig) {
this.url = config.url;
this.factory = config.webSocketFactory;
this.reconnectDelayMs = config.reconnectDelayMs ?? 1000;
this.maxReconnects = config.maxReconnects ?? 5;
}
public get isOpen(): boolean {
return this.socket?.readyState === WS_OPEN;
}
public connect(): Promise<void> {
this.explicitClose = false;
return this.openOnce();
}
private openOnce(): Promise<void> {
return new Promise((resolve, reject) => {
const socket = this.factory(this.url);
this.socket = socket;
socket.onopen = () => {
this.reconnectCount = 0;
resolve();
};
socket.onmessage = (ev) => {
this.handleRaw(ev.data);
};
socket.onclose = () => {
this.socket = null;
if (!this.explicitClose) {
this.scheduleReconnect();
}
};
socket.onerror = () => {
if (socket.readyState !== WS_OPEN) {
reject(
new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.SIGNALING_ERROR,
"signaling.connect-failed"
)
);
}
};
});
}
private scheduleReconnect(): void {
if (this.reconnectCount >= this.maxReconnects) {
return;
}
this.reconnectCount += 1;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.openOnce().catch(() => {
// openOnce will schedule the next reconnect via onclose
});
}, this.reconnectDelayMs);
}
public send(message: SignalingMessage): void {
if (!this.isOpen || this.socket === null) {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.SIGNALING_ERROR,
"signaling.not-open"
);
}
this.socket.send(JSON.stringify(message));
}
public onMessage(handler: SignalingHandler): () => void {
this.handlers.add(handler);
return () => {
this.handlers.delete(handler);
};
}
public close(): void {
this.explicitClose = true;
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.socket?.close();
this.socket = null;
this.handlers.clear();
}
private handleRaw(data: string | ArrayBuffer): void {
if (typeof data !== "string") {
return;
}
try {
const parsed = JSON.parse(data) as SignalingMessage;
for (const handler of this.handlers) {
handler(parsed);
}
} catch {
// Malformed signaling messages are silently dropped.
}
}
}

View File

@@ -0,0 +1,63 @@
# WebRTCTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `Transport` interface from `@suaveplan/network` using WebRTC data channels. This transport enables peer-to-peer messaging in mesh networks without requiring a central server for data relay. It wraps `RTCPeerConnection` and `RTCDataChannel` with a mockable abstraction so it can be fully tested in Node.js.
## Features
- Full `Transport` interface implementation
- Injectable `PeerConnectionLike` factory for testability
- JSON serialization via `@suaveplan/serialize`
- Automatic peer event emission (connected/disconnected)
- Graceful close with cleanup
- Idempotent connect
## Usage
### Basic Example
```ts
import { WebRTCTransport } from "@suaveplan/network-transport-webrtc";
const transport = new WebRTCTransport();
await transport.connect("peer-1", "signal-addr");
```
### Advanced Example
```ts
import { WebRTCTransport } from "@suaveplan/network-transport-webrtc";
const transport = new WebRTCTransport({
peerConnectionFactory: (peerId) => new RTCPeerConnection({ iceServers: [] }),
dataChannelLabel: "mesh",
});
transport.onMessage((peerId, envelope) => {
console.log("Received from", peerId, envelope);
});
transport.onPeerEvent((peerId, event) => {
console.log(peerId, event);
});
```
## API Reference
- `constructor(config?)` — create transport with optional factory and label
- `id` — always `"webrtc"`
- `connect(peerId, address)` — initiate connection
- `disconnect(peerId)` — close specific peer connection
- `send(peerId, envelope)` — send envelope to peer
- `onMessage(handler)` — subscribe to messages
- `onPeerEvent(handler)` — subscribe to connect/disconnect events
- `getConnectedPeers()` — list peers with open data channels
- `close()` — shut down all connections
## Implementation Notes
The transport maintains an internal `Map` of peer connections. Each connection has a data channel that is created immediately during `connect()`. The transport listens for `onopen`, `onmessage`, `onclose`, and `onerror` events on each channel. Messages are serialized to JSON strings using `@suaveplan/serialize` before sending, and deserialized on receipt. Malformed messages are silently dropped to prevent a single bad peer from crashing the transport. The `close()` method iterates all connections, closes channels and connections, and clears all internal state.

View File

@@ -0,0 +1,376 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { NETWORK_WEBRTC_CODES, NetworkWebRTCError } from "../error-codes.js";
import type { DataChannelLike, PeerConnectionLike } from "../types/types.js";
import { WebRTCTransport } from "./transport.js";
function createMockChannel(initialState = "connecting"): DataChannelLike {
const handlers: Record<string, ((ev: unknown) => unknown) | null> = {
onopen: null,
onmessage: null,
onclose: null,
onerror: null,
};
let readyState = initialState;
return {
get readyState() {
return readyState;
},
set onopen(fn) {
handlers.onopen = fn;
},
set onmessage(fn) {
handlers.onmessage = fn;
},
set onclose(fn) {
handlers.onclose = fn;
},
set onerror(fn) {
handlers.onerror = fn;
},
send(data) {
if (readyState !== "open") {
throw new Error("Channel not open");
}
// eslint-disable-next-line no-console
console.log("sent", data);
},
close() {
readyState = "closed";
handlers.onclose?.({});
},
// expose for tests
_open() {
readyState = "open";
handlers.onopen?.({});
},
_emitMessage(data: string) {
handlers.onmessage?.({ data } as unknown as MessageEvent);
},
} as unknown as DataChannelLike;
}
function createMockConnection(channel: DataChannelLike): PeerConnectionLike {
return {
createDataChannel() {
return channel;
},
close() {},
get connectionState() {
return "connected";
},
};
}
describe("WebRTCTransport", () => {
it("must have id 'webrtc'", () => {
const transport = new WebRTCTransport();
expect(transport.id).toBe("webrtc");
});
it("must connect and track peers", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
await transport.connect("p1", "addr1");
(channel as unknown as { _open(): void })._open();
expect(transport.getConnectedPeers()).toContain("p1");
});
it("must emit connected event when channel opens", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
const events: Array<{ peerId: string; event: string }> = [];
transport.onPeerEvent((peerId, event) => {
events.push({ peerId, event });
});
await transport.connect("p1", "addr1");
(channel as unknown as { _open(): void })._open();
expect(events).toContainEqual({ peerId: "p1", event: "connected" });
});
it("must disconnect and remove peers", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
await transport.connect("p1", "addr1");
(channel as unknown as { _open(): void })._open();
await transport.disconnect("p1");
expect(transport.getConnectedPeers()).not.toContain("p1");
});
it("must send data to connected peers", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
const sendSpy = vi.spyOn(channel, "send").mockImplementation(() => {});
await transport.connect("p1", "addr1");
(channel as unknown as { _open(): void })._open();
const envelope: MessageEnvelope = {
id: "m1",
type: "test",
senderId: "self",
payload: { hello: "world" },
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
};
await transport.send("p1", envelope);
expect(sendSpy).toHaveBeenCalledOnce();
});
it("must throw when sending to unknown peer", async () => {
const transport = new WebRTCTransport({
peerConnectionFactory: () =>
createMockConnection(createMockChannel()),
});
const envelope: MessageEnvelope = {
id: "m1",
type: "test",
senderId: "self",
payload: {},
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
};
await expect(transport.send("p1", envelope)).rejects.toThrow(
NetworkWebRTCError
);
try {
await transport.send("p1", envelope);
} catch (err) {
expect((err as NetworkWebRTCError).code).toBe(
NETWORK_WEBRTC_CODES.PEER_NOT_CONNECTED
);
}
});
it("must deliver received messages to handlers", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
const messages: Array<{ peerId: string; envelope: MessageEnvelope }> =
[];
transport.onMessage((peerId, envelope) => {
messages.push({ peerId, envelope });
});
await transport.connect("p1", "addr1");
(channel as unknown as { _open(): void })._open();
const envelope: MessageEnvelope = {
id: "m1",
type: "test",
senderId: "p1",
payload: { hello: "world" },
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
};
const payload = JSON.stringify(envelope);
(
channel as unknown as { _emitMessage(data: string): void }
)._emitMessage(payload);
expect(messages.length).toBe(1);
expect(messages[0].peerId).toBe("p1");
expect(messages[0].envelope.id).toBe("m1");
});
it("must close all peers on close", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
await transport.connect("p1", "addr1");
(channel as unknown as { _open(): void })._open();
await transport.close();
expect(transport.getConnectedPeers().length).toBe(0);
});
it("must throw when connecting after close", async () => {
const transport = new WebRTCTransport({
peerConnectionFactory: () =>
createMockConnection(createMockChannel()),
});
await transport.close();
await expect(transport.connect("p1", "addr1")).rejects.toThrow(
NetworkWebRTCError
);
});
it("must allow idempotent connect", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
await transport.connect("p1", "addr1");
await transport.connect("p1", "addr1");
expect(transport.getConnectedPeers().length).toBe(0); // channel not opened yet
});
it("close is idempotent", async () => {
const transport = new WebRTCTransport({
peerConnectionFactory: () =>
createMockConnection(createMockChannel()),
});
await transport.close();
await transport.close();
});
it("ignores non-string inbound payloads", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
const seen: number[] = [];
transport.onMessage(() => seen.push(1));
await transport.connect("p1", "addr");
(channel as unknown as { _open(): void })._open();
// emit a non-string payload — must be silently dropped
(
channel as unknown as {
_emitMessage(data: string | ArrayBuffer): void;
}
)._emitMessage(new ArrayBuffer(4) as never);
expect(seen.length).toBe(0);
});
it("throws TRANSPORT_CLOSED when sending after close", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
await transport.connect("p1", "addr");
(channel as unknown as { _open(): void })._open();
await transport.close();
await expect(
transport.send("p1", {
id: "x",
type: "y",
senderId: "self",
payload: null,
timestamp: 1,
headers: {},
ttl: 64,
visitedNodes: [],
})
).rejects.toThrow();
});
it("throws DATA_CHANNEL_CLOSED when channel readyState is not open", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
await transport.connect("p1", "addr");
try {
await transport.send("p1", {
id: "x",
type: "y",
senderId: "self",
payload: null,
timestamp: 1,
headers: {},
ttl: 64,
visitedNodes: [],
});
} catch (err) {
expect((err as NetworkWebRTCError).code).toBe(
NETWORK_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
});
it("wraps a channel.send throw as SEND_FAILED", async () => {
const channel = createMockChannel();
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
await transport.connect("p1", "addr");
(channel as unknown as { _open(): void })._open();
// overwrite send so it throws after the channel is open
(channel as unknown as { send: (data: unknown) => void }).send = () => {
throw new Error("xport boom");
};
try {
await transport.send("p1", {
id: "x",
type: "y",
senderId: "self",
payload: null,
timestamp: 1,
headers: {},
ttl: 64,
visitedNodes: [],
});
expect.fail("expected throw");
} catch (err) {
expect((err as NetworkWebRTCError).code).toBe(
NETWORK_WEBRTC_CODES.SEND_FAILED
);
}
});
it("emits disconnected on channel error", async () => {
let onErrorRef: ((ev: unknown) => unknown) | null = null;
const channel: DataChannelLike = {
readyState: "open",
send: vi.fn(),
close: vi.fn(),
onopen: null,
onmessage: null,
onclose: null,
set onerror(fn) {
onErrorRef = fn as never;
},
get onerror() {
return onErrorRef;
},
} as never;
const transport = new WebRTCTransport({
peerConnectionFactory: () => createMockConnection(channel),
});
const events: Array<{ peerId: string; event: string }> = [];
transport.onPeerEvent((peerId, event) => {
events.push({ peerId, event });
});
await transport.connect("p1", "addr");
onErrorRef?.(undefined);
expect(events.some((e) => e.event === "disconnected")).toBe(true);
expect(transport.getConnectedPeers()).not.toContain("p1");
});
it("throws CONNECTION_FAILED when no factory is configured", async () => {
const transport = new WebRTCTransport();
await expect(transport.connect("p1", "addr")).rejects.toThrow(
"webrtc.no-factory"
);
});
it("disconnect of an unknown peer is a no-op", async () => {
const transport = new WebRTCTransport({
peerConnectionFactory: () =>
createMockConnection(createMockChannel()),
});
await transport.disconnect("ghost");
});
it("returns an unsubscribe handle from onMessage and onPeerEvent", async () => {
const transport = new WebRTCTransport({
peerConnectionFactory: () =>
createMockConnection(createMockChannel()),
});
const offMsg = transport.onMessage(() => {});
const offEvt = transport.onPeerEvent(() => {});
offMsg();
offEvt();
});
});

View File

@@ -0,0 +1,191 @@
import type { MessageEnvelope, PeerId, Transport } from "@suaveplan/network";
import { deserialize, serialize } from "@suaveplan/serialize";
import { NETWORK_WEBRTC_CODES, NetworkWebRTCError } from "../error-codes.js";
import type {
MessageHandler,
PeerConnectionFactory,
PeerConnectionState,
PeerEventHandler,
WebRTCConfig,
} from "../types/types.js";
/**
* WebRTC data-channel transport implementing the mesh `Transport` interface.
*
* The transport is testable via injectable `PeerConnectionLike` factories.
*/
export class WebRTCTransport implements Transport {
public readonly id = "webrtc";
private readonly peers = new Map<PeerId, PeerConnectionState>();
private readonly messageHandlers = new Set<MessageHandler>();
private readonly peerEventHandlers = new Set<PeerEventHandler>();
private readonly peerConnectionFactory: PeerConnectionFactory;
private readonly dataChannelLabel: string;
private closed = false;
public constructor(config: WebRTCConfig = {}) {
this.peerConnectionFactory =
config.peerConnectionFactory ??
(() => {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.CONNECTION_FAILED,
"webrtc.no-factory"
);
});
this.dataChannelLabel = config.dataChannelLabel ?? "mesh";
}
public async connect(peerId: PeerId, address: string): Promise<void> {
if (this.closed) {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.TRANSPORT_CLOSED,
"webrtc.transport-closed"
);
}
if (this.peers.has(peerId)) {
return;
}
const connection = this.peerConnectionFactory(peerId);
const channel = connection.createDataChannel(this.dataChannelLabel, {
ordered: true,
});
const state: PeerConnectionState = {
peerId,
connection,
channel,
address,
};
this.peers.set(peerId, state);
channel.onopen = () => {
this.emitPeerEvent(peerId, "connected");
};
channel.onmessage = (ev) => {
this.handleMessage(peerId, ev.data);
};
channel.onclose = () => {
this.emitPeerEvent(peerId, "disconnected");
this.peers.delete(peerId);
};
channel.onerror = () => {
this.emitPeerEvent(peerId, "disconnected");
this.peers.delete(peerId);
};
if (channel.readyState === "open") {
this.emitPeerEvent(peerId, "connected");
}
}
public async disconnect(peerId: PeerId): Promise<void> {
const state = this.peers.get(peerId);
if (!state) {
return;
}
state.channel.close();
state.connection.close();
this.peers.delete(peerId);
this.emitPeerEvent(peerId, "disconnected");
}
public async send(
peerId: PeerId,
envelope: MessageEnvelope
): Promise<void> {
if (this.closed) {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.TRANSPORT_CLOSED,
"webrtc.transport-closed"
);
}
const state = this.peers.get(peerId);
if (!state) {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.PEER_NOT_CONNECTED,
"webrtc.peer-not-connected",
{ context: { peerId } }
);
}
if (state.channel.readyState !== "open") {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
"webrtc.data-channel-closed",
{ context: { peerId } }
);
}
try {
const payload = serialize(envelope);
state.channel.send(payload);
} catch (cause) {
throw new NetworkWebRTCError(
NETWORK_WEBRTC_CODES.SEND_FAILED,
"webrtc.send-failed",
{ context: { peerId }, cause: cause as Error }
);
}
}
public onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler);
return () => {
this.messageHandlers.delete(handler);
};
}
public onPeerEvent(handler: PeerEventHandler): () => void {
this.peerEventHandlers.add(handler);
return () => {
this.peerEventHandlers.delete(handler);
};
}
public getConnectedPeers(): readonly PeerId[] {
const connected: PeerId[] = [];
for (const [peerId, state] of this.peers) {
if (state.channel.readyState === "open") {
connected.push(peerId);
}
}
return connected;
}
public async close(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
for (const [peerId, state] of this.peers) {
state.channel.close();
state.connection.close();
this.emitPeerEvent(peerId, "disconnected");
}
this.peers.clear();
this.messageHandlers.clear();
this.peerEventHandlers.clear();
}
private handleMessage(peerId: PeerId, data: unknown): void {
if (typeof data !== "string") {
return;
}
try {
const envelope = deserialize(data) as MessageEnvelope;
for (const handler of this.messageHandlers) {
handler(peerId, envelope);
}
} catch {
// Silently drop malformed messages
}
}
private emitPeerEvent(
peerId: PeerId,
event: "connected" | "disconnected"
): void {
for (const handler of this.peerEventHandlers) {
handler(peerId, event);
}
}
}

View File

@@ -0,0 +1,55 @@
# WebRTC Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the type definitions and interfaces used by the WebRTC transport plugin. These types are designed to be mockable so the transport can be fully unit-tested in Node.js without requiring a real browser WebRTC implementation.
## Features
- Mockable `PeerConnectionLike` interface
- Mockable `DataChannelLike` interface
- `PeerConnectionFactory` for injectable construction
- `WebRTCConfig` for transport configuration
- Internal `PeerConnectionState` for tracking active connections
## Usage
### Basic Example
```ts
import type { WebRTCConfig } from "@suaveplan/network-transport-webrtc";
const config: WebRTCConfig = {
dataChannelLabel: "mesh",
};
```
### Advanced Example
```ts
import type { PeerConnectionLike, DataChannelLike } from "@suaveplan/network-transport-webrtc";
const mockConnection: PeerConnectionLike = {
createDataChannel: () => mockChannel,
close: () => {},
connectionState: "connected",
};
```
## API Reference
- `PeerConnectionLike` — minimal RTCPeerConnection interface
- `DataChannelLike` — minimal RTCDataChannel interface
- `MessageEventLike` — minimal MessageEvent interface
- `PeerConnectionFactory` — factory function signature
- `WebRTCConfig` — transport configuration object
- `PeerConnectionState` — internal connection tracking
- `MessageHandler` — incoming message callback type
- `PeerEventHandler` — peer event callback type
## Implementation Notes
The interfaces are intentionally minimal — they include only the methods and properties required by `WebRTCTransport`. This keeps the mock surface area small and makes tests easy to write. The real browser `RTCPeerConnection` and `RTCDataChannel` objects implement many more properties, but the transport only needs a subset. If you need to use the transport in a browser, you can pass the native constructors directly via a factory adapter. The `PeerConnectionFactory` receives the target `PeerId` as an argument so the factory can create connection-specific configurations (for example, setting a custom ICE server per peer). The `WebRTCConfig` object is fully serializable and contains no functions by default, making it easy to store or transmit configuration templates. The `PeerConnectionState` interface is used internally by the transport and is exposed for advanced use cases such as connection monitoring or custom signaling implementations.

View File

@@ -0,0 +1,71 @@
import type { MessageEnvelope, PeerId } from "@suaveplan/network";
/**
* Minimal mockable RTCPeerConnection interface.
*/
export interface PeerConnectionLike {
createDataChannel(label: string, init?: unknown): DataChannelLike;
close(): void;
readonly connectionState: string;
}
/**
* Minimal mockable RTCDataChannel interface.
*/
export interface DataChannelLike {
send(data: string | ArrayBufferLike): void;
close(): void;
readonly readyState: string;
onopen: ((this: DataChannelLike, ev: unknown) => unknown) | null;
onmessage:
| ((this: DataChannelLike, ev: MessageEventLike) => unknown)
| null;
onclose: ((this: DataChannelLike, ev: unknown) => unknown) | null;
onerror: ((this: DataChannelLike, ev: unknown) => unknown) | null;
}
/**
* Minimal mockable MessageEvent interface for data channels.
*/
export interface MessageEventLike {
data: string | ArrayBufferLike;
}
/**
* Factory for creating peer connections.
*/
export type PeerConnectionFactory = (peerId: PeerId) => PeerConnectionLike;
/**
* Configuration for WebRTC transport.
*/
export interface WebRTCConfig {
readonly peerConnectionFactory?: PeerConnectionFactory;
readonly dataChannelLabel?: string;
}
/**
* Internal peer connection state.
*/
export interface PeerConnectionState {
readonly peerId: PeerId;
readonly connection: PeerConnectionLike;
readonly channel: DataChannelLike;
readonly address: string;
}
/**
* Handler for incoming messages.
*/
export type MessageHandler = (
peerId: PeerId,
envelope: MessageEnvelope
) => void;
/**
* Handler for peer connect/disconnect events.
*/
export type PeerEventHandler = (
peerId: PeerId,
event: "connected" | "disconnected"
) => void;

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-webrtc dep-graph", () => {
it("must not have circular dependencies", async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.WebRTCPlugin).toBeDefined();
expect(mod.WebRTCTransport).toBeDefined();
});
});

View File

@@ -0,0 +1,5 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) g.crypto = webcrypto as unknown as Crypto;

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-webrtc",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/serialize",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});