Initial Commit

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,110 @@
{
"name": "@suaveplan/network-discovery-trystero",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "Browser-tier Trystero DiscoveryStrategy: serverless WebRTC peer discovery via four interchangeable transports (BitTorrent / Nostr / MQTT / IPFS) behind a single TrysteroTransportAdapter contract. Insecure-default rejection: every transport requires explicit relay / tracker / broker lists. AbortSignal + EventBusLike + ServiceRegistry + OTel + Zod boundaries.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
},
"./trystero-discovery": {
"import": { "types": "./dist/core/trystero-discovery/trystero-discovery.d.ts", "default": "./dist/core/trystero-discovery/trystero-discovery.js" },
"require": { "types": "./dist/core/trystero-discovery/trystero-discovery.d.ts", "default": "./dist/core/trystero-discovery/trystero-discovery.js" }
},
"./trystero-peer-manager": {
"import": { "types": "./dist/core/trystero-peer-manager/trystero-peer-manager.d.ts", "default": "./dist/core/trystero-peer-manager/trystero-peer-manager.js" },
"require": { "types": "./dist/core/trystero-peer-manager/trystero-peer-manager.d.ts", "default": "./dist/core/trystero-peer-manager/trystero-peer-manager.js" }
},
"./trystero-intervals": {
"import": { "types": "./dist/core/trystero-intervals/trystero-intervals.d.ts", "default": "./dist/core/trystero-intervals/trystero-intervals.js" },
"require": { "types": "./dist/core/trystero-intervals/trystero-intervals.d.ts", "default": "./dist/core/trystero-intervals/trystero-intervals.js" }
},
"./trystero-fallback": {
"import": { "types": "./dist/core/trystero-fallback/trystero-fallback.d.ts", "default": "./dist/core/trystero-fallback/trystero-fallback.js" },
"require": { "types": "./dist/core/trystero-fallback/trystero-fallback.d.ts", "default": "./dist/core/trystero-fallback/trystero-fallback.js" }
},
"./trystero-message-handlers": {
"import": { "types": "./dist/core/trystero-message-handlers/trystero-message-handlers.d.ts", "default": "./dist/core/trystero-message-handlers/trystero-message-handlers.js" },
"require": { "types": "./dist/core/trystero-message-handlers/trystero-message-handlers.d.ts", "default": "./dist/core/trystero-message-handlers/trystero-message-handlers.js" }
},
"./torrent-transport": {
"import": { "types": "./dist/core/torrent-transport/torrent-transport.d.ts", "default": "./dist/core/torrent-transport/torrent-transport.js" },
"require": { "types": "./dist/core/torrent-transport/torrent-transport.d.ts", "default": "./dist/core/torrent-transport/torrent-transport.js" }
},
"./nostr-transport": {
"import": { "types": "./dist/core/nostr-transport/nostr-transport.d.ts", "default": "./dist/core/nostr-transport/nostr-transport.js" },
"require": { "types": "./dist/core/nostr-transport/nostr-transport.d.ts", "default": "./dist/core/nostr-transport/nostr-transport.js" }
},
"./mqtt-transport": {
"import": { "types": "./dist/core/mqtt-transport/mqtt-transport.d.ts", "default": "./dist/core/mqtt-transport/mqtt-transport.js" },
"require": { "types": "./dist/core/mqtt-transport/mqtt-transport.d.ts", "default": "./dist/core/mqtt-transport/mqtt-transport.js" }
},
"./ipfs-transport": {
"import": { "types": "./dist/core/ipfs-transport/ipfs-transport.d.ts", "default": "./dist/core/ipfs-transport/ipfs-transport.js" },
"require": { "types": "./dist/core/ipfs-transport/ipfs-transport.d.ts", "default": "./dist/core/ipfs-transport/ipfs-transport.js" }
},
"./transport": {
"import": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" },
"require": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" }
},
"./plugin": {
"import": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" },
"require": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/network-bootstrap": "workspace:^",
"@suaveplan/retry": "workspace:^",
"@suaveplan/service-discovery": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"trystero": "catalog:network-discovery"
},
"peerDependenciesMeta": {
"trystero": { "optional": true }
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1 @@
export { IpfsTransportAdapter } from "./ipfs-transport.js";

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,190 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeTrysteroBroker,
fakeIpfsRoomFactory,
flushMicrotasks,
} from "../../../tests/setup/fake-trystero.js";
import {
NET_DISC_TRYSTERO_CODES,
type NetDiscTrysteroError,
} from "../../errors/errors.js";
import { TrysteroMessageType } from "../../types/types.js";
import { IpfsTransportAdapter } from "./ipfs-transport.js";
describe("IpfsTransportAdapter", () => {
it("initializes + exchanges messages", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const a = new IpfsTransportAdapter(
"demo",
{
bootstrap: ["/ip4/1.2.3.4/tcp/4001/p2p/QmA"],
relay: ["/dnsaddr/relay"],
},
{},
fakeIpfsRoomFactory(broker)
);
const b = new IpfsTransportAdapter(
"demo",
{ bootstrap: ["/ip4/1.2.3.4/tcp/4001/p2p/QmA"] },
{},
fakeIpfsRoomFactory(broker)
);
const got: string[] = [];
await a.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: (m) => got.push(m.from),
});
await b.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
await flushMicrotasks();
await b.send({
type: TrysteroMessageType.HEARTBEAT,
from: "b",
payload: {},
timestamp: 0,
});
await flushMicrotasks();
expect(got).toContain("b");
expect(a.kind).toBe("ipfs");
a.destroy();
b.destroy();
});
it("maps factory failure to IPFS_BOOTSTRAP_FAILED", {
timeout: 5000,
}, async () => {
const adapter = new IpfsTransportAdapter(
"demo",
{ bootstrap: ["/ip4/1"] },
{},
async () => {
throw new Error("ipfs down");
}
);
let caught: unknown;
try {
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.IPFS_BOOTSTRAP_FAILED
);
});
it("AbortError + DISPOSED + NOT_RUNNING + idempotent destroy", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new IpfsTransportAdapter(
"demo",
{ bootstrap: ["/ip4/1"] },
{},
fakeIpfsRoomFactory(broker)
);
const ctrl = new AbortController();
ctrl.abort();
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl.signal
)
).rejects.toBeDefined();
const ctrl2 = new AbortController();
Object.defineProperty(ctrl2.signal, "aborted", { value: true });
Object.defineProperty(ctrl2.signal, "reason", { value: "weird" });
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl2.signal
)
).rejects.toMatchObject({ name: "AbortError" });
adapter.destroy();
await expect(
adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
})
).rejects.toMatchObject({ code: NET_DISC_TRYSTERO_CODES.DISPOSED });
await expect(adapter.ping("x")).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
await expect(
adapter.send({
type: TrysteroMessageType.HEARTBEAT,
from: "me",
payload: {},
timestamp: 0,
})
).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
adapter.destroy();
});
it("end-to-end ping + leave noop", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new IpfsTransportAdapter(
"demo",
{ bootstrap: ["/ip4/1"] },
{},
fakeIpfsRoomFactory(broker)
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
expect(await adapter.ping("x")).toBe(1);
adapter.leave();
adapter.destroy();
adapter.leave(); // no-op after destroy
});
it("leave swallows underlying errors", { timeout: 5000 }, async () => {
const adapter = new IpfsTransportAdapter(
"demo",
{ bootstrap: ["/ip4/1"] },
{},
async () => ({
getPeerId: () => "x",
makeAction: () => [async () => {}, () => {}, () => {}] as never,
onPeerJoin: () => () => {
throw new Error("u");
},
onPeerLeave: () => () => {
throw new Error("u");
},
leave: () => {
throw new Error("l");
},
ping: async () => 0,
})
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
adapter.leave();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
});

View File

@@ -0,0 +1,166 @@
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import type {
IpfsRoomFactory,
IpfsTransportConfig,
TrysteroAdapterCallbacks,
TrysteroMessage,
TrysteroRoomCommonConfig,
TrysteroRoomLike,
TrysteroTransportAdapter,
} from "../../types/types.js";
/**
* IPFS libp2p-pubsub `TrysteroTransportAdapter`. Wraps a Trystero room built
* by a caller-supplied `IpfsRoomFactory` (production binds `joinRoom` from
* `trystero/ipfs`, tests bind a deterministic in-memory fake). The
* `bootstrap` array is REQUIRED and non-empty — enforced at the Zod boundary
* upstream.
*/
export class IpfsTransportAdapter implements TrysteroTransportAdapter {
public readonly kind = "ipfs" as const;
private readonly appId: string;
private readonly config: IpfsTransportConfig;
private readonly common: TrysteroRoomCommonConfig;
private readonly factory: IpfsRoomFactory;
private room: TrysteroRoomLike | null = null;
private myPeerId: string | null = null;
private sendAction:
| ((
data: TrysteroMessage,
target?: string | readonly string[]
) => Promise<void>)
| null = null;
private peerJoinUnsub: (() => void) | null = null;
private peerLeaveUnsub: (() => void) | null = null;
private destroyed = false;
public constructor(
appId: string,
config: IpfsTransportConfig,
common: TrysteroRoomCommonConfig,
factory: IpfsRoomFactory
) {
this.appId = appId;
this.config = config;
this.common = common;
this.factory = factory;
}
public async initialize(
callbacks: TrysteroAdapterCallbacks,
signal?: AbortSignal
): Promise<string> {
if (signal?.aborted) throw toAbortError(signal);
if (this.destroyed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
let room: TrysteroRoomLike;
try {
room = await this.factory(this.appId, {
...this.config,
...this.common,
});
} catch (err) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.IPFS_BOOTSTRAP_FAILED,
"errors.trystero.ipfs_init_failed",
{ cause: err }
);
}
this.room = room;
this.myPeerId = room.getPeerId();
const [send, , onReceive] =
room.makeAction<TrysteroMessage>("discovery");
this.sendAction = send;
onReceive((_peerId, msg) => callbacks.onMessage(msg));
this.peerJoinUnsub = room.onPeerJoin((peerId) =>
callbacks.onPeerJoined(peerId)
);
this.peerLeaveUnsub = room.onPeerLeave((peerId) =>
callbacks.onPeerLost(peerId)
);
return this.myPeerId;
}
public async send(
message: TrysteroMessage,
target?: string
): Promise<void> {
if (this.sendAction === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
await this.sendAction(message, target);
}
public leave(): void {
if (this.room === null) return;
try {
this.room.leave();
} catch {
/* swallow */
}
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.peerJoinUnsub !== null) {
try {
this.peerJoinUnsub();
} catch {
/* swallow */
}
this.peerJoinUnsub = null;
}
if (this.peerLeaveUnsub !== null) {
try {
this.peerLeaveUnsub();
} catch {
/* swallow */
}
this.peerLeaveUnsub = null;
}
if (this.room !== null) {
try {
this.room.leave();
} catch {
/* swallow */
}
this.room = null;
}
this.sendAction = null;
this.myPeerId = null;
}
public get isConnected(): boolean {
return this.room !== null;
}
public async ping(peerId: string): Promise<number> {
if (this.room === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
return this.room.ping(peerId);
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { MqttTransportAdapter } from "./mqtt-transport.js";

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,199 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeTrysteroBroker,
fakeMqttRoomFactory,
flushMicrotasks,
} from "../../../tests/setup/fake-trystero.js";
import {
NET_DISC_TRYSTERO_CODES,
type NetDiscTrysteroError,
} from "../../errors/errors.js";
import { TrysteroMessageType } from "../../types/types.js";
import { MqttTransportAdapter } from "./mqtt-transport.js";
describe("MqttTransportAdapter", () => {
it("initializes + exchanges messages", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const a = new MqttTransportAdapter(
"demo",
{ brokers: ["wss://broker"], username: "u", password: "p" },
{},
fakeMqttRoomFactory(broker)
);
const b = new MqttTransportAdapter(
"demo",
{ brokers: ["wss://broker"] },
{},
fakeMqttRoomFactory(broker)
);
const got: string[] = [];
await a.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: (m) => got.push(m.from),
});
await b.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
await flushMicrotasks();
await b.send({
type: TrysteroMessageType.HEARTBEAT,
from: "b",
payload: {},
timestamp: 0,
});
await flushMicrotasks();
expect(got).toContain("b");
expect(a.kind).toBe("mqtt");
a.destroy();
b.destroy();
});
it("maps factory failure to MQTT_BROKER_FAILED", {
timeout: 5000,
}, async () => {
const adapter = new MqttTransportAdapter(
"demo",
{ brokers: ["wss://broker"] },
{},
async () => {
throw new Error("broker down");
}
);
let caught: unknown;
try {
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.MQTT_BROKER_FAILED
);
});
it("rejects pre-aborted signal + DISPOSED after destroy", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new MqttTransportAdapter(
"demo",
{ brokers: ["wss://broker"] },
{},
fakeMqttRoomFactory(broker)
);
const ctrl = new AbortController();
ctrl.abort();
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl.signal
)
).rejects.toBeDefined();
adapter.destroy();
await expect(
adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
})
).rejects.toMatchObject({ code: NET_DISC_TRYSTERO_CODES.DISPOSED });
});
it("AbortError forging + NOT_RUNNING + leave + destroy idempotent", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new MqttTransportAdapter(
"demo",
{ brokers: ["wss://broker"] },
{},
fakeMqttRoomFactory(broker)
);
const ctrl = new AbortController();
Object.defineProperty(ctrl.signal, "aborted", { value: true });
Object.defineProperty(ctrl.signal, "reason", { value: "weird" });
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl.signal
)
).rejects.toMatchObject({ name: "AbortError" });
await expect(
adapter.send({
type: TrysteroMessageType.HEARTBEAT,
from: "me",
payload: {},
timestamp: 0,
})
).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
await expect(adapter.ping("x")).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
adapter.leave();
adapter.destroy();
adapter.destroy();
});
it("end-to-end ping", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new MqttTransportAdapter(
"demo",
{ brokers: ["wss://broker"] },
{},
fakeMqttRoomFactory(broker)
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
expect(await adapter.ping("x")).toBe(1);
adapter.destroy();
});
it("leave swallows underlying errors", { timeout: 5000 }, async () => {
const adapter = new MqttTransportAdapter(
"demo",
{ brokers: ["wss://b"] },
{},
async () => ({
getPeerId: () => "x",
makeAction: () => [async () => {}, () => {}, () => {}] as never,
onPeerJoin: () => () => {
throw new Error("u");
},
onPeerLeave: () => () => {
throw new Error("u");
},
leave: () => {
throw new Error("l");
},
ping: async () => 0,
})
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
adapter.leave();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
});

View File

@@ -0,0 +1,165 @@
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import type {
MqttRoomFactory,
MqttTransportConfig,
TrysteroAdapterCallbacks,
TrysteroMessage,
TrysteroRoomCommonConfig,
TrysteroRoomLike,
TrysteroTransportAdapter,
} from "../../types/types.js";
/**
* MQTT-broker `TrysteroTransportAdapter`. Wraps a Trystero room built by a
* caller-supplied `MqttRoomFactory` (production binds `joinRoom` from
* `trystero/mqtt`, tests bind a deterministic in-memory fake). The `brokers`
* array is REQUIRED and non-empty — enforced at the Zod boundary upstream.
*/
export class MqttTransportAdapter implements TrysteroTransportAdapter {
public readonly kind = "mqtt" as const;
private readonly appId: string;
private readonly config: MqttTransportConfig;
private readonly common: TrysteroRoomCommonConfig;
private readonly factory: MqttRoomFactory;
private room: TrysteroRoomLike | null = null;
private myPeerId: string | null = null;
private sendAction:
| ((
data: TrysteroMessage,
target?: string | readonly string[]
) => Promise<void>)
| null = null;
private peerJoinUnsub: (() => void) | null = null;
private peerLeaveUnsub: (() => void) | null = null;
private destroyed = false;
public constructor(
appId: string,
config: MqttTransportConfig,
common: TrysteroRoomCommonConfig,
factory: MqttRoomFactory
) {
this.appId = appId;
this.config = config;
this.common = common;
this.factory = factory;
}
public async initialize(
callbacks: TrysteroAdapterCallbacks,
signal?: AbortSignal
): Promise<string> {
if (signal?.aborted) throw toAbortError(signal);
if (this.destroyed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
let room: TrysteroRoomLike;
try {
room = await this.factory(this.appId, {
...this.config,
...this.common,
});
} catch (err) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.MQTT_BROKER_FAILED,
"errors.trystero.mqtt_init_failed",
{ cause: err }
);
}
this.room = room;
this.myPeerId = room.getPeerId();
const [send, , onReceive] =
room.makeAction<TrysteroMessage>("discovery");
this.sendAction = send;
onReceive((_peerId, msg) => callbacks.onMessage(msg));
this.peerJoinUnsub = room.onPeerJoin((peerId) =>
callbacks.onPeerJoined(peerId)
);
this.peerLeaveUnsub = room.onPeerLeave((peerId) =>
callbacks.onPeerLost(peerId)
);
return this.myPeerId;
}
public async send(
message: TrysteroMessage,
target?: string
): Promise<void> {
if (this.sendAction === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
await this.sendAction(message, target);
}
public leave(): void {
if (this.room === null) return;
try {
this.room.leave();
} catch {
/* swallow */
}
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.peerJoinUnsub !== null) {
try {
this.peerJoinUnsub();
} catch {
/* swallow */
}
this.peerJoinUnsub = null;
}
if (this.peerLeaveUnsub !== null) {
try {
this.peerLeaveUnsub();
} catch {
/* swallow */
}
this.peerLeaveUnsub = null;
}
if (this.room !== null) {
try {
this.room.leave();
} catch {
/* swallow */
}
this.room = null;
}
this.sendAction = null;
this.myPeerId = null;
}
public get isConnected(): boolean {
return this.room !== null;
}
public async ping(peerId: string): Promise<number> {
if (this.room === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
return this.room.ping(peerId);
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { NostrTransportAdapter } from "./nostr-transport.js";

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,213 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeTrysteroBroker,
fakeNostrRoomFactory,
flushMicrotasks,
} from "../../../tests/setup/fake-trystero.js";
import {
NET_DISC_TRYSTERO_CODES,
type NetDiscTrysteroError,
} from "../../errors/errors.js";
import { TrysteroMessageType } from "../../types/types.js";
import { NostrTransportAdapter } from "./nostr-transport.js";
describe("NostrTransportAdapter", () => {
it("initializes + exchanges messages", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const a = new NostrTransportAdapter(
"demo",
{ relays: ["wss://relay-1"] },
{},
fakeNostrRoomFactory(broker)
);
const b = new NostrTransportAdapter(
"demo",
{ relays: ["wss://relay-1"] },
{},
fakeNostrRoomFactory(broker)
);
const got: string[] = [];
await a.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: (m) => got.push(m.from),
});
await b.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
await flushMicrotasks();
await b.send({
type: TrysteroMessageType.HEARTBEAT,
from: "b",
payload: {},
timestamp: 0,
});
await flushMicrotasks();
expect(got).toContain("b");
expect(a.kind).toBe("nostr");
a.destroy();
b.destroy();
});
it("maps factory failure to NOSTR_RELAY_FAILED", {
timeout: 5000,
}, async () => {
const adapter = new NostrTransportAdapter(
"demo",
{ relays: ["wss://relay-1"] },
{},
async () => {
throw new Error("relay down");
}
);
let caught: unknown;
try {
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED
);
});
it("rejects pre-aborted signal + DISPOSED after destroy", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new NostrTransportAdapter(
"demo",
{ relays: ["wss://relay-1"] },
{},
fakeNostrRoomFactory(broker)
);
const ctrl = new AbortController();
ctrl.abort();
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl.signal
)
).rejects.toBeDefined();
adapter.destroy();
await expect(
adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
})
).rejects.toMatchObject({ code: NET_DISC_TRYSTERO_CODES.DISPOSED });
});
it("AbortError forging on non-Error reason", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new NostrTransportAdapter(
"demo",
{ relays: ["wss://r"] },
{},
fakeNostrRoomFactory(broker)
);
const ctrl = new AbortController();
Object.defineProperty(ctrl.signal, "aborted", { value: true });
Object.defineProperty(ctrl.signal, "reason", { value: "weird" });
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl.signal
)
).rejects.toMatchObject({ name: "AbortError" });
});
it("send/ping throw NOT_RUNNING before initialize, idempotent destroy + leave", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new NostrTransportAdapter(
"demo",
{ relays: ["wss://r"] },
{},
fakeNostrRoomFactory(broker)
);
await expect(
adapter.send({
type: TrysteroMessageType.HEARTBEAT,
from: "me",
payload: {},
timestamp: 0,
})
).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
await expect(adapter.ping("x")).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
adapter.leave();
adapter.destroy();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
it("end-to-end leave + ping", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new NostrTransportAdapter(
"demo",
{ relays: ["wss://r"] },
{},
fakeNostrRoomFactory(broker)
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
expect(await adapter.ping("x")).toBe(1);
adapter.leave();
adapter.destroy();
});
it("leave swallows underlying errors", { timeout: 5000 }, async () => {
const adapter = new NostrTransportAdapter(
"demo",
{ relays: ["wss://r"] },
{},
async () => ({
getPeerId: () => "x",
makeAction: () => [async () => {}, () => {}, () => {}] as never,
onPeerJoin: () => () => {
throw new Error("u");
},
onPeerLeave: () => () => {
throw new Error("u");
},
leave: () => {
throw new Error("l");
},
ping: async () => 0,
})
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
adapter.leave();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
});

View File

@@ -0,0 +1,165 @@
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import type {
NostrRoomFactory,
NostrTransportConfig,
TrysteroAdapterCallbacks,
TrysteroMessage,
TrysteroRoomCommonConfig,
TrysteroRoomLike,
TrysteroTransportAdapter,
} from "../../types/types.js";
/**
* Nostr-relay `TrysteroTransportAdapter`. Wraps a Trystero room built by a
* caller-supplied `NostrRoomFactory` (production binds `joinRoom` from
* `trystero/nostr`, tests bind a deterministic in-memory fake). The `relays`
* array is REQUIRED and non-empty — enforced at the Zod boundary upstream.
*/
export class NostrTransportAdapter implements TrysteroTransportAdapter {
public readonly kind = "nostr" as const;
private readonly appId: string;
private readonly config: NostrTransportConfig;
private readonly common: TrysteroRoomCommonConfig;
private readonly factory: NostrRoomFactory;
private room: TrysteroRoomLike | null = null;
private myPeerId: string | null = null;
private sendAction:
| ((
data: TrysteroMessage,
target?: string | readonly string[]
) => Promise<void>)
| null = null;
private peerJoinUnsub: (() => void) | null = null;
private peerLeaveUnsub: (() => void) | null = null;
private destroyed = false;
public constructor(
appId: string,
config: NostrTransportConfig,
common: TrysteroRoomCommonConfig,
factory: NostrRoomFactory
) {
this.appId = appId;
this.config = config;
this.common = common;
this.factory = factory;
}
public async initialize(
callbacks: TrysteroAdapterCallbacks,
signal?: AbortSignal
): Promise<string> {
if (signal?.aborted) throw toAbortError(signal);
if (this.destroyed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
let room: TrysteroRoomLike;
try {
room = await this.factory(this.appId, {
...this.config,
...this.common,
});
} catch (err) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED,
"errors.trystero.nostr_init_failed",
{ cause: err }
);
}
this.room = room;
this.myPeerId = room.getPeerId();
const [send, , onReceive] =
room.makeAction<TrysteroMessage>("discovery");
this.sendAction = send;
onReceive((_peerId, msg) => callbacks.onMessage(msg));
this.peerJoinUnsub = room.onPeerJoin((peerId) =>
callbacks.onPeerJoined(peerId)
);
this.peerLeaveUnsub = room.onPeerLeave((peerId) =>
callbacks.onPeerLost(peerId)
);
return this.myPeerId;
}
public async send(
message: TrysteroMessage,
target?: string
): Promise<void> {
if (this.sendAction === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
await this.sendAction(message, target);
}
public leave(): void {
if (this.room === null) return;
try {
this.room.leave();
} catch {
/* swallow */
}
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.peerJoinUnsub !== null) {
try {
this.peerJoinUnsub();
} catch {
/* swallow */
}
this.peerJoinUnsub = null;
}
if (this.peerLeaveUnsub !== null) {
try {
this.peerLeaveUnsub();
} catch {
/* swallow */
}
this.peerLeaveUnsub = null;
}
if (this.room !== null) {
try {
this.room.leave();
} catch {
/* swallow */
}
this.room = null;
}
this.sendAction = null;
this.myPeerId = null;
}
public get isConnected(): boolean {
return this.room !== null;
}
public async ping(peerId: string): Promise<number> {
if (this.room === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
return this.room.ping(peerId);
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { TorrentTransportAdapter } from "./torrent-transport.js";

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,268 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeTrysteroBroker,
fakeTorrentRoomFactory,
flushMicrotasks,
} from "../../../tests/setup/fake-trystero.js";
import {
NET_DISC_TRYSTERO_CODES,
type NetDiscTrysteroError,
} from "../../errors/errors.js";
import { TrysteroMessageType } from "../../types/types.js";
import { TorrentTransportAdapter } from "./torrent-transport.js";
describe("TorrentTransportAdapter", () => {
it("initializes and exposes peer id", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
const id = await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
expect(id).toMatch(/^peer-torrent-/);
expect(adapter.kind).toBe("torrent");
expect(adapter.isConnected).toBe(true);
adapter.destroy();
});
it("rejects pre-aborted signal", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
const ctrl = new AbortController();
ctrl.abort();
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl.signal
)
).rejects.toBeDefined();
});
it("forges AbortError when reason is non-Error", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
const ctrl = new AbortController();
Object.defineProperty(ctrl.signal, "aborted", { value: true });
Object.defineProperty(ctrl.signal, "reason", { value: "weird" });
await expect(
adapter.initialize(
{
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
},
ctrl.signal
)
).rejects.toMatchObject({ name: "AbortError" });
});
it("maps factory failure to TORRENT_TRACKER_FAILED", {
timeout: 5000,
}, async () => {
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
async () => {
throw new Error("tracker down");
}
);
let caught: unknown;
try {
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.TORRENT_TRACKER_FAILED
);
});
it("throws DISPOSED after destroy", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
adapter.destroy();
await expect(
adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
})
).rejects.toMatchObject({ code: NET_DISC_TRYSTERO_CODES.DISPOSED });
});
it("send throws NOT_RUNNING before initialize", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
await expect(
adapter.send({
type: TrysteroMessageType.HEARTBEAT,
from: "me",
payload: {},
timestamp: 0,
})
).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
});
it("ping throws NOT_RUNNING before initialize", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
await expect(adapter.ping("x")).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
});
it("end-to-end message exchange between two adapters", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const received: string[] = [];
const a = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
const b = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
const joins: string[] = [];
await a.initialize({
onPeerJoined: (id) => joins.push(id),
onPeerLost: () => {},
onMessage: (m) => received.push(m.from),
});
await b.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
await flushMicrotasks();
const id = await b.ping("ignored");
expect(id).toBe(1);
await b.send({
type: TrysteroMessageType.HEARTBEAT,
from: "b",
payload: {},
timestamp: 0,
});
await flushMicrotasks();
expect(received).toContain("b");
expect(joins.length).toBeGreaterThan(0);
a.destroy();
b.destroy();
});
it("destroy is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
adapter.destroy();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
it("leave is no-op when room null", { timeout: 5000 }, () => {
const broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
fakeTorrentRoomFactory(broker)
);
adapter.leave();
expect(adapter.isConnected).toBe(false);
});
it("leave swallows underlying errors", { timeout: 5000 }, async () => {
const _broker = new FakeTrysteroBroker();
const adapter = new TorrentTransportAdapter(
"demo",
{ trackers: ["wss://t1"] },
{},
async () => ({
getPeerId: () => "x",
makeAction: () => [async () => {}, () => {}, () => {}] as never,
onPeerJoin: () => () => {
throw new Error("unsub failed");
},
onPeerLeave: () => () => {
throw new Error("unsub failed");
},
leave: () => {
throw new Error("leave failed");
},
ping: async () => 0,
})
);
await adapter.initialize({
onPeerJoined: () => {},
onPeerLost: () => {},
onMessage: () => {},
});
// Both leave + destroy should be safe even when the underlying
// room throws.
adapter.leave();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
});

