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,109 @@
# IPFS Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`IpfsTransportAdapter` implements the universal `TrysteroTransportAdapter`
contract over Trystero's IPFS libp2p-pubsub backend. It joins a
pubsub-hosted room identified by the configured `appId`, exchanges
peer-discovery messages with other participants, and proxies the resulting
peer lifecycle events back to the orchestrator via the injected
`TrysteroAdapterCallbacks`. There is no default bootstrap list inside the
adapter — the caller MUST supply a non-empty `bootstrap` array (the Zod
schema rejects empty lists at the package boundary).
This separation between adapter and orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `IpfsRoomFactory` — production binds
`joinRoom` from `trystero/ipfs`, tests bind a deterministic in-memory
fake.
- Optional `relay` list carried through the config block for hole-punching
scenarios.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `IPFS_BOOTSTRAP_FAILED` with the original
error preserved on `error.cause`.
## Usage: Basic Example
```ts
import { IpfsTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new IpfsTransportAdapter(
"my-app",
{ bootstrap: ["/ip4/1.2.3.4/tcp/4001/p2p/QmExample"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { IpfsTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new IpfsTransportAdapter(
"my-app",
{
bootstrap: [
"/ip4/1.2.3.4/tcp/4001/p2p/QmExample",
"/ip4/5.6.7.8/tcp/4001/p2p/QmAnother",
],
relay: ["/dnsaddr/relay.libp2p.io/p2p/QmRelay"],
},
{},
ipfsJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "ipfs",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new IpfsTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
id. Throws `IPFS_BOOTSTRAP_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
`initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "ipfs"`.
## Implementation Notes
- The bootstrap list must contain libp2p-format multiaddrs. The adapter
treats them as opaque strings — validation that they are actually
reachable lives in the underlying Trystero room implementation.
- The Zod schema enforces the non-empty-bootstrap invariant, so the
adapter does not re-validate.
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
are held for the lifetime of the adapter and called during teardown.

View File

@@ -0,0 +1,107 @@
# MQTT Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MqttTransportAdapter` implements the universal `TrysteroTransportAdapter`
contract over Trystero's MQTT-broker backend. It joins a broker-hosted
room identified by the configured `appId`, exchanges peer-discovery
messages with other participants, and proxies the resulting peer lifecycle
events back to the orchestrator via the injected
`TrysteroAdapterCallbacks`. There is no default broker list inside the
adapter — the caller MUST supply a non-empty `brokers` array (the Zod
schema rejects empty lists at the package boundary).
This separation between adapter and orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `MqttRoomFactory` — production binds
`joinRoom` from `trystero/mqtt`, tests bind a deterministic in-memory
fake.
- Optional MQTT `username` / `password` carried through the config block.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `MQTT_BROKER_FAILED` with the original
error preserved on `error.cause`.
## Usage: Basic Example
```ts
import { MqttTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new MqttTransportAdapter(
"my-app",
{ brokers: ["wss://broker.example:1883"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { MqttTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new MqttTransportAdapter(
"my-app",
{
brokers: ["wss://broker-a:1883", "wss://broker-b:1883"],
username: "u",
password: "p",
},
{ password: "secret-room" },
mqttJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "mqtt",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new MqttTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
id. Throws `MQTT_BROKER_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
`initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "mqtt"`.
## Implementation Notes
- MQTT broker credentials (`username` / `password`) are carried as plain
fields on the config block. They are not redacted by this package — if
they are sensitive, configure your logging sinks to redact the
`mqtt.username` / `mqtt.password` paths via `@suaveplan/data-masking`.
- The Zod schema enforces the non-empty-brokers invariant, so the adapter
does not re-validate.
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
are held for the lifetime of the adapter and called during teardown.

View File

@@ -0,0 +1,105 @@
# Nostr Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NostrTransportAdapter` implements the universal
`TrysteroTransportAdapter` contract over Trystero's Nostr-relay backend.
It joins a Nostr-relay-hosted room identified by the configured `appId`,
exchanges peer-discovery messages with other participants, and proxies the
resulting peer lifecycle events back to the orchestrator via the injected
`TrysteroAdapterCallbacks`. There is no default relay list inside the
adapter — the caller MUST supply a non-empty `relays` array (the Zod
schema rejects empty lists at the package boundary).
This separation between adapter and orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `NostrRoomFactory` — production binds
`joinRoom` from `trystero/nostr`, tests bind a deterministic in-memory
fake.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `NOSTR_RELAY_FAILED` with the original
error preserved on `error.cause`.
- `ping(peerId)` proxies through to the underlying Trystero room's
`ping()` (round-trip ms).
## Usage: Basic Example
```ts
import { NostrTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new NostrTransportAdapter(
"my-app",
{ relays: ["wss://relay.example"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { NostrTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new NostrTransportAdapter(
"my-app",
{ relays: ["wss://relay-1", "wss://relay-2"] },
{ password: "secret" },
nostrJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "nostr",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new NostrTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
id. Throws `NOSTR_RELAY_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
`initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "nostr"`.
## Implementation Notes
- The Zod schema enforces the non-empty-relays invariant, so the adapter
does not re-validate. Empty arrays should never reach the adapter.
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
are held during the lifetime of the adapter and called during teardown.
Exceptions from those unsub calls are swallowed because Trystero may
detach them itself during room leave.
- `myPeerId` is captured from `room.getPeerId()` at initialise time and
cleared in `destroy()`. The orchestrator should treat the adapter as
opaque after `destroy()`.

View File

