first commit

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,86 @@
# peer-connection-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the lifecycle of the `PeerLike` instance and the per-room `Map` of
WebRTC `DataConnectionLike` channels. Wraps the lower-level helpers in
`peerjs-connection.ts` behind a class so the orchestrator and the interval
loops can share one canonical view of the connection registry, the local
peer id, and the broker handle. Adds explicit `DISPOSED` semantics so a
caller cannot accidentally re-use a torn-down manager.
## Features
- `initialize(configuredPeerId, signal?)` resolves on broker `open`, threads
`AbortSignal` through, rejects with `SIGNALING_FAILED` on error.
- Incoming `peer.on("connection")` events flow through `acceptIncoming` →
`handleConnection` and emit the orchestrator's `onIncomingConnection`
callback for any per-peer bookkeeping (e.g. emitting `peerjs:peer-found`).
- `connectToPeer(peerId)` is idempotent on the registry and throws typed
errors instead of returning early.
- `broadcast(message)` fans out via `broadcastMessage`.
- `destroy()` closes every channel and destroys the `PeerLike`, swallowing
per-channel `close()` errors so disposal never throws halfway through.
## Usage: Basic Example
```ts
import { PeerConnectionManager } from "@suaveplan/network-discovery-peerjs/peer-connection-manager";
const mgr = new PeerConnectionManager(factory, peerOpts, {
onIncomingConnection: (c) => console.log("inbound", c.peer),
onMessage: (raw, c) => handleMessage(raw, c, deps),
onPeerLost: (id) => deps.handlePeerLost(id),
});
await mgr.initialize(undefined);
mgr.broadcast({ type: "announce", from: mgr.getMyPeerId()!, payload: meta, timestamp: Date.now() });
```
## Usage: Advanced Example
```ts
import { PeerConnectionManager } from "@suaveplan/network-discovery-peerjs";
const controller = new AbortController();
const mgr = new PeerConnectionManager(factory, peerOpts, callbacks);
try {
const myPeerId = await mgr.initialize(configured, controller.signal);
for (const target of seedPeers) {
try {
mgr.connectToPeer(target);
} catch (e) {
if ((e as { code?: string }).code === "NET_DISC_PEERJS.PEER_CONNECT_FAILED") {
failed.add(target);
}
}
}
} finally {
mgr.destroy();
}
```
## API Reference
- `new PeerConnectionManager(factory, peerOptions, callbacks)` — callbacks
are `{onIncomingConnection, onMessage, onPeerLost}`.
- `initialize(configuredPeerId?, signal?): Promise<string>` — returns the
assigned local peer id.
- `destroy(): void` — close + destroy. Idempotent.
- `getMyPeerId(): string | null`.
- `getConnections(): ReadonlyMap<string, DataConnectionLike>`.
- `hasConnection(peerId): boolean`.
- `removeConnection(peerId): void` — does not call `close()`.
- `connectToPeer(peerId): void` — throws `DISPOSED` or `PEER_CONNECT_FAILED`.
- `broadcast(message: PeerJSMessage): void`.
## Implementation Notes
The `disposed` flag is the only mutable lifecycle state — every guard
inspects it before touching the broker. `destroy()` clears the connections
map before destroying the peer so that any synchronous `close` event PeerJS
fires during teardown does not race against a registry mutation. The class
deliberately exposes `getConnections()` as a `ReadonlyMap` rather than a
cloned snapshot to keep the hot-path heartbeat broadcast allocation-free.

View File

