first commit
This commit is contained in:
136
docs/networking/network/network-discovery-trystero/README.md
Normal file
136
docs/networking/network/network-discovery-trystero/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user