View File

@@ -0,0 +1,166 @@
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import type {
TorrentRoomFactory,
TorrentTransportConfig,
TrysteroAdapterCallbacks,
TrysteroMessage,
TrysteroRoomCommonConfig,
TrysteroRoomLike,
TrysteroTransportAdapter,
} from "../../types/types.js";
/**
* BitTorrent-tracker `TrysteroTransportAdapter`. Wraps a Trystero room built
* by a caller-supplied `TorrentRoomFactory` (production binds `joinRoom` from
* `trystero/torrent`, tests bind a deterministic in-memory fake). The
* `trackers` array must be non-empty — the Zod schema enforces this at the
* constructor boundary, the adapter assumes it has already been validated.
*/
export class TorrentTransportAdapter implements TrysteroTransportAdapter {
public readonly kind = "torrent" as const;
private readonly appId: string;
private readonly config: TorrentTransportConfig;
private readonly common: TrysteroRoomCommonConfig;
private readonly factory: TorrentRoomFactory;
private room: TrysteroRoomLike | null = null;
private myPeerId: string | null = null;
private sendAction:
| ((
data: TrysteroMessage,
target?: string | readonly string[]
) => Promise<void>)
| null = null;
private peerJoinUnsub: (() => void) | null = null;
private peerLeaveUnsub: (() => void) | null = null;
private destroyed = false;
public constructor(
appId: string,
config: TorrentTransportConfig,
common: TrysteroRoomCommonConfig,
factory: TorrentRoomFactory
) {
this.appId = appId;
this.config = config;
this.common = common;
this.factory = factory;
}
public async initialize(
callbacks: TrysteroAdapterCallbacks,
signal?: AbortSignal
): Promise<string> {
if (signal?.aborted) throw toAbortError(signal);
if (this.destroyed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
let room: TrysteroRoomLike;
try {
room = await this.factory(this.appId, {
...this.config,
...this.common,
});
} catch (err) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.TORRENT_TRACKER_FAILED,
"errors.trystero.torrent_init_failed",
{ cause: err }
);
}
this.room = room;
this.myPeerId = room.getPeerId();
const [send, , onReceive] =
room.makeAction<TrysteroMessage>("discovery");
this.sendAction = send;
onReceive((_peerId, msg) => callbacks.onMessage(msg));
this.peerJoinUnsub = room.onPeerJoin((peerId) =>
callbacks.onPeerJoined(peerId)
);
this.peerLeaveUnsub = room.onPeerLeave((peerId) =>
callbacks.onPeerLost(peerId)
);
return this.myPeerId;
}
public async send(
message: TrysteroMessage,
target?: string
): Promise<void> {
if (this.sendAction === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
await this.sendAction(message, target);
}
public leave(): void {
if (this.room === null) return;
try {
this.room.leave();
} catch {
/* swallow */
}
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.peerJoinUnsub !== null) {
try {
this.peerJoinUnsub();
} catch {
/* swallow */
}
this.peerJoinUnsub = null;
}
if (this.peerLeaveUnsub !== null) {
try {
this.peerLeaveUnsub();
} catch {
/* swallow */
}
this.peerLeaveUnsub = null;
}
if (this.room !== null) {
try {
this.room.leave();
} catch {
/* swallow */
}
this.room = null;
}
this.sendAction = null;
this.myPeerId = null;
}
public get isConnected(): boolean {
return this.room !== null;
}
public async ping(peerId: string): Promise<number> {
if (this.room === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
return this.room.ping(peerId);
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { TrysteroDiscovery } from "./trystero-discovery.js";

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,907 @@
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
FakeTrysteroBroker,
fakeIpfsRoomFactory,
fakeMqttRoomFactory,
fakeNostrRoomFactory,
fakeTorrentRoomFactory,
flushMicrotasks,
} from "../../../tests/setup/fake-trystero.js";
import {
NET_DISC_TRYSTERO_CODES,
type NetDiscTrysteroError,
} from "../../errors/errors.js";
import type {
TrysteroDiscoveryConfig,
TrysteroEventMap,
} from "../../types/types.js";
import { TrysteroMessageType } from "../../types/types.js";
import { TrysteroDiscovery } from "./trystero-discovery.js";
function nostrConfig(
broker: FakeTrysteroBroker,
over: Partial<TrysteroDiscoveryConfig> = {}
): TrysteroDiscoveryConfig {
return {
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://relay-1"] },
nostrRoomFactory: fakeNostrRoomFactory(broker),
...over,
};
}
describe("TrysteroDiscovery constructor — rejection paths", () => {
it("INVALID_OPTIONS on malformed Zod input", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "",
transport: "nostr",
nostr: { relays: ["wss://r"] },
nostrRoomFactory: async () => ({}) as never,
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects empty relays list", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: [] },
nostrRoomFactory: async () => ({}) as never,
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects empty trackers list", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "torrent",
torrent: { trackers: [] },
torrentRoomFactory: async () => ({}) as never,
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects empty brokers list", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "mqtt",
mqtt: { brokers: [] },
mqttRoomFactory: async () => ({}) as never,
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects empty bootstrap list", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "ipfs",
ipfs: { bootstrap: [] },
ipfsRoomFactory: async () => ({}) as never,
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects torrent without torrent block", { timeout: 5000 }, () => {
const broker = new FakeTrysteroBroker();
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "torrent",
torrentRoomFactory: fakeTorrentRoomFactory(broker),
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects torrent without factory", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "torrent",
torrent: { trackers: ["wss://t"] },
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects nostr without nostr block", { timeout: 5000 }, () => {
const broker = new FakeTrysteroBroker();
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostrRoomFactory: fakeNostrRoomFactory(broker),
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects nostr without factory", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://r"] },
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects mqtt without mqtt block", { timeout: 5000 }, () => {
const broker = new FakeTrysteroBroker();
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "mqtt",
mqttRoomFactory: fakeMqttRoomFactory(broker),
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects mqtt without factory", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "mqtt",
mqtt: { brokers: ["wss://b"] },
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects ipfs without ipfs block", { timeout: 5000 }, () => {
const broker = new FakeTrysteroBroker();
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "ipfs",
ipfsRoomFactory: fakeIpfsRoomFactory(broker),
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects ipfs without factory", { timeout: 5000 }, () => {
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "ipfs",
ipfs: { bootstrap: ["/ip4/1"] },
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("rejects fallback transport without its config + factory", {
timeout: 5000,
}, () => {
const broker = new FakeTrysteroBroker();
let caught: unknown;
try {
new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://r"] },
nostrRoomFactory: fakeNostrRoomFactory(broker),
fallbackTransports: ["mqtt"], // mqtt block missing
} as TrysteroDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
});
it("applies Zod defaults", { timeout: 5000 }, () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
expect(d.config.interval).toBe(30_000);
expect(d.config.timeout).toBe(60_000);
expect(d.config.maxPeers).toBe(100);
expect(d.name).toBe("trystero");
expect(d.getCurrentTransport()).toBe("nostr");
d.dispose();
});
});
describe("TrysteroDiscovery lifecycle", () => {
it("start emits trystero:started", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const bus = new EventBus<TrysteroEventMap>();
const started: string[] = [];
bus.on("trystero:started", (e) => started.push(e.peerId));
const d = new TrysteroDiscovery(nostrConfig(broker, { bus }));
await d.start();
expect(d.getMyPeerId()).not.toBeNull();
expect(started).toHaveLength(1);
await d.stop();
});
it("start is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
await d.start();
expect(await d.isAvailable()).toBe(true);
await d.stop();
});
it("start throws DISPOSED after dispose", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
d.dispose();
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.DISPOSED,
});
});
it("start rejects pre-aborted signal", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
const ctrl = new AbortController();
ctrl.abort();
await expect(d.start(ctrl.signal)).rejects.toBeDefined();
d.dispose();
});
it("stop is a no-op when not running", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.stop();
expect(await d.isAvailable()).toBe(false);
d.dispose();
});
it("stop with aborted signal skips leave message", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
const ctrl = new AbortController();
ctrl.abort();
await d.stop(ctrl.signal);
expect(d.getMyPeerId()).toBeNull();
});
it("stop swallows send + leave errors", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
// Force underlying adapter send to throw by reaching in and replacing
// the adapter's send action with a thrower.
const adapter = (
d as unknown as { adapter: { send: (...a: unknown[]) => unknown } }
).adapter;
adapter.send = async () => {
throw new Error("boom");
};
await d.stop();
expect(d.getMyPeerId()).toBeNull();
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
d.dispose();
d.dispose();
expect(await d.isAvailable()).toBe(false);
});
});
describe("TrysteroDiscovery discover + announce", () => {
it("discover throws DISPOSED + honours signal", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
d.dispose();
await expect(d.discover(1000)).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.DISPOSED,
});
// recreate to test signal
const e = new TrysteroDiscovery(nostrConfig(broker));
await e.start();
const ctrl = new AbortController();
ctrl.abort();
await expect(e.discover(1000, ctrl.signal)).rejects.toBeDefined();
await e.stop();
});
it("isAvailable honours signal + reflects state", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
const ctrl = new AbortController();
ctrl.abort();
await expect(d.isAvailable(ctrl.signal)).rejects.toBeDefined();
d.dispose();
});
it("announce throws when not running, DISPOSED after dispose", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await expect(
d.announce({
peerId: "x",
addresses: [],
lastSeen: 0,
source: "trystero",
})
).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
await d.start();
d.dispose();
await expect(
d.announce({
peerId: "x",
addresses: [],
lastSeen: 0,
source: "trystero",
})
).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.DISPOSED,
});
});
it("announce honours aborted signal", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
const ctrl = new AbortController();
ctrl.abort();
await expect(
d.announce(
{
peerId: "x",
addresses: [],
lastSeen: 0,
source: "trystero",
},
ctrl.signal
)
).rejects.toBeDefined();
await d.stop();
});
it("forges AbortError when reason is non-Error", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
const ctrl = new AbortController();
Object.defineProperty(ctrl.signal, "aborted", { value: true });
Object.defineProperty(ctrl.signal, "reason", { value: "weird" });
await expect(d.discover(1000, ctrl.signal)).rejects.toMatchObject({
name: "AbortError",
});
await d.stop();
});
it("end-to-end peer discovery between two instances", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const a = new TrysteroDiscovery(nostrConfig(broker));
const b = new TrysteroDiscovery(nostrConfig(broker));
await a.start();
await b.start();
await flushMicrotasks();
await b.announce({
peerId: b.getMyPeerId() ?? "b",
addresses: ["addr-b"],
lastSeen: 1,
source: "trystero",
});
await flushMicrotasks();
const found = await a.discover(1000);
expect(found.map((p) => p.peerId)).toContain(b.getMyPeerId());
await a.stop();
await b.stop();
});
it("integrates with ServiceRegistry", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const registry = new ServiceRegistry();
const a = new TrysteroDiscovery(nostrConfig(broker, { registry }));
const b = new TrysteroDiscovery(nostrConfig(broker));
await a.start();
await b.start();
await flushMicrotasks();
await b.announce({
peerId: b.getMyPeerId() ?? "b",
addresses: ["addr-b"],
lastSeen: 1,
source: "trystero",
});
await flushMicrotasks();
expect(
registry.get(`trystero:nostr:demo:${b.getMyPeerId()}`)
).toBeDefined();
await a.stop();
await b.stop();
});
it("announce emits room_full on cap + ping passes through", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const bus = new EventBus<TrysteroEventMap>();
const reasons: string[] = [];
bus.on("trystero:transport-failed", (e) => reasons.push(e.reason));
const d = new TrysteroDiscovery(
nostrConfig(broker, { bus, maxPeers: 1 })
);
await d.start();
await d.announce({
peerId: "p1",
addresses: [],
lastSeen: 0,
source: "trystero",
});
await d.announce({
peerId: "p2", // second slot exceeds cap=1
addresses: [],
lastSeen: 0,
source: "trystero",
});
expect(reasons).toContain("room_full");
expect(await d.ping("foo")).toBe(1);
await d.stop();
await expect(d.ping("foo")).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
});
});
});
describe("TrysteroDiscovery fallback", () => {
it("falls back to next transport when primary fails", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const bus = new EventBus<TrysteroEventMap>();
const fallbacks: string[] = [];
bus.on("trystero:fallback", (e) =>
fallbacks.push(`${e.from}->${e.to}`)
);
let nostrFail = true;
const d = new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://r"] },
mqtt: { brokers: ["wss://b"] },
fallbackTransports: ["mqtt"],
nostrRoomFactory: async () => {
if (nostrFail) {
nostrFail = false;
throw new Error("nostr down");
}
/* istanbul ignore next */
throw new Error("unreachable");
},
mqttRoomFactory: fakeMqttRoomFactory(broker),
bus,
});
await d.start();
expect(d.getCurrentTransport()).toBe("mqtt");
expect(fallbacks.length).toBeGreaterThan(0);
await d.stop();
});
it("propagates init failure when no fallback configured", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TrysteroEventMap>();
const failed: string[] = [];
bus.on("trystero:transport-failed", (e) => failed.push(e.transport));
const d = new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://r"] },
nostrRoomFactory: async () => {
throw new Error("nostr down");
},
bus,
});
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED,
});
expect(failed).toContain("nostr");
d.dispose();
});
it("throws ALL_TRANSPORTS_DEGRADED when entire chain fails", {
timeout: 5000,
}, async () => {
const d = new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://r"] },
mqtt: { brokers: ["wss://b"] },
fallbackTransports: ["mqtt"],
nostrRoomFactory: async () => {
throw new Error("nostr");
},
mqttRoomFactory: async () => {
throw new Error("mqtt");
},
});
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.ALL_TRANSPORTS_DEGRADED,
});
d.dispose();
});
});
describe("TrysteroDiscovery status accessors", () => {
it("getStatus / getPeers / getPeerCount work", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
const status = d.getStatus();
expect(status.transport).toBe("nostr");
expect(status.connected).toBe(true);
expect(d.getPeerCount()).toBe(0);
expect(d.getPeers()).toEqual([]);
await d.stop();
});
});
describe("TrysteroDiscovery message dispatch via on-receive hook", () => {
it("delivers an announce message into the peer set", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const a = new TrysteroDiscovery(nostrConfig(broker));
const b = new TrysteroDiscovery(nostrConfig(broker));
await a.start();
await b.start();
await flushMicrotasks();
// b sends an announce-style message to the broker - a will receive
// it and admit b into its peer set.
const bAdapter = (
b as unknown as { adapter: { send: (m: unknown) => Promise<void> } }
).adapter;
await bAdapter.send({
type: TrysteroMessageType.ANNOUNCE,
from: b.getMyPeerId() ?? "b",
payload: {
id: b.getMyPeerId() ?? "b",
addresses: ["addr-b"],
metadata: {},
lastSeen: 0,
transport: "nostr",
},
timestamp: 0,
transport: "nostr",
});
await flushMicrotasks();
expect(a.getPeerCount()).toBeGreaterThanOrEqual(1);
await a.stop();
await b.stop();
});
it("handles peer-lost via underlying onPeerLeave callback path", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const a = new TrysteroDiscovery(nostrConfig(broker));
const b = new TrysteroDiscovery(nostrConfig(broker));
await a.start();
await b.start();
await flushMicrotasks();
// Seed b into a's peer set, then leave.
const bAdapter = (
b as unknown as { adapter: { send: (m: unknown) => Promise<void> } }
).adapter;
await bAdapter.send({
type: TrysteroMessageType.ANNOUNCE,
from: b.getMyPeerId() ?? "b",
payload: {
id: b.getMyPeerId() ?? "b",
addresses: [],
metadata: {},
lastSeen: 0,
transport: "nostr",
},
timestamp: 0,
transport: "nostr",
});
await flushMicrotasks();
await b.stop();
await flushMicrotasks();
await a.stop();
});
it("incoming HEARTBEAT touches peer + DISCOVER replies with peer_list", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
await d.announce({
peerId: "p1",
addresses: [],
lastSeen: 0,
source: "trystero",
});
// Heartbeat from p1 should touch its lastSeen.
(d as unknown as { onMessage: (m: unknown) => void }).onMessage({
type: TrysteroMessageType.HEARTBEAT,
from: "p1",
payload: {},
timestamp: 0,
});
// Discover from a stranger should trigger a peer_list reply via the
// active adapter's send(). The fake broker just no-ops the send.
(d as unknown as { onMessage: (m: unknown) => void }).onMessage({
type: TrysteroMessageType.DISCOVER,
from: "p2",
payload: {},
timestamp: 0,
});
await flushMicrotasks();
await d.stop();
});
it("admit rejects ROOM_FULL via incoming message — emits transport-failed", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const bus = new EventBus<TrysteroEventMap>();
const reasons: string[] = [];
bus.on("trystero:transport-failed", (e) => reasons.push(e.reason));
const a = new TrysteroDiscovery(
nostrConfig(broker, { bus, maxPeers: 1 })
);
await a.start();
// Locally admit one peer
await a.announce({
peerId: "p1",
addresses: [],
lastSeen: 0,
source: "trystero",
});
// Incoming announce from someone else should be rejected with
// room_full and the bus should observe it.
const aAdapter = (
a as unknown as { adapter: { send: (m: unknown) => Promise<void> } }
).adapter;
// We synthesise an inbound message by calling onMessage directly.
(a as unknown as { onMessage: (m: unknown) => void }).onMessage({
type: TrysteroMessageType.ANNOUNCE,
from: "p2",
payload: {
id: "p2",
addresses: [],
metadata: {},
lastSeen: 0,
transport: "nostr",
},
timestamp: 0,
transport: "nostr",
});
await flushMicrotasks();
expect(reasons).toContain("room_full");
await a.stop();
// Reference aAdapter so linter does not complain
expect(aAdapter).toBeDefined();
});
});
describe("TrysteroDiscovery transport coverage", () => {
it("works with torrent primary", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery({
appId: "demo",
transport: "torrent",
torrent: { trackers: ["wss://t"] },
torrentRoomFactory: fakeTorrentRoomFactory(broker),
});
await d.start();
expect(d.getCurrentTransport()).toBe("torrent");
await d.stop();
});
it("works with mqtt primary", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery({
appId: "demo",
transport: "mqtt",
mqtt: { brokers: ["wss://b"] },
mqttRoomFactory: fakeMqttRoomFactory(broker),
});
await d.start();
expect(d.getCurrentTransport()).toBe("mqtt");
await d.stop();
});
it("works with ipfs primary", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery({
appId: "demo",
transport: "ipfs",
ipfs: { bootstrap: ["/ip4/1"] },
ipfsRoomFactory: fakeIpfsRoomFactory(broker),
});
await d.start();
expect(d.getCurrentTransport()).toBe("ipfs");
await d.stop();
});
it("forwards password, rtcConfig, relayPeerId through to adapter", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(
nostrConfig(broker, {
password: "secret",
rtcConfig: { iceServers: [] },
relayPeerId: "relay-1",
})
);
await d.start();
expect(d.getMyPeerId()).not.toBeNull();
await d.stop();
});
it("intervals drive announce+heartbeat+cleanup against running orchestrator", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
let now = 1_000_000;
const d = new TrysteroDiscovery(
nostrConfig(broker, {
interval: 1,
heartbeatInterval: 1,
cleanupInterval: 1,
timeout: 1,
}),
() => now
);
await d.start();
// Seed a stale peer so the cleanup loop drops it.
(
d as unknown as { peers: { getMap: () => Map<string, unknown> } }
).peers
.getMap()
.set("stale", {
id: "stale",
addresses: [],
metadata: {},
lastSeen: -1_000_000,
transport: "nostr",
});
now += 10_000_000;
await new Promise((r) => setTimeout(r, 30));
await d.stop();
});
});
describe("TrysteroDiscovery property tests", () => {
it("peer-dedup idempotent under random announce sequences", {
timeout: 10_000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.string({ minLength: 1, maxLength: 6 }), {
minLength: 0,
maxLength: 12,
}),
async (ids) => {
const broker = new FakeTrysteroBroker();
const d = new TrysteroDiscovery(nostrConfig(broker));
await d.start();
for (const id of ids) {
await d.announce({
peerId: id,
addresses: [],
lastSeen: 0,
source: "trystero",
});
}
await flushMicrotasks();
const peers = await d.discover(1000);
const unique = new Set(peers.map((p) => p.peerId));
expect(unique.size).toBe(peers.length);
await d.stop();
}
),
{ numRuns: 8 }
);
});
});