@@ -0,0 +1,105 @@
# Torrent Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TorrentTransportAdapter` implements the universal
`TrysteroTransportAdapter` contract over Trystero's BitTorrent-tracker
backend. It joins a tracker-hosted room identified by the configured
`appId`, exchanges peer-discovery messages with other participants in that
room, and proxies the resulting peer lifecycle events back to the
orchestrator via the injected `TrysteroAdapterCallbacks`. The adapter never
contains a default tracker list; the caller MUST supply a non-empty
`trackers` array (the Zod schema rejects empty lists at the package
boundary).
This separation between the adapter and the orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `TorrentRoomFactory` — production binds
`joinRoom` from `trystero/torrent`, tests bind a deterministic in-memory
fake.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `TORRENT_TRACKER_FAILED` with the original
error preserved on `error.cause`.
- `ping(peerId)` proxies through to the underlying Trystero room's
`ping()` (round-trip ms).
## Usage: Basic Example
```ts
import { TorrentTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new TorrentTransportAdapter(
"my-app",
{ trackers: ["wss://tracker.example"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { TorrentTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new TorrentTransportAdapter(
"my-app",
{ trackers: ["wss://t1", "wss://t2"], trackerRedundancy: 3 },
{ password: "secret", rtcConfig: { iceServers: [] } },
trackerJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "torrent",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new TorrentTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer id.
Throws `TORRENT_TRACKER_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before `initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "torrent"`.
## Implementation Notes
- The adapter holds the `myPeerId` returned by `room.getPeerId()` at
initialise time and clears it in `destroy()`. The orchestrator should
treat the adapter as opaque after `destroy()`.
- Every callback (`onPeerJoined` / `onPeerLost` / `onMessage`) is wired
through the room's own subscribe API; unsub handles are held and called
during teardown. Exceptions from those unsub calls are swallowed because
Trystero's library may detach them itself during room leave.
- The Zod schema enforces the non-empty-trackers invariant, so the
adapter does not re-validate.

View File

