Initial Commit

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

View File

@@ -0,0 +1,114 @@
# @suaveplan/network-discovery-webtorrent - Browser-tier WebTorrent DiscoveryStrategy: WebTorrent-tracker-driven WebRTC peer discovery via p2pt. Tracker list REQUIRED — no public-tracker fallback. AbortSignal + EventBusLike + ServiceRegistry + OTel + Zod boundaries.
## Summary
Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains.
Wraps a [`p2pt`](https://github.com/subins2000/p2pt) instance —
WebTorrent-tracker-driven WebRTC peer discovery — and exposes a deduped,
capped peer set to the bootstrap pipeline.
<!-- begin:auto-docs -->
## Documentation tree
- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this package
- Plug-and-play `DiscoveryStrategy` slot for network bootstrap
chains. Works alongside `@suaveplan/network-discovery-peerjs`,
`@suaveplan/network-discovery-trystero`, and
`@suaveplan/network-discovery-crdt`.
- **Tracker list REQUIRED.** Upstream's hard-coded public-tracker fallback
was removed. Callers must supply their own tracker infrastructure.
- Injectable `p2ptFactory` — production wires the real `p2pt`; tests bind
a deterministic `P2PTLike` fake (no `vi.mock` ever used).
- Typed `EventBusLike<WebTorrentEventMap>` integration.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` mirroring.
- OTel surface: `disc_webtorrent.start` span;
`peers_discovered_total{outcome}`, `tracker_errors_total{tracker}`,
`tracker_announce_duration_ms` instruments.
- AbortSignal honoured on every async public method.
- 100% test + branch + function + line coverage.
## Basic example
```ts
import { WebTorrentDiscovery } from "@suaveplan/network-discovery-webtorrent";
const discovery = new WebTorrentDiscovery({
trackers: ["wss://my-tracker.example.com"],
p2ptFactory: async (trackers, announceId) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], announceId);
},
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Advanced example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
WebTorrentDiscovery,
type WebTorrentEventMap,
} from "@suaveplan/network-discovery-webtorrent";
const bus = new EventBus<WebTorrentEventMap>();
const registry = new ServiceRegistry();
bus.on("webtorrent:peer-discovered", ({ peer }) => {
console.log("peer:", peer.id, peer.addresses);
});
bus.on("webtorrent:tracker-error", ({ reason }) => {
console.warn("tracker:", reason);
});
const discovery = new WebTorrentDiscovery({
trackers: [
"wss://my-tracker-a.example.com",
"wss://my-tracker-b.example.com",
],
announceId: "my-app/v1",
maxPeers: 256,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API
See sibling co-located markdown:
- [`src/types/types.md`](./src/types/types.md)
- [`src/errors/errors.md`](./src/errors/errors.md)
- [`src/otel/otel.md`](./src/otel/otel.md)
- [`src/core/webtorrent-discovery/webtorrent-discovery.md`](./src/core/webtorrent-discovery/webtorrent-discovery.md)
## License
MIT

View File

@@ -0,0 +1,77 @@
{
"name": "@suaveplan/network-discovery-webtorrent",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "Browser-tier WebTorrent DiscoveryStrategy: WebTorrent-tracker-driven WebRTC peer discovery via p2pt. Tracker list REQUIRED — no public-tracker fallback. 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" }
},
"./webtorrent-discovery": {
"import": { "types": "./dist/core/webtorrent-discovery/webtorrent-discovery.d.ts", "default": "./dist/core/webtorrent-discovery/webtorrent-discovery.js" },
"require": { "types": "./dist/core/webtorrent-discovery/webtorrent-discovery.d.ts", "default": "./dist/core/webtorrent-discovery/webtorrent-discovery.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/service-discovery": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"p2pt": "catalog:network-discovery"
},
"peerDependenciesMeta": {
"p2pt": { "optional": true }
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

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

View File

@@ -0,0 +1,170 @@
# webtorrent-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebTorrentDiscovery` is the browser-tier `DiscoveryStrategy` for
`@suaveplan/network-bootstrap` chains. It wraps a single
[`p2pt`](https://github.com/subins2000/p2pt) instance — WebTorrent-tracker-
driven WebRTC peer discovery — and exposes a deduped, capped peer set to
the bootstrap pipeline. The strategy is purely an observer: peers join, the
strategy emits typed events and registers them with the optional
`ServiceRegistry`; peers drop, the strategy removes them.
The `trackers` list is REQUIRED and non-empty. Upstream's hard-coded
public-tracker fallback (`tracker.openwebtorrent.com`, `tracker.btorrent.xyz`,
`tracker.files.fm:7073`, `spacetradersapi-chatbox.herokuapp.com:443`) was
deliberately removed — callers must supply their own tracker infrastructure
or supply an explicit list. Missing or empty `trackers` raises
`NET_DISC_WEBTORRENT.INVALID_OPTIONS` at construction time, not at
`start()` time.
## Features
- Implements the `DiscoveryStrategy` contract (`name`, `discover`,
`isAvailable`) so it slots directly into `@suaveplan/network-bootstrap`
`DiscoveryChain` instances.
- Lifecycle: idempotent `start()` / `stop()` / `dispose()`.
- Injectable `p2ptFactory` — production wires the real `p2pt`; tests pass a
deterministic in-memory `P2PTLike` fake (no `vi.mock` ever used).
- Zod-validated configuration. Tracker list is non-optional and
`.min(1)`-enforced. The factory is enforced at runtime in addition to
schema-level acceptance.
- Typed `EventBusLike<WebTorrentEventMap>` integration: `started`,
`peer-discovered`, `peer-lost`, `tracker-error`, `stopped`.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` integration —
peers auto-register on join, auto-deregister on drop and on
`stop()`/`dispose()`.
- OTel instrumentation: `disc_webtorrent.start` span;
`peers_discovered_total{outcome}`, `tracker_errors_total{tracker}`,
`tracker_announce_duration_ms` instruments.
- `AbortSignal` honoured on every async public method.
- Peer cap (`maxPeers`, default `100`) enforced at admission.
- Local-peer admission counters: `found`, `duplicate`, `rejected`.
## Usage: Basic Example
```ts
import { WebTorrentDiscovery } from "@suaveplan/network-discovery-webtorrent";
const discovery = new WebTorrentDiscovery({
trackers: ["wss://my-tracker.example.com"],
p2ptFactory: async (trackers, announceId) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], announceId);
},
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Usage: Advanced Example
```ts
import { createEventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
WebTorrentDiscovery,
type WebTorrentEventMap,
} from "@suaveplan/network-discovery-webtorrent";
const bus = createEventBus<WebTorrentEventMap>();
const registry = new ServiceRegistry();
bus.on("webtorrent:peer-discovered", ({ peer }) => {
console.log("peer joined:", peer.id, peer.addresses);
});
bus.on("webtorrent:tracker-error", ({ reason }) => {
console.warn("tracker error:", reason);
});
const discovery = new WebTorrentDiscovery({
trackers: [
"wss://my-tracker-a.example.com",
"wss://my-tracker-b.example.com",
],
announceId: "my-app/v1",
maxPeers: 256,
timeout: 30_000,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
bus,
registry,
});
const ac = new AbortController();
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000, ac.signal);
for (const peer of peers) {
// service-discovery has already mirrored these registrations.
console.log(peer.peerId, peer.addresses);
}
await discovery.stop();
discovery.dispose();
```
## API Reference
- `new WebTorrentDiscovery(config, clock?)` — Zod-validates the config,
enforces a callable `p2ptFactory`, stores the resolved configuration.
Throws `NET_DISC_WEBTORRENT.INVALID_OPTIONS` on validation / missing
factory.
- `start(signal?)` — instantiates the underlying p2pt via the factory,
wires every event handler, calls `p2pt.start()`, emits `started`. Throws
`TRACKER_FAILED` (with the underlying error as `cause`) on factory or
start failure; throws `DISPOSED` if disposed; rethrows abort-error if
signalled.
- `stop(signal?)` — idempotent. Tears the p2pt instance down, deregisters
every peer from the registry, clears local state. Emits `stopped` when
the call wasn't aborted.
- `discover(timeoutMs, signal?)` — returns the current dedup'd peer
snapshot mapped into `network-bootstrap` `PeerInfo[]`. Honours signal
up-front.
- `isAvailable(signal?)` — `true` only while running, not disposed, and
the underlying transport has reported `trackerconnect`.
- `announce(peerInfo, signal?)` — registers the local descriptor with the
optional service registry. Throws `DISPOSED` / `NOT_RUNNING` when
appropriate.
- `getPeers()` / `getPeerCount()` / `isRunning()` /
`isTrackerConnected()` — synchronous introspection.
- `dispose()` — permanent teardown; subsequent lifecycle calls throw
`DISPOSED`.
## Implementation Notes
- The `peers` map keys on the p2pt-assigned id (preferring `peer.id`, then
`peer._id`). Anything else surfaces as `recordPeersDiscovered("rejected")`
but never bubbles an exception — the underlying transport runs its own
retries.
- The `maxPeers` cap is enforced at admission: once the map is full,
further `peerconnect` events emit `recordPeersDiscovered("rejected")`
and are dropped. The cap is intentionally simple — no LRU — because
WebTorrent rooms tend to grow monotonically and operators are expected
to set the cap to a sane upper bound for their tracker.
- `trackerconnect` flips `trackerConnected` to `true` exactly once, even
if the underlying p2pt instance re-fires the event during reconnects.
The first transition records the announce-duration histogram.
- The factory is `async`-tolerant so production code can dynamically
`import("p2pt")` (a Node-flavoured package) without forcing this
package itself to bundle it. Tests pass a synchronous in-memory fake.
- `AbortSignal` is honoured up-front on every public method; in
`start()`, after the factory resolves we re-check the signal so a
cancellation during the dynamic import doesn't leak the p2pt instance.
- The `ServiceRegistry` integration uses the canonical id form
`webtorrent:<announceId>:<peerId>` mirroring the `trystero` and `peerjs`
packages, so a single `ServiceRegistry` watcher can see peers from
every discovery family without colliding.
- Every catch around `instance.destroy()` swallows because real p2pt
instances can throw during teardown if a tracker is mid-flight — the
swallowed error is already covered by the structured `TRACKER_FAILED`
emission, so re-throwing would double-report.

View File

@@ -0,0 +1,741 @@
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 { FakeP2PT, makeFakeFactory } from "../../../tests/setup/fake-p2pt.js";
import {
NET_DISC_WEBTORRENT_CODES,
type NetDiscWebTorrentError,
} from "../../errors/errors.js";
import type {
WebTorrentDiscoveryConfig,
WebTorrentEventMap,
} from "../../types/types.js";
import { WebTorrentDiscovery } from "./webtorrent-discovery.js";
function baseConfig(
over: Partial<WebTorrentDiscoveryConfig> = {}
): WebTorrentDiscoveryConfig {
const { factory } = makeFakeFactory();
return {
trackers: ["wss://tracker-a.example"],
p2ptFactory: factory,
...over,
};
}
describe("WebTorrentDiscovery constructor — rejection paths", () => {
it("INVALID_OPTIONS on missing trackers field", { timeout: 5000 }, () => {
const { factory } = makeFakeFactory();
let caught: unknown;
try {
new WebTorrentDiscovery({
p2ptFactory: factory,
} as unknown as WebTorrentDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
it("INVALID_OPTIONS on empty trackers array", { timeout: 5000 }, () => {
const { factory } = makeFakeFactory();
let caught: unknown;
try {
new WebTorrentDiscovery({
trackers: [],
p2ptFactory: factory,
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
it("INVALID_OPTIONS when an entry is an empty string", {
timeout: 5000,
}, () => {
const { factory } = makeFakeFactory();
let caught: unknown;
try {
new WebTorrentDiscovery({
trackers: [""],
p2ptFactory: factory,
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
it("INVALID_OPTIONS when factory missing", { timeout: 5000 }, () => {
let caught: unknown;
try {
new WebTorrentDiscovery({
trackers: ["wss://tracker-a"],
} as unknown as WebTorrentDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
it("INVALID_OPTIONS when factory is not callable", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
new WebTorrentDiscovery({
trackers: ["wss://tracker-a"],
p2ptFactory: 42 as unknown as never,
} as unknown as WebTorrentDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
it("INVALID_OPTIONS on bad announceId", { timeout: 5000 }, () => {
const { factory } = makeFakeFactory();
let caught: unknown;
try {
new WebTorrentDiscovery({
trackers: ["wss://t"],
announceId: "",
p2ptFactory: factory,
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
});
describe("WebTorrentDiscovery lifecycle — happy path", () => {
it("start/stop is idempotent", { timeout: 5000 }, async () => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a", "wss://t-b"],
p2ptFactory: factory,
});
await d.start();
await d.start(); // second start is a no-op
expect(instances).toHaveLength(1);
expect(instances[0]!.started).toBe(true);
await d.stop();
await d.stop(); // second stop is a no-op
expect(instances[0]!.destroyed).toBe(true);
});
it("emits started + stopped on the bus", { timeout: 5000 }, async () => {
const bus = new EventBus<WebTorrentEventMap>();
const events: string[] = [];
bus.on("webtorrent:started", () => events.push("started"));
bus.on("webtorrent:stopped", () => events.push("stopped"));
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
bus,
});
await d.start();
await d.stop();
expect(events).toEqual(["started", "stopped"]);
});
it("isAvailable false until trackerconnect, true after", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
await d.start();
expect(await d.isAvailable()).toBe(false);
instances[0]!.emit("trackerconnect");
expect(await d.isAvailable()).toBe(true);
expect(d.isTrackerConnected()).toBe(true);
// Second trackerconnect is a no-op
instances[0]!.emit("trackerconnect");
expect(d.isTrackerConnected()).toBe(true);
await d.stop();
});
it("isRunning reflects state", { timeout: 5000 }, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
expect(d.isRunning()).toBe(false);
await d.start();
expect(d.isRunning()).toBe(true);
await d.stop();
expect(d.isRunning()).toBe(false);
});
});
describe("WebTorrentDiscovery peer lifecycle", () => {
it("emits peer-discovered on peerconnect; discover() returns mapped PeerInfo", {
timeout: 5000,
}, async () => {
const bus = new EventBus<WebTorrentEventMap>();
const found: string[] = [];
bus.on("webtorrent:peer-discovered", (e) => found.push(e.peer.id));
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
bus,
});
await d.start();
instances[0]!.emit("peerconnect", { id: "peer-1" });
instances[0]!.emit("peerconnect", { _id: "peer-2" });
expect(found).toEqual(["peer-1", "peer-2"]);
const peers = await d.discover(1_000);
expect(peers.map((p) => p.peerId).sort()).toEqual(["peer-1", "peer-2"]);
expect(peers[0]!.source).toBe("webtorrent");
expect(peers[0]!.addresses[0]).toMatch(/^\/webrtc\/p2p\//);
await d.stop();
});
it("deduplicates identical peer ids across concurrent peerconnects", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
await d.start();
instances[0]!.emit("peerconnect", { id: "peer-x" });
instances[0]!.emit("peerconnect", { id: "peer-x" });
instances[0]!.emit("peerconnect", { id: "peer-x" });
expect(d.getPeerCount()).toBe(1);
await d.stop();
});
it("rejects malformed peer objects without throwing", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
await d.start();
instances[0]!.emit("peerconnect", null);
instances[0]!.emit("peerconnect", undefined);
instances[0]!.emit("peerconnect", {});
instances[0]!.emit("peerconnect", { id: "" });
instances[0]!.emit("peerconnect", { id: 12 });
instances[0]!.emit("peerconnect", "literal-string");
expect(d.getPeerCount()).toBe(0);
await d.stop();
});
it("enforces maxPeers cap; further peerconnects are rejected", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
maxPeers: 2,
p2ptFactory: factory,
});
await d.start();
instances[0]!.emit("peerconnect", { id: "a" });
instances[0]!.emit("peerconnect", { id: "b" });
instances[0]!.emit("peerconnect", { id: "c" });
expect(d.getPeerCount()).toBe(2);
await d.stop();
});
it("peerclose drops the peer and emits peer-lost", {
timeout: 5000,
}, async () => {
const bus = new EventBus<WebTorrentEventMap>();
const lost: string[] = [];
bus.on("webtorrent:peer-lost", (e) => lost.push(e.peerId));
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
bus,
});
await d.start();
instances[0]!.emit("peerconnect", { id: "x" });
instances[0]!.emit("peerclose", { id: "x" });
instances[0]!.emit("peerclose", { id: "unknown" }); // no-op
instances[0]!.emit("peerclose", null); // malformed
expect(lost).toEqual(["x"]);
expect(d.getPeerCount()).toBe(0);
await d.stop();
});
it("msg event updates lastSeen on a known peer", {
timeout: 5000,
}, async () => {
let t = 1_000;
const clock = () => t;
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery(
baseConfig({ p2ptFactory: factory }),
clock
);
await d.start();
instances[0]!.emit("peerconnect", { id: "p" });
const before = d.getPeers()[0]!.lastSeen;
t = 5_000;
instances[0]!.emit("msg", { id: "p" }, { hello: "world" });
const after = d.getPeers()[0]!.lastSeen;
expect(after).toBeGreaterThan(before);
// msg on unknown peer is a no-op
instances[0]!.emit("msg", { id: "unknown" });
// malformed msg peer is a no-op
instances[0]!.emit("msg", null);
await d.stop();
});
it("peerconnect after stop() is ignored", { timeout: 5000 }, async () => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
await d.start();
const inst = instances[0]!;
await d.stop();
// Re-fire through the captured reference — orchestrator's
// `running` guard must drop the event.
inst.emit("peerconnect", { id: "late" });
expect(d.getPeerCount()).toBe(0);
});
});
describe("WebTorrentDiscovery tracker errors", () => {
it("trackerwarning emits webtorrent:tracker-error", {
timeout: 5000,
}, async () => {
const bus = new EventBus<WebTorrentEventMap>();
const reasons: string[] = [];
bus.on("webtorrent:tracker-error", (e) => reasons.push(e.reason));
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
bus,
});
await d.start();
instances[0]!.emit("trackerwarning", new Error("dns failure"));
instances[0]!.emit("trackerwarning", "raw string");
instances[0]!.emit("trackerwarning", 42);
expect(reasons).toEqual(["dns failure", "raw string", "42"]);
await d.stop();
});
it("factory rejection maps to TRACKER_FAILED", {
timeout: 5000,
}, async () => {
const cause = new Error("dynamic import failed");
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: () => {
throw cause;
},
});
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
});
});
it("p2pt.start() rejection maps to TRACKER_FAILED", {
timeout: 5000,
}, async () => {
const factory = (trackers: readonly string[], id: string) => {
const f = new FakeP2PT(trackers, id);
f.startError = new Error("ws upgrade rejected");
return f;
};
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
});
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
});
});
it("factory returning non-Error rejection still TRACKER_FAILED", {
timeout: 5000,
}, async () => {
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: () => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw "string literal rejection";
},
});
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
});
});
});
describe("WebTorrentDiscovery announce + discover + signal handling", () => {
it("announce throws NOT_RUNNING before start, DISPOSED after dispose", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
await expect(
d.announce({
peerId: "x",
addresses: [],
lastSeen: 0,
source: "test",
})
).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.NOT_RUNNING,
});
d.dispose();
await expect(
d.announce({
peerId: "x",
addresses: [],
lastSeen: 0,
source: "test",
})
).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.DISPOSED,
});
});
it("discover throws DISPOSED after dispose", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
d.dispose();
await expect(d.discover(1_000)).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.DISPOSED,
});
});
it("start throws DISPOSED after dispose", { timeout: 5000 }, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
d.dispose();
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.DISPOSED,
});
});
it("dispose is idempotent", { timeout: 5000 }, () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
d.dispose();
d.dispose();
expect(d.isRunning()).toBe(false);
});
it("start with already-aborted signal throws AbortError", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
const ac = new AbortController();
ac.abort();
await expect(d.start(ac.signal)).rejects.toMatchObject({
name: "AbortError",
});
});
it("aborted signal with non-Error reason throws synthetic AbortError", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
const ac = new AbortController();
ac.abort("string reason" as unknown as Error);
let caught: Error | undefined;
try {
await d.start(ac.signal);
} catch (e) {
caught = e as Error;
}
expect(caught).toBeDefined();
expect(caught?.name).toBe("AbortError");
expect(caught?.message).toBe("Aborted");
});
it("start signal aborted mid-factory tears down the instance", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const instances: FakeP2PT[] = [];
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: async (trackers, id) => {
// Abort while the factory is in flight.
ac.abort();
const inst = new FakeP2PT(trackers, id);
instances.push(inst);
return inst;
},
});
await expect(d.start(ac.signal)).rejects.toMatchObject({
name: "AbortError",
});
expect(instances[0]!.destroyed).toBe(true);
});
it("start signal aborted with custom Error reason", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
const reason = new Error("custom abort");
reason.name = "AbortError";
const ac = new AbortController();
ac.abort(reason);
await expect(d.start(ac.signal)).rejects.toBe(reason);
});
it("stop with aborted signal still tears the instance down", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
await d.start();
const ac = new AbortController();
ac.abort();
await d.stop(ac.signal);
expect(instances[0]!.destroyed).toBe(true);
});
it("discover / isAvailable / announce honour aborted signal", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
await d.start();
const ac = new AbortController();
ac.abort();
await expect(d.discover(1_000, ac.signal)).rejects.toMatchObject({
name: "AbortError",
});
await expect(d.isAvailable(ac.signal)).rejects.toMatchObject({
name: "AbortError",
});
await expect(
d.announce(
{
peerId: "x",
addresses: ["/webrtc/p2p/x"],
lastSeen: 0,
source: "test",
},
ac.signal
)
).rejects.toMatchObject({ name: "AbortError" });
await d.stop();
});
it("isAvailable false after dispose; signal honoured", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery(baseConfig({ p2ptFactory: factory }));
d.dispose();
expect(await d.isAvailable()).toBe(false);
});
});
describe("WebTorrentDiscovery ServiceRegistry integration", () => {
it("registers a peer on join and deregisters on drop", {
timeout: 5000,
}, async () => {
const registry = new ServiceRegistry();
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
announceId: "app/v1",
p2ptFactory: factory,
registry,
});
await d.start();
instances[0]!.emit("peerconnect", { id: "p-1" });
expect(registry.list().length).toBe(1);
const entry = registry.list()[0]!;
expect(entry.id).toBe("webtorrent:app/v1:p-1");
expect(entry.name).toBe("webtorrent-discovery");
instances[0]!.emit("peerclose", { id: "p-1" });
expect(registry.list().length).toBe(0);
await d.stop();
});
it("announce mirrors local peer into the registry", {
timeout: 5000,
}, async () => {
const registry = new ServiceRegistry();
const { factory } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
registry,
});
await d.start();
await d.announce({
peerId: "local",
addresses: ["/webrtc/p2p/local"],
lastSeen: 0,
source: "test",
});
expect(registry.list().some((s) => s.id.endsWith(":local"))).toBe(true);
// announce w/ no addresses still works (fallback to peerId).
await d.announce({
peerId: "local-2",
addresses: [],
lastSeen: 0,
source: "test",
});
expect(registry.list().some((s) => s.id.endsWith(":local-2"))).toBe(
true
);
await d.stop();
});
it("stop() deregisters every tracked peer", { timeout: 5000 }, async () => {
const registry = new ServiceRegistry();
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
registry,
});
await d.start();
instances[0]!.emit("peerconnect", { id: "p-1" });
instances[0]!.emit("peerconnect", { id: "p-2" });
expect(registry.list().length).toBe(2);
await d.stop();
expect(registry.list().length).toBe(0);
});
it("dispose() deregisters every tracked peer", {
timeout: 5000,
}, async () => {
const registry = new ServiceRegistry();
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
registry,
});
await d.start();
instances[0]!.emit("peerconnect", { id: "p-1" });
d.dispose();
expect(registry.list().length).toBe(0);
});
it("swallows registry.deregister() failures during teardown", {
timeout: 5000,
}, async () => {
const registry = new ServiceRegistry();
// Hostile registry: deregister always throws.
const proxy = new Proxy(registry, {
get(target, prop, recv) {
if (prop === "deregister") {
return () => {
throw new Error("registry down");
};
}
const v = Reflect.get(target, prop, recv);
return typeof v === "function" ? v.bind(target) : v;
},
});
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
registry: proxy as unknown as ServiceRegistry,
});
await d.start();
instances[0]!.emit("peerconnect", { id: "x" });
// peerclose triggers the hostile deregister; must not throw.
instances[0]!.emit("peerclose", { id: "x" });
// stop() also triggers it for any still-present entries.
instances[0]!.emit("peerconnect", { id: "y" });
await d.stop();
expect(true).toBe(true);
});
it("swallows p2pt.destroy() failures during teardown", {
timeout: 5000,
}, async () => {
const factory = (trackers: readonly string[], id: string) => {
const inst = new FakeP2PT(trackers, id);
inst.destroy = () => {
throw new Error("destroy failed");
};
return inst;
};
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
});
await d.start();
await d.stop();
expect(true).toBe(true);
});
it("swallows p2pt.destroy() failure after p2pt.start() rejection", {
timeout: 5000,
}, async () => {
const factory = (trackers: readonly string[], id: string) => {
const f = new FakeP2PT(trackers, id);
f.startError = new Error("start failed");
f.destroy = () => {
throw new Error("destroy failed");
};
return f;
};
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
p2ptFactory: factory,
});
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
});
});
});
describe("WebTorrentDiscovery fast-check property — peer dedup", () => {
it("duplicate peer ids never inflate the peer set", {
timeout: 5000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.string({ minLength: 1, maxLength: 8 }), {
minLength: 1,
maxLength: 50,
}),
async (ids) => {
const { factory, instances } = makeFakeFactory();
const d = new WebTorrentDiscovery({
trackers: ["wss://t-a"],
maxPeers: 1_000,
p2ptFactory: factory,
});
await d.start();
for (const id of ids) {
instances[0]!.emit("peerconnect", { id });
}
const unique = new Set(ids).size;
const ok = d.getPeerCount() === unique;
await d.stop();
return ok;
}
),
{ numRuns: 30 }
);
});
});