View File

@@ -0,0 +1,596 @@
import type { DiscoveryStrategy, PeerInfo } from "@suaveplan/network-bootstrap";
import type { Clock } from "@suaveplan/types";
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import {
recordConnectDuration,
withStartSpan,
withTransportSpan,
} from "../../otel/otel.js";
import type {
ResolvedTrysteroDiscoveryConfig,
TrysteroAdapterCallbacks,
TrysteroConnectionStatus,
TrysteroDiscoveryConfig,
TrysteroMessage,
TrysteroPeerMetadata,
TrysteroTransport,
TrysteroTransportAdapter,
} from "../../types/types.js";
import { TrysteroMessageType } from "../../types/types.js";
import { TrysteroDiscoveryConfigSchema } from "../../types/types.schemas.js";
import { IpfsTransportAdapter } from "../ipfs-transport/ipfs-transport.js";
import { MqttTransportAdapter } from "../mqtt-transport/mqtt-transport.js";
import { NostrTransportAdapter } from "../nostr-transport/nostr-transport.js";
import { TorrentTransportAdapter } from "../torrent-transport/torrent-transport.js";
import { tryFallback } from "../trystero-fallback/trystero-fallback.js";
import { TrysteroIntervals } from "../trystero-intervals/trystero-intervals.js";
import { handleMessage } from "../trystero-message-handlers/trystero-message-handlers.js";
import { TrysteroPeerManager } from "../trystero-peer-manager/trystero-peer-manager.js";
const defaultClock: Clock = () => Date.now();
/**
* Browser-tier Trystero discovery strategy implementing the
* `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. Wraps a
* single `TrysteroTransportAdapter` at a time (one of torrent / nostr / mqtt
* / ipfs) plus a `TrysteroPeerManager`, a `TrysteroIntervals` scheduler,
* and a recursive `tryFallback` chain that swaps adapters on init failure.
*
* The orchestrator never branches on the transport string at runtime — every
* lifecycle call goes through the universal `TrysteroTransportAdapter`
* interface. Construction-time selection picks the initial adapter via
* `pickAdapter(transport)`.
*/
export class TrysteroDiscovery implements DiscoveryStrategy {
public readonly name = "trystero";
public readonly config: ResolvedTrysteroDiscoveryConfig;
private readonly clock: Clock;
private readonly peers: TrysteroPeerManager;
private adapter: TrysteroTransportAdapter;
private readonly intervals: TrysteroIntervals;
private currentTransport: TrysteroTransport;
private fallbackAttempts = 0;
private running = false;
private disposed = false;
private myPeerId: string | null = null;
public constructor(
rawConfig: TrysteroDiscoveryConfig,
clock: Clock = defaultClock
) {
const parsed = TrysteroDiscoveryConfigSchema.safeParse(rawConfig);
if (!parsed.success) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.invalid_options",
{ cause: parsed.error }
);
}
const data = parsed.data;
// Validate transport-specific blocks + factories for the primary and
// every fallback transport at construction time. This guarantees the
// orchestrator never reaches a runtime branch with a missing factory.
const allRequired = new Set<TrysteroTransport>([
data.transport,
...data.fallbackTransports,
]);
for (const t of allRequired) {
requireBlockAndFactory(t, rawConfig);
}
this.clock = clock;
this.currentTransport = data.transport;
this.config = {
appId: data.appId,
transport: data.transport,
torrent: data.torrent,
nostr: data.nostr,
mqtt: data.mqtt,
ipfs: data.ipfs,
password: data.password,
relayPeerId: data.relayPeerId,
rtcConfig: rawConfig.rtcConfig,
interval: data.interval,
timeout: data.timeout,
heartbeatInterval: data.heartbeatInterval,
cleanupInterval: data.cleanupInterval,
maxPeers: data.maxPeers,
fallbackTransports: data.fallbackTransports,
torrentRoomFactory: rawConfig.torrentRoomFactory,
nostrRoomFactory: rawConfig.nostrRoomFactory,
mqttRoomFactory: rawConfig.mqttRoomFactory,
ipfsRoomFactory: rawConfig.ipfsRoomFactory,
bus: rawConfig.bus,
registry: rawConfig.registry,
};
this.peers = new TrysteroPeerManager({
clock: this.clock,
bus: this.config.bus,
registry: this.config.registry,
maxPeers: this.config.maxPeers,
appId: this.config.appId,
});
this.adapter = this.pickAdapter(this.currentTransport);
this.intervals = new TrysteroIntervals({
clock: this.clock,
announceIntervalMs: this.config.interval,
heartbeatIntervalMs: this.config.heartbeatInterval,
cleanupIntervalMs: this.config.cleanupInterval,
timeoutMs: this.config.timeout,
peers: this.peers.getMap(),
getMyPeerId: () => this.myPeerId,
getCurrentTransport: () => this.currentTransport,
isRunning: () => this.running,
selfMetadata: () => this.selfMetadata(),
sendDiscovery: (msg) => this.adapter.send(msg),
handlePeerLost: (id) => this.handlePeerLost(id),
});
}
public async start(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
if (this.running) return;
await withStartSpan(async (span) => {
span.setAttribute("transport", this.currentTransport);
span.setAttribute("app_id", this.config.appId);
try {
await this.initializeTransport(this.currentTransport, signal);
} catch (initErr) {
if (this.config.fallbackTransports.length === 0) {
this.config.bus?.emit("trystero:transport-failed", {
transport: this.currentTransport,
reason: (initErr as Error).message,
at: this.clock(),
});
throw initErr;
}
this.config.bus?.emit("trystero:transport-failed", {
transport: this.currentTransport,
reason: (initErr as Error).message,
at: this.clock(),
});
const state = {
fallbackAttempts: this.fallbackAttempts,
currentTransport: this.currentTransport,
};
await tryFallback(initErr as Error, state, {
fallbackTransports: this.config.fallbackTransports,
clock: this.clock,
initializeTransport: async (next) => {
// Tear down the previous adapter, swap in the next
// one, and re-initialise. The peer manager is
// retagged so existing peer entries reflect the new
// transport. Intervals continue to drive the same
// peer manager.
this.adapter.destroy();
this.currentTransport = next;
this.adapter = this.pickAdapter(next);
this.peers.retagTransport(next);
await this.initializeTransport(next, signal);
},
notifyFallback: async (next) => {
await this.adapter.send({
type: TrysteroMessageType.FALLBACK,
from: this.myPeerId as string,
payload: { transport: next },
timestamp: this.clock(),
transport: next,
});
},
});
this.fallbackAttempts = state.fallbackAttempts;
this.currentTransport = state.currentTransport;
this.config.bus?.emit("trystero:fallback", {
from: this.currentTransport,
to: state.currentTransport,
attempt: state.fallbackAttempts,
at: this.clock(),
});
}
this.running = true;
this.intervals.start();
// myPeerId is set by initializeTransport above; the orchestrator
// never reaches this point with a null peerId.
const peerId = this.myPeerId as string;
this.config.bus?.emit("trystero:started", {
peerId,
transport: this.currentTransport,
appId: this.config.appId,
at: this.clock(),
});
span.setAttribute("peer_id", peerId);
});
}
public async stop(signal?: AbortSignal): Promise<void> {
if (!this.running) return;
this.running = false;
if (signal?.aborted) {
this.intervals.stop();
this.adapter.destroy();
this.peers.clear();
this.myPeerId = null;
return;
}
try {
await this.adapter.send({
type: TrysteroMessageType.LEAVE,
from: this.myPeerId as string,
payload: {},
timestamp: this.clock(),
transport: this.currentTransport,
});
} catch {
/* swallow */
}
try {
this.adapter.leave();
} catch {
/* swallow */
}
this.intervals.stop();
this.adapter.destroy();
this.peers.clear();
this.myPeerId = null;
}
public async discover(
_timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
return this.peers.list().map((m) => ({
peerId: m.id,
addresses: [...m.addresses],
lastSeen: m.lastSeen,
source: this.name,
}));
}
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) throw toAbortError(signal);
return this.running && !this.disposed && this.adapter.isConnected;
}
public async announce(
peerInfo: PeerInfo,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
if (!this.running || this.myPeerId === null) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
const metadata: TrysteroPeerMetadata = {
id: peerInfo.peerId,
addresses: [...peerInfo.addresses],
metadata: {},
lastSeen: this.clock(),
transport: this.currentTransport,
};
if (!this.tryAdmitLocal(metadata)) {
return;
}
await this.adapter.send({
type: TrysteroMessageType.ANNOUNCE,
from: this.myPeerId,
payload: metadata,
timestamp: this.clock(),
transport: this.currentTransport,
});
}
/**
* Admit a local metadata record into the peer manager, surfacing the
* `ROOM_FULL` outcome as a structured event rather than a throw. Returns
* `false` when the peer was rejected; `true` otherwise. The peer manager
* only ever throws `ROOM_FULL`, so any catch implies the cap was hit.
*/
private tryAdmitLocal(metadata: TrysteroPeerMetadata): boolean {
try {
this.peers.admit(metadata);
return true;
} catch {
this.config.bus?.emit("trystero:transport-failed", {
transport: this.currentTransport,
reason: "room_full",
at: this.clock(),
});
return false;
}
}
/** Local peer id, or `null` before `start()` completes. */
public getMyPeerId(): string | null {
return this.myPeerId;
}
/** Snapshot of the live peer set. */
public getPeers(): readonly TrysteroPeerMetadata[] {
return this.peers.list();
}
/** Number of tracked peers. */
public getPeerCount(): number {
return this.peers.size();
}
/** Active transport. */
public getCurrentTransport(): TrysteroTransport {
return this.currentTransport;
}
/** Overall connection status. */
public getStatus(): TrysteroConnectionStatus {
return {
transport: this.currentTransport,
connected: this.running && this.adapter.isConnected,
peerCount: this.peers.size(),
fallbackAttempts: this.fallbackAttempts,
};
}
/** Ping a peer through the active adapter. */
public async ping(peerId: string): Promise<number> {
if (!this.running) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOT_RUNNING,
"errors.trystero.not_running"
);
}
return this.adapter.ping(peerId);
}
/** Dispose every resource. After `dispose()` the strategy is unusable. */
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.running = false;
this.intervals.stop();
this.adapter.destroy();
this.peers.clear();
this.myPeerId = null;
}
private async initializeTransport(
transport: TrysteroTransport,
signal?: AbortSignal
): Promise<void> {
await withTransportSpan(transport, async (span) => {
const t0 = this.clock();
try {
const callbacks: TrysteroAdapterCallbacks = {
onPeerJoined: () => {
// Joins are observed but not added to the peer set
// until an announce arrives with metadata.
},
onPeerLost: (peerId) => this.handlePeerLost(peerId),
onMessage: (msg) => this.onMessage(msg),
};
this.myPeerId = await this.adapter.initialize(
callbacks,
signal
);
span.setAttribute("peer_id", this.myPeerId);
} finally {
recordConnectDuration(transport, this.clock() - t0);
}
});
}
private onMessage(message: TrysteroMessage): void {
void handleMessage(message, {
clock: this.clock,
getMyPeerId: () => this.myPeerId,
getCurrentTransport: () => this.currentTransport,
knownPeerIds: () => this.peers.list().map((p) => p.id),
admitPeer: (record) => {
try {
return this.peers.admit(record);
} catch {
// Peer manager only throws ROOM_FULL.
this.config.bus?.emit("trystero:transport-failed", {
transport: this.currentTransport,
reason: "room_full",
at: this.clock(),
});
return "rejected";
}
},
touchPeer: (peerId) => this.peers.touch(peerId),
onPeerLost: (peerId) => this.handlePeerLost(peerId),
sendDiscovery: (msg, target) => this.adapter.send(msg, target),
});
}
private handlePeerLost(peerId: string): void {
this.peers.drop(peerId);
}
private selfMetadata(): TrysteroPeerMetadata {
// Interval loops null-check `getMyPeerId()` before invoking, so
// `myPeerId` is always set by the time we land here.
return {
id: this.myPeerId as string,
addresses: [],
metadata: {},
lastSeen: this.clock(),
transport: this.currentTransport,
};
}
private pickAdapter(
transport: TrysteroTransport
): TrysteroTransportAdapter {
const common: {
password?: string | undefined;
rtcConfig?: RTCConfiguration | undefined;
relayPeerId?: string | undefined;
} = {};
if (this.config.password !== undefined) {
common.password = this.config.password;
}
if (this.config.rtcConfig !== undefined) {
common.rtcConfig = this.config.rtcConfig;
}
if (this.config.relayPeerId !== undefined) {
common.relayPeerId = this.config.relayPeerId;
}
switch (transport) {
case "torrent":
return new TorrentTransportAdapter(
this.config.appId,
this.config.torrent as NonNullable<
typeof this.config.torrent
>,
common,
this.config.torrentRoomFactory as NonNullable<
typeof this.config.torrentRoomFactory
>
);
case "nostr":
return new NostrTransportAdapter(
this.config.appId,
this.config.nostr as NonNullable<typeof this.config.nostr>,
common,
this.config.nostrRoomFactory as NonNullable<
typeof this.config.nostrRoomFactory
>
);
case "mqtt":
return new MqttTransportAdapter(
this.config.appId,
this.config.mqtt as NonNullable<typeof this.config.mqtt>,
common,
this.config.mqttRoomFactory as NonNullable<
typeof this.config.mqttRoomFactory
>
);
case "ipfs":
return new IpfsTransportAdapter(
this.config.appId,
this.config.ipfs as NonNullable<typeof this.config.ipfs>,
common,
this.config.ipfsRoomFactory as NonNullable<
typeof this.config.ipfsRoomFactory
>
);
/* istanbul ignore next -- exhaustive over literal union */
default: {
const _exhaustive: never = transport;
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.invalid_options",
{ cause: new Error(`unknown transport: ${_exhaustive}`) }
);
}
}
}
}
/**
* Cross-field constraint Zod cannot express cleanly: each transport that
* appears in `transport` or `fallbackTransports` must have both its config
* block AND its factory set.
*/
function requireBlockAndFactory(
transport: TrysteroTransport,
raw: TrysteroDiscoveryConfig
): void {
switch (transport) {
case "torrent":
if (raw.torrent === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.torrent_config_required"
);
}
if (raw.torrentRoomFactory === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.torrent_factory_required"
);
}
return;
case "nostr":
if (raw.nostr === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.nostr_config_required"
);
}
if (raw.nostrRoomFactory === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.nostr_factory_required"
);
}
return;
case "mqtt":
if (raw.mqtt === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.mqtt_config_required"
);
}
if (raw.mqttRoomFactory === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.mqtt_factory_required"
);
}
return;
case "ipfs":
if (raw.ipfs === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.ipfs_config_required"
);
}
if (raw.ipfsRoomFactory === undefined) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.ipfs_factory_required"
);
}
return;
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1,5 @@
export {
type FallbackDeps,
type FallbackState,
tryFallback,
} from "./trystero-fallback.js";

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,208 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
NET_DISC_TRYSTERO_CODES,
type NetDiscTrysteroError,
} from "../../errors/errors.js";
import type { TrysteroTransport } from "../../types/types.js";
import { type FallbackState, tryFallback } from "./trystero-fallback.js";
describe("tryFallback", () => {
it("swaps to the first successful fallback", {
timeout: 5000,
}, async () => {
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
const initialised: TrysteroTransport[] = [];
const notified: TrysteroTransport[] = [];
await tryFallback(new Error("nostr down"), state, {
fallbackTransports: ["mqtt", "torrent"],
clock: () => 0,
initializeTransport: async (t) => {
initialised.push(t);
},
notifyFallback: async (n) => {
notified.push(n);
},
});
expect(initialised).toEqual(["mqtt"]);
expect(notified).toEqual(["mqtt"]);
expect(state.fallbackAttempts).toBe(1);
expect(state.currentTransport).toBe("mqtt");
});
it("recurses across failures", { timeout: 5000 }, async () => {
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
await tryFallback(new Error("nostr"), state, {
fallbackTransports: ["mqtt", "torrent"],
clock: () => 0,
initializeTransport: async (t) => {
if (t === "mqtt") throw new Error("mqtt also down");
},
notifyFallback: async () => {},
});
expect(state.fallbackAttempts).toBe(2);
expect(state.currentTransport).toBe("torrent");
});
it("throws ALL_TRANSPORTS_DEGRADED when chain exhausted", {
timeout: 5000,
}, async () => {
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
let caught: unknown;
try {
await tryFallback(new Error("nostr"), state, {
fallbackTransports: ["mqtt", "torrent"],
clock: () => 0,
initializeTransport: async () => {
throw new Error("everything down");
},
notifyFallback: async () => {},
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.ALL_TRANSPORTS_DEGRADED
);
});
it("throws ALL_TRANSPORTS_DEGRADED when no fallbacks configured", {
timeout: 5000,
}, async () => {
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
await expect(
tryFallback(new Error("primary"), state, {
fallbackTransports: [],
clock: () => 0,
initializeTransport: async () => {},
notifyFallback: async () => {},
})
).rejects.toMatchObject({
code: NET_DISC_TRYSTERO_CODES.ALL_TRANSPORTS_DEGRADED,
});
});
it("falls through to default sleep when sleepFn omitted", {
timeout: 5000,
}, async () => {
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
await tryFallback(new Error("nostr"), state, {
fallbackTransports: ["mqtt"],
clock: () => 0,
backoff: { initial: 1, max: 1, base: 1, jitter: "none" },
initializeTransport: async () => {},
notifyFallback: async () => {},
});
expect(state.currentTransport).toBe("mqtt");
});
it("applies backoff via injected sleepFn", { timeout: 5000 }, async () => {
const slept: number[] = [];
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
await tryFallback(new Error("nostr"), state, {
fallbackTransports: ["mqtt"],
clock: () => 0,
backoff: { initial: 100, max: 1000, base: 2, jitter: "none" },
sleepFn: async (ms) => {
slept.push(ms);
},
initializeTransport: async () => {},
notifyFallback: async () => {},
});
expect(slept).toEqual([100]);
});
it("backoff with zero/negative delay does not sleep", {
timeout: 5000,
}, async () => {
let calls = 0;
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
await tryFallback(new Error("nostr"), state, {
fallbackTransports: ["mqtt"],
clock: () => 0,
backoff: { initial: 0, max: 0, base: 1, jitter: "none" },
sleepFn: async () => {
calls += 1;
},
initializeTransport: async () => {},
notifyFallback: async () => {},
});
expect(calls).toBe(0);
});
it("swallows notifyFallback errors", { timeout: 5000 }, async () => {
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: "nostr",
};
await tryFallback(new Error("nostr"), state, {
fallbackTransports: ["mqtt"],
clock: () => 0,
initializeTransport: async () => {},
notifyFallback: async () => {
throw new Error("can't notify");
},
});
expect(state.currentTransport).toBe("mqtt");
});
it("preserves fallback order under shuffling property", {
timeout: 10_000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc
.uniqueArray(
fc.constantFrom<TrysteroTransport>(
"torrent",
"nostr",
"mqtt",
"ipfs"
),
{ minLength: 1, maxLength: 4 }
)
.filter((arr) => arr.length > 0),
async (chain) => {
const order: TrysteroTransport[] = [];
const state: FallbackState = {
fallbackAttempts: 0,
currentTransport: chain[0] as TrysteroTransport,
};
await tryFallback(new Error("e"), state, {
fallbackTransports: chain,
clock: () => 0,
initializeTransport: async (t) => {
order.push(t);
// Always succeed on first try - we just want to
// confirm the chain promotes in order.
},
notifyFallback: async () => {},
});
expect(order).toEqual([chain[0]]);
}
),
{ numRuns: 12 }
);
});
});