@@ -0,0 +1,88 @@
# peerjs-connection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Thin helpers around the `PeerLike` / `DataConnectionLike` structural surface
that own the WebRTC data-channel lifecycle: opening the peer against the
signalling server, wiring up the inbound `connection` / `data` / `error` /
`close` events, opening outbound `peer.connect(...)` calls, and broadcasting
to every open connection. Keeping the helpers as pure functions over the
injectable interfaces means the orchestrator stays small and every helper is
trivially unit-testable without a real PeerJS broker.
## Features
- `initializePeer` resolves on `peer.on("open")`, rejects with
`SIGNALING_FAILED` on `peer.on("error")` or constructor-throw.
- `AbortSignal` support — pre-aborted signals reject immediately; mid-flight
aborts call `peer.destroy()` and reject with the signal's `reason`.
- `handleConnection` wires open/data/error/close idempotently and adds the
connection to a shared registry on `open`.
- `connectToPeer` is idempotent on the registry and translates
synchronous `peer.connect` failures to `PEER_CONNECT_FAILED`.
- `broadcastMessage` skips closed connections and swallows per-connection
`send` errors so a single misbehaving peer cannot break the broadcast.
- Defensive guard around the user-supplied `onMessage` callback — a thrown
handler does not kill the channel.
## Usage: Basic Example
```ts
import { initializePeer, broadcastMessage } from "@suaveplan/network-discovery-peerjs/peerjs-connection";
const { peer, myPeerId } = await initializePeer(factory, "", {
host: "signaling.example.com",
port: 443,
path: "/",
secure: true,
debug: 0,
config: { iceServers: [] },
});
broadcastMessage({ type: "heartbeat", from: myPeerId, payload: {}, timestamp: Date.now() }, connections);
```
## Usage: Advanced Example
```ts
import { connectToPeer, handleConnection } from "@suaveplan/network-discovery-peerjs";
const controller = new AbortController();
const { peer, myPeerId } = await initializePeer(factory, "", opts, controller.signal);
peer.on("connection", (conn) => handleConnection(conn, connections, onRaw, onLost));
for (const id of remotePeerIds) {
try {
connectToPeer(peer, id, connections, onRaw, onLost);
} catch (err) {
if ((err as { code?: string }).code === "NET_DISC_PEERJS.PEER_CONNECT_FAILED") {
failures.add(id);
}
}
}
```
## API Reference
- `initializePeer(factory, peerId, options, signal?) → Promise<{peer, myPeerId}>`.
Rejects with `NetDiscPeerJSError` code `SIGNALING_FAILED` on broker error.
- `handleConnection(connection, connections, onMessage, onPeerLost)` — wires
the four data-channel events, mutates the `connections` map on `open`.
- `connectToPeer(peer, peerId, connections, onMessage, onPeerLost) →
DataConnectionLike | undefined` — `undefined` when already connected;
throws `NetDiscPeerJSError` code `PEER_CONNECT_FAILED` on connect failure.
- `broadcastMessage(message, connections) → void` — fan-out send.
## Implementation Notes
The `settle` helper in `initializePeer` guarantees the promise resolves
exactly once even if `peer.on("open")` and `peer.on("error")` both fire (or
the signal aborts in between). The `signal.removeEventListener` cleanup
keeps the listener set bounded so this helper is safe to call repeatedly
across reconnects. Defensive `try/catch` around the user `onMessage` is the
only `catch {}` in the package — message handlers must never throw because
PeerJS surfaces unhandled exceptions to the data-channel error path and would
disconnect the peer.

View File

@@ -0,0 +1,101 @@
# peerjs-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level orchestrator for the PeerJS discovery strategy. Implements
`DiscoveryStrategy` from `@suaveplan/network-bootstrap` so the strategy can
slot directly into a `DiscoveryChain`. Wires the `PeerConnectionManager`,
`RoomManager`, `PeerJSIntervals`, message-handler dispatch table, and OTel
surface together behind a class that takes a single Zod-validated
`PeerJSDiscoveryConfig`. Refuses construction without an explicit `host`.
## Features
- Implements `DiscoveryStrategy` (`name`, `discover(timeoutMs, signal?)`,
`isAvailable(signal?)`).
- `start(signal?)` / `stop(signal?)` / `dispose()` lifecycle, all idempotent.
- Mandatory `host` + `peerFactory` — constructor throws `INVALID_OPTIONS`.
No public-`peerjs.com` fallback is reachable from this code.
- `AbortSignal` threaded through `start`, `stop`, `discover`,
`isAvailable`, `connect`.
- Optional `EventBusLike<PeerJSEventMap>` integration for typed event fan-out.
- Optional `ServiceRegistry` integration — discovered peers register under
the `peerjs:<room>` service name.
- OTel: `disc_peerjs.start` span, `peers_discovered_total{outcome}` counter,
`signaling_duration_ms` histogram.
- `DISPOSED` semantics — every public method after `dispose()` throws.
## Usage: Basic Example
```ts
import { PeerJSDiscovery } from "@suaveplan/network-discovery-peerjs";
const discovery = new PeerJSDiscovery({
host: "signaling.example.com",
room: "demo",
peerFactory: realPeerJsFactory,
});
await discovery.start();
const peers = await discovery.discover(5_000);
await discovery.stop();
```
## Usage: Advanced Example
```ts
import { PeerJSDiscovery } from "@suaveplan/network-discovery-peerjs";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
const bus = new EventBus<import("@suaveplan/network-discovery-peerjs/types").PeerJSEventMap>();
const registry = new ServiceRegistry({ maxServices: 1000, ttlMs: 120_000 });
const strategy = new PeerJSDiscovery({
host: "signaling.example.com",
port: 8443,
secure: true,
room: "alpha",
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
peerFactory: realPeerJsFactory,
bus,
registry,
});
bus.on("peerjs:peer-discovered", (e) => console.log("found", e.peer.id));
const chain = new DiscoveryChain({ strategies: [strategy] });
const controller = new AbortController();
const found = await chain.discover(2_000, controller.signal);
```
## API Reference
- `new PeerJSDiscovery(config, clock?)` — `clock` defaults to `Date.now`.
- `name: "peerjs"`.
- `config: ResolvedPeerJSDiscoveryConfig`.
- `start(signal?): Promise<void>`.
- `stop(signal?): Promise<void>`.
- `discover(timeoutMs, signal?): Promise<PeerInfo[]>` — returns bootstrap-
shaped `PeerInfo` records (`{peerId, addresses, lastSeen, source}`).
- `isAvailable(signal?): Promise<boolean>`.
- `connect(peerId, signal?): Promise<void>`.
- `getMyPeerId(): string | null`.
- `getPeers(): readonly PeerJSPeerRecord[]`.
- `getPeerCount(): number`.
- `dispose(): void`.
## Implementation Notes
The orchestrator deliberately keeps no state of its own beyond `running`,
`disposed`, and `myPeerId`: every other piece of state belongs to a
sub-module (room, connection manager, intervals). `start()` records signal
duration before flipping `running` so a slow broker handshake counts cleanly
even if it ends up failing. `stop()` short-circuits when called after abort,
skipping the `leave` broadcast because the transport is already gone. The
config is parsed via the strict Zod schema before any side effects so a
malformed call cannot wire up partial state.