@@ -0,0 +1,129 @@
# TrysteroDiscovery — Orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroDiscovery` is the orchestrator that ties every other module in
`@suaveplan/network-discovery-trystero` together and implements the
`@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. It owns the
active transport adapter, the peer manager, the interval scheduler, and
the recursive fallback chain. The orchestrator never branches on the
transport-id string at runtime — every lifecycle call goes through the
universal `TrysteroTransportAdapter` interface, and construction-time
selection chooses the initial adapter via the internal `pickAdapter` map.
The strategy is browser-tier (the underlying Trystero library is itself
browser-tier) and headless — no React or DOM rendering surface ships from
this package. Devtools UI is the responsibility of downstream packages.
## Features
- Implements `name`, `discover`, `isAvailable`, plus `start`, `stop`,
`announce`, `getMyPeerId`, `getPeers`, `getPeerCount`,
`getCurrentTransport`, `getStatus`, `ping`, `dispose`.
- Construction-time Zod boundary validation + cross-field constraint that
every selected transport (primary or fallback) must have its
config-block AND factory present. Constructor throws
`INVALID_OPTIONS` otherwise.
- AbortSignal threaded through `start`, `stop`, `discover`, `isAvailable`,
`announce`.
- Automatic fallback between transports via `tryFallback` — exponential
backoff between attempts optional via `@suaveplan/retry`.
- Service-registry integration via `@suaveplan/service-discovery`: peers
are registered as `trystero:<transport>:<appId>:<peerId>`.
- Typed `EventBusLike` integration with the `TrysteroEventMap`.
- OTel: `disc_trystero.start` span, per-transport
`disc_trystero.transport.<id>.connect` span, fallback counter, connect
histogram, per-peer-discovery counter.
## Usage: Basic Example
```ts
import { TrysteroDiscovery } from "@suaveplan/network-discovery-trystero";
const strategy = new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory: async () => realJoinRoom(),
});
await strategy.start();
const peers = await strategy.discover(5000);
await strategy.stop();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import {
TrysteroDiscovery,
type TrysteroEventMap,
} from "@suaveplan/network-discovery-trystero";
const bus = new EventBus<TrysteroEventMap>();
bus.on("trystero:fallback", (e) => console.log(`fallback ${e.from} -> ${e.to}`));
bus.on("trystero:transport-failed", (e) => console.warn(`failed ${e.transport}: ${e.reason}`));
const strategy = new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://relay-1", "wss://relay-2"] },
mqtt: { brokers: ["wss://broker:1883"] },
torrent: { trackers: ["wss://tracker"] },
fallbackTransports: ["mqtt", "torrent"],
nostrRoomFactory: realNostrJoin,
mqttRoomFactory: realMqttJoin,
torrentRoomFactory: realTorrentJoin,
interval: 10_000,
maxPeers: 50,
bus,
registry: new ServiceRegistry(),
});
const ctrl = new AbortController();
await strategy.start(ctrl.signal);
```
## API Reference
- `new TrysteroDiscovery(config: TrysteroDiscoveryConfig, clock?: Clock)`.
- `start(signal?): Promise<void>` — idempotent; emits `trystero:started`
on success; throws `DISPOSED` after `dispose()`,
`ALL_TRANSPORTS_DEGRADED` if the entire fallback chain fails, plus the
per-transport init code (`NOSTR_RELAY_FAILED`, etc.) when no fallback is
configured.
- `stop(signal?): Promise<void>` — emits `LEAVE` to peers, tears down the
adapter, clears the peer map.
- `discover(timeoutMs, signal?): Promise<PeerInfo[]>` — returns a snapshot
of the live peer set.
- `isAvailable(signal?): Promise<boolean>` — `true` iff running and the
underlying adapter reports `isConnected`.
- `announce(peerInfo, signal?): Promise<void>`.
- `getMyPeerId / getPeers / getPeerCount / getCurrentTransport / getStatus
/ ping / dispose`.
- `readonly name = "trystero"`.
## Implementation Notes
- The constructor runs the Zod schema first, then walks the union of
`transport` + `fallbackTransports` and asserts each transport's config
block AND factory are present. This is a cross-field constraint Zod
cannot express cleanly; the error code is always `INVALID_OPTIONS`.
- `start()` opens a single `disc_trystero.start` span and inside it opens a
`disc_trystero.transport.<id>.connect` span per init attempt. Fallback
swaps reuse the same outer span but record a new transport span per
swap.
- The interval scheduler keeps a stable reference to the peer-manager's
`getMap()` so transport swaps do not invalidate the cleanup loop.
- `announce` emits the message _after_ admitting the local record so the
peer manager's `transport_fallback_total` and
`peers_discovered_total` counters reflect locally-known state even if
the underlying send fails.
- After fallback, `peers.retagTransport(next)` rewrites every existing
peer entry's `transport` field so consumers (event listeners,
registry) see a consistent view of which transport peers are on.

View File

@@ -0,0 +1,105 @@
# Trystero Fallback
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`tryFallback` implements the inter-transport fallback algorithm: when the
active Trystero transport fails to initialise, walk the configured
`fallbackTransports` array in order, attempting to bring each one up
sequentially. The function recurses across the chain — every individual
transport gets one attempt — and throws `ALL_TRANSPORTS_DEGRADED` only if
every transport in the chain fails.
Backoff between attempts is delegated to `@suaveplan/retry`'s
`createExponentialBackoff` so the fallback policy reuses the suaveplan retry
primitives (instead of re-implementing setTimeout-based delays) and so
tests can swap in deterministic backoff via injectable sleep.
## Features
- Sequential walk over the caller-supplied `fallbackTransports` list.
- Optional exponential backoff via `ExponentialBackoffOptions` (no
backoff by default — preserves upstream parity).
- Injectable `sleepFn` for deterministic tests.
- OTel: increments `transport_fallback_total{from,to}` on every transition.
- `ALL_TRANSPORTS_DEGRADED` carries the original failure on `error.cause`.
- Best-effort `notifyFallback` after a successful swap — failures
swallowed because the chain has already stabilised.
- Recursive — at most `fallbackTransports.length` levels deep, bounded by
the caller-supplied array length.
## Usage: Basic Example
```ts
import { tryFallback } from "@suaveplan/network-discovery-trystero";
await tryFallback(
new Error("nostr down"),
{ fallbackAttempts: 0, currentTransport: "nostr" },
{
fallbackTransports: ["mqtt", "torrent"],
clock: () => Date.now(),
initializeTransport: async (t) => connect(t),
notifyFallback: async () => {},
},
);
```
## Usage: Advanced Example
```ts
import { tryFallback } from "@suaveplan/network-discovery-trystero";
await tryFallback(
new Error("torrent down"),
state,
{
fallbackTransports: ["nostr", "mqtt", "ipfs"],
clock: () => Date.now(),
backoff: { initial: 100, max: 5000, base: 2, jitter: "equal" },
sleepFn: async (ms) => {
console.log(`backoff ${ms}ms`);
// deterministic test sleep
},
initializeTransport: async (t) => {
if (t === "nostr") throw new Error("nostr also down");
await connect(t);
},
notifyFallback: async (next) => {
await broadcast({ type: "fallback", payload: { transport: next } });
},
},
);
```
## API Reference
- `async tryFallback(error, state, deps): Promise<void>` — mutates
`state.fallbackAttempts` / `state.currentTransport`; throws
`ALL_TRANSPORTS_DEGRADED` once the chain is exhausted.
- `interface FallbackState` — `{ fallbackAttempts: number; currentTransport: TrysteroTransport; }`.
- `interface FallbackDeps`:
- `fallbackTransports: readonly TrysteroTransport[]`.
- `clock: Clock`.
- `backoff?: ExponentialBackoffOptions` — `{ initial, max?, base?, jitter? }`.
- `sleepFn?: (ms) => Promise<void>` — defaults to `@suaveplan/retry.sleep`.
- `initializeTransport(transport): Promise<void>`.
- `notifyFallback(next): Promise<void>`.
## Implementation Notes
- The recursion is tail-position-ish but not actually tail-call-optimised;
the call stack is bounded by `fallbackTransports.length` which has a
natural upper bound of 3 (each of `torrent`, `nostr`, `mqtt`, `ipfs`
except the active one). Stack depth is not a concern.
- `recordTransportFallback` is called BEFORE the new transport
initialises — the counter reflects fallback intent, not success. Use
`disc_trystero.transport.<id>.connect` span + counter to differentiate.
- Backoff is computed off `state.fallbackAttempts` AFTER incrementing —
the first fallback gets `attempt = 1`, so under exponential strategy
the delay is `initial * base^0 = initial`.
- Errors from `notifyFallback` are swallowed because the chain has
already moved to the new transport; failing to tell other peers is a
visibility problem, not a correctness one.

View File

@@ -0,0 +1,110 @@
# Trystero Intervals
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroIntervals` replaces the upstream package's three raw `setInterval`
handles with a single `@suaveplan/timing` `Scheduler` driving three
self-rearming loops: announce, heartbeat, and cleanup. The scheduler is
injectable, so tests deterministically drive every tick without
`setTimeout` actually firing, and `dispose()` cancels every pending timer
atomically when the strategy stops.
By isolating timer policy in this module, the orchestrator stays focused on
adapter lifecycle and the cleanup loop has a stable Map handle it can
iterate during staleness sweeps.
## Features
- One `Scheduler` instance, three self-rearming loops.
- `start()` is idempotent and `stop()` is idempotent.
- Each loop reschedules itself only if the scheduler has not been disposed
AND `stop()` has not been called.
- Announce / heartbeat emit a `TrysteroMessage` (type
`ANNOUNCE` / `HEARTBEAT`) via the injected `sendDiscovery` callback.
- Cleanup iterates `peers`, dropping any whose `now - lastSeen > timeoutMs`
via `handlePeerLost`.
- Errors from `sendDiscovery` are swallowed so a single bad tick does not
starve the loop.
## Usage: Basic Example
```ts
import { TrysteroIntervals } from "@suaveplan/network-discovery-trystero";
const intervals = new TrysteroIntervals({
clock: () => Date.now(),
announceIntervalMs: 30_000,
heartbeatIntervalMs: 15_000,
cleanupIntervalMs: 30_000,
timeoutMs: 60_000,
peers: new Map(),
getMyPeerId: () => "me",
getCurrentTransport: () => "nostr",
isRunning: () => true,
selfMetadata: () => ({
id: "me",
addresses: [],
metadata: {},
lastSeen: Date.now(),
transport: "nostr",
}),
sendDiscovery: async () => {},
handlePeerLost: () => {},
});
intervals.start();
intervals.stop();
```
## Usage: Advanced Example
```ts
import { createScheduler } from "@suaveplan/timing";
import { TrysteroIntervals } from "@suaveplan/network-discovery-trystero";
// Custom scheduler factory — useful in tests.
const intervals = new TrysteroIntervals(deps, (clock) =>
createScheduler({ clock }),
);
intervals.start();
intervals.start(); // idempotent
intervals.stop();
intervals.stop(); // idempotent
console.log(intervals.isStopped); // true
```
## API Reference
- `new TrysteroIntervals(deps, schedulerFactory?)`.
- `start(): void` — start every loop; idempotent.
- `stop(): void` — stop every loop, dispose scheduler; idempotent.
- `get isStopped(): boolean`.
`TrysteroIntervalDeps`:
- `clock: Clock`.
- `announceIntervalMs / heartbeatIntervalMs / cleanupIntervalMs / timeoutMs: number`.
- `peers: Map<string, TrysteroPeerMetadata>` — read-write reference to the
manager's underlying map.
- `getMyPeerId(): string | null`.
- `getCurrentTransport(): TrysteroTransport`.
- `isRunning(): boolean`.
- `selfMetadata(): TrysteroPeerMetadata`.
- `sendDiscovery(message): Promise<void>`.
- `handlePeerLost(peerId): void`.
## Implementation Notes
- The scheduler is disposed in `stop()`. Once disposed, no new tasks can
be scheduled. The loop-self-rearm code defends against this race by
checking `scheduler.disposed` before each `schedule()`.
- `sendDiscovery` errors are intentionally swallowed. The orchestrator
emits `trystero:transport-failed` on initialise failures; transient
send failures on a single tick are not surfaced through the bus.
- `runHeartbeat` and `runAnnounce` share a message shape; the difference
is only the `type` literal. Keeping them as two separate methods keeps
the test surface small.