View File

@@ -0,0 +1,402 @@
import type { DiscoveryStrategy, PeerInfo } from "@suaveplan/network-bootstrap";
import type { Clock } from "@suaveplan/types";
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "../../errors/errors.js";
import {
recordPeersDiscovered,
recordTrackerAnnounceDuration,
recordTrackerError,
withStartSpan,
} from "../../otel/otel.js";
import type {
P2PTLike,
ResolvedWebTorrentDiscoveryConfig,
WebTorrentDiscoveryConfig,
WebTorrentPeerMetadata,
} from "../../types/types.js";
import { WebTorrentDiscoveryConfigSchema } from "../../types/types.schemas.js";
const defaultClock: Clock = () => Date.now();
/**
* Shape of the `peer` object emitted by the underlying p2pt transport. Real
* p2pt instances expose either `id` or `_id`; we accept both and prefer
* `id`.
*/
interface P2PTPeerObject {
readonly id?: string;
readonly _id?: string;
}
/**
* Browser-tier WebTorrent discovery strategy implementing the
* `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. Wraps a
* single p2pt instance (constructed via the injected factory) and exposes a
* deduped peer set to bootstrap chains.
*
* The `trackers` list is REQUIRED — there is no implicit public-tracker
* fallback. Construction throws `INVALID_OPTIONS` on missing/empty trackers.
*/
export class WebTorrentDiscovery implements DiscoveryStrategy {
public readonly name = "webtorrent";
public readonly config: ResolvedWebTorrentDiscoveryConfig;
private readonly clock: Clock;
private readonly peers = new Map<string, WebTorrentPeerMetadata>();
private p2pt: P2PTLike | null = null;
private running = false;
private disposed = false;
private trackerConnected = false;
public constructor(
rawConfig: WebTorrentDiscoveryConfig,
clock: Clock = defaultClock
) {
const parsed = WebTorrentDiscoveryConfigSchema.safeParse(rawConfig);
if (!parsed.success) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS,
"errors.webtorrent.invalid_options",
{ cause: parsed.error }
);
}
// Zod treats `unknown` as "any value accepted" — but a missing key
// would fail .strict(). Defend against an explicit `undefined` /
// non-callable factory at runtime.
if (typeof rawConfig.p2ptFactory !== "function") {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS,
"errors.webtorrent.factory_required"
);
}
this.clock = clock;
this.config = {
trackers: parsed.data.trackers,
announceId: parsed.data.announceId,
maxPeers: parsed.data.maxPeers,
timeout: parsed.data.timeout,
p2ptFactory: rawConfig.p2ptFactory,
bus: rawConfig.bus,
registry: rawConfig.registry,
};
}
public async start(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.DISPOSED,
"errors.webtorrent.disposed"
);
}
if (this.running) return;
await withStartSpan(async (span) => {
span.setAttribute("announce_id", this.config.announceId);
span.setAttribute("tracker_count", this.config.trackers.length);
const t0 = this.clock();
let instance: P2PTLike;
try {
instance = await this.config.p2ptFactory(
this.config.trackers,
this.config.announceId
);
} catch (err) {
this.emitTrackerError(undefined, asError(err).message);
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
"errors.webtorrent.tracker_failed",
{ cause: asError(err) }
);
}
if (signal?.aborted) {
instance.destroy();
throw toAbortError(signal);
}
this.p2pt = instance;
this.wireEvents(instance, t0);
try {
await instance.start();
} catch (err) {
this.p2pt = null;
try {
instance.destroy();
} catch {
/* swallow */
}
this.emitTrackerError(undefined, asError(err).message);
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
"errors.webtorrent.tracker_failed",
{ cause: asError(err) }
);
}
this.running = true;
this.config.bus?.emit("webtorrent:started", {
announceId: this.config.announceId,
trackerCount: this.config.trackers.length,
at: this.clock(),
});
});
}
public async stop(signal?: AbortSignal): Promise<void> {
if (!this.running) return;
this.running = false;
this.trackerConnected = false;
if (signal?.aborted) {
this.teardown();
return;
}
this.teardown();
this.config.bus?.emit("webtorrent:stopped", { at: this.clock() });
}
public async discover(
_timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.DISPOSED,
"errors.webtorrent.disposed"
);
}
const out: PeerInfo[] = [];
for (const m of this.peers.values()) {
out.push({
peerId: m.id,
addresses: [...m.addresses],
lastSeen: m.lastSeen,
source: this.name,
});
}
return out;
}
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) throw toAbortError(signal);
return this.running && !this.disposed && this.trackerConnected;
}
public async announce(
peerInfo: PeerInfo,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.DISPOSED,
"errors.webtorrent.disposed"
);
}
if (!this.running) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.NOT_RUNNING,
"errors.webtorrent.not_running"
);
}
// p2pt has no first-class "announce metadata" channel; bootstrap
// chains use this to surface a local descriptor. We mirror it into
// the service registry so `service-discovery` consumers can see
// the local peer alongside the discovered ones.
this.config.registry?.register({
id: this.registryId(peerInfo.peerId),
name: "webtorrent-discovery",
address: peerInfo.addresses[0] ?? peerInfo.peerId,
metadata: {
source: this.name,
announceId: this.config.announceId,
addresses: [...peerInfo.addresses],
},
});
}
/** Local peer count. */
public getPeerCount(): number {
return this.peers.size;
}
/** Snapshot of the live peer set. */
public getPeers(): readonly WebTorrentPeerMetadata[] {
return Array.from(this.peers.values());
}
/** Whether `start()` has completed and `stop()`/`dispose()` have not. */
public isRunning(): boolean {
return this.running;
}
/** Whether the underlying transport has reported `trackerconnect`. */
public isTrackerConnected(): boolean {
return this.trackerConnected;
}
/** Dispose every resource. After `dispose()` the strategy is unusable. */
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.running = false;
this.trackerConnected = false;
this.teardown();
}
private wireEvents(instance: P2PTLike, t0: number): void {
instance.on("peerconnect", (peer: unknown) => {
this.handlePeerConnect(peer);
});
instance.on("peerclose", (peer: unknown) => {
this.handlePeerDisconnect(peer);
});
instance.on("msg", (peer: unknown) => {
this.handlePeerMessage(peer);
});
instance.on("trackerconnect", () => {
if (!this.trackerConnected) {
this.trackerConnected = true;
recordTrackerAnnounceDuration(this.clock() - t0);
}
});
instance.on("trackerwarning", (err: unknown) => {
const reason = asError(err).message;
recordTrackerError(undefined);
this.emitTrackerError(undefined, reason);
});
}
private handlePeerConnect(peer: unknown): void {
if (!this.running) return;
const peerId = extractPeerId(peer);
if (peerId === null) {
recordPeersDiscovered("rejected");
return;
}
if (this.peers.has(peerId)) {
recordPeersDiscovered("duplicate");
return;
}
if (this.peers.size >= this.config.maxPeers) {
recordPeersDiscovered("rejected");
return;
}
const now = this.clock();
const metadata: WebTorrentPeerMetadata = {
id: peerId,
addresses: [`/webrtc/p2p/${peerId}`],
protocols: ["webtorrent", "webrtc"],
metadata: {
connectionType: "webrtc",
transport: "webtorrent",
},
connectedAt: now,
lastSeen: now,
};
this.peers.set(peerId, metadata);
recordPeersDiscovered("found");
this.config.bus?.emit("webtorrent:peer-discovered", {
peer: metadata,
at: now,
});
this.config.registry?.register({
id: this.registryId(peerId),
name: "webtorrent-discovery",
address: metadata.addresses[0] as string,
metadata: {
source: this.name,
announceId: this.config.announceId,
addresses: [...metadata.addresses],
},
});
}
private handlePeerDisconnect(peer: unknown): void {
const peerId = extractPeerId(peer);
if (peerId === null) return;
if (!this.peers.delete(peerId)) return;
this.config.bus?.emit("webtorrent:peer-lost", {
peerId,
at: this.clock(),
});
try {
this.config.registry?.deregister(this.registryId(peerId));
} catch {
/* swallow — registry may not have it */
}
}
private handlePeerMessage(peer: unknown): void {
const peerId = extractPeerId(peer);
if (peerId === null) return;
const existing = this.peers.get(peerId);
if (existing !== undefined) {
existing.lastSeen = this.clock();
}
}
private emitTrackerError(
tracker: string | undefined,
reason: string
): void {
this.config.bus?.emit("webtorrent:tracker-error", {
tracker,
reason,
at: this.clock(),
});
}
private teardown(): void {
const instance = this.p2pt;
this.p2pt = null;
if (instance !== null) {
try {
instance.destroy();
} catch {
/* swallow */
}
}
// Deregister every local registry entry before clearing peer state.
if (this.config.registry !== undefined) {
for (const id of this.peers.keys()) {
try {
this.config.registry.deregister(this.registryId(id));
} catch {
/* swallow */
}
}
}
this.peers.clear();
}
private registryId(peerId: string): string {
return `webtorrent:${this.config.announceId}:${peerId}`;
}
}
function extractPeerId(peer: unknown): string | null {
if (peer === null || typeof peer !== "object") return null;
const obj = peer as P2PTPeerObject;
const id = obj.id ?? obj._id;
if (typeof id !== "string" || id.length === 0) return null;
return id;
}
function asError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : String(value));
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1,107 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured-error registry for
`@suaveplan/network-discovery-webtorrent`. Every public boundary in the
package surfaces failures as a `NetDiscWebTorrentError` whose `code` field
is drawn from the frozen `NET_DISC_WEBTORRENT_CODES` registry built via
`@suaveplan/error`'s `defineCodes` helper. Consumers branch on `error.code`
without parsing message strings.
## Features
- Frozen `NET_DISC_WEBTORRENT_CODES` registry — five stable codes covering
every failure mode the strategy can hit.
- `NetDiscWebTorrentError` class extending `SuaveplanError`, carrying a
typed `code`, a stable message-key, and an optional `cause` chain for
upstream provenance.
- Round-trippable with the rest of the error ecosystem — wire
serialization, redaction, and the OTel span-event adapter all work
out-of-the-box.
## Usage: Basic Example
```ts
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "@suaveplan/network-discovery-webtorrent";
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS,
"errors.webtorrent.invalid_options"
);
```
## Usage: Advanced Example
```ts
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "@suaveplan/network-discovery-webtorrent";
try {
await discovery.start();
} catch (err) {
if (
err instanceof NetDiscWebTorrentError &&
err.code === NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED
) {
// Fall back to another DiscoveryStrategy in the chain.
console.warn("webtorrent trackers unreachable:", err.cause);
} else {
throw err;
}
}
```
## API Reference
- `NET_DISC_WEBTORRENT_CODES` — frozen object whose keys are:
- `INVALID_OPTIONS` — caller-supplied config failed Zod validation, the
`trackers` list was empty, or the required `p2ptFactory` was missing.
- `DISPOSED` — operation attempted after `dispose()` cleared state.
- `NOT_RUNNING` — operation attempted before `start()` completed or after
`stop()` cleared state.
- `TRACKER_FAILED` — the underlying transport's tracker stack failed and
no peers can be discovered.
- `PEER_CONNECT_FAILED` — a peer-connect event was received but the
record could not be admitted.
- `TRANSPORT_CLOSED` — transport-level: a `send`/`connect` was attempted
after `close()`.
- `PEER_ALREADY_CONNECTED` — transport-level: `connect(peerId)` invoked
twice for the same id.
- `PEER_UNKNOWN` — transport-level: `send` invoked for an unknown peer.
- `MAX_PEERS_EXCEEDED` — transport-level: per-peer cap reached on
`connect`.
- `SEND_FAILED` — transport-level: the underlying `p2pt.send` rejected.
- `ABORTED` — transport-level: pre-aborted `AbortSignal` observed on
`connect`.
- `CONNECT_TIMEOUT` — transport-level: peer never showed up before the
configured connect timeout.
- `SEND_UNSUPPORTED` — transport-level: the supplied `p2pt` instance
omits the `send` capability.
- `NetDiscWebTorrentError extends SuaveplanError<NetDiscWebTorrentErrorCode>`
— constructor `(code, messageKey, options?)` accepting an optional
`{ cause }` for chained errors.
## Implementation Notes
- Codes are registered with `@suaveplan/error`'s `defineCodes` so they
participate in the global stable-code namespace; duplicate registrations
fail at import time rather than silently overlapping.
- `messageKey` is a translation key, not human-facing copy — i18n bundles
resolve these via `@suaveplan/i18n` when running in apps that wire the
resolver.
- The `cause` chain is preserved across `JSON.stringify` via
`SuaveplanError.toJSON`; consumers can serialise discovery failures to
structured logs without losing the original exception.
- The registry is intentionally small. `TRACKER_FAILED` covers every
transport-level failure (we have only one transport here); `INVALID_OPTIONS`
is the umbrella for every static configuration error including the
missing-tracker-list case.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_DISC_WEBTORRENT_CODES, NetDiscWebTorrentError } from "./errors.js";
describe("NetDiscWebTorrentError", () => {
it("registers exactly the documented codes", { timeout: 5000 }, () => {
expect(Object.keys(NET_DISC_WEBTORRENT_CODES).sort()).toEqual(
[
"ABORTED",
"CONNECT_TIMEOUT",
"DISPOSED",
"INVALID_OPTIONS",
"MAX_PEERS_EXCEEDED",
"NOT_RUNNING",
"PEER_ALREADY_CONNECTED",
"PEER_CONNECT_FAILED",
"PEER_UNKNOWN",
"SEND_FAILED",
"SEND_UNSUPPORTED",
"TRACKER_FAILED",
"TRANSPORT_CLOSED",
].sort()
);
});
it("preserves code, name, and cause", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
"errors.webtorrent.tracker_failed",
{ cause }
);
expect(err.code).toBe(NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED);
expect(err.name).toBe("NetDiscWebTorrentError");
expect(err.cause).toBe(cause);
expect(err instanceof NetDiscWebTorrentError).toBe(true);
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS,
"errors.webtorrent.invalid_options"
);
expect(err.cause).toBeUndefined();
expect(err.code).toBe(NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS);
});
});

