first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.