View File

@@ -0,0 +1,87 @@
# peerjs-intervals
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Replaces upstream's three raw `setInterval` calls with a single `Scheduler`
from `@suaveplan/timing` running three self-rearming loops (announce,
heartbeat, cleanup). The injectable `Clock` makes every cadence deterministic
under test and the `scheduler.dispose()` atomicity guarantees `stop()` cannot
leak pending timers — the upstream package had no equivalent and required
manual `clearInterval` on every handle.
## Features
- Three self-rearming loops at independent cadences.
- Single underlying `Scheduler` — `stop()` disposes every pending task at
once.
- Injectable `Clock` everywhere (`clock()` is the canonical timestamp source).
- Idempotent `start()` / `stop()` — safe to call from any lifecycle hook.
- Optional `schedulerFactory` override for unit tests that need a fake
scheduler.
- Heartbeat and cleanup loops are pure; announce is async to mirror the
broadcast `await` semantics callers may expect.
## Usage: Basic Example
```ts
import { PeerJSIntervals } from "@suaveplan/network-discovery-peerjs/peerjs-intervals";
const intervals = new PeerJSIntervals({
clock: () => Date.now(),
announceIntervalMs: 30_000,
heartbeatIntervalMs: 15_000,
cleanupIntervalMs: 30_000,
timeoutMs: 60_000,
connections,
discoveredPeers,
getMyPeerId: () => myPeerId,
isRunning: () => running,
selfMetadata: () => ({ id: myPeerId, addresses: [], metadata: {}, lastSeen: Date.now() }),
handlePeerLost: (id) => removePeer(id),
});
intervals.start();
// later
intervals.stop();
```
## Usage: Advanced Example
```ts
import { PeerJSIntervals } from "@suaveplan/network-discovery-peerjs";
import { createScheduler } from "@suaveplan/timing";
let fakeNow = 0;
const fakeClock = () => fakeNow;
const intervals = new PeerJSIntervals(
{ ...deps, clock: fakeClock },
(clock) => createScheduler({ clock })
);
intervals.start();
fakeNow += 30_000;
// trigger scheduler ticks via the test scheduler in your harness...
intervals.stop();
```
## API Reference
- `PeerJSIntervalDeps` — `{clock, announceIntervalMs, heartbeatIntervalMs,
cleanupIntervalMs, timeoutMs, connections, discoveredPeers, getMyPeerId,
isRunning, selfMetadata, handlePeerLost}`.
- `new PeerJSIntervals(deps, schedulerFactory?)` — second arg overrides the
scheduler construction (defaults to `createScheduler({ clock })`).
- `start(): void` — kick off all three loops. Idempotent.
- `stop(): void` — dispose every pending timer. Idempotent.
- `isStopped: boolean` — true after `stop()`.
## Implementation Notes
Each `schedule(fn, delay)` callback re-arms itself at the tail to keep the
underlying scheduler one-shot. Re-arm is guarded by `stopped` and
`scheduler.disposed` so a callback that fires the same tick as `stop()` does
not enqueue a new task against a disposed scheduler. The announce loop awaits
the broadcast before re-arming so a slow signalling path stretches the
effective interval rather than stacking sends, which matches the upstream
behaviour but without the `setInterval` pile-up.

View File