View File

@@ -0,0 +1,91 @@
import {
createExponentialBackoff,
type ExponentialBackoffOptions,
sleep,
} from "@suaveplan/retry";
import type { Clock } from "@suaveplan/types";
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import { recordTransportFallback } from "../../otel/otel.js";
import type { TrysteroTransport } from "../../types/types.js";
/**
* State threaded through the fallback recursion. Mutated by `tryFallback`
* so the orchestrator can observe progress without polling the chain.
*/
export interface FallbackState {
fallbackAttempts: number;
currentTransport: TrysteroTransport;
}
/**
* Dependencies for fallback execution. The orchestrator passes hooks for
* tearing down the failing adapter, swapping in the next adapter, and
* notifying remote peers of the transport change.
*/
export interface FallbackDeps {
readonly fallbackTransports: readonly TrysteroTransport[];
readonly clock: Clock;
/** Optional exponential backoff between fallback attempts. */
readonly backoff?: ExponentialBackoffOptions;
/** Optional sleep override (defaults to `@suaveplan/retry`'s `sleep`). */
readonly sleepFn?: (ms: number) => Promise<void>;
/** Try to bring up the named transport. Throws on failure. */
initializeTransport(transport: TrysteroTransport): Promise<void>;
/** Best-effort notification to remote peers of the new transport. */
notifyFallback(next: TrysteroTransport): Promise<void>;
}
/**
* Attempt to use the next fallback transport after a failure. Mutates the
* provided state with the new attempt count and active transport. Recurses
* across the remaining configured transports until one succeeds or the
* chain is exhausted (throwing `ALL_TRANSPORTS_DEGRADED`).
*/
export async function tryFallback(
error: Error,
state: FallbackState,
deps: FallbackDeps
): Promise<void> {
if (state.fallbackAttempts >= deps.fallbackTransports.length) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.ALL_TRANSPORTS_DEGRADED,
"errors.trystero.all_transports_degraded",
{ cause: error }
);
}
// Safe: the guard above ensures `state.fallbackAttempts <
// fallbackTransports.length`, so the indexed access is always defined.
const next = deps.fallbackTransports[
state.fallbackAttempts
] as TrysteroTransport;
const from = state.currentTransport;
state.fallbackAttempts += 1;
if (deps.backoff !== undefined) {
const strategy = createExponentialBackoff(deps.backoff);
const delay = strategy(state.fallbackAttempts);
if (delay > 0) {
const sleeper = deps.sleepFn ?? sleep;
await sleeper(delay);
}
}
state.currentTransport = next;
recordTransportFallback(from, next);
try {
await deps.initializeTransport(next);
} catch (innerErr) {
await tryFallback(innerErr as Error, state, deps);
return;
}
try {
await deps.notifyFallback(next);
} catch {
/* swallow — best-effort notification only */
}
}

View File

@@ -0,0 +1,4 @@
export {
type TrysteroIntervalDeps,
TrysteroIntervals,
} from "./trystero-intervals.js";

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,228 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
TrysteroMessage,
TrysteroPeerMetadata,
} from "../../types/types.js";
import {
type TrysteroIntervalDeps,
TrysteroIntervals,
} from "./trystero-intervals.js";
function makeDeps(
over: Partial<TrysteroIntervalDeps> = {}
): TrysteroIntervalDeps & {
announces: TrysteroMessage[];
heartbeats: TrysteroMessage[];
cleared: string[];
peers: Map<string, TrysteroPeerMetadata>;
setRunning(v: boolean): void;
} {
const announces: TrysteroMessage[] = [];
const heartbeats: TrysteroMessage[] = [];
const cleared: string[] = [];
const peers = new Map<string, TrysteroPeerMetadata>();
let running = true;
const deps: TrysteroIntervalDeps = {
clock: () => Date.now(),
announceIntervalMs: 1,
heartbeatIntervalMs: 1,
cleanupIntervalMs: 1,
timeoutMs: 50,
peers,
getMyPeerId: () => "me",
getCurrentTransport: () => "nostr",
isRunning: () => running,
selfMetadata: () => ({
id: "me",
addresses: [],
metadata: {},
lastSeen: Date.now(),
transport: "nostr",
}),
sendDiscovery: async (msg) => {
if (msg.type === "announce") announces.push(msg);
else if (msg.type === "heartbeat") heartbeats.push(msg);
},
handlePeerLost: (id) => cleared.push(id),
...over,
};
return Object.assign(deps, {
announces,
heartbeats,
cleared,
peers,
setRunning: (v: boolean) => {
running = v;
},
});
}
async function tick(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
describe("TrysteroIntervals", () => {
it("start is idempotent + stop is idempotent", {
timeout: 5000,
}, () => {
const deps = makeDeps();
const intervals = new TrysteroIntervals(deps);
intervals.start();
intervals.start();
intervals.stop();
intervals.stop();
expect(intervals.isStopped).toBe(true);
});
it("does not start after stop", { timeout: 5000 }, () => {
const deps = makeDeps();
const intervals = new TrysteroIntervals(deps);
intervals.stop();
intervals.start();
expect(intervals.isStopped).toBe(true);
});
it("fires announce + heartbeat loops with short delays", {
timeout: 5000,
}, async () => {
const deps = makeDeps();
const intervals = new TrysteroIntervals(deps);
intervals.start();
await tick(30);
intervals.stop();
expect(deps.announces.length).toBeGreaterThan(0);
expect(deps.heartbeats.length).toBeGreaterThan(0);
});
it("skips announce/heartbeat when not running or no peerId", {
timeout: 5000,
}, async () => {
const deps = makeDeps({ getMyPeerId: () => null });
const intervals = new TrysteroIntervals(deps);
intervals.start();
await tick(30);
intervals.stop();
expect(deps.announces).toHaveLength(0);
expect(deps.heartbeats).toHaveLength(0);
const deps2 = makeDeps();
deps2.setRunning(false);
const intervals2 = new TrysteroIntervals(deps2);
intervals2.start();
await tick(30);
intervals2.stop();
expect(deps2.announces).toHaveLength(0);
});
it("cleanup leaves fresh peers in place", { timeout: 5000 }, async () => {
const now = 1_000_000;
const deps = makeDeps({
clock: () => now,
timeoutMs: 10_000_000,
cleanupIntervalMs: 1,
announceIntervalMs: 1_000,
heartbeatIntervalMs: 1_000,
});
deps.peers.set("fresh", {
id: "fresh",
addresses: [],
metadata: {},
lastSeen: now,
transport: "nostr",
});
const intervals = new TrysteroIntervals(deps);
intervals.start();
await tick(20);
intervals.stop();
expect(deps.cleared).not.toContain("fresh");
});
it("cleanup drops stale peers", { timeout: 5000 }, async () => {
const now = 1_000_000;
const deps = makeDeps({
clock: () => now,
timeoutMs: 10,
cleanupIntervalMs: 1,
announceIntervalMs: 1_000,
heartbeatIntervalMs: 1_000,
});
deps.peers.set("stale", {
id: "stale",
addresses: [],
metadata: {},
lastSeen: 0,
transport: "nostr",
});
const intervals = new TrysteroIntervals(deps);
intervals.start();
await tick(30);
intervals.stop();
expect(deps.cleared).toContain("stale");
});
it("swallows send errors", { timeout: 5000 }, async () => {
const deps = makeDeps({
sendDiscovery: async () => {
throw new Error("nope");
},
announceIntervalMs: 1,
heartbeatIntervalMs: 1,
cleanupIntervalMs: 1_000,
});
const intervals = new TrysteroIntervals(deps);
intervals.start();
await tick(20);
intervals.stop();
// Did not throw — that's the assertion.
expect(true).toBe(true);
});
it("scheduleSafe short-circuits when scheduler is already disposed", {
timeout: 5000,
}, () => {
let queuedFn: (() => void | Promise<void>) | null = null;
let disposed = false;
const intervals = new TrysteroIntervals(makeDeps(), () => ({
schedule: (fn, _d) => {
queuedFn = fn;
return { id: 1, cancel: () => {} };
},
cancel: () => {},
drain: async () => {},
dispose: () => {
disposed = true;
},
get disposed() {
return disposed;
},
}));
intervals.start();
intervals.stop();
// Re-fire the queued callback after dispose — its inner reschedule
// must hit the `if (disposed)` short-circuit.
if (queuedFn !== null) {
void (queuedFn as () => void | Promise<void>)();
}
expect(intervals.isStopped).toBe(true);
});
it("custom scheduler factory is honoured", { timeout: 5000 }, () => {
let called = false;
const intervals = new TrysteroIntervals(makeDeps(), (clock) => {
called = true;
return {
schedule: (_fn, _delay) => ({
id: 1,
cancel: () => {},
}),
cancel: () => {},
drain: async () => {},
dispose: () => {},
disposed: false,
};
});
intervals.start();
intervals.stop();
expect(called).toBe(true);
});
});

View File

@@ -0,0 +1,158 @@
import { createScheduler, type Scheduler } from "@suaveplan/timing";
import type { Clock } from "@suaveplan/types";
import type {
TrysteroMessage,
TrysteroPeerMetadata,
TrysteroTransport,
} from "../../types/types.js";
import { TrysteroMessageType } from "../../types/types.js";
/**
* Dependencies the interval loops close over.
*/
export interface TrysteroIntervalDeps {
readonly clock: Clock;
readonly announceIntervalMs: number;
readonly heartbeatIntervalMs: number;
readonly cleanupIntervalMs: number;
readonly timeoutMs: number;
readonly peers: Map<string, TrysteroPeerMetadata>;
/** Current local peer id — `null` until the adapter is initialised. */
getMyPeerId(): string | null;
/** Active transport ID. */
getCurrentTransport(): TrysteroTransport;
/** Returns `true` while the strategy is running. */
isRunning(): boolean;
/** Build the announce metadata for the local peer. */
selfMetadata(): TrysteroPeerMetadata;
/**
* Send a discovery message through the active adapter. Swallowed
* errors propagate as `false` so the loop can continue on next tick.
*/
sendDiscovery(message: TrysteroMessage): Promise<void>;
/** Drop a peer from local state. */
handlePeerLost(peerId: string): void;
}
/**
* Three `Scheduler`-driven self-rearming loops — announce / heartbeat /
* cleanup. Replaces upstream's three `setInterval` handles with a single
* `Scheduler` whose `dispose()` cancels every pending timer atomically and
* is fully injectable for deterministic tests.
*/
export class TrysteroIntervals {
private readonly scheduler: Scheduler;
private readonly deps: TrysteroIntervalDeps;
private stopped = false;
private started = false;
public constructor(
deps: TrysteroIntervalDeps,
schedulerFactory: (clock: Clock) => Scheduler = (c) =>
createScheduler({ clock: c })
) {
this.deps = deps;
this.scheduler = schedulerFactory(deps.clock);
}
/** Start every periodic loop. Idempotent. */
public start(): void {
if (this.stopped || this.started) return;
this.started = true;
this.scheduleAnnounce();
this.scheduleHeartbeat();
this.scheduleCleanup();
}
/** Stop every loop and dispose the underlying scheduler. Idempotent. */
public stop(): void {
if (this.stopped) return;
this.stopped = true;
this.scheduler.dispose();
}
/** Whether `stop()` has been called. */
public get isStopped(): boolean {
return this.stopped;
}
private scheduleAnnounce(): void {
this.scheduleSafe(async () => {
await this.runAnnounce();
this.scheduleAnnounce();
}, this.deps.announceIntervalMs);
}
private scheduleHeartbeat(): void {
this.scheduleSafe(async () => {
await this.runHeartbeat();
this.scheduleHeartbeat();
}, this.deps.heartbeatIntervalMs);
}
private scheduleCleanup(): void {
this.scheduleSafe(() => {
this.runCleanup();
this.scheduleCleanup();
}, this.deps.cleanupIntervalMs);
}
/**
* Wrap `scheduler.schedule` so that a disposed scheduler never causes a
* thrown error to surface — the scheduled callback simply doesn't fire,
* which matches the orchestrator's lifecycle contract.
*/
private scheduleSafe(fn: () => void | Promise<void>, delay: number): void {
if (this.scheduler.disposed) return;
this.scheduler.schedule(fn, delay);
}
private async runAnnounce(): Promise<void> {
if (!this.deps.isRunning()) return;
const myPeerId = this.deps.getMyPeerId();
if (myPeerId === null) return;
const transport = this.deps.getCurrentTransport();
const meta = this.deps.selfMetadata();
const message: TrysteroMessage = {
type: TrysteroMessageType.ANNOUNCE,
from: myPeerId,
payload: meta,
timestamp: this.deps.clock(),
transport,
};
try {
await this.deps.sendDiscovery(message);
} catch {
/* swallow — loop continues next tick */
}
}
private async runHeartbeat(): Promise<void> {
if (!this.deps.isRunning()) return;
const myPeerId = this.deps.getMyPeerId();
if (myPeerId === null) return;
const transport = this.deps.getCurrentTransport();
const message: TrysteroMessage = {
type: TrysteroMessageType.HEARTBEAT,
from: myPeerId,
payload: {},
timestamp: this.deps.clock(),
transport,
};
try {
await this.deps.sendDiscovery(message);
} catch {
/* swallow */
}
}
private runCleanup(): void {
if (!this.deps.isRunning()) return;
const now = this.deps.clock();
for (const [peerId, peer] of this.deps.peers.entries()) {
if (now - peer.lastSeen > this.deps.timeoutMs) {
this.deps.handlePeerLost(peerId);
}
}
}
}

View File

@@ -0,0 +1,4 @@
export {
handleMessage,
type TrysteroMessageHandlerDeps,
} from "./trystero-message-handlers.js";

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,270 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
TrysteroMessage,
TrysteroPeerMetadata,
} from "../../types/types.js";
import { TrysteroMessageType } from "../../types/types.js";
import {
handleMessage,
type TrysteroMessageHandlerDeps,
} from "./trystero-message-handlers.js";
function makeDeps(
over: Partial<TrysteroMessageHandlerDeps> = {}
): TrysteroMessageHandlerDeps & {
admitted: TrysteroPeerMetadata[];
touched: string[];
lost: string[];
sent: { msg: TrysteroMessage; target?: string }[];
peers: Set<string>;
} {
const admitted: TrysteroPeerMetadata[] = [];
const touched: string[] = [];
const lost: string[] = [];
const sent: { msg: TrysteroMessage; target?: string }[] = [];
const peers = new Set<string>();
const deps: TrysteroMessageHandlerDeps = {
clock: () => 42,
getMyPeerId: () => "me",
getCurrentTransport: () => "nostr",
knownPeerIds: () => Array.from(peers),
admitPeer: (record) => {
admitted.push(record);
peers.add(record.id);
return "added";
},
touchPeer: (id) => touched.push(id),
onPeerLost: (id) => lost.push(id),
sendDiscovery: async (msg, target) => {
sent.push({ msg, ...(target !== undefined ? { target } : {}) });
},
...over,
};
return Object.assign(deps, { admitted, touched, lost, sent, peers });
}
function meta(over: Partial<TrysteroPeerMetadata> = {}): TrysteroPeerMetadata {
return {
id: over.id ?? "p1",
addresses: over.addresses ?? [],
metadata: over.metadata ?? {},
lastSeen: over.lastSeen ?? 0,
transport: over.transport ?? "nostr",
...(over.capabilities !== undefined
? { capabilities: over.capabilities }
: {}),
};
}
describe("handleMessage", () => {
it("dispatches announce -> admitPeer", { timeout: 5000 }, async () => {
const deps = makeDeps();
const ok = await handleMessage(
{
type: TrysteroMessageType.ANNOUNCE,
from: "p1",
payload: meta({ id: "p1" }),
timestamp: 0,
},
deps
);
expect(ok).toBe(true);
expect(deps.admitted).toHaveLength(1);
expect(deps.admitted[0]?.lastSeen).toBe(42);
});
it("announce defaults transport from current", {
timeout: 5000,
}, async () => {
const deps = makeDeps({ getCurrentTransport: () => "mqtt" });
const noTransport = {
id: "p1",
addresses: [],
metadata: {},
lastSeen: 0,
} as unknown as TrysteroPeerMetadata;
await handleMessage(
{
type: TrysteroMessageType.ANNOUNCE,
from: "p1",
payload: noTransport,
timestamp: 0,
},
deps
);
expect(deps.admitted[0]?.transport).toBe("mqtt");
});
it("announce includes capabilities when present", {
timeout: 5000,
}, async () => {
const deps = makeDeps();
await handleMessage(
{
type: TrysteroMessageType.ANNOUNCE,
from: "p1",
payload: meta({ id: "p1", capabilities: ["chat"] }),
timestamp: 0,
},
deps
);
expect(deps.admitted[0]?.capabilities).toEqual(["chat"]);
});
it("announce returns false when admit rejects", {
timeout: 5000,
}, async () => {
const deps = makeDeps({ admitPeer: () => "rejected" });
const ok = await handleMessage(
{
type: TrysteroMessageType.ANNOUNCE,
from: "p1",
payload: meta({ id: "p1" }),
timestamp: 0,
},
deps
);
expect(ok).toBe(false);
});
it("discover replies with peer_list targeted at sender", {
timeout: 5000,
}, async () => {
const deps = makeDeps();
deps.peers.add("a");
deps.peers.add("b");
const ok = await handleMessage(
{
type: TrysteroMessageType.DISCOVER,
from: "p2",
payload: {},
timestamp: 0,
},
deps
);
expect(ok).toBe(true);
expect(deps.sent).toHaveLength(1);
expect(deps.sent[0]?.msg.type).toBe(TrysteroMessageType.PEER_LIST);
expect(deps.sent[0]?.target).toBe("p2");
});
it("discover returns false when myPeerId is null", {
timeout: 5000,
}, async () => {
const deps = makeDeps({ getMyPeerId: () => null });
const ok = await handleMessage(
{
type: TrysteroMessageType.DISCOVER,
from: "p2",
payload: {},
timestamp: 0,
},
deps
);
expect(ok).toBe(false);
});
it("discover swallows send errors", { timeout: 5000 }, async () => {
const deps = makeDeps({
sendDiscovery: async () => {
throw new Error("nope");
},
});
const ok = await handleMessage(
{
type: TrysteroMessageType.DISCOVER,
from: "p2",
payload: {},
timestamp: 0,
},
deps
);
expect(ok).toBe(false);
});
it("peer_list returns true on array payload, false otherwise", {
timeout: 5000,
}, async () => {
const deps = makeDeps();
expect(
await handleMessage(
{
type: TrysteroMessageType.PEER_LIST,
from: "x",
payload: ["a", "b"],
timestamp: 0,
},
deps
)
).toBe(true);
expect(
await handleMessage(
{
type: TrysteroMessageType.PEER_LIST,
from: "x",
payload: { notArray: true },
timestamp: 0,
},
deps
)
).toBe(false);
});
it("leave drops sender", { timeout: 5000 }, async () => {
const deps = makeDeps();
const ok = await handleMessage(
{
type: TrysteroMessageType.LEAVE,
from: "p1",
payload: {},
timestamp: 0,
},
deps
);
expect(ok).toBe(true);
expect(deps.lost).toEqual(["p1"]);
});
it("heartbeat touches sender", { timeout: 5000 }, async () => {
const deps = makeDeps();
const ok = await handleMessage(
{
type: TrysteroMessageType.HEARTBEAT,
from: "p1",
payload: {},
timestamp: 0,
},
deps
);
expect(ok).toBe(true);
expect(deps.touched).toEqual(["p1"]);
});
it("fallback returns true on valid transport, false otherwise", {
timeout: 5000,
}, async () => {
const deps = makeDeps();
expect(
await handleMessage(
{
type: TrysteroMessageType.FALLBACK,
from: "p1",
payload: { transport: "mqtt" },
timestamp: 0,
},
deps
)
).toBe(true);
expect(
await handleMessage(
{
type: TrysteroMessageType.FALLBACK,
from: "p1",
payload: {},
timestamp: 0,
},
deps
)
).toBe(false);
});
});