View File

@@ -0,0 +1,66 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-discovery-webtorrent`.
*
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation, the
* `trackers` list was missing or empty, or the required `p2ptFactory` was
* missing.
* - `DISPOSED` — operation attempted after `dispose()` / `stop()` cleared
* state permanently.
* - `NOT_RUNNING` — operation attempted before `start()` completed (or
* after `stop()` cleared state).
* - `TRACKER_FAILED` — every tracker in the configured list raised a
* `trackerwarning` and no peer was ever discovered, or the underlying
* `p2pt` instance reported an unrecoverable tracker failure during
* `start()`.
* - `PEER_CONNECT_FAILED` — the underlying transport emitted a peer-connect
* event but the peer record could not be admitted (e.g. malformed peer
* object or admission throws).
*/
export const NET_DISC_WEBTORRENT_CODES = defineCodes("NET_DISC_WEBTORRENT", [
"INVALID_OPTIONS",
"DISPOSED",
"NOT_RUNNING",
"TRACKER_FAILED",
"PEER_CONNECT_FAILED",
/** Transport-level: a `send`/`connect` was attempted after `close()`. */
"TRANSPORT_CLOSED",
/** Transport-level: `connect(peerId)` invoked twice for the same id. */
"PEER_ALREADY_CONNECTED",
/** Transport-level: `send` invoked for an unknown peer. */
"PEER_UNKNOWN",
/** Transport-level: per-peer cap reached on `connect`. */
"MAX_PEERS_EXCEEDED",
/** Transport-level: underlying `p2pt.send` rejected. */
"SEND_FAILED",
/** Transport-level: pre-aborted signal observed on `connect`. */
"ABORTED",
/** Transport-level: peer never showed up before the connect timeout. */
"CONNECT_TIMEOUT",
/** Transport-level: underlying `p2pt` instance has no `send` capability. */
"SEND_UNSUPPORTED",
] as const);
export type NetDiscWebTorrentErrorCode =
(typeof NET_DISC_WEBTORRENT_CODES)[keyof typeof NET_DISC_WEBTORRENT_CODES];
/**
* Structured error class for the WebTorrent discovery strategy. Every
* public boundary throws this on failure; consumers branch on
* `error.code`.
*/
export class NetDiscWebTorrentError extends SuaveplanError<NetDiscWebTorrentErrorCode> {
public constructor(
code: NetDiscWebTorrentErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetDiscWebTorrentError";
}
}

View File

@@ -0,0 +1,46 @@
// Core
export { WebTorrentDiscovery } from "./core/webtorrent-discovery/webtorrent-discovery.js";
// Errors
export {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
type NetDiscWebTorrentErrorCode,
} from "./errors/errors.js";
// OTel
export {
adjustTransportActivePeers,
type PeerDiscoveryOutcome,
recordPeersDiscovered,
recordTrackerAnnounceDuration,
recordTrackerError,
recordTransportMessage,
withStartSpan,
withTransportSpan,
} from "./otel/otel.js";
// Plugin
export { WebTorrentPlugin } from "./plugin/plugin.js";
// Transport (Wave 3.4 — folded from network-transport-webtorrent)
export { WebTorrentTransport } from "./transport/transport.js";
// Types
export type {
P2PTFactory,
P2PTLike,
ResolvedWebTorrentDiscoveryConfig,
WebTorrentDiscoveredEvent,
WebTorrentDiscoveryConfig,
WebTorrentEventMap,
WebTorrentLostEvent,
WebTorrentPeerMetadata,
WebTorrentStartedEvent,
WebTorrentStoppedEvent,
WebTorrentTrackerErrorEvent,
WebTorrentTransportConfig,
} from "./types/types.js";
// Schemas
export {
WebTorrentDiscoveryConfigSchema,
WebTorrentPeerMetadataSchema,
WebTorrentTransportConfigSchema,
} from "./types/types.schemas.js";

View File

@@ -0,0 +1,105 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation surface for the WebTorrent discovery
strategy. Provides one span helper for the `start()` lifecycle, one counter
for discovered peers (labelled by outcome), one counter for tracker errors
(labelled by tracker URL), and one histogram measuring tracker-announce
wall-clock latency.
All instruments resolve against `@opentelemetry/api` only — no SDK is
required for the package to load or run. When the host application wires an
SDK, every instrument records into the same pipelines as the rest of the
suaveplan observability stack.
## Features
- `withStartSpan(fn)` — wraps the `start()` body in a
`disc_webtorrent.start` span; surfaces errors through the span's standard
`recordException` path.
- `recordPeersDiscovered(outcome, count?)` — increments
`disc_webtorrent.peers_discovered_total{outcome}`. Outcomes are
`"found" | "duplicate" | "rejected"`. Negative or zero counts are
silently ignored.
- `recordTrackerError(tracker)` — increments
`disc_webtorrent.tracker_errors_total{tracker}`. Missing tracker labels
fall back to `"unknown"` to keep cardinality bounded.
- `recordTrackerAnnounceDuration(ms)` — records into
`disc_webtorrent.tracker_announce_duration_ms`. Negative values are
clamped to zero.
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
withStartSpan,
} from "@suaveplan/network-discovery-webtorrent";
await withStartSpan(async (span) => {
span.setAttribute("app_id", "my-app/v1");
recordPeersDiscovered("found", 1);
});
```
## Usage: Advanced Example
```ts
import {
recordPeersDiscovered,
recordTrackerAnnounceDuration,
recordTrackerError,
withStartSpan,
} from "@suaveplan/network-discovery-webtorrent";
await withStartSpan(async (span) => {
span.setAttribute("tracker_count", 3);
const t0 = Date.now();
try {
await connectToTrackers();
recordTrackerAnnounceDuration(Date.now() - t0);
} catch (err) {
recordTrackerError("wss://tracker.example.com");
throw err;
}
// Inside event loop:
recordPeersDiscovered("found", 1);
recordPeersDiscovered("duplicate", 1);
});
```
## API Reference
- `withStartSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — runs
`fn` inside an active `disc_webtorrent.start` span, ending the span on
resolution and on failure.
- `PeerDiscoveryOutcome = "found" | "duplicate" | "rejected"` — outcome
label union for the peers-discovered counter.
- `recordPeersDiscovered(outcome: PeerDiscoveryOutcome, count?: number)` —
increments the discovered-peers counter (default count `1`; non-positive
counts are no-ops).
- `recordTrackerError(tracker: string | undefined)` — increments the
tracker-error counter; an `undefined` label is normalised to `"unknown"`.
- `recordTrackerAnnounceDuration(ms: number)` — records milliseconds into
the announce-duration histogram, clamping negative values to zero.
## Implementation Notes
- Instrument names are namespaced under `disc_webtorrent.*` so dashboards
can filter the entire WebTorrent discovery family without parsing
attribute values.
- Label cardinality is intentionally bounded: outcomes are a closed
three-way union, and unknown tracker labels collapse to `"unknown"` so
the counter cannot explode under adversarial input.
- The tracker-announce histogram measures from `start()` entry to the
underlying transport's first `trackerconnect` event, not to the first
peer. The two latencies are deliberately separated so dashboards can
distinguish slow trackers from slow peers.
- The span helper is a thin pass-through over `tracer.startActiveSpan`
with mandatory `span.end()` in a `finally` block. Errors propagate
unchanged so the caller's catch-block sees the original exception.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
adjustTransportActivePeers,
recordPeersDiscovered,
recordTrackerAnnounceDuration,
recordTrackerError,
recordTransportMessage,
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("counters and histograms accept calls with no SDK", {
timeout: 5000,
}, () => {
recordPeersDiscovered("found", 3);
recordPeersDiscovered("duplicate");
recordPeersDiscovered("rejected", 0);
recordPeersDiscovered("rejected", -5);
recordTrackerError("wss://tracker.example");
recordTrackerError(undefined);
recordTrackerAnnounceDuration(125);
recordTrackerAnnounceDuration(-1);
expect(true).toBe(true);
});
it("transport-level instruments accept calls with no SDK", {
timeout: 5000,
}, () => {
recordTransportMessage("inbound", "ok");
recordTransportMessage("outbound", "error");
adjustTransportActivePeers(+1);
adjustTransportActivePeers(-1);
expect(true).toBe(true);
});
it("withTransportSpan resolves and propagates errors", {
timeout: 5000,
}, async () => {
await expect(
withTransportSpan("webtorrent_transport.connect", async () => 7)
).resolves.toBe(7);
await expect(
withTransportSpan("webtorrent_transport.send", async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
});

View File

@@ -0,0 +1,137 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
const TRACER_NAME = "@suaveplan/network-discovery-webtorrent";
const METER_NAME = "@suaveplan/network-discovery-webtorrent";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented for every peer the strategy emits, labelled by the
* outcome of the discovery attempt.
*/
const peersDiscoveredCounter = meter.createCounter(
"disc_webtorrent.peers_discovered_total",
{
description:
"Total peers seen by the WebTorrent discovery strategy, by outcome.",
}
);
/**
* Counter incremented for every tracker-level warning surfaced by the
* underlying p2pt transport, labelled by tracker URL when known.
*/
const trackerErrorsCounter = meter.createCounter(
"disc_webtorrent.tracker_errors_total",
{
description:
"Number of tracker warnings / failures emitted by the underlying p2pt instance.",
}
);
/**
* Histogram of the wall-clock duration (ms) between `start()` being called
* and the underlying transport reporting `trackerconnect` (i.e. successful
* tracker announce).
*/
const trackerAnnounceDurationHistogram = meter.createHistogram(
"disc_webtorrent.tracker_announce_duration_ms",
{
description:
"Wall-clock duration between `start()` and the first successful tracker announce, 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_webtorrent.start", async (span) => {
try {
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(
outcome: PeerDiscoveryOutcome,
count = 1
): void {
if (count > 0) {
peersDiscoveredCounter.add(count, { outcome });
}
}
/** Increment the tracker-errors counter. */
export function recordTrackerError(tracker: string | undefined): void {
trackerErrorsCounter.add(1, { tracker: tracker ?? "unknown" });
}
/** Record the wall-clock duration of a successful tracker announce. */
export function recordTrackerAnnounceDuration(ms: number): void {
trackerAnnounceDurationHistogram.record(Math.max(0, ms));
}
// ---------------------------------------------------------------------------
// Transport-level instruments (consumed by `WebTorrentTransport`)
// ---------------------------------------------------------------------------
const transportMessagesCounter = meter.createCounter(
"webtorrent_transport.messages_total",
{
description:
"Total messages sent/received on WebTorrentTransport, by direction and outcome.",
}
);
const transportActivePeersGauge = meter.createUpDownCounter(
"webtorrent_transport.active_peers",
{
description:
"Current number of connected peers on WebTorrentTransport.",
}
);
/**
* Wrap a transport-level async operation in an OTel span. Spans are named
* `webtorrent_transport.connect` / `.disconnect` / `.send`.
*/
export async function withTransportSpan<T>(
name: string,
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(name, async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/**
* Increment the transport messages counter.
*/
export function recordTransportMessage(
direction: "inbound" | "outbound",
outcome: "ok" | "error"
): void {
transportMessagesCounter.add(1, { direction, outcome });
}
/**
* Adjust the active-peers gauge on the transport.
*/
export function adjustTransportActivePeers(delta: number): void {
transportActivePeersGauge.add(delta);
}

View File

@@ -0,0 +1,105 @@
# plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebTorrentPlugin` is the `NetworkPlugin` adapter that exposes a
`WebTorrentTransport` to a host `NetworkNode` from `@suaveplan/network`.
It is a thin lifecycle wrapper — the heavy lifting (tracker
rendezvous, peer registry, message framing, error mapping, OTel) lives
inside the underlying `WebTorrentTransport` — so the plugin keeps the
shape callers already learned from the `WebSocketPlugin` and
`WebRTCPlugin` siblings.
This package is the "fold" Wave-3.4 addition to the existing
`@suaveplan/network-discovery-webtorrent` package: both the
`DiscoveryStrategy` and the `Transport` family of plugins live under the
same package umbrella, mirroring the upstream layout while preserving a
clean subpath surface (`./transport`, `./plugin`).
## Features
- Stable `name: "webtorrent"` plugin identifier — discovery and routing
plumbing key off this value.
- `install(node)` registers the underlying transport with the node's
`MessageRouter` so routed envelopes can find it.
- `start()` is intentionally a no-op — the p2pt tracker socket is opened
lazily inside the transport on the first `connect()`.
- `stop()` closes the transport (rejecting pending connects, draining
peer state, destroying the p2pt instance) and unregisters it from the
node.
- `dispose()` is idempotent.
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { WebTorrentPlugin } from "@suaveplan/network-discovery-webtorrent/plugin";
import P2PT from "p2pt";
const node = new NetworkNode();
const plugin = new WebTorrentPlugin({
trackers: ["wss://tracker.openwebtorrent.com"],
p2ptFactory: (trackers, id) => new P2PT(trackers as string[], id),
});
node.use(plugin);
await node.start();
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { WebTorrentPlugin } from "@suaveplan/network-discovery-webtorrent/plugin";
async function makeNode(trackers: string[]) {
const node = new NetworkNode();
const plugin = new WebTorrentPlugin({
trackers,
announceId: "demo-room",
maxPeers: 16,
connectTimeoutMs: 8_000,
p2ptFactory: async (t, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(t as string[], id);
},
});
node.use(plugin);
await node.start();
// Routing through the node now selects the webtorrent transport
// whenever an envelope's target peer is reachable through it.
return { node, plugin };
}
```
## API Reference
- `class WebTorrentPlugin implements NetworkPlugin`
- `readonly name: "webtorrent"`
- `readonly transport: WebTorrentTransport` — exposed for tests and
advanced wiring; consumers should drive sends through the host
`NetworkNode` rather than this field.
- `constructor(config: WebTorrentTransportConfig)` — throws
`INVALID_OPTIONS` on bad config.
- `install(node: NetworkNode): void` — registers the transport.
- `start(): void` — no-op.
- `stop(): Promise<void>` — closes the transport and unregisters it
from the node. Safe to call when no node was installed.
- `dispose(): void` — idempotent; releases the node reference.
## Implementation Notes
- `start()` is intentionally non-async — there is no remote rendezvous
to perform up-front. p2pt is created on the first `connect()` so a
headless node never opens a tracker socket.
- `stop()` always closes the transport first, then unregisters; this
ordering guarantees that any in-flight `send` rejects with
`TRANSPORT_CLOSED` rather than `ROUTER_NO_TRANSPORT`.
- Sequential `stop()`s are tolerated through the underlying transport's
idempotent `close()` and the node's idempotent
`unregisterTransport` (the second call is a no-op when the transport
is already gone).
- `dispose()` is separate from `stop()` so consumers can release the
node reference without re-closing the transport.

View File

@@ -0,0 +1,78 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFakeFactory } from "../../tests/setup/fake-p2pt.js";
import { WebTorrentPlugin } from "./plugin.js";
function makePlugin(
over: Partial<{ maxPeers: number }> = {}
): WebTorrentPlugin {
const { factory } = makeFakeFactory();
return new WebTorrentPlugin({
trackers: ["wss://tracker.example"],
p2ptFactory: factory,
...over,
});
}
describe("WebTorrentPlugin", () => {
it("exposes the canonical plugin name", { timeout: 5000 }, () => {
const p = makePlugin();
expect(p.name).toBe("webtorrent");
});
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("webtorrent");
});
it("start() is a no-op", { timeout: 5000 }, async () => {
const node = new NetworkNode();
const plugin = makePlugin();
node.use(plugin);
await node.start();
expect(node.isStarted).toBe(true);
await node.stop();
});
it("stop() closes the transport and unregisters it from the node", {
timeout: 5000,
}, async () => {
const node = new NetworkNode();
const plugin = makePlugin();
node.use(plugin);
await node.start();
await node.stop();
expect(
node.messageRouter.getTransports().map((t) => t.id)
).not.toContain("webtorrent");
});
it("dispose releases the node reference and 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 WebTorrentTransportConfig to the transport", {
timeout: 5000,
}, () => {
const plugin = makePlugin({ maxPeers: 7 });
expect(plugin.transport.id).toBe("webtorrent");
});
});

View File

@@ -0,0 +1,46 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { WebTorrentTransport } from "../transport/transport.js";
import type { WebTorrentTransportConfig } from "../types/types.js";
/**
* `NetworkPlugin` wrapper exposing a `WebTorrentTransport` to the host
* `NetworkNode`. Thin lifecycle wrapper mirroring `WebSocketPlugin` and
* `WebRTCPlugin`:
*
* - `install(node)` registers the transport so the router can find it.
* - `start()` is a no-op — the p2pt instance is created lazily on the
* first `connect()`.
* - `stop()` closes the transport and unregisters it from the node.
* - `dispose()` releases the node reference and is idempotent.
*/
export class WebTorrentPlugin implements NetworkPlugin {
public readonly name = "webtorrent";
public readonly transport: WebTorrentTransport;
#node: NetworkNode | null = null;
#disposed = false;
public constructor(config: WebTorrentTransportConfig) {
this.transport = new WebTorrentTransport(config);
}
public install(node: NetworkNode): void {
this.#node = node;
node.registerTransport(this.transport);
}
public start(): void {
// Lazy: p2pt instance is created on the first transport.connect()
// so headless nodes that never connect don't open a tracker socket.
}
public async stop(): Promise<void> {
await this.transport.close();
this.#node?.unregisterTransport(this.transport.id);
}
public dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#node = null;
}
}

View File

@@ -0,0 +1,147 @@
# transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebTorrentTransport` implements the `@suaveplan/network` `Transport`
contract over a WebTorrent-tracker-signalled WebRTC mesh via `p2pt`. It
composes the same `P2PTLike` surface that `WebTorrentDiscovery` consumes,
so production code wires the real `(trackers, id) => new P2PT(trackers,
id)` and tests wire `FakeP2PT` — no `vi.mock` required. The adapter owns
its own p2pt instance (lazily constructed on the first `connect` call)
and surfaces typed peer events, JSON-framed envelope delivery, AbortSignal
cancellation, structured errors, and OTel instrumentation.
This package is "Option B (fold)" from the `network-transport-webtorrent`
proposal — the Wave 2 discovery package gains a sibling `Transport`
implementation rather than spawning a separate package, matching upstream
layout while keeping a clean public surface via the `./transport`
subpath export.
## Features
- Implements every member of the `Transport` contract (`id`, `connect`,
`disconnect`, `send`, `onMessage`, `onPeerEvent`, `getConnectedPeers`,
`close`).
- Lazy p2pt instantiation — headless nodes that never `connect()` never
open a tracker socket.
- AbortSignal threaded through `connect` (both pre-aborted and mid-flight).
- Per-`connect` timeout (`connectTimeoutMs`, default 30s) surfacing as
`CONNECT_TIMEOUT`.
- Strict per-peer registry — `connect` rejects duplicates with
`PEER_ALREADY_CONNECTED` and respects an optional `maxPeers` cap.
- Outbound `send` JSON-stringifies envelopes and delegates to
`p2pt.send(peer, msg)`; inbound `msg` events JSON-parse and fan out to
every registered handler.
- 9 transport-level error codes share the
`NET_DISC_WEBTORRENT_CODES` registry alongside the discovery codes.
- OTel: `webtorrent_transport.{connect,disconnect,send}` spans, a
`webtorrent_transport.messages_total{direction,outcome}` counter, and
a `webtorrent_transport.active_peers` up-down gauge.
## Usage: Basic Example
```ts
import { WebTorrentTransport } from "@suaveplan/network-discovery-webtorrent/transport";
import P2PT from "p2pt";
const transport = new WebTorrentTransport({
trackers: ["wss://tracker.openwebtorrent.com"],
p2ptFactory: (trackers, id) => new P2PT(trackers as string[], id),
});
transport.onMessage((peerId, envelope) => {
console.log("from", peerId, envelope);
});
await transport.connect("peer-abc", "/webrtc/p2p/peer-abc");
await transport.send("peer-abc", {
id: "m1",
type: "ping",
senderId: "self",
payload: {},
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
});
await transport.close();
```
## Usage: Advanced Example
```ts
import { WebTorrentTransport } from "@suaveplan/network-discovery-webtorrent/transport";
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "@suaveplan/network-discovery-webtorrent";
const ac = new AbortController();
const transport = new WebTorrentTransport({
trackers: ["wss://tracker.openwebtorrent.com"],
announceId: "my-app-room",
maxPeers: 32,
connectTimeoutMs: 10_000,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
});
setTimeout(() => ac.abort(), 5_000);
try {
await transport.connect("peer-xyz", "/p2p/peer-xyz", ac.signal);
} catch (err) {
if (
err instanceof NetDiscWebTorrentError &&
err.code === NET_DISC_WEBTORRENT_CODES.CONNECT_TIMEOUT
) {
// fall back to a different transport
} else {
throw err;
}
}
```
## API Reference
- `class WebTorrentTransport implements Transport`
- `readonly id: "webtorrent"`
- `constructor(config: WebTorrentTransportConfig)` — throws
`INVALID_OPTIONS` on bad config.
- `connect(peerId, address, signal?): Promise<void>` — resolves once
p2pt reports `peerconnect` for the id. Rejects with
`TRANSPORT_CLOSED`, `ABORTED`, `PEER_ALREADY_CONNECTED`,
`MAX_PEERS_EXCEEDED`, `CONNECT_TIMEOUT`, or `TRACKER_FAILED`.
- `disconnect(peerId): Promise<void>` — no-op for unknown peers.
- `send(peerId, envelope): Promise<void>` — rejects with
`TRANSPORT_CLOSED`, `PEER_UNKNOWN`, `SEND_UNSUPPORTED`, or
`SEND_FAILED`.
- `onMessage(handler): () => void` — returns unsubscribe handle.
- `onPeerEvent(handler): () => void` — returns unsubscribe handle.
- `getConnectedPeers(): readonly PeerId[]`
- `close(): Promise<void>` — idempotent.
## Implementation Notes
- The Transport contract's `connect(peerId, address)` is a poor fit for
p2pt's room-based discovery model. The adapter therefore treats
`address` as informational and resolves the promise once p2pt emits
`peerconnect` for the matching id (within `connectTimeoutMs`).
- p2pt is created lazily and exactly once per transport instance. A
factory rejection bubbles up wrapped in `TRACKER_FAILED` and clears the
init promise so the next `connect()` can retry.
- The active-peers gauge is `UpDownCounter`-backed and only adjusted on
observed `peerconnect`/`peerclose` (or `disconnect`/`close`); `connect`
rollback paths never adjust the gauge so the metric stays consistent
with `getConnectedPeers()`.
- All AbortSignal listeners are registered with `{ once: true }` and
proactively removed on resolve/reject/timeout to avoid leaks.
- Inbound messages are tolerated as either pre-parsed objects or JSON
strings — real p2pt delivers strings; in-memory fakes can deliver
objects directly. Parse failures count as `inbound,error` on the
messages counter.