@@ -0,0 +1,90 @@
# peerjs-message-handlers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure dispatch table for inbound PeerJS discovery messages. Validates every
incoming wire payload through `PeerJSMessageSchema` (Zod) at the boundary and
routes well-formed messages to the matching case: `announce`, `discover`,
`peer_list`, `leave`, `heartbeat`. The handlers are intentionally stateless —
they never mutate the peer set directly. State lives in the orchestrator's
`RoomManager`, which the handlers call via the `admitPeer` / `touchPeer` /
`onPeerLost` callbacks. This split avoids double-bookkeeping bugs where two
places race to set the same entry.
## Features
- Zod-validated boundary — malformed messages drop silently instead of
crashing the strategy (discovery must tolerate hostile peers).
- Exhaustive switch over the wire-format enum with safe-fallthrough.
- Stateless: every state mutation is delegated to caller-supplied callbacks.
- `admitPeer` returns `"added" | "refreshed" | "rejected"` so the room can
enforce `maxPeers` admission centrally; the handler only reports the
outcome (`false` when `"rejected"`).
- Discover-request replies are skipped when the connection is closed,
guarding against a race between `discover` arrival and channel teardown.
- `knownPeerIds()` is a callback so the handler does not need to hold a Map
reference, keeping memory ownership inside the room manager.
## Usage: Basic Example
```ts
import { handleMessage } from "@suaveplan/network-discovery-peerjs/peerjs-message-handlers";
handleMessage(raw, conn, {
myPeerId,
clock: () => Date.now(),
knownPeerIds: () => room.listPeers().map((p) => p.id),
admitPeer: (r) => room.admit(r),
touchPeer: (id) => room.touch(id),
onPeerLost: (id) => room.drop(id),
connectToPeer: (id) => discovery.connect(id),
});
```
## Usage: Advanced Example
```ts
import { handleMessage } from "@suaveplan/network-discovery-peerjs";
const deps = {
myPeerId: "alpha",
clock,
knownPeerIds: () => Array.from(state.keys()),
admitPeer(r) {
const action = state.has(r.id) ? "refreshed" : "added";
state.set(r.id, r);
if (action === "added") metrics.peerFound.inc();
return action;
},
touchPeer: (id) => (state.get(id) ? (state.get(id)!.lastSeen = clock(), true) : false),
onPeerLost: (id) => state.delete(id),
connectToPeer: (id) => discovery.connect(id),
};
socket.on("data", (raw) => {
const accepted = handleMessage(raw, connection, deps);
if (!accepted) logger.debug("dropped malformed peerjs message");
});
```
## API Reference
- `PeerJSMessageHandlerDeps` — `{myPeerId, clock, knownPeerIds, admitPeer,
touchPeer, onPeerLost, connectToPeer}`.
- `handleMessage(raw, connection, deps): boolean` — returns `true` when the
message was accepted and routed, `false` when it was dropped (Zod failure,
admission rejected, heartbeat for unknown peer, discover with no local
peer id, send-to-closed-connection).
## Implementation Notes
The module never throws — drop-on-error is intentional because PeerJS data
channels can carry payloads from arbitrary remote peers and a strategy that
crashes on bad input would create a trivial DoS vector. The `default` arm of
the switch is unreachable in practice because Zod's enum constraint already
rejects unknown types; an `/* c8 ignore */` annotation acknowledges that for
the coverage gate. Fire-and-forget on `connectToPeer` is the same model
upstream used — the orchestrator owns retry policy.

View File

@@ -0,0 +1,80 @@
# room-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the logical-room view of discovered peers, layered on top of the raw
PeerJS connection registry. Provides admission (`maxPeers` cap), TTL-driven
pruning, deduped `lastSeen` refresh, and optional fan-out to both an
`EventBusLike` and a `ServiceRegistry` so the strategy can mirror state into
shared discovery infrastructure without baking either dependency in.
## Features
- Deduped peer set with the `maxPeers` admission cap enforced inside
`admit()` — `ROOM_FULL` outcomes increment the OTel counter and return a
discriminated `"rejected"`.
- `touch(peerId)` updates `lastSeen` in place — used by heartbeats.
- `pruneStale(timeoutMs)` iterates the map once per cleanup tick and drops
every peer past TTL, emitting `peerjs:peer-lost` for each.
- Optional bus emissions on every state transition (`peerjs:peer-discovered`,
`peerjs:peer-lost`).
- Optional `ServiceRegistry` integration — discovered peers are registered as
`peerjs:<room>` services keyed by peer id, deregistered on drop.
## Usage: Basic Example
```ts
import { RoomManager } from "@suaveplan/network-discovery-peerjs/room-manager";
const room = new RoomManager(() => Date.now(), "demo", 100, undefined, undefined);
const action = room.admit({ id: "alpha", addresses: [], metadata: {}, lastSeen: Date.now() });
// action === "added"
```
## Usage: Advanced Example
```ts
import { RoomManager } from "@suaveplan/network-discovery-peerjs";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<import("@suaveplan/network-discovery-peerjs/types").PeerJSEventMap>();
const registry = new ServiceRegistry({ maxServices: 1000, ttlMs: 60_000 });
const room = new RoomManager(() => Date.now(), "alpha", 50, bus, registry);
bus.on("peerjs:peer-lost", (e) => console.log("lost", e.peerId));
// every announce arriving from the wire:
room.admit(record);
// every heartbeat:
room.touch(record.id);
// once per cleanup tick:
const dropped = room.pruneStale(60_000);
```
## API Reference
- `new RoomManager(clock, room, maxPeers, bus?, registry?)`.
- `getPeers(): ReadonlyMap<string, PeerJSPeerRecord>`.
- `listPeers(): readonly PeerJSPeerRecord[]`.
- `getPeer(peerId): PeerJSPeerRecord | undefined`.
- `size(): number`.
- `admit(record): "added" | "refreshed" | "rejected"`.
- `touch(peerId): boolean` — `false` if peer unknown.
- `drop(peerId): boolean` — `false` if peer unknown.
- `pruneStale(timeoutMs): number` — count dropped.
- `clear(): void` — drop every peer.
## Implementation Notes
`admit` writes a fresh record (spread-copy) on first admission to seal the
`metadata` and `capabilities` references against caller mutation, then
mutates `lastSeen` in place on subsequent refreshes to keep the hot heartbeat
path allocation-free. Pruning is done in a single pass over `entries()` and
uses `drop()` so emissions and registry deregistration stay consistent with
manual drops. The registry's `address` defaults to the first multiaddr or
falls back to the peer id so `ServiceRegistry.register` never rejects on an
empty `address` field.