View File

@@ -0,0 +1,121 @@
# Trystero Message Handlers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure dispatch function (`handleMessage`) routing every inbound
`TrysteroMessage` to the appropriate handler based on `message.type`. Each
handler closes over a `TrysteroMessageHandlerDeps` bag wired by the
orchestrator at construction — keeping the dispatch surface free of any
orchestrator imports avoids a circular-dependency edge between
`trystero-discovery` and `trystero-message-handlers`.
The module owns the wire-format protocol contract: every supported message
type (announce, discover, peer_list, leave, heartbeat, fallback) is handled
exactly once, malformed payloads are coerced to `false` returns, and there
are no side effects beyond the explicit callback invocations.
## Features
- Six message types (announce / discover / peer_list / leave / heartbeat /
fallback) dispatched via a `switch` on a frozen literal union.
- Pure handlers — every side effect goes through the `deps` callbacks.
- Announces re-tag the peer's `transport` with the active transport when
the inbound payload omits one (defensive default).
- Discover requests are answered with the active peer-id set, sent
unicast to the requester via `deps.sendDiscovery(..., target)`.
- Heartbeats touch the sender's `lastSeen`. Leaves drop the sender.
- Fallback notifications are observational — the receiving peer does not
need to switch transports just because a remote did.
## Usage: Basic Example
```ts
import {
handleMessage,
TrysteroMessageType,
} from "@suaveplan/network-discovery-trystero";
await handleMessage(
{
type: TrysteroMessageType.ANNOUNCE,
from: "p1",
payload: {
id: "p1",
addresses: ["wss://x"],
metadata: {},
lastSeen: 0,
transport: "nostr",
},
timestamp: Date.now(),
},
deps,
);
```
## Usage: Advanced Example
```ts
import {
handleMessage,
TrysteroMessageType,
type TrysteroMessageHandlerDeps,
} from "@suaveplan/network-discovery-trystero";
const deps: TrysteroMessageHandlerDeps = {
clock: () => Date.now(),
getMyPeerId: () => "me",
getCurrentTransport: () => "nostr",
knownPeerIds: () => Array.from(peerMap.keys()),
admitPeer: (r) => (peerMap.set(r.id, r), "added"),
touchPeer: (id) => {
const p = peerMap.get(id);
if (p) p.lastSeen = Date.now();
},
onPeerLost: (id) => peerMap.delete(id),
sendDiscovery: async (m, t) => transport.send(m, t),
};
await handleMessage(
{
type: TrysteroMessageType.DISCOVER,
from: "p2",
payload: {},
timestamp: Date.now(),
},
deps,
);
```
## API Reference
- `async handleMessage(message, deps): Promise<boolean>` — `false` for
malformed / unknown messages.
- `interface TrysteroMessageHandlerDeps` — callback bag:
- `clock: Clock`.
- `getMyPeerId(): string | null`.
- `getCurrentTransport(): TrysteroTransport`.
- `knownPeerIds(): readonly string[]`.
- `admitPeer(record): "added" | "updated" | "rejected"`.
- `touchPeer(peerId): void`.
- `onPeerLost(peerId): void`.
- `sendDiscovery(message, target?): Promise<void>`.
## Implementation Notes
- The handler is exhaustive over the frozen `TrysteroMessageType`
registry. The `default` branch is `istanbul ignore`d because Zod
validates the wire-format type at the orchestrator boundary before
dispatch; the literal-union switch is provably exhaustive.
- `handleDiscoverRequest` swallows send errors and returns `false`. The
caller (the message-receive callback wired into the room) cannot do
anything useful with the failure, so propagating would just crash the
fire-and-forget receive loop.
- `handleFallback` is structurally permissive (`payload.transport: string`)
rather than strictly schema-validated because the fallback message is
informational; rejecting on schema mismatch would do nothing useful.
- The announce-path admits the peer even if `admitPeer` reports
`"rejected"` (room full) — the return value lets the caller
observe rejections without losing the message.

View File