View File

@@ -0,0 +1,839 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeP2PT, makeFakeFactory } from "../../tests/setup/fake-p2pt.js";
import {
NET_DISC_WEBTORRENT_CODES,
type NetDiscWebTorrentError,
} from "../errors/errors.js";
import { WebTorrentTransport } from "./transport.js";
function envelope(id: string, type = "test"): MessageEnvelope {
return {
id,
type,
senderId: "self",
payload: { hello: id },
timestamp: 1,
headers: {},
ttl: 64,
visitedNodes: [],
};
}
async function tick(ms = 0): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
describe("WebTorrentTransport — construction", () => {
it("has id 'webtorrent'", { timeout: 5000 }, () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
expect(t.id).toBe("webtorrent");
});
it("throws INVALID_OPTIONS on empty trackers", { timeout: 5000 }, () => {
const { factory } = makeFakeFactory();
let caught: unknown;
try {
new WebTorrentTransport({
trackers: [],
p2ptFactory: factory,
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
it("throws INVALID_OPTIONS on missing factory", { timeout: 5000 }, () => {
let caught: unknown;
try {
new WebTorrentTransport({
trackers: ["wss://t"],
} as unknown as { trackers: string[]; p2ptFactory: () => never });
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
it("throws INVALID_OPTIONS when factory is a non-function value", {
timeout: 5000,
}, () => {
// `p2ptFactory: z.unknown()` admits any value at the Zod boundary;
// the explicit `typeof !== "function"` guard catches non-callable
// values that slipped through. Pins coverage of that distinct path.
let caught: unknown;
try {
new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: "not-a-function",
} as unknown as {
trackers: string[];
p2ptFactory: () => never;
});
} catch (e) {
caught = e;
}
expect((caught as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS
);
});
});
describe("WebTorrentTransport — connect", () => {
it("resolves once p2pt emits peerconnect", { timeout: 5000 }, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const events: Array<{ peerId: string; event: string }> = [];
t.onPeerEvent((peerId, ev) => {
events.push({ peerId, event: ev });
});
const promise = t.connect("p1", "/p2p/p1");
// Allow the lazy factory to materialise + wire events.
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await promise;
expect(t.getConnectedPeers()).toContain("p1");
expect(events).toContainEqual({ peerId: "p1", event: "connected" });
await t.close();
});
it("rejects pre-aborted signal with ABORTED", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const ac = new AbortController();
ac.abort();
try {
await t.connect("p1", "/x", ac.signal);
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.ABORTED
);
}
await t.close();
});
it("rejects mid-flight abort with ABORTED", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 5000,
});
const ac = new AbortController();
const promise = t.connect("p1", "/x", ac.signal);
await tick();
ac.abort();
try {
await promise;
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.ABORTED
);
}
await t.close();
});
it("rejects duplicate connect with PEER_ALREADY_CONNECTED", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const p1 = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p1;
try {
await t.connect("p1", "/x");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.PEER_ALREADY_CONNECTED
);
}
await t.close();
});
it("rejects past maxPeers with MAX_PEERS_EXCEEDED", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
maxPeers: 1,
});
const p1 = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p1;
try {
await t.connect("p2", "/y");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.MAX_PEERS_EXCEEDED
);
}
await t.close();
});
it("rejects when transport is closed with TRANSPORT_CLOSED", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
await t.close();
try {
await t.connect("p1", "/x");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.TRANSPORT_CLOSED
);
}
});
it("resolves while a non-aborted signal is attached", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 5000,
});
const ac = new AbortController();
const p = t.connect("p1", "/x", ac.signal);
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
expect(t.getConnectedPeers()).toContain("p1");
await t.close();
});
it("CONNECT_TIMEOUT cleans up a still-attached signal listener", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 20,
});
const ac = new AbortController();
try {
await t.connect("ghost", "/g", ac.signal);
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.CONNECT_TIMEOUT
);
}
await t.close();
});
it("rejects on CONNECT_TIMEOUT when peer never appears", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 20,
});
try {
await t.connect("ghost", "/g");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.CONNECT_TIMEOUT
);
}
await t.close();
});
it("wraps p2pt factory failure as TRACKER_FAILED", {
timeout: 5000,
}, async () => {
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: () => {
throw new Error("boom");
},
});
try {
await t.connect("p1", "/x");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED
);
}
await t.close();
});
it("wraps p2pt.start rejection as TRACKER_FAILED", {
timeout: 5000,
}, async () => {
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: (trackers, id) => {
const f = new FakeP2PT(trackers, id);
f.startError = new Error("start-failed");
return f;
},
});
try {
await t.connect("p1", "/x");
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED
);
}
await t.close();
});
it("ignores peerconnect for unrequested peer ids", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 100,
});
const p1 = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "stranger" });
instances[0]!.emit("peerconnect", { id: "p1" });
await p1;
expect(t.getConnectedPeers()).toEqual(["p1"]);
await t.close();
});
it("ignores peerconnect with no extractable id", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 50,
});
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", null);
instances[0]!.emit("peerconnect", { id: "" });
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
await t.close();
});
it("second peerconnect for already-connected peer is ignored", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const events: string[] = [];
t.onPeerEvent((_pid, ev) => events.push(ev));
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
instances[0]!.emit("peerconnect", { id: "p1" });
expect(events.filter((e) => e === "connected").length).toBe(1);
await t.close();
});
});
describe("WebTorrentTransport — send / receive", () => {
it("send delegates to p2pt.send", { timeout: 5000 }, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
await t.send("p1", envelope("m1"));
expect(instances[0]!.outbound.length).toBe(1);
const sent = JSON.parse(instances[0]!.outbound[0]!.msg as string);
expect(sent.id).toBe("m1");
await t.close();
});
it("send rejects PEER_UNKNOWN for unknown peer", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
try {
await t.send("ghost", envelope("m1"));
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.PEER_UNKNOWN
);
}
await t.close();
});
it("send rejects TRANSPORT_CLOSED after close", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
await t.close();
try {
await t.send("p1", envelope("m1"));
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.TRANSPORT_CLOSED
);
}
});
it("send maps underlying failure to SEND_FAILED", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
instances[0]!.sendError = new Error("offline");
try {
await t.send("p1", envelope("m1"));
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.SEND_FAILED
);
}
await t.close();
});
it("send raises SEND_UNSUPPORTED if underlying p2pt has no send", {
timeout: 5000,
}, async () => {
let captured: FakeP2PT | null = null;
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: (trackers, id) => {
const f = new FakeP2PT(trackers, id);
(f as { send?: unknown }).send = undefined;
captured = f;
return f;
},
});
const cp = t.connect("p1", "/x");
await tick();
captured!.emit("peerconnect", { id: "p1" });
await cp;
try {
await t.send("p1", envelope("m1"));
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.SEND_UNSUPPORTED
);
}
await t.close();
});
it("inbound msg fans out to handlers", { timeout: 5000 }, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const inbound: MessageEnvelope[] = [];
t.onMessage((_p, e) => inbound.push(e));
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
instances[0]!.emit("msg", { id: "p1" }, JSON.stringify(envelope("m1")));
expect(inbound.length).toBe(1);
expect(inbound[0]!.id).toBe("m1");
await t.close();
});
it("inbound msg accepts pre-parsed objects", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const inbound: MessageEnvelope[] = [];
t.onMessage((_p, e) => inbound.push(e));
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
instances[0]!.emit("msg", { id: "p1" }, envelope("obj"));
expect(inbound.length).toBe(1);
expect(inbound[0]!.id).toBe("obj");
await t.close();
});
it("inbound msg with no peer id is ignored", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const inbound: MessageEnvelope[] = [];
t.onMessage((_p, e) => inbound.push(e));
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
instances[0]!.emit("msg", null, JSON.stringify(envelope("m1")));
instances[0]!.emit(
"msg",
{ id: "unknown" },
JSON.stringify(envelope("m2"))
);
instances[0]!.emit("msg", { id: "p1" }, "not-json");
expect(inbound.length).toBe(0);
await t.close();
});
});
describe("WebTorrentTransport — disconnect / close", () => {
it("disconnect of unknown peer is no-op", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
await t.disconnect("ghost");
await t.close();
});
it("disconnect of a still-pending peer cancels with ABORTED", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 5000,
});
const events: string[] = [];
t.onPeerEvent((_p, ev) => events.push(ev));
const p = t.connect("p1", "/x");
await tick();
await t.disconnect("p1");
try {
await p;
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.ABORTED
);
}
// No `connected` was ever observed → no disconnected event either.
expect(events).not.toContain("disconnected");
await t.close();
});
it("disconnect of a still-pending peer with attached signal cleans up", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 5000,
});
const ac = new AbortController();
const p = t.connect("p1", "/x", ac.signal);
await tick();
await t.disconnect("p1");
await expect(p).rejects.toBeDefined();
await t.close();
});
it("disconnect emits disconnected event", { timeout: 5000 }, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const events: string[] = [];
t.onPeerEvent((_p, ev) => events.push(ev));
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
await t.disconnect("p1");
expect(events).toContain("disconnected");
await t.close();
});
it("peerclose prunes registry and emits disconnected", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const events: string[] = [];
t.onPeerEvent((_p, ev) => events.push(ev));
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
instances[0]!.emit("peerclose", { id: "p1" });
expect(t.getConnectedPeers()).not.toContain("p1");
expect(events).toContain("disconnected");
await t.close();
});
it("peerclose for unknown peer is ignored", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const p = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p;
instances[0]!.emit("peerclose", { id: "stranger" });
instances[0]!.emit("peerclose", null);
expect(t.getConnectedPeers()).toContain("p1");
await t.close();
});
it("close is idempotent", { timeout: 5000 }, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
await t.close();
await t.close();
});
it("close rejects pending connects with TRANSPORT_CLOSED", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 5000,
});
const p = t.connect("p1", "/x");
await tick();
await t.close();
try {
await p;
expect.fail("expected throw");
} catch (err) {
expect((err as NetDiscWebTorrentError).code).toBe(
NET_DISC_WEBTORRENT_CODES.TRANSPORT_CLOSED
);
}
});
it("close rejects pending connect with abort signal still attached", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 5000,
});
const ac = new AbortController();
const p = t.connect("p1", "/x", ac.signal);
await tick();
await t.close();
await expect(p).rejects.toBeDefined();
});
it("close swallows p2pt.destroy throws", {
timeout: 5000,
}, async () => {
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: (trackers, id) => {
const f = new FakeP2PT(trackers, id);
f.destroy = () => {
throw new Error("destroy-boom");
};
return f;
},
});
const p = t.connect("p1", "/x");
await tick();
// peer never arrives — close cleanly
await t.close();
await expect(p).rejects.toBeDefined();
});
it("getConnectedPeers omits pending (not-yet-connected) peers", {
timeout: 5000,
}, async () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 100,
});
const p = t.connect("pending", "/x");
await tick();
expect(t.getConnectedPeers()).not.toContain("pending");
await p.catch(() => undefined);
await t.close();
});
it("peerclose for a not-yet-connected pending peer is silent", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 1000,
});
const events: string[] = [];
t.onPeerEvent((_p, ev) => events.push(ev));
const p = t.connect("p1", "/x");
await tick();
// peerclose before peerconnect — state exists with connected:false
instances[0]!.emit("peerclose", { id: "p1" });
expect(events).not.toContain("disconnected");
await p.catch(() => undefined);
await t.close();
});
it("ensureP2pt reuses an already-initialised p2pt across connects", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const p1 = t.connect("p1", "/x");
await tick();
instances[0]!.emit("peerconnect", { id: "p1" });
await p1;
const p2 = t.connect("p2", "/y");
await tick();
instances[0]!.emit("peerconnect", { id: "p2" });
await p2;
// Single factory invocation — no second p2pt instance.
expect(instances.length).toBe(1);
await t.close();
});
it("ensureP2pt collapses concurrent connects onto one init promise", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
connectTimeoutMs: 1000,
});
// Fire two connects before either await on ensureP2pt resolves.
const p1 = t.connect("p1", "/x");
const p2 = t.connect("p2", "/y");
await tick();
expect(instances.length).toBe(1);
instances[0]!.emit("peerconnect", { id: "p1" });
instances[0]!.emit("peerconnect", { id: "p2" });
await p1;
await p2;
await t.close();
});
it("accepts a peer object that uses _id instead of id", {
timeout: 5000,
}, async () => {
const { factory, instances } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const p = t.connect("alt", "/x");
await tick();
instances[0]!.emit("peerconnect", { _id: "alt" });
await p;
expect(t.getConnectedPeers()).toContain("alt");
await t.close();
});
it("unsubscribe handles from onMessage/onPeerEvent work", {
timeout: 5000,
}, () => {
const { factory } = makeFakeFactory();
const t = new WebTorrentTransport({
trackers: ["wss://t"],
p2ptFactory: factory,
});
const offMsg = t.onMessage(() => {});
const offEvt = t.onPeerEvent(() => {});
offMsg();
offEvt();
});
});