View File

@@ -0,0 +1,78 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Structured-error registry for `@suaveplan/network-discovery-peerjs`. Defines the
five frozen `NET_DISC_PEERJS_*` codes every public boundary uses and the
`NetDiscPeerJSError` class that wraps them with `SuaveplanError`'s standard
chained-cause + i18n-key plumbing. Centralising the registry here keeps every
throw site consistent and makes branching on `error.code` reliable across the
package and any downstream `network-bootstrap` chain consuming the strategy.
## Features
- Frozen `defineCodes("NET_DISC_PEERJS", [...])` registry — code names are
string-literal types, not arbitrary strings.
- `NetDiscPeerJSError` extends `SuaveplanError` with the discriminated code type.
- Stable code surface for downstream callers that switch on `error.code`.
## Usage: Basic Example
```ts
import {
NET_DISC_PEERJS_CODES,
NetDiscPeerJSError,
} from "@suaveplan/network-discovery-peerjs";
throw new NetDiscPeerJSError(
NET_DISC_PEERJS_CODES.INVALID_OPTIONS,
"errors.peerjs.invalid_options"
);
```
## Usage: Advanced Example
```ts
import {
NET_DISC_PEERJS_CODES,
NetDiscPeerJSError,
type NetDiscPeerJSErrorCode,
} from "@suaveplan/network-discovery-peerjs";
function classify(err: unknown): NetDiscPeerJSErrorCode | "unknown" {
if (err instanceof NetDiscPeerJSError) return err.code;
return "unknown";
}
try {
await discovery.start();
} catch (err) {
const code = classify(err);
if (code === NET_DISC_PEERJS_CODES.SIGNALING_FAILED) {
// back off + retry against an alternate broker URL
}
}
```
## API Reference
- `NET_DISC_PEERJS_CODES` — frozen record with five members:
`SIGNALING_FAILED`, `PEER_CONNECT_FAILED`, `ROOM_FULL`, `INVALID_OPTIONS`,
`DISPOSED`.
- `NetDiscPeerJSErrorCode` — string-literal union derived from the registry.
- `NetDiscPeerJSError(code, messageKey, options?)` — `SuaveplanError` subclass.
Accepts the standard `SuaveplanErrorOptions` (cause, params, redaction).
## Implementation Notes
`SIGNALING_FAILED` is the wrapper for any error PeerJS surfaces during the
initial `peer.on("error")` handshake. `PEER_CONNECT_FAILED` is reserved for
post-handshake `peer.connect(...)` failures so call-sites can tell apart
"broker unreachable" from "specific peer unreachable". `ROOM_FULL` is emitted
proactively when the `maxPeers` cap would be exceeded — the strategy refuses
the new peer rather than evicting an existing one to keep room semantics
predictable. `DISPOSED` is thrown by every public method after `stop()` so
that lifecycle bugs surface loudly instead of silently no-op-ing.

View File