View File

@@ -0,0 +1,113 @@
import type { Clock } from "@suaveplan/types";
import type {
TrysteroMessage,
TrysteroPeerMetadata,
TrysteroTransport,
} from "../../types/types.js";
import { TrysteroMessageType } from "../../types/types.js";
/**
* Dependencies the message dispatch table fans out to. Implemented by the
* orchestrator (`TrysteroDiscovery`); kept as an interface so the handlers
* remain pure routers with no orchestrator import (avoids cycles).
*/
export interface TrysteroMessageHandlerDeps {
readonly clock: Clock;
getMyPeerId(): string | null;
getCurrentTransport(): TrysteroTransport;
/** Snapshot of currently-known peer ids for the `discover` reply. */
knownPeerIds(): readonly string[];
/** Admit (or refresh) a peer reported by an `announce`. */
admitPeer(record: TrysteroPeerMetadata): "added" | "updated" | "rejected";
/** Touch the `lastSeen` on a heartbeat. */
touchPeer(peerId: string): void;
/** Drop a peer on `leave`. */
onPeerLost(peerId: string): void;
/** Send a discovery message via the active adapter. */
sendDiscovery(message: TrysteroMessage, target?: string): Promise<void>;
}
/**
* Dispatch an inbound `TrysteroMessage`. Unknown / malformed messages are
* dropped silently — the wire format must tolerate hostile peers without
* crashing the strategy.
*/
export async function handleMessage(
message: TrysteroMessage,
deps: TrysteroMessageHandlerDeps
): Promise<boolean> {
switch (message.type) {
case TrysteroMessageType.ANNOUNCE:
return handleAnnounce(message, deps);
case TrysteroMessageType.DISCOVER:
return handleDiscoverRequest(message, deps);
case TrysteroMessageType.PEER_LIST:
return handlePeerList(message);
case TrysteroMessageType.LEAVE:
deps.onPeerLost(message.from);
return true;
case TrysteroMessageType.HEARTBEAT:
deps.touchPeer(message.from);
return true;
case TrysteroMessageType.FALLBACK:
return handleFallback(message);
/* istanbul ignore next -- exhaustive switch on a frozen literal union */
default:
return false;
}
}
function handleAnnounce(
message: TrysteroMessage,
deps: TrysteroMessageHandlerDeps
): boolean {
const payload = message.payload as TrysteroPeerMetadata;
const record: TrysteroPeerMetadata = {
id: payload.id,
addresses: payload.addresses,
metadata: payload.metadata,
lastSeen: deps.clock(),
transport: payload.transport ?? deps.getCurrentTransport(),
...(payload.capabilities !== undefined
? { capabilities: payload.capabilities }
: {}),
};
const action = deps.admitPeer(record);
return action !== "rejected";
}
async function handleDiscoverRequest(
message: TrysteroMessage,
deps: TrysteroMessageHandlerDeps
): Promise<boolean> {
const myPeerId = deps.getMyPeerId();
if (myPeerId === null) return false;
try {
await deps.sendDiscovery(
{
type: TrysteroMessageType.PEER_LIST,
from: myPeerId,
payload: deps.knownPeerIds(),
timestamp: deps.clock(),
transport: deps.getCurrentTransport(),
},
message.from
);
} catch {
return false;
}
return true;
}
function handlePeerList(message: TrysteroMessage): boolean {
// No-op — the announce path is responsible for adding peers. The
// peer_list reply is informational only (helps a freshly-joined peer
// build a hypothesis about who else is in the room).
const payload = message.payload as readonly string[];
return Array.isArray(payload);
}
function handleFallback(message: TrysteroMessage): boolean {
const payload = message.payload as { transport?: TrysteroTransport };
return typeof payload?.transport === "string";
}

View File