View File

@@ -0,0 +1,495 @@
import type { MessageEnvelope, PeerId, Transport } from "@suaveplan/network";
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "../errors/errors.js";
import {
adjustTransportActivePeers,
recordTransportMessage,
withTransportSpan,
} from "../otel/otel.js";
import type {
P2PTFactory,
P2PTLike,
WebTorrentTransportConfig,
} from "../types/types.js";
import { WebTorrentTransportConfigSchema } from "../types/types.schemas.js";
interface P2PTPeerObject {
readonly id?: string;
readonly _id?: string;
}
type MessageHandler = (peerId: PeerId, envelope: MessageEnvelope) => void;
type PeerEventHandler = (
peerId: PeerId,
event: "connected" | "disconnected"
) => void;
interface PendingConnect {
readonly peerId: PeerId;
readonly resolve: () => void;
readonly reject: (err: Error) => void;
readonly timer: ReturnType<typeof setTimeout>;
readonly signalHandler?: (() => void) | undefined;
readonly signal?: AbortSignal | undefined;
}
interface PeerState {
readonly peerId: PeerId;
readonly address: string;
/** The opaque peer reference handed to us by p2pt. */
peerRef: unknown;
connected: boolean;
}
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
const DEFAULT_ANNOUNCE_ID = "suaveplan-transport";
/**
* Peer-oriented `Transport` adapter implementing the
* `@suaveplan/network` `Transport` contract over a WebTorrent-tracker-
* signalled WebRTC mesh via `p2pt`. Composes the same `P2PTLike` surface
* used by `WebTorrentDiscovery` — production code wires a real
* `(trackers, id) => new P2PT(trackers, id)`, tests wire `FakeP2PT`.
*
* The adapter owns its own p2pt instance (lazily constructed on the
* first `connect` call) and surfaces:
*
* - `connect(peerId, address)` — registers the peer as a target; resolves
* once p2pt reports `peerconnect` for the id, or rejects with
* `CONNECT_TIMEOUT` on the configured timeout, or `ABORTED` on signal
* abort.
* - `disconnect(peerId)` — drops local tracking and emits a
* `disconnected` peer event.
* - `send(peerId, envelope)` — delegates to `p2pt.send(peerRef, JSON)`.
* - `onMessage` / `onPeerEvent` — registry of observer fan-out handlers.
* - `getConnectedPeers` — current snapshot.
* - `close` — destroys the p2pt instance and tears down state.
*/
export class WebTorrentTransport implements Transport {
public readonly id = "webtorrent";
readonly #trackers: readonly string[];
readonly #announceId: string;
readonly #factory: P2PTFactory;
readonly #maxPeers: number;
readonly #connectTimeoutMs: number;
readonly #peers = new Map<PeerId, PeerState>();
readonly #pending = new Map<PeerId, PendingConnect>();
readonly #messageHandlers = new Set<MessageHandler>();
readonly #peerEventHandlers = new Set<PeerEventHandler>();
#p2pt: P2PTLike | null = null;
#p2ptInitPromise: Promise<P2PTLike> | null = null;
#closed = false;
public constructor(config: WebTorrentTransportConfig) {
const parsed = WebTorrentTransportConfigSchema.safeParse(config);
if (!parsed.success) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS,
"errors.webtorrent.invalid_options",
{ cause: parsed.error }
);
}
if (typeof config.p2ptFactory !== "function") {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS,
"errors.webtorrent.factory_required"
);
}
this.#trackers = parsed.data.trackers;
this.#announceId = parsed.data.announceId ?? DEFAULT_ANNOUNCE_ID;
this.#factory = config.p2ptFactory;
this.#maxPeers = parsed.data.maxPeers ?? Number.POSITIVE_INFINITY;
this.#connectTimeoutMs =
parsed.data.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
}
public async connect(
peerId: PeerId,
address: string,
signal?: AbortSignal
): Promise<void> {
return withTransportSpan(
"webtorrent_transport.connect",
async (span) => {
span.setAttribute("peer.id", peerId);
if (this.#closed) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.TRANSPORT_CLOSED,
"errors.webtorrent.transport_closed"
);
}
if (signal?.aborted) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.ABORTED,
"errors.webtorrent.aborted"
);
}
if (this.#peers.has(peerId) || this.#pending.has(peerId)) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.PEER_ALREADY_CONNECTED,
"errors.webtorrent.peer_already_connected",
{ context: { peerId } }
);
}
if (this.#peers.size >= this.#maxPeers) {
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.MAX_PEERS_EXCEEDED,
"errors.webtorrent.max_peers_exceeded",
{ context: { peerId, maxPeers: this.#maxPeers } }
);
}
const p2pt = await this.#ensureP2pt();
// Record the peer slot up-front so `peerconnect` can resolve it.
this.#peers.set(peerId, {
peerId,
address,
peerRef: null,
connected: false,
});
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
this.#pending.delete(peerId);
if (signalHandler && signal) {
signal.removeEventListener(
"abort",
signalHandler
);
}
reject(
new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.CONNECT_TIMEOUT,
"errors.webtorrent.connect_timeout",
{ context: { peerId } }
)
);
}, this.#connectTimeoutMs);
const signalHandler: (() => void) | undefined = signal
? () => {
// Listener is registered with `{ once: true }`
// and is only reachable while the pending
// entry is live (timeout/peerconnect/close
// all unregister synchronously before
// resolving/rejecting), so the entry is
// guaranteed present here.
const p = this.#pending.get(
peerId
) as PendingConnect;
this.#pending.delete(peerId);
clearTimeout(p.timer);
reject(
new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.ABORTED,
"errors.webtorrent.aborted"
)
);
}
: undefined;
if (signal && signalHandler) {
signal.addEventListener("abort", signalHandler, {
once: true,
});
}
this.#pending.set(peerId, {
peerId,
resolve,
reject,
timer,
signalHandler,
signal,
});
// Real p2pt invokes `start()` exactly once when first
// requested; the helper hook here gives the underlying
// implementation a chance to (re)issue an announce.
p2pt.requestMorePeers?.();
});
} catch (err) {
this.#peers.delete(peerId);
throw err;
}
}
);
}
public async disconnect(peerId: PeerId): Promise<void> {
return withTransportSpan(
"webtorrent_transport.disconnect",
async (span) => {
span.setAttribute("peer.id", peerId);
const state = this.#peers.get(peerId);
if (state === undefined) {
return;
}
const wasConnected = state.connected;
this.#peers.delete(peerId);
// If still pending, cancel the in-flight connect.
const pending = this.#pending.get(peerId);
if (pending !== undefined) {
clearTimeout(pending.timer);
if (pending.signalHandler && pending.signal) {
pending.signal.removeEventListener(
"abort",
pending.signalHandler
);
}
this.#pending.delete(peerId);
pending.reject(
new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.ABORTED,
"errors.webtorrent.aborted"
)
);
}
if (wasConnected) {
adjustTransportActivePeers(-1);
this.#emitPeerEvent(peerId, "disconnected");
}
}
);
}
public async send(
peerId: PeerId,
envelope: MessageEnvelope
): Promise<void> {
return withTransportSpan("webtorrent_transport.send", async (span) => {
span.setAttribute("peer.id", peerId);
if (this.#closed) {
recordTransportMessage("outbound", "error");
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.TRANSPORT_CLOSED,
"errors.webtorrent.transport_closed"
);
}
const state = this.#peers.get(peerId);
if (state === undefined || !state.connected) {
recordTransportMessage("outbound", "error");
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.PEER_UNKNOWN,
"errors.webtorrent.peer_unknown",
{ context: { peerId } }
);
}
const p2pt = this.#p2pt;
if (p2pt === null || typeof p2pt.send !== "function") {
recordTransportMessage("outbound", "error");
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.SEND_UNSUPPORTED,
"errors.webtorrent.send_unsupported"
);
}
try {
await Promise.resolve(
p2pt.send(state.peerRef, JSON.stringify(envelope))
);
recordTransportMessage("outbound", "ok");
} catch (cause) {
recordTransportMessage("outbound", "error");
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.SEND_FAILED,
"errors.webtorrent.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[] {
const out: PeerId[] = [];
for (const [peerId, state] of this.#peers) {
if (state.connected) out.push(peerId);
}
return out;
}
public async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
// Reject any pending connects.
for (const pending of [...this.#pending.values()]) {
clearTimeout(pending.timer);
if (pending.signalHandler && pending.signal) {
pending.signal.removeEventListener(
"abort",
pending.signalHandler
);
}
pending.reject(
new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.TRANSPORT_CLOSED,
"errors.webtorrent.transport_closed"
)
);
}
this.#pending.clear();
// Emit disconnects for every connected peer.
for (const [peerId, state] of [...this.#peers]) {
if (state.connected) {
adjustTransportActivePeers(-1);
this.#emitPeerEvent(peerId, "disconnected");
}
}
this.#peers.clear();
const p2pt = this.#p2pt;
this.#p2pt = null;
if (p2pt !== null) {
try {
p2pt.destroy();
} catch {
/* swallow */
}
}
this.#messageHandlers.clear();
this.#peerEventHandlers.clear();
}
async #ensureP2pt(): Promise<P2PTLike> {
if (this.#p2pt !== null) return this.#p2pt;
if (this.#p2ptInitPromise !== null) return this.#p2ptInitPromise;
this.#p2ptInitPromise = (async () => {
const instance = await this.#factory(
this.#trackers,
this.#announceId
);
this.#wire(instance);
await instance.start();
this.#p2pt = instance;
return instance;
})();
try {
return await this.#p2ptInitPromise;
} catch (cause) {
this.#p2ptInitPromise = null;
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED,
"errors.webtorrent.tracker_failed",
{ cause: cause as Error }
);
}
}
#wire(p2pt: P2PTLike): void {
p2pt.on("peerconnect", (peer: unknown) => {
this.#handlePeerConnect(peer);
});
p2pt.on("peerclose", (peer: unknown) => {
this.#handlePeerClose(peer);
});
p2pt.on("msg", (peer: unknown, msg: unknown) => {
this.#handleMessage(peer, msg);
});
}
#handlePeerConnect(peer: unknown): void {
const peerId = extractPeerId(peer);
if (peerId === null) return;
const state = this.#peers.get(peerId);
if (state === undefined) {
// p2pt advertised a peer no caller asked for. Real p2pt is room-
// wide so this is expected; we ignore until the caller calls
// `connect(peerId)`.
return;
}
if (state.connected) return;
state.peerRef = peer;
state.connected = true;
adjustTransportActivePeers(+1);
this.#emitPeerEvent(peerId, "connected");
// Pending entry is always present here — `connect()` registers
// `#peers` and `#pending` in the same synchronous tick, and every
// teardown path (timeout / signal-abort / close / connect-failure)
// unregisters both. Cast and assert.
const pending = this.#pending.get(peerId) as PendingConnect;
clearTimeout(pending.timer);
if (pending.signalHandler && pending.signal) {
pending.signal.removeEventListener("abort", pending.signalHandler);
}
this.#pending.delete(peerId);
pending.resolve();
}
#handlePeerClose(peer: unknown): void {
const peerId = extractPeerId(peer);
if (peerId === null) return;
const state = this.#peers.get(peerId);
if (state === undefined) return;
const wasConnected = state.connected;
this.#peers.delete(peerId);
if (wasConnected) {
adjustTransportActivePeers(-1);
this.#emitPeerEvent(peerId, "disconnected");
}
}
#handleMessage(peer: unknown, msg: unknown): void {
const peerId = extractPeerId(peer);
if (peerId === null) {
recordTransportMessage("inbound", "error");
return;
}
const state = this.#peers.get(peerId);
if (state === undefined || !state.connected) {
recordTransportMessage("inbound", "error");
return;
}
let envelope: MessageEnvelope;
try {
const parsed =
typeof msg === "string" ? JSON.parse(msg) : (msg as unknown);
envelope = parsed as MessageEnvelope;
} catch {
recordTransportMessage("inbound", "error");
return;
}
for (const handler of this.#messageHandlers) {
handler(peerId, envelope);
}
recordTransportMessage("inbound", "ok");
}
#emitPeerEvent(peerId: PeerId, event: "connected" | "disconnected"): void {
for (const handler of this.#peerEventHandlers) {
handler(peerId, event);
}
}
}
function extractPeerId(peer: unknown): string | null {
if (peer === null || typeof peer !== "object") return null;
const obj = peer as P2PTPeerObject;
const id = obj.id ?? obj._id;
if (typeof id !== "string" || id.length === 0) return null;
return id;
}