@@ -0,0 +1,110 @@
# Trystero Peer Manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroPeerManager` owns the deduped set of currently-discovered peers
for a single `TrysteroDiscovery` instance. It is the single source of
truth for the peer-list snapshot returned by `discover()`, mirrors every
peer transition onto the optional `EventBusLike` (`trystero:peer-discovered`
and `trystero:peer-lost`), and (when supplied) onto a
`@suaveplan/service-discovery` `ServiceRegistry` so other parts of a
running application can react to peers without coupling to the discovery
strategy. The manager also enforces the `maxPeers` cap by throwing a
`ROOM_FULL` `NetDiscTrysteroError` on insert.
Pulling all of this into a dedicated module keeps the orchestrator small
(it never touches the peer-map directly) and ensures the cleanup loop has
a stable Map handle it can iterate during staleness sweeps.
## Features
- `admit(metadata)` — insert or update. Throws `ROOM_FULL` when over cap on
insert.
- `touch(peerId)` — heartbeat refresh of `lastSeen`.
- `drop(peerId)` — emits `trystero:peer-lost`, deregisters from registry.
- `list()` / `get()` / `size()` / `getMap()`.
- `clear()` — drops every peer (emitting `trystero:peer-lost` for each).
- `retagTransport(transport)` — rewrites every entry's `transport` field
when the strategy falls back to a different transport so consumers see a
consistent view.
- OTel: increments `peers_discovered_total{transport,outcome}` with
outcome `"found" / "duplicate" / "rejected"`.
## Usage: Basic Example
```ts
import { TrysteroPeerManager } from "@suaveplan/network-discovery-trystero";
const peers = new TrysteroPeerManager({
clock: () => Date.now(),
maxPeers: 100,
appId: "demo",
});
peers.admit({
id: "p1",
addresses: ["wss://x"],
metadata: {},
lastSeen: 0,
transport: "nostr",
});
console.log(peers.size()); // 1
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import {
TrysteroPeerManager,
type TrysteroEventMap,
} from "@suaveplan/network-discovery-trystero";
const bus = new EventBus<TrysteroEventMap>();
const registry = new ServiceRegistry();
const peers = new TrysteroPeerManager({
clock: () => Date.now(),
bus,
registry,
maxPeers: 50,
appId: "demo",
});
bus.on("trystero:peer-discovered", (e) => console.log("found", e.peer.id));
bus.on("trystero:peer-lost", (e) => console.log("lost", e.peerId));
peers.admit(peerMeta);
peers.retagTransport("mqtt"); // after fallback torrent -> mqtt
peers.drop("p1");
```
## API Reference
- `new TrysteroPeerManager({clock, bus?, registry?, maxPeers, appId})`.
- `admit(metadata): "added" | "updated"` — throws `NetDiscTrysteroError`
with code `ROOM_FULL` on insert when at cap.
- `touch(peerId): void`.
- `drop(peerId): boolean` — returns whether a peer was actually removed.
- `list(): readonly TrysteroPeerMetadata[]`.
- `get(peerId): TrysteroPeerMetadata | undefined`.
- `size(): number`.
- `getMap(): Map<string, TrysteroPeerMetadata>` — exposed so the cleanup
interval can iterate without paying for an array snapshot every tick.
- `clear(): void`.
- `retagTransport(transport): void`.
## Implementation Notes
- The service-registry id format is
`trystero:<transport>:<appId>:<peerId>` so multiple `TrysteroDiscovery`
instances against the same registry but with different `appId`s never
collide.
- `retagTransport` writes a new metadata object for every entry rather
than mutating in place — the existing entries are exposed externally via
`list()` and consumers may have captured prior references.
- The OTel `outcome` label is one of three literals — never user-supplied
— so dashboards can pivot on it safely.

View File