@@ -0,0 +1 @@
export { TrysteroPeerManager } from "./trystero-peer-manager.js";

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,169 @@
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import type {
TrysteroEventMap,
TrysteroPeerMetadata,
} from "../../types/types.js";
import { TrysteroPeerManager } from "./trystero-peer-manager.js";
function meta(over: Partial<TrysteroPeerMetadata> = {}): TrysteroPeerMetadata {
return {
id: over.id ?? "p1",
addresses: over.addresses ?? ["a"],
metadata: over.metadata ?? {},
lastSeen: over.lastSeen ?? 0,
transport: over.transport ?? "nostr",
...(over.capabilities !== undefined
? { capabilities: over.capabilities }
: {}),
};
}
describe("TrysteroPeerManager", () => {
it("admit returns added then updated, refreshes lastSeen", {
timeout: 5000,
}, () => {
let now = 1000;
const pm = new TrysteroPeerManager({
clock: () => now,
maxPeers: 10,
appId: "demo",
});
expect(pm.admit(meta({ id: "p1" }))).toBe("added");
now = 2000;
expect(pm.admit(meta({ id: "p1" }))).toBe("updated");
expect(pm.get("p1")?.lastSeen).toBe(2000);
expect(pm.size()).toBe(1);
expect(pm.list()).toHaveLength(1);
});
it("admit throws ROOM_FULL when over cap", { timeout: 5000 }, () => {
const pm = new TrysteroPeerManager({
clock: () => 1,
maxPeers: 1,
appId: "demo",
});
pm.admit(meta({ id: "p1" }));
let caught: unknown;
try {
pm.admit(meta({ id: "p2" }));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(NetDiscTrysteroError);
expect((caught as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.ROOM_FULL
);
});
it("emits trystero:peer-discovered and trystero:peer-lost", {
timeout: 5000,
}, () => {
const bus = new EventBus<TrysteroEventMap>();
const events: string[] = [];
bus.on("trystero:peer-discovered", (e) =>
events.push(`d:${e.peer.id}`)
);
bus.on("trystero:peer-lost", (e) => events.push(`l:${e.peerId}`));
const pm = new TrysteroPeerManager({
clock: () => 1,
bus,
maxPeers: 5,
appId: "demo",
});
pm.admit(meta({ id: "p1" }));
pm.drop("p1");
expect(events).toEqual(["d:p1", "l:p1"]);
expect(pm.drop("missing")).toBe(false);
});
it("touch updates lastSeen only for existing peers", {
timeout: 5000,
}, () => {
let now = 100;
const pm = new TrysteroPeerManager({
clock: () => now,
maxPeers: 5,
appId: "demo",
});
pm.admit(meta({ id: "p1" }));
now = 200;
pm.touch("p1");
expect(pm.get("p1")?.lastSeen).toBe(200);
pm.touch("missing"); // no-op
});
it("registers + deregisters from ServiceRegistry", {
timeout: 5000,
}, () => {
const registry = new ServiceRegistry();
const pm = new TrysteroPeerManager({
clock: () => 1,
registry,
maxPeers: 5,
appId: "demo",
});
pm.admit(meta({ id: "p1", addresses: ["addr-1"], transport: "mqtt" }));
expect(registry.get("trystero:mqtt:demo:p1")).toBeDefined();
pm.drop("p1");
expect(registry.get("trystero:mqtt:demo:p1")).toBeUndefined();
});
it("registers with fallback peerId when no addresses", {
timeout: 5000,
}, () => {
const registry = new ServiceRegistry();
const pm = new TrysteroPeerManager({
clock: () => 1,
registry,
maxPeers: 5,
appId: "demo",
});
pm.admit(meta({ id: "p1", addresses: [], transport: "mqtt" }));
const entry = registry.get("trystero:mqtt:demo:p1");
expect(entry?.address).toBe("p1");
});
it("clear drops every peer (emits + deregisters)", {
timeout: 5000,
}, () => {
const bus = new EventBus<TrysteroEventMap>();
const registry = new ServiceRegistry();
const lost: string[] = [];
bus.on("trystero:peer-lost", (e) => lost.push(e.peerId));
const pm = new TrysteroPeerManager({
clock: () => 1,
bus,
registry,
maxPeers: 5,
appId: "demo",
});
pm.admit(meta({ id: "p1", transport: "torrent" }));
pm.admit(meta({ id: "p2", transport: "torrent" }));
pm.clear();
expect(lost.sort()).toEqual(["p1", "p2"]);
expect(pm.size()).toBe(0);
expect(registry.get("trystero:torrent:demo:p1")).toBeUndefined();
});
it("retagTransport rewrites every existing peer", {
timeout: 5000,
}, () => {
const pm = new TrysteroPeerManager({
clock: () => 1,
maxPeers: 5,
appId: "demo",
});
pm.admit(meta({ id: "p1", transport: "torrent" }));
pm.admit(meta({ id: "p2", transport: "torrent" }));
pm.retagTransport("mqtt");
expect(pm.get("p1")?.transport).toBe("mqtt");
expect(pm.get("p2")?.transport).toBe("mqtt");
expect(pm.getMap().get("p1")?.transport).toBe("mqtt");
});
});

View File

@@ -0,0 +1,146 @@
import type { ServiceRegistry } from "@suaveplan/service-discovery";
import type { Clock, EventBusLike } from "@suaveplan/types";
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../../errors/errors.js";
import { recordPeersDiscovered } from "../../otel/otel.js";
import type {
TrysteroEventMap,
TrysteroPeerMetadata,
TrysteroTransport,
} from "../../types/types.js";
/**
* Tracks the deduped set of discovered peers for a `TrysteroDiscovery`
* instance. Mirrors deltas onto the optional `EventBusLike` and (when
* supplied) a `ServiceRegistry`. Enforces `maxPeers` via `ROOM_FULL`.
*/
export class TrysteroPeerManager {
private readonly peers = new Map<string, TrysteroPeerMetadata>();
private readonly clock: Clock;
private readonly bus: EventBusLike<TrysteroEventMap> | undefined;
private readonly registry: ServiceRegistry | undefined;
private readonly maxPeers: number;
private readonly appId: string;
public constructor(deps: {
clock: Clock;
bus?: EventBusLike<TrysteroEventMap> | undefined;
registry?: ServiceRegistry | undefined;
maxPeers: number;
appId: string;
}) {
this.clock = deps.clock;
this.bus = deps.bus;
this.registry = deps.registry;
this.maxPeers = deps.maxPeers;
this.appId = deps.appId;
}
/**
* Admit a peer (insert or update). Throws `ROOM_FULL` on insert when the
* cap is hit. Returns `"added"` or `"updated"`.
*/
public admit(metadata: TrysteroPeerMetadata): "added" | "updated" {
const existing = this.peers.get(metadata.id);
if (existing === undefined) {
if (this.peers.size >= this.maxPeers) {
recordPeersDiscovered(metadata.transport, "rejected");
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.ROOM_FULL,
"errors.trystero.room_full"
);
}
const record: TrysteroPeerMetadata = {
...metadata,
lastSeen: this.clock(),
};
this.peers.set(metadata.id, record);
recordPeersDiscovered(metadata.transport, "found");
this.bus?.emit("trystero:peer-discovered", {
peer: record,
at: this.clock(),
});
this.registry?.register({
id: `trystero:${metadata.transport}:${this.appId}:${metadata.id}`,
name: "trystero-discovery",
address: metadata.addresses[0] ?? metadata.id,
metadata: {
...metadata.metadata,
transport: metadata.transport,
addresses: [...metadata.addresses],
},
});
return "added";
}
existing.lastSeen = this.clock();
recordPeersDiscovered(metadata.transport, "duplicate");
return "updated";
}
/** Refresh the `lastSeen` timestamp of an existing peer. */
public touch(peerId: string): void {
const peer = this.peers.get(peerId);
if (peer !== undefined) {
peer.lastSeen = this.clock();
}
}
/** Drop a peer. Emits `trystero:peer-lost` and deregisters when known. */
public drop(peerId: string): boolean {
const peer = this.peers.get(peerId);
if (peer === undefined) return false;
this.peers.delete(peerId);
this.bus?.emit("trystero:peer-lost", {
peerId,
at: this.clock(),
});
this.registry?.deregister(
`trystero:${peer.transport}:${this.appId}:${peerId}`
);
return true;
}
/** Snapshot of the live peer set. */
public list(): readonly TrysteroPeerMetadata[] {
return Array.from(this.peers.values());
}
/** Peer lookup. */
public get(peerId: string): TrysteroPeerMetadata | undefined {
return this.peers.get(peerId);
}
/** Number of tracked peers. */
public size(): number {
return this.peers.size;
}
/** Underlying map (read-write, used by the interval cleanup loop). */
public getMap(): Map<string, TrysteroPeerMetadata> {
return this.peers;
}
/** Drop every peer (emits `trystero:peer-lost` for each). */
public clear(): void {
const entries = Array.from(this.peers.entries());
this.peers.clear();
for (const [id, peer] of entries) {
this.bus?.emit("trystero:peer-lost", {
peerId: id,
at: this.clock(),
});
this.registry?.deregister(
`trystero:${peer.transport}:${this.appId}:${id}`
);
}
}
/** Promote every existing peer to the new active transport. */
public retagTransport(transport: TrysteroTransport): void {
for (const [id, peer] of this.peers) {
this.peers.set(id, { ...peer, transport });
}
}
}

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,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_DISC_TRYSTERO_CODES, NetDiscTrysteroError } from "./errors.js";
describe("NetDiscTrysteroError", () => {
it("registers exactly the documented codes", { timeout: 5000 }, () => {
expect(Object.keys(NET_DISC_TRYSTERO_CODES).sort()).toEqual(
[
"ABORTED",
"ALL_TRANSPORTS_DEGRADED",
"DISPOSED",
"INVALID_OPTIONS",
"IPFS_BOOTSTRAP_FAILED",
"MQTT_BROKER_FAILED",
"NOSTR_RELAY_FAILED",
"NOT_RUNNING",
"PEER_ALREADY_CONNECTED",
"PEER_UNKNOWN",
"RELAY_FAILED",
"ROOM_FULL",
"TORRENT_TRACKER_FAILED",
"TRANSPORT_INIT_FAILED",
].sort()
);
});
it("preserves code, name, and cause", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED,
"errors.trystero.nostr_init_failed",
{ cause }
);
expect(err.code).toBe(NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED);
expect(err.name).toBe("NetDiscTrysteroError");
expect(err.cause).toBe(cause);
expect(err instanceof NetDiscTrysteroError).toBe(true);
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.invalid_options"
);
expect(err.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,68 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-discovery-trystero`.
*
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation, a
* required transport block was missing, or a required factory was missing
* for the selected primary or a fallback transport.
* - `DISPOSED` — operation attempted after `dispose()` / `stop()`.
* - `NOT_RUNNING` — operation attempted before `start()` completed (or after
* `stop()` cleared state).
* - `RELAY_FAILED` — generic transport relay/tracker/broker failure not
* covered by the transport-specific codes below.
* - `ROOM_FULL` — `maxPeers` exceeded.
* - `ALL_TRANSPORTS_DEGRADED` — every primary + fallback transport failed
* to initialise; discovery cannot proceed.
* - `TORRENT_TRACKER_FAILED` — BitTorrent-tracker transport initialisation
* failed.
* - `NOSTR_RELAY_FAILED` — Nostr-relay transport initialisation failed.
* - `MQTT_BROKER_FAILED` — MQTT-broker transport initialisation failed.
* - `IPFS_BOOTSTRAP_FAILED` — IPFS pubsub transport initialisation failed.
* - `PEER_ALREADY_CONNECTED` — `TrysteroNodeTransport.connect()` invoked with
* a `peerId` that is already in the per-peer registry.
* - `PEER_UNKNOWN` — `TrysteroNodeTransport.send()` invoked with a `peerId`
* not present in the per-peer registry.
* - `ABORTED` — caller-supplied `AbortSignal` was pre-aborted before the
* operation could begin.
* - `TRANSPORT_INIT_FAILED` — `TrysteroNodeTransport` failed to initialise its
* underlying Trystero room.
*/
export const NET_DISC_TRYSTERO_CODES = defineCodes("NET_DISC_TRYSTERO", [
"INVALID_OPTIONS",
"DISPOSED",
"NOT_RUNNING",
"RELAY_FAILED",
"ROOM_FULL",
"ALL_TRANSPORTS_DEGRADED",
"TORRENT_TRACKER_FAILED",
"NOSTR_RELAY_FAILED",
"MQTT_BROKER_FAILED",
"IPFS_BOOTSTRAP_FAILED",
"PEER_ALREADY_CONNECTED",
"PEER_UNKNOWN",
"ABORTED",
"TRANSPORT_INIT_FAILED",
] as const);
export type NetDiscTrysteroErrorCode =
(typeof NET_DISC_TRYSTERO_CODES)[keyof typeof NET_DISC_TRYSTERO_CODES];
/**
* Structured error class for the Trystero discovery strategy. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*/
export class NetDiscTrysteroError extends SuaveplanError<NetDiscTrysteroErrorCode> {
public constructor(
code: NetDiscTrysteroErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetDiscTrysteroError";
}
}

View File

@@ -0,0 +1,92 @@
// Core
export { IpfsTransportAdapter } from "./core/ipfs-transport/ipfs-transport.js";
export { MqttTransportAdapter } from "./core/mqtt-transport/mqtt-transport.js";
export { NostrTransportAdapter } from "./core/nostr-transport/nostr-transport.js";
export { TorrentTransportAdapter } from "./core/torrent-transport/torrent-transport.js";
export { TrysteroDiscovery } from "./core/trystero-discovery/trystero-discovery.js";
export {
type FallbackDeps,
type FallbackState,
tryFallback,
} from "./core/trystero-fallback/trystero-fallback.js";
export {
type TrysteroIntervalDeps,
TrysteroIntervals,
} from "./core/trystero-intervals/trystero-intervals.js";
export {
handleMessage,
type TrysteroMessageHandlerDeps,
} from "./core/trystero-message-handlers/trystero-message-handlers.js";
export { TrysteroPeerManager } from "./core/trystero-peer-manager/trystero-peer-manager.js";
// Errors
export {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
type NetDiscTrysteroErrorCode,
} from "./errors/errors.js";
// OTel
export {
adjustTransportActivePeers,
type PeerDiscoveryOutcome,
recordConnectDuration,
recordPeersDiscovered,
recordTransportFallback,
recordTransportMessage,
type TransportMessageDirection,
type TransportMessageOutcome,
withNodeTransportSpan,
withStartSpan,
withTransportSpan,
} from "./otel/otel.js";
// Plugin (NetworkPlugin)
export { TrysteroPlugin } from "./plugin/plugin.js";
// Transport (Transport adapter)
export {
TrysteroNodeTransport,
type TrysteroNodeTransportConfig,
} from "./transport/transport.js";
// Types
export type {
IpfsRoomFactory,
IpfsTransportConfig,
MqttRoomFactory,
MqttTransportConfig,
NostrRoomFactory,
NostrTransportConfig,
ResolvedTrysteroDiscoveryConfig,
TorrentRoomFactory,
TorrentTransportConfig,
TrysteroAdapterCallbacks,
TrysteroConnectionStatus,
TrysteroDiscoveredEvent,
TrysteroDiscoveryConfig,
TrysteroEventMap,
TrysteroFallbackEvent,
TrysteroLostEvent,
TrysteroMessage,
TrysteroMessageTypeValue,
TrysteroPeerMetadata,
TrysteroRoomCommonConfig,
TrysteroRoomLike,
TrysteroStartedEvent,
TrysteroTransport,
TrysteroTransportAdapter,
TrysteroTransportFailedEvent,
} from "./types/types.js";
export { TrysteroMessageType } from "./types/types.js";
// Schemas
export {
IpfsTransportConfigSchema,
MqttTransportConfigSchema,
NostrTransportConfigSchema,
TorrentTransportConfigSchema,
TrysteroDiscoveryConfigSchema,
TrysteroPeerMetadataSchema,
TrysteroTransportSchema,
} from "./types/types.schemas.js";

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,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
adjustTransportActivePeers,
recordConnectDuration,
recordPeersDiscovered,
recordTransportFallback,
recordTransportMessage,
withNodeTransportSpan,
withStartSpan,
withTransportSpan,
} from "./otel.js";
describe("otel surface", () => {
it("withStartSpan resolves and propagates errors", {
timeout: 5000,
}, async () => {
await expect(withStartSpan(async () => 42)).resolves.toBe(42);
await expect(
withStartSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
it("withTransportSpan resolves and propagates errors", {
timeout: 5000,
}, async () => {
await expect(
withTransportSpan("torrent", async () => "ok")
).resolves.toBe("ok");
await expect(
withTransportSpan("nostr", async () => {
throw new Error("nope");
})
).rejects.toThrow("nope");
});
it("counters and histograms accept calls with no SDK", {
timeout: 5000,
}, () => {
recordPeersDiscovered("mqtt", "found", 3);
recordPeersDiscovered("ipfs", "duplicate");
recordPeersDiscovered("torrent", "rejected", 0);
recordPeersDiscovered("torrent", "rejected", -5);
recordTransportFallback("torrent", "nostr");
recordConnectDuration("ipfs", 12.5);
recordConnectDuration("mqtt", -1);
expect(true).toBe(true);
});
it("withNodeTransportSpan resolves and propagates errors", {
timeout: 5000,
}, async () => {
await expect(
withNodeTransportSpan("connect", async () => "ok")
).resolves.toBe("ok");
await expect(
withNodeTransportSpan("send", async () => {
throw new Error("nope");
})
).rejects.toThrow("nope");
await expect(
withNodeTransportSpan("disconnect", async () => "x")
).resolves.toBe("x");
});
it("transport-message + active-peers instruments accept calls without an SDK", {
timeout: 5000,
}, () => {
recordTransportMessage("inbound", "ok");
recordTransportMessage("inbound", "error");
recordTransportMessage("outbound", "ok");
recordTransportMessage("outbound", "error");
adjustTransportActivePeers(0);
adjustTransportActivePeers(2);
adjustTransportActivePeers(-1);
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,178 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
import type { TrysteroTransport } from "../types/types.js";
const TRACER_NAME = "@suaveplan/network-discovery-trystero";
const METER_NAME = "@suaveplan/network-discovery-trystero";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented for every peer the strategy emits, labelled by the
* transport and the outcome of the discovery attempt.
*/
const peersDiscoveredCounter = meter.createCounter(
"disc_trystero.peers_discovered_total",
{
description:
"Total peers seen by the Trystero discovery strategy, by transport and outcome.",
}
);
/**
* Counter incremented every time the fallback chain promotes a new
* transport.
*/
const transportFallbackCounter = meter.createCounter(
"disc_trystero.transport_fallback_total",
{
description:
"Number of times the strategy fell back from one transport to another.",
}
);
/**
* Histogram of `room.connect` wall-clock duration (ms), labelled by
* transport.
*/
const connectDurationHistogram = meter.createHistogram(
"disc_trystero.connect_duration_ms",
{
description:
"Wall-clock duration of `transport.connect` calls in milliseconds.",
unit: "ms",
}
);
/**
* Wrap the `start()` body in an OTel span.
*/
export async function withStartSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("disc_trystero.start", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/**
* Wrap a transport-specific connect block in an OTel span. The span name
* embeds the transport ID so dashboards can pivot without parsing
* attributes.
*/
export async function withTransportSpan<T>(
transport: TrysteroTransport,
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(
`disc_trystero.transport.${transport}.connect`,
async (span) => {
try {
span.setAttribute("transport", transport);
return await fn(span);
} finally {
span.end();
}
}
);
}
/** Outcome label for {@link recordPeersDiscovered}. */
export type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected";
/** Increment the peers-discovered counter. */
export function recordPeersDiscovered(
transport: TrysteroTransport,
outcome: PeerDiscoveryOutcome,
count = 1
): void {
if (count > 0) {
peersDiscoveredCounter.add(count, { transport, outcome });
}
}
/** Record the wall-clock duration of a transport connect attempt. */
export function recordConnectDuration(
transport: TrysteroTransport,
ms: number
): void {
connectDurationHistogram.record(Math.max(0, ms), { transport });
}
/** Record a fallback transition. */
export function recordTransportFallback(
from: TrysteroTransport,
to: TrysteroTransport
): void {
transportFallbackCounter.add(1, { from, to });
}
/**
* Counter incremented for every `MessageEnvelope` the
* `TrysteroNodeTransport` sends or receives, labelled by direction +
* outcome. Distinct namespace from the discovery counters so dashboards
* can separate control-plane (discovery) from data-plane (transport)
* traffic.
*/
const transportMessagesCounter = meter.createCounter(
"trystero_transport.messages_total",
{
description:
"Total MessageEnvelopes processed by TrysteroNodeTransport, by direction and outcome.",
}
);
/** Gauge of currently-connected peers tracked by `TrysteroNodeTransport`. */
const transportActivePeers = meter.createUpDownCounter(
"trystero_transport.active_peers",
{
description:
"Number of currently-connected peers in TrysteroNodeTransport's per-peer registry.",
}
);
/** Direction label for {@link recordTransportMessage}. */
export type TransportMessageDirection = "inbound" | "outbound";
/** Outcome label for {@link recordTransportMessage}. */
export type TransportMessageOutcome = "ok" | "error";
/** Increment the transport-messages counter. */
export function recordTransportMessage(
direction: TransportMessageDirection,
outcome: TransportMessageOutcome
): void {
transportMessagesCounter.add(1, { direction, outcome });
}
/** Adjust the active-peers gauge by `delta`. */
export function adjustTransportActivePeers(delta: number): void {
if (delta !== 0) {
transportActivePeers.add(delta);
}
}
/**
* Wrap a `TrysteroNodeTransport.{connect,disconnect,send}` body in an OTel
* span. The span name embeds the operation so dashboards can pivot without
* parsing attributes.
*/
export async function withNodeTransportSpan<T>(
operation: "connect" | "disconnect" | "send",
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(
`trystero_transport.${operation}`,
async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
}
);
}

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,88 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeTrysteroBroker,
fakeTorrentRoomFactory,
} from "../../tests/setup/fake-trystero.js";
import { TrysteroPlugin } from "./plugin.js";
function makePlugin(broker = new FakeTrysteroBroker()): TrysteroPlugin {
return new TrysteroPlugin({
appId: "test-app",
transport: "torrent",
torrent: { trackers: ["wss://t.example"] },
torrentRoomFactory: fakeTorrentRoomFactory(broker),
});
}
describe("TrysteroPlugin", () => {
it("exposes the canonical plugin name", { timeout: 5000 }, () => {
const p = makePlugin();
expect(p.name).toBe("trystero");
});
it("registers its transport with the node on install", {
timeout: 5000,
}, () => {
const node = new NetworkNode();
const plugin = makePlugin();
node.use(plugin);
const transports = node.messageRouter.getTransports();
expect(transports.map((t) => t.id)).toContain("trystero");
});
it("start() is a no-op and does not open the room", {
timeout: 5000,
}, async () => {
const node = new NetworkNode();
const plugin = makePlugin();
node.use(plugin);
await node.start();
expect(node.isStarted).toBe(true);
expect(plugin.transport.isInitialized).toBe(false);
await node.stop();
});
it("stop() closes the transport and unregisters it", {
timeout: 5000,
}, async () => {
const node = new NetworkNode();
const plugin = makePlugin();
node.use(plugin);
await node.start();
await plugin.transport.connect("p1", "tr-1");
expect(plugin.transport.isInitialized).toBe(true);
await node.stop();
expect(
node.messageRouter.getTransports().map((t) => t.id)
).not.toContain("trystero");
});
it("dispose is idempotent", { timeout: 5000 }, () => {
const plugin = makePlugin();
const node = new NetworkNode();
plugin.install(node);
plugin.dispose();
plugin.dispose();
});
it("stop is safe to call when no node was installed", {
timeout: 5000,
}, async () => {
const plugin = makePlugin();
await plugin.stop();
});
it("forwards config to the transport", { timeout: 5000 }, () => {
const plugin = new TrysteroPlugin({
appId: "x",
transport: "torrent",
torrent: { trackers: ["wss://t"] },
torrentRoomFactory: fakeTorrentRoomFactory(
new FakeTrysteroBroker()
),
maxPeers: 7,
});
expect(plugin.transport.id).toBe("trystero");
});
});

View File

@@ -0,0 +1,48 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import type { TrysteroNodeTransportConfig } from "../transport/transport.js";
import { TrysteroNodeTransport } from "../transport/transport.js";
/**
* Network plugin that exposes a `TrysteroNodeTransport` to the host
* `NetworkNode`. Thin lifecycle wrapper mirroring
* `@suaveplan/network-transport-websockets`'s `WebSocketPlugin`:
*
* - `install(node)` registers the transport so the router can find it.
* - `start()` is a no-op — the underlying Trystero room opens lazily on
* the first `transport.connect()` call.
* - `stop()` closes the transport and unregisters it from the node.
* - `dispose()` releases the node reference and is idempotent.
*/
export class TrysteroPlugin implements NetworkPlugin {
public readonly name = "trystero";
public readonly transport: TrysteroNodeTransport;
#node: NetworkNode | null = null;
#disposed = false;
public constructor(config: TrysteroNodeTransportConfig) {
this.transport = new TrysteroNodeTransport(config);
}
public install(node: NetworkNode): void {
this.#node = node;
node.registerTransport(this.transport);
}
public start(): void {
// Lazy connect — consumers open peer connections through the
// transport API directly.
}
public async stop(): Promise<void> {
await this.transport.close();
this.#node?.unregisterTransport(this.transport.id);
}
public dispose(): void {
if (this.#disposed) {
return;
}
this.#disposed = true;
this.#node = null;
}
}

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,506 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeTrysteroBroker,
fakeIpfsRoomFactory,
fakeMqttRoomFactory,
fakeNostrRoomFactory,
fakeTorrentRoomFactory,
flushMicrotasks,
} from "../../tests/setup/fake-trystero.js";
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../errors/errors.js";
import type { TrysteroNodeTransportConfig } from "./transport.js";
import { TrysteroNodeTransport } from "./transport.js";
function envelope(id: string, type = "test"): MessageEnvelope {
return {
id,
type,
senderId: "self",
payload: { hello: id },
timestamp: 1,
headers: {},
ttl: 64,
visitedNodes: [],
};
}
function makeTorrentTransport(
broker: FakeTrysteroBroker,
opts: Partial<TrysteroNodeTransportConfig> = {}
): TrysteroNodeTransport {
return new TrysteroNodeTransport({
appId: "test-app",
transport: "torrent",
torrent: { trackers: ["wss://t.example"] },
torrentRoomFactory: fakeTorrentRoomFactory(broker),
...opts,
});
}
describe("TrysteroNodeTransport", () => {
it("must expose id 'trystero'", { timeout: 5000 }, () => {
const t = makeTorrentTransport(new FakeTrysteroBroker());
expect(t.id).toBe("trystero");
expect(t.isInitialized).toBe(false);
});
it("connects and tracks peers", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.connect("p1", "tr-peer-1");
expect(t.getConnectedPeers()).toContain("p1");
expect(t.isInitialized).toBe(true);
await t.close();
});
it("emits connected/disconnected events", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
const events: Array<{ peerId: string; event: string }> = [];
t.onPeerEvent((peerId, event) => events.push({ peerId, event }));
await t.connect("p1", "tr-peer-1");
await t.disconnect("p1");
expect(events).toContainEqual({ peerId: "p1", event: "connected" });
expect(events).toContainEqual({ peerId: "p1", event: "disconnected" });
await t.close();
});
it("send delivers envelope to targeted peer", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const a = makeTorrentTransport(broker);
const b = makeTorrentTransport(broker);
const inboundB: MessageEnvelope[] = [];
b.onMessage((_pid, env) => inboundB.push(env));
// a opens its room FIRST so its onPeerJoin handler is wired
// before b registers — broker.register fires both sides'
// notePeerJoined synchronously, but only the side wired before
// the register microtask runs observes the join.
await a.connect("__bootstrap-a", "tr-bootstrap-a");
await flushMicrotasks();
await b.connect("__bootstrap-b", "tr-bootstrap-b");
await flushMicrotasks();
const seenFromA = a.getRoomPeers();
expect(seenFromA.length).toBeGreaterThan(0);
const bId = seenFromA[0] ?? "";
await a.connect("peer-b", bId);
await a.send("peer-b", envelope("m1"));
await flushMicrotasks();
expect(inboundB.find((e) => e.id === "m1")).toBeDefined();
await a.close();
await b.close();
});
it("rejects PEER_ALREADY_CONNECTED on duplicate", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.connect("p1", "tr-1");
try {
await t.connect("p1", "tr-1");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.PEER_ALREADY_CONNECTED
);
}
await t.close();
});
it("rejects past maxPeers with ROOM_FULL", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker, { maxPeers: 1 });
await t.connect("p1", "tr-1");
try {
await t.connect("p2", "tr-2");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.ROOM_FULL
);
}
await t.close();
});
it("throws ABORTED on pre-aborted signal", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
const ac = new AbortController();
ac.abort();
try {
await t.connect("p1", "tr-1", ac.signal);
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.ABORTED
);
}
});
it("throws DISPOSED after close()", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.close();
try {
await t.connect("p1", "tr-1");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.DISPOSED
);
}
try {
await t.send("p1", envelope("m1"));
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.DISPOSED
);
}
});
it("throws PEER_UNKNOWN when sending to unregistered peer", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.connect("p1", "tr-1");
try {
await t.send("ghost", envelope("m1"));
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.PEER_UNKNOWN
);
}
await t.close();
});
it("disconnect of unknown peer is a no-op", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.disconnect("ghost");
await t.close();
});
it("close is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.close();
await t.close();
});
it("returns unsubscribe handles from onMessage/onPeerEvent", {
timeout: 5000,
}, () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
const offM = t.onMessage(() => {});
const offE = t.onPeerEvent(() => {});
offM();
offE();
});
it("wraps room-init failure with transport-specific code", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
broker.broken = true;
const t = makeTorrentTransport(broker);
try {
await t.connect("p1", "tr-1");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.TORRENT_TRACKER_FAILED
);
}
});
it("wraps send failure as RELAY_FAILED", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.connect("p1", "tr-1");
// Force the room's send to throw by injecting a broken factory
// post-init is impossible — instead replace #sendAction via close+reopen
// pattern. Simpler: monkey-patch the room's makeAction return is not
// accessible. Instead trigger inbound from a non-existent peer via
// close + new sender raising mismatch. Use an alternate strategy:
// mark broker.broken AFTER connect, then close room mid-flight.
// For deterministic coverage, fall back to an explicit factory that
// returns a room whose makeAction send rejects.
const broken = new TrysteroNodeTransport({
appId: "test-app",
transport: "torrent",
torrent: { trackers: ["wss://t.example"] },
torrentRoomFactory: async () => {
return {
getPeerId: () => "x",
makeAction: <T>() =>
[
async (_d: T) => {
throw new Error("relay down");
},
(_p: string, _d: T) => {},
(_h: (peerId: string, data: T) => void) => {},
] as const,
onPeerJoin: () => () => {},
onPeerLeave: () => () => {},
leave: () => {},
ping: async () => 0,
};
},
});
await broken.connect("p1", "x");
try {
await broken.send("p1", envelope("m1"));
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.RELAY_FAILED
);
}
await broken.close();
await t.close();
});
it("throws INVALID_OPTIONS when factory missing for selected transport", {
timeout: 5000,
}, async () => {
const t = new TrysteroNodeTransport({
appId: "test-app",
transport: "nostr",
// missing nostr block + nostrRoomFactory
});
try {
await t.connect("p1", "x");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS
);
}
});
it("auto-prunes peer on underlying room leave", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const a = makeTorrentTransport(broker);
const b = makeTorrentTransport(broker);
const events: Array<{ peerId: string; event: string }> = [];
a.onPeerEvent((peerId, event) => events.push({ peerId, event }));
// a connects first so its onPeerJoin/onPeerLeave handlers are
// wired before b registers in the broker.
await a.connect("__a", "tr-a");
await flushMicrotasks();
await b.connect("__b", "tr-b");
await flushMicrotasks();
const bPeers = a.getRoomPeers();
expect(bPeers.length).toBeGreaterThan(0);
const bId = bPeers[0] ?? "";
await a.connect("peer-b", bId);
await b.close();
await flushMicrotasks();
const disconnected = events.filter(
(e) => e.event === "disconnected" && e.peerId === "peer-b"
);
expect(disconnected.length).toBeGreaterThan(0);
await a.close();
});
it("supports each transport kind for factory resolution", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const nostr = new TrysteroNodeTransport({
appId: "test-app",
transport: "nostr",
nostr: { relays: ["wss://r"] },
nostrRoomFactory: fakeNostrRoomFactory(broker),
});
const mqtt = new TrysteroNodeTransport({
appId: "test-app",
transport: "mqtt",
mqtt: { brokers: ["wss://b"] },
mqttRoomFactory: fakeMqttRoomFactory(broker),
});
const ipfs = new TrysteroNodeTransport({
appId: "test-app",
transport: "ipfs",
ipfs: { bootstrap: ["wss://i"] },
ipfsRoomFactory: fakeIpfsRoomFactory(broker),
});
await nostr.connect("a", "x");
await mqtt.connect("a", "x");
await ipfs.connect("a", "x");
await nostr.close();
await mqtt.close();
await ipfs.close();
});
it("nostr missing config surfaces INVALID_OPTIONS", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t1 = new TrysteroNodeTransport({
appId: "a",
transport: "nostr",
nostrRoomFactory: fakeNostrRoomFactory(broker),
});
await expect(t1.connect("a", "x")).rejects.toThrow(
NetDiscTrysteroError
);
const t2 = new TrysteroNodeTransport({
appId: "a",
transport: "mqtt",
mqttRoomFactory: fakeMqttRoomFactory(broker),
});
await expect(t2.connect("a", "x")).rejects.toThrow(
NetDiscTrysteroError
);
const t3 = new TrysteroNodeTransport({
appId: "a",
transport: "ipfs",
ipfsRoomFactory: fakeIpfsRoomFactory(broker),
});
await expect(t3.connect("a", "x")).rejects.toThrow(
NetDiscTrysteroError
);
const t4 = new TrysteroNodeTransport({
appId: "a",
transport: "torrent",
torrentRoomFactory: fakeTorrentRoomFactory(broker),
});
await expect(t4.connect("a", "x")).rejects.toThrow(
NetDiscTrysteroError
);
});
it("concurrent first connects share the same room-open promise", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await Promise.all([t.connect("p1", "tr-1"), t.connect("p2", "tr-2")]);
expect(t.isInitialized).toBe(true);
expect(t.getConnectedPeers()).toEqual(
expect.arrayContaining(["p1", "p2"])
);
await t.close();
});
it("aborts after factory resolves but before action wired", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const t = new TrysteroNodeTransport({
appId: "a",
transport: "torrent",
torrent: { trackers: ["wss://t.example"] },
torrentRoomFactory: async () => {
ac.abort();
return {
getPeerId: () => "x",
makeAction: <T>() =>
[
async (_d: T) => {},
(_p: string, _d: T) => {},
(_h: (peerId: string, data: T) => void) => {},
] as const,
onPeerJoin: () => () => {},
onPeerLeave: () => () => {},
leave: () => {},
ping: async () => 0,
};
},
});
try {
await t.connect("p1", "x", ac.signal);
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscTrysteroError).code).toBe(
NET_DISC_TRYSTERO_CODES.ABORTED
);
}
});
it("inbound from unregistered room peer surfaces room id as peerId", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const a = makeTorrentTransport(broker);
const b = makeTorrentTransport(broker);
// a wires its handlers first so it observes b joining.
const inboundB: Array<{ peerId: string; env: MessageEnvelope }> = [];
b.onMessage((peerId, env) => inboundB.push({ peerId, env }));
await a.connect("__a", "tr-a");
await flushMicrotasks();
await b.connect("__b", "tr-b");
await flushMicrotasks();
// a sees b's room id via onPeerJoin (b joined after a wired).
const peersFromA = a.getRoomPeers();
expect(peersFromA.length).toBeGreaterThan(0);
const bRoomId = peersFromA[0] ?? "";
// a sends to b. b never called connect for a, so the inbound
// peerId on b falls back to a's room id.
await a.connect("peer-b", bRoomId);
await a.send("peer-b", envelope("unsolicited"));
await flushMicrotasks();
const match = inboundB.find((m) => m.env.id === "unsolicited");
expect(match).toBeDefined();
// b's inbound peerId is a's room peer id, NOT the registered
// "__b" — confirming the address-to-peer fallback path.
expect(match?.peerId).not.toBe("__b");
await a.close();
await b.close();
});
it("handler throw is swallowed and counted as inbound error", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const a = makeTorrentTransport(broker);
const b = makeTorrentTransport(broker);
b.onMessage(() => {
throw new Error("handler boom");
});
await a.connect("__a", "tr-a");
await flushMicrotasks();
await b.connect("__b", "tr-b");
await flushMicrotasks();
const peersFromA = a.getRoomPeers();
expect(peersFromA.length).toBeGreaterThan(0);
const bId = peersFromA[0] ?? "";
await a.connect("peer-b", bId);
// Should not throw despite b's handler throwing.
await a.send("peer-b", envelope("boom-msg"));
await flushMicrotasks();
await a.close();
await b.close();
});
it("post-close send throws DISPOSED even when peer was previously connected", {
timeout: 5000,
}, async () => {
const broker = new FakeTrysteroBroker();
const t = makeTorrentTransport(broker);
await t.connect("p1", "tr-1");
await t.close();
await expect(t.send("p1", envelope("m1"))).rejects.toThrow();
});
});

View File

@@ -0,0 +1,494 @@
import type { MessageEnvelope, PeerId, Transport } from "@suaveplan/network";
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "../errors/errors.js";
import {
adjustTransportActivePeers,
recordTransportMessage,
withNodeTransportSpan,
} from "../otel/otel.js";
import type {
IpfsRoomFactory,
IpfsTransportConfig,
MqttRoomFactory,
MqttTransportConfig,
NostrRoomFactory,
NostrTransportConfig,
TorrentRoomFactory,
TorrentTransportConfig,
TrysteroRoomCommonConfig,
TrysteroRoomLike,
TrysteroTransport,
} from "../types/types.js";
type MessageHandler = (peerId: PeerId, envelope: MessageEnvelope) => void;
type PeerEventHandler = (
peerId: PeerId,
event: "connected" | "disconnected"
) => void;
interface TrysteroWire {
readonly room: TrysteroRoomLike;
readonly send: (
data: MessageEnvelope,
target?: string | readonly string[]
) => Promise<void>;
}
/**
* Config for `TrysteroNodeTransport`. Reuses the per-transport blocks and
* factories already defined in the discovery surface — the transport reuses
* the same room machinery on a separate `makeAction("envelope")` channel.
*/
export interface TrysteroNodeTransportConfig {
/** App / room name — peers with the same `appId` discover each other. */
readonly appId: string;
/** Primary transport selection. */
readonly transport: TrysteroTransport;
/** BitTorrent tracker block (REQUIRED when `transport === "torrent"`). */
readonly torrent?: TorrentTransportConfig | undefined;
/** Nostr relay block (REQUIRED when `transport === "nostr"`). */
readonly nostr?: NostrTransportConfig | undefined;
/** MQTT broker block (REQUIRED when `transport === "mqtt"`). */
readonly mqtt?: MqttTransportConfig | undefined;
/** IPFS bootstrap block (REQUIRED when `transport === "ipfs"`). */
readonly ipfs?: IpfsTransportConfig | undefined;
/** Per-transport room factories — REQUIRED for the selected transport. */
readonly torrentRoomFactory?: TorrentRoomFactory | undefined;
readonly nostrRoomFactory?: NostrRoomFactory | undefined;
readonly mqttRoomFactory?: MqttRoomFactory | undefined;
readonly ipfsRoomFactory?: IpfsRoomFactory | undefined;
/** Optional room password. */
readonly password?: string | undefined;
/** Optional WebRTC relay peer id. */
readonly relayPeerId?: string | undefined;
/** Optional ICE servers. */
readonly rtcConfig?: RTCConfiguration | undefined;
/** Hard cap on tracked peers. Default unlimited. */
readonly maxPeers?: number | undefined;
}
interface PeerState {
readonly peerId: PeerId;
readonly address: string;
}
/**
* Peer-oriented Trystero transport implementing the `@suaveplan/network`
* `Transport` contract. Reuses the same room factories the discovery
* strategy uses, but opens a separate `"envelope"` typed-action channel
* for the data plane so discovery (control) and transport (data)
* traffic stay namespace-isolated.
*
* Peer addressing: `address` is the underlying Trystero room peer id.
* `connect()` registers the mapping; `send()` dispatches via Trystero's
* targeted `send(envelope, target)`; inbound messages are matched back to
* the registered `peerId` by reverse-lookup of the Trystero peer id.
*/
export class TrysteroNodeTransport implements Transport {
public readonly id = "trystero";
readonly #appId: string;
readonly #transportKind: TrysteroTransport;
readonly #common: TrysteroRoomCommonConfig;
readonly #config: TrysteroNodeTransportConfig;
readonly #maxPeers: number;
readonly #peers = new Map<PeerId, PeerState>();
readonly #addressToPeer = new Map<string, PeerId>();
readonly #messageHandlers = new Set<MessageHandler>();
readonly #peerEventHandlers = new Set<PeerEventHandler>();
readonly #knownRoomPeers = new Set<string>();
/**
* The opened Trystero room and the matching typed `send` action are
* always set together (in `#openRoom`) and cleared together (in
* `#tearDownRoom`). Grouping them in a single nullable record
* collapses two parallel invariants into one — when `#wire !== null`
* both the room AND the send action are known to be valid, so the
* `send()` hot path narrows without a defensive throw branch that
* the public API cannot reach.
*/
#wire: TrysteroWire | null = null;
#roomPeerJoinUnsub: (() => void) | null = null;
#roomPeerLeaveUnsub: (() => void) | null = null;
#initPromise: Promise<void> | null = null;
#closed = false;
public constructor(config: TrysteroNodeTransportConfig) {
this.#config = config;
this.#appId = config.appId;
this.#transportKind = config.transport;
this.#common = {
password: config.password,
rtcConfig: config.rtcConfig,
relayPeerId: config.relayPeerId,
};
this.#maxPeers = config.maxPeers ?? Number.POSITIVE_INFINITY;
}
public async connect(
peerId: PeerId,
address: string,
signal?: AbortSignal
): Promise<void> {
return withNodeTransportSpan("connect", async (span) => {
span.setAttribute("peer.id", peerId);
span.setAttribute("peer.address", address);
this.#assertOpen();
if (signal?.aborted) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.ABORTED,
"errors.trystero.aborted"
);
}
if (this.#peers.has(peerId)) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.PEER_ALREADY_CONNECTED,
"errors.trystero.peer_already_connected",
{ context: { peerId } }
);
}
if (this.#peers.size >= this.#maxPeers) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.ROOM_FULL,
"errors.trystero.room_full",
{
context: { peerId, maxPeers: this.#maxPeers },
}
);
}
await this.#ensureRoom(signal);
this.#peers.set(peerId, { peerId, address });
this.#addressToPeer.set(address, peerId);
adjustTransportActivePeers(+1);
this.#emitPeerEvent(peerId, "connected");
});
}
public async disconnect(peerId: PeerId): Promise<void> {
return withNodeTransportSpan("disconnect", async (span) => {
span.setAttribute("peer.id", peerId);
const state = this.#peers.get(peerId);
if (state === undefined) {
return;
}
this.#peers.delete(peerId);
this.#addressToPeer.delete(state.address);
adjustTransportActivePeers(-1);
this.#emitPeerEvent(peerId, "disconnected");
});
}
public async send(
peerId: PeerId,
envelope: MessageEnvelope
): Promise<void> {
return withNodeTransportSpan("send", async (span) => {
span.setAttribute("peer.id", peerId);
if (this.#closed) {
recordTransportMessage("outbound", "error");
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
const state = this.#peers.get(peerId);
if (state === undefined) {
recordTransportMessage("outbound", "error");
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.PEER_UNKNOWN,
"errors.trystero.peer_unknown",
{ context: { peerId } }
);
}
// `state` presence implies `#wire` was set during the prior
// `connect()` call (room and send action are written together
// in `#openRoom`); the `#closed` guard above covers the
// post-close path where `#wire` is cleared. The non-null
// assertion encodes this invariant directly so the public
// API has no unreachable defensive branch.
const wire = this.#wire as NonNullable<TrysteroWire>;
try {
await wire.send(envelope, state.address);
recordTransportMessage("outbound", "ok");
} catch (cause) {
recordTransportMessage("outbound", "error");
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.RELAY_FAILED,
"errors.trystero.send_failed",
{
context: { peerId },
cause: cause as Error,
}
);
}
});
}
public onMessage(handler: MessageHandler): () => void {
this.#messageHandlers.add(handler);
return () => {
this.#messageHandlers.delete(handler);
};
}
public onPeerEvent(handler: PeerEventHandler): () => void {
this.#peerEventHandlers.add(handler);
return () => {
this.#peerEventHandlers.delete(handler);
};
}
public getConnectedPeers(): readonly PeerId[] {
return [...this.#peers.keys()];
}
public async close(): Promise<void> {
if (this.#closed) {
return;
}
this.#closed = true;
const peerIds = [...this.#peers.keys()];
this.#peers.clear();
this.#addressToPeer.clear();
for (const peerId of peerIds) {
adjustTransportActivePeers(-1);
this.#emitPeerEvent(peerId, "disconnected");
}
this.#messageHandlers.clear();
this.#peerEventHandlers.clear();
this.#tearDownRoom();
}
/**
* Whether the underlying Trystero room has been opened. The room
* opens lazily on the first `connect()` call.
*/
public get isInitialized(): boolean {
return this.#wire !== null;
}
/** Snapshot of room peer ids currently seen via `onPeerJoin`. */
public getRoomPeers(): readonly string[] {
return [...this.#knownRoomPeers];
}
#assertOpen(): void {
if (this.#closed) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.DISPOSED,
"errors.trystero.disposed"
);
}
}
async #ensureRoom(signal: AbortSignal | undefined): Promise<void> {
if (this.#wire !== null) {
return;
}
if (this.#initPromise !== null) {
await this.#initPromise;
return;
}
const promise = this.#openRoom(signal);
this.#initPromise = promise;
try {
await promise;
} finally {
this.#initPromise = null;
}
}
async #openRoom(signal: AbortSignal | undefined): Promise<void> {
const { factory, errorCode } = this.#resolveFactory();
let room: TrysteroRoomLike;
try {
room = await factory();
} catch (cause) {
throw new NetDiscTrysteroError(
errorCode,
"errors.trystero.init_failed",
{
cause: cause as Error,
}
);
}
if (signal?.aborted) {
try {
room.leave();
} catch {
/* swallow */
}
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.ABORTED,
"errors.trystero.aborted"
);
}
const [send, , onReceive] =
room.makeAction<MessageEnvelope>("envelope");
this.#wire = { room, send };
onReceive((roomPeerId, envelope) => {
this.#handleInbound(roomPeerId, envelope);
});
this.#roomPeerJoinUnsub = room.onPeerJoin((roomPeerId) => {
this.#knownRoomPeers.add(roomPeerId);
});
this.#roomPeerLeaveUnsub = room.onPeerLeave((roomPeerId) => {
this.#knownRoomPeers.delete(roomPeerId);
const peerId = this.#addressToPeer.get(roomPeerId);
if (peerId !== undefined) {
this.#peers.delete(peerId);
this.#addressToPeer.delete(roomPeerId);
adjustTransportActivePeers(-1);
this.#emitPeerEvent(peerId, "disconnected");
}
});
}
#resolveFactory(): {
readonly factory: () => Promise<TrysteroRoomLike>;
readonly errorCode: (typeof NET_DISC_TRYSTERO_CODES)[
| "TORRENT_TRACKER_FAILED"
| "NOSTR_RELAY_FAILED"
| "MQTT_BROKER_FAILED"
| "IPFS_BOOTSTRAP_FAILED"
| "TRANSPORT_INIT_FAILED"];
} {
const cfg = this.#config;
switch (this.#transportKind) {
case "torrent": {
if (
cfg.torrent === undefined ||
cfg.torrentRoomFactory === undefined
) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.torrent_missing"
);
}
const torrent = cfg.torrent;
const torrentFactory = cfg.torrentRoomFactory;
return {
factory: () =>
torrentFactory(this.#appId, {
...torrent,
...this.#common,
}),
errorCode: NET_DISC_TRYSTERO_CODES.TORRENT_TRACKER_FAILED,
};
}
case "nostr": {
if (
cfg.nostr === undefined ||
cfg.nostrRoomFactory === undefined
) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.nostr_missing"
);
}
const nostr = cfg.nostr;
const nostrFactory = cfg.nostrRoomFactory;
return {
factory: () =>
nostrFactory(this.#appId, {
...nostr,
...this.#common,
}),
errorCode: NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED,
};
}
case "mqtt": {
if (
cfg.mqtt === undefined ||
cfg.mqttRoomFactory === undefined
) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.mqtt_missing"
);
}
const mqtt = cfg.mqtt;
const mqttFactory = cfg.mqttRoomFactory;
return {
factory: () =>
mqttFactory(this.#appId, {
...mqtt,
...this.#common,
}),
errorCode: NET_DISC_TRYSTERO_CODES.MQTT_BROKER_FAILED,
};
}
case "ipfs": {
if (
cfg.ipfs === undefined ||
cfg.ipfsRoomFactory === undefined
) {
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS,
"errors.trystero.ipfs_missing"
);
}
const ipfs = cfg.ipfs;
const ipfsFactory = cfg.ipfsRoomFactory;
return {
factory: () =>
ipfsFactory(this.#appId, {
...ipfs,
...this.#common,
}),
errorCode: NET_DISC_TRYSTERO_CODES.IPFS_BOOTSTRAP_FAILED,
};
}
}
}
#handleInbound(roomPeerId: string, envelope: MessageEnvelope): void {
const peerId =
this.#addressToPeer.get(roomPeerId) ?? (roomPeerId as PeerId);
try {
for (const handler of this.#messageHandlers) {
handler(peerId, envelope);
}
recordTransportMessage("inbound", "ok");
} catch {
recordTransportMessage("inbound", "error");
}
}
#emitPeerEvent(peerId: PeerId, event: "connected" | "disconnected"): void {
for (const handler of this.#peerEventHandlers) {
handler(peerId, event);
}
}
#tearDownRoom(): void {
if (this.#roomPeerJoinUnsub !== null) {
try {
this.#roomPeerJoinUnsub();
} catch {
/* swallow */
}
this.#roomPeerJoinUnsub = null;
}
if (this.#roomPeerLeaveUnsub !== null) {
try {
this.#roomPeerLeaveUnsub();
} catch {
/* swallow */
}
this.#roomPeerLeaveUnsub = null;
}
if (this.#wire !== null) {
try {
this.#wire.room.leave();
} catch {
/* swallow */
}
this.#wire = null;
}
this.#knownRoomPeers.clear();
}
}

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.