View File

@@ -0,0 +1,122 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type contracts for `@suaveplan/network-discovery-webtorrent`. Defines
the wire-format-neutral peer-metadata record, the structural `P2PTLike`
surface that production code and tests both bind to, the discovery-strategy
configuration, the fully-resolved configuration shape after defaults are
applied, and the typed event-map for the optional `EventBusLike` integration.
These types are zero-runtime; every public boundary is also validated by a
Zod schema in `types.schemas.ts`.
## Features
- `WebTorrentPeerMetadata` — observed-peer record with `addresses`,
`connectedAt`, mutable `lastSeen`, and free-form metadata.
- `P2PTLike` — structural surface of a `p2pt` instance (`peerconnect`,
`peerclose`, `msg`, `trackerconnect`, `trackerwarning` events, `start` /
`destroy` lifecycle). Tests bind a deterministic in-memory implementation
to this interface — no `vi.mock` ever needed.
- `P2PTFactory` — `(trackers, announceId) => P2PTLike | Promise<P2PTLike>`
signature injected via constructor options.
- `WebTorrentDiscoveryConfig` — strict caller-supplied configuration. The
`trackers` array is REQUIRED and may not be empty; there is no implicit
public-tracker fallback in this package.
- `ResolvedWebTorrentDiscoveryConfig` — fully-defaulted configuration the
orchestrator stores after Zod parsing.
- `WebTorrentEventMap` — typed payload contract for every event emitted on
the optional `EventBusLike`.
## Usage: Basic Example
```ts
import type {
WebTorrentDiscoveryConfig,
P2PTFactory,
} from "@suaveplan/network-discovery-webtorrent/types";
const factory: P2PTFactory = (trackers, id) => {
// Tests pass a deterministic stub here.
throw new Error("supply a real factory in production");
};
const config: WebTorrentDiscoveryConfig = {
trackers: ["wss://my-tracker.example.com"],
p2ptFactory: factory,
};
```
## Usage: Advanced Example
```ts
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { createEventBus } from "@suaveplan/events";
import type {
WebTorrentDiscoveryConfig,
WebTorrentEventMap,
} from "@suaveplan/network-discovery-webtorrent/types";
const bus = createEventBus<WebTorrentEventMap>();
const registry = new ServiceRegistry();
bus.on("webtorrent:peer-discovered", ({ peer }) => {
console.log("peer:", peer.id, peer.addresses);
});
const config: WebTorrentDiscoveryConfig = {
trackers: [
"wss://my-tracker-a.example.com",
"wss://my-tracker-b.example.com",
],
announceId: "my-app/v1",
maxPeers: 256,
timeout: 30_000,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
bus,
registry,
};
```
## API Reference
- `WebTorrentPeerMetadata` — `{ id, addresses, protocols, metadata,
connectedAt, lastSeen }`. `lastSeen` is intentionally mutable.
- `P2PTLike` — `on(event, handler)`, `start()`, `destroy()`. The structural
surface every p2pt-like backend (real + fake) must conform to.
- `P2PTFactory` — `(trackers, announceId) => P2PTLike | Promise<P2PTLike>`.
Production binds to the real `p2pt` package; tests pass an in-memory
implementation.
- `WebTorrentDiscoveryConfig` — `trackers` (REQUIRED, non-empty),
`announceId` (default `"suaveplan-discovery"`), `maxPeers` (default `100`),
`timeout` (default `60_000`), `p2ptFactory` (REQUIRED), `bus`, `registry`.
- `ResolvedWebTorrentDiscoveryConfig` — defaulted form stored on the
orchestrator instance.
- `WebTorrentEventMap` — `webtorrent:started`,
`webtorrent:peer-discovered`, `webtorrent:peer-lost`,
`webtorrent:tracker-error`, `webtorrent:stopped` payload types.
## Implementation Notes
- The `trackers` array is never defaulted by this package. Upstream's public
tracker fallback (`wss://tracker.openwebtorrent.com` / `tracker.btorrent.xyz`
/ `tracker.files.fm:7073/announce` / `spacetradersapi-chatbox.herokuapp.com:443/announce`)
was deliberately removed — callers must own their tracker infrastructure
or supply an explicit list. The Zod schema enforces `.min(1)` and the
constructor surfaces violations as `INVALID_OPTIONS`.
- `P2PTLike` is intentionally narrower than the real `p2pt` event-emitter
surface — only the events this package consumes are typed. The real
`p2pt` instance conforms; the test fake conforms; nothing else is required.
- `P2PTFactory` returns `P2PTLike | Promise<P2PTLike>` so production code
can dynamically import `p2pt` (a Node-flavoured package) without forcing
the discovery package itself to bundle it.
- `WebTorrentPeerMetadata.lastSeen` is `let`-mutable to allow zero-allocation
in-place touch updates — this is the only field that mutates after the
record is built.

