Initial Commit
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { WebTorrentDiscovery } from "./webtorrent-discovery.js";
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user