@@ -0,0 +1,101 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry surface for `@suaveplan/network-discovery-peerjs`. Owns the
tracer, meter, and instrument handles used by the strategy and its supporting
modules so every signal in the package is namespaced consistently
(`disc_peerjs.*`). Centralising instrument creation here prevents accidental
duplicate-instrument warnings from the OTel SDK when multiple modules want to
record the same metric.
## Features
- `withStartSpan(fn)` wraps `PeerJSDiscovery.start()` in an active span called
`disc_peerjs.start`, exposing the live span to the caller for attribute
enrichment.
- `recordPeersDiscovered(outcome, count?)` counter — labelled by `outcome` to
distinguish fresh peers (`found`), repeats (`duplicate`), and `ROOM_FULL`
rejections (`rejected`).
- `recordSignalingDuration(ms)` histogram of broker-handshake wall time.
- `withTransportSpan(name, fn)` wraps transport-tier async operations
(`peerjs_transport.connect`, `peerjs_transport.disconnect`,
`peerjs_transport.send`) in active spans.
- `recordTransportMessage(direction, outcome)` counter labelled by
`direction` (`inbound` / `outbound`) and `outcome` (`ok` / `error`).
- `adjustTransportActivePeers(delta)` up-down counter exposed as a gauge of
currently-connected transport peers.
- Single shared `tracer` + `meter` instance with stable names.
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
recordSignalingDuration,
withStartSpan,
} from "@suaveplan/network-discovery-peerjs";
await withStartSpan(async (span) => {
span.setAttribute("room", "demo");
const t = performance.now();
// ...open peer...
recordSignalingDuration(performance.now() - t);
recordPeersDiscovered("found", 1);
});
```
## Usage: Advanced Example
```ts
import { withStartSpan, recordPeersDiscovered } from "@suaveplan/network-discovery-peerjs";
async function discoverWithBudget(timeoutMs: number) {
return withStartSpan(async (span) => {
span.setAttribute("budget_ms", timeoutMs);
const start = performance.now();
try {
const peers = await openSignalling(timeoutMs);
recordPeersDiscovered("found", peers.length);
return peers;
} catch (err) {
span.recordException(err as Error);
recordPeersDiscovered("rejected");
throw err;
} finally {
span.setAttribute("elapsed_ms", performance.now() - start);
}
});
}
```
## API Reference
- `withStartSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — creates an
active span named `disc_peerjs.start`, ends it in `finally`.
- `recordPeersDiscovered(outcome: "found"|"duplicate"|"rejected", count?)` —
increments the `disc_peerjs.peers_discovered_total` counter.
- `recordSignalingDuration(ms: number)` — records into the
`disc_peerjs.signaling_duration_ms` histogram, clamping negative values to
zero.
- `PeerDiscoveryOutcome` — exported string-literal union used by the counter.
- `withTransportSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T>` —
active-span wrapper for transport-tier operations.
- `recordTransportMessage(direction, outcome)` — increments
`peerjs_transport.messages_total`.
- `adjustTransportActivePeers(delta)` — adjusts
`peerjs_transport.active_peers`. No-op when `delta === 0`.
- `TransportMessageDirection` / `TransportMessageOutcome` — exported
string-literal unions.
## Implementation Notes
`outcome` keeps cardinality bounded to three values so downstream Prometheus
dashboards remain efficient. The histogram clamps negative inputs because the
strategy's signalling-duration measurement uses `Date.now()` differences that
can occasionally drift on systems where NTP corrects the wall clock during a
handshake. Tracer and meter names match the package name verbatim so the OTel
resource attributes line up with the npm package id without further mapping.

View File