@@ -0,0 +1,95 @@
# Trystero Discovery — Structured Errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of error codes emitted by
`@suaveplan/network-discovery-trystero` and the `NetDiscTrysteroError`
subclass of `SuaveplanError` that every public boundary throws on failure.
Consumers branch on `error.code` — never on `error.message` — and the
registry keeps the surface auditable per `@suaveplan/error` conventions.
The codes are split between **lifecycle / option violations** that surface
at construction or via mid-flight invariant violations (`INVALID_OPTIONS`,
`DISPOSED`, `NOT_RUNNING`, `ROOM_FULL`, `ALL_TRANSPORTS_DEGRADED`) and
**transport-specific initialisation failures** keyed by which of the four
Trystero transports went down (`TORRENT_TRACKER_FAILED`,
`NOSTR_RELAY_FAILED`, `MQTT_BROKER_FAILED`, `IPFS_BOOTSTRAP_FAILED`). The
specific codes let dashboards and alerting rules attribute failures to the
correct transport without grepping `error.cause` chains.
## Features
- Codes registered via `defineCodes("NET_DISC_TRYSTERO", [...])` —
monomorphic, namespaced, autocompletable.
- `NetDiscTrysteroError` carries `code`, optional `cause`, and a
message-resolver-friendly `messageKey`.
- Stable wire compatibility with the rest of the
`network-discovery-*` family — code keys follow the family convention.
## Usage: Basic Example
```ts
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "@suaveplan/network-discovery-trystero";
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED,
"errors.trystero.nostr_init_failed",
{ cause: new Error("ECONNREFUSED") },
);
```
## Usage: Advanced Example
```ts
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "@suaveplan/network-discovery-trystero";
try {
await strategy.start();
} catch (err) {
if (err instanceof NetDiscTrysteroError) {
switch (err.code) {
case NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS:
return reportMisconfiguration(err);
case NET_DISC_TRYSTERO_CODES.ALL_TRANSPORTS_DEGRADED:
return surfaceDegradedBanner();
case NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED:
case NET_DISC_TRYSTERO_CODES.MQTT_BROKER_FAILED:
case NET_DISC_TRYSTERO_CODES.IPFS_BOOTSTRAP_FAILED:
case NET_DISC_TRYSTERO_CODES.TORRENT_TRACKER_FAILED:
return logTransportOutage(err);
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `const NET_DISC_TRYSTERO_CODES` — frozen registry containing exactly the
10 codes listed above.
- `type NetDiscTrysteroErrorCode` — string-literal union of the registered
codes.
- `class NetDiscTrysteroError extends SuaveplanError<NetDiscTrysteroErrorCode>`
— constructor `(code, messageKey, options?)`.
## Implementation Notes
- The transport-specific codes intentionally do not double as "running" /
"degraded" status flags. A single transport failure does not necessarily
mean the discovery strategy as a whole has failed — the fallback chain
may recover. Only `ALL_TRANSPORTS_DEGRADED` signals terminal failure.
- `RELAY_FAILED` is reserved as the generic transport-agnostic code; the
per-transport codes are preferred when the failing transport is known.
- All codes are surfaced through `withStartSpan` / `withTransportSpan`
attributes so OTel back-ends can pivot on `code` without parsing logs.

View File

@@ -0,0 +1,107 @@
# Trystero Discovery — OpenTelemetry Surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns every OpenTelemetry instrument emitted by
`@suaveplan/network-discovery-trystero`. The surface is deliberately small:
one parent span (`disc_trystero.start`), one transport-keyed connect span
(`disc_trystero.transport.<id>.connect`), three counters / histograms keyed
by transport so dashboards can pivot per-transport without parsing
attributes, and explicit fallback tracking.
The shared `tracer` / `meter` instances are obtained once at module load via
the OTel global provider. In a fresh process with no OTel SDK registered
this collapses to the cheap no-op instruments, which is what the test suite
exercises — the API is identical either way, so the same code paths cover
both observability-on and observability-off runs.
## Features
- `withStartSpan(fn)` — wraps `TrysteroDiscovery.start()` in
`disc_trystero.start`.
- `withTransportSpan(transport, fn)` — wraps a transport-specific connect
block in `disc_trystero.transport.<transport>.connect`.
- `recordPeersDiscovered(transport, outcome, count?)` — counter
`disc_trystero.peers_discovered_total{transport,outcome}`. Outcomes:
`found` / `duplicate` / `rejected`.
- `recordTransportFallback(from, to)` — counter
`disc_trystero.transport_fallback_total{from,to}`.
- `recordConnectDuration(transport, ms)` — histogram
`disc_trystero.connect_duration_ms{transport}` (unit: `ms`).
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
withStartSpan,
} from "@suaveplan/network-discovery-trystero";
await withStartSpan(async (span) => {
span.setAttribute("transport", "nostr");
// ... open the room ...
recordPeersDiscovered("nostr", "found", 1);
});
```
## Usage: Advanced Example
```ts
import {
recordConnectDuration,
recordTransportFallback,
withTransportSpan,
} from "@suaveplan/network-discovery-trystero";
async function connect(transport: "torrent" | "nostr" | "mqtt" | "ipfs") {
return withTransportSpan(transport, async () => {
const t0 = performance.now();
try {
await openRoom();
} finally {
recordConnectDuration(transport, performance.now() - t0);
}
});
}
async function failover() {
try {
await connect("nostr");
} catch {
recordTransportFallback("nostr", "mqtt");
await connect("mqtt");
}
}
```
## API Reference
- `withStartSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — opens
`disc_trystero.start` and ends it on resolve/reject.
- `withTransportSpan<T>(transport, fn): Promise<T>` — opens
`disc_trystero.transport.<transport>.connect`, sets the `transport`
attribute, ends on resolve/reject.
- `recordPeersDiscovered(transport, outcome, count = 1): void` — outcome
literal `"found" | "duplicate" | "rejected"`. Count `<= 0` is ignored.
- `recordTransportFallback(from, to): void` — increment by one with the
`from` and `to` labels.
- `recordConnectDuration(transport, ms): void` — record the value clamped
to `[0, +Infinity)`.
- `type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected"`.
## Implementation Notes
- Tracer and meter are intentionally module-level globals, fetched once
via `trace.getTracer` / `metrics.getMeter`. Production setups that swap
the provider after module-load will still see new instruments — the
global proxy obtained at load time delegates to whichever provider is
current at call time.
- The counter dimensions (`transport`, `outcome`, `from`, `to`) are
low-cardinality by design: four transports, three outcomes. Adding more
outcomes (e.g. timing buckets) must reuse the histogram instead.
- The histogram unit is `ms`. OTel back-ends should configure histogram
bucket boundaries to match peer-discovery latency expectations
(typically `[10, 50, 250, 1000, 5000, 30000]`).

View File