View File

@@ -0,0 +1,85 @@
import z from "zod";
/**
* Zod boundary schemas for every public option struct in
* `@suaveplan/network-discovery-trystero`. Callable contracts (room
* factories, `EventBusLike`, `ServiceRegistry`) are type-only and tolerated
* as `z.unknown()` — runtime checks live in the constructor that consumes
* them.
*
* Every transport's relay/tracker/broker list is non-optional and
* non-empty — there is no implicit public default. Construction must fail
* loudly if a list is missing or empty.
*/
export const TrysteroTransportSchema = z.enum([
"torrent",
"nostr",
"mqtt",
"ipfs",
]);
export const TorrentTransportConfigSchema = z
.object({
trackers: z.array(z.string().min(1)).min(1),
trackerRedundancy: z.number().int().positive().optional(),
})
.strict();
export const NostrTransportConfigSchema = z
.object({
relays: z.array(z.string().min(1)).min(1),
})
.strict();
export const MqttTransportConfigSchema = z
.object({
brokers: z.array(z.string().min(1)).min(1),
username: z.string().min(1).optional(),
password: z.string().min(1).optional(),
})
.strict();
export const IpfsTransportConfigSchema = z
.object({
bootstrap: z.array(z.string().min(1)).min(1),
relay: z.array(z.string().min(1)).optional(),
})
.strict();
export const TrysteroPeerMetadataSchema = z
.object({
id: z.string().min(1),
addresses: z.array(z.string()).readonly(),
metadata: z.record(z.string(), z.unknown()),
capabilities: z.array(z.string()).optional(),
lastSeen: z.number().int().nonnegative(),
transport: TrysteroTransportSchema,
})
.strict();
export const TrysteroDiscoveryConfigSchema = z
.object({
appId: z.string().min(1),
transport: TrysteroTransportSchema,
torrent: TorrentTransportConfigSchema.optional(),
nostr: NostrTransportConfigSchema.optional(),
mqtt: MqttTransportConfigSchema.optional(),
ipfs: IpfsTransportConfigSchema.optional(),
password: z.string().min(1).optional(),
relayPeerId: z.string().min(1).optional(),
rtcConfig: z.unknown().optional(),
interval: z.number().int().positive().default(30_000),
timeout: z.number().int().positive().default(60_000),
heartbeatInterval: z.number().int().positive().default(15_000),
cleanupInterval: z.number().int().positive().default(30_000),
maxPeers: z.number().int().positive().default(100),
fallbackTransports: z.array(TrysteroTransportSchema).default([]),
torrentRoomFactory: z.unknown().optional(),
nostrRoomFactory: z.unknown().optional(),
mqttRoomFactory: z.unknown().optional(),
ipfsRoomFactory: z.unknown().optional(),
bus: z.unknown().optional(),
registry: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,314 @@
import type { ServiceRegistry } from "@suaveplan/service-discovery";
import type { EventBusLike } from "@suaveplan/types";
/**
* Identifier for which Trystero transport `TrysteroDiscovery` is wired
* against. Fixed at construction time per-adapter; the orchestrator may swap
* adapters during fallback but never branches on this value at runtime.
*/
export type TrysteroTransport = "torrent" | "nostr" | "mqtt" | "ipfs";
/**
* Discovery-message types exchanged through the active Trystero room.
*
* Frozen string-literal union (the wire-format values are stable across the
* entire `network-discovery-*` family).
*/
export const TrysteroMessageType = Object.freeze({
/** Announce presence + metadata in the shared room. */
ANNOUNCE: "announce",
/** Request the full known peer list. */
DISCOVER: "discover",
/** Reply to a `DISCOVER` with an array of peer ids. */
PEER_LIST: "peer_list",
/** Voluntary leave notification. */
LEAVE: "leave",
/** Liveness ping. */
HEARTBEAT: "heartbeat",
/** Notification that the sender has switched to a fallback transport. */
FALLBACK: "fallback",
} as const);
export type TrysteroMessageTypeValue =
(typeof TrysteroMessageType)[keyof typeof TrysteroMessageType];
/**
* Peer metadata broadcast over the Trystero room.
*/
export interface TrysteroPeerMetadata {
readonly id: string;
readonly addresses: readonly string[];
readonly metadata: Readonly<Record<string, unknown>>;
readonly capabilities?: readonly string[] | undefined;
lastSeen: number;
readonly transport: TrysteroTransport;
}
/**
* Trystero discovery wire message.
*/
export interface TrysteroMessage {
readonly type: TrysteroMessageTypeValue;
readonly from: string;
readonly payload:
| TrysteroPeerMetadata
| readonly string[]
| { readonly transport: TrysteroTransport }
| Readonly<Record<string, unknown>>;
readonly timestamp: number;
readonly transport?: TrysteroTransport | undefined;
}
/**
* BitTorrent-tracker transport options. `trackers` is REQUIRED and may not be
* empty — no implicit public-tracker default.
*/
export interface TorrentTransportConfig {
readonly trackers: readonly string[];
readonly trackerRedundancy?: number | undefined;
}
/**
* Nostr-relay transport options. `relays` is REQUIRED and may not be empty.
*/
export interface NostrTransportConfig {
readonly relays: readonly string[];
}
/**
* MQTT-broker transport options. `brokers` is REQUIRED and may not be empty.
*/
export interface MqttTransportConfig {
readonly brokers: readonly string[];
readonly username?: string | undefined;
readonly password?: string | undefined;
}
/**
* IPFS libp2p pubsub transport options. `bootstrap` is REQUIRED and may not
* be empty.
*/
export interface IpfsTransportConfig {
readonly bootstrap: readonly string[];
readonly relay?: readonly string[] | undefined;
}
/**
* Structural surface of a Trystero room used by this package. Real Trystero
* rooms conform to this shape; tests bind deterministic in-memory fakes.
*/
export interface TrysteroRoomLike {
getPeerId(): string;
makeAction<T = TrysteroMessage>(
namespace: string
): readonly [
send: (data: T, target?: string | readonly string[]) => Promise<void>,
// Pinger / direct-peer callback - kept for parity with Trystero's
// `[send, get, on]` triple. Unused by this package's orchestrator.
get: (peerId: string, data: T) => void,
onReceive: (handler: (peerId: string, data: T) => void) => void,
];
onPeerJoin(handler: (peerId: string) => void): () => void;
onPeerLeave(handler: (peerId: string) => void): () => void;
leave(): void;
ping(peerId: string): Promise<number>;
}
/**
* Callbacks the adapter fires into the orchestrator.
*/
export interface TrysteroAdapterCallbacks {
/** A peer joined the underlying room. */
onPeerJoined(peerId: string): void;
/** A peer left the underlying room. */
onPeerLost(peerId: string): void;
/** A discovery message was received from a remote peer. */
onMessage(message: TrysteroMessage): void;
}
/**
* Shared adapter contract every Trystero transport conforms to. The
* orchestrator drives transport lifecycle without branching on the transport
* string at runtime — construction-time selection only.
*/
export interface TrysteroTransportAdapter {
readonly kind: TrysteroTransport;
/**
* Initialise the underlying Trystero room. Returns the assigned local
* peer id.
*/
initialize(
callbacks: TrysteroAdapterCallbacks,
signal?: AbortSignal
): Promise<string>;
/** Send a discovery message (optionally targeted at a single peer). */
send(message: TrysteroMessage, target?: string): Promise<void>;
/** Leave the underlying room. Idempotent. */
leave(): void;
/** Tear the adapter down. Idempotent. */
destroy(): void;
/** Whether the underlying transport is currently connected. */
readonly isConnected: boolean;
/** Ping a remote peer (round-trip time in ms). */
ping(peerId: string): Promise<number>;
}
/**
* Factory wiring `(appId, config) => TrysteroRoomLike`. Real implementations
* dynamically import the matching `trystero/<transport>` entry point; tests
* pass a deterministic stub.
*/
export type TorrentRoomFactory = (
appId: string,
config: TorrentTransportConfig & TrysteroRoomCommonConfig
) => Promise<TrysteroRoomLike>;
export type NostrRoomFactory = (
appId: string,
config: NostrTransportConfig & TrysteroRoomCommonConfig
) => Promise<TrysteroRoomLike>;
export type MqttRoomFactory = (
appId: string,
config: MqttTransportConfig & TrysteroRoomCommonConfig
) => Promise<TrysteroRoomLike>;
export type IpfsRoomFactory = (
appId: string,
config: IpfsTransportConfig & TrysteroRoomCommonConfig
) => Promise<TrysteroRoomLike>;
/**
* Shared room configuration applied across every transport.
*/
export interface TrysteroRoomCommonConfig {
readonly password?: string | undefined;
readonly rtcConfig?: RTCConfiguration | undefined;
readonly relayPeerId?: string | undefined;
}
/**
* Strict configuration for `TrysteroDiscovery`. `appId`, `transport`, and the
* per-transport block + factory matching the chosen transport are required.
*/
export interface TrysteroDiscoveryConfig {
/** App / room name — peers with the same `appId` discover each other. */
readonly appId: string;
/** Primary transport selection. */
readonly transport: TrysteroTransport;
/** BitTorrent tracker block (REQUIRED when `transport === "torrent"`). */
readonly torrent?: TorrentTransportConfig | undefined;
/** Nostr relay block (REQUIRED when `transport === "nostr"`). */
readonly nostr?: NostrTransportConfig | undefined;
/** MQTT broker block (REQUIRED when `transport === "mqtt"`). */
readonly mqtt?: MqttTransportConfig | undefined;
/** IPFS bootstrap block (REQUIRED when `transport === "ipfs"`). */
readonly ipfs?: IpfsTransportConfig | undefined;
/** Password for encrypted rooms. */
readonly password?: string | undefined;
/** Optional explicit WebRTC relay peer id. */
readonly relayPeerId?: string | undefined;
/** Optional ICE servers for WebRTC. */
readonly rtcConfig?: RTCConfiguration | undefined;
/** Announce-interval (ms). Default `30_000`. */
readonly interval?: number | undefined;
/** Peer-staleness timeout (ms). Default `60_000`. */
readonly timeout?: number | undefined;
/** Heartbeat interval (ms). Default `15_000`. */
readonly heartbeatInterval?: number | undefined;
/** Cleanup-loop interval (ms). Default `30_000`. */
readonly cleanupInterval?: number | undefined;
/** Hard cap on tracked peers. Default `100`. */
readonly maxPeers?: number | undefined;
/**
* Ordered list of fallback transports to try if the primary fails. Each
* fallback REQUIRES its matching config block + factory to be present.
*/
readonly fallbackTransports?: readonly TrysteroTransport[] | undefined;
/** Per-transport room factories — REQUIRED for every selected transport. */
readonly torrentRoomFactory?: TorrentRoomFactory | undefined;
readonly nostrRoomFactory?: NostrRoomFactory | undefined;
readonly mqttRoomFactory?: MqttRoomFactory | undefined;
readonly ipfsRoomFactory?: IpfsRoomFactory | undefined;
/** Optional event bus for typed emissions. */
readonly bus?: EventBusLike<TrysteroEventMap> | undefined;
/** Optional service registry to mirror discovered peers into. */
readonly registry?: ServiceRegistry | undefined;
}
/**
* Fully resolved config — every optional field defaulted, every required
* transport factory present.
*/
export interface ResolvedTrysteroDiscoveryConfig {
readonly appId: string;
readonly transport: TrysteroTransport;
readonly torrent: TorrentTransportConfig | undefined;
readonly nostr: NostrTransportConfig | undefined;
readonly mqtt: MqttTransportConfig | undefined;
readonly ipfs: IpfsTransportConfig | undefined;
readonly password: string | undefined;
readonly relayPeerId: string | undefined;
readonly rtcConfig: RTCConfiguration | undefined;
readonly interval: number;
readonly timeout: number;
readonly heartbeatInterval: number;
readonly cleanupInterval: number;
readonly maxPeers: number;
readonly fallbackTransports: readonly TrysteroTransport[];
readonly torrentRoomFactory: TorrentRoomFactory | undefined;
readonly nostrRoomFactory: NostrRoomFactory | undefined;
readonly mqttRoomFactory: MqttRoomFactory | undefined;
readonly ipfsRoomFactory: IpfsRoomFactory | undefined;
readonly bus: EventBusLike<TrysteroEventMap> | undefined;
readonly registry: ServiceRegistry | undefined;
}
/**
* Overall connection status.
*/
export interface TrysteroConnectionStatus {
readonly transport: TrysteroTransport;
readonly connected: boolean;
readonly peerCount: number;
readonly fallbackAttempts: number;
}
/** Typed events emitted on the optional `EventBusLike`. */
export interface TrysteroStartedEvent {
readonly peerId: string;
readonly transport: TrysteroTransport;
readonly appId: string;
readonly at: number;
}
export interface TrysteroDiscoveredEvent {
readonly peer: TrysteroPeerMetadata;
readonly at: number;
}
export interface TrysteroLostEvent {
readonly peerId: string;
readonly at: number;
}
export interface TrysteroFallbackEvent {
readonly from: TrysteroTransport;
readonly to: TrysteroTransport;
readonly attempt: number;
readonly at: number;
}
export interface TrysteroTransportFailedEvent {
readonly transport: TrysteroTransport;
readonly reason: string;
readonly at: number;
}
export type TrysteroEventMap = {
"trystero:started": TrysteroStartedEvent;
"trystero:peer-discovered": TrysteroDiscoveredEvent;
"trystero:peer-lost": TrysteroLostEvent;
"trystero:fallback": TrysteroFallbackEvent;
"trystero:transport-failed": TrysteroTransportFailedEvent;
};

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-discovery-trystero dep-graph", () => {
it("must not have circular dependencies", { timeout: 5000 }, async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.TrysteroDiscovery).toBeDefined();
expect(mod.TrysteroPeerManager).toBeDefined();
expect(mod.TrysteroIntervals).toBeDefined();
expect(mod.TorrentTransportAdapter).toBeDefined();
expect(mod.NostrTransportAdapter).toBeDefined();
expect(mod.MqttTransportAdapter).toBeDefined();
expect(mod.IpfsTransportAdapter).toBeDefined();
expect(mod.NET_DISC_TRYSTERO_CODES).toBeDefined();
expect(mod.NetDiscTrysteroError).toBeDefined();
expect(mod.TrysteroDiscoveryConfigSchema).toBeDefined();
expect(mod.TrysteroMessageType).toBeDefined();
expect(mod.withStartSpan).toBeDefined();
expect(mod.recordPeersDiscovered).toBeDefined();
expect(mod.recordTransportFallback).toBeDefined();
expect(mod.handleMessage).toBeDefined();
expect(mod.tryFallback).toBeDefined();
});
});