@@ -0,0 +1,107 @@
# plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerJSPlugin` is the thin `NetworkPlugin` lifecycle wrapper that mounts
`PeerJSTransport` onto a `@suaveplan/network` `NetworkNode`. It mirrors the
canonical `WebRTCPlugin` / `WebSocketPlugin` shape: construct → install →
start (no-op) → stop (closes transport + unregisters) → dispose (drops the
node reference).
The plugin pairs with the rest of this package — the same `PeerLike` the
transport opens can serve a `PeerJSDiscovery` instance side-by-side because
the transport's wire frame uses a discriminator (`"suaveplan.transport.v1"`)
that the discovery Zod schema explicitly rejects. Discovery + transport on a
single PeerJS unit was the upstream model and is preserved here by folding
both into one package.
## Features
- `name: "peerjs"` lookup key.
- Constructs and owns one `PeerJSTransport` from a `PeerJSTransportConfig`.
- `install(node)` calls `node.registerTransport(transport)`.
- `start()` is a no-op (transport is lazy).
- `stop()` closes the transport and unregisters it from the node.
- `dispose()` drops the cached `NetworkNode` reference; safe to call
before `install` or twice in a row.
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { PeerJSPlugin } from "@suaveplan/network-discovery-peerjs/plugin";
const node = new NetworkNode();
node.use(
new PeerJSPlugin({
host: "signalling.example.com",
peerFactory: makePeerFactory(),
})
);
await node.start();
// ... use node.send / node.broadcast ...
await node.stop();
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { PeerJSPlugin } from "@suaveplan/network-discovery-peerjs/plugin";
const plugin = new PeerJSPlugin({
host: "signalling.example.com",
port: 443,
path: "/",
secure: true,
iceServers: [
{ urls: "stun:stun.example.com:3478" },
{
urls: "turn:turn.example.com:3478",
username: "u",
credential: "p",
},
],
peerId: "alice",
debug: 0,
peerFactory: makePeerFactory(),
});
const node = new NetworkNode();
plugin.install(node);
plugin.start();
// Direct access to the transport for connect / disconnect.
await plugin.transport.connect("bob", "ignored");
plugin.transport.onPeerEvent((peerId, event) => {
console.log(`peer ${peerId} ${event}`);
});
await plugin.stop();
plugin.dispose();
```
## API Reference
- `class PeerJSPlugin implements NetworkPlugin`
- `readonly name = "peerjs"`.
- `readonly transport: PeerJSTransport`.
- `constructor(config: PeerJSTransportConfig)`.
- `install(node: NetworkNode): void`.
- `start(): void` — no-op.
- `stop(): Promise<void>` — idempotent close + unregister.
- `dispose(): void` — drops the node reference; idempotent.
## Implementation Notes
`install` caches the `NetworkNode` reference so `stop` can unregister the
transport on tear-down even when the caller forgets to invoke `stop` via
the node lifecycle. `dispose` clears that reference so the plugin can be
garbage-collected after the node is released. The plugin deliberately does
no eager broker connect — opening the signalling handshake on `start` would
make `NetworkNode.start()` block on a remote service, which violates the
upstream contract where `start` is synchronous-or-fast and connections are
opened by the surrounding mesh / discovery plugins.

View File

@@ -0,0 +1,147 @@
# transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerJSTransport` adapts the PeerJS WebRTC stack to the
`@suaveplan/network` mesh `Transport` contract. It lazy-initializes a single
local `PeerLike` against a configured signalling broker, then opens one
`DataConnectionLike` per remote peer and routes `MessageEnvelope` traffic
through a small wire frame (`{kind: "suaveplan.transport.v1", envelope}`) so
the same `PeerLike` can host concurrent discovery traffic from the rest of
this package without confusion.
The transport composes the existing primitives in this package
(`initializePeer`, `connectToPeer`, `handleConnection`) rather than reaching
into raw `peerjs` APIs, which keeps the migrated unit byte-identical to the
upstream integrated discovery + transport bundle.
## Features
- Single `Transport` implementation with `id: "peerjs"`.
- Lazy broker connect — the first `connect()` opens the signalling
handshake; subsequent connects reuse the same `PeerLike`.
- Per-peer `DataConnectionLike` registry with `connect` / `disconnect` /
`send` / `onMessage` / `onPeerEvent` / `getConnectedPeers` / `close`.
- Pre-aborted `AbortSignal` rejected with `ABORTED`; mid-handshake aborts
surface the same code.
- Frame discriminator ensures discovery-only peers ignore transport bytes.
- OTel: `peerjs_transport.connect` / `disconnect` / `send` spans,
`peerjs_transport.messages_total{direction,outcome}` counter,
`peerjs_transport.active_peers` gauge.
## Usage: Basic Example
```ts
import { PeerJSTransport } from "@suaveplan/network-discovery-peerjs/transport";
const transport = new PeerJSTransport({
host: "signalling.example.com",
peerFactory: makePeerFactory(),
});
await transport.connect("remote-peer-id", "ignored");
transport.onMessage((peerId, envelope) => {
console.log("from", peerId, envelope);
});
await transport.send("remote-peer-id", {
id: "m1",
type: "ping",
senderId: "self",
payload: { hello: "world" },
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
});
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { PeerJSPlugin } from "@suaveplan/network-discovery-peerjs/plugin";
const node = new NetworkNode();
const plugin = new PeerJSPlugin({
host: "signalling.example.com",
iceServers: [{ urls: "stun:stun.example.com:3478" }],
peerFactory: makePeerFactory(),
});
node.use(plugin);
await node.start();
const transport = plugin.transport;
transport.onPeerEvent((peerId, event) => {
if (event === "connected") {
console.log("peer joined", peerId);
}
});
// Open a connection with a cancellable signal — useful for UI flows that
// abort the dial when the user navigates away.
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
await transport.connect("alice", "ignored", ac.signal);
await node.stop();
```
## API Reference
- `class PeerJSTransport implements Transport`
- `readonly id = "peerjs"`.
- `constructor(config: PeerJSTransportConfig)` — `host` is required; all
other fields default (`port=443`, `path="/"`, `secure=true`, `debug=0`).
- `connect(peerId: string, address: string, signal?: AbortSignal): Promise<void>` —
lazy-opens the broker handshake on first call, then opens a `DataConnection`
to `peerId`. `address` is accepted for `Transport` compatibility but
unused (PeerJS centralises signalling at `host`). Throws
`PEER_ALREADY_CONNECTED` / `TRANSPORT_CLOSED` / `ABORTED` /
`PEER_CONNECT_FAILED`.
- `disconnect(peerId: string): Promise<void>` — closes the per-peer
connection. Unknown peer is a no-op.
- `send(peerId: string, envelope: MessageEnvelope): Promise<void>` —
wraps `envelope` in the transport frame and sends. Throws
`PEER_UNKNOWN` / `TRANSPORT_CLOSED` / `SEND_FAILED`.
- `onMessage(handler)` / `onPeerEvent(handler)` — return unsubscribe.
- `getConnectedPeers(): readonly string[]`.
- `close(): Promise<void>` — idempotent; closes every connection,
destroys the `PeerLike`, drops handler sets.
- `getMyPeerId(): string | null` — broker-assigned local id, or `null`
before the first `connect()`.
## Implementation Notes
The transport reuses `initializePeer` + `connectToPeer` + `handleConnection`
from `core/peerjs-connection/` so the integrated discovery + transport unit
upstream sees as a single PeerJS package is preserved without splitting it.
The discovery layer's Zod `PeerJSMessageSchema` deliberately does not list
the transport `kind` discriminator, so when a discovery-only peer receives
a transport frame the schema rejects it and the handler silently drops it —
mixing both protocols on the same `PeerLike` is safe.
Per-peer connection state is held in `#peers: Map<PeerId, PeerState>`. The
fresh-`Map` invariant in `connect()` (each call hands `openConnection` a
private map) guarantees the only path that returns `undefined` from
`openConnection` is unreachable in production; an `/* istanbul ignore next */`
guard documents the defensive branch.
`#waitForOpen` resolves on the first `open` event from the new
`DataConnectionLike`; if the channel emits `error` before `open` it
rejects with `PEER_CONNECT_FAILED`. Pre-aborted and mid-handshake
abort signals both reject with `ABORTED`.
Frame validation uses a structural type-guard (`kind === "suaveplan.transport.v1"`
- object `envelope`), which means malformed or non-transport traffic is
dropped without touching the registered handlers, preventing a hostile
peer from injecting through `onMessage`.
The transport never emits `connected` for a peer twice — `#acceptIncoming`
short-circuits when the registry already has the id, and `#handlePeerLost`
gates the `disconnected` emission on registry presence, so consumers can
treat the event stream as exactly-once per session.