@@ -0,0 +1,129 @@
# TrysteroPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroPlugin` is the `NetworkPlugin` that exposes
`TrysteroNodeTransport` to a host `NetworkNode`. It mirrors the thin
lifecycle wrappers shipped by the sibling network-transport packages
(`@suaveplan/network-transport-webrtc`,
`@suaveplan/network-transport-websockets`): `install` registers the
transport with the node's `MessageRouter`, `start` is intentionally a
no-op so the underlying Trystero room only opens lazily on the first
`connect()` call, and `stop` closes the transport and unregisters it
from the router.
Combined with `TrysteroDiscovery` from the same package, callers can
build a fully-decentralised network node: discovery learns the peer
set over Trystero rooms, and `TrysteroPlugin`'s transport carries the
application data plane on a separate action channel inside the same
Trystero infrastructure.
## Features
- Implements `NetworkPlugin` (`name`, `install`, `start`, `stop`,
optional `dispose`)
- Re-uses every `TrysteroNodeTransportConfig` field — `appId`,
primary transport, per-transport config blocks, factories,
password, ICE config, `maxPeers`
- Idempotent `dispose()`
- Safe `stop()` even when no node was installed
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { TrysteroPlugin } from "@suaveplan/network-discovery-trystero/plugin";
const node = new NetworkNode();
node.use(
new TrysteroPlugin({
appId: "my-app",
transport: "torrent",
torrent: { trackers: ["wss://tracker.example"] },
torrentRoomFactory,
})
);
await node.start();
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { TrysteroPlugin } from "@suaveplan/network-discovery-trystero/plugin";
import { TrysteroDiscovery } from "@suaveplan/network-discovery-trystero";
const plugin = new TrysteroPlugin({
appId: "my-app",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory,
maxPeers: 64,
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
// Hand-off: discovery learns peer ids; the plugin's transport
// delivers application envelopes to them.
const discovery = new TrysteroDiscovery({
appId: "my-app",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory,
});
await discovery.start();
// ... wire discovery events to plugin.transport.connect(peerId, roomPeerId)
await node.stop(); // closes the transport
plugin.dispose();
```
## API Reference
### `class TrysteroPlugin implements NetworkPlugin`
#### Constructor
`new TrysteroPlugin(config: TrysteroNodeTransportConfig)` — same
config object as the underlying transport.
#### Properties
- `name: "trystero"` — canonical plugin identifier.
- `transport: TrysteroNodeTransport` — the wrapped transport instance
for direct API access when needed.
#### Methods
- `install(node: NetworkNode): void` — stores the node reference and
calls `node.registerTransport(this.transport)`.
- `start(): void` — no-op. The Trystero room opens lazily on the
first `transport.connect()` call so plugin start is non-blocking
and side-effect free.
- `stop(): Promise<void>` — calls `transport.close()` then
`node.unregisterTransport(this.transport.id)`. Safe to call even
when `install()` was never called.
- `dispose(): void` — idempotent; clears the node reference.
## Implementation Notes
- Lazy room init is a deliberate departure from "open on `start()`":
Trystero rooms hold open WebSocket connections to relays/trackers,
and a plugin used only as a fallback should not pay that cost
until its transport is actually exercised.
- The plugin does NOT install `TrysteroDiscovery` automatically.
Discovery and transport share the same room infrastructure but
remain independent surfaces so consumers can swap discovery
strategies without losing the transport (e.g. discovery via
PeerJS, data plane via Trystero, or vice versa).
- `stop()` is the only place the underlying room is torn down. Once
`transport.close()` runs the transport instance is permanently
unusable; callers must construct a new plugin to reconnect.
- `dispose()` does not call `stop()`. Callers MUST `await stop()`
themselves to release the underlying Trystero room — disposing
alone simply releases the node reference for GC.

View File

@@ -0,0 +1,148 @@
# TrysteroNodeTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroNodeTransport` adapts the serverless Trystero WebRTC transport
family (BitTorrent / Nostr / MQTT / IPFS) into the
`@suaveplan/network` `Transport` contract. While the sibling
`TrysteroDiscovery` strategy uses the same room infrastructure as a
control plane to **find** peers, this transport carries the **data
plane** — `MessageEnvelope` payloads addressed to specific peers on a
dedicated `makeAction("envelope")` channel so discovery traffic and
application data stay namespace-isolated on the wire.
It reuses the four existing room factories (`TorrentRoomFactory`,
`NostrRoomFactory`, `MqttRoomFactory`, `IpfsRoomFactory`) so the same
configuration material works in both surfaces — production callers
bind the real `trystero/<transport>` `joinRoom` exports; tests bind
the deterministic in-memory `FakeTrysteroBroker` / `FakeTrysteroRoom`
pair.
## Features
- Implements the `Transport` interface (`connect`, `disconnect`,
`send`, `onMessage`, `onPeerEvent`, `getConnectedPeers`, `close`)
- Per-peer registry keyed by `peerId` with reverse-lookup by the
underlying Trystero room peer id
- Lazy room initialisation — the Trystero room opens on the first
`connect()` call, not at construction time
- Pre-aborted `AbortSignal` short-circuits `connect()` with `ABORTED`
- Auto-prunes peers when their Trystero room peer leaves
- Targeted send via Trystero's typed-action `send(envelope, target)`
- Optional `maxPeers` cap surfaces as `ROOM_FULL`
- Structured `NetDiscTrysteroError` at every boundary
- Full OTel surface — `trystero_transport.{connect,disconnect,send}`
spans, `trystero_transport.messages_total{direction,outcome}`
counter, `trystero_transport.active_peers` gauge
## Usage: Basic Example
```ts
import { TrysteroNodeTransport } from "@suaveplan/network-discovery-trystero/transport";
import { joinRoom } from "trystero/nostr";
const transport = new TrysteroNodeTransport({
appId: "my-app",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory: (appId, config) => joinRoom(config, appId),
});
await transport.connect("peer-a", "trystero-room-peer-id-1");
await transport.send("peer-a", envelope);
```
## Usage: Advanced Example
```ts
import { TrysteroNodeTransport } from "@suaveplan/network-discovery-trystero/transport";
const transport = new TrysteroNodeTransport({
appId: "my-app",
transport: "torrent",
torrent: { trackers: ["wss://tracker.example"] },
torrentRoomFactory,
password: "shared-secret",
rtcConfig: {
iceServers: [{ urls: "stun:stun.example:3478" }],
},
maxPeers: 64,
});
const offMessage = transport.onMessage((peerId, envelope) => {
console.log("inbound", peerId, envelope.id);
});
const offEvent = transport.onPeerEvent((peerId, event) => {
if (event === "disconnected") {
// peer pruned automatically; clear app-level state
}
});
const ac = new AbortController();
try {
await transport.connect("alice", "tr-peer-a", ac.signal);
await transport.send("alice", envelope);
} finally {
offMessage();
offEvent();
await transport.close();
}
```
## API Reference
### `class TrysteroNodeTransport implements Transport`
#### Constructor
`new TrysteroNodeTransport(config: TrysteroNodeTransportConfig)`
Reads `appId`, `transport`, the matching per-transport config block,
the matching room factory, and optional `password` / `relayPeerId` /
`rtcConfig` / `maxPeers`.
#### Methods
- `connect(peerId, address, signal?)` — registers peer; opens the
underlying Trystero room on first call. Throws `ABORTED` on
pre-aborted signal, `PEER_ALREADY_CONNECTED` on duplicate,
`ROOM_FULL` past `maxPeers`, `DISPOSED` after `close()`,
`INVALID_OPTIONS` on missing config/factory.
- `disconnect(peerId)` — removes registry entry, emits `disconnected`;
no-op on unknown peer.
- `send(peerId, envelope)` — dispatches via Trystero's targeted
send. Throws `PEER_UNKNOWN`, `DISPOSED`, `NOT_RUNNING`, or
`RELAY_FAILED`.
- `onMessage(handler)` / `onPeerEvent(handler)` — returns an
unsubscribe function.
- `getConnectedPeers()` — readonly snapshot.
- `close()` — idempotent; emits `disconnected` for every remaining
peer and tears down the room.
- `isInitialized` — true once the Trystero room is open.
- `getRoomPeers()` — diagnostic snapshot of room peer ids seen via
`onPeerJoin`.
## Implementation Notes
- The transport keeps the **data plane** on a separate Trystero
action namespace (`"envelope"`) from the discovery namespace
(`"discovery"`) used by `TrysteroDiscovery`. Two transports
sharing the same room therefore do not cross-deliver each other's
payloads.
- Address semantics: the `address` argument to `connect()` is the
underlying Trystero room peer id. Tests and integrations can use
`TrysteroDiscovery`'s emitted peer metadata to map app-level
`peerId` to Trystero peer id and then drive `connect()`.
- Inbound envelopes from room peers that have not been explicitly
registered via `connect()` are still delivered to `onMessage`
handlers — the room peer id is passed through as the `PeerId`.
Callers MAY choose to ignore unsolicited traffic at the
application layer.
- Failures to open the room map to the same transport-specific codes
the discovery strategy uses (`TORRENT_TRACKER_FAILED`,
`NOSTR_RELAY_FAILED`, etc.) so dashboards can pivot uniformly.
- `close()` is idempotent and never throws — internal `room.leave()`
failures are swallowed to keep teardown deterministic.

View File

@@ -0,0 +1,106 @@
# Trystero Discovery — Public Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns every type and Zod schema exposed by
`@suaveplan/network-discovery-trystero`. It defines the four transport
configuration shapes (BitTorrent / Nostr / MQTT / IPFS), the single
`TrysteroTransportAdapter` contract every transport conforms to, the
wire-format message and peer-metadata shapes, the typed event map emitted
through the optional `EventBusLike`, and the runtime Zod schemas that gate
every public option struct at the package boundary.
Keeping every type in a single module (with a sibling `.schemas.ts` for
runtime validation) lets the orchestrator stay completely transport-agnostic:
the four transport adapters all conform to the same interface, and
construction-time selection happens via `transport` + the matching factory.
At runtime the orchestrator never branches on the transport-string itself.
## Features
- `TrysteroTransport` literal-union — `"torrent" | "nostr" | "mqtt" | "ipfs"`.
- Per-transport config interfaces: `TorrentTransportConfig`,
`NostrTransportConfig`, `MqttTransportConfig`, `IpfsTransportConfig`. All
enforce non-empty relay / tracker / broker lists via the matching Zod
schema.
- `TrysteroTransportAdapter` — the universal adapter contract every transport
conforms to. Owns `initialize`, `send`, `leave`, `destroy`, `ping`,
`isConnected`.
- `TrysteroAdapterCallbacks` — `onPeerJoined` / `onPeerLost` / `onMessage`.
- `TrysteroMessage` + frozen `TrysteroMessageType` registry — discovery wire
format.
- `TrysteroDiscoveryConfig` + `ResolvedTrysteroDiscoveryConfig`.
- `TrysteroEventMap` — typed events emitted on the optional `EventBusLike`.
- Zod schemas: `TrysteroDiscoveryConfigSchema`,
`TrysteroPeerMetadataSchema`, plus one strict schema per transport block.
## Usage: Basic Example
```ts
import {
type TrysteroDiscoveryConfig,
TrysteroDiscoveryConfigSchema,
} from "@suaveplan/network-discovery-trystero/types";
const cfg: TrysteroDiscoveryConfig = {
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory: async () => { throw new Error("test"); },
};
const parsed = TrysteroDiscoveryConfigSchema.parse(cfg);
console.log(parsed.interval); // 30_000
```
## Usage: Advanced Example
```ts
import type {
TrysteroEventMap,
TrysteroTransportAdapter,
} from "@suaveplan/network-discovery-trystero/types";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<TrysteroEventMap>();
bus.on("trystero:fallback", (e) => {
console.log(`fell back from ${e.from} -> ${e.to} (attempt ${e.attempt})`);
});
function describe(a: TrysteroTransportAdapter): string {
return `kind=${a.kind} connected=${a.isConnected}`;
}
```
## API Reference
- `type TrysteroTransport` — `"torrent" | "nostr" | "mqtt" | "ipfs"`.
- `interface TrysteroPeerMetadata` — `id, addresses, metadata, lastSeen, transport`.
- `interface TrysteroMessage` — `type, from, payload, timestamp, transport?`.
- `interface TrysteroTransportAdapter` — adapter contract.
- `interface TrysteroAdapterCallbacks` — adapter→orchestrator callbacks.
- `interface TrysteroDiscoveryConfig` — caller-supplied config struct.
- `interface ResolvedTrysteroDiscoveryConfig` — post-Zod struct (no
`undefined` for defaulted fields).
- `type TrysteroEventMap` — typed events emitted on `EventBusLike`.
- `TrysteroDiscoveryConfigSchema.parse(input)` → `output` — throws Zod error
on malformed input.
- Each transport schema rejects empty `trackers` / `relays` / `brokers` /
`bootstrap` arrays.
## Implementation Notes
- The empty-list rejection is implemented on the Zod schemas via `.min(1)`,
not in the constructor. This guarantees there is no execution path where
the orchestrator sees an empty relay list, regardless of how the config
arrives at the boundary.
- `TrysteroRoomLike.makeAction` returns a `readonly` triple to discourage
callers from mutating it; the actual `trystero` library returns a mutable
array but the orchestrator only reads.
- `ResolvedTrysteroDiscoveryConfig` keeps callable factories as `undefined`
for the transports that were not selected at construction; the
orchestrator enforces "factory present for selected + fallback transports"
in code, since Zod cannot express that cross-field constraint cleanly.