View File

@@ -0,0 +1,50 @@
import z from "zod";
/**
* Zod boundary schemas for every public option struct in
* `@suaveplan/network-discovery-webtorrent`. Callable contracts (factory,
* `EventBusLike`, `ServiceRegistry`) are type-only and tolerated as
* `z.unknown()` — the constructor enforces presence via a runtime check.
*
* The tracker list is non-optional and non-empty. There is no implicit
* public default — construction must fail loudly if the list is missing or
* empty.
*/
export const WebTorrentPeerMetadataSchema = z
.object({
id: z.string().min(1),
addresses: z.array(z.string()).readonly(),
protocols: z.array(z.string()).readonly(),
metadata: z.record(z.string(), z.unknown()),
connectedAt: z.number().int().nonnegative(),
lastSeen: z.number().int().nonnegative(),
})
.strict();
export const WebTorrentDiscoveryConfigSchema = z
.object({
trackers: z.array(z.string().min(1)).min(1),
announceId: z.string().min(1).default("suaveplan-discovery"),
maxPeers: z.number().int().positive().default(100),
timeout: z.number().int().positive().default(60_000),
p2ptFactory: z.unknown(),
bus: z.unknown().optional(),
registry: z.unknown().optional(),
})
.strict();
/**
* Schema for `WebTorrentTransportConfig`. Mirrors the discovery schema but
* adds `connectTimeoutMs` and drops bus/registry — the transport adapter
* does not surface discovery events.
*/
export const WebTorrentTransportConfigSchema = z
.object({
trackers: z.array(z.string().min(1)).min(1),
announceId: z.string().min(1).optional(),
maxPeers: z.number().int().positive().optional(),
connectTimeoutMs: z.number().int().positive().optional(),
p2ptFactory: z.unknown(),
})
.strict();