View File

@@ -0,0 +1,203 @@
/**
* Deterministic in-memory Trystero substitute used by the test suites. Models
* a single shared broker — every `FakeTrysteroRoom` constructed against the
* same `FakeTrysteroBroker` and the same `(appId, transport)` pair shares
* peer-join/leave + message broadcast.
*
* No mocks: these are real implementations of the `TrysteroRoomLike`
* structural contract. Tests that exercise the transport-specific adapters
* select one of the four `fake<X>RoomFactory` factories below — but all four
* factories share the same broker, the same `FakeTrysteroRoom` class, and
* are differentiated only by the `kind` they tag the room with for
* observability assertions.
*/
import type {
IpfsRoomFactory,
MqttRoomFactory,
NostrRoomFactory,
TorrentRoomFactory,
TrysteroMessage,
TrysteroRoomLike,
TrysteroTransport,
} from "../../src/types/types.js";
type MessageHandler = (peerId: string, data: TrysteroMessage) => void;
type PeerHandler = (peerId: string) => void;
let nextPeerSeq = 0;
interface RoomKey {
readonly appId: string;
readonly transport: TrysteroTransport;
}
function keyString(k: RoomKey): string {
return `${k.transport}:${k.appId}`;
}
export class FakeTrysteroBroker {
public broken = false;
public readonly rooms = new Map<string, Set<FakeTrysteroRoom>>();
public register(room: FakeTrysteroRoom): void {
const k = keyString({ appId: room.appId, transport: room.transport });
let set = this.rooms.get(k);
if (set === undefined) {
set = new Set();
this.rooms.set(k, set);
}
for (const other of set) {
other.notePeerJoined(room.myPeerId);
room.notePeerJoined(other.myPeerId);
}
set.add(room);
}
public unregister(room: FakeTrysteroRoom): void {
const k = keyString({ appId: room.appId, transport: room.transport });
const set = this.rooms.get(k);
if (set === undefined) return;
if (!set.delete(room)) return;
for (const other of set) {
other.notePeerLeft(room.myPeerId);
}
}
public broadcast(
room: FakeTrysteroRoom,
message: TrysteroMessage,
target?: string | readonly string[]
): void {
const k = keyString({ appId: room.appId, transport: room.transport });
const set = this.rooms.get(k);
if (set === undefined) return;
const targets =
typeof target === "string"
? new Set<string>([target])
: Array.isArray(target)
? new Set<string>(target as readonly string[])
: undefined;
for (const other of set) {
if (other === room) continue;
if (targets !== undefined && !targets.has(other.myPeerId)) continue;
other.deliver(room.myPeerId, message);
}
}
}
export class FakeTrysteroRoom implements TrysteroRoomLike {
public readonly appId: string;
public readonly transport: TrysteroTransport;
public readonly myPeerId: string;
private readonly broker: FakeTrysteroBroker;
private peerJoinHandlers = new Set<PeerHandler>();
private peerLeaveHandlers = new Set<PeerHandler>();
private messageHandlers = new Set<MessageHandler>();
private destroyed = false;
public constructor(
appId: string,
transport: TrysteroTransport,
broker: FakeTrysteroBroker
) {
this.appId = appId;
this.transport = transport;
this.broker = broker;
nextPeerSeq += 1;
this.myPeerId = `peer-${transport}-${nextPeerSeq}`;
if (broker.broken) {
throw new Error(`broker down [${transport}]`);
}
queueMicrotask(() => {
if (!this.destroyed) broker.register(this);
});
}
public getPeerId(): string {
return this.myPeerId;
}
public makeAction<T = TrysteroMessage>(
_namespace: string
): readonly [
send: (data: T, target?: string | readonly string[]) => Promise<void>,
get: (peerId: string, data: T) => void,
onReceive: (handler: (peerId: string, data: T) => void) => void,
] {
return [
async (data, target) =>
this.broker.broadcast(this, data as TrysteroMessage, target),
(_peerId, _data) => {
/* parity with Trystero's [send, get, on] triple */
},
(handler) =>
this.messageHandlers.add(handler as unknown as MessageHandler),
] as const;
}
public onPeerJoin(handler: (peerId: string) => void): () => void {
this.peerJoinHandlers.add(handler);
return () => this.peerJoinHandlers.delete(handler);
}
public onPeerLeave(handler: (peerId: string) => void): () => void {
this.peerLeaveHandlers.add(handler);
return () => this.peerLeaveHandlers.delete(handler);
}
public leave(): void {
if (this.destroyed) return;
this.destroyed = true;
this.broker.unregister(this);
this.peerJoinHandlers.clear();
this.peerLeaveHandlers.clear();
this.messageHandlers.clear();
}
public async ping(_peerId: string): Promise<number> {
return 1;
}
public notePeerJoined(peerId: string): void {
for (const h of this.peerJoinHandlers) h(peerId);
}
public notePeerLeft(peerId: string): void {
for (const h of this.peerLeaveHandlers) h(peerId);
}
public deliver(from: string, message: TrysteroMessage): void {
for (const h of this.messageHandlers) h(from, message);
}
}
export function fakeTorrentRoomFactory(
broker: FakeTrysteroBroker
): TorrentRoomFactory {
return async (appId) => new FakeTrysteroRoom(appId, "torrent", broker);
}
export function fakeNostrRoomFactory(
broker: FakeTrysteroBroker
): NostrRoomFactory {
return async (appId) => new FakeTrysteroRoom(appId, "nostr", broker);
}
export function fakeMqttRoomFactory(
broker: FakeTrysteroBroker
): MqttRoomFactory {
return async (appId) => new FakeTrysteroRoom(appId, "mqtt", broker);
}
export function fakeIpfsRoomFactory(
broker: FakeTrysteroBroker
): IpfsRoomFactory {
return async (appId) => new FakeTrysteroRoom(appId, "ipfs", broker);
}
/** Yield control until pending microtasks have drained. */
export async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
}

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

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

View File

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

View File

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

View File

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