View File

@@ -0,0 +1,87 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Type-only foundation for `@suaveplan/network-discovery-peerjs`. Declares the
wire-format `PeerJSMessage` envelope, the structural `PeerLike` and
`DataConnectionLike` surfaces used to inject real or stub PeerJS implementations,
the strict `PeerJSDiscoveryConfig` (which deliberately makes `host` mandatory —
there is no public-server fallback), the resolved-config shape produced by Zod
defaulting, and the typed event map fanned out on the optional bus.
## Features
- Frozen `PeerJSMessageType` literal-record covering every wire-format verb.
- `PeerJSPeerMetadata` + `PeerJSPeerRecord` separation: wire payload vs.
in-memory room state.
- `DataConnectionLike` / `PeerLike` structural surfaces — production binds to
real `peerjs` instances, tests bind to a deterministic stub with no mocks.
- `PeerFactory` contract for injecting the constructor.
- `PeerJSDiscoveryConfig` with required `host` and Zod-defaulted optional
fields; `ResolvedPeerJSDiscoveryConfig` for the post-parse internal shape.
- `PeerJSEventMap` typed bus emissions: `peerjs:started`,
`peerjs:peer-discovered`, `peerjs:peer-lost`, `peerjs:signaling-failed`.
## Usage: Basic Example
```ts
import type {
PeerJSDiscoveryConfig,
PeerJSEventMap,
} from "@suaveplan/network-discovery-peerjs/types";
const config: PeerJSDiscoveryConfig = {
host: "signaling.example.com",
room: "demo",
};
```
## Usage: Advanced Example
```ts
import type {
PeerJSDiscoveryConfig,
PeerFactory,
PeerLike,
} from "@suaveplan/network-discovery-peerjs/types";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<import("@suaveplan/network-discovery-peerjs/types").PeerJSEventMap>();
const factory: PeerFactory = (id, opts): PeerLike => fakePeer(id, opts);
const config: PeerJSDiscoveryConfig = {
host: "signaling.example.com",
port: 8443,
secure: true,
room: "alpha",
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
bus,
peerFactory: factory,
};
```
## API Reference
- `PeerJSMessageType` — frozen `{ANNOUNCE,DISCOVER,PEER_LIST,LEAVE,HEARTBEAT}`.
- `PeerJSPeerMetadata` — announce payload shape.
- `PeerJSMessage` — full envelope `{type, from, payload, timestamp}`.
- `PeerJSPeerRecord` — internal room-manager record (mutable `lastSeen`).
- `DataConnectionLike` — `{peer, open, send, on(event,...), close}`.
- `PeerLike` — `{connect, on(event,...), destroy}`.
- `PeerFactory` — `(peerId, opts) => PeerLike`.
- `PeerJSPeerOptions` — host/port/path/secure/key/debug/config bundle.
- `PeerJSDiscoveryConfig` — caller-facing options, `host`+`room` required.
- `ResolvedPeerJSDiscoveryConfig` — internal post-default shape.
- `PeerJSEventMap` — typed bus event keys + payloads.
## Implementation Notes
`host` has no default. Constructing without one throws `INVALID_OPTIONS` at
the Zod boundary inside `PeerJSDiscovery` — this is the explicit decision the
parity checklist records as an enterprise uplift over the upstream package,
which silently fell back to the public `peerjs.com` cloud. Wire-format types
keep `readonly` modifiers so downstream code cannot mutate buffers shared
across the message-handler dispatch table.