Initial Commit
This commit is contained in:
136
packages/networking/network/network-discovery-trystero/README.md
Normal file
136
packages/networking/network/network-discovery-trystero/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { IpfsTransportAdapter } from "./ipfs-transport.js";
|
||||
@@ -0,0 +1,109 @@
|
||||
# IPFS Transport Adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`IpfsTransportAdapter` implements the universal `TrysteroTransportAdapter`
|
||||
contract over Trystero's IPFS libp2p-pubsub backend. It joins a
|
||||
pubsub-hosted room identified by the configured `appId`, exchanges
|
||||
peer-discovery messages with other participants, and proxies the resulting
|
||||
peer lifecycle events back to the orchestrator via the injected
|
||||
`TrysteroAdapterCallbacks`. There is no default bootstrap list inside the
|
||||
adapter — the caller MUST supply a non-empty `bootstrap` array (the Zod
|
||||
schema rejects empty lists at the package boundary).
|
||||
|
||||
This separation between adapter and orchestrator is deliberate: the
|
||||
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
|
||||
adapter only wraps the room lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps any caller-supplied `IpfsRoomFactory` — production binds
|
||||
`joinRoom` from `trystero/ipfs`, tests bind a deterministic in-memory
|
||||
fake.
|
||||
- Optional `relay` list carried through the config block for hole-punching
|
||||
scenarios.
|
||||
- Per-instance state: room handle, send-action, peer-join/leave unsub,
|
||||
destroyed flag. All cleaned up in `destroy()` (idempotent).
|
||||
- AbortSignal-aware `initialize()`.
|
||||
- Maps every factory failure to `IPFS_BOOTSTRAP_FAILED` with the original
|
||||
error preserved on `error.cause`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { IpfsTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new IpfsTransportAdapter(
|
||||
"my-app",
|
||||
{ bootstrap: ["/ip4/1.2.3.4/tcp/4001/p2p/QmExample"] },
|
||||
{},
|
||||
async () => fakeRoom,
|
||||
);
|
||||
|
||||
await adapter.initialize({
|
||||
onPeerJoined: (id) => console.log("join", id),
|
||||
onPeerLost: (id) => console.log("leave", id),
|
||||
onMessage: (m) => console.log("msg", m),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { IpfsTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new IpfsTransportAdapter(
|
||||
"my-app",
|
||||
{
|
||||
bootstrap: [
|
||||
"/ip4/1.2.3.4/tcp/4001/p2p/QmExample",
|
||||
"/ip4/5.6.7.8/tcp/4001/p2p/QmAnother",
|
||||
],
|
||||
relay: ["/dnsaddr/relay.libp2p.io/p2p/QmRelay"],
|
||||
},
|
||||
{},
|
||||
ipfsJoinRoom,
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
await adapter.initialize(callbacks, ctrl.signal);
|
||||
await adapter.send({
|
||||
type: "heartbeat",
|
||||
from: "me",
|
||||
payload: {},
|
||||
timestamp: Date.now(),
|
||||
transport: "ipfs",
|
||||
});
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new IpfsTransportAdapter(appId, config, common, factory)`.
|
||||
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
|
||||
id. Throws `IPFS_BOOTSTRAP_FAILED` on factory throw, `DISPOSED` after
|
||||
`destroy()`, AbortError on pre-aborted signal.
|
||||
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
|
||||
`initialize` resolves.
|
||||
- `leave(): void` — best-effort `room.leave()`.
|
||||
- `destroy(): void` — idempotent teardown.
|
||||
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
|
||||
`initialize`.
|
||||
- `get isConnected(): boolean`.
|
||||
- `readonly kind: "ipfs"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bootstrap list must contain libp2p-format multiaddrs. The adapter
|
||||
treats them as opaque strings — validation that they are actually
|
||||
reachable lives in the underlying Trystero room implementation.
|
||||
- The Zod schema enforces the non-empty-bootstrap invariant, so the
|
||||
adapter does not re-validate.
|
||||
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
|
||||
are held for the lifetime of the adapter and called during teardown.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MqttTransportAdapter } from "./mqtt-transport.js";
|
||||
@@ -0,0 +1,107 @@
|
||||
# MQTT Transport Adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MqttTransportAdapter` implements the universal `TrysteroTransportAdapter`
|
||||
contract over Trystero's MQTT-broker backend. It joins a broker-hosted
|
||||
room identified by the configured `appId`, exchanges peer-discovery
|
||||
messages with other participants, and proxies the resulting peer lifecycle
|
||||
events back to the orchestrator via the injected
|
||||
`TrysteroAdapterCallbacks`. There is no default broker list inside the
|
||||
adapter — the caller MUST supply a non-empty `brokers` array (the Zod
|
||||
schema rejects empty lists at the package boundary).
|
||||
|
||||
This separation between adapter and orchestrator is deliberate: the
|
||||
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
|
||||
adapter only wraps the room lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps any caller-supplied `MqttRoomFactory` — production binds
|
||||
`joinRoom` from `trystero/mqtt`, tests bind a deterministic in-memory
|
||||
fake.
|
||||
- Optional MQTT `username` / `password` carried through the config block.
|
||||
- Per-instance state: room handle, send-action, peer-join/leave unsub,
|
||||
destroyed flag. All cleaned up in `destroy()` (idempotent).
|
||||
- AbortSignal-aware `initialize()`.
|
||||
- Maps every factory failure to `MQTT_BROKER_FAILED` with the original
|
||||
error preserved on `error.cause`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MqttTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new MqttTransportAdapter(
|
||||
"my-app",
|
||||
{ brokers: ["wss://broker.example:1883"] },
|
||||
{},
|
||||
async () => fakeRoom,
|
||||
);
|
||||
|
||||
await adapter.initialize({
|
||||
onPeerJoined: (id) => console.log("join", id),
|
||||
onPeerLost: (id) => console.log("leave", id),
|
||||
onMessage: (m) => console.log("msg", m),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MqttTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new MqttTransportAdapter(
|
||||
"my-app",
|
||||
{
|
||||
brokers: ["wss://broker-a:1883", "wss://broker-b:1883"],
|
||||
username: "u",
|
||||
password: "p",
|
||||
},
|
||||
{ password: "secret-room" },
|
||||
mqttJoinRoom,
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
await adapter.initialize(callbacks, ctrl.signal);
|
||||
await adapter.send({
|
||||
type: "heartbeat",
|
||||
from: "me",
|
||||
payload: {},
|
||||
timestamp: Date.now(),
|
||||
transport: "mqtt",
|
||||
});
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new MqttTransportAdapter(appId, config, common, factory)`.
|
||||
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
|
||||
id. Throws `MQTT_BROKER_FAILED` on factory throw, `DISPOSED` after
|
||||
`destroy()`, AbortError on pre-aborted signal.
|
||||
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
|
||||
`initialize` resolves.
|
||||
- `leave(): void` — best-effort `room.leave()`.
|
||||
- `destroy(): void` — idempotent teardown.
|
||||
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
|
||||
`initialize`.
|
||||
- `get isConnected(): boolean`.
|
||||
- `readonly kind: "mqtt"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- MQTT broker credentials (`username` / `password`) are carried as plain
|
||||
fields on the config block. They are not redacted by this package — if
|
||||
they are sensitive, configure your logging sinks to redact the
|
||||
`mqtt.username` / `mqtt.password` paths via `@suaveplan/data-masking`.
|
||||
- The Zod schema enforces the non-empty-brokers invariant, so the adapter
|
||||
does not re-validate.
|
||||
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
|
||||
are held for the lifetime of the adapter and called during teardown.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NostrTransportAdapter } from "./nostr-transport.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
# Nostr Transport Adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`NostrTransportAdapter` implements the universal
|
||||
`TrysteroTransportAdapter` contract over Trystero's Nostr-relay backend.
|
||||
It joins a Nostr-relay-hosted room identified by the configured `appId`,
|
||||
exchanges peer-discovery messages with other participants, and proxies the
|
||||
resulting peer lifecycle events back to the orchestrator via the injected
|
||||
`TrysteroAdapterCallbacks`. There is no default relay list inside the
|
||||
adapter — the caller MUST supply a non-empty `relays` array (the Zod
|
||||
schema rejects empty lists at the package boundary).
|
||||
|
||||
This separation between adapter and orchestrator is deliberate: the
|
||||
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
|
||||
adapter only wraps the room lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps any caller-supplied `NostrRoomFactory` — production binds
|
||||
`joinRoom` from `trystero/nostr`, tests bind a deterministic in-memory
|
||||
fake.
|
||||
- Per-instance state: room handle, send-action, peer-join/leave unsub,
|
||||
destroyed flag. All cleaned up in `destroy()` (idempotent).
|
||||
- AbortSignal-aware `initialize()`.
|
||||
- Maps every factory failure to `NOSTR_RELAY_FAILED` with the original
|
||||
error preserved on `error.cause`.
|
||||
- `ping(peerId)` proxies through to the underlying Trystero room's
|
||||
`ping()` (round-trip ms).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NostrTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new NostrTransportAdapter(
|
||||
"my-app",
|
||||
{ relays: ["wss://relay.example"] },
|
||||
{},
|
||||
async () => fakeRoom,
|
||||
);
|
||||
|
||||
await adapter.initialize({
|
||||
onPeerJoined: (id) => console.log("join", id),
|
||||
onPeerLost: (id) => console.log("leave", id),
|
||||
onMessage: (m) => console.log("msg", m),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NostrTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new NostrTransportAdapter(
|
||||
"my-app",
|
||||
{ relays: ["wss://relay-1", "wss://relay-2"] },
|
||||
{ password: "secret" },
|
||||
nostrJoinRoom,
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
await adapter.initialize(callbacks, ctrl.signal);
|
||||
await adapter.send({
|
||||
type: "heartbeat",
|
||||
from: "me",
|
||||
payload: {},
|
||||
timestamp: Date.now(),
|
||||
transport: "nostr",
|
||||
});
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new NostrTransportAdapter(appId, config, common, factory)`.
|
||||
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
|
||||
id. Throws `NOSTR_RELAY_FAILED` on factory throw, `DISPOSED` after
|
||||
`destroy()`, AbortError on pre-aborted signal.
|
||||
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
|
||||
`initialize` resolves.
|
||||
- `leave(): void` — best-effort `room.leave()`.
|
||||
- `destroy(): void` — idempotent teardown.
|
||||
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
|
||||
`initialize`.
|
||||
- `get isConnected(): boolean`.
|
||||
- `readonly kind: "nostr"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Zod schema enforces the non-empty-relays invariant, so the adapter
|
||||
does not re-validate. Empty arrays should never reach the adapter.
|
||||
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
|
||||
are held during the lifetime of the adapter and called during teardown.
|
||||
Exceptions from those unsub calls are swallowed because Trystero may
|
||||
detach them itself during room leave.
|
||||
- `myPeerId` is captured from `room.getPeerId()` at initialise time and
|
||||
cleared in `destroy()`. The orchestrator should treat the adapter as
|
||||
opaque after `destroy()`.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TorrentTransportAdapter } from "./torrent-transport.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
# Torrent Transport Adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TorrentTransportAdapter` implements the universal
|
||||
`TrysteroTransportAdapter` contract over Trystero's BitTorrent-tracker
|
||||
backend. It joins a tracker-hosted room identified by the configured
|
||||
`appId`, exchanges peer-discovery messages with other participants in that
|
||||
room, and proxies the resulting peer lifecycle events back to the
|
||||
orchestrator via the injected `TrysteroAdapterCallbacks`. The adapter never
|
||||
contains a default tracker list; the caller MUST supply a non-empty
|
||||
`trackers` array (the Zod schema rejects empty lists at the package
|
||||
boundary).
|
||||
|
||||
This separation between the adapter and the orchestrator is deliberate: the
|
||||
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
|
||||
adapter only wraps the room lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps any caller-supplied `TorrentRoomFactory` — production binds
|
||||
`joinRoom` from `trystero/torrent`, tests bind a deterministic in-memory
|
||||
fake.
|
||||
- Per-instance state: room handle, send-action, peer-join/leave unsub,
|
||||
destroyed flag. All cleaned up in `destroy()` (idempotent).
|
||||
- AbortSignal-aware `initialize()`.
|
||||
- Maps every factory failure to `TORRENT_TRACKER_FAILED` with the original
|
||||
error preserved on `error.cause`.
|
||||
- `ping(peerId)` proxies through to the underlying Trystero room's
|
||||
`ping()` (round-trip ms).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TorrentTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new TorrentTransportAdapter(
|
||||
"my-app",
|
||||
{ trackers: ["wss://tracker.example"] },
|
||||
{},
|
||||
async () => fakeRoom,
|
||||
);
|
||||
|
||||
await adapter.initialize({
|
||||
onPeerJoined: (id) => console.log("join", id),
|
||||
onPeerLost: (id) => console.log("leave", id),
|
||||
onMessage: (m) => console.log("msg", m),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TorrentTransportAdapter } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const adapter = new TorrentTransportAdapter(
|
||||
"my-app",
|
||||
{ trackers: ["wss://t1", "wss://t2"], trackerRedundancy: 3 },
|
||||
{ password: "secret", rtcConfig: { iceServers: [] } },
|
||||
trackerJoinRoom,
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
await adapter.initialize(callbacks, ctrl.signal);
|
||||
await adapter.send({
|
||||
type: "heartbeat",
|
||||
from: "me",
|
||||
payload: {},
|
||||
timestamp: Date.now(),
|
||||
transport: "torrent",
|
||||
});
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TorrentTransportAdapter(appId, config, common, factory)`.
|
||||
- `initialize(callbacks, signal?): Promise<string>` — returns local peer id.
|
||||
Throws `TORRENT_TRACKER_FAILED` on factory throw, `DISPOSED` after
|
||||
`destroy()`, AbortError on pre-aborted signal.
|
||||
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
|
||||
`initialize` resolves.
|
||||
- `leave(): void` — best-effort `room.leave()`.
|
||||
- `destroy(): void` — idempotent teardown.
|
||||
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before `initialize`.
|
||||
- `get isConnected(): boolean`.
|
||||
- `readonly kind: "torrent"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The adapter holds the `myPeerId` returned by `room.getPeerId()` at
|
||||
initialise time and clears it in `destroy()`. The orchestrator should
|
||||
treat the adapter as opaque after `destroy()`.
|
||||
- Every callback (`onPeerJoined` / `onPeerLost` / `onMessage`) is wired
|
||||
through the room's own subscribe API; unsub handles are held and called
|
||||
during teardown. Exceptions from those unsub calls are swallowed because
|
||||
Trystero's library may detach them itself during room leave.
|
||||
- The Zod schema enforces the non-empty-trackers invariant, so the
|
||||
adapter does not re-validate.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TrysteroDiscovery } from "./trystero-discovery.js";
|
||||
@@ -0,0 +1,129 @@
|
||||
# TrysteroDiscovery — Orchestrator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TrysteroDiscovery` is the orchestrator that ties every other module in
|
||||
`@suaveplan/network-discovery-trystero` together and implements the
|
||||
`@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. It owns the
|
||||
active transport adapter, the peer manager, the interval scheduler, and
|
||||
the recursive fallback chain. The orchestrator never branches on the
|
||||
transport-id string at runtime — every lifecycle call goes through the
|
||||
universal `TrysteroTransportAdapter` interface, and construction-time
|
||||
selection chooses the initial adapter via the internal `pickAdapter` map.
|
||||
|
||||
The strategy is browser-tier (the underlying Trystero library is itself
|
||||
browser-tier) and headless — no React or DOM rendering surface ships from
|
||||
this package. Devtools UI is the responsibility of downstream packages.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `name`, `discover`, `isAvailable`, plus `start`, `stop`,
|
||||
`announce`, `getMyPeerId`, `getPeers`, `getPeerCount`,
|
||||
`getCurrentTransport`, `getStatus`, `ping`, `dispose`.
|
||||
- Construction-time Zod boundary validation + cross-field constraint that
|
||||
every selected transport (primary or fallback) must have its
|
||||
config-block AND factory present. Constructor throws
|
||||
`INVALID_OPTIONS` otherwise.
|
||||
- AbortSignal threaded through `start`, `stop`, `discover`, `isAvailable`,
|
||||
`announce`.
|
||||
- Automatic fallback between transports via `tryFallback` — exponential
|
||||
backoff between attempts optional via `@suaveplan/retry`.
|
||||
- Service-registry integration via `@suaveplan/service-discovery`: peers
|
||||
are registered as `trystero:<transport>:<appId>:<peerId>`.
|
||||
- Typed `EventBusLike` integration with the `TrysteroEventMap`.
|
||||
- OTel: `disc_trystero.start` span, per-transport
|
||||
`disc_trystero.transport.<id>.connect` span, fallback counter, connect
|
||||
histogram, per-peer-discovery counter.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TrysteroDiscovery } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const strategy = new TrysteroDiscovery({
|
||||
appId: "demo",
|
||||
transport: "nostr",
|
||||
nostr: { relays: ["wss://relay.example"] },
|
||||
nostrRoomFactory: async () => realJoinRoom(),
|
||||
});
|
||||
|
||||
await strategy.start();
|
||||
const peers = await strategy.discover(5000);
|
||||
await strategy.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import {
|
||||
TrysteroDiscovery,
|
||||
type TrysteroEventMap,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const bus = new EventBus<TrysteroEventMap>();
|
||||
bus.on("trystero:fallback", (e) => console.log(`fallback ${e.from} -> ${e.to}`));
|
||||
bus.on("trystero:transport-failed", (e) => console.warn(`failed ${e.transport}: ${e.reason}`));
|
||||
|
||||
const strategy = new TrysteroDiscovery({
|
||||
appId: "demo",
|
||||
transport: "nostr",
|
||||
nostr: { relays: ["wss://relay-1", "wss://relay-2"] },
|
||||
mqtt: { brokers: ["wss://broker:1883"] },
|
||||
torrent: { trackers: ["wss://tracker"] },
|
||||
fallbackTransports: ["mqtt", "torrent"],
|
||||
nostrRoomFactory: realNostrJoin,
|
||||
mqttRoomFactory: realMqttJoin,
|
||||
torrentRoomFactory: realTorrentJoin,
|
||||
interval: 10_000,
|
||||
maxPeers: 50,
|
||||
bus,
|
||||
registry: new ServiceRegistry(),
|
||||
});
|
||||
|
||||
const ctrl = new AbortController();
|
||||
await strategy.start(ctrl.signal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TrysteroDiscovery(config: TrysteroDiscoveryConfig, clock?: Clock)`.
|
||||
- `start(signal?): Promise<void>` — idempotent; emits `trystero:started`
|
||||
on success; throws `DISPOSED` after `dispose()`,
|
||||
`ALL_TRANSPORTS_DEGRADED` if the entire fallback chain fails, plus the
|
||||
per-transport init code (`NOSTR_RELAY_FAILED`, etc.) when no fallback is
|
||||
configured.
|
||||
- `stop(signal?): Promise<void>` — emits `LEAVE` to peers, tears down the
|
||||
adapter, clears the peer map.
|
||||
- `discover(timeoutMs, signal?): Promise<PeerInfo[]>` — returns a snapshot
|
||||
of the live peer set.
|
||||
- `isAvailable(signal?): Promise<boolean>` — `true` iff running and the
|
||||
underlying adapter reports `isConnected`.
|
||||
- `announce(peerInfo, signal?): Promise<void>`.
|
||||
- `getMyPeerId / getPeers / getPeerCount / getCurrentTransport / getStatus
|
||||
/ ping / dispose`.
|
||||
- `readonly name = "trystero"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The constructor runs the Zod schema first, then walks the union of
|
||||
`transport` + `fallbackTransports` and asserts each transport's config
|
||||
block AND factory are present. This is a cross-field constraint Zod
|
||||
cannot express cleanly; the error code is always `INVALID_OPTIONS`.
|
||||
- `start()` opens a single `disc_trystero.start` span and inside it opens a
|
||||
`disc_trystero.transport.<id>.connect` span per init attempt. Fallback
|
||||
swaps reuse the same outer span but record a new transport span per
|
||||
swap.
|
||||
- The interval scheduler keeps a stable reference to the peer-manager's
|
||||
`getMap()` so transport swaps do not invalidate the cleanup loop.
|
||||
- `announce` emits the message _after_ admitting the local record so the
|
||||
peer manager's `transport_fallback_total` and
|
||||
`peers_discovered_total` counters reflect locally-known state even if
|
||||
the underlying send fails.
|
||||
- After fallback, `peers.retagTransport(next)` rewrites every existing
|
||||
peer entry's `transport` field so consumers (event listeners,
|
||||
registry) see a consistent view of which transport peers are on.
|
||||
@@ -0,0 +1,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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type FallbackDeps,
|
||||
type FallbackState,
|
||||
tryFallback,
|
||||
} from "./trystero-fallback.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
# Trystero Fallback
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`tryFallback` implements the inter-transport fallback algorithm: when the
|
||||
active Trystero transport fails to initialise, walk the configured
|
||||
`fallbackTransports` array in order, attempting to bring each one up
|
||||
sequentially. The function recurses across the chain — every individual
|
||||
transport gets one attempt — and throws `ALL_TRANSPORTS_DEGRADED` only if
|
||||
every transport in the chain fails.
|
||||
|
||||
Backoff between attempts is delegated to `@suaveplan/retry`'s
|
||||
`createExponentialBackoff` so the fallback policy reuses the suaveplan retry
|
||||
primitives (instead of re-implementing setTimeout-based delays) and so
|
||||
tests can swap in deterministic backoff via injectable sleep.
|
||||
|
||||
## Features
|
||||
|
||||
- Sequential walk over the caller-supplied `fallbackTransports` list.
|
||||
- Optional exponential backoff via `ExponentialBackoffOptions` (no
|
||||
backoff by default — preserves upstream parity).
|
||||
- Injectable `sleepFn` for deterministic tests.
|
||||
- OTel: increments `transport_fallback_total{from,to}` on every transition.
|
||||
- `ALL_TRANSPORTS_DEGRADED` carries the original failure on `error.cause`.
|
||||
- Best-effort `notifyFallback` after a successful swap — failures
|
||||
swallowed because the chain has already stabilised.
|
||||
- Recursive — at most `fallbackTransports.length` levels deep, bounded by
|
||||
the caller-supplied array length.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { tryFallback } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
await tryFallback(
|
||||
new Error("nostr down"),
|
||||
{ fallbackAttempts: 0, currentTransport: "nostr" },
|
||||
{
|
||||
fallbackTransports: ["mqtt", "torrent"],
|
||||
clock: () => Date.now(),
|
||||
initializeTransport: async (t) => connect(t),
|
||||
notifyFallback: async () => {},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { tryFallback } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
await tryFallback(
|
||||
new Error("torrent down"),
|
||||
state,
|
||||
{
|
||||
fallbackTransports: ["nostr", "mqtt", "ipfs"],
|
||||
clock: () => Date.now(),
|
||||
backoff: { initial: 100, max: 5000, base: 2, jitter: "equal" },
|
||||
sleepFn: async (ms) => {
|
||||
console.log(`backoff ${ms}ms`);
|
||||
// deterministic test sleep
|
||||
},
|
||||
initializeTransport: async (t) => {
|
||||
if (t === "nostr") throw new Error("nostr also down");
|
||||
await connect(t);
|
||||
},
|
||||
notifyFallback: async (next) => {
|
||||
await broadcast({ type: "fallback", payload: { transport: next } });
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `async tryFallback(error, state, deps): Promise<void>` — mutates
|
||||
`state.fallbackAttempts` / `state.currentTransport`; throws
|
||||
`ALL_TRANSPORTS_DEGRADED` once the chain is exhausted.
|
||||
- `interface FallbackState` — `{ fallbackAttempts: number; currentTransport: TrysteroTransport; }`.
|
||||
- `interface FallbackDeps`:
|
||||
- `fallbackTransports: readonly TrysteroTransport[]`.
|
||||
- `clock: Clock`.
|
||||
- `backoff?: ExponentialBackoffOptions` — `{ initial, max?, base?, jitter? }`.
|
||||
- `sleepFn?: (ms) => Promise<void>` — defaults to `@suaveplan/retry.sleep`.
|
||||
- `initializeTransport(transport): Promise<void>`.
|
||||
- `notifyFallback(next): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The recursion is tail-position-ish but not actually tail-call-optimised;
|
||||
the call stack is bounded by `fallbackTransports.length` which has a
|
||||
natural upper bound of 3 (each of `torrent`, `nostr`, `mqtt`, `ipfs`
|
||||
except the active one). Stack depth is not a concern.
|
||||
- `recordTransportFallback` is called BEFORE the new transport
|
||||
initialises — the counter reflects fallback intent, not success. Use
|
||||
`disc_trystero.transport.<id>.connect` span + counter to differentiate.
|
||||
- Backoff is computed off `state.fallbackAttempts` AFTER incrementing —
|
||||
the first fallback gets `attempt = 1`, so under exponential strategy
|
||||
the delay is `initial * base^0 = initial`.
|
||||
- Errors from `notifyFallback` are swallowed because the chain has
|
||||
already moved to the new transport; failing to tell other peers is a
|
||||
visibility problem, not a correctness one.
|
||||
@@ -0,0 +1,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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type TrysteroIntervalDeps,
|
||||
TrysteroIntervals,
|
||||
} from "./trystero-intervals.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
# Trystero Intervals
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TrysteroIntervals` replaces the upstream package's three raw `setInterval`
|
||||
handles with a single `@suaveplan/timing` `Scheduler` driving three
|
||||
self-rearming loops: announce, heartbeat, and cleanup. The scheduler is
|
||||
injectable, so tests deterministically drive every tick without
|
||||
`setTimeout` actually firing, and `dispose()` cancels every pending timer
|
||||
atomically when the strategy stops.
|
||||
|
||||
By isolating timer policy in this module, the orchestrator stays focused on
|
||||
adapter lifecycle and the cleanup loop has a stable Map handle it can
|
||||
iterate during staleness sweeps.
|
||||
|
||||
## Features
|
||||
|
||||
- One `Scheduler` instance, three self-rearming loops.
|
||||
- `start()` is idempotent and `stop()` is idempotent.
|
||||
- Each loop reschedules itself only if the scheduler has not been disposed
|
||||
AND `stop()` has not been called.
|
||||
- Announce / heartbeat emit a `TrysteroMessage` (type
|
||||
`ANNOUNCE` / `HEARTBEAT`) via the injected `sendDiscovery` callback.
|
||||
- Cleanup iterates `peers`, dropping any whose `now - lastSeen > timeoutMs`
|
||||
via `handlePeerLost`.
|
||||
- Errors from `sendDiscovery` are swallowed so a single bad tick does not
|
||||
starve the loop.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TrysteroIntervals } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const intervals = new TrysteroIntervals({
|
||||
clock: () => Date.now(),
|
||||
announceIntervalMs: 30_000,
|
||||
heartbeatIntervalMs: 15_000,
|
||||
cleanupIntervalMs: 30_000,
|
||||
timeoutMs: 60_000,
|
||||
peers: new Map(),
|
||||
getMyPeerId: () => "me",
|
||||
getCurrentTransport: () => "nostr",
|
||||
isRunning: () => true,
|
||||
selfMetadata: () => ({
|
||||
id: "me",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: Date.now(),
|
||||
transport: "nostr",
|
||||
}),
|
||||
sendDiscovery: async () => {},
|
||||
handlePeerLost: () => {},
|
||||
});
|
||||
|
||||
intervals.start();
|
||||
intervals.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createScheduler } from "@suaveplan/timing";
|
||||
import { TrysteroIntervals } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
// Custom scheduler factory — useful in tests.
|
||||
const intervals = new TrysteroIntervals(deps, (clock) =>
|
||||
createScheduler({ clock }),
|
||||
);
|
||||
|
||||
intervals.start();
|
||||
intervals.start(); // idempotent
|
||||
intervals.stop();
|
||||
intervals.stop(); // idempotent
|
||||
console.log(intervals.isStopped); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TrysteroIntervals(deps, schedulerFactory?)`.
|
||||
- `start(): void` — start every loop; idempotent.
|
||||
- `stop(): void` — stop every loop, dispose scheduler; idempotent.
|
||||
- `get isStopped(): boolean`.
|
||||
|
||||
`TrysteroIntervalDeps`:
|
||||
|
||||
- `clock: Clock`.
|
||||
- `announceIntervalMs / heartbeatIntervalMs / cleanupIntervalMs / timeoutMs: number`.
|
||||
- `peers: Map<string, TrysteroPeerMetadata>` — read-write reference to the
|
||||
manager's underlying map.
|
||||
- `getMyPeerId(): string | null`.
|
||||
- `getCurrentTransport(): TrysteroTransport`.
|
||||
- `isRunning(): boolean`.
|
||||
- `selfMetadata(): TrysteroPeerMetadata`.
|
||||
- `sendDiscovery(message): Promise<void>`.
|
||||
- `handlePeerLost(peerId): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The scheduler is disposed in `stop()`. Once disposed, no new tasks can
|
||||
be scheduled. The loop-self-rearm code defends against this race by
|
||||
checking `scheduler.disposed` before each `schedule()`.
|
||||
- `sendDiscovery` errors are intentionally swallowed. The orchestrator
|
||||
emits `trystero:transport-failed` on initialise failures; transient
|
||||
send failures on a single tick are not surfaced through the bus.
|
||||
- `runHeartbeat` and `runAnnounce` share a message shape; the difference
|
||||
is only the `type` literal. Keeping them as two separate methods keeps
|
||||
the test surface small.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
handleMessage,
|
||||
type TrysteroMessageHandlerDeps,
|
||||
} from "./trystero-message-handlers.js";
|
||||
@@ -0,0 +1,121 @@
|
||||
# Trystero Message Handlers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Pure dispatch function (`handleMessage`) routing every inbound
|
||||
`TrysteroMessage` to the appropriate handler based on `message.type`. Each
|
||||
handler closes over a `TrysteroMessageHandlerDeps` bag wired by the
|
||||
orchestrator at construction — keeping the dispatch surface free of any
|
||||
orchestrator imports avoids a circular-dependency edge between
|
||||
`trystero-discovery` and `trystero-message-handlers`.
|
||||
|
||||
The module owns the wire-format protocol contract: every supported message
|
||||
type (announce, discover, peer_list, leave, heartbeat, fallback) is handled
|
||||
exactly once, malformed payloads are coerced to `false` returns, and there
|
||||
are no side effects beyond the explicit callback invocations.
|
||||
|
||||
## Features
|
||||
|
||||
- Six message types (announce / discover / peer_list / leave / heartbeat /
|
||||
fallback) dispatched via a `switch` on a frozen literal union.
|
||||
- Pure handlers — every side effect goes through the `deps` callbacks.
|
||||
- Announces re-tag the peer's `transport` with the active transport when
|
||||
the inbound payload omits one (defensive default).
|
||||
- Discover requests are answered with the active peer-id set, sent
|
||||
unicast to the requester via `deps.sendDiscovery(..., target)`.
|
||||
- Heartbeats touch the sender's `lastSeen`. Leaves drop the sender.
|
||||
- Fallback notifications are observational — the receiving peer does not
|
||||
need to switch transports just because a remote did.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
handleMessage,
|
||||
TrysteroMessageType,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
await handleMessage(
|
||||
{
|
||||
type: TrysteroMessageType.ANNOUNCE,
|
||||
from: "p1",
|
||||
payload: {
|
||||
id: "p1",
|
||||
addresses: ["wss://x"],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
transport: "nostr",
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
handleMessage,
|
||||
TrysteroMessageType,
|
||||
type TrysteroMessageHandlerDeps,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const deps: TrysteroMessageHandlerDeps = {
|
||||
clock: () => Date.now(),
|
||||
getMyPeerId: () => "me",
|
||||
getCurrentTransport: () => "nostr",
|
||||
knownPeerIds: () => Array.from(peerMap.keys()),
|
||||
admitPeer: (r) => (peerMap.set(r.id, r), "added"),
|
||||
touchPeer: (id) => {
|
||||
const p = peerMap.get(id);
|
||||
if (p) p.lastSeen = Date.now();
|
||||
},
|
||||
onPeerLost: (id) => peerMap.delete(id),
|
||||
sendDiscovery: async (m, t) => transport.send(m, t),
|
||||
};
|
||||
|
||||
await handleMessage(
|
||||
{
|
||||
type: TrysteroMessageType.DISCOVER,
|
||||
from: "p2",
|
||||
payload: {},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `async handleMessage(message, deps): Promise<boolean>` — `false` for
|
||||
malformed / unknown messages.
|
||||
- `interface TrysteroMessageHandlerDeps` — callback bag:
|
||||
- `clock: Clock`.
|
||||
- `getMyPeerId(): string | null`.
|
||||
- `getCurrentTransport(): TrysteroTransport`.
|
||||
- `knownPeerIds(): readonly string[]`.
|
||||
- `admitPeer(record): "added" | "updated" | "rejected"`.
|
||||
- `touchPeer(peerId): void`.
|
||||
- `onPeerLost(peerId): void`.
|
||||
- `sendDiscovery(message, target?): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The handler is exhaustive over the frozen `TrysteroMessageType`
|
||||
registry. The `default` branch is `istanbul ignore`d because Zod
|
||||
validates the wire-format type at the orchestrator boundary before
|
||||
dispatch; the literal-union switch is provably exhaustive.
|
||||
- `handleDiscoverRequest` swallows send errors and returns `false`. The
|
||||
caller (the message-receive callback wired into the room) cannot do
|
||||
anything useful with the failure, so propagating would just crash the
|
||||
fire-and-forget receive loop.
|
||||
- `handleFallback` is structurally permissive (`payload.transport: string`)
|
||||
rather than strictly schema-validated because the fallback message is
|
||||
informational; rejecting on schema mismatch would do nothing useful.
|
||||
- The announce-path admits the peer even if `admitPeer` reports
|
||||
`"rejected"` (room full) — the return value lets the caller
|
||||
observe rejections without losing the message.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TrysteroPeerManager } from "./trystero-peer-manager.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
# Trystero Peer Manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TrysteroPeerManager` owns the deduped set of currently-discovered peers
|
||||
for a single `TrysteroDiscovery` instance. It is the single source of
|
||||
truth for the peer-list snapshot returned by `discover()`, mirrors every
|
||||
peer transition onto the optional `EventBusLike` (`trystero:peer-discovered`
|
||||
and `trystero:peer-lost`), and (when supplied) onto a
|
||||
`@suaveplan/service-discovery` `ServiceRegistry` so other parts of a
|
||||
running application can react to peers without coupling to the discovery
|
||||
strategy. The manager also enforces the `maxPeers` cap by throwing a
|
||||
`ROOM_FULL` `NetDiscTrysteroError` on insert.
|
||||
|
||||
Pulling all of this into a dedicated module keeps the orchestrator small
|
||||
(it never touches the peer-map directly) and ensures the cleanup loop has
|
||||
a stable Map handle it can iterate during staleness sweeps.
|
||||
|
||||
## Features
|
||||
|
||||
- `admit(metadata)` — insert or update. Throws `ROOM_FULL` when over cap on
|
||||
insert.
|
||||
- `touch(peerId)` — heartbeat refresh of `lastSeen`.
|
||||
- `drop(peerId)` — emits `trystero:peer-lost`, deregisters from registry.
|
||||
- `list()` / `get()` / `size()` / `getMap()`.
|
||||
- `clear()` — drops every peer (emitting `trystero:peer-lost` for each).
|
||||
- `retagTransport(transport)` — rewrites every entry's `transport` field
|
||||
when the strategy falls back to a different transport so consumers see a
|
||||
consistent view.
|
||||
- OTel: increments `peers_discovered_total{transport,outcome}` with
|
||||
outcome `"found" / "duplicate" / "rejected"`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TrysteroPeerManager } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const peers = new TrysteroPeerManager({
|
||||
clock: () => Date.now(),
|
||||
maxPeers: 100,
|
||||
appId: "demo",
|
||||
});
|
||||
|
||||
peers.admit({
|
||||
id: "p1",
|
||||
addresses: ["wss://x"],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
transport: "nostr",
|
||||
});
|
||||
console.log(peers.size()); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import {
|
||||
TrysteroPeerManager,
|
||||
type TrysteroEventMap,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const bus = new EventBus<TrysteroEventMap>();
|
||||
const registry = new ServiceRegistry();
|
||||
const peers = new TrysteroPeerManager({
|
||||
clock: () => Date.now(),
|
||||
bus,
|
||||
registry,
|
||||
maxPeers: 50,
|
||||
appId: "demo",
|
||||
});
|
||||
|
||||
bus.on("trystero:peer-discovered", (e) => console.log("found", e.peer.id));
|
||||
bus.on("trystero:peer-lost", (e) => console.log("lost", e.peerId));
|
||||
|
||||
peers.admit(peerMeta);
|
||||
peers.retagTransport("mqtt"); // after fallback torrent -> mqtt
|
||||
peers.drop("p1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TrysteroPeerManager({clock, bus?, registry?, maxPeers, appId})`.
|
||||
- `admit(metadata): "added" | "updated"` — throws `NetDiscTrysteroError`
|
||||
with code `ROOM_FULL` on insert when at cap.
|
||||
- `touch(peerId): void`.
|
||||
- `drop(peerId): boolean` — returns whether a peer was actually removed.
|
||||
- `list(): readonly TrysteroPeerMetadata[]`.
|
||||
- `get(peerId): TrysteroPeerMetadata | undefined`.
|
||||
- `size(): number`.
|
||||
- `getMap(): Map<string, TrysteroPeerMetadata>` — exposed so the cleanup
|
||||
interval can iterate without paying for an array snapshot every tick.
|
||||
- `clear(): void`.
|
||||
- `retagTransport(transport): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The service-registry id format is
|
||||
`trystero:<transport>:<appId>:<peerId>` so multiple `TrysteroDiscovery`
|
||||
instances against the same registry but with different `appId`s never
|
||||
collide.
|
||||
- `retagTransport` writes a new metadata object for every entry rather
|
||||
than mutating in place — the existing entries are exposed externally via
|
||||
`list()` and consumers may have captured prior references.
|
||||
- The OTel `outcome` label is one of three literals — never user-supplied
|
||||
— so dashboards can pivot on it safely.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Trystero Discovery — Structured Errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the closed registry of error codes emitted by
|
||||
`@suaveplan/network-discovery-trystero` and the `NetDiscTrysteroError`
|
||||
subclass of `SuaveplanError` that every public boundary throws on failure.
|
||||
Consumers branch on `error.code` — never on `error.message` — and the
|
||||
registry keeps the surface auditable per `@suaveplan/error` conventions.
|
||||
|
||||
The codes are split between **lifecycle / option violations** that surface
|
||||
at construction or via mid-flight invariant violations (`INVALID_OPTIONS`,
|
||||
`DISPOSED`, `NOT_RUNNING`, `ROOM_FULL`, `ALL_TRANSPORTS_DEGRADED`) and
|
||||
**transport-specific initialisation failures** keyed by which of the four
|
||||
Trystero transports went down (`TORRENT_TRACKER_FAILED`,
|
||||
`NOSTR_RELAY_FAILED`, `MQTT_BROKER_FAILED`, `IPFS_BOOTSTRAP_FAILED`). The
|
||||
specific codes let dashboards and alerting rules attribute failures to the
|
||||
correct transport without grepping `error.cause` chains.
|
||||
|
||||
## Features
|
||||
|
||||
- Codes registered via `defineCodes("NET_DISC_TRYSTERO", [...])` —
|
||||
monomorphic, namespaced, autocompletable.
|
||||
- `NetDiscTrysteroError` carries `code`, optional `cause`, and a
|
||||
message-resolver-friendly `messageKey`.
|
||||
- Stable wire compatibility with the rest of the
|
||||
`network-discovery-*` family — code keys follow the family convention.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NET_DISC_TRYSTERO_CODES,
|
||||
NetDiscTrysteroError,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
throw new NetDiscTrysteroError(
|
||||
NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED,
|
||||
"errors.trystero.nostr_init_failed",
|
||||
{ cause: new Error("ECONNREFUSED") },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NET_DISC_TRYSTERO_CODES,
|
||||
NetDiscTrysteroError,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
try {
|
||||
await strategy.start();
|
||||
} catch (err) {
|
||||
if (err instanceof NetDiscTrysteroError) {
|
||||
switch (err.code) {
|
||||
case NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS:
|
||||
return reportMisconfiguration(err);
|
||||
case NET_DISC_TRYSTERO_CODES.ALL_TRANSPORTS_DEGRADED:
|
||||
return surfaceDegradedBanner();
|
||||
case NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED:
|
||||
case NET_DISC_TRYSTERO_CODES.MQTT_BROKER_FAILED:
|
||||
case NET_DISC_TRYSTERO_CODES.IPFS_BOOTSTRAP_FAILED:
|
||||
case NET_DISC_TRYSTERO_CODES.TORRENT_TRACKER_FAILED:
|
||||
return logTransportOutage(err);
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const NET_DISC_TRYSTERO_CODES` — frozen registry containing exactly the
|
||||
10 codes listed above.
|
||||
- `type NetDiscTrysteroErrorCode` — string-literal union of the registered
|
||||
codes.
|
||||
- `class NetDiscTrysteroError extends SuaveplanError<NetDiscTrysteroErrorCode>`
|
||||
— constructor `(code, messageKey, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport-specific codes intentionally do not double as "running" /
|
||||
"degraded" status flags. A single transport failure does not necessarily
|
||||
mean the discovery strategy as a whole has failed — the fallback chain
|
||||
may recover. Only `ALL_TRANSPORTS_DEGRADED` signals terminal failure.
|
||||
- `RELAY_FAILED` is reserved as the generic transport-agnostic code; the
|
||||
per-transport codes are preferred when the failing transport is known.
|
||||
- All codes are surfaced through `withStartSpan` / `withTransportSpan`
|
||||
attributes so OTel back-ends can pivot on `code` without parsing logs.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,107 @@
|
||||
# Trystero Discovery — OpenTelemetry Surface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module owns every OpenTelemetry instrument emitted by
|
||||
`@suaveplan/network-discovery-trystero`. The surface is deliberately small:
|
||||
one parent span (`disc_trystero.start`), one transport-keyed connect span
|
||||
(`disc_trystero.transport.<id>.connect`), three counters / histograms keyed
|
||||
by transport so dashboards can pivot per-transport without parsing
|
||||
attributes, and explicit fallback tracking.
|
||||
|
||||
The shared `tracer` / `meter` instances are obtained once at module load via
|
||||
the OTel global provider. In a fresh process with no OTel SDK registered
|
||||
this collapses to the cheap no-op instruments, which is what the test suite
|
||||
exercises — the API is identical either way, so the same code paths cover
|
||||
both observability-on and observability-off runs.
|
||||
|
||||
## Features
|
||||
|
||||
- `withStartSpan(fn)` — wraps `TrysteroDiscovery.start()` in
|
||||
`disc_trystero.start`.
|
||||
- `withTransportSpan(transport, fn)` — wraps a transport-specific connect
|
||||
block in `disc_trystero.transport.<transport>.connect`.
|
||||
- `recordPeersDiscovered(transport, outcome, count?)` — counter
|
||||
`disc_trystero.peers_discovered_total{transport,outcome}`. Outcomes:
|
||||
`found` / `duplicate` / `rejected`.
|
||||
- `recordTransportFallback(from, to)` — counter
|
||||
`disc_trystero.transport_fallback_total{from,to}`.
|
||||
- `recordConnectDuration(transport, ms)` — histogram
|
||||
`disc_trystero.connect_duration_ms{transport}` (unit: `ms`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordPeersDiscovered,
|
||||
withStartSpan,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
await withStartSpan(async (span) => {
|
||||
span.setAttribute("transport", "nostr");
|
||||
// ... open the room ...
|
||||
recordPeersDiscovered("nostr", "found", 1);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordConnectDuration,
|
||||
recordTransportFallback,
|
||||
withTransportSpan,
|
||||
} from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
async function connect(transport: "torrent" | "nostr" | "mqtt" | "ipfs") {
|
||||
return withTransportSpan(transport, async () => {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await openRoom();
|
||||
} finally {
|
||||
recordConnectDuration(transport, performance.now() - t0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function failover() {
|
||||
try {
|
||||
await connect("nostr");
|
||||
} catch {
|
||||
recordTransportFallback("nostr", "mqtt");
|
||||
await connect("mqtt");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `withStartSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — opens
|
||||
`disc_trystero.start` and ends it on resolve/reject.
|
||||
- `withTransportSpan<T>(transport, fn): Promise<T>` — opens
|
||||
`disc_trystero.transport.<transport>.connect`, sets the `transport`
|
||||
attribute, ends on resolve/reject.
|
||||
- `recordPeersDiscovered(transport, outcome, count = 1): void` — outcome
|
||||
literal `"found" | "duplicate" | "rejected"`. Count `<= 0` is ignored.
|
||||
- `recordTransportFallback(from, to): void` — increment by one with the
|
||||
`from` and `to` labels.
|
||||
- `recordConnectDuration(transport, ms): void` — record the value clamped
|
||||
to `[0, +Infinity)`.
|
||||
- `type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Tracer and meter are intentionally module-level globals, fetched once
|
||||
via `trace.getTracer` / `metrics.getMeter`. Production setups that swap
|
||||
the provider after module-load will still see new instruments — the
|
||||
global proxy obtained at load time delegates to whichever provider is
|
||||
current at call time.
|
||||
- The counter dimensions (`transport`, `outcome`, `from`, `to`) are
|
||||
low-cardinality by design: four transports, three outcomes. Adding more
|
||||
outcomes (e.g. timing buckets) must reuse the histogram instead.
|
||||
- The histogram unit is `ms`. OTel back-ends should configure histogram
|
||||
bucket boundaries to match peer-discovery latency expectations
|
||||
(typically `[10, 50, 250, 1000, 5000, 30000]`).
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
# TrysteroPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TrysteroPlugin` is the `NetworkPlugin` that exposes
|
||||
`TrysteroNodeTransport` to a host `NetworkNode`. It mirrors the thin
|
||||
lifecycle wrappers shipped by the sibling network-transport packages
|
||||
(`@suaveplan/network-transport-webrtc`,
|
||||
`@suaveplan/network-transport-websockets`): `install` registers the
|
||||
transport with the node's `MessageRouter`, `start` is intentionally a
|
||||
no-op so the underlying Trystero room only opens lazily on the first
|
||||
`connect()` call, and `stop` closes the transport and unregisters it
|
||||
from the router.
|
||||
|
||||
Combined with `TrysteroDiscovery` from the same package, callers can
|
||||
build a fully-decentralised network node: discovery learns the peer
|
||||
set over Trystero rooms, and `TrysteroPlugin`'s transport carries the
|
||||
application data plane on a separate action channel inside the same
|
||||
Trystero infrastructure.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `NetworkPlugin` (`name`, `install`, `start`, `stop`,
|
||||
optional `dispose`)
|
||||
- Re-uses every `TrysteroNodeTransportConfig` field — `appId`,
|
||||
primary transport, per-transport config blocks, factories,
|
||||
password, ICE config, `maxPeers`
|
||||
- Idempotent `dispose()`
|
||||
- Safe `stop()` even when no node was installed
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { TrysteroPlugin } from "@suaveplan/network-discovery-trystero/plugin";
|
||||
|
||||
const node = new NetworkNode();
|
||||
node.use(
|
||||
new TrysteroPlugin({
|
||||
appId: "my-app",
|
||||
transport: "torrent",
|
||||
torrent: { trackers: ["wss://tracker.example"] },
|
||||
torrentRoomFactory,
|
||||
})
|
||||
);
|
||||
await node.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { TrysteroPlugin } from "@suaveplan/network-discovery-trystero/plugin";
|
||||
import { TrysteroDiscovery } from "@suaveplan/network-discovery-trystero";
|
||||
|
||||
const plugin = new TrysteroPlugin({
|
||||
appId: "my-app",
|
||||
transport: "nostr",
|
||||
nostr: { relays: ["wss://relay.example"] },
|
||||
nostrRoomFactory,
|
||||
maxPeers: 64,
|
||||
});
|
||||
|
||||
const node = new NetworkNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
|
||||
// Hand-off: discovery learns peer ids; the plugin's transport
|
||||
// delivers application envelopes to them.
|
||||
const discovery = new TrysteroDiscovery({
|
||||
appId: "my-app",
|
||||
transport: "nostr",
|
||||
nostr: { relays: ["wss://relay.example"] },
|
||||
nostrRoomFactory,
|
||||
});
|
||||
await discovery.start();
|
||||
// ... wire discovery events to plugin.transport.connect(peerId, roomPeerId)
|
||||
|
||||
await node.stop(); // closes the transport
|
||||
plugin.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class TrysteroPlugin implements NetworkPlugin`
|
||||
|
||||
#### Constructor
|
||||
|
||||
`new TrysteroPlugin(config: TrysteroNodeTransportConfig)` — same
|
||||
config object as the underlying transport.
|
||||
|
||||
#### Properties
|
||||
|
||||
- `name: "trystero"` — canonical plugin identifier.
|
||||
- `transport: TrysteroNodeTransport` — the wrapped transport instance
|
||||
for direct API access when needed.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `install(node: NetworkNode): void` — stores the node reference and
|
||||
calls `node.registerTransport(this.transport)`.
|
||||
- `start(): void` — no-op. The Trystero room opens lazily on the
|
||||
first `transport.connect()` call so plugin start is non-blocking
|
||||
and side-effect free.
|
||||
- `stop(): Promise<void>` — calls `transport.close()` then
|
||||
`node.unregisterTransport(this.transport.id)`. Safe to call even
|
||||
when `install()` was never called.
|
||||
- `dispose(): void` — idempotent; clears the node reference.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Lazy room init is a deliberate departure from "open on `start()`":
|
||||
Trystero rooms hold open WebSocket connections to relays/trackers,
|
||||
and a plugin used only as a fallback should not pay that cost
|
||||
until its transport is actually exercised.
|
||||
- The plugin does NOT install `TrysteroDiscovery` automatically.
|
||||
Discovery and transport share the same room infrastructure but
|
||||
remain independent surfaces so consumers can swap discovery
|
||||
strategies without losing the transport (e.g. discovery via
|
||||
PeerJS, data plane via Trystero, or vice versa).
|
||||
- `stop()` is the only place the underlying room is torn down. Once
|
||||
`transport.close()` runs the transport instance is permanently
|
||||
unusable; callers must construct a new plugin to reconnect.
|
||||
- `dispose()` does not call `stop()`. Callers MUST `await stop()`
|
||||
themselves to release the underlying Trystero room — disposing
|
||||
alone simply releases the node reference for GC.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# TrysteroNodeTransport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TrysteroNodeTransport` adapts the serverless Trystero WebRTC transport
|
||||
family (BitTorrent / Nostr / MQTT / IPFS) into the
|
||||
`@suaveplan/network` `Transport` contract. While the sibling
|
||||
`TrysteroDiscovery` strategy uses the same room infrastructure as a
|
||||
control plane to **find** peers, this transport carries the **data
|
||||
plane** — `MessageEnvelope` payloads addressed to specific peers on a
|
||||
dedicated `makeAction("envelope")` channel so discovery traffic and
|
||||
application data stay namespace-isolated on the wire.
|
||||
|
||||
It reuses the four existing room factories (`TorrentRoomFactory`,
|
||||
`NostrRoomFactory`, `MqttRoomFactory`, `IpfsRoomFactory`) so the same
|
||||
configuration material works in both surfaces — production callers
|
||||
bind the real `trystero/<transport>` `joinRoom` exports; tests bind
|
||||
the deterministic in-memory `FakeTrysteroBroker` / `FakeTrysteroRoom`
|
||||
pair.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the `Transport` interface (`connect`, `disconnect`,
|
||||
`send`, `onMessage`, `onPeerEvent`, `getConnectedPeers`, `close`)
|
||||
- Per-peer registry keyed by `peerId` with reverse-lookup by the
|
||||
underlying Trystero room peer id
|
||||
- Lazy room initialisation — the Trystero room opens on the first
|
||||
`connect()` call, not at construction time
|
||||
- Pre-aborted `AbortSignal` short-circuits `connect()` with `ABORTED`
|
||||
- Auto-prunes peers when their Trystero room peer leaves
|
||||
- Targeted send via Trystero's typed-action `send(envelope, target)`
|
||||
- Optional `maxPeers` cap surfaces as `ROOM_FULL`
|
||||
- Structured `NetDiscTrysteroError` at every boundary
|
||||
- Full OTel surface — `trystero_transport.{connect,disconnect,send}`
|
||||
spans, `trystero_transport.messages_total{direction,outcome}`
|
||||
counter, `trystero_transport.active_peers` gauge
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TrysteroNodeTransport } from "@suaveplan/network-discovery-trystero/transport";
|
||||
import { joinRoom } from "trystero/nostr";
|
||||
|
||||
const transport = new TrysteroNodeTransport({
|
||||
appId: "my-app",
|
||||
transport: "nostr",
|
||||
nostr: { relays: ["wss://relay.example"] },
|
||||
nostrRoomFactory: (appId, config) => joinRoom(config, appId),
|
||||
});
|
||||
|
||||
await transport.connect("peer-a", "trystero-room-peer-id-1");
|
||||
await transport.send("peer-a", envelope);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TrysteroNodeTransport } from "@suaveplan/network-discovery-trystero/transport";
|
||||
|
||||
const transport = new TrysteroNodeTransport({
|
||||
appId: "my-app",
|
||||
transport: "torrent",
|
||||
torrent: { trackers: ["wss://tracker.example"] },
|
||||
torrentRoomFactory,
|
||||
password: "shared-secret",
|
||||
rtcConfig: {
|
||||
iceServers: [{ urls: "stun:stun.example:3478" }],
|
||||
},
|
||||
maxPeers: 64,
|
||||
});
|
||||
|
||||
const offMessage = transport.onMessage((peerId, envelope) => {
|
||||
console.log("inbound", peerId, envelope.id);
|
||||
});
|
||||
const offEvent = transport.onPeerEvent((peerId, event) => {
|
||||
if (event === "disconnected") {
|
||||
// peer pruned automatically; clear app-level state
|
||||
}
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
try {
|
||||
await transport.connect("alice", "tr-peer-a", ac.signal);
|
||||
await transport.send("alice", envelope);
|
||||
} finally {
|
||||
offMessage();
|
||||
offEvent();
|
||||
await transport.close();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class TrysteroNodeTransport implements Transport`
|
||||
|
||||
#### Constructor
|
||||
|
||||
`new TrysteroNodeTransport(config: TrysteroNodeTransportConfig)`
|
||||
|
||||
Reads `appId`, `transport`, the matching per-transport config block,
|
||||
the matching room factory, and optional `password` / `relayPeerId` /
|
||||
`rtcConfig` / `maxPeers`.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `connect(peerId, address, signal?)` — registers peer; opens the
|
||||
underlying Trystero room on first call. Throws `ABORTED` on
|
||||
pre-aborted signal, `PEER_ALREADY_CONNECTED` on duplicate,
|
||||
`ROOM_FULL` past `maxPeers`, `DISPOSED` after `close()`,
|
||||
`INVALID_OPTIONS` on missing config/factory.
|
||||
- `disconnect(peerId)` — removes registry entry, emits `disconnected`;
|
||||
no-op on unknown peer.
|
||||
- `send(peerId, envelope)` — dispatches via Trystero's targeted
|
||||
send. Throws `PEER_UNKNOWN`, `DISPOSED`, `NOT_RUNNING`, or
|
||||
`RELAY_FAILED`.
|
||||
- `onMessage(handler)` / `onPeerEvent(handler)` — returns an
|
||||
unsubscribe function.
|
||||
- `getConnectedPeers()` — readonly snapshot.
|
||||
- `close()` — idempotent; emits `disconnected` for every remaining
|
||||
peer and tears down the room.
|
||||
- `isInitialized` — true once the Trystero room is open.
|
||||
- `getRoomPeers()` — diagnostic snapshot of room peer ids seen via
|
||||
`onPeerJoin`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport keeps the **data plane** on a separate Trystero
|
||||
action namespace (`"envelope"`) from the discovery namespace
|
||||
(`"discovery"`) used by `TrysteroDiscovery`. Two transports
|
||||
sharing the same room therefore do not cross-deliver each other's
|
||||
payloads.
|
||||
- Address semantics: the `address` argument to `connect()` is the
|
||||
underlying Trystero room peer id. Tests and integrations can use
|
||||
`TrysteroDiscovery`'s emitted peer metadata to map app-level
|
||||
`peerId` to Trystero peer id and then drive `connect()`.
|
||||
- Inbound envelopes from room peers that have not been explicitly
|
||||
registered via `connect()` are still delivered to `onMessage`
|
||||
handlers — the room peer id is passed through as the `PeerId`.
|
||||
Callers MAY choose to ignore unsolicited traffic at the
|
||||
application layer.
|
||||
- Failures to open the room map to the same transport-specific codes
|
||||
the discovery strategy uses (`TORRENT_TRACKER_FAILED`,
|
||||
`NOSTR_RELAY_FAILED`, etc.) so dashboards can pivot uniformly.
|
||||
- `close()` is idempotent and never throws — internal `room.leave()`
|
||||
failures are swallowed to keep teardown deterministic.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user