View File

@@ -0,0 +1,149 @@
import type { ServiceRegistry } from "@suaveplan/service-discovery";
import type { EventBusLike } from "@suaveplan/types";
/**
* Peer metadata tracked by `WebTorrentDiscovery`. The id is the p2pt-assigned
* peer identifier (a hex hash); `addresses` carries the synthesized
* `/webrtc/p2p/<id>` multiaddr; `connectedAt` is the unix-ms timestamp at
* which the peer was first observed.
*/
export interface WebTorrentPeerMetadata {
readonly id: string;
readonly addresses: readonly string[];
readonly protocols: readonly string[];
readonly metadata: Readonly<Record<string, unknown>>;
/** Unix-ms timestamp of first observation. */
readonly connectedAt: number;
/** Mutable in-place because `lastSeen` updates on every message. */
lastSeen: number;
}
/**
* Structural surface of a `p2pt` instance used by this package. Real
* production code passes a real `p2pt` `P2PT` instance via the factory; tests
* pass an in-memory deterministic fake conforming to this exact shape — no
* `vi.mock` ever needed.
*/
export interface P2PTLike {
on(event: "peerconnect", handler: (peer: unknown) => void): void;
on(event: "peerclose", handler: (peer: unknown) => void): void;
on(event: "msg", handler: (peer: unknown, msg: unknown) => void): void;
on(event: "trackerconnect", handler: () => void): void;
on(event: "trackerwarning", handler: (err: unknown) => void): void;
on(event: string, handler: (...args: unknown[]) => void): void;
start(): void | Promise<void>;
destroy(): void;
requestMorePeers?(): void;
/**
* Optional unicast-send hook. Real `p2pt` instances expose
* `send(peer, msg)`; the transport adapter delegates outbound
* envelopes through this method. Discovery does not call it.
*/
send?(peer: unknown, msg: unknown): unknown;
}
/**
* Factory wiring `(trackers, announceId) => P2PTLike`. Production code passes
* `(trackers, id) => new P2PT(trackers, id)`; tests pass a deterministic
* in-memory implementation. The factory MAY be async to permit dynamic
* imports.
*/
export type P2PTFactory = (
trackers: readonly string[],
announceId: string
) => P2PTLike | Promise<P2PTLike>;
/**
* Strict configuration for `WebTorrentDiscovery`.
*
* **`trackers` is REQUIRED and may not be empty.** There is no implicit
* public-tracker fallback. Callers MUST supply their own tracker list — the
* package will not provide a default.
*/
export interface WebTorrentDiscoveryConfig {
/** WebRTC tracker URLs. REQUIRED, non-empty. No default fallback. */
readonly trackers: readonly string[];
/** Announce identifier (room name). Default `"suaveplan-discovery"`. */
readonly announceId?: string | undefined;
/** Hard cap on tracked peers. Default `100`. */
readonly maxPeers?: number | undefined;
/** Soft timeout (ms). Default `60_000`. */
readonly timeout?: number | undefined;
/** Required factory wiring the underlying p2pt instance. */
readonly p2ptFactory: P2PTFactory;
/** Optional event bus for typed emissions. */
readonly bus?: EventBusLike<WebTorrentEventMap> | undefined;
/** Optional service registry to mirror discovered peers into. */
readonly registry?: ServiceRegistry | undefined;
}
/**
* Fully-resolved configuration — every optional field defaulted.
*/
export interface ResolvedWebTorrentDiscoveryConfig {
readonly trackers: readonly string[];
readonly announceId: string;
readonly maxPeers: number;
readonly timeout: number;
readonly p2ptFactory: P2PTFactory;
readonly bus: EventBusLike<WebTorrentEventMap> | undefined;
readonly registry: ServiceRegistry | undefined;
}
/** Typed events emitted on the optional `EventBusLike`. */
export interface WebTorrentStartedEvent {
readonly announceId: string;
readonly trackerCount: number;
readonly at: number;
}
export interface WebTorrentDiscoveredEvent {
readonly peer: WebTorrentPeerMetadata;
readonly at: number;
}
export interface WebTorrentLostEvent {
readonly peerId: string;
readonly at: number;
}
export interface WebTorrentTrackerErrorEvent {
readonly tracker: string | undefined;
readonly reason: string;
readonly at: number;
}
export interface WebTorrentStoppedEvent {
readonly at: number;
}
/**
* Configuration for `WebTorrentTransport`. Owns its own p2pt instance —
* production wires `(trackers, id) => new P2PT(trackers, id)`; tests wire
* `FakeP2PT`. The transport-specific surface intentionally omits the
* discovery package's `bus` / `registry` slots (the `Transport` contract
* surfaces its own observer fan-out via `onPeerEvent` / `onMessage`).
*/
export interface WebTorrentTransportConfig {
/** WebRTC tracker URLs. REQUIRED, non-empty. */
readonly trackers: readonly string[];
/** Announce identifier (room name). Default `"suaveplan-transport"`. */
readonly announceId?: string | undefined;
/** Hard cap on tracked peers. Default unbounded. */
readonly maxPeers?: number | undefined;
/**
* Per-`connect()` timeout in ms before rejecting with
* `CONNECT_TIMEOUT`. Default `30_000`.
*/
readonly connectTimeoutMs?: number | undefined;
/** Required factory wiring the underlying p2pt instance. */
readonly p2ptFactory: P2PTFactory;
}
export type WebTorrentEventMap = {
"webtorrent:started": WebTorrentStartedEvent;
"webtorrent:peer-discovered": WebTorrentDiscoveredEvent;
"webtorrent:peer-lost": WebTorrentLostEvent;
"webtorrent:tracker-error": WebTorrentTrackerErrorEvent;
"webtorrent:stopped": WebTorrentStoppedEvent;
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-discovery-webtorrent dep-graph", () => {
it("must not have circular dependencies", { timeout: 5000 }, async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.WebTorrentDiscovery).toBeDefined();
expect(mod.NET_DISC_WEBTORRENT_CODES).toBeDefined();
expect(mod.NetDiscWebTorrentError).toBeDefined();
expect(mod.WebTorrentDiscoveryConfigSchema).toBeDefined();
expect(mod.WebTorrentPeerMetadataSchema).toBeDefined();
expect(mod.withStartSpan).toBeDefined();
expect(mod.recordPeersDiscovered).toBeDefined();
expect(mod.recordTrackerError).toBeDefined();
expect(mod.recordTrackerAnnounceDuration).toBeDefined();
expect(mod.WebTorrentTransport).toBeDefined();
expect(mod.WebTorrentPlugin).toBeDefined();
expect(mod.WebTorrentTransportConfigSchema).toBeDefined();
expect(mod.recordTransportMessage).toBeDefined();
expect(mod.adjustTransportActivePeers).toBeDefined();
expect(mod.withTransportSpan).toBeDefined();
});
});

View File

@@ -0,0 +1,95 @@
import type { P2PTLike } from "../../src/types/types.js";
/**
* In-memory deterministic implementation of the `P2PTLike` surface. Used
* across every test in this package so the real `p2pt` runtime is never
* pulled into the test environment (Node-flavoured, expects WebSocket /
* WebRTC globals) and `vi.mock` is never needed.
*/
export class FakeP2PT implements P2PTLike {
public started = false;
public destroyed = false;
public readonly trackers: readonly string[];
public readonly announceId: string;
public startError: Error | null = null;
private readonly handlers = new Map<
string,
Array<(...args: unknown[]) => void>
>();
public constructor(trackers: readonly string[], announceId: string) {
this.trackers = trackers;
this.announceId = announceId;
}
public on(event: string, handler: (...args: unknown[]) => void): void {
const list = this.handlers.get(event) ?? [];
list.push(handler);
this.handlers.set(event, list);
}
public async start(): Promise<void> {
if (this.startError !== null) {
throw this.startError;
}
this.started = true;
}
public destroy(): void {
this.destroyed = true;
// Keep handlers around so tests can drive late events through a
// captured reference and exercise the orchestrator's `running`
// guard. Real p2pt instances detach internally; the orchestrator
// never re-uses a destroyed instance.
}
public requestMorePeers(): void {
/* no-op in tests */
}
/** Captured outbound sends for transport-adapter tests. */
public readonly outbound: Array<{ peer: unknown; msg: unknown }> = [];
/** Programmable per-call failure for `send`. */
public sendError: Error | null = null;
public send(peer: unknown, msg: unknown): Promise<void> {
if (this.sendError !== null) {
return Promise.reject(this.sendError);
}
this.outbound.push({ peer, msg });
return Promise.resolve();
}
/** Fire an event into every subscribed handler. Tests use this to
* drive the orchestrator. */
public emit(event: string, ...args: unknown[]): void {
const list = this.handlers.get(event);
if (list === undefined) return;
for (const handler of [...list]) {
handler(...args);
}
}
}
/**
* Factory helper producing a `FakeP2PT` and recording every instance the
* factory created so the test can introspect destroy / start state.
*/
export function makeFakeFactory(): {
readonly instances: FakeP2PT[];
readonly factory: (
trackers: readonly string[],
announceId: string
) => FakeP2PT;
} {
const instances: FakeP2PT[] = [];
return {
instances,
factory: (trackers, announceId) => {
const inst = new FakeP2PT(trackers, announceId);
instances.push(inst);
return inst;
},
};
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-discovery-webtorrent",
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/service-discovery",
"@suaveplan/timing",
"@suaveplan/types",
"p2pt",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

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