Initial Commit

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

View File

@@ -0,0 +1,395 @@
# @suaveplan/drive-webrtc - WebRTC P2P mesh plugin for @suaveplan/drive: signaling, peer management, data channels, chunk discovery, real signature verification.
## Summary
Browser-tier WebRTC P2P chunk-distribution plugin for
[`@suaveplan/drive`](../drive/README.md). Provides a complete mesh
networking stack — signalling client, ICE-aware WebRTC client, peer
manager, data-channel manager, integrity verifier, chunk-discovery
engine, and a `ChunkSource` consumable by Drive — wrapped in a single
`WebRTCDrivePlugin` orchestrator. Every layer is constructed with real
implementations (`@suaveplan/crypto-curve` Ed25519, `@suaveplan/crypto-hash`
SHA-256, `@suaveplan/events` event bus, `@suaveplan/error` error
codes), and every async path threads `AbortSignal` so cooperative
cancellation works end-to-end.
This package targets the browser (`globalThis.WebSocket`,
`globalThis.RTCPeerConnection`) but accepts constructor injections so
tests and SSR contexts can wire deterministic doubles.
---
<!-- begin:auto-docs -->
## Documentation tree
- [chunk-source](./src/chunk-source/chunk-source/chunk-source.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chunk-source](./src/chunk-source/chunk-source/chunk-source.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes.ts` module defines the closed registry of error codes that `@suaveplan/drive-webrtc` may throw, plus t…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes.ts` module defines the closed registry of error codes that `@suaveplan/drive-webrtc` may throw, plus t…
- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [full-mesh](./src/peer-manager/topology/full-mesh.md) — Every peer connects to every other peer up to a configurable cap. This is the default topology and matches the assumpti…
- [hub-and-spoke](./src/peer-manager/topology/hub-and-spoke.md) — `HubAndSpokeStrategy` pins the mesh into a star topology: a single designated hub accepts connections from every spoke,…
- [super-peer](./src/peer-manager/topology/super-peer.md) — The super-peer topology lets well-resourced nodes act as relays for the rest of the mesh. Super peers form a full mesh…
- [topology-strategy](./src/peer-manager/topology/topology-strategy.md) — `TopologyStrategy` is the pure-function contract `PeerManager` consults when a new peer is announced. A strategy decide…
- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [config.schemas](./src/schemas/config.schemas.md) — The `config.schemas.ts` module defines Zod boundary schemas for the configuration surface declared in `types/config.typ…
- [signaling.schemas](./src/schemas/signaling.schemas.md) — The `signaling.schemas.ts` module exposes Zod schemas mirroring every variant of `SignalingMessage` from `types/message…
- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [chunk-source](./src/chunk-source/chunk-source/chunk-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [full-mesh](./src/peer-manager/topology/full-mesh.md) — Every peer connects to every other peer up to a configurable cap. This is the default topology and matches the assumpti…
- [hub-and-spoke](./src/peer-manager/topology/hub-and-spoke.md) — `HubAndSpokeStrategy` pins the mesh into a star topology: a single designated hub accepts connections from every spoke,…
- [super-peer](./src/peer-manager/topology/super-peer.md) — The super-peer topology lets well-resourced nodes act as relays for the rest of the mesh. Super peers form a full mesh…
- [topology-strategy](./src/peer-manager/topology/topology-strategy.md) — `TopologyStrategy` is the pure-function contract `PeerManager` consults when a new peer is announced. A strategy decide…
- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_- [config.schemas](./src/schemas/config.schemas.md) — The `config.schemas.ts` module defines Zod boundary schemas for the configuration surface declared in `types/config.typ…
- [signaling.schemas](./src/schemas/signaling.schemas.md) — The `signaling.schemas.ts` module exposes Zod schemas mirroring every variant of `SignalingMessage` from `types/message…
- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — The `error-codes.ts` module defines the closed registry of error codes that `@suaveplan/drive-webrtc` may throw, plus t…
- [chunk-source](./src/chunk-source/chunk-source/chunk-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [full-mesh](./src/peer-manager/topology/full-mesh.md) — Every peer connects to every other peer up to a configurable cap. This is the default topology and matches the assumpti…
- [hub-and-spoke](./src/peer-manager/topology/hub-and-spoke.md) — `HubAndSpokeStrategy` pins the mesh into a star topology: a single designated hub accepts connections from every spoke,…
- [super-peer](./src/peer-manager/topology/super-peer.md) — The super-peer topology lets well-resourced nodes act as relays for the rest of the mesh. Super peers form a full mesh…
- [topology-strategy](./src/peer-manager/topology/topology-strategy.md) — `TopologyStrategy` is the pure-function contract `PeerManager` consults when a new peer is announced. A strategy decide…
- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_- [config.schemas](./src/schemas/config.schemas.md) — The `config.schemas.ts` module defines Zod boundary schemas for the configuration surface declared in `types/config.typ…
- [signaling.schemas](./src/schemas/signaling.schemas.md) — The `signaling.schemas.ts` module exposes Zod schemas mirroring every variant of `SignalingMessage` from `types/message…
- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — The `error-codes.ts` module defines the closed registry of error codes that `@suaveplan/drive-webrtc` may throw, plus t…
- [chunk-source](./src/chunk-source/chunk-source/chunk-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [full-mesh](./src/peer-manager/topology/full-mesh.md) — Every peer connects to every other peer up to a configurable cap. This is the default topology and matches the assumpti…
- [hub-and-spoke](./src/peer-manager/topology/hub-and-spoke.md) — `HubAndSpokeStrategy` pins the mesh into a star topology: a single designated hub accepts connections from every spoke,…
- [super-peer](./src/peer-manager/topology/super-peer.md) — The super-peer topology lets well-resourced nodes act as relays for the rest of the mesh. Super peers form a full mesh…
- [topology-strategy](./src/peer-manager/topology/topology-strategy.md) — `TopologyStrategy` is the pure-function contract `PeerManager` consults when a new peer is announced. A strategy decide…
- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_- [config.schemas](./src/schemas/config.schemas.md) — The `config.schemas.ts` module defines Zod boundary schemas for the configuration surface declared in `types/config.typ…
- [signaling.schemas](./src/schemas/signaling.schemas.md) — The `signaling.schemas.ts` module exposes Zod schemas mirroring every variant of `SignalingMessage` from `types/message…
- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — The `error-codes.ts` module defines the closed registry of error codes that `@suaveplan/drive-webrtc` may throw, plus t…
- [chunk-source](./src/chunk-source/chunk-source/chunk-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [client](./src/client/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ice](./src/client/ice/ice.md) — <!-- begin:auto-docs --> _(no submodules)_- [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [offer-answer](./src/client/offer-answer/offer-answer.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_ - [reconnect](./src/client/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [data-channel-manager](./src/data-channel/data-channel-manager/data-channel-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chunk-discovery-manager](./src/discovery/chunk-discovery-manager/chunk-discovery-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [typed-event-emitter](./src/events/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chunk-integrity](./src/integrity/chunk-integrity/chunk-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [lru-cache](./src/lru-cache/lru-cache/lru-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [peer-bandwidth-tracker](./src/peer-manager/peer-bandwidth-tracker/peer-bandwidth-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [peer-manager](./src/peer-manager/peer-manager/peer-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [full-mesh](./src/peer-manager/topology/full-mesh.md) — Every peer connects to every other peer up to a configurable cap. This is the default topology and matches the assumpti…
- [hub-and-spoke](./src/peer-manager/topology/hub-and-spoke.md) — `HubAndSpokeStrategy` pins the mesh into a star topology: a single designated hub accepts connections from every spoke,…
- [super-peer](./src/peer-manager/topology/super-peer.md) — The super-peer topology lets well-resourced nodes act as relays for the rest of the mesh. Super peers form a full mesh…
- [topology-strategy](./src/peer-manager/topology/topology-strategy.md) — `TopologyStrategy` is the pure-function contract `PeerManager` consults when a new peer is announced. A strategy decide…
- [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [webrtc-plugin](./src/plugin/webrtc-plugin/webrtc-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rate-limiter](./src/rate-limiter/rate-limiter/rate-limiter.md) — <!-- begin:auto-docs --> _(no submodules)_- [config.schemas](./src/schemas/config.schemas.md) — The `config.schemas.ts` module defines Zod boundary schemas for the configuration surface declared in `types/config.typ…
- [signaling.schemas](./src/schemas/signaling.schemas.md) — The `signaling.schemas.ts` module exposes Zod schemas mirroring every variant of `SignalingMessage` from `types/message…
- [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [security-validator](./src/security/security-validator/security-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [signaling-client](./src/signaling/signaling-client/signaling-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sub-chunk](./src/sub-chunk/sub-chunk/sub-chunk.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — The `error-codes.ts` module defines the closed registry of error codes that `@suaveplan/drive-webrtc` may throw, plus t…
## Quick Start
```bash
bun add @suaveplan/drive-webrtc
```
```ts
import { Drive } from "@suaveplan/drive";
import { WebRTCDrivePlugin } from "@suaveplan/drive-webrtc";
const plugin = new WebRTCDrivePlugin({
peerId: crypto.randomUUID(),
config: {
signaling: { url: "wss://signal.example.com/p2p" },
p2p: {
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
},
},
signalingUrl: "wss://signal.example.com/p2p",
});
await plugin.start();
const drive = new Drive(/* storage */);
// Drive integration: hand the chunk source to your read pipeline.
const bytes = await plugin.chunkSource.fetchChunk("chunk:42");
// Subscribe to any of the plugin-level events.
plugin.on("webrtc:connected", ({ peerId }) => {
console.info(`p2p peer ${peerId} is up`);
});
// On shutdown:
await plugin.stop();
```
---
## Subpath Map
Every internal subsystem is published under a dedicated subpath so
downstream apps tree-shake to the minimum surface they need. Eleven
tree-shake snapshots in `tests/bundle-size/` enforce the leaf
boundaries.
| Subpath | Primary export | Purpose |
| -------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------- |
| `.` (root) | Everything | Convenience barrel — re-exports every public surface below. |
| `./plugin/webrtc-plugin` | `WebRTCDrivePlugin` | Top-level orchestrator. Start here. |
| `./client/webrtc-client` | `WebRTCClient`, `resolveConfig` | ICE-aware peer-connection lifecycle. |
| `./signaling/signaling-client` | `SignalingClient` | WebSocket signalling with bounded-backoff reconnect. |
| `./peer-manager/peer-manager` | `PeerManager` | Peer admission / eviction + bandwidth-scored ranking. |
| `./data-channel/data-channel-manager` | `DataChannelManager` | Multiplexed `RTCDataChannel` lifecycle + backpressure. |
| `./integrity/chunk-integrity` | `ChunkIntegrityManager` | Real SHA-256 + Ed25519 verify per inbound chunk. |
| `./chunk-source/chunk-source` | `WebRTCChunkSource` | Drive-facing `ChunkSource` backed by the discovery manager. |
| `./rate-limiter/rate-limiter` | `RateLimiter` | Token-bucket rate limiter. |
| `./lru-cache/lru-cache` | `LRUCache` | Bounded LRU cache for chunk hot-path memoisation. |
| `./security/security-validator` | `validateSignalingUrl`, `generateSecurePeerId`, ... | UUID v4 + signalling-URL guards. |
| `./sub-chunk/sub-chunk-splitter` | `splitIntoSubChunks`, `SubChunkReassembler` | Sub-chunk fragmentation + reassembly for data-channel transfers. |
| `./errors` | `DRIVE_WEBRTC_CODES`, `DriveWebRtcError` | Stable typed error registry. |
| `./schemas` | Zod schemas | Wire-format validation for every signalling + config surface. |
---
## ICE / TURN Operations
WebRTC peers exchange ICE candidates over signalling; in production
networks behind symmetric NATs or restrictive firewalls, peers cannot
reach each other directly and require a TURN relay. The `p2p.iceServers`
list is exhaustively typed as `RTCIceServer[]`:
```ts
{
p2p: {
iceServers: [
// STUN: enough on permissive networks.
{ urls: ["stun:stun.l.google.com:19302"] },
// TURN: relays the media path through your infra; required
// for restrictive corp / cell networks. Costs egress.
{
urls: [
"turn:turn.example.com:3478?transport=udp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:5349?transport=tcp",
],
username: "user",
credential: "secret",
},
],
},
}
```
### TURN URL format
```
turn://username:credential@host:port?transport={udp,tcp}
turns://username:credential@host:port?transport=tcp
```
`turn:` is unencrypted; `turns:` runs TURN over TLS (required for some
mobile carrier networks). Always provide BOTH UDP and TCP transports —
some networks block UDP/3478 entirely.
### ICE-restart policy
`WebRTCClient` automatically restarts ICE up to `maxIceRestartAttempts`
times (default `3`) when the connection enters the `failed` state. After
the cap is exhausted, `ice:state` reports `failed` once more without a
further restart and the plugin evicts the peer. Tune the cap with the
`maxIceRestartAttempts` option on `WebRTCClient` (the plugin forwards
this through `config`).
### Exhaustion symptoms
- Many `webrtc:failed` events: peers cannot complete ICE — check TURN
reachability, credentials, port allowlists.
- Bandwidth blackholes: high `bytesIn`, zero `bytesOut` — direct path
blocked, TURN relay forced; verify TURN provider quota.
- `signaling:reconnect` storms: signalling URL or TLS misconfiguration —
check `SignalingClient.state` + `reconnect:exhausted` events.
---
## Topology Strategies
`PeerManager` consumes a `TopologyStrategy`. Three are bundled:
- **`FullMeshStrategy`** (default): every peer connects to every other
peer. Best for small meshes (<30 peers) where every pair benefits
from a direct edge. Memory + CPU scales O(n^2) with mesh size.
- **`SuperPeerStrategy`**: nominate one or more high-bandwidth /
high-uptime peers as super peers; other peers only connect to super
peers + a small set of equal peers. Best for asymmetric capacity
(server-grade super peer + browser leaves). Reduces edge count to
O(n).
- **`HubAndSpokeStrategy`**: every peer connects to one of a small
configured hub set. Best for centralised topologies (e.g. server-
assisted mesh). Lowest edge count, simplest debug surface, no peer-
to-peer privacy.
Pick `FullMeshStrategy` by default. Move to `SuperPeerStrategy` when
peer counts exceed ~20 and `HubAndSpokeStrategy` when the application
is fundamentally hub-mediated.
---
## Telemetry
Every long-lived component emits OpenTelemetry instrumentation. Names
are stable across patch releases:
| Metric | Type | Attributes | Description |
| ------------------------------------------------- | --------- | --------------------------- | ---------------------------------------------------- |
| `drive.webrtc.peers_connected_total` | counter | - | Peers that have reached ICE `connected`. |
| `drive.webrtc.chunks_served_total` | counter | `outcome` | Local chunk-serve outcomes (`ok`/`not-found`/...). |
| `drive.webrtc.peer_rtt_ms` | histogram | `peer_id` | Round-trip latency per peer (ms). |
| `drive.webrtc.signaling_reconnect_ms` | histogram | `attempt`,`outcome` | Reconnect wall-time. |
| `drive.webrtc.signaling_messages_total` | counter | `direction`,`outcome` | Signalling messages by direction (in/out) + outcome. |
Every collaborator additionally emits its own span (`signaling.connect`,
`plugin.start`, `webrtc.ice.restart`, ...). Wire your OTel exporter at
process boot — the package does nothing else to initialise the API.
---
## Architecture (text diagram)
```
+---------------------------+
| WebRTCDrivePlugin |
| (start / stop / stats) |
+-----+----------+----------+
| |
+-------------+ +--------------+
v v
+-------------------+ +----------------------+
| SignalingClient | wss frames | WebRTCClient |
| (WebSocket) | <--------------> | (RTCPeerConnection) |
+---------+---------+ +----------+-----------+
| |
| peer-joined / peer-left | datachannel
v v
+-------------------+ +----------------------+
| PeerManager | selects peers | DataChannelManager |
| + topology | <--------------> | + backpressure |
+---------+---------+ +----------+-----------+
| |
| peer rank, bandwidth | sub-chunk
v v
+-------------------+ +----------------------+
| PeerBandwidth | | SubChunkReassembler |
| Tracker | | |
+-------------------+ +----------+-----------+
|
v
+----------------------+
| ChunkDiscoveryMgr |
| (ranks, dedups, ver) |
+----------+-----------+
|
v
+----------------------+
| ChunkIntegrityMgr |
| (SHA-256, Ed25519) |
+----------+-----------+
|
v
+----------------------+
| WebRTCChunkSource |
| (Drive surface) |
+----------------------+
```
Leaf utilities (`LRUCache`, `RateLimiter`, `security-validator`,
`sub-chunk-splitter`) are referenced from the components above but have
no upward dependency — they remain individually importable.
---
## Configuration Schemas
All wire-format and configuration shapes are validated with Zod under
`./schemas`. The discriminator-bearing
`SignalingMessageSchema` is the single source of truth for every
signalling frame; `WebRTCPluginConfigSchema` validates plugin
construction options. Direct schema access is available via:
```ts
import {
SignalingMessageSchema,
WebRTCPluginConfigSchema,
} from "@suaveplan/drive-webrtc/schemas";
```
---
## Error Codes
Every boundary throws `DriveWebRtcError` carrying one of the codes from
`DRIVE_WEBRTC_CODES`:
- `SIGNALING_UNREACHABLE`
- `PEER_HANDSHAKE_FAILED`
- `CHUNK_INTEGRITY_FAILED`
- `ICE_FAILED`
- `PEER_BANDWIDTH_EXCEEDED`
- `RATE_LIMIT_EXCEEDED`
- `SIGNALING_MESSAGE_INVALID`
- `CONFIG_INVALID`
- `PEER_NOT_FOUND`
- `DATA_CHANNEL_CLOSED`
- `SUB_CHUNK_TIMEOUT`
- `SUB_CHUNK_MISSING`
- `CHUNK_NOT_AVAILABLE`
Import them directly from the leaf module:
```ts
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "@suaveplan/drive-webrtc/errors";
```
---
## Drive plugin contract deviation
The Drive package exposes the `DrivePlugin<THandle>` interface with an
`install(drive)` shape. The new `WebRTCDrivePlugin` orchestrator surface
specified by the migration uses `start()` / `stop()` / `stats()`
instead — these two contracts are not directly compatible. The
orchestrator class therefore exposes its own lifecycle and a
`name: "drive-webrtc"` literal; consumers wire it to Drive with a thin
adapter:
```ts
const adapter = {
name: "drive-webrtc",
async install() {
await plugin.start();
return { chunkSource: plugin.chunkSource };
},
async dispose() {
await plugin.stop();
},
};
await drive.usePlugin(adapter);
```
This deviation is intentional and documented in `webrtc-plugin.md`.
---
## License
MIT.

View File

@@ -0,0 +1,145 @@
{
"name": "@suaveplan/drive-webrtc",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "WebRTC P2P mesh plugin for @suaveplan/drive: signaling, peer management, data channels, chunk discovery, real signature verification.",
"license": "MIT",
"author": "Suaveplan Team",
"browser": "./dist/index.js",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./client/webrtc-client": {
"import": {
"types": "./dist/client/client/client.d.ts",
"default": "./dist/client/client/client.js"
}
},
"./chunk-source/chunk-source": {
"import": {
"types": "./dist/chunk-source/chunk-source/chunk-source.d.ts",
"default": "./dist/chunk-source/chunk-source/chunk-source.js"
}
},
"./plugin/webrtc-plugin": {
"import": {
"types": "./dist/plugin/webrtc-plugin/webrtc-plugin.d.ts",
"default": "./dist/plugin/webrtc-plugin/webrtc-plugin.js"
}
},
"./signaling/signaling-client": {
"import": {
"types": "./dist/signaling/signaling-client/signaling-client.d.ts",
"default": "./dist/signaling/signaling-client/signaling-client.js"
}
},
"./peer-manager/peer-manager": {
"import": {
"types": "./dist/peer-manager/peer-manager/peer-manager.d.ts",
"default": "./dist/peer-manager/peer-manager/peer-manager.js"
}
},
"./data-channel/data-channel-manager": {
"import": {
"types": "./dist/data-channel/data-channel-manager/data-channel-manager.d.ts",
"default": "./dist/data-channel/data-channel-manager/data-channel-manager.js"
}
},
"./rate-limiter/rate-limiter": {
"import": {
"types": "./dist/rate-limiter/rate-limiter/rate-limiter.d.ts",
"default": "./dist/rate-limiter/rate-limiter/rate-limiter.js"
}
},
"./lru-cache/lru-cache": {
"import": {
"types": "./dist/lru-cache/lru-cache/lru-cache.d.ts",
"default": "./dist/lru-cache/lru-cache/lru-cache.js"
}
},
"./integrity/chunk-integrity": {
"import": {
"types": "./dist/integrity/chunk-integrity/chunk-integrity.d.ts",
"default": "./dist/integrity/chunk-integrity/chunk-integrity.js"
}
},
"./security/security-validator": {
"import": {
"types": "./dist/security/security-validator/security-validator.d.ts",
"default": "./dist/security/security-validator/security-validator.js"
}
},
"./sub-chunk/sub-chunk-splitter": {
"import": {
"types": "./dist/sub-chunk/sub-chunk/sub-chunk.d.ts",
"default": "./dist/sub-chunk/sub-chunk/sub-chunk.js"
}
},
"./errors": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto": "workspace:^",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/drive": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/packer": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"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"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,99 @@
# `WebRTCChunkSource`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebRTCChunkSource` is the drive-facing facade over `ChunkDiscoveryManager`. It presents a small, stable API — `id`, `health`, `fetchChunk(chunkId)`, `prefetch(chunkIds)` — that an upstream `@suaveplan/drive` instance can consume without knowing the topology, bandwidth, or integrity primitives underneath. An optional `LRUCache<string, Uint8Array>` lets the caller short-circuit repeated reads of hot chunks without hammering the WebRTC mesh.
A single `WebRTCChunkSource` instance is intended to live for the lifetime of a drive session: it holds no state itself beyond the wired discovery manager and the optional cache, so closing the underlying mesh implicitly tears down the source.
## Features
- Plug-and-play surface for the drive integration layer (`id`, `health`, `fetchChunk`, `prefetch`).
- Optional in-memory LRU cache with the same eviction semantics as the rest of the package (size + TTL).
- `defaultPublicKey` for environments where every chunk is signed by the same authority, overridable per-call.
- `prefetch()` fire-and-forget — errors swallowed, de-dup handled by the discovery manager.
- `health.available` flips to `false` when the peer registry is empty.
- AbortSignal threaded all the way to the wire.
## Usage: Basic Example
```ts
import {
ChunkDiscoveryManager,
WebRTCChunkSource,
} from "@suaveplan/drive-webrtc";
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels,
integrity,
});
const source = new WebRTCChunkSource({ discovery });
const bytes = await source.fetchChunk("chunk-abc");
```
## Usage: Advanced Example
Wire a 64-entry LRU cache, a publisher key, and warm-start the cache by prefetching a contiguous range of hot chunks:
```ts
import { LRUCache, WebRTCChunkSource } from "@suaveplan/drive-webrtc";
const cache = new LRUCache<string, Uint8Array>({
maxSize: 64,
ttlMs: 5 * 60_000,
});
const source = new WebRTCChunkSource({
discovery,
defaultPublicKey: publisherKey,
cache,
id: "p2p-webrtc",
});
source.prefetch(["chunk-1", "chunk-2", "chunk-3"]);
const controller = new AbortController();
const bytes = await source.fetchChunk("chunk-1", {
signal: controller.signal,
publicKey: someOtherKey, // overrides defaultPublicKey for this call
});
if (!source.health.available) {
console.warn("WebRTC mesh has no peers:", source.health.reason);
}
```
## API Reference
### `new WebRTCChunkSource(options)`
- `discovery: ChunkDiscoveryManager` — wire transport.
- `defaultPublicKey?: Uint8Array` — Ed25519 publisher key applied when no per-call key is supplied.
- `cache?: LRUCache<string, Uint8Array>` — optional in-memory cache.
- `id?: string` (default `"webrtc"`) — exposed via the `id` field.
### `id: string`
Stable identifier for the chunk source. Used by the drive layer to dedupe sources and tag metrics.
### `health: WebRTCChunkSourceHealth` (getter)
`{ available: boolean; reason?: string; latencyMs?: number }`. `available` is `true` when at least one peer is registered with the underlying `PeerManager`, otherwise `false` with `reason: "no-peers"`.
### `fetchChunk(chunkId, options?)` → `Promise<Uint8Array>`
Returns the chunk bytes. Honours the cache on hit; on miss delegates to `discovery.request()` and caches the verified result.
### `prefetch(chunkIds)`
Fire-and-forget. Each `fetchChunk` is invoked with `.catch(() => undefined)` so failures never surface to the caller.
## Implementation Notes
`health` is intentionally cheap — it reads `peerManager.list().length`. We do not poll latency or scoring details here because the cost would scale with peer fan-out; richer diagnostics belong in a separate observability adapter that subscribes to the discovery manager's event stream. The cache is filled with the verified post-integrity bytes only, never with provisional or partial-stream data, so a cache hit is always safe to return without re-verification.

View File

@@ -0,0 +1,382 @@
/**
* Tests for {@link WebRTCChunkSource}. Built on top of a real
* ChunkDiscoveryManager wired against the same structural
* MemoryDataChannelManager used in the discovery-manager test
* fixture — no mocks, no `vi.stubGlobal`, no `vi.mock`.
*/
import { sha256 } from "@suaveplan/crypto-hash";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type { DataChannelManager } from "../../data-channel/data-channel-manager/index.js";
import { ChunkDiscoveryManager } from "../../discovery/chunk-discovery-manager/index.js";
import type { DriveWebRtcError } from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
import { ChunkIntegrityManager } from "../../integrity/chunk-integrity/index.js";
import { LRUCache } from "../../lru-cache/lru-cache/index.js";
import { PeerBandwidthTracker } from "../../peer-manager/peer-bandwidth-tracker/index.js";
import { PeerManager } from "../../peer-manager/peer-manager/index.js";
import { encodeToBase64 } from "../../sub-chunk/sub-chunk/index.js";
import { WebRTCChunkSource } from "./chunk-source.js";
interface ChannelEventMap {
"channel:open": [{ peerId: string; label: string }];
"channel:close": [{ peerId: string; label: string; reason?: string }];
"channel:message": [
{ peerId: string; label: string; data: ArrayBuffer | string },
];
"channel:error": [
{ peerId: string; label: string; error: DriveWebRtcError },
];
}
interface SendRecord {
peerId: string;
label: string;
payload: ArrayBuffer | string;
}
class MemoryDataChannelManager extends TypedEventEmitter<ChannelEventMap> {
public readonly sends: SendRecord[] = [];
public async send(
peerId: string,
label: string,
payload: ArrayBuffer | string,
_signal?: AbortSignal
): Promise<void> {
this.sends.push({ peerId, label, payload });
}
public simulateIncoming(peerId: string, label: string, data: string): void {
this.emit("channel:message", { peerId, label, data });
}
}
function asReal(m: MemoryDataChannelManager): DataChannelManager {
return m as unknown as DataChannelManager;
}
function parseRequestId(payload: string): string {
return (JSON.parse(payload) as { requestId: string }).requestId;
}
function chunkDataMessage(args: {
requestId: string;
chunkId: string;
data: Uint8Array;
hash: Uint8Array;
}): string {
return JSON.stringify({
type: "chunk-data",
payload: {
requestId: args.requestId,
fileId: args.chunkId,
chunkId: args.chunkId,
data: encodeToBase64(args.data),
hash: encodeToBase64(args.hash),
},
});
}
function buildHarness(opts?: { cache?: LRUCache<string, Uint8Array> }): {
discovery: ChunkDiscoveryManager;
channels: MemoryDataChannelManager;
peers: PeerManager;
source: WebRTCChunkSource;
} {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
requestTimeoutMs: 250,
});
const source = new WebRTCChunkSource({
discovery,
...(opts?.cache !== undefined ? { cache: opts.cache } : {}),
});
return { discovery, channels, peers, source };
}
function admitPeer(peers: PeerManager, id: string, chunks: string[]): void {
peers.admit({
id,
name: id,
chunks: new Set(chunks),
connectionState: "connected",
lastSeen: Date.now(),
});
}
let openHarnesses: Array<{ discovery: ChunkDiscoveryManager }> = [];
beforeEach(() => {
openHarnesses = [];
});
afterEach(() => {
for (const h of openHarnesses) h.discovery.closeAll();
});
describe("WebRTCChunkSource", () => {
it("exposes the default id 'webrtc'", () => {
const h = buildHarness();
openHarnesses.push(h);
expect(h.source.id).toBe("webrtc");
});
it("supports a custom id", () => {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
});
openHarnesses.push({ discovery });
const source = new WebRTCChunkSource({ discovery, id: "custom" });
expect(source.id).toBe("custom");
});
it("health.available reflects peer count", { timeout: 5000 }, async () => {
const h = buildHarness();
openHarnesses.push(h);
expect(h.source.health.available).toBe(false);
expect(h.source.health.reason).toBe("no-peers");
admitPeer(h.peers, "alice", []);
expect(h.source.health.available).toBe(true);
});
it("fetchChunk delegates to discovery and returns bytes", {
timeout: 5000,
}, async () => {
const h = buildHarness();
openHarnesses.push(h);
admitPeer(h.peers, "alice", ["c1"]);
const data = new Uint8Array([1, 2, 3]);
const hash = sha256(data);
const promise = h.source.fetchChunk("c1");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({ requestId: reqId, chunkId: "c1", data, hash })
);
const result = await promise;
expect(Array.from(result)).toEqual([1, 2, 3]);
});
it("returns cached bytes on a hit without dispatching a wire request", {
timeout: 5000,
}, async () => {
const cache = new LRUCache<string, Uint8Array>({ maxSize: 4 });
const h = buildHarness({ cache });
openHarnesses.push(h);
admitPeer(h.peers, "alice", ["c2"]);
const data = new Uint8Array([9, 8, 7]);
const hash = sha256(data);
const p1 = h.source.fetchChunk("c2");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({ requestId: reqId, chunkId: "c2", data, hash })
);
await p1;
expect(h.channels.sends.length).toBe(1);
const result = await h.source.fetchChunk("c2");
expect(Array.from(result)).toEqual([9, 8, 7]);
expect(h.channels.sends.length).toBe(1);
});
it("evicts past the cache cap", { timeout: 5000 }, async () => {
const cache = new LRUCache<string, Uint8Array>({ maxSize: 1 });
const h = buildHarness({ cache });
openHarnesses.push(h);
admitPeer(h.peers, "alice", ["a", "b"]);
const dataA = new Uint8Array([1]);
const dataB = new Uint8Array([2]);
const pA = h.source.fetchChunk("a");
await new Promise((r) => setTimeout(r, 5));
const reqA = parseRequestId(h.channels.sends[0]!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqA,
chunkId: "a",
data: dataA,
hash: sha256(dataA),
})
);
await pA;
const pB = h.source.fetchChunk("b");
await new Promise((r) => setTimeout(r, 5));
const reqB = parseRequestId(h.channels.sends[1]!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqB,
chunkId: "b",
data: dataB,
hash: sha256(dataB),
})
);
await pB;
// Now "a" should have been evicted by "b"; a refetch must re-hit
// the wire.
const pA2 = h.source.fetchChunk("a");
await new Promise((r) => setTimeout(r, 5));
const lastSend = h.channels.sends[2];
expect(lastSend).toBeDefined();
const reqA2 = parseRequestId(lastSend!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqA2,
chunkId: "a",
data: dataA,
hash: sha256(dataA),
})
);
await pA2;
expect(h.channels.sends.length).toBe(3);
});
it("uses defaultPublicKey when none is provided per-call", {
timeout: 5000,
}, async () => {
// Wire a real PublicKey path. With a public key but no signature
// attached on the response, integrity verification must FAIL
// with missing-signature — proving the path is exercised.
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
requestTimeoutMs: 200,
});
openHarnesses.push({ discovery });
admitPeer(peers, "alice", ["c"]);
const fakePk = new Uint8Array(32);
const source = new WebRTCChunkSource({
discovery,
defaultPublicKey: fakePk,
});
const promise = source.fetchChunk("c");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(channels.sends[0]!.payload as string);
const data = new Uint8Array([1, 2]);
channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqId,
chunkId: "c",
data,
hash: sha256(data),
})
);
try {
await promise;
throw new Error("expected throw");
} catch (err) {
// Either integrity failure or exhaustion; both are acceptable
// signals that the publicKey path was traversed.
expect(err).toBeInstanceOf(Error);
}
});
it("prefetch fires fire-and-forget and swallows errors", {
timeout: 5000,
}, async () => {
const h = buildHarness();
openHarnesses.push(h);
// No peers advertise these chunks → discovery emits exhausted →
// prefetch must not throw.
h.source.prefetch(["a", "b", "c"]);
// Wait a turn so the rejected promises settle.
await new Promise((r) => setTimeout(r, 20));
expect(true).toBe(true);
});
it("AbortSignal propagates from fetchChunk", {
timeout: 5000,
}, async () => {
const h = buildHarness();
openHarnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const controller = new AbortController();
const promise = h.source.fetchChunk("c", {
signal: controller.signal,
});
await new Promise((r) => setTimeout(r, 5));
controller.abort();
try {
await promise;
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(Error);
}
});
it("per-call publicKey overrides defaultPublicKey", {
timeout: 5000,
}, async () => {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
requestTimeoutMs: 200,
});
openHarnesses.push({ discovery });
admitPeer(peers, "alice", ["c"]);
const source = new WebRTCChunkSource({
discovery,
defaultPublicKey: new Uint8Array(32),
});
const data = new Uint8Array([1]);
const promise = source.fetchChunk("c", {
publicKey: new Uint8Array(32),
});
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(channels.sends[0]!.payload as string);
channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqId,
chunkId: "c",
data,
hash: sha256(data),
})
);
try {
await promise;
} catch {
// Expected: missing-signature with the wrong PK.
}
});
});

View File

@@ -0,0 +1,93 @@
/**
* WebRTCChunkSource — drive-facing chunk source backed by
* {@link ChunkDiscoveryManager}.
*
* Implements the minimal {id, health, fetchChunk, prefetch} surface
* required by the new drive integration layer. Maintains an optional
* in-memory LRU cache to avoid re-fetching recently-served chunks.
*
* See `chunk-source.md` for prose docs.
*/
import type { ChunkDiscoveryManager } from "../../discovery/chunk-discovery-manager/index.js";
import type { LRUCache } from "../../lru-cache/lru-cache/index.js";
/** Health snapshot for a chunk source. */
export interface WebRTCChunkSourceHealth {
readonly available: boolean;
readonly latencyMs?: number;
readonly reason?: string;
}
/** Constructor options for {@link WebRTCChunkSource}. */
export interface ChunkSourceOptions {
readonly discovery: ChunkDiscoveryManager;
readonly defaultPublicKey?: Uint8Array;
readonly cache?: LRUCache<string, Uint8Array>;
readonly id?: string;
}
/**
* P2P chunk source over WebRTC data channels. Delegates resolution to
* a {@link ChunkDiscoveryManager} and (optionally) caches verified
* bytes in an LRU.
*/
export class WebRTCChunkSource {
public readonly id: string;
private readonly discovery: ChunkDiscoveryManager;
private readonly defaultPublicKey: Uint8Array | undefined;
private readonly cache: LRUCache<string, Uint8Array> | undefined;
public constructor(options: ChunkSourceOptions) {
this.id = options.id ?? "webrtc";
this.discovery = options.discovery;
this.defaultPublicKey = options.defaultPublicKey;
this.cache = options.cache;
}
/**
* Derived liveness. `available` reflects whether at least one peer
* is currently registered with the underlying discovery manager's
* peer registry.
*/
public get health(): WebRTCChunkSourceHealth {
const count = this.discovery.peerManager.list().length;
if (count > 0) return { available: true };
return { available: false, reason: "no-peers" };
}
/**
* Fetch a chunk. Returns cached bytes on hit; otherwise delegates
* to the discovery manager and (when a cache is wired) caches the
* verified result.
*/
public async fetchChunk(
chunkId: string,
options: { signal?: AbortSignal; publicKey?: Uint8Array } = {}
): Promise<Uint8Array> {
if (this.cache !== undefined) {
const hit = this.cache.get(chunkId);
if (hit !== undefined) return hit;
}
const publicKey = options.publicKey ?? this.defaultPublicKey;
const bytes = await this.discovery.request(chunkId, {
...(options.signal !== undefined ? { signal: options.signal } : {}),
...(publicKey !== undefined ? { publicKey } : {}),
});
if (this.cache !== undefined) {
this.cache.set(chunkId, bytes);
}
return bytes;
}
/**
* Fire-and-forget prefetch — caller does not await any of the
* resulting requests, errors are swallowed. De-duplication is
* handled by the discovery manager.
*/
public prefetch(chunkIds: readonly string[]): void {
for (const id of chunkIds) {
this.fetchChunk(id).catch(() => undefined);
}
}
}

View File

@@ -0,0 +1,5 @@
export {
type ChunkSourceOptions,
WebRTCChunkSource,
type WebRTCChunkSourceHealth,
} from "./chunk-source.js";

View File

@@ -0,0 +1,157 @@
# WebRTCClient
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebRTCClient` is the primary orchestrator surface in `@suaveplan/drive-webrtc`.
It owns a `Map<peerId, RTCPeerConnection>`, wires every peer's ICE,
connection-state, and data-channel listeners through the `ice` and
`offer-answer` helpers in this package, and emits a focused, typed event
stream describing peer-connection lifecycle. The class is intentionally
**not** the full P2P mesh — chunk transfer, peer discovery, and the
signaling-protocol glue land in sibling subsystems that consume the
connection lifecycle this class exposes.
Configuration goes through Zod at the boundary: `resolveConfig` runs
`WebRTCPluginConfigSchema.safeParse` and throws
`DriveWebRtcError(CONFIG_INVALID)` (with the Zod error chained via
`cause`) on any failure, then derives a fully-defaulted `ResolvedConfig`.
Tests inject a structural `FakeRTCPeerConnection` via the
`rtcPeerConnectionCtor` option — the same injection pattern
`SignalingClient` uses for `WebSocket` — so the package never relies on
`vi.stubGlobal` or any module-level monkey patching.
## Features
- Lazy per-peer `RTCPeerConnection` creation cached in an internal map.
- Spec-faithful offer/answer/ICE primitives delegated to the
`offer-answer` and `ice` modules so the orchestrator stays focused on
lifecycle and eventing.
- Bounded ICE auto-restart (default 3 attempts) with deterministic
`ice:restart` events carrying `{ peerId, attempt, timestamp }`.
- Typed event map (`WebRTCClientEventMap`) covering ICE candidates,
ICE state, connection state, data-channel open, and peer disconnect.
- `closePeer` / `closeAll` tear down listeners, close the underlying
connections, and emit a final `peer:disconnect`; both are idempotent.
- Every thrown WebRTC error is wrapped as `DriveWebRtcError` with the
original error preserved via `cause`.
## Usage: Basic Example
```ts
import { WebRTCClient } from "@suaveplan/drive-webrtc";
const client = new WebRTCClient({
peerId: "alice",
config: { signaling: { url: "wss://signal.example" } },
});
client.on("ice:candidate", ({ peerId, candidate }) =>
signaling.send({ type: "ice-candidate", to: peerId, payload: candidate })
);
const offer = await client.createOffer("bob");
signaling.send({ type: "offer", to: "bob", payload: offer });
// Later, after bob's answer arrives:
await client.handleAnswer("bob", remoteAnswer);
```
## Usage: Advanced Example
```ts
import { resolveConfig, WebRTCClient } from "@suaveplan/drive-webrtc";
const config = resolveConfig({
signaling: { url: "wss://signal.example" },
p2p: {
iceServers: [{ urls: "turn:turn.example", username: "u", credential: "p" }],
timeout: 15_000,
},
});
const client = new WebRTCClient({
peerId: "alice",
config,
// Tests / non-browser runtimes: inject the ctor explicitly.
rtcPeerConnectionCtor: FakeRTCPeerConnection,
clock: { now: () => deterministicClock.now() },
maxIceRestartAttempts: 5,
});
client.on("ice:restart", (info) => log.warn("ice restart", info));
client.on("peer:disconnect", (info) => peerManager.drop(info.peerId));
// Inbound handshake:
const answer = await client.handleOffer("bob", remoteOffer);
signaling.send({ type: "answer", to: "bob", payload: answer });
// Trickled candidates:
await client.addIceCandidate("bob", remoteCandidate);
// Teardown:
client.closePeer("bob", "session-end");
client.closeAll("client-shutdown");
```
## API Reference
### `new WebRTCClient(options)`
- `options.peerId: string`
- `options.config: WebRTCPluginConfig` — Zod-validated.
- `options.rtcPeerConnectionCtor?: typeof RTCPeerConnection`
- `options.clock?: { now: () => number }`
- `options.maxIceRestartAttempts?: number` (default 3)
Throws `DriveWebRtcError(CONFIG_INVALID)` when config validation fails
or when no `RTCPeerConnection` ctor is available.
### `createOffer(remotePeerId, signal?)`
Creates the peer connection if needed and returns the local SDP offer.
Throws `DriveWebRtcError(PEER_HANDSHAKE_FAILED)` on any failure.
### `handleOffer(remotePeerId, offer, signal?)`
Accepts a remote offer and returns the local answer.
### `handleAnswer(remotePeerId, answer)`
Applies a remote answer to an outbound offer. Throws
`DriveWebRtcError(PEER_NOT_FOUND)` if the peer was never registered.
### `addIceCandidate(remotePeerId, candidate)`
Wraps `pc.addIceCandidate`. Throws `DriveWebRtcError(ICE_FAILED)` on
failure or `PEER_NOT_FOUND` if the peer is unknown.
### `getPeerConnection(remotePeerId)`
Returns the underlying `RTCPeerConnection` or `undefined`. Stable across
calls for the same peer; distinct instances per peer.
### `closePeer(remotePeerId, reason?)` / `closeAll(reason?)`
Idempotent teardown. Removes listeners, closes the connection, deletes
the map entry, and emits a final `peer:disconnect`.
### `resolveConfig(input): ResolvedConfig`
Exported standalone so consumers can pre-validate configuration.
## Implementation Notes
- The class extends `TypedEventEmitter<WebRTCClientEventMap>` so handler
signatures are statically checked at every `emit`/`on` site.
- Construction-time Zod validation means downstream code can assume
`resolvedConfig` is always well-formed.
- `wirePeer` keeps the per-peer listener setup in one place; both
inbound and outbound flows share the exact same wiring.
- `connection:state === "closed" | "failed"` triggers an automatic
`peer:disconnect` so callers don't have to mirror the two state
machines.
- `closePeer` is idempotent (lookup-then-return on miss); duplicate
calls during teardown are harmless.

View File

@@ -0,0 +1,496 @@
/**
* Tests for {@link WebRTCClient}.
*
* The "no mocks" rule precludes `vi.stubGlobal` / `vi.mock`. Instead, the
* tests drive the client through a structural FakeRTCPeerConnection
* implementing the slice of the WebRTC surface the client touches. The
* Fake is a real `EventTarget` subclass; transitions are triggered
* synchronously via `simulateIceState` / `simulateConnectionState` /
* `simulateIceCandidate` / `simulateDataChannel`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import type { WebRTCPluginConfig } from "../../types/config.types.js";
import { resolveConfig, WebRTCClient } from "./client.js";
let constructed: FakeRTCPeerConnection[] = [];
class FakeRTCPeerConnection extends EventTarget {
public iceConnectionState: RTCIceConnectionState = "new";
public connectionState: RTCPeerConnectionState = "new";
public localDescription: RTCSessionDescriptionInit | null = null;
public remoteDescription: RTCSessionDescriptionInit | null = null;
public addedCandidates: RTCIceCandidateInit[] = [];
public restartIceCalls = 0;
public closed = false;
public createOfferImpl: () => Promise<RTCSessionDescriptionInit> =
async () => ({ type: "offer", sdp: "fake-offer" });
public createAnswerImpl: () => Promise<RTCSessionDescriptionInit> =
async () => ({ type: "answer", sdp: "fake-answer" });
public addIceCandidateImpl: (c: RTCIceCandidateInit) => Promise<void> =
async () => {};
public restartIceImpl: () => void = () => {};
public constructor(public readonly config?: RTCConfiguration) {
super();
constructed.push(this);
}
public createOffer(): Promise<RTCSessionDescriptionInit> {
return this.createOfferImpl();
}
public createAnswer(): Promise<RTCSessionDescriptionInit> {
return this.createAnswerImpl();
}
public async setLocalDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.localDescription = d;
}
public async setRemoteDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.remoteDescription = d;
}
public async addIceCandidate(
candidate: RTCIceCandidateInit
): Promise<void> {
await this.addIceCandidateImpl(candidate);
this.addedCandidates.push(candidate);
}
public restartIce(): void {
this.restartIceCalls += 1;
this.restartIceImpl();
}
public close(): void {
this.closed = true;
this.connectionState = "closed";
}
public simulateIceState(state: RTCIceConnectionState): void {
this.iceConnectionState = state;
this.dispatchEvent(new Event("iceconnectionstatechange"));
}
public simulateConnectionState(state: RTCPeerConnectionState): void {
this.connectionState = state;
this.dispatchEvent(new Event("connectionstatechange"));
}
public simulateIceCandidate(candidate: RTCIceCandidate | null): void {
const e = new Event("icecandidate") as RTCPeerConnectionIceEvent;
(e as unknown as { candidate: RTCIceCandidate | null }).candidate =
candidate;
this.dispatchEvent(e);
}
public simulateDataChannel(channel: RTCDataChannel): void {
const e = new Event("datachannel") as RTCDataChannelEvent;
(e as unknown as { channel: RTCDataChannel }).channel = channel;
this.dispatchEvent(e);
}
}
async function expectError(
fn: () => unknown | Promise<unknown>,
code: string
): Promise<DriveWebRtcError> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(code);
return err as DriveWebRtcError;
}
throw new Error(`expected throw with code ${code}`);
}
interface FreshClientOverrides {
config?: WebRTCPluginConfig;
maxIceRestartAttempts?: number;
clockNow?: () => number;
}
function freshClient(overrides: FreshClientOverrides = {}): {
client: WebRTCClient;
} {
constructed = [];
const client = new WebRTCClient({
peerId: "alice",
config: overrides.config ?? {
signaling: { url: "wss://signal.example" },
},
rtcPeerConnectionCtor:
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
...(overrides.clockNow !== undefined
? { clock: { now: overrides.clockNow } }
: {}),
...(overrides.maxIceRestartAttempts !== undefined
? { maxIceRestartAttempts: overrides.maxIceRestartAttempts }
: {}),
});
return { client };
}
describe("resolveConfig", () => {
it("applies defaults to a minimal config", { timeout: 5000 }, () => {
const resolved = resolveConfig({
signaling: { url: "wss://signal.example" },
});
expect(resolved.signaling.url).toBe("wss://signal.example");
expect(resolved.signaling.reconnect).toBe(true);
expect(resolved.signaling.reconnectDelay).toBe(1_000);
expect(resolved.p2p.iceServers.length).toBeGreaterThan(0);
expect(resolved.p2p.timeout).toBe(30_000);
expect(resolved.subChunks.size).toBe(16_000);
expect(resolved.subChunks.rateLimit.delayMs).toBe(5);
});
it("threads through caller-supplied overrides", { timeout: 5000 }, () => {
const resolved = resolveConfig({
signaling: { url: "wss://x", reconnect: false, peerId: "p" },
p2p: {
iceServers: [{ urls: "stun:custom" }],
timeout: 12_000,
priority: 1,
maxConcurrentDownloads: 9,
},
subChunks: { size: 8_000, backpressure: 1_000 },
});
expect(resolved.signaling.reconnect).toBe(false);
expect(resolved.signaling.peerId).toBe("p");
expect(resolved.p2p.iceServers).toEqual([{ urls: "stun:custom" }]);
expect(resolved.p2p.timeout).toBe(12_000);
expect(resolved.p2p.priority).toBe(1);
expect(resolved.p2p.maxConcurrentDownloads).toBe(9);
expect(resolved.subChunks.size).toBe(8_000);
expect(resolved.subChunks.backpressure).toBe(1_000);
});
it("throws DriveWebRtcError(CONFIG_INVALID) on Zod failure", {
timeout: 5000,
}, async () => {
const err = await expectError(
() =>
resolveConfig({
signaling: { url: "" },
} as unknown as WebRTCPluginConfig),
DRIVE_WEBRTC_CODES.CONFIG_INVALID
);
expect(err.cause).toBeDefined();
});
});
describe("WebRTCClient ctor", () => {
it("rejects when no RTCPeerConnection ctor is available", {
timeout: 5000,
}, async () => {
await expectError(
() =>
new WebRTCClient({
peerId: "alice",
config: { signaling: { url: "wss://x" } },
rtcPeerConnectionCtor:
undefined as unknown as typeof RTCPeerConnection,
}),
DRIVE_WEBRTC_CODES.CONFIG_INVALID
);
});
it("falls back to globalThis.RTCPeerConnection when set", {
timeout: 5000,
}, () => {
const original = (globalThis as { RTCPeerConnection?: unknown })
.RTCPeerConnection;
(globalThis as { RTCPeerConnection?: unknown }).RTCPeerConnection =
FakeRTCPeerConnection;
try {
const c = new WebRTCClient({
peerId: "alice",
config: { signaling: { url: "wss://x" } },
});
expect(c.peerId).toBe("alice");
} finally {
(globalThis as { RTCPeerConnection?: unknown }).RTCPeerConnection =
original;
}
});
it("exposes resolvedConfig", { timeout: 5000 }, () => {
const { client } = freshClient();
expect(client.resolvedConfig.signaling.url).toBe(
"wss://signal.example"
);
});
it("uses default clock for ICE restart timestamps", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
const pc = constructed[0] as FakeRTCPeerConnection;
let observed = 0;
client.on("ice:restart", ({ timestamp }) => {
observed = timestamp;
});
const before = Date.now();
pc.simulateIceState("failed");
const after = Date.now();
expect(observed).toBeGreaterThanOrEqual(before);
expect(observed).toBeLessThanOrEqual(after);
});
});
describe("WebRTCClient handshake", () => {
it("createOffer creates pc and applies local description", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
const offer = await client.createOffer("bob");
expect(offer.type).toBe("offer");
expect(constructed).toHaveLength(1);
expect(constructed[0]?.localDescription).toEqual(offer);
expect(constructed[0]?.config?.iceServers?.length).toBeGreaterThan(0);
});
it("handleOffer applies remote then returns answer", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
const incoming: RTCSessionDescriptionInit = {
type: "offer",
sdp: "remote",
};
const answer = await client.handleOffer("bob", incoming);
expect(answer.type).toBe("answer");
const pc = constructed[0];
expect(pc?.remoteDescription).toEqual(incoming);
expect(pc?.localDescription).toEqual(answer);
});
it("handleAnswer requires existing peer", { timeout: 5000 }, async () => {
const { client } = freshClient();
await expectError(
() => client.handleAnswer("bob", { type: "answer", sdp: "x" }),
DRIVE_WEBRTC_CODES.PEER_NOT_FOUND
);
});
it("handleAnswer applies remote description to outbound offer", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
await client.handleAnswer("bob", { type: "answer", sdp: "ok" });
const pc = constructed[0];
expect(pc?.remoteDescription).toEqual({ type: "answer", sdp: "ok" });
});
it("pre-aborted createOffer/handleOffer both reject", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
const ac = new AbortController();
ac.abort();
await expectError(
() => client.createOffer("bob", ac.signal),
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
await expectError(
() =>
client.handleOffer(
"carol",
{ type: "offer", sdp: "x" },
ac.signal
),
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
});
});
describe("WebRTCClient ICE", () => {
it("addIceCandidate applies candidate; bad candidate → ICE_FAILED", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
const pc = constructed[0] as FakeRTCPeerConnection;
await client.addIceCandidate("bob", { candidate: "a" });
expect(pc.addedCandidates).toEqual([{ candidate: "a" }]);
pc.addIceCandidateImpl = async () => {
throw new Error("bad candidate");
};
await expectError(
() => client.addIceCandidate("bob", { candidate: "b" }),
DRIVE_WEBRTC_CODES.ICE_FAILED
);
});
it("addIceCandidate on unknown peer throws PEER_NOT_FOUND", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await expectError(
() => client.addIceCandidate("ghost", { candidate: "x" }),
DRIVE_WEBRTC_CODES.PEER_NOT_FOUND
);
});
it("emits ice:candidate for every locally-gathered candidate", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
const pc = constructed[0] as FakeRTCPeerConnection;
const seen: RTCIceCandidate[] = [];
client.on("ice:candidate", ({ candidate }) => seen.push(candidate));
const cand = { candidate: "fake" } as unknown as RTCIceCandidate;
pc.simulateIceCandidate(cand);
pc.simulateIceCandidate(null); // end-of-candidates — ignored
expect(seen).toEqual([cand]);
});
it("triggers ICE restart with attempt+timestamp on failed", {
timeout: 5000,
}, async () => {
let nowMs = 1_000;
const { client } = freshClient({
maxIceRestartAttempts: 2,
clockNow: () => nowMs,
});
await client.createOffer("bob");
const pc = constructed[0] as FakeRTCPeerConnection;
const restarts: { attempt: number; timestamp: number }[] = [];
client.on("ice:restart", ({ attempt, timestamp }) =>
restarts.push({ attempt, timestamp })
);
nowMs = 100;
pc.simulateIceState("failed");
nowMs = 200;
pc.simulateIceState("failed");
nowMs = 300;
pc.simulateIceState("failed"); // exhausted — no further restart
expect(pc.restartIceCalls).toBe(2);
expect(restarts).toEqual([
{ attempt: 1, timestamp: 100 },
{ attempt: 2, timestamp: 200 },
]);
});
});
describe("WebRTCClient lifecycle", () => {
it("getPeerConnection returns stable instance per peer", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
await client.createOffer("bob");
await client.createOffer("carol");
expect(client.getPeerConnection("bob")).toBe(
client.getPeerConnection("bob")
);
expect(client.getPeerConnection("bob")).not.toBe(
client.getPeerConnection("carol")
);
expect(client.getPeerConnection("ghost")).toBeUndefined();
});
it("closePeer without reason emits peer:disconnect sans reason", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
const disconnects: { peerId: string; reason?: string }[] = [];
client.on("peer:disconnect", (info) => disconnects.push(info));
client.closePeer("bob");
expect(disconnects).toEqual([{ peerId: "bob" }]);
});
it("closePeer closes pc, removes map entry, emits peer:disconnect", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
const pc = constructed[0] as FakeRTCPeerConnection;
const disconnects: { peerId: string; reason?: string }[] = [];
client.on("peer:disconnect", (info) => disconnects.push(info));
client.closePeer("bob", "test");
expect(pc.closed).toBe(true);
expect(client.getPeerConnection("bob")).toBeUndefined();
expect(disconnects).toEqual([{ peerId: "bob", reason: "test" }]);
// Idempotent — second call no-ops.
client.closePeer("bob");
expect(disconnects).toHaveLength(1);
});
it("closeAll closes every peer", { timeout: 5000 }, async () => {
const { client } = freshClient();
await client.createOffer("bob");
await client.createOffer("carol");
const disconnects: string[] = [];
client.on("peer:disconnect", ({ peerId }) => disconnects.push(peerId));
client.closeAll("shutdown");
expect(new Set(disconnects)).toEqual(new Set(["bob", "carol"]));
// After closeAll, ensurePeer rejects further handshakes.
await expectError(
() => client.createOffer("dave"),
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
// closeAll is idempotent.
client.closeAll();
});
it("emits connection:state and peer:disconnect on terminal states", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
const pc = constructed[0] as FakeRTCPeerConnection;
const cstates: RTCPeerConnectionState[] = [];
const disconnects: { peerId: string; reason?: string }[] = [];
client.on("connection:state", ({ state }) => cstates.push(state));
client.on("peer:disconnect", (info) => disconnects.push(info));
pc.simulateConnectionState("connecting");
pc.simulateConnectionState("connected");
pc.simulateConnectionState("failed");
expect(cstates).toEqual(["connecting", "connected", "failed"]);
expect(disconnects).toContainEqual({
peerId: "bob",
reason: "failed",
});
});
it("emits datachannel:open when a remote channel arrives", {
timeout: 5000,
}, async () => {
const { client } = freshClient();
await client.createOffer("bob");
const pc = constructed[0] as FakeRTCPeerConnection;
const opens: { peerId: string; channel: RTCDataChannel }[] = [];
client.on("datachannel:open", (info) => opens.push(info));
const channel = { label: "chunks" } as unknown as RTCDataChannel;
pc.simulateDataChannel(channel);
expect(opens).toEqual([{ peerId: "bob", channel }]);
});
it("surfaces RTCPeerConnection ctor failure as PEER_HANDSHAKE_FAILED", {
timeout: 5000,
}, async () => {
class ThrowingCtor {
public constructor() {
throw new Error("boom");
}
}
const client = new WebRTCClient({
peerId: "alice",
config: { signaling: { url: "wss://x" } },
rtcPeerConnectionCtor:
ThrowingCtor as unknown as typeof RTCPeerConnection,
});
await expectError(
() => client.createOffer("bob"),
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
});
});

View File

@@ -0,0 +1,290 @@
/**
* WebRTCClient — primary orchestrator surface. See client.md.
*/
import { DEFAULT_ICE_SERVERS } from "../../constants/index.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
import { WebRTCPluginConfigSchema } from "../../schemas/config.schemas.js";
import type {
ResolvedConfig,
WebRTCPluginConfig,
} from "../../types/config.types.js";
import {
applyIceCandidate,
attachIceHandlers,
type IceHandlersHandle,
} from "../ice/index.js";
import {
createOffer as primitiveCreateOffer,
handleAnswer as primitiveHandleAnswer,
handleOffer as primitiveHandleOffer,
} from "../offer-answer/index.js";
/**
* Constructor-time peer-connection factory. Defaults to
* `globalThis.RTCPeerConnection`; tests inject a structural double.
*/
export type RTCPeerConnectionCtor = new (
config?: RTCConfiguration
) => RTCPeerConnection;
/**
* Events emitted by {@link WebRTCClient}.
*/
export type WebRTCClientEventMap = {
"ice:candidate": [{ peerId: string; candidate: RTCIceCandidate }];
"ice:state": [{ peerId: string; state: RTCIceConnectionState }];
"ice:restart": [{ peerId: string; attempt: number; timestamp: number }];
"connection:state": [{ peerId: string; state: RTCPeerConnectionState }];
"peer:disconnect": [{ peerId: string; reason?: string }];
"datachannel:open": [{ peerId: string; channel: RTCDataChannel }];
};
/**
* Options for {@link WebRTCClient}.
*/
export interface WebRTCClientOptions {
readonly peerId: string;
readonly config: WebRTCPluginConfig;
/** Custom RTCPeerConnection ctor (defaults to `globalThis.RTCPeerConnection`). */
readonly rtcPeerConnectionCtor?: RTCPeerConnectionCtor;
readonly clock?: { now: () => number };
/** Cap on automatic ICE restart attempts (default 3). */
readonly maxIceRestartAttempts?: number;
}
interface ManagedPeer {
readonly pc: RTCPeerConnection;
readonly ice: IceHandlersHandle;
readonly onDataChannel: (event: RTCDataChannelEvent) => void;
readonly onConnectionStateChange: () => void;
}
/**
* Apply defaults to user-supplied configuration. Throws
* `DriveWebRtcError(CONFIG_INVALID)` if the input fails Zod validation.
*/
export function resolveConfig(input: WebRTCPluginConfig): ResolvedConfig {
const parsed = WebRTCPluginConfigSchema.safeParse(input);
if (!parsed.success) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CONFIG_INVALID,
"WebRTCPluginConfig failed Zod validation",
{ cause: parsed.error }
);
}
const valid = parsed.data;
return {
signaling: {
url: valid.signaling.url,
peerId: valid.signaling.peerId ?? "",
peerName: valid.signaling.peerName ?? "",
authToken: valid.signaling.authToken ?? "",
reconnect: valid.signaling.reconnect ?? true,
reconnectDelay: valid.signaling.reconnectDelay ?? 1_000,
},
p2p: {
priority: valid.p2p?.priority ?? 10,
timeout: valid.p2p?.timeout ?? 30_000,
iceServers: (valid.p2p?.iceServers ??
DEFAULT_ICE_SERVERS) as RTCIceServer[],
maxConcurrentDownloads: valid.p2p?.maxConcurrentDownloads ?? 5,
},
subChunks: {
size: valid.subChunks?.size ?? 16_000,
backpressure: valid.subChunks?.backpressure ?? 262_144,
rateLimit: valid.subChunks?.rateLimit ?? {
delayMs: 5,
everyNParts: 10,
},
},
};
}
/**
* Primary WebRTC orchestrator.
*/
export class WebRTCClient extends TypedEventEmitter<WebRTCClientEventMap> {
public readonly peerId: string;
public readonly resolvedConfig: ResolvedConfig;
private readonly ctor: RTCPeerConnectionCtor;
private readonly clock: { now: () => number };
private readonly maxIceRestartAttempts: number;
private readonly peers = new Map<string, ManagedPeer>();
private closed = false;
public constructor(options: WebRTCClientOptions) {
super();
this.peerId = options.peerId;
this.resolvedConfig = resolveConfig(options.config);
const ctor =
options.rtcPeerConnectionCtor ??
(globalThis as { RTCPeerConnection?: RTCPeerConnectionCtor })
.RTCPeerConnection;
if (typeof ctor !== "function") {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CONFIG_INVALID,
"no RTCPeerConnection ctor available (set options.rtcPeerConnectionCtor)"
);
}
this.ctor = ctor;
this.clock = options.clock ?? { now: () => Date.now() };
this.maxIceRestartAttempts = options.maxIceRestartAttempts ?? 3;
}
/** Create the peer connection if needed and return an SDP offer. */
public async createOffer(
remotePeerId: string,
signal?: AbortSignal
): Promise<RTCSessionDescriptionInit> {
const peer = this.ensurePeer(remotePeerId);
return primitiveCreateOffer(peer.pc, signal);
}
/** Accept a remote offer and return our answer. */
public async handleOffer(
remotePeerId: string,
offer: RTCSessionDescriptionInit,
signal?: AbortSignal
): Promise<RTCSessionDescriptionInit> {
const peer = this.ensurePeer(remotePeerId);
return primitiveHandleOffer(peer.pc, offer, signal);
}
/** Apply a remote answer to an outbound offer. */
public async handleAnswer(
remotePeerId: string,
answer: RTCSessionDescriptionInit
): Promise<void> {
const peer = this.requirePeer(remotePeerId);
await primitiveHandleAnswer(peer.pc, answer);
}
/** Apply a trickled ICE candidate from the remote peer. */
public async addIceCandidate(
remotePeerId: string,
candidate: RTCIceCandidateInit
): Promise<void> {
const peer = this.requirePeer(remotePeerId);
await applyIceCandidate(peer.pc, candidate);
}
/** Returns the RTCPeerConnection for a peer (or undefined). */
public getPeerConnection(
remotePeerId: string
): RTCPeerConnection | undefined {
return this.peers.get(remotePeerId)?.pc;
}
/** Close every peer connection. */
public closeAll(reason?: string): void {
if (this.closed) return;
this.closed = true;
for (const id of Array.from(this.peers.keys())) {
this.closePeer(id, reason);
}
}
/** Close a single peer connection. */
public closePeer(remotePeerId: string, reason?: string): void {
const peer = this.peers.get(remotePeerId);
if (peer === undefined) return;
peer.ice.dispose();
peer.pc.removeEventListener(
"datachannel",
peer.onDataChannel as EventListener
);
peer.pc.removeEventListener(
"connectionstatechange",
peer.onConnectionStateChange as EventListener
);
peer.pc.close();
this.peers.delete(remotePeerId);
this.emit(
"peer:disconnect",
reason === undefined
? { peerId: remotePeerId }
: { peerId: remotePeerId, reason }
);
}
private ensurePeer(remotePeerId: string): ManagedPeer {
const existing = this.peers.get(remotePeerId);
if (existing !== undefined) return existing;
if (this.closed) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
"WebRTCClient is closed"
);
}
let pc: RTCPeerConnection;
try {
pc = new this.ctor({
iceServers: this.resolvedConfig.p2p.iceServers,
});
} catch (cause) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
"failed to construct RTCPeerConnection",
{ cause: cause as Error }
);
}
const managed = this.wirePeer(pc, remotePeerId);
this.peers.set(remotePeerId, managed);
return managed;
}
private wirePeer(pc: RTCPeerConnection, remotePeerId: string): ManagedPeer {
const ice = attachIceHandlers(pc, remotePeerId, {
onCandidate: (candidate) =>
this.emit("ice:candidate", { peerId: remotePeerId, candidate }),
onStateChange: (state) =>
this.emit("ice:state", { peerId: remotePeerId, state }),
onRestart: ({ attempt, timestamp }) =>
this.emit("ice:restart", {
peerId: remotePeerId,
attempt,
timestamp,
}),
maxRestartAttempts: this.maxIceRestartAttempts,
clock: this.clock,
});
const onDataChannel = (event: RTCDataChannelEvent): void => {
this.emit("datachannel:open", {
peerId: remotePeerId,
channel: event.channel,
});
};
const onConnectionStateChange = (): void => {
const state = pc.connectionState;
this.emit("connection:state", { peerId: remotePeerId, state });
if (state === "closed" || state === "failed") {
this.emit("peer:disconnect", {
peerId: remotePeerId,
reason: state,
});
}
};
pc.addEventListener("datachannel", onDataChannel as EventListener);
pc.addEventListener(
"connectionstatechange",
onConnectionStateChange as EventListener
);
return { pc, ice, onDataChannel, onConnectionStateChange };
}
private requirePeer(remotePeerId: string): ManagedPeer {
const peer = this.peers.get(remotePeerId);
if (peer === undefined) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_NOT_FOUND,
`no peer connection for ${remotePeerId}`
);
}
return peer;
}
}

View File

@@ -0,0 +1,10 @@
/**
* Public surface for the WebRTCClient orchestrator.
*/
export type {
RTCPeerConnectionCtor,
WebRTCClientEventMap,
WebRTCClientOptions,
} from "./client.js";
export { resolveConfig, WebRTCClient } from "./client.js";

View File

@@ -0,0 +1,116 @@
# ICE Handlers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ice` module owns the two thin pieces of ICE handling that `WebRTCClient`
needs but that don't belong in the orchestrator: listener attachment with
auto-restart policy, and a typed wrapper around `addIceCandidate`. Keeping
this logic in its own module means the restart algorithm is testable in
isolation (against a structural `FakeRTCPeerConnection`) and reusable if
another transport wants to adopt the same bounded-restart semantics.
The restart policy is intentionally bounded — three attempts by default —
because mobile networks routinely cycle `iceConnectionState === "failed"`
during handover. We want to ride those out without flapping forever; once
the budget is exhausted, `onStateChange("failed")` fires and the caller
(typically `WebRTCClient`) decides whether to close the peer or escalate to
the reconnect controller.
## Features
- `attachIceHandlers(pc, peerId, options)` installs both `icecandidate`
and `iceconnectionstatechange` listeners on an `RTCPeerConnection` and
returns an idempotent `dispose()` handle.
- Bounded auto-restart: on `iceConnectionState === "failed"` the helper
calls `pc.restartIce()` and emits `onRestart({ peerId, attempt, timestamp })`
until `maxRestartAttempts` (default 3) is reached.
- `restartIce` throws are swallowed (closed connections re-fire the
terminal state through `onStateChange`).
- `applyIceCandidate(pc, candidate)` wraps `pc.addIceCandidate` and
translates failures into `DriveWebRtcError(ICE_FAILED)` with the
original error preserved via `cause`.
- Injectable clock keeps `timestamp` deterministic in tests.
## Usage: Basic Example
```ts
import { applyIceCandidate, attachIceHandlers } from "@suaveplan/drive-webrtc";
const handle = attachIceHandlers(pc, "peer-A", {
onCandidate: (c) => signaling.send({ type: "ice-candidate", payload: c }),
onStateChange: (s) => console.log("ice state", s),
onRestart: (info) => console.log("restart", info),
});
// Later, when a remote candidate arrives:
await applyIceCandidate(pc, remoteCandidate);
// On peer teardown:
handle.dispose();
```
## Usage: Advanced Example
```ts
import { attachIceHandlers } from "@suaveplan/drive-webrtc";
let nowMs = 0;
const clock = { now: () => nowMs };
const restarts: { attempt: number; timestamp: number }[] = [];
const states: RTCIceConnectionState[] = [];
const handle = attachIceHandlers(fakePc, "peer-X", {
onCandidate: () => {},
onStateChange: (s) => states.push(s),
onRestart: (info) => restarts.push(info),
maxRestartAttempts: 2,
clock,
});
// Simulate three failures: the first two trigger restartIce, the third
// surfaces as a terminal "failed" without restart.
nowMs = 100; fakePc.simulateIceState("failed");
nowMs = 200; fakePc.simulateIceState("failed");
nowMs = 300; fakePc.simulateIceState("failed");
// restarts === [{ attempt: 1, timestamp: 100 }, { attempt: 2, timestamp: 200 }]
// states === ["failed", "failed", "failed"]
// handle.restartAttempts === 2
handle.dispose();
```
## API Reference
### `attachIceHandlers(pc, peerId, options): IceHandlersHandle`
- `pc: RTCPeerConnection`
- `peerId: string`
- `options.onCandidate(candidate: RTCIceCandidate): void`
- `options.onStateChange(state: RTCIceConnectionState): void`
- `options.onRestart(info: IceRestartInfo): void`
- `options.maxRestartAttempts?: number` (default 3)
- `options.clock?: { now: () => number }`
Returns `{ dispose(), restartAttempts }`. `dispose()` is idempotent.
### `applyIceCandidate(pc, candidate): Promise<void>`
Wraps `pc.addIceCandidate(candidate)`. Throws
`DriveWebRtcError(ICE_FAILED)` with `cause` populated on any failure.
## Implementation Notes
- `EventTarget`-style `addEventListener`/`removeEventListener` is used
rather than `oniceconnectionstatechange = ...` assignment so that the
module never clobbers a listener the caller installed for another
purpose.
- `pc.restartIce()` is guarded by `typeof pc.restartIce === "function"`
to remain robust against older `RTCPeerConnection` shims and the
structural test double.
- `dispose()` flips a local flag _before_ removing listeners so an
in-flight event-dispatch loop that re-enters during teardown is a
no-op rather than a double-emit.

View File

@@ -0,0 +1,193 @@
/**
* Tests for ICE handlers + candidate application.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { applyIceCandidate, attachIceHandlers } from "./ice.js";
class FakePc extends EventTarget {
public iceConnectionState: RTCIceConnectionState = "new";
public restartIceCalls = 0;
public restartIceImpl: () => void = () => {};
public addIceCandidateImpl: (c: RTCIceCandidateInit) => Promise<void> =
async () => {};
public restartIce(): void {
this.restartIceCalls += 1;
this.restartIceImpl();
}
public addIceCandidate(c: RTCIceCandidateInit): Promise<void> {
return this.addIceCandidateImpl(c);
}
public simulateState(state: RTCIceConnectionState): void {
this.iceConnectionState = state;
this.dispatchEvent(new Event("iceconnectionstatechange"));
}
public simulateCandidate(candidate: RTCIceCandidate | null): void {
const event = new Event("icecandidate") as RTCPeerConnectionIceEvent;
(event as unknown as { candidate: RTCIceCandidate | null }).candidate =
candidate;
this.dispatchEvent(event);
}
}
function pc(): FakePc & RTCPeerConnection {
return new FakePc() as unknown as FakePc & RTCPeerConnection;
}
describe("attachIceHandlers", () => {
it("forwards locally-gathered candidates, ignores null", {
timeout: 5000,
}, () => {
const p = pc() as unknown as FakePc;
const out: RTCIceCandidate[] = [];
attachIceHandlers(p as unknown as RTCPeerConnection, "bob", {
onCandidate: (c) => out.push(c),
onStateChange: () => {},
onRestart: () => {},
});
const c = { candidate: "x" } as unknown as RTCIceCandidate;
p.simulateCandidate(c);
p.simulateCandidate(null);
expect(out).toEqual([c]);
});
it("triggers restartIce up to maxRestartAttempts", {
timeout: 5000,
}, () => {
const p = pc() as unknown as FakePc;
const restarts: number[] = [];
const states: RTCIceConnectionState[] = [];
let nowMs = 100;
const handle = attachIceHandlers(
p as unknown as RTCPeerConnection,
"bob",
{
onCandidate: () => {},
onStateChange: (s) => states.push(s),
onRestart: ({ attempt, timestamp }) => {
restarts.push(attempt);
expect(timestamp).toBe(nowMs);
},
maxRestartAttempts: 2,
clock: { now: () => nowMs },
}
);
nowMs = 100;
p.simulateState("failed");
nowMs = 200;
p.simulateState("failed");
nowMs = 300;
p.simulateState("failed");
expect(p.restartIceCalls).toBe(2);
expect(restarts).toEqual([1, 2]);
expect(states).toEqual(["failed", "failed", "failed"]);
expect(handle.restartAttempts).toBe(2);
});
it("swallows restartIce throws", { timeout: 5000 }, () => {
const p = pc() as unknown as FakePc;
p.restartIceImpl = () => {
throw new Error("closed");
};
const states: RTCIceConnectionState[] = [];
attachIceHandlers(p as unknown as RTCPeerConnection, "bob", {
onCandidate: () => {},
onStateChange: (s) => states.push(s),
onRestart: () => {},
});
p.simulateState("failed");
expect(states).toEqual(["failed"]);
});
it("uses default clock when none supplied", { timeout: 5000 }, () => {
const p = pc() as unknown as FakePc;
let observedTimestamp = 0;
const before = Date.now();
attachIceHandlers(p as unknown as RTCPeerConnection, "bob", {
onCandidate: () => {},
onStateChange: () => {},
onRestart: ({ timestamp }) => {
observedTimestamp = timestamp;
},
});
p.simulateState("failed");
const after = Date.now();
expect(observedTimestamp).toBeGreaterThanOrEqual(before);
expect(observedTimestamp).toBeLessThanOrEqual(after);
});
it("skips restartIce when not a function", { timeout: 5000 }, () => {
const p = pc() as unknown as FakePc & {
restartIce?: () => void;
};
// Strip restartIce — simulate an older shim.
(p as { restartIce?: () => void }).restartIce = undefined;
const restarts: number[] = [];
attachIceHandlers(p as unknown as RTCPeerConnection, "bob", {
onCandidate: () => {},
onStateChange: () => {},
onRestart: ({ attempt }) => restarts.push(attempt),
});
p.simulateState("failed");
expect(restarts).toEqual([]);
});
it("dispose removes listeners and is idempotent", { timeout: 5000 }, () => {
const p = pc() as unknown as FakePc;
const states: RTCIceConnectionState[] = [];
const handle = attachIceHandlers(
p as unknown as RTCPeerConnection,
"bob",
{
onCandidate: () => {},
onStateChange: (s) => states.push(s),
onRestart: () => {},
}
);
p.simulateState("checking");
handle.dispose();
handle.dispose(); // idempotent
p.simulateState("failed");
expect(states).toEqual(["checking"]);
});
});
describe("applyIceCandidate", () => {
it("forwards candidate to pc.addIceCandidate", {
timeout: 5000,
}, async () => {
const p = pc() as unknown as FakePc;
const seen: RTCIceCandidateInit[] = [];
p.addIceCandidateImpl = async (c) => {
seen.push(c);
};
await applyIceCandidate(p as unknown as RTCPeerConnection, {
candidate: "x",
});
expect(seen).toEqual([{ candidate: "x" }]);
});
it("wraps failure as ICE_FAILED", { timeout: 5000 }, async () => {
const p = pc() as unknown as FakePc;
p.addIceCandidateImpl = async () => {
throw new Error("bad");
};
try {
await applyIceCandidate(p as unknown as RTCPeerConnection, {
candidate: "x",
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.ICE_FAILED
);
expect((err as DriveWebRtcError).cause).toBeDefined();
}
});
});

View File

@@ -0,0 +1,142 @@
/**
* ICE candidate handling + auto-restart policy.
*
* `attachIceHandlers` wires the `oniceconnectionstatechange` +
* `onicecandidate` listeners and implements the bounded ICE-restart loop:
* on `iceConnectionState === "failed"`, increment a counter and call
* `pc.restartIce()` until `maxRestartAttempts` is reached, then surface
* a terminal `"failed"` state-change to the caller.
*
* `applyIceCandidate` wraps `pc.addIceCandidate` and translates failures
* into `DriveWebRtcError(ICE_FAILED)` with the original cause preserved.
*/
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
const DEFAULT_MAX_RESTART_ATTEMPTS = 3;
/**
* Information about a single ICE-restart attempt.
*/
export interface IceRestartInfo {
readonly peerId: string;
readonly attempt: number;
readonly timestamp: number;
}
/**
* Options for {@link attachIceHandlers}.
*/
export interface AttachIceHandlersOptions {
/** Called for every locally-gathered ICE candidate. */
readonly onCandidate: (candidate: RTCIceCandidate) => void;
/** Called for every ICE connection-state transition. */
readonly onStateChange: (state: RTCIceConnectionState) => void;
/** Called every time the controller fires `pc.restartIce()`. */
readonly onRestart: (info: IceRestartInfo) => void;
/** Cap on automatic restart attempts (default 3). */
readonly maxRestartAttempts?: number;
/** Clock for restart-timestamp emission. */
readonly clock?: { now: () => number };
}
/**
* Result handle returned by {@link attachIceHandlers}.
*/
export interface IceHandlersHandle {
/** Remove the installed listeners. Idempotent. */
dispose(): void;
/** Read the current restart-attempt counter (test inspection). */
readonly restartAttempts: number;
}
/**
* Install ICE listeners on `pc` and drive the auto-restart policy.
*/
export function attachIceHandlers(
pc: RTCPeerConnection,
peerId: string,
options: AttachIceHandlersOptions
): IceHandlersHandle {
const maxRestartAttempts =
options.maxRestartAttempts ?? DEFAULT_MAX_RESTART_ATTEMPTS;
const clock = options.clock ?? { now: () => Date.now() };
let restartAttempts = 0;
let disposed = false;
const onIceCandidate = (event: RTCPeerConnectionIceEvent): void => {
if (event.candidate !== null) {
options.onCandidate(event.candidate);
}
};
const onStateChange = (): void => {
const state = pc.iceConnectionState;
if (
state === "failed" &&
restartAttempts < maxRestartAttempts &&
typeof pc.restartIce === "function"
) {
restartAttempts += 1;
try {
pc.restartIce();
} catch {
// `restartIce` can throw if the connection is closed; the
// subsequent state-change event will surface the terminal
// state to the caller.
}
options.onRestart({
peerId,
attempt: restartAttempts,
timestamp: clock.now(),
});
}
options.onStateChange(state);
};
pc.addEventListener("icecandidate", onIceCandidate as EventListener);
pc.addEventListener(
"iceconnectionstatechange",
onStateChange as EventListener
);
return {
dispose(): void {
if (disposed) return;
disposed = true;
pc.removeEventListener(
"icecandidate",
onIceCandidate as EventListener
);
pc.removeEventListener(
"iceconnectionstatechange",
onStateChange as EventListener
);
},
get restartAttempts() {
return restartAttempts;
},
};
}
/**
* Apply a trickled ICE candidate to `pc`. Translates failures into
* `DriveWebRtcError(ICE_FAILED)`.
*/
export async function applyIceCandidate(
pc: RTCPeerConnection,
candidate: RTCIceCandidateInit
): Promise<void> {
try {
await pc.addIceCandidate(candidate);
} catch (cause) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.ICE_FAILED,
"RTCPeerConnection.addIceCandidate failed",
{ cause: cause as Error }
);
}
}

View File

@@ -0,0 +1,10 @@
/**
* Public surface for ICE handlers + candidate application.
*/
export type {
AttachIceHandlersOptions,
IceHandlersHandle,
IceRestartInfo,
} from "./ice.js";
export { applyIceCandidate, attachIceHandlers } from "./ice.js";

View File

@@ -0,0 +1,5 @@
/**
* Public surface for the SDP offer/answer primitives.
*/
export { createOffer, handleAnswer, handleOffer } from "./offer-answer.js";

View File

@@ -0,0 +1,111 @@
# Offer / Answer Primitives
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The offer/answer module exposes three pure async wrappers around the
spec-standard WebRTC handshake calls. `createOffer` covers the outbound
half (local offer + `setLocalDescription`), `handleOffer` covers the
inbound half (`setRemoteDescription` + answer + `setLocalDescription`),
and `handleAnswer` finalises an outbound handshake by applying the
remote answer. Each helper takes an `RTCPeerConnection` plus an optional
`AbortSignal` and never owns peer state itself — that lives in
`WebRTCClient`.
Splitting these out keeps `WebRTCClient` thin and testable. The handshake
logic is a deterministic three- or four-step pipeline that maps
beautifully to property tests against a structural `FakeRTCPeerConnection`;
the orchestrator can then focus on lifecycle, eventing, and connection
caching without having to re-stub the same `pc.createOffer().then(...)`
chain in every code path.
## Features
- Spec-faithful call ordering: `createOffer → setLocalDescription`,
`setRemoteDescription → createAnswer → setLocalDescription`.
- Every failure surfaces as `DriveWebRtcError(PEER_HANDSHAKE_FAILED)`
with the underlying error preserved via `cause`.
- `AbortSignal` is checked **before** every async step, so a pre-aborted
signal rejects immediately and a signal aborted mid-handshake rejects
at the next step boundary.
- Pure functions over an injected `RTCPeerConnection` — no module-level
state, no globals, no hidden allocations.
## Usage: Basic Example
```ts
import { createOffer, handleAnswer } from "@suaveplan/drive-webrtc";
const pc = new RTCPeerConnection();
const offer = await createOffer(pc);
sendToPeer({ type: "offer", payload: offer });
const answer = await receiveAnswerFromPeer();
await handleAnswer(pc, answer);
```
## Usage: Advanced Example
```ts
import { createOffer, handleOffer } from "@suaveplan/drive-webrtc";
const pc = new RTCPeerConnection({ iceServers });
// Inbound caller — respond to a remote offer with an abort budget.
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
try {
const answer = await handleOffer(pc, remoteOffer, ac.signal);
sendToPeer({ type: "answer", payload: answer });
} catch (err) {
if (err instanceof DriveWebRtcError && err.code === DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED) {
// Handshake failed — log + tear down the peer connection.
}
throw err;
}
// Outbound caller — create an offer and survive a transient
// setLocalDescription error.
try {
const offer = await createOffer(pc);
sendToPeer({ type: "offer", payload: offer });
} catch (err) {
pc.close();
throw err;
}
```
## API Reference
### `createOffer(pc, signal?): Promise<RTCSessionDescriptionInit>`
Creates an SDP offer on `pc`, applies it as local description, returns
the offer. Throws `DriveWebRtcError(PEER_HANDSHAKE_FAILED)` on abort or
any step failure (original error preserved via `cause`).
### `handleOffer(pc, offer, signal?): Promise<RTCSessionDescriptionInit>`
Applies `offer` as remote description, generates an answer, applies it
as local description, returns the answer. Same error semantics as
`createOffer`.
### `handleAnswer(pc, answer, signal?): Promise<void>`
Applies `answer` as remote description on a `pc` that has already sent
an offer. Same error semantics as `createOffer`.
## Implementation Notes
- Abort checks happen between every awaited step so an abort that lands
during `createOffer` does not silently proceed into
`setLocalDescription`.
- The wrapper never mutates `pc` outside of the documented calls and never
registers event listeners — handler wiring is the orchestrator's
responsibility (see `client.ts`).
- The structural typing of `RTCPeerConnection` means these helpers work
unchanged against the project's `FakeRTCPeerConnection` test double;
the no-mocks rule is satisfied by injection at the call site, not by
monkey-patching globals.

View File

@@ -0,0 +1,272 @@
/**
* Tests for the offer/answer primitives. Use a minimal structural
* FakeRTCPeerConnection — no `vi.mock`, no globals touched.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { createOffer, handleAnswer, handleOffer } from "./offer-answer.js";
class FakePc {
public localDescription: RTCSessionDescriptionInit | null = null;
public remoteDescription: RTCSessionDescriptionInit | null = null;
public createOfferImpl: () => Promise<RTCSessionDescriptionInit> =
async () => ({ type: "offer", sdp: "o" });
public createAnswerImpl: () => Promise<RTCSessionDescriptionInit> =
async () => ({ type: "answer", sdp: "a" });
public setLocalImpl: (d: RTCSessionDescriptionInit) => Promise<void> =
async () => {};
public setRemoteImpl: (d: RTCSessionDescriptionInit) => Promise<void> =
async () => {};
public createOffer(): Promise<RTCSessionDescriptionInit> {
return this.createOfferImpl();
}
public createAnswer(): Promise<RTCSessionDescriptionInit> {
return this.createAnswerImpl();
}
public async setLocalDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
await this.setLocalImpl(d);
this.localDescription = d;
}
public async setRemoteDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
await this.setRemoteImpl(d);
this.remoteDescription = d;
}
}
function pc(): FakePc & RTCPeerConnection {
return new FakePc() as unknown as FakePc & RTCPeerConnection;
}
describe("createOffer", () => {
it("creates offer and applies it as local description", {
timeout: 5000,
}, async () => {
const p = pc();
const offer = await createOffer(p);
expect(offer).toEqual({ type: "offer", sdp: "o" });
expect(p.localDescription).toEqual(offer);
});
it("pre-aborted rejects with PEER_HANDSHAKE_FAILED", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
try {
await createOffer(pc(), ac.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("aborts mid-handshake before setLocalDescription", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const p = pc();
(p as unknown as FakePc).createOfferImpl = async () => {
ac.abort();
return { type: "offer", sdp: "o" };
};
try {
await createOffer(p, ac.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
expect((p as unknown as FakePc).localDescription).toBeNull();
});
it("wraps createOffer failure", { timeout: 5000 }, async () => {
const p = pc();
(p as unknown as FakePc).createOfferImpl = async () => {
throw new Error("nope");
};
try {
await createOffer(p);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
expect((err as DriveWebRtcError).cause).toBeDefined();
}
});
it("wraps setLocalDescription failure", { timeout: 5000 }, async () => {
const p = pc();
(p as unknown as FakePc).setLocalImpl = async () => {
throw new Error("sld bad");
};
try {
await createOffer(p);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
});
describe("handleOffer", () => {
const remote: RTCSessionDescriptionInit = { type: "offer", sdp: "r" };
it("applies remote then returns answer", { timeout: 5000 }, async () => {
const p = pc();
const answer = await handleOffer(p, remote);
expect(answer).toEqual({ type: "answer", sdp: "a" });
expect((p as unknown as FakePc).remoteDescription).toEqual(remote);
expect((p as unknown as FakePc).localDescription).toEqual(answer);
});
it("pre-aborted rejects", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
try {
await handleOffer(pc(), remote, ac.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("aborts mid-handshake after setRemoteDescription", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const p = pc();
(p as unknown as FakePc).setRemoteImpl = async () => {
ac.abort();
};
try {
await handleOffer(p, remote, ac.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("aborts before setLocalDescription(answer)", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const p = pc();
(p as unknown as FakePc).createAnswerImpl = async () => {
ac.abort();
return { type: "answer", sdp: "a" };
};
try {
await handleOffer(p, remote, ac.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("wraps setRemoteDescription failure", { timeout: 5000 }, async () => {
const p = pc();
(p as unknown as FakePc).setRemoteImpl = async () => {
throw new Error("bad remote");
};
try {
await handleOffer(p, remote);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("wraps createAnswer failure", { timeout: 5000 }, async () => {
const p = pc();
(p as unknown as FakePc).createAnswerImpl = async () => {
throw new Error("answer bad");
};
try {
await handleOffer(p, remote);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("wraps setLocalDescription(answer) failure", {
timeout: 5000,
}, async () => {
const p = pc();
(p as unknown as FakePc).setLocalImpl = async () => {
throw new Error("local answer bad");
};
try {
await handleOffer(p, remote);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
});
describe("handleAnswer", () => {
const remote: RTCSessionDescriptionInit = { type: "answer", sdp: "r" };
it("applies the remote answer", { timeout: 5000 }, async () => {
const p = pc();
await handleAnswer(p, remote);
expect((p as unknown as FakePc).remoteDescription).toEqual(remote);
});
it("pre-aborted rejects", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
try {
await handleAnswer(pc(), remote, ac.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("wraps setRemoteDescription failure", { timeout: 5000 }, async () => {
const p = pc();
(p as unknown as FakePc).setRemoteImpl = async () => {
throw new Error("no");
};
try {
await handleAnswer(p, remote);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
});

View File

@@ -0,0 +1,113 @@
/**
* SDP offer / answer primitives.
*
* Pure async wrappers over the spec-standard
* `pc.createOffer() → pc.setLocalDescription()` pattern (and inverse).
* Every failure mode is translated into `DriveWebRtcError(PEER_HANDSHAKE_FAILED)`
* with the original DOMException attached as `cause` so callers never see
* raw browser errors leak past the package boundary.
*/
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
function throwIfAborted(signal: AbortSignal | undefined, where: string): void {
if (signal?.aborted === true) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
`${where} aborted`
);
}
}
function wrap(cause: unknown, message: string): DriveWebRtcError {
return new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
message,
{ cause: cause as Error }
);
}
/**
* Create an SDP offer on `pc`, apply it as local description, and return it.
*/
export async function createOffer(
pc: RTCPeerConnection,
signal?: AbortSignal
): Promise<RTCSessionDescriptionInit> {
throwIfAborted(signal, "createOffer");
let offer: RTCSessionDescriptionInit;
try {
offer = await pc.createOffer();
} catch (cause) {
throw wrap(cause, "RTCPeerConnection.createOffer failed");
}
throwIfAborted(signal, "createOffer");
try {
await pc.setLocalDescription(offer);
} catch (cause) {
throw wrap(
cause,
"RTCPeerConnection.setLocalDescription(offer) failed"
);
}
return offer;
}
/**
* Apply a remote offer to `pc`, create an answer, apply it as local
* description, and return the answer.
*/
export async function handleOffer(
pc: RTCPeerConnection,
offer: RTCSessionDescriptionInit,
signal?: AbortSignal
): Promise<RTCSessionDescriptionInit> {
throwIfAborted(signal, "handleOffer");
try {
await pc.setRemoteDescription(offer);
} catch (cause) {
throw wrap(
cause,
"RTCPeerConnection.setRemoteDescription(offer) failed"
);
}
throwIfAborted(signal, "handleOffer");
let answer: RTCSessionDescriptionInit;
try {
answer = await pc.createAnswer();
} catch (cause) {
throw wrap(cause, "RTCPeerConnection.createAnswer failed");
}
throwIfAborted(signal, "handleOffer");
try {
await pc.setLocalDescription(answer);
} catch (cause) {
throw wrap(
cause,
"RTCPeerConnection.setLocalDescription(answer) failed"
);
}
return answer;
}
/**
* Apply a remote answer to an outbound offer we already sent.
*/
export async function handleAnswer(
pc: RTCPeerConnection,
answer: RTCSessionDescriptionInit,
signal?: AbortSignal
): Promise<void> {
throwIfAborted(signal, "handleAnswer");
try {
await pc.setRemoteDescription(answer);
} catch (cause) {
throw wrap(
cause,
"RTCPeerConnection.setRemoteDescription(answer) failed"
);
}
}

View File

@@ -0,0 +1,6 @@
/**
* Public surface for the reconnect controller.
*/
export type { ReconnectPolicy, ReconnectStep } from "./reconnect.js";
export { ReconnectController } from "./reconnect.js";

View File

@@ -0,0 +1,114 @@
# ReconnectController
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReconnectController` is a small, deterministic backoff-sequence walker used
by every part of the `@suaveplan/drive-webrtc` runtime that needs to retry
with bounded delay: the ICE-restart loop inside `WebRTCClient`, and any
future caller (e.g. a chunk-source health probe) that needs the same shape.
It is intentionally decoupled from WebRTC so it can be reused without
dragging the whole client surface in.
The controller takes a `ReconnectPolicy` describing the delay sequence and
the maximum number of attempts, plus an optional `Clock` for tests. Each
call to `next()` advances internal state and returns the next delay or
`null` once the policy is exhausted; `reset()` returns the controller to
attempt 0 so the same instance can be reused across recovery cycles.
## Features
- Pure logic — no WebRTC, no timers, no I/O.
- Bounded backoff with last-entry clamping when more attempts are made
than the sequence has explicit entries.
- Injectable `Clock` for deterministic tests (no `Date.now()` lock-in).
- Hard-fails fast in the constructor on empty `backoffMs` or
`maxAttempts < 1` — invariants are checked once, not per call.
- Reset-without-recreate: the controller can be reused across recovery
cycles to avoid allocation churn inside hot loops.
## Usage: Basic Example
```ts
import { ReconnectController } from "@suaveplan/drive-webrtc";
const controller = new ReconnectController({
backoffMs: [500, 1_000, 2_000],
maxAttempts: 3,
});
let step = controller.next();
while (step !== null) {
await sleep(step.delayMs);
if (await tryConnect()) break;
step = controller.next();
}
```
## Usage: Advanced Example
```ts
import { ReconnectController } from "@suaveplan/drive-webrtc";
// Inject a clock for deterministic tests.
let nowMs = 0;
const clock = { now: () => nowMs };
const controller = new ReconnectController(
{ backoffMs: [100, 200, 400, 800], maxAttempts: 6 },
clock
);
// Walk past the explicit entries — the last delay is clamped and reused.
const observed: number[] = [];
let step = controller.next();
while (step !== null) {
observed.push(step.delayMs);
nowMs += step.delayMs;
step = controller.next();
}
// observed === [100, 200, 400, 800, 800, 800]
controller.reset();
// attempts === 0; the same controller can drive the next recovery.
```
## API Reference
### `new ReconnectController(policy, clock?)`
- `policy: ReconnectPolicy` — `{ backoffMs: readonly number[]; maxAttempts: number }`.
Throws `RangeError` if `backoffMs` is empty or `maxAttempts < 1`.
- `clock?: { now: () => number }` — defaults to `{ now: () => Date.now() }`.
### `next(): ReconnectStep | null`
Returns `{ attempt, delayMs }` for the upcoming attempt and advances state.
`attempt` is 1-indexed. Returns `null` once `attempts >= maxAttempts`.
### `reset(): void`
Zero out the attempt counter and last-attempt timestamp.
### `attempts: number`
Read-only number of `next()` calls that returned a step.
### `lastAttemptAt: number`
Read-only timestamp captured from the injected clock on the most recent
`next()` call. Zero after construction or `reset()`.
## Implementation Notes
- The constructor validates invariants once. Hot-path `next()` does a
single comparison + array index — no allocation beyond the result
object.
- Backoff clamping (use the last entry once the explicit sequence is
exhausted) matches the SignalingClient reconnect policy and avoids
surprise infinite-growth schedules.
- The controller is intentionally _not_ a scheduler: it does not call
`setTimeout`. Callers compose it with their preferred async primitive
(browser `setTimeout`, Bun/Node timers, or AbortSignal-backed waits).

View File

@@ -0,0 +1,89 @@
/**
* Tests for {@link ReconnectController}.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ReconnectController } from "./reconnect.js";
describe("ReconnectController", () => {
it("walks the backoff sequence and returns null on exhaustion", {
timeout: 5000,
}, () => {
const c = new ReconnectController({
backoffMs: [100, 200, 400],
maxAttempts: 3,
});
expect(c.next()).toEqual({ attempt: 1, delayMs: 100 });
expect(c.next()).toEqual({ attempt: 2, delayMs: 200 });
expect(c.next()).toEqual({ attempt: 3, delayMs: 400 });
expect(c.next()).toBeNull();
expect(c.attempts).toBe(3);
});
it("clamps to the last delay when maxAttempts exceeds the sequence", {
timeout: 5000,
}, () => {
const c = new ReconnectController({
backoffMs: [50, 100],
maxAttempts: 5,
});
const observed: number[] = [];
let step = c.next();
while (step !== null) {
observed.push(step.delayMs);
step = c.next();
}
expect(observed).toEqual([50, 100, 100, 100, 100]);
});
it("reset returns to attempt 0", { timeout: 5000 }, () => {
const c = new ReconnectController({
backoffMs: [10],
maxAttempts: 2,
});
c.next();
c.next();
expect(c.next()).toBeNull();
c.reset();
expect(c.attempts).toBe(0);
expect(c.lastAttemptAt).toBe(0);
expect(c.next()).toEqual({ attempt: 1, delayMs: 10 });
});
it("records lastAttemptAt from the injected clock", {
timeout: 5000,
}, () => {
let nowMs = 1_000;
const c = new ReconnectController(
{ backoffMs: [10], maxAttempts: 2 },
{ now: () => nowMs }
);
expect(c.lastAttemptAt).toBe(0);
c.next();
expect(c.lastAttemptAt).toBe(1_000);
nowMs = 5_000;
c.next();
expect(c.lastAttemptAt).toBe(5_000);
});
it("uses default clock when none is provided", { timeout: 5000 }, () => {
const c = new ReconnectController({ backoffMs: [1], maxAttempts: 1 });
const before = Date.now();
c.next();
const after = Date.now();
expect(c.lastAttemptAt).toBeGreaterThanOrEqual(before);
expect(c.lastAttemptAt).toBeLessThanOrEqual(after);
});
it("throws on empty backoffMs", { timeout: 5000 }, () => {
expect(
() => new ReconnectController({ backoffMs: [], maxAttempts: 1 })
).toThrow(RangeError);
});
it("throws on maxAttempts < 1", { timeout: 5000 }, () => {
expect(
() => new ReconnectController({ backoffMs: [1], maxAttempts: 0 })
).toThrow(RangeError);
});
});

View File

@@ -0,0 +1,92 @@
/**
* Reusable bounded-backoff reconnect / retry controller.
*
* Generic helper consumed by {@link WebRTCClient}'s ICE-restart loop and
* available to any other genesis subsystem that needs a deterministic,
* clock-injectable backoff sequence. Plain logic, no WebRTC coupling —
* which keeps it trivial to unit test and reuse.
*/
/**
* Policy describing the backoff sequence and exhaustion bound.
*/
export interface ReconnectPolicy {
/** Backoff delays in milliseconds. The last entry is clamped on overflow. */
readonly backoffMs: readonly number[];
/** Maximum number of attempts before {@link ReconnectController.next} returns null. */
readonly maxAttempts: number;
}
/**
* One advancing step in the backoff sequence.
*/
export interface ReconnectStep {
/** 1-indexed attempt number. */
readonly attempt: number;
/** Delay before this attempt should fire, in milliseconds. */
readonly delayMs: number;
}
/**
* Stateful controller that walks a {@link ReconnectPolicy}'s backoff
* sequence. Calling {@link ReconnectController.next} advances internal
* state; calling {@link ReconnectController.reset} returns to attempt 0.
*/
export class ReconnectController {
private readonly policy: ReconnectPolicy;
private readonly clock: { now: () => number };
private currentAttempts = 0;
private lastEventAt = 0;
public constructor(
policy: ReconnectPolicy,
clock: { now: () => number } = { now: () => Date.now() }
) {
if (policy.backoffMs.length === 0) {
throw new RangeError(
"ReconnectController: backoffMs must contain at least one step"
);
}
if (policy.maxAttempts < 1) {
throw new RangeError(
"ReconnectController: maxAttempts must be >= 1"
);
}
this.policy = policy;
this.clock = clock;
}
/**
* Returns the delay for the next attempt; advances internal state.
* Returns `null` once `maxAttempts` is reached.
*/
public next(): ReconnectStep | null {
if (this.currentAttempts >= this.policy.maxAttempts) {
return null;
}
const idx = Math.min(
this.currentAttempts,
this.policy.backoffMs.length - 1
);
const delayMs = this.policy.backoffMs[idx] as number;
this.currentAttempts += 1;
this.lastEventAt = this.clock.now();
return { attempt: this.currentAttempts, delayMs };
}
/** Reset to attempt 0. */
public reset(): void {
this.currentAttempts = 0;
this.lastEventAt = 0;
}
/** Attempts consumed so far. */
public get attempts(): number {
return this.currentAttempts;
}
/** Timestamp of the last `next()` call, sourced from the injected clock. */
public get lastAttemptAt(): number {
return this.lastEventAt;
}
}

View File

@@ -0,0 +1,41 @@
/**
* Package-wide constants for `@suaveplan/drive-webrtc`.
*/
/** Default cap on simultaneously-tracked peers. */
export const DEFAULT_MAX_PEERS = 10;
/** Default sub-chunk size in bytes (data-channel safe payload limit). */
export const DEFAULT_SUB_CHUNK_SIZE = 16_000;
/** Default backpressure threshold in bytes (data-channel buffered amount). */
export const DEFAULT_BACKPRESSURE_BYTES = 262_144;
/** Default chunk-source priority (lower = higher priority). */
export const DEFAULT_PRIORITY = 10;
/** Default per-request timeout in milliseconds. */
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
/** Default maximum concurrent chunk downloads. */
export const DEFAULT_MAX_CONCURRENT_DOWNLOADS = 5;
/** Default base reconnect delay in milliseconds. */
export const DEFAULT_RECONNECT_DELAY_MS = 1_000;
/** Reconnect backoff schedule (milliseconds, exponential with caps). */
export const DEFAULT_RECONNECT_BACKOFF_MS: readonly number[] = [
500, 1_000, 2_000, 5_000, 10_000,
];
/** Default sub-chunk rate-limit delay in milliseconds. */
export const DEFAULT_RATE_LIMIT_DELAY_MS = 5;
/** Default sub-chunk rate-limit cadence (apply delay every N parts). */
export const DEFAULT_RATE_LIMIT_EVERY_N_PARTS = 10;
/** Default Google public STUN servers used when no `iceServers` are configured. */
export const DEFAULT_ICE_SERVERS: readonly RTCIceServer[] = [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
];

View File

@@ -0,0 +1,115 @@
# DataChannelManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DataChannelManager` opens, multiplexes, and gates writes on
`RTCDataChannel`s for the drive-webrtc mesh. It composes a
`WebRTCClient` — pulling out the underlying `RTCPeerConnection` per
peer — and exposes a uniform send/close/event surface so callers
(chunk-source layer, plugin glue, debug tools) never touch raw
`RTCDataChannel` event listeners. Backpressure-aware `send` waits for
`bufferedamountlow` when the channel is buffering above the
high-water mark, preventing the WebRTC stack from being saturated
during burst transfers.
## Features
- Per-peer + per-label channel multiplexing.
- Default channel label `drive-chunks` and ordered delivery
(configurable).
- High-water-mark backpressure (default 1 MiB) with AbortSignal
cancellation.
- Idempotent `close` and `closeAll` semantics.
- Typed event surface: `channel:open`, `channel:close`,
`channel:message`, `channel:error`.
- All errors raised at the API boundary are
`DriveWebRtcError(PEER_NOT_FOUND | DATA_CHANNEL_CLOSED)`.
## Usage: Basic Example
```ts
import { DataChannelManager, WebRTCClient } from "@suaveplan/drive-webrtc";
const manager = new DataChannelManager({ client });
const channel = manager.open("bob");
manager.on("channel:message", ({ peerId, data }) => {
// route inbound payload
});
await manager.send("bob", "drive-chunks", "hello");
```
## Usage: Advanced Example
```ts
import { DataChannelManager } from "@suaveplan/drive-webrtc";
const manager = new DataChannelManager({
client,
defaultLabel: "drive-chunks",
defaultOrdered: true,
highWaterMarkBytes: 256_000,
});
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5_000);
try {
for (const part of parts) {
await manager.send("bob", "drive-chunks", part, ctrl.signal);
}
} catch (err) {
if (err instanceof DriveWebRtcError && err.code === "DRIVE_WEBRTC.DATA_CHANNEL_CLOSED") {
// either peer dropped or signal aborted
}
}
manager.close("bob");
```
## API Reference
### `new DataChannelManager(options)`
- `client: WebRTCClient`.
- `defaultLabel?: string` (default `"drive-chunks"`).
- `defaultOrdered?: boolean` (default `true`).
- `highWaterMarkBytes?: number` (default `1_048_576`).
### `open(peerId, label?, init?): RTCDataChannel`
Reuse an open channel for the label, or create a new one via
`pc.createDataChannel`. Throws `PEER_NOT_FOUND` if the client has no
peer connection for `peerId`.
### `send(peerId, label, payload, signal?): Promise<void>`
Throws `DATA_CHANNEL_CLOSED` when the channel is missing or its
`readyState` is not `"open"`. When `bufferedAmount` is above the
high-water mark, waits for one `bufferedamountlow` event before
calling `channel.send`. AbortSignal aborts the wait and the resulting
error has `cause` set to the signal's reason.
### `close(peerId, label?, reason?)` / `closeAll(reason?)`
Idempotent. `close(peerId)` with no label closes every channel for
the peer.
### `get(peerId, label?): RTCDataChannel | undefined`
Lookup. Defaults to the manager's `defaultLabel`.
## Implementation Notes
- The manager listens to `open`, `close`, `error`, and `message`
events on every channel it creates. It removes those listeners on
detach to avoid leaking after `close`.
- On `register`, if the channel is already `"open"` (an unusual race
the test doubles can reproduce), `channel:open` is dispatched via
`queueMicrotask` so callers awaiting the event still receive it.
- `error` events surface as `channel:error` carrying a
`DriveWebRtcError(DATA_CHANNEL_CLOSED)` with the underlying
`event.error` as `cause`.
- The internal storage is `Map<peerId, Map<label, ChannelEntry>>`;
the outer map is pruned when its inner map becomes empty so a
`closeAll` returns the manager to a fully-clean state.

View File

@@ -0,0 +1,517 @@
/**
* Tests for {@link DataChannelManager}. Uses a structural
* FakeRTCPeerConnection + FakeRTCDataChannel — no `vi.stubGlobal`,
* no `vi.mock`. The Fakes are real `EventTarget` subclasses; tests
* drive transitions through `simulate*` helpers.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WebRTCClient } from "../../client/client/client.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { DataChannelManager } from "./data-channel-manager.js";
class FakeRTCDataChannel extends EventTarget {
public readyState: RTCDataChannelState = "connecting";
public bufferedAmount = 0;
public bufferedAmountLowThreshold = 0;
public closed = false;
public sentPayloads: (ArrayBuffer | string)[] = [];
public sendImpl: (payload: ArrayBuffer | string) => void = (payload) => {
this.sentPayloads.push(payload);
};
public closeImpl: () => void = () => {
this.closed = true;
this.readyState = "closed";
};
public constructor(
public readonly label: string,
_init?: RTCDataChannelInit
) {
super();
}
public send(payload: ArrayBuffer | string): void {
this.sendImpl(payload);
}
public close(): void {
this.closeImpl();
this.dispatchEvent(new Event("close"));
}
public simulateOpen(): void {
this.readyState = "open";
this.dispatchEvent(new Event("open"));
}
public simulateClose(): void {
this.readyState = "closed";
this.dispatchEvent(new Event("close"));
}
public simulateMessage(data: ArrayBuffer | string): void {
const e = new Event("message") as MessageEvent;
(e as unknown as { data: unknown }).data = data;
this.dispatchEvent(e);
}
public simulateError(error?: Error): void {
const e = new Event("error") as ErrorEvent;
(e as unknown as { error: Error | undefined }).error = error;
this.dispatchEvent(e);
}
public simulateBufferedAmount(value: number): void {
this.bufferedAmount = value;
}
public simulateBufferedAmountLow(): void {
this.dispatchEvent(new Event("bufferedamountlow"));
}
}
class FakeRTCPeerConnection extends EventTarget {
public connectionState: RTCPeerConnectionState = "new";
public iceConnectionState: RTCIceConnectionState = "new";
public localDescription: RTCSessionDescriptionInit | null = null;
public remoteDescription: RTCSessionDescriptionInit | null = null;
public createdChannels: FakeRTCDataChannel[] = [];
public closed = false;
public constructor(public readonly config?: RTCConfiguration) {
super();
}
public async createOffer(): Promise<RTCSessionDescriptionInit> {
return { type: "offer", sdp: "fake" };
}
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
return { type: "answer", sdp: "fake" };
}
public async setLocalDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.localDescription = d;
}
public async setRemoteDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.remoteDescription = d;
}
public async addIceCandidate(): Promise<void> {}
public restartIce(): void {}
public close(): void {
this.closed = true;
}
public createDataChannel(
label: string,
init?: RTCDataChannelInit
): RTCDataChannel {
const ch = new FakeRTCDataChannel(label, init);
this.createdChannels.push(ch);
return ch as unknown as RTCDataChannel;
}
}
async function makeManager(opts?: {
highWaterMarkBytes?: number;
defaultLabel?: string;
defaultOrdered?: boolean;
}): Promise<{
manager: DataChannelManager;
client: WebRTCClient;
pc: FakeRTCPeerConnection;
}> {
const client = new WebRTCClient({
peerId: "self",
config: { signaling: { url: "wss://x" } },
rtcPeerConnectionCtor:
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
});
await client.createOffer("bob");
const pc = client.getPeerConnection(
"bob"
) as unknown as FakeRTCPeerConnection;
const manager = new DataChannelManager({
client,
...(opts?.highWaterMarkBytes !== undefined
? { highWaterMarkBytes: opts.highWaterMarkBytes }
: {}),
...(opts?.defaultLabel !== undefined
? { defaultLabel: opts.defaultLabel }
: {}),
...(opts?.defaultOrdered !== undefined
? { defaultOrdered: opts.defaultOrdered }
: {}),
});
return { manager, client, pc };
}
describe("DataChannelManager.open", () => {
it("throws PEER_NOT_FOUND for unknown peer", {
timeout: 5000,
}, async () => {
const client = new WebRTCClient({
peerId: "self",
config: { signaling: { url: "wss://x" } },
rtcPeerConnectionCtor:
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
});
const manager = new DataChannelManager({ client });
try {
manager.open("missing");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_NOT_FOUND
);
}
});
it("creates a new channel on first open", { timeout: 5000 }, async () => {
const { manager, pc } = await makeManager();
const ch = manager.open("bob");
expect(pc.createdChannels).toHaveLength(1);
expect((ch as unknown as FakeRTCDataChannel).label).toBe(
"drive-chunks"
);
});
it("reuses an existing channel for the same label", {
timeout: 5000,
}, async () => {
const { manager, pc } = await makeManager();
const a = manager.open("bob");
const b = manager.open("bob");
expect(a).toBe(b);
expect(pc.createdChannels).toHaveLength(1);
});
it("emits channel:open when channel opens", { timeout: 5000 }, async () => {
const { manager } = await makeManager();
const events: { peerId: string; label: string }[] = [];
manager.on("channel:open", (e) => events.push(e));
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
expect(events).toEqual([{ peerId: "bob", label: "drive-chunks" }]);
});
it("emits channel:open eagerly when channel was already open at register", {
timeout: 5000,
}, async () => {
const { manager, pc } = await makeManager();
// Forcefully open the channel before manager.open registers handlers.
// We achieve this by monkey-patching createDataChannel for one call.
const originalCreate = pc.createDataChannel.bind(pc);
pc.createDataChannel = (label, init) => {
const ch = originalCreate(
label,
init
) as unknown as FakeRTCDataChannel;
ch.readyState = "open";
return ch as unknown as RTCDataChannel;
};
const events: { peerId: string; label: string }[] = [];
manager.on("channel:open", (e) => events.push(e));
manager.open("bob");
await Promise.resolve();
expect(events).toEqual([{ peerId: "bob", label: "drive-chunks" }]);
});
it("threads RTCDataChannelInit", { timeout: 5000 }, async () => {
const { manager, pc } = await makeManager({
defaultOrdered: false,
});
manager.open("bob", "control", { maxRetransmits: 3 });
// Two channels created? No — only one for "control".
expect(pc.createdChannels.map((c) => c.label)).toEqual(["control"]);
});
});
describe("DataChannelManager.send", () => {
it("throws DATA_CHANNEL_CLOSED if channel is missing", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
try {
await manager.send("bob", "drive-chunks", "x");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
});
it("throws DATA_CHANNEL_CLOSED if channel not open", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
manager.open("bob");
try {
await manager.send("bob", "drive-chunks", "x");
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
});
it("sends string payload when open and below watermark", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager({ highWaterMarkBytes: 1_000 });
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
await manager.send("bob", "drive-chunks", "hello");
expect(ch.sentPayloads).toEqual(["hello"]);
});
it("sends ArrayBuffer payload", { timeout: 5000 }, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
const buf = new ArrayBuffer(4);
await manager.send("bob", "drive-chunks", buf);
expect(ch.sentPayloads[0]).toBe(buf);
});
it("waits for bufferedamountlow when buffered > watermark", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager({ highWaterMarkBytes: 100 });
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
ch.simulateBufferedAmount(500);
const pending = manager.send("bob", "drive-chunks", "p");
// Allow microtasks to run; pending must not yet have resolved.
let resolved = false;
pending.then(() => {
resolved = true;
});
await Promise.resolve();
expect(resolved).toBe(false);
ch.simulateBufferedAmountLow();
await pending;
expect(ch.sentPayloads).toEqual(["p"]);
});
it("rejects with DATA_CHANNEL_CLOSED when AbortSignal aborts before send", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
const ctrl = new AbortController();
ctrl.abort(new Error("boom"));
try {
await manager.send("bob", "drive-chunks", "p", ctrl.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
expect(ch.sentPayloads).toEqual([]);
});
it("rejects waiting send when AbortSignal aborts during drain", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager({ highWaterMarkBytes: 100 });
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
ch.simulateBufferedAmount(500);
const ctrl = new AbortController();
const pending = manager.send("bob", "drive-chunks", "p", ctrl.signal);
let rejection: unknown;
pending.catch((err) => {
rejection = err;
});
await Promise.resolve();
ctrl.abort(new Error("user-cancel"));
await Promise.resolve();
await Promise.resolve();
expect(rejection).toBeInstanceOf(DriveWebRtcError);
expect((rejection as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
});
});
describe("DataChannelManager.close / closeAll", () => {
it("close(peerId, label) detaches and emits", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
const closes: { peerId: string; label: string; reason?: string }[] = [];
manager.on("channel:close", (e) => closes.push(e));
manager.close("bob", "drive-chunks", "test-reason");
expect(ch.closed).toBe(true);
// Two close events: one from manager's emit + (NOT) one from the
// simulateClose dispatch. Because detach happened before
// channel.close, the close-listener already removed; only the
// manager's own emit lands.
expect(closes).toHaveLength(1);
expect(closes[0]?.reason).toBe("test-reason");
expect(manager.get("bob")).toBeUndefined();
});
it("close(peerId) closes every label", { timeout: 5000 }, async () => {
const { manager } = await makeManager();
const a = manager.open("bob", "a") as unknown as FakeRTCDataChannel;
const b = manager.open("bob", "b") as unknown as FakeRTCDataChannel;
a.simulateOpen();
b.simulateOpen();
manager.close("bob");
expect(a.closed).toBe(true);
expect(b.closed).toBe(true);
expect(manager.get("bob", "a")).toBeUndefined();
expect(manager.get("bob", "b")).toBeUndefined();
});
it("close is idempotent on unknown peer/label", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
manager.close("nobody");
manager.close("nobody", "x");
});
it("close with unknown label on known peer is a silent no-op", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
let closes = 0;
manager.on("channel:close", () => closes++);
manager.close("bob", "no-such-label");
expect(closes).toBe(0);
// The known label is still alive.
expect(manager.get("bob")).toBeDefined();
});
it("aborts drain wait with no signal reason", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager({ highWaterMarkBytes: 100 });
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
ch.simulateBufferedAmount(500);
const ctrl = new AbortController();
const pending = manager.send("bob", "drive-chunks", "p", ctrl.signal);
let rejection: unknown;
pending.catch((err) => {
rejection = err;
});
await Promise.resolve();
ctrl.abort(); // no reason
await Promise.resolve();
await Promise.resolve();
expect(rejection).toBeInstanceOf(DriveWebRtcError);
});
it("closeAll closes every peer", { timeout: 5000 }, async () => {
const client = new WebRTCClient({
peerId: "self",
config: { signaling: { url: "wss://x" } },
rtcPeerConnectionCtor:
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
});
await client.createOffer("bob");
await client.createOffer("carol");
const manager = new DataChannelManager({ client });
const a = manager.open("bob") as unknown as FakeRTCDataChannel;
const b = manager.open("carol") as unknown as FakeRTCDataChannel;
a.simulateOpen();
b.simulateOpen();
manager.closeAll("shutdown");
expect(a.closed).toBe(true);
expect(b.closed).toBe(true);
});
it("swallows close() throws", { timeout: 5000 }, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
ch.closeImpl = () => {
throw new Error("bad");
};
manager.close("bob");
});
});
describe("DataChannelManager remote events", () => {
it("forwards messages", { timeout: 5000 }, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
const messages: {
peerId: string;
label: string;
data: ArrayBuffer | string;
}[] = [];
manager.on("channel:message", (e) => messages.push(e));
ch.simulateMessage("hi");
const buf = new ArrayBuffer(2);
ch.simulateMessage(buf);
expect(messages).toHaveLength(2);
expect(messages[0]?.data).toBe("hi");
expect(messages[1]?.data).toBe(buf);
});
it("forwards errors with DriveWebRtcError(DATA_CHANNEL_CLOSED)", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
const errors: { error: DriveWebRtcError }[] = [];
manager.on("channel:error", (e) => errors.push(e));
ch.simulateError(new Error("boom"));
ch.simulateError(undefined);
expect(errors).toHaveLength(2);
expect(errors[0]?.error.code).toBe(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
expect(errors[0]?.error.cause).toBeInstanceOf(Error);
expect(errors[1]?.error.cause).toBeUndefined();
});
it("emits channel:close when channel closes remotely", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
ch.simulateOpen();
const closes: { peerId: string; label: string }[] = [];
manager.on("channel:close", (e) => closes.push(e));
ch.simulateClose();
expect(closes).toHaveLength(1);
expect(manager.get("bob")).toBeUndefined();
});
});
describe("DataChannelManager.get", () => {
it("returns the active channel by default label", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
const ch = manager.open("bob");
expect(manager.get("bob")).toBe(ch);
});
it("returns undefined for unknown peer/label", {
timeout: 5000,
}, async () => {
const { manager } = await makeManager();
expect(manager.get("nobody")).toBeUndefined();
manager.open("bob");
expect(manager.get("bob", "other")).toBeUndefined();
});
});

View File

@@ -0,0 +1,261 @@
/**
* DataChannelManager — opens and multiplexes `RTCDataChannel`s per
* peer for the drive-webrtc mesh. See `data-channel-manager.md` for
* prose docs.
*/
import type { WebRTCClient } from "../../client/client/client.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
const DEFAULT_LABEL = "drive-chunks";
const DEFAULT_HIGH_WATERMARK_BYTES = 1_048_576;
export interface DataChannelManagerOptions {
readonly client: WebRTCClient;
readonly defaultLabel?: string;
readonly defaultOrdered?: boolean;
readonly highWaterMarkBytes?: number;
}
export type DataChannelEventMap = {
"channel:open": [{ peerId: string; label: string }];
"channel:close": [{ peerId: string; label: string; reason?: string }];
"channel:message": [
{ peerId: string; label: string; data: ArrayBuffer | string },
];
"channel:error": [
{ peerId: string; label: string; error: DriveWebRtcError },
];
};
interface ChannelEntry {
readonly channel: RTCDataChannel;
readonly handlers: {
readonly onOpen: () => void;
readonly onClose: () => void;
readonly onError: (event: Event) => void;
readonly onMessage: (event: MessageEvent) => void;
};
}
export class DataChannelManager extends TypedEventEmitter<DataChannelEventMap> {
private readonly client: WebRTCClient;
private readonly defaultLabel: string;
private readonly defaultOrdered: boolean;
private readonly highWaterMarkBytes: number;
private readonly channels = new Map<string, Map<string, ChannelEntry>>();
public constructor(options: DataChannelManagerOptions) {
super();
this.client = options.client;
this.defaultLabel = options.defaultLabel ?? DEFAULT_LABEL;
this.defaultOrdered = options.defaultOrdered ?? true;
this.highWaterMarkBytes =
options.highWaterMarkBytes ?? DEFAULT_HIGH_WATERMARK_BYTES;
}
public open(
peerId: string,
label: string = this.defaultLabel,
init?: RTCDataChannelInit
): RTCDataChannel {
const pc = this.client.getPeerConnection(peerId);
if (pc === undefined) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_NOT_FOUND,
`no RTCPeerConnection for peer ${peerId}`
);
}
const existing = this.channels.get(peerId)?.get(label);
if (existing !== undefined) return existing.channel;
const channel = pc.createDataChannel(label, {
ordered: this.defaultOrdered,
...init,
});
this.register(peerId, label, channel);
return channel;
}
public async send(
peerId: string,
label: string,
payload: ArrayBuffer | string,
signal?: AbortSignal
): Promise<void> {
const channel = this.get(peerId, label);
if (channel === undefined || channel.readyState !== "open") {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
`channel ${label} for ${peerId} is not open`
);
}
if (signal?.aborted) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
"send aborted before dispatch",
{ cause: signal.reason as Error }
);
}
if (channel.bufferedAmount > this.highWaterMarkBytes) {
await this.waitForDrain(channel, signal);
}
if (typeof payload === "string") {
channel.send(payload);
} else {
channel.send(payload);
}
}
public close(peerId: string, label?: string, reason?: string): void {
const peerChannels = this.channels.get(peerId);
if (peerChannels === undefined) return;
if (label === undefined) {
for (const lab of Array.from(peerChannels.keys())) {
this.closeOne(peerId, lab, reason);
}
return;
}
this.closeOne(peerId, label, reason);
}
public closeAll(reason?: string): void {
for (const peerId of Array.from(this.channels.keys())) {
this.close(peerId, undefined, reason);
}
}
public get(
peerId: string,
label: string = this.defaultLabel
): RTCDataChannel | undefined {
return this.channels.get(peerId)?.get(label)?.channel;
}
private register(
peerId: string,
label: string,
channel: RTCDataChannel
): void {
const onOpen = (): void => {
this.emit("channel:open", { peerId, label });
};
const onClose = (): void => {
// `onClose` is only attached as a listener while the entry is
// tracked — `detach` removes the listener before deleting the
// entry, so reaching here implies the entry is still live.
this.detach(peerId, label);
this.emit("channel:close", { peerId, label });
};
const onError = (event: Event): void => {
const cause = (event as ErrorEvent).error as Error | undefined;
const error = new DriveWebRtcError(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
`data channel ${label} for ${peerId} errored`,
cause !== undefined ? { cause } : undefined
);
this.emit("channel:error", { peerId, label, error });
};
const onMessage = (event: MessageEvent): void => {
this.emit("channel:message", {
peerId,
label,
data: event.data as ArrayBuffer | string,
});
};
channel.addEventListener("open", onOpen);
channel.addEventListener("close", onClose);
channel.addEventListener("error", onError);
channel.addEventListener("message", onMessage);
let peerChannels = this.channels.get(peerId);
if (peerChannels === undefined) {
peerChannels = new Map();
this.channels.set(peerId, peerChannels);
}
peerChannels.set(label, {
channel,
handlers: { onOpen, onClose, onError, onMessage },
});
if (channel.readyState === "open") {
// Channel was already open at registration time (e.g.
// adopted via simulateOpen before the handler attached).
// Emit eagerly so callers awaiting "channel:open" make
// progress.
queueMicrotask(onOpen);
}
}
private closeOne(peerId: string, label: string, reason?: string): void {
const entry = this.channels.get(peerId)?.get(label);
if (entry === undefined) return;
this.detach(peerId, label);
try {
entry.channel.close();
} catch {
// close() should not throw on real channels, but the
// Fake doubles in tests may. Swallow to keep close()
// idempotent.
}
this.emit(
"channel:close",
reason === undefined ? { peerId, label } : { peerId, label, reason }
);
}
private detach(peerId: string, label: string): void {
// Callers (`closeOne`, `onClose`) only invoke `detach` after
// confirming both the peer and label maps still hold the entry,
// so the lookups below cannot fail at runtime.
const peerChannels = this.channels.get(peerId) as Map<
string,
ChannelEntry
>;
const entry = peerChannels.get(label) as ChannelEntry;
entry.channel.removeEventListener("open", entry.handlers.onOpen);
entry.channel.removeEventListener("close", entry.handlers.onClose);
entry.channel.removeEventListener("error", entry.handlers.onError);
entry.channel.removeEventListener("message", entry.handlers.onMessage);
peerChannels.delete(label);
if (peerChannels.size === 0) this.channels.delete(peerId);
}
private waitForDrain(
channel: RTCDataChannel,
signal: AbortSignal | undefined
): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = (): void => {
channel.removeEventListener(
"bufferedamountlow",
onLow as EventListener
);
signal?.removeEventListener("abort", onAbort);
};
const onLow = (): void => {
cleanup();
resolve();
};
const onAbort = (): void => {
cleanup();
// AbortController always sets `signal.reason` when abort
// fires (a DOMException by default); thread it through as
// the error cause.
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
"send aborted while waiting for bufferedamountlow",
{ cause: signal?.reason as Error }
)
);
};
channel.addEventListener(
"bufferedamountlow",
onLow as EventListener
);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
}

View File

@@ -0,0 +1,5 @@
export {
type DataChannelEventMap,
DataChannelManager,
type DataChannelManagerOptions,
} from "./data-channel-manager.js";

View File

@@ -0,0 +1,113 @@
# `ChunkDiscoveryManager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ChunkDiscoveryManager` orchestrates pull-side chunk discovery for the WebRTC drive transport. It accepts a `chunkId` from the caller, ranks every connected peer that advertises the chunk by their `PeerBandwidthTracker` score, dispatches a `request-chunk` wire message to the best candidate, awaits a `chunk-data` (or reassembled `chunk-data-part` stream) reply within a per-attempt timeout, verifies the bytes through the `ChunkIntegrityManager`, and falls back to the next-best peer on any failure.
The manager deduplicates concurrent `request(chunkId)` calls so a noisy drive layer (multiple workers, retried hot paths) never produces more than one wire request per chunk, and enforces a global concurrency cap so a fleet-wide download burst cannot saturate every peer at once. Every state transition is published through a typed `TypedEventEmitter` channel so observability and metrics packages can consume the same surface.
## Features
- Best-score peer ranking via `PeerBandwidthTracker`.
- Multi-peer fallback on timeout / integrity failure / channel error.
- In-flight de-duplication keyed by `chunkId`.
- Bounded global concurrency with FIFO slot queue, both abortable and `closeAll`-cancellable.
- Sub-chunk reassembly through `SubChunkReassembler` for chunks exceeding the 16 KiB SCTP wire limit.
- Real-crypto integrity check (SHA-256 + optional Ed25519 signature) via `ChunkIntegrityManager`.
- AbortSignal threaded through queue-wait, wire-send, and response-await paths.
- Five typed events: `request:start`, `request:attempt`, `request:success`, `request:fail`, `request:exhausted`.
## Usage: Basic Example
```ts
import {
ChunkDiscoveryManager,
ChunkIntegrityManager,
DataChannelManager,
PeerBandwidthTracker,
PeerManager,
WebRTCClient,
} from "@suaveplan/drive-webrtc";
const client = new WebRTCClient({ peerId: "self", config });
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const channels = new DataChannelManager({ client });
const integrity = new ChunkIntegrityManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: channels,
integrity,
});
const bytes = await discovery.request("chunk-abc");
```
## Usage: Advanced Example
Wire telemetry, supply an Ed25519 publisher key, and cap concurrency at four:
```ts
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: channels,
integrity,
requestTimeoutMs: 8_000,
maxConcurrentRequests: 4,
});
discovery.on("request:fail", ({ chunkId, peerId, reason }) => {
metrics.failures.inc({ peerId, reason });
});
discovery.on("request:success", ({ chunkId, bytes, durationMs }) => {
metrics.latency.observe(durationMs);
});
const controller = new AbortController();
const bytes = await discovery.request("chunk-abc", {
signal: controller.signal,
publicKey: publisherKey,
});
```
`closeAll(reason?)` aborts every in-flight attempt and rejects every queued slot-waiter so a clean shutdown surfaces deterministically.
## API Reference
### `new ChunkDiscoveryManager(options)`
- `peers: PeerManager` — peer registry.
- `bandwidth: PeerBandwidthTracker` — score source.
- `dataChannels: DataChannelManager` — wire transport.
- `integrity: ChunkIntegrityManager` — verifier.
- `defaultLabel?: string` (default `"drive-chunks"`).
- `requestTimeoutMs?: number` (default `10_000`).
- `maxConcurrentRequests?: number` (default `16`).
- `clock?: { now(): number }`.
### `request(chunkId, options?)` → `Promise<Uint8Array>`
Returns verified chunk bytes from the highest-scoring peer that advertises `chunkId`. De-dup keyed by `chunkId`. Throws `DriveWebRtcError(CHUNK_NOT_AVAILABLE)` when no peer advertises or every candidate fails.
### `inFlight` (getter) → `number`
Active + queued requests.
### `peerManager` (getter) → `PeerManager`
Read-only handle for `WebRTCChunkSource.health` derivation.
### `closeAll(reason?)`
Aborts every active and queued request. Idempotent.
## Implementation Notes
The slot queue is a plain FIFO array; releasing a slot wakes exactly one waiter so order is preserved. Slot acquisition itself is abortable, which means a queued request whose `AbortSignal` fires never enters the dispatch path. The integrity check trusts the locally-verifiable hash bound to the chunk identity (publisher-signed when a `publicKey` is provided); a tampered response always fails before the bytes are delivered to the caller, and the manager records `request:fail` then continues to the next candidate without surfacing the half-trusted data.

View File

@@ -0,0 +1,874 @@
/**
* Tests for {@link ChunkDiscoveryManager}. Uses a structural
* MemoryDataChannelManager (real class, not a mock) that extends
* the same TypedEventEmitter as the real DataChannelManager and
* exposes the same `send`/`on` shape. Wire format and integrity
* checks run against real ed25519 + SHA-256.
*/
import * as ed25519 from "@suaveplan/crypto-curve/ed25519";
import { sha256 } from "@suaveplan/crypto-hash";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type { DataChannelManager } from "../../data-channel/data-channel-manager/index.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
import { ChunkIntegrityManager } from "../../integrity/chunk-integrity/index.js";
import { PeerBandwidthTracker } from "../../peer-manager/peer-bandwidth-tracker/index.js";
import { PeerManager } from "../../peer-manager/peer-manager/index.js";
import { encodeToBase64 } from "../../sub-chunk/sub-chunk/index.js";
import { ChunkDiscoveryManager } from "./chunk-discovery-manager.js";
interface ChannelEventMap {
"channel:open": [{ peerId: string; label: string }];
"channel:close": [{ peerId: string; label: string; reason?: string }];
"channel:message": [
{ peerId: string; label: string; data: ArrayBuffer | string },
];
"channel:error": [
{ peerId: string; label: string; error: DriveWebRtcError },
];
}
interface SendRecord {
peerId: string;
label: string;
payload: ArrayBuffer | string;
}
class MemoryDataChannelManager extends TypedEventEmitter<ChannelEventMap> {
public readonly sends: SendRecord[] = [];
public sendImpl: (
peerId: string,
label: string,
payload: ArrayBuffer | string
) => Promise<void> = async (peerId, label, payload) => {
this.sends.push({ peerId, label, payload });
};
public async send(
peerId: string,
label: string,
payload: ArrayBuffer | string,
_signal?: AbortSignal
): Promise<void> {
return this.sendImpl(peerId, label, payload);
}
public simulateIncoming(peerId: string, label: string, data: string): void {
this.emit("channel:message", { peerId, label, data });
}
}
function asReal(m: MemoryDataChannelManager): DataChannelManager {
return m as unknown as DataChannelManager;
}
function buildHarness(): {
peers: PeerManager;
bandwidth: PeerBandwidthTracker;
integrity: ChunkIntegrityManager;
channels: MemoryDataChannelManager;
discovery: ChunkDiscoveryManager;
} {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
requestTimeoutMs: 250,
});
return { peers, bandwidth, integrity, channels, discovery };
}
function admitPeer(peers: PeerManager, id: string, chunks: string[]): void {
peers.admit({
id,
name: id,
chunks: new Set(chunks),
connectionState: "connected",
lastSeen: Date.now(),
});
}
function parseRequestId(payload: string): string {
const obj = JSON.parse(payload) as { requestId: string };
return obj.requestId;
}
function chunkDataMessage(args: {
requestId: string;
chunkId: string;
data: Uint8Array;
hash?: Uint8Array;
signature?: Uint8Array;
}): string {
return JSON.stringify({
type: "chunk-data",
payload: {
requestId: args.requestId,
fileId: args.chunkId,
chunkId: args.chunkId,
data: encodeToBase64(args.data),
...(args.hash !== undefined
? { hash: encodeToBase64(args.hash) }
: {}),
...(args.signature !== undefined
? { signature: encodeToBase64(args.signature) }
: {}),
},
});
}
let harnesses: Array<{ discovery: ChunkDiscoveryManager }> = [];
beforeEach(() => {
harnesses = [];
});
afterEach(() => {
for (const h of harnesses) h.discovery.closeAll();
});
describe("ChunkDiscoveryManager.request — happy path", () => {
it("fetches a chunk from a peer that advertises it", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c1"]);
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = sha256(data);
const promise = h.discovery.request("c1");
await new Promise((r) => setTimeout(r, 5));
const send = h.channels.sends[0];
expect(send).toBeDefined();
const reqId = parseRequestId(send!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqId,
chunkId: "c1",
data,
hash,
})
);
const result = await promise;
expect(Array.from(result)).toEqual(Array.from(data));
});
it("verifies signature when publicKey provided", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c2"]);
const data = new Uint8Array([9, 8, 7]);
const hash = sha256(data);
const sk = ed25519.randomPrivateKey();
const pk = ed25519.getPublicKey(sk);
// Canonical sign-message: hash || utf8(chunkId)
const idBytes = new TextEncoder().encode("c2");
const msg = new Uint8Array(hash.length + idBytes.length);
msg.set(hash, 0);
msg.set(idBytes, hash.length);
const sig = ed25519.sign(msg, sk);
const promise = h.discovery.request("c2", { publicKey: pk });
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqId,
chunkId: "c2",
data,
hash,
signature: sig,
})
);
const result = await promise;
expect(result.length).toBe(3);
});
});
describe("ChunkDiscoveryManager.request — fallback + integrity", () => {
it("falls back to peer B when peer A times out", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c1"]);
admitPeer(h.peers, "bob", ["c1"]);
const data = new Uint8Array([1, 2, 3]);
const hash = sha256(data);
const promise = h.discovery.request("c1");
// Wait past the request timeout (250ms) so alice's attempt
// expires and bob receives the wire request.
await new Promise((r) => setTimeout(r, 300));
// Bob's send is the second one in the list.
const bobSend = h.channels.sends.find((s) => s.peerId === "bob");
expect(bobSend).toBeDefined();
const reqId = parseRequestId(bobSend!.payload as string);
h.channels.simulateIncoming(
"bob",
"drive-chunks",
chunkDataMessage({ requestId: reqId, chunkId: "c1", data, hash })
);
const result = await promise;
expect(Array.from(result)).toEqual(Array.from(data));
});
it("falls back when peer A returns tampered bytes", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c3"]);
admitPeer(h.peers, "bob", ["c3"]);
const data = new Uint8Array([4, 5, 6, 7]);
const hash = sha256(data);
const tampered = new Uint8Array([0, 0, 0, 0]);
const failures: string[] = [];
h.discovery.on("request:fail", (e) => failures.push(e.peerId));
const promise = h.discovery.request("c3");
await new Promise((r) => setTimeout(r, 5));
const aliceSend = h.channels.sends.find((s) => s.peerId === "alice");
expect(aliceSend).toBeDefined();
const aliceReqId = parseRequestId(aliceSend!.payload as string);
// Alice claims chunk has `hash` but sends `tampered` bytes.
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: aliceReqId,
chunkId: "c3",
data: tampered,
hash,
})
);
await new Promise((r) => setTimeout(r, 10));
const bobSend = h.channels.sends.find((s) => s.peerId === "bob");
expect(bobSend).toBeDefined();
const bobReqId = parseRequestId(bobSend!.payload as string);
h.channels.simulateIncoming(
"bob",
"drive-chunks",
chunkDataMessage({
requestId: bobReqId,
chunkId: "c3",
data,
hash,
})
);
const result = await promise;
expect(Array.from(result)).toEqual(Array.from(data));
expect(failures).toContain("alice");
});
});
describe("ChunkDiscoveryManager.request — de-dup + concurrency", () => {
it("returns the same in-flight Promise on duplicate calls", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["k"]);
const data = new Uint8Array([42]);
const hash = sha256(data);
const p1 = h.discovery.request("k");
const p2 = h.discovery.request("k");
expect(p1).toBe(p2);
await new Promise((r) => setTimeout(r, 5));
// Only one wire request emitted.
expect(h.channels.sends.length).toBe(1);
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({ requestId: reqId, chunkId: "k", data, hash })
);
await Promise.all([p1, p2]);
});
it("queues past the maxConcurrentRequests cap", {
timeout: 10000,
}, async () => {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
requestTimeoutMs: 5000,
maxConcurrentRequests: 2,
});
harnesses.push({ discovery });
admitPeer(peers, "alice", ["a", "b", "c"]);
const p1 = discovery.request("a");
const p2 = discovery.request("b");
const p3 = discovery.request("c");
await new Promise((r) => setTimeout(r, 10));
// Only two requests have been dispatched (a and b); c is queued.
expect(channels.sends.length).toBe(2);
expect(discovery.inFlight).toBe(3);
// Resolve a — frees a slot.
const dataA = new Uint8Array([1]);
const hashA = sha256(dataA);
const reqA = parseRequestId(
channels.sends.find((s) =>
s.payload.toString().includes('"chunkId":"a"')
)!.payload as string
);
channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqA,
chunkId: "a",
data: dataA,
hash: hashA,
})
);
await p1;
await new Promise((r) => setTimeout(r, 10));
expect(channels.sends.length).toBe(3);
const dataB = new Uint8Array([2]);
const hashB = sha256(dataB);
const reqB = parseRequestId(
channels.sends.find((s) =>
s.payload.toString().includes('"chunkId":"b"')
)!.payload as string
);
channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqB,
chunkId: "b",
data: dataB,
hash: hashB,
})
);
await p2;
const dataC = new Uint8Array([3]);
const hashC = sha256(dataC);
const reqC = parseRequestId(
channels.sends.find((s) =>
s.payload.toString().includes('"chunkId":"c"')
)!.payload as string
);
channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqC,
chunkId: "c",
data: dataC,
hash: hashC,
})
);
await p3;
});
});
describe("ChunkDiscoveryManager.request — abort + close", () => {
it("rejects when no peer advertises the chunk", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["other"]);
try {
await h.discovery.request("missing");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE
);
}
});
it("closeAll aborts pending in-flight requests", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const promise = h.discovery.request("c");
await new Promise((r) => setTimeout(r, 5));
h.discovery.closeAll("shutdown");
try {
await promise;
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
});
it("closeAll rejects queued slot waiters", { timeout: 5000 }, async () => {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
requestTimeoutMs: 5000,
maxConcurrentRequests: 1,
});
harnesses.push({ discovery });
admitPeer(peers, "alice", ["x", "y"]);
const p1 = discovery.request("x");
const p2 = discovery.request("y");
await new Promise((r) => setTimeout(r, 5));
discovery.closeAll("shutdown");
try {
await p2;
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
try {
await p1;
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
});
it("closeAll rejects subsequent request calls", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
h.discovery.closeAll();
try {
await h.discovery.request("c");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE
);
}
});
it("AbortSignal cancels a single request mid-flight", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const controller = new AbortController();
const promise = h.discovery.request("c", { signal: controller.signal });
await new Promise((r) => setTimeout(r, 5));
controller.abort(new Error("user-cancelled"));
try {
await promise;
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
});
it("AbortSignal aborts a request queued for a slot", {
timeout: 5000,
}, async () => {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
requestTimeoutMs: 5000,
maxConcurrentRequests: 1,
});
harnesses.push({ discovery });
admitPeer(peers, "alice", ["a", "b"]);
const p1 = discovery.request("a");
const controller = new AbortController();
const p2 = discovery.request("b", { signal: controller.signal });
await new Promise((r) => setTimeout(r, 5));
controller.abort();
try {
await p2;
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
// Resolve p1 to clean up.
const reqA = parseRequestId(channels.sends[0]!.payload as string);
const data = new Uint8Array([1]);
channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqA,
chunkId: "a",
data,
hash: sha256(data),
})
);
await p1;
});
it("rejects immediately when signal already aborted before slot acquire", {
timeout: 5000,
}, async () => {
const bandwidth = new PeerBandwidthTracker();
const peers = new PeerManager({ selfPeerId: "self", bandwidth });
const integrity = new ChunkIntegrityManager();
const channels = new MemoryDataChannelManager();
const discovery = new ChunkDiscoveryManager({
peers,
bandwidth,
dataChannels: asReal(channels),
integrity,
maxConcurrentRequests: 1,
});
harnesses.push({ discovery });
admitPeer(peers, "alice", ["x", "y"]);
const p1 = discovery.request("x");
const controller = new AbortController();
controller.abort();
try {
await discovery.request("y", { signal: controller.signal });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
// Clean up p1.
const reqX = parseRequestId(channels.sends[0]!.payload as string);
const data = new Uint8Array([1]);
channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqX,
chunkId: "x",
data,
hash: sha256(data),
})
);
await p1;
});
});
describe("ChunkDiscoveryManager.request — sub-chunk reassembly + edge cases", () => {
it("reassembles a multi-part response", { timeout: 5000 }, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const data = new Uint8Array([10, 20, 30, 40, 50, 60]);
const hash = sha256(data);
const promise = h.discovery.request("c");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
const part1 = data.subarray(0, 3);
const part2 = data.subarray(3);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({
type: "chunk-data-part",
requestId: reqId,
partIndex: 0,
totalParts: 2,
data: encodeToBase64(part1),
chunkId: "c",
})
);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({
type: "chunk-data-part",
requestId: reqId,
partIndex: 1,
totalParts: 2,
data: encodeToBase64(part2),
chunkId: "c",
hash: encodeToBase64(hash),
})
);
const result = await promise;
expect(Array.from(result)).toEqual(Array.from(data));
});
it("ignores messages for unrelated peers and unrelated requestIds", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const data = new Uint8Array([7, 7, 7]);
const hash = sha256(data);
const promise = h.discovery.request("c");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
// Wrong peer
h.channels.simulateIncoming(
"bob",
"drive-chunks",
chunkDataMessage({
requestId: reqId,
chunkId: "c",
data,
hash,
})
);
// Wrong label
h.channels.simulateIncoming(
"alice",
"other-label",
chunkDataMessage({
requestId: reqId,
chunkId: "c",
data,
hash,
})
);
// Wrong requestId
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: "other-id",
chunkId: "c",
data,
hash,
})
);
// Binary payload (ignored)
h.channels.emit("channel:message", {
peerId: "alice",
label: "drive-chunks",
data: new ArrayBuffer(8),
});
// Malformed JSON
h.channels.simulateIncoming("alice", "drive-chunks", "{not json");
// Top-level JSON null
h.channels.simulateIncoming("alice", "drive-chunks", "null");
// Wrong type
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({ type: "noise" })
);
// chunk-data-part with wrong requestId
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({
type: "chunk-data-part",
requestId: "other",
partIndex: 0,
totalParts: 1,
data: encodeToBase64(new Uint8Array([1])),
})
);
// chunk-data with missing fields
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({
type: "chunk-data",
payload: { requestId: reqId },
})
);
// chunk-data with missing payload
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({ type: "chunk-data" })
);
// chunk-data-part with malformed fields
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({
type: "chunk-data-part",
requestId: reqId,
partIndex: "x",
totalParts: 2,
data: "abc",
})
);
// Now the real one
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({
requestId: reqId,
chunkId: "c",
data,
hash,
})
);
const result = await promise;
expect(result.length).toBe(3);
});
it("emits the full event sequence on success", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const events: string[] = [];
h.discovery.on("request:start", () => events.push("start"));
h.discovery.on("request:attempt", () => events.push("attempt"));
h.discovery.on("request:success", () => events.push("success"));
const data = new Uint8Array([1]);
const hash = sha256(data);
const promise = h.discovery.request("c");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({ requestId: reqId, chunkId: "c", data, hash })
);
await promise;
expect(events).toEqual(["start", "attempt", "success"]);
});
it("ranks higher-scoring peers first", { timeout: 5000 }, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "slow", ["c"]);
admitPeer(h.peers, "fast", ["c"]);
// Drive slow's score down.
h.bandwidth.recordRtt("slow", 800);
h.bandwidth.recordBytesIn("slow", 0); // trigger non-default
let firstSendPeer = "";
h.discovery.on("request:start", (e) => {
firstSendPeer = e.candidates[0] ?? "";
});
const data = new Uint8Array([1]);
const hash = sha256(data);
const promise = h.discovery.request("c");
await new Promise((r) => setTimeout(r, 5));
expect(firstSendPeer).toBe("fast");
const fastSend = h.channels.sends.find((s) => s.peerId === "fast");
expect(fastSend).toBeDefined();
const reqId = parseRequestId(fastSend!.payload as string);
h.channels.simulateIncoming(
"fast",
"drive-chunks",
chunkDataMessage({ requestId: reqId, chunkId: "c", data, hash })
);
await promise;
});
it("rejects when send() rejects", { timeout: 5000 }, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
h.channels.sendImpl = async () => {
throw new Error("channel closed");
};
try {
await h.discovery.request("c");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE
);
}
});
it("fails integrity check when chunk-data has no hash", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const data = new Uint8Array([1, 2, 3]);
const promise = h.discovery.request("c");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
// No hash → integrity verifyHash reports missing-hash → fail.
h.channels.simulateIncoming(
"alice",
"drive-chunks",
chunkDataMessage({ requestId: reqId, chunkId: "c", data })
);
try {
await promise;
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
});
it("reassembles a single-part response without chunkId / with signature", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const data = new Uint8Array([5, 5, 5]);
const hash = sha256(data);
const promise = h.discovery.request("c");
await new Promise((r) => setTimeout(r, 5));
const reqId = parseRequestId(h.channels.sends[0]!.payload as string);
// Single-part: totalParts=1, partIndex=0 → complete on the first
// part. Carry hash AND a dummy signature so the ternary "set"
// branches are taken.
h.channels.simulateIncoming(
"alice",
"drive-chunks",
JSON.stringify({
type: "chunk-data-part",
requestId: reqId,
partIndex: 0,
totalParts: 1,
data: encodeToBase64(data),
hash: encodeToBase64(hash),
signature: encodeToBase64(new Uint8Array(64)),
})
);
const result = await promise;
expect(Array.from(result)).toEqual([5, 5, 5]);
});
it("times out and emits request:fail for the candidate", {
timeout: 5000,
}, async () => {
const h = buildHarness();
harnesses.push(h);
admitPeer(h.peers, "alice", ["c"]);
const fails: { peerId: string; reason: string }[] = [];
h.discovery.on("request:fail", (e) => fails.push(e));
try {
await h.discovery.request("c");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
}
expect(fails[0]?.peerId).toBe("alice");
});
});

View File

@@ -0,0 +1,517 @@
/**
* ChunkDiscoveryManager — request chunks from the best-scoring peer
* that advertises them, with integrity verification, multi-peer
* fallback, in-flight de-duplication, and a global concurrency cap.
*
* See `chunk-discovery-manager.md` for prose docs.
*/
import type { DataChannelManager } from "../../data-channel/data-channel-manager/index.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
import type { ChunkIntegrityManager } from "../../integrity/chunk-integrity/index.js";
import type { PeerBandwidthTracker } from "../../peer-manager/peer-bandwidth-tracker/index.js";
import type { PeerManager } from "../../peer-manager/peer-manager/index.js";
import { generateSecureRequestId } from "../../security/security-validator/index.js";
import {
decodeFromBase64,
SubChunkReassembler,
} from "../../sub-chunk/sub-chunk/index.js";
const DEFAULT_LABEL = "drive-chunks";
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_CONCURRENT = 16;
/** Constructor options for {@link ChunkDiscoveryManager}. */
export interface ChunkDiscoveryManagerOptions {
readonly peers: PeerManager;
readonly bandwidth: PeerBandwidthTracker;
readonly dataChannels: DataChannelManager;
readonly integrity: ChunkIntegrityManager;
readonly defaultLabel?: string;
readonly requestTimeoutMs?: number;
readonly maxConcurrentRequests?: number;
readonly clock?: { now: () => number };
}
/** Events emitted by {@link ChunkDiscoveryManager}. */
export type ChunkDiscoveryEventMap = {
"request:start": [{ chunkId: string; candidates: readonly string[] }];
"request:attempt": [{ chunkId: string; peerId: string; attempt: number }];
"request:success": [
{
chunkId: string;
peerId: string;
bytes: number;
durationMs: number;
},
];
"request:fail": [{ chunkId: string; peerId: string; reason: string }];
"request:exhausted": [{ chunkId: string }];
};
interface IncomingChunkData {
readonly requestId: string;
readonly chunkId: string;
readonly data: Uint8Array;
readonly hash: Uint8Array | undefined;
readonly signature: Uint8Array | undefined;
}
interface IncomingChunkPart {
readonly requestId: string;
readonly partIndex: number;
readonly totalParts: number;
readonly data: Uint8Array;
readonly chunkId: string | undefined;
readonly hash: Uint8Array | undefined;
readonly signature: Uint8Array | undefined;
}
type ParsedMessage =
| { kind: "chunk-data"; payload: IncomingChunkData }
| { kind: "chunk-data-part"; payload: IncomingChunkPart }
| { kind: "ignore" };
/**
* Manages chunk download from connected peers. Reuses an in-flight
* request when one already exists for the same `chunkId`, ranks peers
* by `bandwidth.snapshot()` score, falls back through candidates on
* any failure, and verifies every response through
* {@link ChunkIntegrityManager}.
*/
export class ChunkDiscoveryManager extends TypedEventEmitter<ChunkDiscoveryEventMap> {
private readonly peers: PeerManager;
private readonly bandwidth: PeerBandwidthTracker;
private readonly dataChannels: DataChannelManager;
private readonly integrity: ChunkIntegrityManager;
private readonly label: string;
private readonly requestTimeoutMs: number;
private readonly maxConcurrent: number;
private readonly clock: { now: () => number };
private readonly inflightByChunk = new Map<string, Promise<Uint8Array>>();
private readonly waiters: Array<() => void> = [];
private active = 0;
private closed = false;
private readonly abortControllers = new Set<AbortController>();
public constructor(options: ChunkDiscoveryManagerOptions) {
super();
this.peers = options.peers;
this.bandwidth = options.bandwidth;
this.dataChannels = options.dataChannels;
this.integrity = options.integrity;
this.label = options.defaultLabel ?? DEFAULT_LABEL;
this.requestTimeoutMs =
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.maxConcurrent =
options.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT;
this.clock = options.clock ?? { now: () => Date.now() };
}
/** Number of in-flight requests (active + queued). */
public get inFlight(): number {
return this.active + this.waiters.length;
}
/** Read-only access to the underlying peer manager. */
public get peerManager(): PeerManager {
return this.peers;
}
/**
* Fetch the chunk bytes from the best-scoring peer that advertises
* it. Returns the in-flight Promise on duplicate calls so concurrent
* `request(chunkId)` calls produce one wire request.
*/
public request(
chunkId: string,
options: { signal?: AbortSignal; publicKey?: Uint8Array } = {}
): Promise<Uint8Array> {
if (this.closed) {
return Promise.reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE,
"ChunkDiscoveryManager is closed"
)
);
}
const existing = this.inflightByChunk.get(chunkId);
if (existing !== undefined) return existing;
const promise = this.runRequest(chunkId, options).finally(() => {
this.inflightByChunk.delete(chunkId);
});
this.inflightByChunk.set(chunkId, promise);
return promise;
}
/** Abort every queued and active request. Idempotent. */
public closeAll(reason = "ChunkDiscoveryManager.closeAll"): void {
this.closed = true;
for (const controller of Array.from(this.abortControllers)) {
controller.abort(new Error(reason));
}
this.abortControllers.clear();
const queued = this.waiters.splice(0, this.waiters.length);
for (const w of queued) w();
}
private async runRequest(
chunkId: string,
options: { signal?: AbortSignal; publicKey?: Uint8Array }
): Promise<Uint8Array> {
await this.acquireSlot(options.signal);
try {
return await this.runRequestUnsynchronized(chunkId, options);
} finally {
this.releaseSlot();
}
}
private acquireSlot(signal: AbortSignal | undefined): Promise<void> {
if (signal?.aborted) {
return Promise.reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE,
"request aborted before slot acquire",
{ cause: signal.reason as Error }
)
);
}
if (this.active < this.maxConcurrent) {
this.active += 1;
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const onWake = (): void => {
signal?.removeEventListener("abort", onAbort);
if (this.closed) {
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE,
"ChunkDiscoveryManager closed while waiting for slot"
)
);
return;
}
this.active += 1;
resolve();
};
const onAbort = (): void => {
const idx = this.waiters.indexOf(onWake);
this.waiters.splice(idx, 1);
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE,
"request aborted while waiting for slot",
{ cause: signal?.reason as Error }
)
);
};
signal?.addEventListener("abort", onAbort, { once: true });
this.waiters.push(onWake);
});
}
private releaseSlot(): void {
this.active -= 1;
const next = this.waiters.shift();
next?.();
}
private async runRequestUnsynchronized(
chunkId: string,
options: { signal?: AbortSignal; publicKey?: Uint8Array }
): Promise<Uint8Array> {
const candidates = this.rankCandidates(chunkId);
this.emit("request:start", {
chunkId,
candidates: candidates.map((p) => p.id),
});
if (candidates.length === 0) {
this.emit("request:exhausted", { chunkId });
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE,
`no peer advertises chunk ${chunkId}`,
{ context: { chunkId } }
);
}
let attempt = 0;
for (const peer of candidates) {
attempt += 1;
this.emit("request:attempt", {
chunkId,
peerId: peer.id,
attempt,
});
try {
const start = this.clock.now();
const bytes = await this.attemptOne(chunkId, peer.id, options);
const durationMs = this.clock.now() - start;
this.emit("request:success", {
chunkId,
peerId: peer.id,
bytes: bytes.length,
durationMs,
});
return bytes;
} catch (err) {
const reason = (err as Error).message;
this.emit("request:fail", {
chunkId,
peerId: peer.id,
reason,
});
if (options.signal?.aborted === true) {
throw err;
}
}
}
this.emit("request:exhausted", { chunkId });
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CHUNK_NOT_AVAILABLE,
`every peer failed to serve chunk ${chunkId}`,
{ context: { chunkId } }
);
}
private rankCandidates(
chunkId: string
): ReadonlyArray<{ id: string; score: number }> {
const list = this.peers.list();
const filtered = list
.filter((peer) => peer.chunks.has(chunkId))
.map((peer) => ({
id: peer.id,
score: this.bandwidth.get(peer.id).score,
}));
filtered.sort((a, b) => b.score - a.score);
return filtered;
}
private attemptOne(
chunkId: string,
peerId: string,
options: { signal?: AbortSignal; publicKey?: Uint8Array }
): Promise<Uint8Array> {
const requestId = generateSecureRequestId();
const reassembler = new SubChunkReassembler({
clock: () => this.clock.now(),
});
const controller = new AbortController();
this.abortControllers.add(controller);
const externalSignal = options.signal;
const onExternalAbort = (): void => {
controller.abort(externalSignal?.reason as Error | undefined);
};
externalSignal?.addEventListener("abort", onExternalAbort, {
once: true,
});
return new Promise<Uint8Array>((resolve, reject) => {
let unsubscribe: (() => void) | null = null;
const settle = (): void => {
clearTimeout(timer);
unsubscribe?.();
this.abortControllers.delete(controller);
externalSignal?.removeEventListener("abort", onExternalAbort);
};
const timer = setTimeout(() => {
settle();
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
`request to ${peerId} timed out after ${this.requestTimeoutMs}ms`,
{ context: { peerId, chunkId, requestId } }
)
);
}, this.requestTimeoutMs);
const onAbort = (): void => {
settle();
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
`request to ${peerId} aborted`,
{ cause: controller.signal.reason as Error }
)
);
};
controller.signal.addEventListener("abort", onAbort, {
once: true,
});
const handleVerified = async (
resolvedChunkId: string,
data: Uint8Array,
hash: Uint8Array | undefined,
signature: Uint8Array | undefined
): Promise<void> => {
const result = await this.integrity.verify(
{
chunkId: resolvedChunkId,
data,
...(hash !== undefined ? { hash } : {}),
...(signature !== undefined ? { signature } : {}),
},
options.publicKey
);
if (!result.ok) {
settle();
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CHUNK_INTEGRITY_FAILED,
`chunk ${resolvedChunkId} integrity check failed: ${result.reason}`,
{
context: {
chunkId: resolvedChunkId,
peerId,
reason: result.reason,
},
}
)
);
return;
}
settle();
this.bandwidth.recordBytesIn(peerId, data.length);
resolve(data);
};
const onMessage = (event: {
peerId: string;
label: string;
data: ArrayBuffer | string;
}): void => {
if (event.peerId !== peerId) return;
if (event.label !== this.label) return;
if (typeof event.data !== "string") return;
const parsed = parseMessage(event.data, requestId);
if (parsed.kind === "ignore") return;
if (parsed.kind === "chunk-data") {
void handleVerified(
parsed.payload.chunkId,
parsed.payload.data,
parsed.payload.hash,
parsed.payload.signature
);
return;
}
const part = parsed.payload;
const result = reassembler.addPart({
chunkId,
partId: `${chunkId}:${part.partIndex}`,
index: part.partIndex,
total: part.totalParts,
bytes: part.data,
});
if (result.kind === "complete") {
void handleVerified(
result.chunkId,
result.data,
part.hash,
part.signature
);
}
};
const off = this.dataChannels.on("channel:message", onMessage);
unsubscribe = () => {
off();
};
this.bandwidth.recordRequestOut(peerId);
const wire = JSON.stringify({
type: "request-chunk",
requestId,
fileId: chunkId,
chunkId,
});
this.dataChannels.send(peerId, this.label, wire).catch((err) => {
settle();
reject(err as Error);
});
});
}
}
function parseMessage(raw: string, requestId: string): ParsedMessage {
let json: unknown;
try {
json = JSON.parse(raw);
} catch {
return { kind: "ignore" };
}
if (typeof json !== "object" || json === null) return { kind: "ignore" };
const obj = json as Record<string, unknown>;
const type = obj["type"];
if (type === "chunk-data") {
const payload = obj["payload"] as
| {
requestId?: string;
chunkId?: string;
data?: string;
hash?: string;
signature?: string;
}
| undefined;
if (payload === undefined) return { kind: "ignore" };
if (payload.requestId !== requestId) return { kind: "ignore" };
if (
typeof payload.chunkId !== "string" ||
typeof payload.data !== "string"
) {
return { kind: "ignore" };
}
return {
kind: "chunk-data",
payload: {
requestId: payload.requestId,
chunkId: payload.chunkId,
data: decodeFromBase64(payload.data),
hash:
typeof payload.hash === "string"
? decodeFromBase64(payload.hash)
: undefined,
signature:
typeof payload.signature === "string"
? decodeFromBase64(payload.signature)
: undefined,
},
};
}
if (type === "chunk-data-part") {
const partRequestId = obj["requestId"];
if (partRequestId !== requestId) return { kind: "ignore" };
const partIndex = obj["partIndex"];
const totalParts = obj["totalParts"];
const partData = obj["data"];
if (
typeof partIndex !== "number" ||
typeof totalParts !== "number" ||
typeof partData !== "string"
) {
return { kind: "ignore" };
}
const partChunkId = obj["chunkId"];
const partHash = obj["hash"];
const partSig = obj["signature"];
return {
kind: "chunk-data-part",
payload: {
requestId: partRequestId as string,
partIndex,
totalParts,
data: decodeFromBase64(partData),
chunkId:
typeof partChunkId === "string" ? partChunkId : undefined,
hash:
typeof partHash === "string"
? decodeFromBase64(partHash)
: undefined,
signature:
typeof partSig === "string"
? decodeFromBase64(partSig)
: undefined,
},
};
}
return { kind: "ignore" };
}

View File

@@ -0,0 +1,5 @@
export {
type ChunkDiscoveryEventMap,
ChunkDiscoveryManager,
type ChunkDiscoveryManagerOptions,
} from "./chunk-discovery-manager.js";

View File

@@ -0,0 +1,115 @@
# Error Codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes.ts` module defines the closed registry of error codes that `@suaveplan/drive-webrtc` may throw, plus the `DriveWebRtcError` class extending `SuaveplanError` from `@suaveplan/error`. Every public boundary in the package (signaling client, peer manager, data-channel manager, rate limiter, chunk source, plugin host) throws a `DriveWebRtcError` carrying one of the registered codes — never a bare `Error` and never a string-coded ad-hoc throw.
Closed code registries are the single most useful debugging tool in a distributed system: every failure has a stable, greppable identifier; every dashboard can pivot on it; every customer-support ticket can include it without ambiguity. The `defineCodes` helper from `@suaveplan/error` enforces uniqueness within the domain and exports a typed map so consumers can pattern-match on the code at compile time.
## Features
- 13 stable error codes covering signaling, handshake, ICE, integrity, rate limiting, chunk transfer, and configuration
- `defineCodes("DRIVE_WEBRTC", [...])` registration produces an immutable map
- `DriveWebRtcErrorCode` literal-union type for exhaustive pattern matching
- `DriveWebRtcError extends SuaveplanError<DriveWebRtcErrorCode>` with `name = "DriveWebRtcError"` for stack-trace clarity
- Re-registration is idempotent — calling the module twice does not throw (`defineCodes` semantics)
- Compatible with the centralized OpenTelemetry error counter (`drive_webrtc.errors_total{code}`)
## Usage
### Basic Example
```typescript
import { DRIVE_WEBRTC_CODES, DriveWebRtcError } from "@suaveplan/drive-webrtc";
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"signaling websocket failed to open",
);
```
### Advanced Example
```typescript
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
type DriveWebRtcErrorCode,
} from "@suaveplan/drive-webrtc";
function classifyAndRethrow(err: unknown, peerId: string): never {
if (err instanceof DriveWebRtcError) {
// already classified — re-throw with extra context
throw new DriveWebRtcError(err.code, err.message, {
cause: err,
context: { peerId },
});
}
if (err instanceof Error && err.message.includes("ICE")) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.ICE_FAILED,
"ICE negotiation failed",
{ cause: err, context: { peerId } },
);
}
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
"unclassified handshake failure",
{ cause: err instanceof Error ? err : undefined, context: { peerId } },
);
}
function isRecoverable(code: DriveWebRtcErrorCode): boolean {
return (
code === DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE ||
code === DRIVE_WEBRTC_CODES.ICE_FAILED ||
code === DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
```
## API Reference
### `DRIVE_WEBRTC_CODES`
Frozen map produced by `defineCodes("DRIVE_WEBRTC", [...])`. Properties (and their string values):
- `SIGNALING_UNREACHABLE` — signaling server could not be reached / handshake failed.
- `PEER_HANDSHAKE_FAILED` — offer/answer exchange could not complete.
- `CHUNK_INTEGRITY_FAILED` — received chunk's hash or signature did not verify.
- `ICE_FAILED` — ICE candidate gathering or connectivity check failed.
- `PEER_BANDWIDTH_EXCEEDED` — peer's per-window byte quota exhausted.
- `RATE_LIMIT_EXCEEDED` — peer's per-window request quota exhausted.
- `SIGNALING_MESSAGE_INVALID` — inbound message failed Zod parsing.
- `CONFIG_INVALID` — `WebRTCPluginConfig` failed Zod parsing.
- `PEER_NOT_FOUND` — referenced peer is not in the mesh roster.
- `DATA_CHANNEL_CLOSED` — operation attempted on a closed data channel.
- `SUB_CHUNK_TIMEOUT` — sub-chunk reassembly timer expired.
- `SUB_CHUNK_MISSING` — gap in sub-chunk part-index sequence.
- `CHUNK_NOT_AVAILABLE` — no peer in the mesh has the requested chunk.
### `DriveWebRtcErrorCode`
Literal union over the values of `DRIVE_WEBRTC_CODES`.
### `class DriveWebRtcError`
Extends `SuaveplanError<DriveWebRtcErrorCode>`. Public constructor:
```typescript
new DriveWebRtcError(code, message, options?)
```
Sets `name = "DriveWebRtcError"` for distinguishability in stack traces. `options` is the standard `SuaveplanErrorOptions` (`cause`, `context`, `traceId`).
## Implementation Notes
- `defineCodes` is idempotent under re-import — Hot Module Reload and test isolation do not produce duplicate-registration failures.
- The constructor is public (widening the base `protected` constructor) so consumers can instantiate the error directly. We avoid the `noUselessConstructor` lint by setting `this.name` inside the body — a non-trivial side effect that biome accepts.
- Adding a new code is a non-breaking change in the additive direction. Removing or renaming a code is a breaking change — bump the major and announce in the changelog.
- Codes are intentionally coarse-grained — they classify the failure family, not the exact line of code. For finer detail, pass `options.context` with relevant fields (peer ID, chunk ID, retry count).
- The OpenTelemetry surface emits a `drive_webrtc.errors_total{code}` counter on every throw, labelled with the literal code value. Dashboard pivots on this label give per-code error rates without parsing log messages.

View File

@@ -0,0 +1,80 @@
/**
* Error-code registry tests for `@suaveplan/drive-webrtc`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DRIVE_WEBRTC_CODES, DriveWebRtcError } from "./error-codes.js";
describe("DRIVE_WEBRTC_CODES", () => {
it("registers every expected code", { timeout: 5000 }, () => {
const expected = [
"SIGNALING_UNREACHABLE",
"PEER_HANDSHAKE_FAILED",
"CHUNK_INTEGRITY_FAILED",
"ICE_FAILED",
"PEER_BANDWIDTH_EXCEEDED",
"RATE_LIMIT_EXCEEDED",
"SIGNALING_MESSAGE_INVALID",
"CONFIG_INVALID",
"PEER_NOT_FOUND",
"DATA_CHANNEL_CLOSED",
"SUB_CHUNK_TIMEOUT",
"SUB_CHUNK_MISSING",
"CHUNK_NOT_AVAILABLE",
];
for (const key of expected) {
expect(
(DRIVE_WEBRTC_CODES as Record<string, unknown>)[key]
).toBeDefined();
}
});
it("uses DRIVE_WEBRTC-prefixed values", { timeout: 5000 }, () => {
for (const value of Object.values(DRIVE_WEBRTC_CODES)) {
expect(typeof value).toBe("string");
expect(value).toMatch(/^DRIVE_WEBRTC\./);
}
});
it("re-importing the module is idempotent (no duplicate registration)", {
timeout: 5000,
}, async () => {
const first = await import("./error-codes.js");
const second = await import("./error-codes.js");
expect(first.DRIVE_WEBRTC_CODES).toBe(second.DRIVE_WEBRTC_CODES);
});
});
describe("DriveWebRtcError", () => {
it("carries the code, message, and SuaveplanError-style fields", {
timeout: 5000,
}, () => {
const err = new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"signaling websocket failed to open"
);
expect(err.code).toBe(DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE);
expect(err.message).toBe("signaling websocket failed to open");
expect(err.name).toBe("DriveWebRtcError");
expect(err).toBeInstanceOf(Error);
});
it("preserves the cause when supplied", { timeout: 5000 }, () => {
const root = new Error("ws closed");
const err = new DriveWebRtcError(
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
"channel closed before send",
{ cause: root }
);
expect(err.cause).toBe(root);
});
it("preserves context when supplied", { timeout: 5000 }, () => {
const err = new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_NOT_FOUND,
"no such peer",
{ context: { peerId: "peer-a" } }
);
expect(err.context).toEqual({ peerId: "peer-a" });
});
});

View File

@@ -0,0 +1,42 @@
/**
* Error codes for `@suaveplan/drive-webrtc`.
*
* Every public boundary throws {@link DriveWebRtcError} carrying one of
* the codes from {@link DRIVE_WEBRTC_CODES}.
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const DRIVE_WEBRTC_CODES = defineCodes("DRIVE_WEBRTC", [
"SIGNALING_UNREACHABLE",
"PEER_HANDSHAKE_FAILED",
"CHUNK_INTEGRITY_FAILED",
"ICE_FAILED",
"PEER_BANDWIDTH_EXCEEDED",
"RATE_LIMIT_EXCEEDED",
"SIGNALING_MESSAGE_INVALID",
"CONFIG_INVALID",
"PEER_NOT_FOUND",
"DATA_CHANNEL_CLOSED",
"SUB_CHUNK_TIMEOUT",
"SUB_CHUNK_MISSING",
"CHUNK_NOT_AVAILABLE",
] as const);
export type DriveWebRtcErrorCode =
(typeof DRIVE_WEBRTC_CODES)[keyof typeof DRIVE_WEBRTC_CODES];
/**
* Typed error thrown by every `@suaveplan/drive-webrtc` boundary.
*/
export class DriveWebRtcError extends SuaveplanError<DriveWebRtcErrorCode> {
public constructor(
code: DriveWebRtcErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "DriveWebRtcError";
}
}

View File

@@ -0,0 +1,5 @@
export type { DriveWebRtcErrorCode } from "./error-codes.js";
export {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "./error-codes.js";

View File

@@ -0,0 +1,2 @@
export type { EventArgsMap } from "./typed-event-emitter.js";
export { TypedEventEmitter } from "./typed-event-emitter.js";

View File

@@ -0,0 +1,107 @@
# `TypedEventEmitter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TypedEventEmitter` is the strongly-typed pub/sub fabric that every
`@suaveplan/drive-webrtc` subsystem uses to surface lifecycle, peer,
data-channel, and chunk-transfer events to its host. It wraps the
single-payload `EventBus` from `@suaveplan/events` and adapts it to the
tuple-args event-map convention (`Record<string, unknown[]>`) that the
canonical `WebRTCEventMap` declares. The tuple form lets call sites write
`emit("peer:joined", { peerId, peerName })` instead of forcing every
argument into an object literal first, while keeping every argument
type-checked at both the emitter and the listener.
The class deliberately exposes only the small surface drive-webrtc needs
— `on`, `once`, `off`, `emit`, `removeAllListeners` — and intentionally
omits the async / interceptor / observability tooling on `EventBus`,
since those concerns belong to the consuming application rather than the
plugin itself.
## Features
- Tuple-arg event map: `emit("name", a, b, c)` and
`on("name", (a, b, c) => ...)`.
- Type inference flows from the map declaration into both the emitter
call sites and the listener parameters.
- `on` and `once` both return an `Unsubscribe` function for symmetry
with the rest of the suaveplan event ecosystem.
- `once` is automatically deregistered once it fires; calling its
returned unsubscribe before it fires cancels it.
- `removeAllListeners` clears every event in one call.
## Usage: Basic Example
```ts
import { TypedEventEmitter } from "@suaveplan/drive-webrtc";
interface Events extends Record<string, unknown[]> {
"user:joined": [{ userId: string }];
}
const emitter = new TypedEventEmitter<Events>();
emitter.on("user:joined", (payload) => {
console.log(payload.userId);
});
emitter.emit("user:joined", { userId: "alice" });
```
## Usage: Advanced Example
```ts
import { TypedEventEmitter, type WebRTCEventMap } from "@suaveplan/drive-webrtc";
class Service extends TypedEventEmitter<WebRTCEventMap> {
private readonly registry = new Map<string, () => void>();
public watch(peerId: string): void {
const unsub = this.on("peer:left", (payload) => {
if (payload.peerId === peerId) {
unsub();
this.registry.delete(peerId);
}
});
this.registry.set(peerId, unsub);
}
public reset(): void {
for (const unsub of this.registry.values()) unsub();
this.registry.clear();
this.removeAllListeners();
}
}
```
## API Reference
### `class TypedEventEmitter<TMap extends Record<string, unknown[]>>`
- `on(event, handler): Unsubscribe` — register a persistent listener.
- `once(event, handler): Unsubscribe` — register a one-shot listener.
- `off(event, handler): void` — remove a previously registered listener;
no-op when the listener is unknown.
- `emit(event, ...args): boolean` — synchronously dispatch to every
registered listener for `event`. Returns `true` iff at least one
listener was invoked.
- `removeAllListeners(): void` — drop every listener across every event.
### `type EventArgsMap = Record<string, unknown[]>`
Alias for the tuple-args map shape used by event maps in this package
(`WebRTCEventMap` extends it).
## Implementation Notes
- The underlying `EventBus` is single-payload; this class stores the
tuple as a single `unknown[]` payload and spreads it back out at
dispatch. The internal `wrapped` map tracks the function adapters per
user handler so `off` can locate the right inner unsubscribe.
- Errors thrown by listeners are caught by `EventBus` and surface in
`EmitResult.errors`, which `emit` discards in favour of returning a
delivered-count boolean. Code that needs per-listener error visibility
should subscribe to the underlying bus directly.
- Dispatch is synchronous (`bus.emit`, not `emitAsync`). Async semantics
belong to the consuming application, not the WebRTC plugin.

View File

@@ -0,0 +1,144 @@
/**
* Unit tests for {@link TypedEventEmitter}.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { TypedEventEmitter } from "./typed-event-emitter.js";
interface TestMap extends Record<string, unknown[]> {
"user:joined": [{ userId: string }];
"user:left": [{ userId: string; reason: string }];
ping: [];
}
describe("TypedEventEmitter", () => {
it("delivers emitted events to subscribers", { timeout: 5000 }, () => {
const emitter = new TypedEventEmitter<TestMap>();
const received: Array<{ userId: string }> = [];
emitter.on("user:joined", (payload) => received.push(payload));
const delivered = emitter.emit("user:joined", { userId: "u1" });
expect(delivered).toBe(true);
expect(received).toEqual([{ userId: "u1" }]);
});
it("returns false from emit when no listener is registered", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
expect(emitter.emit("ping")).toBe(false);
});
it("supports unsubscribe via the returned function", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
const calls: number[] = [];
const unsub = emitter.on("ping", () => calls.push(1));
emitter.emit("ping");
unsub();
emitter.emit("ping");
expect(calls).toEqual([1]);
});
it("off removes a specific handler", { timeout: 5000 }, () => {
const emitter = new TypedEventEmitter<TestMap>();
const calls: number[] = [];
const handler = (): void => {
calls.push(1);
};
emitter.on("ping", handler);
emitter.on("ping", () => calls.push(2));
emitter.off("ping", handler);
emitter.emit("ping");
expect(calls).toEqual([2]);
});
it("off is a no-op for unknown events and handlers", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
const handler = (): void => undefined;
expect(() => emitter.off("ping", handler)).not.toThrow();
emitter.on("ping", () => undefined);
expect(() => emitter.off("ping", handler)).not.toThrow();
});
it("off drops the event entry when the last handler is removed", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
const handler = (): void => undefined;
emitter.on("ping", handler);
emitter.off("ping", handler);
// Subsequent off must not throw because the event entry was pruned.
expect(() => emitter.off("ping", handler)).not.toThrow();
});
it("unsubscribing one of several handlers keeps the event entry", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
const calls: number[] = [];
const unsubA = emitter.on("ping", () => calls.push(1));
emitter.on("ping", () => calls.push(2));
unsubA();
emitter.emit("ping");
expect(calls).toEqual([2]);
});
it("calling unsubscribe twice is a no-op", { timeout: 5000 }, () => {
const emitter = new TypedEventEmitter<TestMap>();
const unsub = emitter.on("ping", () => undefined);
unsub();
expect(() => unsub()).not.toThrow();
const onceUnsub = emitter.once("ping", () => undefined);
onceUnsub();
expect(() => onceUnsub()).not.toThrow();
});
it("once fires exactly once", { timeout: 5000 }, () => {
const emitter = new TypedEventEmitter<TestMap>();
const calls: number[] = [];
emitter.once("ping", () => calls.push(1));
emitter.emit("ping");
emitter.emit("ping");
expect(calls).toEqual([1]);
});
it("once handler can be unsubscribed before firing", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
const calls: number[] = [];
const unsub = emitter.once("ping", () => calls.push(1));
unsub();
emitter.emit("ping");
expect(calls).toEqual([]);
});
it("removeAllListeners drops every subscription", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
const calls: number[] = [];
emitter.on("ping", () => calls.push(1));
emitter.on("user:joined", () => calls.push(2));
emitter.removeAllListeners();
emitter.emit("ping");
emitter.emit("user:joined", { userId: "u" });
expect(calls).toEqual([]);
});
it("infers handler argument types from the event map", {
timeout: 5000,
}, () => {
const emitter = new TypedEventEmitter<TestMap>();
emitter.on("user:left", (payload) => {
expectTypeOf(payload).toEqualTypeOf<{
userId: string;
reason: string;
}>();
});
emitter.emit("user:left", { userId: "u", reason: "bye" });
});
});

View File

@@ -0,0 +1,123 @@
/**
* Typed event emitter.
*
* Thin wrapper around `EventBus` from `@suaveplan/events` adapted to the
* tuple-args event-map convention used by `WebRTCEventMap`. The event map
* shape `Record<string, unknown[]>` mirrors Node's `EventEmitter` so that
* call sites read `emit("name", arg)` rather than wrapping arguments in
* an object literal at every call.
*/
import type { Unsubscribe } from "@suaveplan/events";
import { EventBus } from "@suaveplan/events";
/**
* Event map alias: every event name maps to a tuple of payload arguments.
*/
export type EventArgsMap = Record<string, unknown[]>;
type Handler<TArgs extends unknown[]> = (...args: TArgs) => void;
/**
* Typed event emitter with tuple-shaped payloads.
*
* @typeParam TMap - Record mapping event names to argument tuples.
*/
export class TypedEventEmitter<TMap extends EventArgsMap> {
/**
* Internal bus stores payload tuples as a single `unknown[]` value
* per event. The class spreads the tuple back out on dispatch.
*/
private readonly bus = new EventBus<{ [K in keyof TMap]: TMap[K] }>();
private readonly wrapped = new Map<
keyof TMap,
Map<Handler<TMap[keyof TMap]>, Unsubscribe>
>();
/**
* Subscribe to `event`. Returns an unsubscribe function for symmetry
* with the rest of the genesis event surface.
*/
public on<K extends keyof TMap>(
event: K,
handler: Handler<TMap[K]>
): Unsubscribe {
const wrapped = (args: TMap[K]): void => {
handler(...args);
};
const unsub = this.bus.on(event, wrapped);
this.recordWrap(event, handler, unsub);
return () => {
unsub();
this.forgetWrap(event, handler);
};
}
/**
* Subscribe once. The handler fires for the next emission and is then
* automatically unsubscribed.
*/
public once<K extends keyof TMap>(
event: K,
handler: Handler<TMap[K]>
): Unsubscribe {
const unsub = this.bus.once(event, (args: TMap[K]) => {
this.forgetWrap(event, handler);
handler(...args);
});
this.recordWrap(event, handler, unsub);
return () => {
unsub();
this.forgetWrap(event, handler);
};
}
/** Remove a previously registered handler. */
public off<K extends keyof TMap>(
event: K,
handler: Handler<TMap[K]>
): void {
const map = this.wrapped.get(event);
if (map === undefined) return;
const unsub = map.get(handler as Handler<TMap[keyof TMap]>);
if (unsub === undefined) return;
unsub();
map.delete(handler as Handler<TMap[keyof TMap]>);
if (map.size === 0) this.wrapped.delete(event);
}
/** Emit an event to every subscriber synchronously. */
public emit<K extends keyof TMap>(event: K, ...args: TMap[K]): boolean {
const result = this.bus.emit(event, args);
return result.delivered > 0;
}
/** Drop every listener across every event. */
public removeAllListeners(): void {
this.bus.clear();
this.wrapped.clear();
}
private recordWrap<K extends keyof TMap>(
event: K,
handler: Handler<TMap[K]>,
unsub: Unsubscribe
): void {
let map = this.wrapped.get(event);
if (map === undefined) {
map = new Map();
this.wrapped.set(event, map);
}
map.set(handler as Handler<TMap[keyof TMap]>, unsub);
}
private forgetWrap<K extends keyof TMap>(
event: K,
handler: Handler<TMap[K]>
): void {
const map = this.wrapped.get(event);
if (map === undefined) return;
map.delete(handler as Handler<TMap[keyof TMap]>);
if (map.size === 0) this.wrapped.delete(event);
}
}

View File

@@ -0,0 +1,200 @@
/**
* Root barrel for `@suaveplan/drive-webrtc`.
*
* Only the type, error-code, schema, and constants surface lands here
* during the genesis migration. Runtime modules (client, plugin,
* signaling, peer manager, data channel) will be exported as they
* land.
*/
export type {
ChunkSourceOptions,
WebRTCChunkSourceHealth,
} from "./chunk-source/chunk-source/index.js";
export { WebRTCChunkSource } from "./chunk-source/chunk-source/index.js";
export type {
RTCPeerConnectionCtor,
WebRTCClientEventMap,
WebRTCClientOptions,
} from "./client/client/index.js";
export { resolveConfig, WebRTCClient } from "./client/client/index.js";
export type {
ReconnectPolicy,
ReconnectStep,
} from "./client/reconnect/index.js";
export { ReconnectController } from "./client/reconnect/index.js";
export {
DEFAULT_BACKPRESSURE_BYTES,
DEFAULT_ICE_SERVERS,
DEFAULT_MAX_CONCURRENT_DOWNLOADS,
DEFAULT_MAX_PEERS,
DEFAULT_PRIORITY,
DEFAULT_RATE_LIMIT_DELAY_MS,
DEFAULT_RATE_LIMIT_EVERY_N_PARTS,
DEFAULT_RECONNECT_BACKOFF_MS,
DEFAULT_RECONNECT_DELAY_MS,
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_SUB_CHUNK_SIZE,
} from "./constants/index.js";
export type {
DataChannelEventMap,
DataChannelManagerOptions,
} from "./data-channel/data-channel-manager/index.js";
export { DataChannelManager } from "./data-channel/data-channel-manager/index.js";
export type {
ChunkDiscoveryEventMap,
ChunkDiscoveryManagerOptions,
} from "./discovery/chunk-discovery-manager/index.js";
export { ChunkDiscoveryManager } from "./discovery/chunk-discovery-manager/index.js";
export {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
type DriveWebRtcErrorCode,
} from "./error-codes/index.js";
// Wave 2: leaf utilities (lru-cache, rate-limiter, typed event emitter,
// security validator).
export { TypedEventEmitter } from "./events/typed-event-emitter/index.js";
export type {
ChunkIntegrityManagerOptions,
ChunkMetadata,
ChunkVerificationResult,
Ed25519Verifier,
} from "./integrity/chunk-integrity/index.js";
export {
buildSignatureMessage,
ChunkIntegrityManager,
computeChunkHash,
verifyChunkIntegrity,
} from "./integrity/chunk-integrity/index.js";
export type {
LRUCacheEvictionReason,
LRUCacheOptions,
} from "./lru-cache/lru-cache/index.js";
export { LRUCache } from "./lru-cache/lru-cache/index.js";
export type {
PeerBandwidthTrackerConfig,
PeerMetrics,
} from "./peer-manager/peer-bandwidth-tracker/index.js";
export { PeerBandwidthTracker } from "./peer-manager/peer-bandwidth-tracker/index.js";
export type {
PeerManagerEventMap,
PeerManagerOptions,
} from "./peer-manager/peer-manager/index.js";
export { PeerManager } from "./peer-manager/peer-manager/index.js";
export type {
AdmitDecision,
HubAndSpokeStrategyOptions,
SuperPeerStrategyOptions,
TopologySelectOptions,
TopologyStrategy,
TopologyStrategyName,
} from "./peer-manager/topology/index.js";
export {
FullMeshStrategy,
HubAndSpokeStrategy,
pickLowestScore,
SuperPeerStrategy,
} from "./peer-manager/topology/index.js";
export type {
LocalChunkProvider,
WebRTCDrivePluginOptions,
} from "./plugin/webrtc-plugin/index.js";
export { WebRTCDrivePlugin } from "./plugin/webrtc-plugin/index.js";
export type { RateLimiterOptions } from "./rate-limiter/rate-limiter/index.js";
export { RateLimiter } from "./rate-limiter/rate-limiter/index.js";
export {
IceServerSchema,
P2PConfigSchema,
SignalingConfigSchema,
SubChunkConfigSchema,
WebRTCPluginConfigSchema,
} from "./schemas/config.schemas.js";
export {
AnnounceChunksMessageSchema,
AnswerMessageSchema,
ChunkBroadcastRequestSchema,
ChunkBroadcastResponseSchema,
ChunkDataMessageSchema,
ChunkDataPartMessageSchema,
ChunkQueryMessageSchema,
ChunkQueryResponseSchema,
ChunkRequestMessageSchema,
ChunksUpdatedMessageSchema,
DataChannelChunkRequestSchema,
IceCandidateMessageSchema,
OfferMessageSchema,
PeerJoinedMessageSchema,
PeerLeftMessageSchema,
RegisteredMessageSchema,
RegisterMessageSchema,
SignalingMessageSchema,
} from "./schemas/signaling.schemas.js";
export {
generateSecurePeerId,
generateSecureRequestId,
validatePeerSecurity,
validateSignalingUrl,
} from "./security/security-validator/index.js";
export type {
SignalingClientEventMap,
SignalingClientOptions,
SignalingClientState,
SignalingCloseInfo,
} from "./signaling/signaling-client/index.js";
export { SignalingClient } from "./signaling/signaling-client/index.js";
export type {
SubChunkCallback,
SubChunkPart,
SubChunkSplitterOptions,
} from "./sub-chunk/sub-chunk/index.js";
export {
decodeFromBase64,
encodeToBase64,
type SubChunkAddResult,
SubChunkReassembler,
type SubChunkReassemblerOptions,
sendSubChunks,
splitIntoSubChunks,
} from "./sub-chunk/sub-chunk/index.js";
export type {
P2PConfig,
ResolvedConfig,
SignalingConfig,
SubChunkConfig,
WebRTCPluginConfig,
} from "./types/config.types.js";
export type {
ChunkSource,
ChunkSourceHealth,
P2PTransport,
Plugin,
PluginType,
} from "./types/drive.types.js";
export type { WebRTCEventMap } from "./types/events.types.js";
export type {
AnnounceChunksMessage,
AnswerMessage,
ChunkBroadcastRequest,
ChunkBroadcastResponse,
ChunkDataMessage,
ChunkDataPartMessage,
ChunkQueryMessage,
ChunkQueryResponse,
ChunkRequestMessage,
ChunksUpdatedMessage,
DataChannelChunkRequest,
IceCandidateMessage,
OfferMessage,
PeerJoinedMessage,
PeerLeftMessage,
RegisteredMessage,
RegisterMessage,
SignalingMessage,
} from "./types/messages.types.js";
export type {
ChunkInfo,
PeerConnectionState,
PeerInfo,
} from "./types/peer.types.js";
export type { WebRTCStats } from "./types/stats.types.js";
export { createDefaultStats } from "./types/stats.types.js";

View File

@@ -0,0 +1,117 @@
# Chunk integrity verifier
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The chunk integrity module is the first line of defence against
tampered chunks served by untrusted P2P peers (CWE-494). Every chunk
that crosses the WebRTC data channel boundary is verified locally —
SHA-256-recomputed over the bytes and constant-time-compared against
authoritative metadata, then (when a `signature` is present)
Ed25519-verified against the publisher's public key over a canonical
`hash || utf8(chunkId)` form so a captured signature cannot be replayed
against a different chunk identifier.
The Ed25519 verifier is the real `@suaveplan/crypto-curve` primitive by
default — there are no stubs, no `verifyForwardLooking` placeholders,
and no "TODO: wire signature check" comments. Tests substitute their
own verifier via the `verifier` option to drive both happy-path and
failure branches without monkey-patching globals.
## Features
- `computeChunkHash(data)` — real SHA-256 via
`@suaveplan/crypto-hash`. Returns the 32-byte digest as a
`Uint8Array` (not hex) so the hot path avoids encode round-trips.
- `verifyChunkIntegrity({ data, hash })` — constant-time hash compare
via `@suaveplan/crypto-utils.constantTimeEqual`. Mismatched lengths
short-circuit to `false`.
- `ChunkIntegrityManager.verifyHash` — typed
`ChunkVerificationResult` with `reason: "hash-mismatch" | "missing-hash"`.
Routes missing-hash through the same API so trusted-source
rehydration calls don't need a different code path.
- `ChunkIntegrityManager.verifySignature` — REAL Ed25519 verify over
canonical `hash || utf8(chunkId)`. Throwing verifiers are coerced to
`bad-signature` so raw crypto exceptions never leak across the
boundary.
- `ChunkIntegrityManager.verify` — convenience: hash, then optional
signature. First failure wins.
- `buildSignatureMessage(hash, chunkId)` — exposed canonical form so
publishers can produce signatures with the matching layout.
## Usage: Basic Example
```ts
import {
ChunkIntegrityManager,
computeChunkHash,
} from "@suaveplan/drive-webrtc/integrity/chunk-integrity";
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("hello");
const hash = computeChunkHash(data);
const result = manager.verifyHash({ chunkId: "c1", data, hash });
if (!result.ok) throw new Error(result.reason);
```
## Usage: Advanced Example
```ts
import * as ed25519 from "@suaveplan/crypto-curve/ed25519";
import {
ChunkIntegrityManager,
buildSignatureMessage,
computeChunkHash,
} from "@suaveplan/drive-webrtc/integrity/chunk-integrity";
const privateKey = ed25519.randomPrivateKey();
const publicKey = ed25519.getPublicKey(privateKey);
const data = new TextEncoder().encode("payload");
const hash = computeChunkHash(data);
const signature = ed25519.sign(buildSignatureMessage(hash, "c2"), privateKey);
const manager = new ChunkIntegrityManager();
const verdict = await manager.verify(
{ chunkId: "c2", data, hash, signature },
publicKey
);
if (!verdict.ok) {
console.warn("rejected:", verdict.reason, verdict.code);
}
```
## API Reference
- `computeChunkHash(data: Uint8Array): Uint8Array` — 32-byte SHA-256.
- `verifyChunkIntegrity(chunk): boolean` — constant-time hash compare.
- `buildSignatureMessage(hash, chunkId): Uint8Array` — canonical
signed-message form.
- `new ChunkIntegrityManager({ verifier?, clock? })`
- `.verifyHash(chunk): ChunkVerificationResult`
- `.verifySignature(chunk, publicKey): Promise<ChunkVerificationResult>`
- `.verify(chunk, publicKey?): Promise<ChunkVerificationResult>`
- `.now(): number`
Every failure result carries
`code: DRIVE_WEBRTC.CHUNK_INTEGRITY_FAILED` and a stable `reason`
string (`hash-mismatch` / `missing-hash` / `bad-signature` /
`missing-signature`) so downstream telemetry can bucket on reason
without parsing messages.
## Implementation Notes
The signed message is `hash || utf8(chunkId)` rather than just `hash`
to prevent same-bytes cross-chunk replay: an attacker who captures a
valid `(hash, signature)` pair cannot reuse the signature to attribute
the same bytes to a different `chunkId` without forging the publisher's
key.
The verifier is awaited unconditionally — even though the default
noble-backed `ed25519.verify` is synchronous, the API admits async
backends (e.g. browser `crypto.subtle` adapters) so the convenience
`verify` method is `async` end-to-end. Exceptions inside the verifier
are caught and downgraded to `bad-signature` so a misbehaving custom
verifier cannot crash the integrity manager.

View File

@@ -0,0 +1,282 @@
/**
* Unit tests for the chunk-integrity module. Uses REAL
* `@suaveplan/crypto-curve` Ed25519 keypairs throughout — no stubs.
*/
import * as ed25519 from "@suaveplan/crypto-curve/ed25519";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DRIVE_WEBRTC_CODES } from "../../error-codes/error-codes.js";
import {
buildSignatureMessage,
ChunkIntegrityManager,
computeChunkHash,
verifyChunkIntegrity,
} from "./chunk-integrity.js";
/**
* NIST-style SHA-256 test vector: "abc" →
* ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
*/
const ABC_SHA256_HEX =
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
function hexToBytes(hex: string): Uint8Array {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
describe("computeChunkHash", () => {
it("produces the known SHA-256 of 'abc'", { timeout: 5000 }, () => {
const data = new TextEncoder().encode("abc");
const hash = computeChunkHash(data);
expect(hash).toEqual(hexToBytes(ABC_SHA256_HEX));
});
it("produces a 32-byte digest for arbitrary input", {
timeout: 5000,
}, () => {
const data = new Uint8Array(1024);
crypto.getRandomValues(data);
const hash = computeChunkHash(data);
expect(hash.byteLength).toBe(32);
});
});
describe("verifyChunkIntegrity", () => {
it("returns true for matching data + hash", { timeout: 5000 }, () => {
const data = new TextEncoder().encode("hello world");
const hash = computeChunkHash(data);
expect(verifyChunkIntegrity({ data, hash })).toBe(true);
});
it("returns false for tampered data", { timeout: 5000 }, () => {
const data = new TextEncoder().encode("hello world");
const hash = computeChunkHash(data);
const tampered = new Uint8Array(data);
tampered[0] = (tampered[0] ?? 0) ^ 0x01;
expect(verifyChunkIntegrity({ data: tampered, hash })).toBe(false);
});
it("returns false for length-mismatched hash", { timeout: 5000 }, () => {
const data = new TextEncoder().encode("hello");
const tooShort = new Uint8Array(16);
expect(verifyChunkIntegrity({ data, hash: tooShort })).toBe(false);
});
});
describe("ChunkIntegrityManager.verifyHash", () => {
it("returns ok: true for matching hash", { timeout: 5000 }, () => {
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("payload");
const hash = computeChunkHash(data);
const result = manager.verifyHash({ chunkId: "c1", data, hash });
expect(result.ok).toBe(true);
});
it("returns hash-mismatch reason for tampered data", {
timeout: 5000,
}, () => {
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("payload");
const hash = computeChunkHash(data);
const tampered = new Uint8Array(data);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
const result = manager.verifyHash({
chunkId: "c1",
data: tampered,
hash,
});
if (result.ok) throw new Error("expected failure");
expect(result.reason).toBe("hash-mismatch");
expect(result.code).toBe(DRIVE_WEBRTC_CODES.CHUNK_INTEGRITY_FAILED);
});
it("returns missing-hash when no hash supplied", { timeout: 5000 }, () => {
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("payload");
const result = manager.verifyHash({ chunkId: "c1", data });
if (result.ok) throw new Error("expected failure");
expect(result.reason).toBe("missing-hash");
});
});
describe("ChunkIntegrityManager.verifySignature (real ed25519)", () => {
it("verifies a valid signature", { timeout: 5000 }, async () => {
const privateKey = ed25519.randomPrivateKey();
const publicKey = ed25519.getPublicKey(privateKey);
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("genuine");
const hash = computeChunkHash(data);
const message = buildSignatureMessage(hash, "good");
const signature = ed25519.sign(message, privateKey);
const result = await manager.verifySignature(
{ chunkId: "good", data, hash, signature },
publicKey
);
expect(result.ok).toBe(true);
});
it("rejects a tampered signature", { timeout: 5000 }, async () => {
const privateKey = ed25519.randomPrivateKey();
const publicKey = ed25519.getPublicKey(privateKey);
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("genuine");
const hash = computeChunkHash(data);
const message = buildSignatureMessage(hash, "bad");
const signature = ed25519.sign(message, privateKey);
// Flip a bit in the signature.
signature[0] = (signature[0] ?? 0) ^ 0x01;
const result = await manager.verifySignature(
{ chunkId: "bad", data, hash, signature },
publicKey
);
if (result.ok) throw new Error("expected failure");
expect(result.reason).toBe("bad-signature");
expect(result.code).toBe(DRIVE_WEBRTC_CODES.CHUNK_INTEGRITY_FAILED);
});
it("rejects a signature signed with the wrong key", {
timeout: 5000,
}, async () => {
const alice = ed25519.randomPrivateKey();
const bob = ed25519.randomPrivateKey();
const bobPub = ed25519.getPublicKey(bob);
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("wrong-key");
const hash = computeChunkHash(data);
const signature = ed25519.sign(buildSignatureMessage(hash, "k"), alice);
const result = await manager.verifySignature(
{ chunkId: "k", data, hash, signature },
bobPub
);
if (result.ok) throw new Error("expected failure");
expect(result.reason).toBe("bad-signature");
});
it("rejects when signature is missing", { timeout: 5000 }, async () => {
const manager = new ChunkIntegrityManager();
const publicKey = ed25519.getPublicKey(ed25519.randomPrivateKey());
const data = new TextEncoder().encode("x");
const hash = computeChunkHash(data);
const result = await manager.verifySignature(
{ chunkId: "no-sig", data, hash },
publicKey
);
if (result.ok) throw new Error("expected failure");
expect(result.reason).toBe("missing-signature");
});
it("recomputes hash when signed chunk omits the hash field", {
timeout: 5000,
}, async () => {
const privateKey = ed25519.randomPrivateKey();
const publicKey = ed25519.getPublicKey(privateKey);
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("derive-me");
const hash = computeChunkHash(data);
const signature = ed25519.sign(
buildSignatureMessage(hash, "derive"),
privateKey
);
const result = await manager.verifySignature(
{ chunkId: "derive", data, signature },
publicKey
);
expect(result.ok).toBe(true);
});
it("downgrades a throwing verifier to bad-signature", {
timeout: 5000,
}, async () => {
const manager = new ChunkIntegrityManager({
verifier: () => {
throw new Error("boom");
},
});
const data = new TextEncoder().encode("x");
const hash = computeChunkHash(data);
const sig = new Uint8Array(64);
const pk = new Uint8Array(32);
const result = await manager.verifySignature(
{ chunkId: "throw", data, hash, signature: sig },
pk
);
if (result.ok) throw new Error("expected failure");
expect(result.reason).toBe("bad-signature");
});
});
describe("ChunkIntegrityManager.verify (composite)", () => {
it("returns ok when both hash and signature are valid", {
timeout: 5000,
}, async () => {
const privateKey = ed25519.randomPrivateKey();
const publicKey = ed25519.getPublicKey(privateKey);
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("happy");
const hash = computeChunkHash(data);
const signature = ed25519.sign(
buildSignatureMessage(hash, "ok"),
privateKey
);
const result = await manager.verify(
{ chunkId: "ok", data, hash, signature },
publicKey
);
expect(result.ok).toBe(true);
});
it("returns hash-mismatch before checking signature", {
timeout: 5000,
}, async () => {
const privateKey = ed25519.randomPrivateKey();
const publicKey = ed25519.getPublicKey(privateKey);
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("orig");
const hash = computeChunkHash(data);
const signature = ed25519.sign(
buildSignatureMessage(hash, "sig-but-bad-hash"),
privateKey
);
const tampered = new Uint8Array(data);
tampered[0] = (tampered[0] ?? 0) ^ 0x01;
const result = await manager.verify(
{
chunkId: "sig-but-bad-hash",
data: tampered,
hash,
signature,
},
publicKey
);
if (result.ok) throw new Error("expected failure");
expect(result.reason).toBe("hash-mismatch");
});
it("returns ok hash-only when no publicKey is supplied", {
timeout: 5000,
}, async () => {
const manager = new ChunkIntegrityManager();
const data = new TextEncoder().encode("local");
const hash = computeChunkHash(data);
const result = await manager.verify({ chunkId: "local", data, hash });
expect(result.ok).toBe(true);
});
it("now() returns the injected clock value", { timeout: 5000 }, () => {
let t = 0;
const manager = new ChunkIntegrityManager({ clock: () => t });
t = 42;
expect(manager.now()).toBe(42);
});
it("now() falls back to wall clock when none injected", {
timeout: 5000,
}, () => {
const manager = new ChunkIntegrityManager();
expect(manager.now()).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,154 @@
/**
* Chunk integrity verification.
*
* Guards a drive node against tampered chunks served by untrusted P2P
* peers (CWE-494). Every received chunk is SHA-256-hashed locally and
* the result is constant-time-compared against authoritative metadata;
* when a `signature` is present, it is verified against the
* publisher's Ed25519 public key using the real
* `@suaveplan/crypto-curve/ed25519` primitive — no stubs, no
* "forward-looking" placeholders.
*/
import * as ed25519 from "@suaveplan/crypto-curve/ed25519";
import { sha256 } from "@suaveplan/crypto-hash";
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import type { Clock } from "@suaveplan/types";
import { DRIVE_WEBRTC_CODES } from "../../error-codes/error-codes.js";
import type {
ChunkIntegrityManagerOptions,
ChunkMetadata,
ChunkVerificationResult,
Ed25519Verifier,
} from "./chunk-integrity.types.js";
const defaultClock: Clock = () => Date.now();
const defaultVerifier: Ed25519Verifier = (signature, message, publicKey) =>
ed25519.verify(signature, message, publicKey);
/** SHA-256 over `data`. 32-byte digest. */
export function computeChunkHash(data: Uint8Array): Uint8Array {
return sha256(data);
}
/**
* Recompute the SHA-256 of `chunk.data` and constant-time-compare it
* against `chunk.hash`. Returns `true` iff the digest matches.
*/
export function verifyChunkIntegrity(chunk: {
readonly data: Uint8Array;
readonly hash: Uint8Array;
}): boolean {
const actual = computeChunkHash(chunk.data);
if (actual.length !== chunk.hash.length) return false;
return constantTimeEqual(actual, chunk.hash);
}
/**
* Canonical signed-message form. The publisher signs the concatenation
* of the 32-byte SHA-256 hash and the UTF-8-encoded `chunkId` so a
* signature is bound to the chunk identity (preventing same-bytes
* cross-chunk replay).
*/
export function buildSignatureMessage(
hash: Uint8Array,
chunkId: string
): Uint8Array {
const idBytes = new TextEncoder().encode(chunkId);
const out = new Uint8Array(hash.length + idBytes.length);
out.set(hash, 0);
out.set(idBytes, hash.length);
return out;
}
/**
* Stateful integrity verifier. The Ed25519 verifier defaults to the
* real `@suaveplan/crypto-curve` impl; tests can inject a substitute
* to drive specific branches deterministically. The optional `clock`
* is reserved for telemetry timestamping at the call site.
*/
export class ChunkIntegrityManager {
private readonly verifier: Ed25519Verifier;
private readonly clock: Clock;
public constructor(options: ChunkIntegrityManagerOptions = {}) {
this.verifier = options.verifier ?? defaultVerifier;
this.clock = options.clock ?? defaultClock;
}
/** Wall clock the manager was constructed with — useful for callers. */
public now(): number {
return this.clock();
}
/**
* Verify the SHA-256 hash of `chunk.data` against `chunk.hash`.
* `missing-hash` is returned when no hash is supplied so the caller
* can route trusted-source paths through the same API.
*/
public verifyHash(chunk: ChunkMetadata): ChunkVerificationResult {
if (chunk.hash === undefined) {
return {
ok: false,
reason: "missing-hash",
code: DRIVE_WEBRTC_CODES.CHUNK_INTEGRITY_FAILED,
};
}
if (verifyChunkIntegrity({ data: chunk.data, hash: chunk.hash })) {
return { ok: true };
}
return {
ok: false,
reason: "hash-mismatch",
code: DRIVE_WEBRTC_CODES.CHUNK_INTEGRITY_FAILED,
};
}
/**
* Verify the Ed25519 signature over canonical `hash || utf8(chunkId)`
* against `publicKey`. Returns `missing-signature` if the chunk has
* no signature, `bad-signature` if the verifier returns `false` or
* throws.
*/
public async verifySignature(
chunk: ChunkMetadata,
publicKey: Uint8Array
): Promise<ChunkVerificationResult> {
if (chunk.signature === undefined) {
return {
ok: false,
reason: "missing-signature",
code: DRIVE_WEBRTC_CODES.CHUNK_INTEGRITY_FAILED,
};
}
const hash = chunk.hash ?? computeChunkHash(chunk.data);
const message = buildSignatureMessage(hash, chunk.chunkId);
let ok = false;
try {
ok = await this.verifier(chunk.signature, message, publicKey);
} catch {
ok = false;
}
if (ok) return { ok: true };
return {
ok: false,
reason: "bad-signature",
code: DRIVE_WEBRTC_CODES.CHUNK_INTEGRITY_FAILED,
};
}
/**
* Convenience: hash check, then (if `publicKey` supplied) signature
* check. Returns the first failure; on success both checks passed.
*/
public async verify(
chunk: ChunkMetadata,
publicKey?: Uint8Array
): Promise<ChunkVerificationResult> {
const hashResult = this.verifyHash(chunk);
if (!hashResult.ok) return hashResult;
if (publicKey === undefined) return { ok: true };
return this.verifySignature(chunk, publicKey);
}
}

View File

@@ -0,0 +1,77 @@
# Chunk integrity types
## Purpose
This file defines the type-level contract of the chunk-integrity
module, which guards a drive node against tampered chunks served by
untrusted P2P peers (CWE-494). The integrity module never trusts the
sender's claim about chunk contents — every received chunk is
SHA-256-hashed locally and the result is constant-time-compared
against authoritative metadata, and (when a `signature` is present)
verified against the publisher's Ed25519 public key.
## Features
- `ChunkMetadata` carries raw bytes, an optional 32-byte SHA-256
`hash`, and an optional 64-byte Ed25519 `signature` — the optionality
lets callers compose hash-only verification on trusted-source paths
(e.g. local cache rehydration) without forcing every code path to
provide a signature.
- `ChunkVerificationResult` is a discriminated union: success carries
no payload; failure carries a typed `reason` and a
`DriveWebRtcErrorCode` the caller can map to telemetry buckets
without parsing error messages.
- `Ed25519Verifier` is the injection seam — production wires it to the
real `@suaveplan/crypto-curve/ed25519.verify` and tests can drive
golden-path / tampered-signature branches without monkey-patching
globals.
- `ChunkIntegrityManagerOptions` exposes `clock` for telemetry
timestamping and `verifier` for the substitution seam.
## Usage: Basic Example
```ts
import type {
ChunkMetadata,
ChunkVerificationResult,
} from "@suaveplan/drive-webrtc/integrity/chunk-integrity";
declare const meta: ChunkMetadata;
declare const result: ChunkVerificationResult;
if (!result.ok) {
console.warn(meta.chunkId, "failed:", result.reason, result.code);
}
```
## Usage: Advanced Example
```ts
import type {
ChunkIntegrityManagerOptions,
Ed25519Verifier,
} from "@suaveplan/drive-webrtc/integrity/chunk-integrity";
const verifier: Ed25519Verifier = (sig, msg, pk) =>
realEd25519.verify(sig, msg, pk);
const options: ChunkIntegrityManagerOptions = {
verifier,
clock: () => performance.now(),
};
```
## API Reference
- `ChunkMetadata` — `{ chunkId: string; data: Uint8Array; hash?: Uint8Array; signature?: Uint8Array }`.
- `ChunkVerificationResult` — `{ ok: true } | { ok: false; reason; code }`.
- `Ed25519Verifier` — `(sig, msg, publicKey) => boolean | Promise<boolean>`.
- `ChunkIntegrityManagerOptions` — `{ verifier?; clock? }`.
## Implementation Notes
All buffer fields are `Uint8Array` rather than hex/base64 strings so
the verification hot path avoids encode/decode round-trips. The
`Ed25519Verifier` return type permits both sync (`@noble/curves` is
sync) and async (browser `crypto.subtle` is async) backends without
forcing the caller to await. The `reason` strings are stable —
downstream telemetry can bucket on them safely.

View File

@@ -0,0 +1,59 @@
/**
* Types for the chunk-integrity module.
*
* Chunks received from an untrusted P2P peer are verified in two
* stages: (a) `hash` mismatch detection against an authoritative
* SHA-256 over `data`, and (b) optional Ed25519 signature verification
* over the canonical `hash || chunkId` form against the packer's
* public key.
*/
import type { Clock } from "@suaveplan/types";
import type { DriveWebRtcErrorCode } from "../../error-codes/error-codes.js";
/**
* Wire-level chunk metadata as the integrity module sees it. `hash` is
* the authoritative SHA-256 of `data` (32 bytes); `signature` is the
* Ed25519 signature over `hash || utf8(chunkId)`. The signature field
* is optional — callers can compose hash-only verify for trusted-source
* paths (e.g. local cache rehydration).
*/
export interface ChunkMetadata {
readonly chunkId: string;
readonly data: Uint8Array;
readonly hash?: Uint8Array;
readonly signature?: Uint8Array;
}
/** Discriminated result of one verification call. */
export type ChunkVerificationResult =
| { readonly ok: true }
| {
readonly ok: false;
readonly reason:
| "hash-mismatch"
| "missing-hash"
| "bad-signature"
| "missing-signature";
readonly code: DriveWebRtcErrorCode;
};
/**
* Injectable Ed25519 verifier. Defaults to
* `@suaveplan/crypto-curve/ed25519.verify`. The verifier is forced to
* return `boolean` rather than throw so the integrity manager never
* propagates raw crypto exceptions across the boundary.
*/
export type Ed25519Verifier = (
signature: Uint8Array,
message: Uint8Array,
publicKey: Uint8Array
) => boolean | Promise<boolean>;
/** Constructor options for {@link ChunkIntegrityManager}. */
export interface ChunkIntegrityManagerOptions {
/** Override the default real-crypto Ed25519 verify. */
readonly verifier?: Ed25519Verifier;
/** Wall clock for telemetry timestamps. */
readonly clock?: Clock;
}

View File

@@ -0,0 +1,12 @@
export {
buildSignatureMessage,
ChunkIntegrityManager,
computeChunkHash,
verifyChunkIntegrity,
} from "./chunk-integrity.js";
export type {
ChunkIntegrityManagerOptions,
ChunkMetadata,
ChunkVerificationResult,
Ed25519Verifier,
} from "./chunk-integrity.types.js";

View File

@@ -0,0 +1,5 @@
export { LRUCache } from "./lru-cache.js";
export type {
LRUCacheEvictionReason,
LRUCacheOptions,
} from "./lru-cache.types.js";

View File

@@ -0,0 +1,101 @@
# `LRUCache`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LRUCache<K, V>` is a memory-bounded least-recently-used cache used by the
`@suaveplan/drive-webrtc` plugin to cap the working-set memory of per-peer
state, recently-served chunk metadata, and pending request bookkeeping. The
cache is the primary defence against CWE-400 (resource-exhaustion) attacks
in which a misbehaving or malicious peer floods the local node with chunk
queries: regardless of inbound traffic shape, the cache cannot exceed its
configured `maxSize` and stale entries are lazily expired on the next `get`.
The implementation is backed by a `Map`, which preserves insertion order in
ECMAScript. A successful `get` deletes and re-inserts the entry, which is
how recency is tracked without a doubly-linked list. Eviction always walks
from the oldest end (`map.keys().next()`) so the LRU contract is exact.
## Features
- Strict capacity bound (`maxSize`) with `onEvict(key, value, "size")`
callback for the displaced entry.
- Optional per-entry TTL (`ttlMs`) with lazy expiry — `get` removes the
expired entry and fires `onEvict(..., "ttl")`.
- `delete(key)` fires `onEvict(..., "delete")` for a live entry or
`onEvict(..., "ttl")` if the entry was already past its expiry.
- `clear()` fires `onEvict(..., "clear")` for every remaining entry.
- Injectable `clock` for deterministic TTL testing without timers.
- Defensive constructor validation rejects non-positive `maxSize` / `ttlMs`.
## Usage: Basic Example
```ts
import { LRUCache } from "@suaveplan/drive-webrtc";
const cache = new LRUCache<string, Uint8Array>({ maxSize: 128 });
cache.set("chunk:42", new Uint8Array([1, 2, 3]));
const bytes = cache.get("chunk:42");
```
## Usage: Advanced Example
```ts
import { LRUCache } from "@suaveplan/drive-webrtc";
const cache = new LRUCache<string, { peerId: string; bytes: Uint8Array }>(
{
maxSize: 1024,
ttlMs: 30_000,
clock: () => performance.timeOrigin + performance.now(),
onEvict: (key, value, reason) => {
metrics.recordEviction(reason);
if (reason === "ttl" || reason === "size") {
value.bytes.fill(0); // zeroize evicted plaintext
}
},
}
);
cache.set("chunk:0:0", { peerId: "peer-a", bytes: new Uint8Array(1024) });
```
## API Reference
### `new LRUCache<K, V>(options)`
- `options.maxSize: number` — positive finite cap on entry count.
- `options.ttlMs?: number` — positive finite TTL in milliseconds.
- `options.clock?: () => number` — wall-clock provider, defaults to
`Date.now`.
- `options.onEvict?: (key, value, reason) => void` — eviction callback;
`reason` is one of `"size" | "ttl" | "delete" | "clear"`.
Throws `RangeError` if either numeric option is non-positive or non-finite.
### Methods
- `get(key): V | undefined` — returns the value (refreshes recency) or
`undefined` when missing or expired.
- `has(key): boolean` — non-mutating existence check; expired entries are
reported as absent but not removed.
- `set(key, value): void` — inserts or replaces; replacement bumps recency
and refreshes TTL.
- `delete(key): boolean` — `true` iff a value was removed.
- `clear(): void` — drops every entry.
- `size: number` — current cardinality.
## Implementation Notes
- Recency is encoded in `Map` insertion order; `get` performs
`delete` + `set` on hits. This keeps the public API allocation-free for
misses and a single-`Entry`-object allocation for hits.
- TTL is lazy. There are no background timers — important for a browser
context where unreferenced timers leak handles across tab lifecycle
events. Entries past their `expiresAt` are removed on next access.
- The TTL clock is injectable so unit tests drive it with a counter rather
than waiting on real time.
- Eviction reasons are kept stable so dashboards and audit logs can group
by cause without parsing free-form strings.

View File

@@ -0,0 +1,191 @@
/**
* Unit tests for {@link LRUCache}.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LRUCache } from "./lru-cache.js";
import type { LRUCacheEvictionReason } from "./lru-cache.types.js";
describe("LRUCache constructor validation", () => {
it("rejects non-positive maxSize", { timeout: 5000 }, () => {
expect(() => new LRUCache<string, number>({ maxSize: 0 })).toThrow(
RangeError
);
expect(() => new LRUCache<string, number>({ maxSize: -1 })).toThrow(
RangeError
);
expect(
() =>
new LRUCache<string, number>({
maxSize: Number.POSITIVE_INFINITY,
})
).toThrow(RangeError);
});
it("rejects non-positive ttlMs", { timeout: 5000 }, () => {
expect(
() => new LRUCache<string, number>({ maxSize: 2, ttlMs: 0 })
).toThrow(RangeError);
expect(
() =>
new LRUCache<string, number>({
maxSize: 2,
ttlMs: Number.NaN,
})
).toThrow(RangeError);
});
});
describe("LRUCache basic CRUD", () => {
it("get/set/has/size/delete behave as expected", { timeout: 5000 }, () => {
const cache = new LRUCache<string, number>({ maxSize: 3 });
expect(cache.size).toBe(0);
cache.set("a", 1);
cache.set("b", 2);
expect(cache.size).toBe(2);
expect(cache.has("a")).toBe(true);
expect(cache.has("missing")).toBe(false);
expect(cache.get("a")).toBe(1);
expect(cache.get("missing")).toBeUndefined();
expect(cache.delete("missing")).toBe(false);
expect(cache.delete("a")).toBe(true);
expect(cache.has("a")).toBe(false);
});
it("replaces values in place without growing the cache", {
timeout: 5000,
}, () => {
const cache = new LRUCache<string, number>({ maxSize: 2 });
cache.set("a", 1);
cache.set("a", 2);
expect(cache.size).toBe(1);
expect(cache.get("a")).toBe(2);
});
it("uses the default wall clock when none is injected", {
timeout: 5000,
}, () => {
// Exercising the no-clock path drives the module-level default clock,
// which simply reads Date.now().
const cache = new LRUCache<string, number>({
maxSize: 2,
ttlMs: 10_000,
});
cache.set("a", 1);
expect(cache.get("a")).toBe(1);
expect(cache.has("a")).toBe(true);
});
});
describe("LRUCache eviction by size", () => {
it("evicts the least-recently-used entry when capacity overflows", {
timeout: 5000,
}, () => {
const evictions: Array<[string, number, LRUCacheEvictionReason]> = [];
const cache = new LRUCache<string, number>({
maxSize: 2,
onEvict: (k, v, r) => evictions.push([k, v, r]),
});
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3); // evicts "a"
expect(cache.has("a")).toBe(false);
expect(cache.has("b")).toBe(true);
expect(cache.has("c")).toBe(true);
expect(evictions).toEqual([["a", 1, "size"]]);
});
it("marks a touched entry as recent so it survives eviction", {
timeout: 5000,
}, () => {
const cache = new LRUCache<string, number>({ maxSize: 2 });
cache.set("a", 1);
cache.set("b", 2);
expect(cache.get("a")).toBe(1);
cache.set("c", 3); // should evict "b"
expect(cache.has("a")).toBe(true);
expect(cache.has("b")).toBe(false);
expect(cache.has("c")).toBe(true);
});
});
describe("LRUCache TTL", () => {
it("lazy-expires entries on get with reason 'ttl'", {
timeout: 5000,
}, () => {
let now = 0;
const evictions: Array<[string, number, LRUCacheEvictionReason]> = [];
const cache = new LRUCache<string, number>({
maxSize: 4,
ttlMs: 100,
clock: () => now,
onEvict: (k, v, r) => evictions.push([k, v, r]),
});
cache.set("a", 1);
now = 50;
expect(cache.get("a")).toBe(1);
now = 250;
// has() reports expired entries as absent but does not evict.
expect(cache.has("a")).toBe(false);
expect(cache.get("a")).toBeUndefined();
expect(cache.has("a")).toBe(false);
expect(evictions).toEqual([["a", 1, "ttl"]]);
});
it("reports ttl reason when deleting an already-expired entry", {
timeout: 5000,
}, () => {
let now = 0;
const evictions: Array<[string, number, LRUCacheEvictionReason]> = [];
const cache = new LRUCache<string, number>({
maxSize: 2,
ttlMs: 10,
clock: () => now,
onEvict: (k, v, r) => evictions.push([k, v, r]),
});
cache.set("a", 1);
now = 100;
expect(cache.delete("a")).toBe(true);
expect(evictions).toEqual([["a", 1, "ttl"]]);
});
});
describe("LRUCache delete + clear", () => {
it("delete fires onEvict with reason 'delete' for live entries", {
timeout: 5000,
}, () => {
const evictions: Array<[string, number, LRUCacheEvictionReason]> = [];
const cache = new LRUCache<string, number>({
maxSize: 4,
onEvict: (k, v, r) => evictions.push([k, v, r]),
});
cache.set("a", 1);
cache.delete("a");
expect(evictions).toEqual([["a", 1, "delete"]]);
});
it("clear fires onEvict with reason 'clear' for every entry", {
timeout: 5000,
}, () => {
const evictions: Array<[string, number, LRUCacheEvictionReason]> = [];
const cache = new LRUCache<string, number>({
maxSize: 4,
onEvict: (k, v, r) => evictions.push([k, v, r]),
});
cache.set("a", 1);
cache.set("b", 2);
cache.clear();
expect(cache.size).toBe(0);
expect(evictions).toContainEqual(["a", 1, "clear"]);
expect(evictions).toContainEqual(["b", 2, "clear"]);
});
it("clear without onEvict drops every entry silently", {
timeout: 5000,
}, () => {
const cache = new LRUCache<string, number>({ maxSize: 2 });
cache.set("a", 1);
cache.clear();
expect(cache.size).toBe(0);
});
});

View File

@@ -0,0 +1,133 @@
/**
* Bounded LRU cache with optional per-entry TTL.
*
* Backed by a `Map` to keep insertion order; a successful `get` re-inserts
* the entry to mark it as most-recently-used. Used by the WebRTC plugin to
* cap memory used by per-peer state and recently-served chunk metadata,
* mitigating CWE-400 (resource exhaustion).
*/
import type { Clock } from "@suaveplan/types";
import type {
LRUCacheEvictionReason,
LRUCacheOptions,
} from "./lru-cache.types.js";
interface Entry<V> {
value: V;
/** Absolute expiry time (ms since epoch) or `undefined` if no TTL. */
expiresAt: number | undefined;
}
const defaultClock: Clock = () => Date.now();
/**
* Memory-bounded LRU cache.
*
* @typeParam K - Key type (anything `Map` accepts).
* @typeParam V - Value type.
*/
export class LRUCache<K, V> {
private readonly store = new Map<K, Entry<V>>();
private readonly maxSize: number;
private readonly ttlMs: number | undefined;
private readonly clock: Clock;
private readonly onEvict:
| ((key: K, value: V, reason: LRUCacheEvictionReason) => void)
| undefined;
public constructor(options: LRUCacheOptions<K, V>) {
if (!Number.isFinite(options.maxSize) || options.maxSize <= 0) {
throw new RangeError(
"LRUCache: maxSize must be a positive finite number"
);
}
if (
options.ttlMs !== undefined &&
(!Number.isFinite(options.ttlMs) || options.ttlMs <= 0)
) {
throw new RangeError(
"LRUCache: ttlMs must be a positive finite number when provided"
);
}
this.maxSize = options.maxSize;
this.ttlMs = options.ttlMs;
this.clock = options.clock ?? defaultClock;
this.onEvict = options.onEvict;
}
/** Number of entries currently cached. */
public get size(): number {
return this.store.size;
}
/** Lookup; marks the entry as recently used. Lazy-expires stale entries. */
public get(key: K): V | undefined {
const entry = this.store.get(key);
if (entry === undefined) return undefined;
if (this.isExpired(entry)) {
this.store.delete(key);
this.onEvict?.(key, entry.value, "ttl");
return undefined;
}
// Refresh recency.
this.store.delete(key);
this.store.set(key, entry);
return entry.value;
}
/** Existence check (does not refresh recency, does not lazy-expire). */
public has(key: K): boolean {
const entry = this.store.get(key);
if (entry === undefined) return false;
return !this.isExpired(entry);
}
/** Insert or replace. Evicts the oldest entry when capacity is exceeded. */
public set(key: K, value: V): void {
// Replace path keeps existing slot but bumps recency by deleting first.
if (this.store.has(key)) {
this.store.delete(key);
}
const expiresAt =
this.ttlMs === undefined ? undefined : this.clock() + this.ttlMs;
this.store.set(key, { value, expiresAt });
// Evict from the oldest end until under cap. `store.size > maxSize`
// guarantees the iterator yields a key and `store.get` resolves.
while (this.store.size > this.maxSize) {
const oldestKey = this.store.keys().next().value as K;
const oldestEntry = this.store.get(oldestKey) as Entry<V>;
this.store.delete(oldestKey);
this.onEvict?.(oldestKey, oldestEntry.value, "size");
}
}
/** Remove a single entry. Returns `true` if a live entry was removed. */
public delete(key: K): boolean {
const entry = this.store.get(key);
if (entry === undefined) return false;
this.store.delete(key);
// Stale entries still count as a successful delete from the caller's
// perspective, but we report eviction with the cause that matches the
// pruning path so dashboards stay coherent.
const reason: LRUCacheEvictionReason = this.isExpired(entry)
? "ttl"
: "delete";
this.onEvict?.(key, entry.value, reason);
return true;
}
/** Remove every entry, firing `onEvict` for each with reason `"clear"`. */
public clear(): void {
if (this.onEvict !== undefined) {
for (const [key, entry] of this.store) {
this.onEvict(key, entry.value, "clear");
}
}
this.store.clear();
}
private isExpired(entry: Entry<V>): boolean {
return entry.expiresAt !== undefined && this.clock() >= entry.expiresAt;
}
}

View File

@@ -0,0 +1,73 @@
# LRU cache types
## Purpose
This file declares the public option and discriminator types for
`LRUCache`. Keeping the types in a sibling `.types.ts` lets consumers
import the shapes without dragging the runtime class through their bundle
graph, and lets `@suaveplan/drive-webrtc` re-export the types from its
root barrel with no risk of pulling in unused runtime code. The types are
used by every WebRTC subsystem that wants to expose its own
LRU-backed cache to consumers — most prominently the chunk-source and
peer-manager — so they live in a stable home rather than in the
implementation file.
## Features
- `LRUCacheOptions<K, V>` — constructor option bundle.
- `LRUCacheEvictionReason` — string-literal union closing over the four
cache exit points: `"size" | "ttl" | "delete" | "clear"`.
- Generic over `K` and `V` so both the key and value types are inferred
through to the `onEvict` callback at the call site.
## Usage: Basic Example
```ts
import type { LRUCacheOptions } from "@suaveplan/drive-webrtc";
const opts: LRUCacheOptions<string, Uint8Array> = { maxSize: 64 };
```
## Usage: Advanced Example
```ts
import type {
LRUCacheEvictionReason,
LRUCacheOptions,
} from "@suaveplan/drive-webrtc";
interface ChunkRecord {
readonly peerId: string;
readonly bytes: Uint8Array;
}
const opts: LRUCacheOptions<string, ChunkRecord> = {
maxSize: 256,
ttlMs: 60_000,
onEvict(_key, value, reason: LRUCacheEvictionReason) {
if (reason !== "delete") value.bytes.fill(0);
},
};
```
## API Reference
### `LRUCacheOptions<K, V>`
- `maxSize: number` — positive finite cap on entry count.
- `ttlMs?: number` — optional positive finite TTL in milliseconds.
- `clock?: () => number` — optional wall-clock provider for tests.
- `onEvict?: (key: K, value: V, reason: LRUCacheEvictionReason) => void` —
optional eviction observer.
### `LRUCacheEvictionReason`
String literal union: `"size" | "ttl" | "delete" | "clear"`.
## Implementation Notes
- `LRUCacheOptions` is intentionally read-only on every field; the
implementation only consults the option bag in the constructor and the
cache itself owns mutability thereafter.
- `Clock` is re-imported from `@suaveplan/types` so it stays in lockstep
with the rest of the project's clock-injection convention.

View File

@@ -0,0 +1,33 @@
/**
* LRU cache type definitions for `@suaveplan/drive-webrtc`.
*/
import type { Clock } from "@suaveplan/types";
/**
* Reason an entry left the cache, surfaced through {@link LRUCacheOptions.onEvict}.
*
* - `size` — capacity bound exceeded; oldest entry removed.
* - `ttl` — entry expired on a lazy `get`.
* - `delete` — caller invoked `delete(key)`.
* - `clear` — caller invoked `clear()`.
*/
export type LRUCacheEvictionReason = "size" | "ttl" | "delete" | "clear";
/**
* Configuration options for {@link LRUCache}.
*/
export interface LRUCacheOptions<K, V> {
/** Maximum number of entries before LRU eviction kicks in. Must be > 0. */
readonly maxSize: number;
/** Optional per-entry time-to-live in milliseconds (lazy-checked on `get`). */
readonly ttlMs?: number;
/** Injectable wall-clock (defaults to `Date.now`). */
readonly clock?: Clock;
/** Callback fired whenever an entry leaves the cache. */
readonly onEvict?: (
key: K,
value: V,
reason: LRUCacheEvictionReason
) => void;
}

View File

@@ -0,0 +1,5 @@
export { PeerBandwidthTracker } from "./peer-bandwidth-tracker.js";
export type {
PeerBandwidthTrackerConfig,
PeerMetrics,
} from "./peer-bandwidth-tracker.types.js";

View File

@@ -0,0 +1,132 @@
# PeerBandwidthTracker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerBandwidthTracker` keeps a rolling-window record of per-peer
transfer activity for the WebRTC mesh: bytes in and out, request
counts in both directions, and an EWMA-smoothed round-trip time. It
exposes a composite `score` in `[0, 1]` that
`@suaveplan/drive-webrtc`'s `PeerManager` uses to rank candidate
sources, and a `canServe` / `assertCanServe` pair the chunk-source
layer calls before serving a remote request, so noisy or
abusive peers can be detected and throttled without coupling every
caller to per-peer accounting.
## Features
- Rolling window of `windowMs` (default 60s) with explicit `rollWindow`
hook callable from a periodic timer.
- Per-peer byte / request counters in both directions.
- EWMA RTT (`α = 0.2`) preserved across window rolls.
- Composite score: `0.5 * (1 − bytesInLoad) + 0.3 * (1 − requestsInLoad) + 0.2 * rttFactor`.
- New peers with no samples start at `score = 1.0` so they are not
unfairly demoted on first sight.
- `assertCanServe` throws `DriveWebRtcError(PEER_BANDWIDTH_EXCEEDED)`.
- Injectable `clock` for deterministic tests.
## Usage: Basic Example
```ts
import { PeerBandwidthTracker } from "@suaveplan/drive-webrtc";
const tracker = new PeerBandwidthTracker();
tracker.recordBytesIn("peer-1", 16_000);
tracker.recordRtt("peer-1", 42);
const metrics = tracker.get("peer-1");
console.log(metrics.score, metrics.rttMs);
```
## Usage: Advanced Example
```ts
import { PeerBandwidthTracker, DriveWebRtcError } from "@suaveplan/drive-webrtc";
import { FakeClock } from "@suaveplan/testing";
const clock = new FakeClock();
const tracker = new PeerBandwidthTracker({
windowMs: 1_000,
maxBytesPerWindow: 100_000,
maxRequestsPerWindow: 50,
clock: { now: () => clock.now() },
});
for (let i = 0; i < 50; i++) tracker.recordRequestIn("peer-1");
try {
tracker.assertCanServe("peer-1");
} catch (err) {
if (err instanceof DriveWebRtcError) {
// err.code === "DRIVE_WEBRTC.PEER_BANDWIDTH_EXCEEDED"
}
}
clock.advance(1_001);
tracker.rollWindow();
tracker.assertCanServe("peer-1"); // recovered
```
## API Reference
### `new PeerBandwidthTracker(config?)`
Construct a tracker. `config` accepts `windowMs`, `maxBytesPerWindow`,
`maxRequestsPerWindow`, `clock`.
### `recordBytesIn(peerId, bytes)` / `recordBytesOut(peerId, bytes)`
Add bytes to the inbound or outbound counter, lazily rolling the
window if the peer's window has expired.
### `recordRequestIn(peerId)` / `recordRequestOut(peerId)`
Increment the inbound / outbound request counter by one.
### `recordRtt(peerId, rttMs)`
Update the EWMA RTT estimate (`α = 0.2`). The first sample seeds the
estimate verbatim.
### `canServe(peerId): boolean`
Returns `false` when the peer's total bytes or total requests in the
current window meet or exceed the configured thresholds.
### `assertCanServe(peerId): void`
Throws `DriveWebRtcError(PEER_BANDWIDTH_EXCEEDED)` when `canServe`
returns `false`.
### `get(peerId): PeerMetrics`
Snapshot of the peer's current window. Returns a zero-valued metrics
object with `score = 1` if the peer has never been touched.
### `snapshot(): readonly PeerMetrics[]`
Array of metrics for every tracked peer.
### `forget(peerId)`
Drop all state for the peer — call on disconnect.
### `rollWindow(now?)`
Force-roll every peer's window. Counters past `windowMs` are zeroed;
RTT estimates persist.
## Implementation Notes
- The window rolls lazily on every read / record call so callers do
not need to schedule `rollWindow` for correctness — it is provided
for callers that want predictable timing.
- Scores compose three normalised loads with hand-picked weights
(50% inbound bytes, 30% inbound requests, 20% RTT). The weights
match the upstream design intent of biasing against peers that are
saturating us with traffic versus peers that are slow.
- When `maxBytesPerWindow` / `maxRequestsPerWindow` are `Infinity`
(default), the load components collapse to zero and the score is
driven entirely by RTT.
- The class never throws on `record*` — recording is always safe;
only `assertCanServe` raises.

View File

@@ -0,0 +1,255 @@
/**
* Tests for {@link PeerBandwidthTracker}.
*/
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { PeerBandwidthTracker } from "./peer-bandwidth-tracker.js";
function makeTracker(opts?: {
windowMs?: number;
maxBytesPerWindow?: number;
maxRequestsPerWindow?: number;
}): { tracker: PeerBandwidthTracker; clock: FakeClock } {
const clock = new FakeClock();
const tracker = new PeerBandwidthTracker({
windowMs: opts?.windowMs,
maxBytesPerWindow: opts?.maxBytesPerWindow,
maxRequestsPerWindow: opts?.maxRequestsPerWindow,
clock: { now: () => clock.now() },
});
return { tracker, clock };
}
describe("PeerBandwidthTracker accounting", () => {
it("records bytes and requests in both directions", {
timeout: 5000,
}, () => {
const { tracker } = makeTracker();
tracker.recordBytesIn("p1", 1_000);
tracker.recordBytesOut("p1", 2_000);
tracker.recordRequestIn("p1");
tracker.recordRequestOut("p1");
tracker.recordRequestOut("p1");
const m = tracker.get("p1");
expect(m.bytesIn).toBe(1_000);
expect(m.bytesOut).toBe(2_000);
expect(m.requestsIn).toBe(1);
expect(m.requestsOut).toBe(2);
});
it("returns zero metrics for unknown peer with score 1", {
timeout: 5000,
}, () => {
const { tracker } = makeTracker();
const m = tracker.get("missing");
expect(m.bytesIn).toBe(0);
expect(m.bytesOut).toBe(0);
expect(m.requestsIn).toBe(0);
expect(m.requestsOut).toBe(0);
expect(m.rttMs).toBeNull();
expect(m.score).toBe(1);
});
});
describe("PeerBandwidthTracker RTT EWMA", () => {
it("seeds verbatim then blends with α = 0.2", { timeout: 5000 }, () => {
const { tracker } = makeTracker();
tracker.recordRtt("p1", 100);
expect(tracker.get("p1").rttMs).toBeCloseTo(100, 6);
tracker.recordRtt("p1", 200);
// 0.2 * 200 + 0.8 * 100 = 120
expect(tracker.get("p1").rttMs).toBeCloseTo(120, 6);
tracker.recordRtt("p1", 0);
// 0.2 * 0 + 0.8 * 120 = 96
expect(tracker.get("p1").rttMs).toBeCloseTo(96, 6);
});
it("preserves RTT across window rolls", { timeout: 5000 }, () => {
const { tracker, clock } = makeTracker({ windowMs: 1_000 });
tracker.recordRtt("p1", 200);
tracker.recordBytesIn("p1", 500);
clock.advance(1_500);
tracker.rollWindow();
const m = tracker.get("p1");
expect(m.bytesIn).toBe(0);
expect(m.rttMs).toBeCloseTo(200, 6);
});
});
describe("PeerBandwidthTracker score", () => {
it("orders peers by quality with rttFactor dominating among equals", {
timeout: 5000,
}, () => {
const { tracker } = makeTracker({
maxBytesPerWindow: 10_000,
maxRequestsPerWindow: 100,
});
tracker.recordRtt("fast", 50);
tracker.recordBytesIn("fast", 100);
tracker.recordRtt("slow", 800);
tracker.recordBytesIn("slow", 100);
const fast = tracker.get("fast");
const slow = tracker.get("slow");
expect(fast.score).toBeGreaterThan(slow.score);
});
it("penalises peers near byte budget", { timeout: 5000 }, () => {
const { tracker } = makeTracker({ maxBytesPerWindow: 10_000 });
tracker.recordBytesIn("p1", 9_000);
tracker.recordRtt("p1", 0);
const m = tracker.get("p1");
// bytesInLoad = 0.9 → 0.5 * 0.1 = 0.05
// requestsInLoad = 0 → 0.3 * 1 = 0.3
// rttFactor = 1 → 0.2 * 1 = 0.2
// total = 0.55
expect(m.score).toBeCloseTo(0.55, 6);
});
it("penalises peers near request budget", { timeout: 5000 }, () => {
const { tracker } = makeTracker({ maxRequestsPerWindow: 10 });
for (let i = 0; i < 9; i++) tracker.recordRequestIn("p1");
tracker.recordRtt("p1", 0);
const m = tracker.get("p1");
// bytesInLoad = 0 (Infinity max) → 0.5
// requestsInLoad = 0.9 → 0.3 * 0.1 = 0.03
// rttFactor = 1 → 0.2
expect(m.score).toBeCloseTo(0.73, 6);
});
it("uses rttFactor=1 when RTT unmeasured", { timeout: 5000 }, () => {
const { tracker } = makeTracker({ maxBytesPerWindow: 1_000 });
tracker.recordBytesIn("p1", 500);
const m = tracker.get("p1");
// bytesInLoad = 0.5 → 0.5 * 0.5 = 0.25
// requestsInLoad = 0 → 0.3
// rttFactor = 1 → 0.2
expect(m.score).toBeCloseTo(0.75, 6);
});
it("clamps rttFactor at zero past 1s", { timeout: 5000 }, () => {
const { tracker } = makeTracker();
tracker.recordRtt("p1", 5_000);
const m = tracker.get("p1");
// loads zero (Infinity caps) → 0.5 + 0.3 + 0 = 0.8
expect(m.score).toBeCloseTo(0.8, 6);
});
});
describe("PeerBandwidthTracker.canServe / assertCanServe", () => {
it("allows new peers", { timeout: 5000 }, () => {
const { tracker } = makeTracker({ maxBytesPerWindow: 100 });
expect(tracker.canServe("never-seen")).toBe(true);
expect(() => tracker.assertCanServe("never-seen")).not.toThrow();
});
it("rejects peers over byte budget", { timeout: 5000 }, () => {
const { tracker } = makeTracker({ maxBytesPerWindow: 100 });
tracker.recordBytesIn("p1", 60);
tracker.recordBytesOut("p1", 60);
expect(tracker.canServe("p1")).toBe(false);
try {
tracker.assertCanServe("p1");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_BANDWIDTH_EXCEEDED
);
}
});
it("rejects peers over request budget", { timeout: 5000 }, () => {
const { tracker } = makeTracker({ maxRequestsPerWindow: 4 });
tracker.recordRequestIn("p1");
tracker.recordRequestIn("p1");
tracker.recordRequestOut("p1");
tracker.recordRequestOut("p1");
expect(tracker.canServe("p1")).toBe(false);
});
it("recovers after window rolls", { timeout: 5000 }, () => {
const { tracker, clock } = makeTracker({
windowMs: 1_000,
maxBytesPerWindow: 100,
});
tracker.recordBytesIn("p1", 200);
expect(tracker.canServe("p1")).toBe(false);
clock.advance(1_100);
expect(tracker.canServe("p1")).toBe(true);
});
});
describe("PeerBandwidthTracker.snapshot / forget", () => {
it("snapshots every tracked peer", { timeout: 5000 }, () => {
const { tracker } = makeTracker();
tracker.recordBytesIn("p1", 10);
tracker.recordBytesIn("p2", 20);
const snap = tracker.snapshot();
expect(snap.map((m) => m.peerId).sort()).toEqual(["p1", "p2"]);
});
it("forget purges all peer state", { timeout: 5000 }, () => {
const { tracker } = makeTracker();
tracker.recordBytesIn("p1", 10);
tracker.recordRtt("p1", 50);
tracker.forget("p1");
const m = tracker.get("p1");
expect(m.bytesIn).toBe(0);
expect(m.rttMs).toBeNull();
expect(tracker.snapshot()).toHaveLength(0);
});
});
describe("PeerBandwidthTracker.rollWindow", () => {
it("rolls only expired windows", { timeout: 5000 }, () => {
const { tracker, clock } = makeTracker({ windowMs: 1_000 });
tracker.recordBytesIn("p1", 100);
clock.advance(500);
tracker.recordBytesIn("p2", 200);
clock.advance(600); // p1 now 1_100ms in, p2 600ms in
tracker.rollWindow();
expect(tracker.get("p1").bytesIn).toBe(0);
expect(tracker.get("p2").bytesIn).toBe(200);
});
it("accepts an explicit now", { timeout: 5000 }, () => {
const { tracker, clock } = makeTracker({ windowMs: 1_000 });
tracker.recordBytesIn("p1", 100);
tracker.rollWindow(clock.now() + 5_000);
expect(tracker.get("p1").bytesIn).toBe(0);
});
});
describe("PeerBandwidthTracker clamp behaviour", () => {
it("clamps high request load to zero contribution", {
timeout: 5000,
}, () => {
const { tracker } = makeTracker({ maxRequestsPerWindow: 2 });
for (let i = 0; i < 5; i++) tracker.recordRequestIn("p1");
tracker.recordRtt("p1", 0);
const m = tracker.get("p1");
expect(m.score).toBeCloseTo(0.7, 6);
});
it("clamps high byte load to zero contribution", { timeout: 5000 }, () => {
const { tracker } = makeTracker({ maxBytesPerWindow: 100 });
tracker.recordBytesIn("p1", 5_000);
tracker.recordRtt("p1", 0);
const m = tracker.get("p1");
expect(m.score).toBeCloseTo(0.5, 6);
});
});
describe("PeerBandwidthTracker default clock", () => {
it("uses Date.now() when no clock supplied", { timeout: 5000 }, () => {
const tracker = new PeerBandwidthTracker();
tracker.recordBytesIn("p1", 5);
const m = tracker.get("p1");
expect(m.windowStartedAt).toBeLessThanOrEqual(Date.now());
});
});

View File

@@ -0,0 +1,226 @@
/**
* Per-peer bandwidth + request + RTT tracker with a rolling window
* and a composite score for ranked peer selection.
*
* See `peer-bandwidth-tracker.md` for prose docs.
*/
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import type {
PeerBandwidthTrackerConfig,
PeerMetrics,
} from "./peer-bandwidth-tracker.types.js";
const DEFAULT_WINDOW_MS = 60_000;
const RTT_ALPHA = 0.2;
interface MutablePeerState {
bytesIn: number;
bytesOut: number;
requestsIn: number;
requestsOut: number;
rttMs: number | null;
windowStartedAt: number;
}
/**
* Per-peer rolling-window bandwidth tracker.
*
* Records inbound/outbound byte + request counters and an
* EWMA-smoothed round-trip time. Exposes a composite `score` used by
* {@link import("../peer-manager/peer-manager.js").PeerManager} to
* rank candidate sources.
*/
export class PeerBandwidthTracker {
private readonly windowMs: number;
private readonly maxBytesPerWindow: number;
private readonly maxRequestsPerWindow: number;
private readonly clock: { now: () => number };
private readonly peers = new Map<string, MutablePeerState>();
public constructor(config: PeerBandwidthTrackerConfig = {}) {
this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
this.maxBytesPerWindow =
config.maxBytesPerWindow ?? Number.POSITIVE_INFINITY;
this.maxRequestsPerWindow =
config.maxRequestsPerWindow ?? Number.POSITIVE_INFINITY;
this.clock = config.clock ?? { now: () => Date.now() };
}
public recordBytesIn(peerId: string, bytes: number): void {
const state = this.ensure(peerId);
this.maybeRoll(state);
state.bytesIn += bytes;
}
public recordBytesOut(peerId: string, bytes: number): void {
const state = this.ensure(peerId);
this.maybeRoll(state);
state.bytesOut += bytes;
}
public recordRequestIn(peerId: string): void {
const state = this.ensure(peerId);
this.maybeRoll(state);
state.requestsIn += 1;
}
public recordRequestOut(peerId: string): void {
const state = this.ensure(peerId);
this.maybeRoll(state);
state.requestsOut += 1;
}
public recordRtt(peerId: string, rttMs: number): void {
const state = this.ensure(peerId);
this.maybeRoll(state);
if (state.rttMs === null) {
state.rttMs = rttMs;
} else {
state.rttMs = RTT_ALPHA * rttMs + (1 - RTT_ALPHA) * state.rttMs;
}
}
/** True iff the peer is within both `max*PerWindow` thresholds. */
public canServe(peerId: string): boolean {
const state = this.peers.get(peerId);
if (state === undefined) return true;
this.maybeRoll(state);
if (state.bytesIn + state.bytesOut >= this.maxBytesPerWindow) {
return false;
}
if (state.requestsIn + state.requestsOut >= this.maxRequestsPerWindow) {
return false;
}
return true;
}
/**
* Throws {@link DriveWebRtcError} with code
* `PEER_BANDWIDTH_EXCEEDED` when {@link canServe} would return
* `false`.
*/
public assertCanServe(peerId: string): void {
if (!this.canServe(peerId)) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_BANDWIDTH_EXCEEDED,
`peer ${peerId} exceeded per-window bandwidth budget`
);
}
}
public get(peerId: string): PeerMetrics {
const state = this.peers.get(peerId);
if (state === undefined) {
const now = this.clock.now();
return {
peerId,
bytesIn: 0,
bytesOut: 0,
requestsIn: 0,
requestsOut: 0,
rttMs: null,
score: 1,
windowStartedAt: now,
};
}
this.maybeRoll(state);
return this.toMetrics(peerId, state);
}
public snapshot(): readonly PeerMetrics[] {
const out: PeerMetrics[] = [];
for (const [peerId, state] of this.peers) {
this.maybeRoll(state);
out.push(this.toMetrics(peerId, state));
}
return out;
}
public forget(peerId: string): void {
this.peers.delete(peerId);
}
/** Roll the window for every peer past `windowMs`. */
public rollWindow(now: number = this.clock.now()): void {
for (const state of this.peers.values()) {
this.rollIfExpired(state, now);
}
}
private ensure(peerId: string): MutablePeerState {
let state = this.peers.get(peerId);
if (state === undefined) {
state = {
bytesIn: 0,
bytesOut: 0,
requestsIn: 0,
requestsOut: 0,
rttMs: null,
windowStartedAt: this.clock.now(),
};
this.peers.set(peerId, state);
}
return state;
}
private maybeRoll(state: MutablePeerState): void {
this.rollIfExpired(state, this.clock.now());
}
private rollIfExpired(state: MutablePeerState, now: number): void {
if (now - state.windowStartedAt < this.windowMs) return;
state.bytesIn = 0;
state.bytesOut = 0;
state.requestsIn = 0;
state.requestsOut = 0;
state.windowStartedAt = now;
// Note: RTT EWMA is intentionally preserved across windows
// because it is a smoothed estimate, not a counter.
}
private toMetrics(peerId: string, state: MutablePeerState): PeerMetrics {
return {
peerId,
bytesIn: state.bytesIn,
bytesOut: state.bytesOut,
requestsIn: state.requestsIn,
requestsOut: state.requestsOut,
rttMs: state.rttMs,
score: this.computeScore(state),
windowStartedAt: state.windowStartedAt,
};
}
private computeScore(state: MutablePeerState): number {
const noSamples =
state.bytesIn === 0 &&
state.bytesOut === 0 &&
state.requestsIn === 0 &&
state.requestsOut === 0 &&
state.rttMs === null;
if (noSamples) return 1;
const bytesInLoad = Number.isFinite(this.maxBytesPerWindow)
? clamp01(state.bytesIn / this.maxBytesPerWindow)
: 0;
const requestsInLoad = Number.isFinite(this.maxRequestsPerWindow)
? clamp01(state.requestsIn / this.maxRequestsPerWindow)
: 0;
const rttFactor =
state.rttMs === null ? 1 : clamp01(1 - state.rttMs / 1000);
return (
0.5 * (1 - bytesInLoad) +
0.3 * (1 - requestsInLoad) +
0.2 * rttFactor
);
}
}
function clamp01(value: number): number {
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}

View File

@@ -0,0 +1,94 @@
# PeerBandwidthTracker Types
## Purpose
This file defines the public shape of the configuration accepted by
`PeerBandwidthTracker` and the `PeerMetrics` snapshot it returns.
Splitting types from the implementation keeps the public contract
visible at a glance and lets downstream packages depend on the type
surface without importing runtime code from
`@suaveplan/drive-webrtc`.
## Features
- `PeerBandwidthTrackerConfig` — all fields optional, suitable for
zero-arg construction.
- `PeerMetrics` — read-only snapshot returned by `get` and
`snapshot`.
- Both interfaces are deeply `readonly` so consumers cannot mutate
the tracker's internal state via a leaked reference.
- Optional `clock` field on the config interface keeps tests
deterministic without depending on `Date.now()`.
## Usage: Basic Example
```ts
import type {
PeerBandwidthTrackerConfig,
PeerMetrics,
} from "@suaveplan/drive-webrtc";
const config: PeerBandwidthTrackerConfig = { windowMs: 30_000 };
function rank(metrics: readonly PeerMetrics[]): readonly PeerMetrics[] {
return [...metrics].sort((a, b) => b.score - a.score);
}
```
## Usage: Advanced Example
```ts
import type {
PeerBandwidthTrackerConfig,
PeerMetrics,
} from "@suaveplan/drive-webrtc";
const config: PeerBandwidthTrackerConfig = {
windowMs: 5_000,
maxBytesPerWindow: 1_048_576,
maxRequestsPerWindow: 200,
clock: { now: () => performance.now() },
};
function isHealthy(metrics: PeerMetrics): boolean {
return (
metrics.score >= 0.6 &&
(metrics.rttMs === null || metrics.rttMs < 250)
);
}
```
## API Reference
### `PeerBandwidthTrackerConfig`
- `windowMs?: number` — rolling window length (default 60s).
- `maxBytesPerWindow?: number` — total byte budget per peer per
window (default `Infinity`).
- `maxRequestsPerWindow?: number` — total request budget per peer
per window (default `Infinity`).
- `clock?: { now: () => number }` — injectable clock; defaults to
wall-clock `Date.now()`.
### `PeerMetrics`
- `peerId: string`.
- `bytesIn`, `bytesOut`, `requestsIn`, `requestsOut: number` —
current-window counters.
- `rttMs: number | null` — EWMA-smoothed RTT, `null` until first
sample.
- `score: number` — composite quality score in `[0, 1]`.
- `windowStartedAt: number` — wall-clock timestamp of the current
window's start.
## Implementation Notes
- Every counter field is a plain `number` (not `bigint`). The
tracker's window is short enough that overflow is impractical
before the window rolls and counters reset.
- `rttMs: null` distinguishes "no samples" from "0ms" — important so
the score formula can treat unmeasured RTT as ideal (factor `1`)
rather than penalising a peer the tracker simply hasn't probed
yet.
- `score` is plain `number` (not branded) — keeps the metrics
shape ergonomic for ad-hoc sorting and logging.

View File

@@ -0,0 +1,58 @@
/**
* Type definitions for {@link PeerBandwidthTracker}. See
* `peer-bandwidth-tracker.types.md` for prose-level documentation.
*/
/**
* Configuration accepted by {@link PeerBandwidthTracker}.
*
* All fields are optional. The tracker keeps a rolling window of byte
* and request counters per peer; `windowMs` governs how long counters
* accumulate before {@link PeerBandwidthTracker.rollWindow} resets
* them. The two `max*` thresholds bound `canServe` / `assertCanServe`.
*/
export interface PeerBandwidthTrackerConfig {
/** Rolling-window length in milliseconds (default `60_000`). */
readonly windowMs?: number;
/**
* Maximum inbound + outbound byte budget per window before the
* peer is considered throttled. Defaults to `Infinity` (no cap).
*/
readonly maxBytesPerWindow?: number;
/**
* Maximum inbound + outbound request count per window before the
* peer is considered throttled. Defaults to `Infinity` (no cap).
*/
readonly maxRequestsPerWindow?: number;
/** Injectable clock; defaults to `Date.now()`. */
readonly clock?: { now: () => number };
}
/**
* Snapshot of the metrics tracked for a single peer. Returned by
* {@link PeerBandwidthTracker.get} and inside the array returned by
* {@link PeerBandwidthTracker.snapshot}.
*/
export interface PeerMetrics {
readonly peerId: string;
/** Bytes received from this peer in the current window. */
readonly bytesIn: number;
/** Bytes sent to this peer in the current window. */
readonly bytesOut: number;
/** Inbound request count in the current window. */
readonly requestsIn: number;
/** Outbound request count in the current window. */
readonly requestsOut: number;
/** EWMA-smoothed round-trip time (ms) — `null` until first sample. */
readonly rttMs: number | null;
/**
* Composite score in `[0, 1]`. Higher = better source. Computed
* as `0.5 * (1 - bytesInLoad) + 0.3 * (1 - requestsInLoad) +
* 0.2 * rttFactor` with `rttFactor = clamp(1 - rttMs/1000, 0, 1)`.
* Defaults to `1.0` when there are no samples yet so newly-seen
* peers are not unfairly demoted.
*/
readonly score: number;
/** Wall-clock timestamp marking the start of the current window. */
readonly windowStartedAt: number;
}

View File

@@ -0,0 +1,5 @@
export {
PeerManager,
type PeerManagerEventMap,
type PeerManagerOptions,
} from "./peer-manager.js";

View File

@@ -0,0 +1,122 @@
# PeerManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerManager` is the peer-lifecycle orchestrator for the
`@suaveplan/drive-webrtc` mesh. It composes a
{@link PeerBandwidthTracker} and a {@link TopologyStrategy} to decide
which peers to admit, when to evict, and how to rank candidates for
chunk source selection. It is a `TypedEventEmitter` so downstream
layers (chunk-source, plugin, UI) can subscribe to admission /
eviction / connection-state / chunk-availability events without
polling.
## Features
- Pluggable `TopologyStrategy` (default `FullMeshStrategy`).
- Pluggable `PeerBandwidthTracker` (default constructed).
- Configurable `maxPeers` (default from `DEFAULT_MAX_PEERS`).
- Atomic evict-then-admit when topology requests an eviction.
- Score-ranked peer listing via `rankByScore`.
- Forgets bandwidth metrics on eviction so reclaimed peer ids start
fresh.
- Throws `DriveWebRtcError(PEER_HANDSHAKE_FAILED)` when the topology
refuses admission.
## Usage: Basic Example
```ts
import { PeerManager } from "@suaveplan/drive-webrtc";
const manager = new PeerManager({ selfPeerId: "alice" });
manager.admit({
id: "bob",
name: "Bob",
chunks: new Set(),
connectionState: "connecting",
lastSeen: Date.now(),
});
manager.setConnectionState("bob", "connected");
```
## Usage: Advanced Example
```ts
import {
PeerManager,
PeerBandwidthTracker,
SuperPeerStrategy,
} from "@suaveplan/drive-webrtc";
const bandwidth = new PeerBandwidthTracker({ windowMs: 30_000 });
const topology = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: new Set(["sp-1", "sp-2"]),
});
const manager = new PeerManager({
selfPeerId: "client-7",
maxPeers: 12,
topology,
bandwidth,
});
manager.on("peer:evicted", ({ peer, reason }) => {
console.warn(`evicted ${peer.id}:`, reason);
});
const ranked = manager.rankByScore();
```
## API Reference
### `new PeerManager(options)`
- `selfPeerId: string`.
- `maxPeers?: number` (default `DEFAULT_MAX_PEERS`).
- `topology?: TopologyStrategy` (default `FullMeshStrategy`).
- `bandwidth?: PeerBandwidthTracker`.
- `clock?: { now: () => number }`.
### `admit(peer): PeerInfo`
Consult the topology; on `admit: true` with `evict` set, evict the
listed peer first, then store the new peer. Throws
`DriveWebRtcError(PEER_HANDSHAKE_FAILED)` on refusal. The stored
`PeerInfo` is a deep clone — callers cannot mutate manager state
through the returned reference's `chunks` set.
### `evict(peerId, reason?)`
Remove the peer and call `bandwidth.forget(peerId)`. Idempotent —
calling with an unknown id is a no-op.
### `get(peerId)` / `list()`
Lookup / enumeration.
### `rankByScore(): readonly PeerInfo[]`
Sort peers descending by `bandwidth.get(peerId).score`.
### `setConnectionState(peerId, state)` / `setAvailableChunks(peerId, chunkIds)`
Update peer state and emit the matching event. Unknown peer ids are
silently ignored — the events module is a notification layer, not a
validation layer.
### `bandwidth` / `topology` (readonly)
Expose the underlying primitives so chunk-source code can record
bytes/requests without going through `PeerManager`.
## Implementation Notes
- The topology decision uses `bandwidth.get` as `metricsFor`, so all
scoring is consistent across the manager.
- `admit` never silently replaces an existing peer; the topology
must explicitly reject duplicates.
- `evict` is the only entry point that clears bandwidth state to
avoid memory leaks across peer churn.

View File

@@ -0,0 +1,252 @@
/**
* Tests for {@link PeerManager}.
*/
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import type { PeerInfo } from "../../types/peer.types.js";
import { PeerBandwidthTracker } from "../peer-bandwidth-tracker/peer-bandwidth-tracker.js";
import { FullMeshStrategy } from "../topology/full-mesh.js";
import { HubAndSpokeStrategy } from "../topology/hub-and-spoke.js";
import { PeerManager } from "./peer-manager.js";
function peer(id: string): PeerInfo {
return {
id,
name: id,
chunks: new Set(),
connectionState: "connecting",
lastSeen: 0,
};
}
describe("PeerManager.admit", () => {
it("admits a new peer below capacity and emits event", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
const events: PeerInfo[] = [];
manager.on("peer:admitted", (p) => events.push(p));
const stored = manager.admit(peer("bob"));
expect(stored.id).toBe("bob");
expect(manager.get("bob")).toBeDefined();
expect(events).toHaveLength(1);
});
it("throws PEER_HANDSHAKE_FAILED when topology refuses", {
timeout: 5000,
}, () => {
const manager = new PeerManager({
selfPeerId: "self",
topology: new HubAndSpokeStrategy({ hubPeerId: "hub" }),
});
try {
manager.admit(peer("other-spoke"));
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
expect((err as DriveWebRtcError).code).toBe(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
);
}
});
it("evicts then admits when topology requests eviction", {
timeout: 5000,
}, () => {
const bandwidth = new PeerBandwidthTracker({
maxBytesPerWindow: 1_000,
});
const manager = new PeerManager({
selfPeerId: "self",
maxPeers: 2,
topology: new FullMeshStrategy(),
bandwidth,
});
manager.admit(peer("a"));
manager.admit(peer("b"));
// a has higher score (1) vs b's (lower after recording bytes)
bandwidth.recordBytesIn("b", 900); // load 0.9 → score ~0.35
const evicted: { peer: PeerInfo; reason: string }[] = [];
manager.on("peer:evicted", (e) => evicted.push(e));
manager.admit(peer("c"));
expect(evicted).toHaveLength(1);
expect(evicted[0]?.peer.id).toBe("b");
expect(evicted[0]?.reason).toBe("topology:capacity");
expect(manager.get("c")).toBeDefined();
expect(manager.get("b")).toBeUndefined();
});
it("seeds lastSeen from clock when peer.lastSeen is 0", {
timeout: 5000,
}, () => {
const clock = new FakeClock();
clock.advance(12_345);
const manager = new PeerManager({
selfPeerId: "self",
clock: { now: () => clock.now() },
});
const stored = manager.admit(peer("a"));
expect(stored.lastSeen).toBe(12_345);
});
it("preserves caller lastSeen when truthy", { timeout: 5000 }, () => {
const manager = new PeerManager({ selfPeerId: "self" });
const stored = manager.admit({
...peer("a"),
lastSeen: 999,
});
expect(stored.lastSeen).toBe(999);
});
it("clones chunk set so caller mutations don't leak", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
const callerChunks = new Set(["c1"]);
const stored = manager.admit({
...peer("a"),
chunks: callerChunks,
});
callerChunks.add("c2");
expect(stored.chunks.has("c2")).toBe(false);
});
it("preserves rtcConnection and rtcDataChannel when supplied", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
const conn = {} as unknown as RTCPeerConnection;
const dc = {} as unknown as RTCDataChannel;
const stored = manager.admit({
...peer("a"),
rtcConnection: conn,
rtcDataChannel: dc,
});
expect(stored.rtcConnection).toBe(conn);
expect(stored.rtcDataChannel).toBe(dc);
});
});
describe("PeerManager.evict", () => {
it("removes peer, forgets bandwidth, emits event", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
manager.admit(peer("a"));
manager.bandwidth.recordBytesIn("a", 100);
const evicted: { peer: PeerInfo; reason: string }[] = [];
manager.on("peer:evicted", (e) => evicted.push(e));
manager.evict("a", "test");
expect(manager.get("a")).toBeUndefined();
expect(manager.bandwidth.get("a").bytesIn).toBe(0);
expect(evicted[0]?.reason).toBe("test");
});
it("is idempotent on unknown peer", { timeout: 5000 }, () => {
const manager = new PeerManager({ selfPeerId: "self" });
let emitted = 0;
manager.on("peer:evicted", () => emitted++);
manager.evict("missing");
expect(emitted).toBe(0);
});
it("defaults reason to 'evicted'", { timeout: 5000 }, () => {
const manager = new PeerManager({ selfPeerId: "self" });
manager.admit(peer("a"));
const evicted: { peer: PeerInfo; reason: string }[] = [];
manager.on("peer:evicted", (e) => evicted.push(e));
manager.evict("a");
expect(evicted[0]?.reason).toBe("evicted");
});
});
describe("PeerManager.list / get / rankByScore", () => {
it("lists every admitted peer", { timeout: 5000 }, () => {
const manager = new PeerManager({ selfPeerId: "self" });
manager.admit(peer("a"));
manager.admit(peer("b"));
expect(
manager
.list()
.map((p) => p.id)
.sort()
).toEqual(["a", "b"]);
});
it("rankByScore sorts descending", { timeout: 5000 }, () => {
const bandwidth = new PeerBandwidthTracker({
maxBytesPerWindow: 1_000,
});
const manager = new PeerManager({ selfPeerId: "self", bandwidth });
manager.admit(peer("good"));
manager.admit(peer("bad"));
bandwidth.recordBytesIn("bad", 900);
const ranked = manager.rankByScore();
expect(ranked.map((p) => p.id)).toEqual(["good", "bad"]);
});
});
describe("PeerManager state mutators", () => {
it("setConnectionState updates and emits", { timeout: 5000 }, () => {
const manager = new PeerManager({ selfPeerId: "self" });
manager.admit(peer("a"));
const events: { peerId: string; state: string }[] = [];
manager.on("peer:state", (e) => events.push(e));
manager.setConnectionState("a", "connected");
expect(manager.get("a")?.connectionState).toBe("connected");
expect(events[0]?.state).toBe("connected");
});
it("setConnectionState is silent on unknown peer", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
let emitted = 0;
manager.on("peer:state", () => emitted++);
manager.setConnectionState("missing", "connected");
expect(emitted).toBe(0);
});
it("setAvailableChunks replaces chunk set and emits", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
manager.admit(peer("a"));
const events: { peerId: string; chunkIds: readonly string[] }[] = [];
manager.on("peer:chunks-updated", (e) => events.push(e));
manager.setAvailableChunks("a", ["c1", "c2"]);
expect(manager.get("a")?.chunks.has("c1")).toBe(true);
expect(events[0]?.chunkIds).toEqual(["c1", "c2"]);
});
it("setAvailableChunks is silent on unknown peer", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
let emitted = 0;
manager.on("peer:chunks-updated", () => emitted++);
manager.setAvailableChunks("missing", []);
expect(emitted).toBe(0);
});
});
describe("PeerManager defaults", () => {
it("exposes default FullMeshStrategy and PeerBandwidthTracker", {
timeout: 5000,
}, () => {
const manager = new PeerManager({ selfPeerId: "self" });
expect(manager.topology.name).toBe("full-mesh");
expect(manager.bandwidth).toBeInstanceOf(PeerBandwidthTracker);
});
it("uses Date.now clock by default", { timeout: 5000 }, () => {
const manager = new PeerManager({ selfPeerId: "self" });
const stored = manager.admit(peer("a"));
expect(stored.lastSeen).toBeLessThanOrEqual(Date.now());
});
});

View File

@@ -0,0 +1,141 @@
/**
* PeerManager — peer-lifecycle orchestrator composing
* {@link PeerBandwidthTracker} and a {@link TopologyStrategy}.
*
* See `peer-manager.md` for prose docs.
*/
import { DEFAULT_MAX_PEERS } from "../../constants/index.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
import type { PeerConnectionState, PeerInfo } from "../../types/peer.types.js";
import { PeerBandwidthTracker } from "../peer-bandwidth-tracker/peer-bandwidth-tracker.js";
import { FullMeshStrategy } from "../topology/full-mesh.js";
import type {
TopologySelectOptions,
TopologyStrategy,
} from "../topology/topology-strategy.js";
export interface PeerManagerOptions {
readonly selfPeerId: string;
readonly maxPeers?: number;
readonly topology?: TopologyStrategy;
readonly bandwidth?: PeerBandwidthTracker;
readonly clock?: { now: () => number };
}
export type PeerManagerEventMap = {
"peer:admitted": [PeerInfo];
"peer:evicted": [{ peer: PeerInfo; reason: string }];
"peer:state": [{ peerId: string; state: PeerConnectionState }];
"peer:chunks-updated": [{ peerId: string; chunkIds: readonly string[] }];
};
export class PeerManager extends TypedEventEmitter<PeerManagerEventMap> {
public readonly bandwidth: PeerBandwidthTracker;
public readonly topology: TopologyStrategy;
private readonly selfPeerId: string;
private readonly maxPeers: number;
private readonly clock: { now: () => number };
private readonly peers = new Map<string, PeerInfo>();
public constructor(options: PeerManagerOptions) {
super();
this.selfPeerId = options.selfPeerId;
this.maxPeers = options.maxPeers ?? DEFAULT_MAX_PEERS;
this.topology = options.topology ?? new FullMeshStrategy();
this.bandwidth = options.bandwidth ?? new PeerBandwidthTracker();
this.clock = options.clock ?? { now: () => Date.now() };
}
public admit(peer: PeerInfo): PeerInfo {
const current = Array.from(this.peers.values());
const decision = this.topology.admitOrEvict(
current,
peer,
this.selectOptions()
);
if (!decision.admit) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
`topology refused peer ${peer.id}`
);
}
if (decision.evict !== undefined) {
this.evict(decision.evict.id, "topology:capacity");
}
const stored: PeerInfo = {
id: peer.id,
name: peer.name,
chunks: new Set(peer.chunks),
connectionState: peer.connectionState,
lastSeen: peer.lastSeen || this.clock.now(),
...(peer.rtcConnection !== undefined
? { rtcConnection: peer.rtcConnection }
: {}),
...(peer.rtcDataChannel !== undefined
? { rtcDataChannel: peer.rtcDataChannel }
: {}),
};
this.peers.set(stored.id, stored);
this.emit("peer:admitted", stored);
return stored;
}
public evict(peerId: string, reason = "evicted"): void {
const peer = this.peers.get(peerId);
if (peer === undefined) return;
this.peers.delete(peerId);
this.bandwidth.forget(peerId);
this.emit("peer:evicted", { peer, reason });
}
public get(peerId: string): PeerInfo | undefined {
return this.peers.get(peerId);
}
public list(): readonly PeerInfo[] {
return Array.from(this.peers.values());
}
public rankByScore(): readonly PeerInfo[] {
const peers = Array.from(this.peers.values());
return peers.sort(
(a, b) =>
this.bandwidth.get(b.id).score - this.bandwidth.get(a.id).score
);
}
public setConnectionState(
peerId: string,
state: PeerConnectionState
): void {
const peer = this.peers.get(peerId);
if (peer === undefined) return;
peer.connectionState = state;
peer.lastSeen = this.clock.now();
this.emit("peer:state", { peerId, state });
}
public setAvailableChunks(
peerId: string,
chunkIds: readonly string[]
): void {
const peer = this.peers.get(peerId);
if (peer === undefined) return;
peer.chunks = new Set(chunkIds);
peer.lastSeen = this.clock.now();
this.emit("peer:chunks-updated", { peerId, chunkIds });
}
private selectOptions(): TopologySelectOptions {
return {
maxPeers: this.maxPeers,
selfPeerId: this.selfPeerId,
metricsFor: (id) => this.bandwidth.get(id),
};
}
}

View File

@@ -0,0 +1,79 @@
# FullMeshStrategy
## Purpose
Every peer connects to every other peer up to a configurable cap.
This is the default topology and matches the assumption baked into
the drive-webrtc plugin: small (< 32) groups that benefit from
direct peer-to-peer chunk transfers without a central relay. When
capacity is reached, the strategy evicts the lowest-scoring existing
peer if and only if the newcomer's score is strictly higher,
preventing oscillation.
## Features
- Symmetric connect: `selectConnections` returns `[newPeer]` for
every new peer below `maxPeers`.
- Score-gated eviction: never evicts unless a strictly better peer
arrives.
- Self-peer never gets admitted to its own peer set.
## Usage: Basic Example
```ts
import { FullMeshStrategy } from "@suaveplan/drive-webrtc";
const strategy = new FullMeshStrategy();
const decision = strategy.admitOrEvict([], newPeer, {
maxPeers: 10,
selfPeerId: "alice",
});
```
## Usage: Advanced Example
```ts
import {
FullMeshStrategy,
PeerBandwidthTracker,
} from "@suaveplan/drive-webrtc";
const tracker = new PeerBandwidthTracker();
const strategy = new FullMeshStrategy();
const decision = strategy.admitOrEvict(currentPeers, newPeer, {
maxPeers: 4,
selfPeerId: "alice",
metricsFor: (id) => tracker.get(id),
});
if (decision.admit && decision.evict !== undefined) {
// PeerManager will evict `decision.evict` then admit `newPeer`.
}
```
## API Reference
### `new FullMeshStrategy()`
No constructor parameters.
### `selectConnections(current, newPeer, options)`
Returns `[newPeer]` while `current.length < options.maxPeers` and
`newPeer.id !== options.selfPeerId`. Empty otherwise.
### `admitOrEvict(current, newPeer, options)`
- Refuses duplicates (peer already in `current`).
- Below capacity → admit with no eviction.
- At capacity → find lowest-score existing peer; admit only if the
newcomer's score is strictly higher.
## Implementation Notes
- New peers default to score `1` (treated as healthy until a sample
exists), and existing peers without metrics default to `0.5`. This
biases admission toward fresh peers — desirable when scoring data
is sparse.
- The strategy never returns `evict` unless `admit === true`. The
`PeerManager` does not need to handle a "kick this peer out
anyway" decision.

View File

@@ -0,0 +1,138 @@
/**
* Tests for {@link FullMeshStrategy}.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PeerInfo } from "../../types/peer.types.js";
import type { PeerMetrics } from "../peer-bandwidth-tracker/peer-bandwidth-tracker.types.js";
import { FullMeshStrategy } from "./full-mesh.js";
function peer(id: string): PeerInfo {
return {
id,
name: id,
chunks: new Set(),
connectionState: "connected",
lastSeen: 0,
};
}
function metrics(peerId: string, score: number): PeerMetrics {
return {
peerId,
bytesIn: 0,
bytesOut: 0,
requestsIn: 0,
requestsOut: 0,
rttMs: null,
score,
windowStartedAt: 0,
};
}
const baseOpts = { maxPeers: 3, selfPeerId: "self" };
describe("FullMeshStrategy.selectConnections", () => {
it("connects to newcomers up to maxPeers", { timeout: 5000 }, () => {
const s = new FullMeshStrategy();
const result = s.selectConnections([peer("a")], peer("b"), baseOpts);
expect(result).toEqual([peer("b")]);
});
it("skips self", { timeout: 5000 }, () => {
const s = new FullMeshStrategy();
expect(s.selectConnections([], peer("self"), baseOpts)).toEqual([]);
});
it("returns empty at capacity", { timeout: 5000 }, () => {
const s = new FullMeshStrategy();
expect(
s.selectConnections(
[peer("a"), peer("b"), peer("c")],
peer("d"),
baseOpts
)
).toEqual([]);
});
});
describe("FullMeshStrategy.admitOrEvict", () => {
it("admits below capacity without eviction", { timeout: 5000 }, () => {
const s = new FullMeshStrategy();
const d = s.admitOrEvict([peer("a")], peer("b"), baseOpts);
expect(d).toEqual({ admit: true });
});
it("refuses duplicates", { timeout: 5000 }, () => {
const s = new FullMeshStrategy();
const d = s.admitOrEvict([peer("a")], peer("a"), baseOpts);
expect(d).toEqual({ admit: false });
});
it("evicts lowest score at capacity when newcomer is better", {
timeout: 5000,
}, () => {
const s = new FullMeshStrategy();
const m = new Map([
["a", metrics("a", 0.9)],
["b", metrics("b", 0.2)],
["c", metrics("c", 0.5)],
["new", metrics("new", 0.7)],
]);
const d = s.admitOrEvict(
[peer("a"), peer("b"), peer("c")],
peer("new"),
{ ...baseOpts, metricsFor: (id) => m.get(id) }
);
expect(d.admit).toBe(true);
expect(d.evict?.id).toBe("b");
});
it("refuses at capacity when newcomer is not strictly better", {
timeout: 5000,
}, () => {
const s = new FullMeshStrategy();
const m = new Map([
["a", metrics("a", 0.9)],
["b", metrics("b", 0.8)],
["c", metrics("c", 0.7)],
["new", metrics("new", 0.5)],
]);
const d = s.admitOrEvict(
[peer("a"), peer("b"), peer("c")],
peer("new"),
{ ...baseOpts, metricsFor: (id) => m.get(id) }
);
expect(d).toEqual({ admit: false });
});
it("admits and evicts default-score peer when no metrics hook", {
timeout: 5000,
}, () => {
const s = new FullMeshStrategy();
// newScore defaults to 1, existing all default to 0.5; eviction happens.
const d = s.admitOrEvict(
[peer("a"), peer("b"), peer("c")],
peer("new"),
baseOpts
);
expect(d.admit).toBe(true);
expect(d.evict?.id).toBe("a");
});
it("returns name", { timeout: 5000 }, () => {
expect(new FullMeshStrategy().name).toBe("full-mesh");
});
it("refuses when current peers list is empty but capacity is zero", {
timeout: 5000,
}, () => {
const s = new FullMeshStrategy();
const d = s.admitOrEvict([], peer("new"), {
maxPeers: 0,
selfPeerId: "self",
});
// current.length (0) is not < maxPeers (0); evict candidate is undefined → refuse.
expect(d).toEqual({ admit: false });
});
});

View File

@@ -0,0 +1,50 @@
/**
* Full-mesh topology — every peer connects to every other peer, up
* to `maxPeers`. When capacity is reached, the lowest-scoring
* existing peer is evicted to make room for the new arrival.
*/
import type { PeerInfo } from "../../types/peer.types.js";
import {
type AdmitDecision,
pickLowestScore,
type TopologySelectOptions,
type TopologyStrategy,
} from "./topology-strategy.js";
export class FullMeshStrategy implements TopologyStrategy {
public readonly name = "full-mesh" as const;
public selectConnections(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): readonly PeerInfo[] {
if (newPeer.id === options.selfPeerId) return [];
if (currentPeers.length >= options.maxPeers) return [];
return [newPeer];
}
public admitOrEvict(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): AdmitDecision {
if (currentPeers.some((p) => p.id === newPeer.id)) {
return { admit: false };
}
if (currentPeers.length < options.maxPeers) {
return { admit: true };
}
const newScore = options.metricsFor?.(newPeer.id)?.score ?? 1;
const evictCandidate = pickLowestScore(
currentPeers,
options.metricsFor
);
if (evictCandidate === undefined) return { admit: false };
const evictScore =
options.metricsFor?.(evictCandidate.id)?.score ?? 0.5;
if (newScore <= evictScore) return { admit: false };
return { admit: true, evict: evictCandidate };
}
}

View File

@@ -0,0 +1,72 @@
# HubAndSpokeStrategy
## Purpose
`HubAndSpokeStrategy` pins the mesh into a star topology: a single
designated hub accepts connections from every spoke, and spokes
never connect to each other. This is the canonical shape for managed
deployments where a server-side relay coordinates chunk delivery and
no direct peer-to-peer link is desired.
## Features
- Configured via `hubPeerId`; the strategy itself is symmetric across
hub and spoke nodes — each side derives its role from
`options.selfPeerId` at decision time.
- Spokes only emit one outbound dial: to the hub. Hub-side admits any
spoke up to `maxPeers`.
- No score-based eviction: at capacity, new peers are refused (the
hub configuration is expected to size `maxPeers` to the deployment).
## Usage: Basic Example
```ts
import { HubAndSpokeStrategy } from "@suaveplan/drive-webrtc";
const strategy = new HubAndSpokeStrategy({ hubPeerId: "hub-1" });
```
## Usage: Advanced Example
```ts
import { HubAndSpokeStrategy } from "@suaveplan/drive-webrtc";
const strategy = new HubAndSpokeStrategy({ hubPeerId: "hub-1" });
const spokeDecision = strategy.admitOrEvict(currentPeers, hubPeerInfo, {
maxPeers: 1,
selfPeerId: "spoke-a",
});
// spokeDecision.admit === true when the hub is the only inbound peer.
const hubDecision = strategy.admitOrEvict(currentPeers, spokeInfo, {
maxPeers: 256,
selfPeerId: "hub-1",
});
// hubDecision admits any spoke until capacity.
```
## API Reference
### `new HubAndSpokeStrategy(options)`
- `hubPeerId: string` — id of the hub.
### `selectConnections(current, newPeer, options)`
- Hub-side self: returns `[newPeer]` while below capacity.
- Spoke-side self: returns `[newPeer]` only when `newPeer.id ===
hubPeerId` and the hub is not already connected.
### `admitOrEvict(current, newPeer, options)`
- Hub-side: admit anyone below `maxPeers`; refuse otherwise.
- Spoke-side: admit only the hub.
## Implementation Notes
- Spoke nodes that misconfigure another spoke as the hub will simply
fail to admit anyone; the topology never crashes, only refuses.
- Reconfiguration (switching to a different hub) requires
constructing a new strategy and re-handshaking — the strategy
itself is immutable post-construction.

View File

@@ -0,0 +1,130 @@
/**
* Tests for {@link HubAndSpokeStrategy}.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PeerInfo } from "../../types/peer.types.js";
import { HubAndSpokeStrategy } from "./hub-and-spoke.js";
function peer(id: string): PeerInfo {
return {
id,
name: id,
chunks: new Set(),
connectionState: "connected",
lastSeen: 0,
};
}
describe("HubAndSpokeStrategy as hub", () => {
const opts = { maxPeers: 4, selfPeerId: "hub" };
it("connects to any newcomer", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(s.selectConnections([], peer("spoke1"), opts)).toEqual([
peer("spoke1"),
]);
});
it("admits below capacity", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(s.admitOrEvict([peer("a")], peer("b"), opts)).toEqual({
admit: true,
});
});
it("refuses at capacity (no eviction)", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
const fullOpts = { maxPeers: 2, selfPeerId: "hub" };
expect(
s.admitOrEvict([peer("a"), peer("b")], peer("c"), fullOpts)
).toEqual({ admit: false });
});
it("skips self in selectConnections", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(s.selectConnections([], peer("hub"), opts)).toEqual([]);
});
it("returns empty selectConnections at capacity", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(
s.selectConnections([peer("a"), peer("b")], peer("c"), {
maxPeers: 2,
selfPeerId: "hub",
})
).toEqual([]);
});
});
describe("HubAndSpokeStrategy as spoke", () => {
const spokeOpts = { maxPeers: 1, selfPeerId: "spoke" };
it("only connects to hub", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(s.selectConnections([], peer("hub"), spokeOpts)).toEqual([
peer("hub"),
]);
expect(s.selectConnections([], peer("other-spoke"), spokeOpts)).toEqual(
[]
);
});
it("does not reconnect to hub", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(
s.selectConnections([peer("hub")], peer("hub"), spokeOpts)
).toEqual([]);
});
it("admits hub below cap", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(s.admitOrEvict([], peer("hub"), spokeOpts)).toEqual({
admit: true,
});
});
it("refuses other spokes", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(s.admitOrEvict([], peer("other-spoke"), spokeOpts)).toEqual({
admit: false,
});
});
it("refuses hub at capacity", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(
s.admitOrEvict([peer("hub")], peer("hub-alt"), {
maxPeers: 1,
selfPeerId: "spoke",
})
).toEqual({ admit: false });
});
it("refuses duplicate admission", { timeout: 5000 }, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
expect(
s.admitOrEvict([peer("hub")], peer("hub"), {
maxPeers: 5,
selfPeerId: "spoke",
})
).toEqual({ admit: false });
});
it("refuses hub admission when spoke is at capacity with a non-hub peer", {
timeout: 5000,
}, () => {
const s = new HubAndSpokeStrategy({ hubPeerId: "hub" });
const d = s.admitOrEvict([peer("stale")], peer("hub"), {
maxPeers: 1,
selfPeerId: "spoke",
});
expect(d).toEqual({ admit: false });
});
it("returns name", { timeout: 5000 }, () => {
expect(new HubAndSpokeStrategy({ hubPeerId: "hub" }).name).toBe(
"hub-and-spoke"
);
});
});

View File

@@ -0,0 +1,63 @@
/**
* Hub-and-spoke topology — a single configured hub peer accepts
* connections from every spoke; spokes never connect to each other.
* Useful for centralised relay deployments and small-team meshes
* where a server pins the topology.
*/
import type { PeerInfo } from "../../types/peer.types.js";
import type {
AdmitDecision,
TopologySelectOptions,
TopologyStrategy,
} from "./topology-strategy.js";
export interface HubAndSpokeStrategyOptions {
/** Peer id of the hub. */
readonly hubPeerId: string;
}
export class HubAndSpokeStrategy implements TopologyStrategy {
public readonly name = "hub-and-spoke" as const;
private readonly hubPeerId: string;
public constructor(opts: HubAndSpokeStrategyOptions) {
this.hubPeerId = opts.hubPeerId;
}
public selectConnections(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): readonly PeerInfo[] {
if (newPeer.id === options.selfPeerId) return [];
const selfIsHub = options.selfPeerId === this.hubPeerId;
if (selfIsHub) {
if (currentPeers.length >= options.maxPeers) return [];
return [newPeer];
}
if (newPeer.id !== this.hubPeerId) return [];
if (currentPeers.some((p) => p.id === this.hubPeerId)) return [];
return [newPeer];
}
public admitOrEvict(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): AdmitDecision {
if (currentPeers.some((p) => p.id === newPeer.id)) {
return { admit: false };
}
const selfIsHub = options.selfPeerId === this.hubPeerId;
if (selfIsHub) {
if (currentPeers.length < options.maxPeers) {
return { admit: true };
}
return { admit: false };
}
if (newPeer.id !== this.hubPeerId) return { admit: false };
if (currentPeers.length < options.maxPeers) return { admit: true };
return { admit: false };
}
}

View File

@@ -0,0 +1,16 @@
export { FullMeshStrategy } from "./full-mesh.js";
export {
HubAndSpokeStrategy,
type HubAndSpokeStrategyOptions,
} from "./hub-and-spoke.js";
export {
SuperPeerStrategy,
type SuperPeerStrategyOptions,
} from "./super-peer.js";
export {
type AdmitDecision,
pickLowestScore,
type TopologySelectOptions,
type TopologyStrategy,
type TopologyStrategyName,
} from "./topology-strategy.js";

View File

@@ -0,0 +1,78 @@
# SuperPeerStrategy
## Purpose
The super-peer topology lets well-resourced nodes act as relays for
the rest of the mesh. Super peers form a full mesh among themselves
and admit any peer that contacts them; ordinary peers connect only
to a bounded number of super peers (default 3) and never to each
other. This dramatically reduces connection count for large meshes
while preserving multi-hop reachability between every pair of peers.
## Features
- `selfIsSuperPeer` flag switches the admission policy between
"accept anyone" and "connect outbound only to super peers".
- `targetSuperPeers` caps a non-super peer's outbound super-peer
connections (default 3).
- Super peers prefer evicting non-super peers when at capacity so
the super-peer backbone stays healthy.
## Usage: Basic Example
```ts
import { SuperPeerStrategy } from "@suaveplan/drive-webrtc";
const strategy = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: new Set(["sp-1", "sp-2", "sp-3"]),
});
```
## Usage: Advanced Example
```ts
import { SuperPeerStrategy } from "@suaveplan/drive-webrtc";
const strategy = new SuperPeerStrategy({
selfIsSuperPeer: true,
superPeerIds: new Set(["sp-1", "sp-2"]),
targetSuperPeers: 2,
});
const decision = strategy.admitOrEvict(currentPeers, newPeer, {
maxPeers: 32,
selfPeerId: "sp-1",
metricsFor: (id) => tracker.get(id),
});
```
## API Reference
### `new SuperPeerStrategy(options)`
- `selfIsSuperPeer: boolean`.
- `superPeerIds: ReadonlySet<string>`.
- `targetSuperPeers?: number` (default `3`).
### `selectConnections(current, newPeer, options)`
- Super peers: connect to every newcomer up to `maxPeers`.
- Non-super peers: connect only to peers in `superPeerIds`, up to
`targetSuperPeers` simultaneously.
### `admitOrEvict(current, newPeer, options)`
- Super peer self: admit any newcomer; evict lowest-scoring
non-super peer when at capacity.
- Non-super peer self: admit only super peers below
`targetSuperPeers`.
## Implementation Notes
- The strategy treats super-peer identity as static configuration.
Promoting a peer to super status during runtime requires rebuilding
the strategy with an updated `superPeerIds` set.
- Eviction only targets non-super peers when self is super; the
rationale is that super-peer-to-super-peer links carry the most
routing traffic and must remain stable.

View File

@@ -0,0 +1,235 @@
/**
* Tests for {@link SuperPeerStrategy}.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PeerInfo } from "../../types/peer.types.js";
import type { PeerMetrics } from "../peer-bandwidth-tracker/peer-bandwidth-tracker.types.js";
import { SuperPeerStrategy } from "./super-peer.js";
function peer(id: string): PeerInfo {
return {
id,
name: id,
chunks: new Set(),
connectionState: "connected",
lastSeen: 0,
};
}
function metrics(peerId: string, score: number): PeerMetrics {
return {
peerId,
bytesIn: 0,
bytesOut: 0,
requestsIn: 0,
requestsOut: 0,
rttMs: null,
score,
windowStartedAt: 0,
};
}
const baseOpts = { maxPeers: 8, selfPeerId: "self" };
describe("SuperPeerStrategy as super-peer self", () => {
it("connects to any newcomer", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: true,
superPeerIds: new Set(["self", "sp2"]),
});
expect(s.selectConnections([], peer("ordinary"), baseOpts)).toEqual([
peer("ordinary"),
]);
});
it("admits below capacity", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: true,
superPeerIds: new Set(["self"]),
});
const d = s.admitOrEvict([peer("a")], peer("b"), baseOpts);
expect(d).toEqual({ admit: true });
});
it("evicts lowest non-super peer at capacity", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: true,
superPeerIds: new Set(["self", "sp2"]),
});
const m = new Map([
["sp2", metrics("sp2", 0.99)],
["a", metrics("a", 0.4)],
["b", metrics("b", 0.2)],
]);
const d = s.admitOrEvict(
[peer("sp2"), peer("a"), peer("b")],
peer("c"),
{
maxPeers: 3,
selfPeerId: "self",
metricsFor: (id) => m.get(id),
}
);
expect(d.admit).toBe(true);
expect(d.evict?.id).toBe("b");
});
it("refuses when all current peers are super peers and at capacity", {
timeout: 5000,
}, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: true,
superPeerIds: new Set(["self", "sp2", "sp3", "sp4"]),
});
const d = s.admitOrEvict(
[peer("sp2"), peer("sp3"), peer("sp4")],
peer("ordinary"),
{ maxPeers: 3, selfPeerId: "self" }
);
expect(d).toEqual({ admit: false });
});
it("returns empty selectConnections at capacity", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: true,
superPeerIds: new Set(["self"]),
});
expect(
s.selectConnections([peer("a"), peer("b"), peer("c")], peer("d"), {
maxPeers: 3,
selfPeerId: "self",
})
).toEqual([]);
});
});
describe("SuperPeerStrategy as ordinary self", () => {
const superSet = new Set(["sp1", "sp2", "sp3", "sp4"]);
it("connects only to super peers below targetSuperPeers", {
timeout: 5000,
}, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
targetSuperPeers: 2,
});
expect(s.selectConnections([], peer("sp1"), baseOpts)).toEqual([
peer("sp1"),
]);
expect(s.selectConnections([], peer("ordinary"), baseOpts)).toEqual([]);
});
it("stops once targetSuperPeers reached", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
targetSuperPeers: 2,
});
expect(
s.selectConnections(
[peer("sp1"), peer("sp2")],
peer("sp3"),
baseOpts
)
).toEqual([]);
});
it("admits super peers below cap, refuses ordinary peers", {
timeout: 5000,
}, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
});
expect(s.admitOrEvict([], peer("sp1"), baseOpts)).toEqual({
admit: true,
});
expect(s.admitOrEvict([], peer("ordinary"), baseOpts)).toEqual({
admit: false,
});
});
it("refuses duplicate admissions", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
});
const d = s.admitOrEvict([peer("sp1")], peer("sp1"), baseOpts);
expect(d).toEqual({ admit: false });
});
it("refuses once targetSuperPeers met", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
targetSuperPeers: 1,
});
const d = s.admitOrEvict([peer("sp1")], peer("sp2"), baseOpts);
expect(d).toEqual({ admit: false });
});
it("respects maxPeers cap", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
targetSuperPeers: 5,
});
const d = s.admitOrEvict(
[peer("sp1"), peer("sp2"), peer("sp3")],
peer("sp4"),
{ maxPeers: 3, selfPeerId: "self" }
);
expect(d).toEqual({ admit: false });
});
it("skips self in selectConnections", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: new Set(["self"]),
});
expect(s.selectConnections([], peer("self"), baseOpts)).toEqual([]);
});
it("selectConnections returns empty when at maxPeers even below target", {
timeout: 5000,
}, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
targetSuperPeers: 5,
});
// 2 non-super peers fill our cap of 2; a super peer arrives.
expect(
s.selectConnections([peer("ord1"), peer("ord2")], peer("sp1"), {
maxPeers: 2,
selfPeerId: "self",
})
).toEqual([]);
});
it("default targetSuperPeers is 3", { timeout: 5000 }, () => {
const s = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: superSet,
});
const ordinary = { maxPeers: 8, selfPeerId: "self" };
// 3 already → 4th refused.
const d = s.admitOrEvict(
[peer("sp1"), peer("sp2"), peer("sp3")],
peer("sp4"),
ordinary
);
expect(d).toEqual({ admit: false });
});
it("returns name", { timeout: 5000 }, () => {
expect(
new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: new Set(),
}).name
).toBe("super-peer");
});
});

View File

@@ -0,0 +1,89 @@
/**
* Super-peer topology — well-resourced "super peers" form a full
* mesh among themselves and accept connections from any peer; ordinary
* peers connect to a bounded number of super peers (default 3).
*/
import type { PeerInfo } from "../../types/peer.types.js";
import {
type AdmitDecision,
pickLowestScore,
type TopologySelectOptions,
type TopologyStrategy,
} from "./topology-strategy.js";
export interface SuperPeerStrategyOptions {
/** Whether this node is itself a super peer. */
readonly selfIsSuperPeer: boolean;
/** Set of peer ids known to be super peers. */
readonly superPeerIds: ReadonlySet<string>;
/** Cap on outbound super-peer connections for non-super peers. */
readonly targetSuperPeers?: number;
}
const DEFAULT_TARGET_SUPER_PEERS = 3;
export class SuperPeerStrategy implements TopologyStrategy {
public readonly name = "super-peer" as const;
private readonly selfIsSuperPeer: boolean;
private readonly superPeerIds: ReadonlySet<string>;
private readonly targetSuperPeers: number;
public constructor(opts: SuperPeerStrategyOptions) {
this.selfIsSuperPeer = opts.selfIsSuperPeer;
this.superPeerIds = opts.superPeerIds;
this.targetSuperPeers =
opts.targetSuperPeers ?? DEFAULT_TARGET_SUPER_PEERS;
}
public selectConnections(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): readonly PeerInfo[] {
if (newPeer.id === options.selfPeerId) return [];
if (this.selfIsSuperPeer) {
if (currentPeers.length >= options.maxPeers) return [];
return [newPeer];
}
if (!this.superPeerIds.has(newPeer.id)) return [];
const connectedSuperPeers = currentPeers.filter((p) =>
this.superPeerIds.has(p.id)
).length;
if (connectedSuperPeers >= this.targetSuperPeers) return [];
if (currentPeers.length >= options.maxPeers) return [];
return [newPeer];
}
public admitOrEvict(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): AdmitDecision {
if (currentPeers.some((p) => p.id === newPeer.id)) {
return { admit: false };
}
if (this.selfIsSuperPeer) {
if (currentPeers.length < options.maxPeers) {
return { admit: true };
}
const evict = pickLowestScore(
currentPeers.filter((p) => !this.superPeerIds.has(p.id)),
options.metricsFor
);
if (evict === undefined) return { admit: false };
return { admit: true, evict };
}
if (!this.superPeerIds.has(newPeer.id)) return { admit: false };
const connectedSuperPeers = currentPeers.filter((p) =>
this.superPeerIds.has(p.id)
);
if (
connectedSuperPeers.length < this.targetSuperPeers &&
currentPeers.length < options.maxPeers
) {
return { admit: true };
}
return { admit: false };
}
}

View File

@@ -0,0 +1,94 @@
# TopologyStrategy
## Purpose
`TopologyStrategy` is the pure-function contract `PeerManager`
consults when a new peer is announced. A strategy decides which of
the currently-known peers to attempt outbound connections to, and
whether to admit (or evict-and-then-admit) a new peer when the local
peer set has reached capacity. Strategies never mutate state; the
caller (`PeerManager`) performs admission, eviction, and event
emission. This split keeps the topology layer trivially unit-testable
and lets a single `PeerManager` swap policies at runtime.
## Features
- Three built-in strategies: `FullMeshStrategy`,
`SuperPeerStrategy`, `HubAndSpokeStrategy`.
- Score-aware eviction via the optional `metricsFor` hook from
`PeerBandwidthTracker`.
- Pure, deterministic, no `Date`/`Math.random` calls — perfect for
property tests.
- `pickLowestScore` helper exported for custom strategies.
## Usage: Basic Example
```ts
import { FullMeshStrategy } from "@suaveplan/drive-webrtc";
const strategy = new FullMeshStrategy();
const decision = strategy.admitOrEvict([], newPeer, {
maxPeers: 10,
selfPeerId: "alice",
});
if (decision.admit) {
// accept
}
```
## Usage: Advanced Example
```ts
import {
PeerBandwidthTracker,
SuperPeerStrategy,
} from "@suaveplan/drive-webrtc";
const tracker = new PeerBandwidthTracker();
const strategy = new SuperPeerStrategy({
selfIsSuperPeer: false,
superPeerIds: new Set(["hub-a", "hub-b", "hub-c"]),
targetSuperPeers: 2,
});
const decision = strategy.admitOrEvict(currentPeers, newPeer, {
maxPeers: 8,
selfPeerId: "alice",
metricsFor: (id) => tracker.get(id),
});
```
## API Reference
### `interface TopologyStrategy`
- `name: "full-mesh" | "super-peer" | "hub-and-spoke"`.
- `selectConnections(current, newPeer, options): readonly PeerInfo[]`
— peers to attempt outbound connections to (often `[newPeer]`).
- `admitOrEvict(current, newPeer, options): AdmitDecision` — admit
decision with optional eviction target.
### `interface TopologySelectOptions`
- `maxPeers: number`.
- `selfPeerId: string`.
- `metricsFor?: (peerId) => PeerMetrics | undefined`.
### `interface AdmitDecision`
- `admit: boolean`.
- `evict?: PeerInfo` — peer the caller must evict before admitting.
### `pickLowestScore(peers, metricsFor)`
Returns the peer with the lowest score (or `undefined` when the set
is empty). Peers with no metrics are treated as score `0.5`.
## Implementation Notes
- Strategies never throw — they return `{ admit: false }` to refuse.
- `selectConnections` is the "outbound dial" hook; `admitOrEvict` is
the "inbound admission" hook. A symmetric mesh calls both for the
same peer; an asymmetric topology (hub-and-spoke) returns `[]` from
one of the two.
- `pickLowestScore` is exposed so callers can build custom strategies
layered on the same eviction policy.

View File

@@ -0,0 +1,66 @@
/**
* Tests for {@link pickLowestScore} and shared topology helpers.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PeerInfo } from "../../types/peer.types.js";
import type { PeerMetrics } from "../peer-bandwidth-tracker/peer-bandwidth-tracker.types.js";
import { pickLowestScore } from "./topology-strategy.js";
function peer(id: string): PeerInfo {
return {
id,
name: id,
chunks: new Set(),
connectionState: "connected",
lastSeen: 0,
};
}
function metrics(peerId: string, score: number): PeerMetrics {
return {
peerId,
bytesIn: 0,
bytesOut: 0,
requestsIn: 0,
requestsOut: 0,
rttMs: null,
score,
windowStartedAt: 0,
};
}
describe("pickLowestScore", () => {
it("returns undefined for empty set", { timeout: 5000 }, () => {
expect(pickLowestScore([], undefined)).toBeUndefined();
});
it("picks lowest scored peer", { timeout: 5000 }, () => {
const a = peer("a");
const b = peer("b");
const c = peer("c");
const m = new Map([
["a", metrics("a", 0.9)],
["b", metrics("b", 0.2)],
["c", metrics("c", 0.6)],
]);
const lowest = pickLowestScore([a, b, c], (id) => m.get(id));
expect(lowest?.id).toBe("b");
});
it("treats peers with no metrics as 0.5", { timeout: 5000 }, () => {
const a = peer("a");
const b = peer("b");
const m = new Map([["a", metrics("a", 0.9)]]);
const lowest = pickLowestScore([a, b], (id) => m.get(id));
expect(lowest?.id).toBe("b");
});
it("uses 0.5 fallback when metricsFor is undefined", {
timeout: 5000,
}, () => {
const a = peer("a");
const lowest = pickLowestScore([a], undefined);
expect(lowest?.id).toBe("a");
});
});

View File

@@ -0,0 +1,77 @@
/**
* Topology strategy interface for {@link
* import("../peer-manager/peer-manager.js").PeerManager}.
*
* Strategies decide (a) which peers to attempt connections to when a
* new peer is announced, and (b) whether a new peer should be
* admitted (optionally evicting an existing peer) when capacity has
* been reached.
*/
import type { PeerInfo } from "../../types/peer.types.js";
import type { PeerMetrics } from "../peer-bandwidth-tracker/peer-bandwidth-tracker.types.js";
/** Selection name used to discriminate strategies at runtime. */
export type TopologyStrategyName = "full-mesh" | "super-peer" | "hub-and-spoke";
/**
* Options threaded into every strategy call. `metricsFor` is the
* preferred hook for `PeerBandwidthTracker.get` so strategies can
* make score-aware eviction decisions without a hard dep on the
* tracker class.
*/
export interface TopologySelectOptions {
readonly maxPeers: number;
readonly selfPeerId: string;
readonly metricsFor?: (peerId: string) => PeerMetrics | undefined;
}
/**
* Admit-or-evict decision returned by
* {@link TopologyStrategy.admitOrEvict}. When `admit` is `true` and
* `evict` is set, the caller MUST evict the listed peer first.
*/
export interface AdmitDecision {
readonly admit: boolean;
readonly evict?: PeerInfo;
}
/**
* Strategy contract. Implementations are pure — they consult the
* current peer set + bandwidth metrics and return decisions. The
* `PeerManager` performs the actual mutation.
*/
export interface TopologyStrategy {
readonly name: TopologyStrategyName;
selectConnections(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): readonly PeerInfo[];
admitOrEvict(
currentPeers: readonly PeerInfo[],
newPeer: PeerInfo,
options: TopologySelectOptions
): AdmitDecision;
}
/**
* Score helper used by strategies that need to find the
* lowest-quality peer in the current set (default score `0.5` when
* the metrics hook is absent or returns `undefined`).
*/
export function pickLowestScore(
peers: readonly PeerInfo[],
metricsFor: ((peerId: string) => PeerMetrics | undefined) | undefined
): PeerInfo | undefined {
let lowest: PeerInfo | undefined;
let lowestScore = Number.POSITIVE_INFINITY;
for (const peer of peers) {
const score = metricsFor?.(peer.id)?.score ?? 0.5;
if (score < lowestScore) {
lowestScore = score;
lowest = peer;
}
}
return lowest;
}

View File

@@ -0,0 +1,5 @@
export type {
LocalChunkProvider,
WebRTCDrivePluginOptions,
} from "./webrtc-plugin.js";
export { WebRTCDrivePlugin } from "./webrtc-plugin.js";

View File

@@ -0,0 +1,185 @@
# `WebRTCDrivePlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebRTCDrivePlugin` is the top-level orchestrator that wires every
`@suaveplan/drive-webrtc` building block — signaling client, ICE-aware
WebRTC client, peer manager, bandwidth tracker, data-channel manager,
chunk-integrity manager, chunk-discovery manager, and chunk source —
behind one cohesive `start()` / `stop()` / `stats()` surface. It is the
package's primary public entry point and the only class consumers must
instantiate to participate in a P2P mesh.
The plugin reacts to signalling traffic, drives offer/answer exchange
through the `WebRTCClient`, opens data channels when ICE reaches
`connected`, evicts peers when ICE reports `failed`, and forwards
locally available chunks to peers that ask for them via an injectable
`localChunkProvider` callback.
## Features
- Idempotent `start()` / `stop()` lifecycle.
- Real Ed25519 signature verification through `ChunkIntegrityManager`.
- Automatic peer admission + eviction on ICE state transitions.
- Pluggable topology strategy (`FullMesh` default,
`SuperPeerStrategy`, `HubAndSpokeStrategy` available).
- OTel surface — `drive.webrtc.peers_connected_total`,
`drive.webrtc.chunks_served_total{outcome}`, `drive.webrtc.peer_rtt_ms`.
- AbortSignal threaded into `start()` so cooperative connect cancellation
works through every layer.
- Optional `localChunkProvider` callback for serving inbound
`request-chunk` data-channel frames.
- Defaults are browser-tier (`globalThis.WebSocket`,
`globalThis.RTCPeerConnection`); tests inject structural doubles via
`webSocketCtor` / `rtcPeerConnectionCtor`.
## Usage: Basic Example
```ts
import { WebRTCDrivePlugin } from "@suaveplan/drive-webrtc";
const plugin = new WebRTCDrivePlugin({
peerId: crypto.randomUUID(),
config: {
signaling: { url: "wss://signal.example.com/p2p" },
},
signalingUrl: "wss://signal.example.com/p2p",
});
await plugin.start();
// Drive integration: hand `plugin.chunkSource` to the Drive instance so
// it can fetch chunks over the P2P mesh.
const bytes = await plugin.chunkSource.fetchChunk("chunk:42");
await plugin.stop();
```
## Usage: Advanced Example
```ts
import {
HubAndSpokeStrategy,
WebRTCDrivePlugin,
} from "@suaveplan/drive-webrtc";
const plugin = new WebRTCDrivePlugin({
peerId: "node-a",
config: {
signaling: {
url: "wss://signal.example.com/p2p",
reconnect: true,
reconnectDelay: 2_000,
},
p2p: {
priority: 50,
iceServers: [
{ urls: ["stun:stun.example.com:3478"] },
{
urls: ["turn:turn.example.com:3478?transport=udp"],
username: "user",
credential: "secret",
},
],
maxConcurrentDownloads: 8,
},
},
signalingUrl: "wss://signal.example.com/p2p?peerId=node-a",
topology: new HubAndSpokeStrategy({ hubPeerIds: ["hub-a"] }),
maxPeers: 32,
defaultPublicKey: publisherPubKey,
localChunkProvider: async (chunkId) => {
return cache.get(chunkId) ?? null;
},
});
plugin.on("webrtc:connected", ({ peerId }) => {
log.info({ peerId }, "p2p peer is up");
});
await plugin.start(abortController.signal);
const bytes = await plugin.chunkSource.fetchChunk("chunk:42", {
signal: abortController.signal,
});
const stats = plugin.stats();
metrics.gauge("p2p.active_peers", stats.activePeers);
```
## API Reference
### `new WebRTCDrivePlugin(options: WebRTCDrivePluginOptions)`
Constructs the orchestrator and every collaborator. Does not initiate
any network traffic until `start()` is called.
### `WebRTCDrivePluginOptions`
- `peerId: string` — local peer id (UUID v4 recommended).
- `config: WebRTCPluginConfig` — validated by `resolveConfig`.
- `signalingUrl: string` — WebSocket URL for the signalling server.
- `webSocketCtor?: typeof WebSocket` — optional WebSocket constructor
override. Defaults to `globalThis.WebSocket`.
- `rtcPeerConnectionCtor?: RTCPeerConnectionCtor` — optional override.
- `topology?: TopologyStrategy` — defaults to `FullMeshStrategy`.
- `maxPeers?: number` — forwarded to `PeerManager`.
- `defaultPublicKey?: Uint8Array` — Ed25519 public key for chunk
signature verification.
- `clock?: { now(): number }` — test-injectable wall clock.
- `localChunkProvider?: LocalChunkProvider` — callback that returns the
chunk bytes a local node owns, or `null`.
- `defaultLabel?: string` — data channel label. Defaults to
`"drive-chunks"`.
### `plugin.start(signal?: AbortSignal): Promise<void>`
Connect to signalling, send `register`, install all event wiring.
Idempotent: a second call is a no-op while the plugin is running.
Throws `DriveWebRtcError(SIGNALING_UNREACHABLE)` if invoked after
`stop()`.
### `plugin.stop(reason?: string): Promise<void>`
Tear everything down: unsubscribe handlers, close the discovery
manager, close all data channels, close every peer connection, close
the signalling socket. Idempotent and safe to call before `start()`.
### `plugin.stats(): WebRTCStats`
Snapshot of plugin-level statistics. Fills `activePeers` (peers in
`connected` state) and `totalPeers`; remaining counters stay at the
`createDefaultStats()` baseline pending the broader stats integration.
### Public collaborators
`client`, `signaling`, `peers`, `bandwidth`, `dataChannels`,
`integrity`, `discovery`, `chunkSource`. Each is the real instance and
each surfaces its own event emitter — consumers can subscribe directly
when they need finer-grained events than `WebRTCEventMap`.
## Implementation Notes
- The plugin extends `TypedEventEmitter<WebRTCEventMap>` so it can
surface a stable public event surface independent of the
collaborator-specific event maps below it.
- Drive contract deviation: the upstream `@suaveplan/drive` exposes
`DrivePlugin<THandle>` with `install(drive)` / `dispose?()`. The
spec for this orchestrator requires a richer `start()` / `stop()` /
`stats()` API; the two surfaces are not directly compatible, so this
class implements the spec surface and `name: "drive-webrtc"` rather
than the structural `DrivePlugin` shape. Drive integration can
trivially wrap an instance in `{ name, install(){...}, dispose() }`
when needed.
- ICE state mapping collapses `connected` and `completed` onto the
4-value `PeerConnectionState`; all other states route to
`connecting`, `disconnected`, or `failed`.
- Local-chunk serving uses a minimal JSON envelope
(`{type:"chunk-data", payload:{requestId, chunkId, data}}`) compatible
with the discovery manager's wire format. Sub-chunked uploads remain
the responsibility of the discovery / sub-chunk subsystem.
- Every cross-component event subscription is recorded in a single
`unsubscribers` list so `stop()` can flush them in O(n).

View File

@@ -0,0 +1,585 @@
/**
* WebRTCDrivePlugin — top-level orchestrator wiring every drive-webrtc
* component together (signaling, client, peer manager, bandwidth tracker,
* data-channel manager, integrity manager, discovery manager, chunk
* source) behind a single `start`/`stop`/`stats` surface.
*
* See `webrtc-plugin.md` for prose docs.
*/
import { metrics, trace } from "@opentelemetry/api";
import { WebRTCChunkSource } from "../../chunk-source/chunk-source/index.js";
import type { RTCPeerConnectionCtor } from "../../client/client/index.js";
import { WebRTCClient } from "../../client/client/index.js";
import { DataChannelManager } from "../../data-channel/data-channel-manager/index.js";
import { ChunkDiscoveryManager } from "../../discovery/chunk-discovery-manager/index.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
import { ChunkIntegrityManager } from "../../integrity/chunk-integrity/index.js";
import { PeerBandwidthTracker } from "../../peer-manager/peer-bandwidth-tracker/index.js";
import { PeerManager } from "../../peer-manager/peer-manager/index.js";
import type { TopologyStrategy } from "../../peer-manager/topology/index.js";
import { SignalingClient } from "../../signaling/signaling-client/index.js";
import type { WebRTCPluginConfig } from "../../types/config.types.js";
import type { WebRTCEventMap } from "../../types/events.types.js";
import type { SignalingMessage } from "../../types/messages.types.js";
import {
createDefaultStats,
type WebRTCStats,
} from "../../types/stats.types.js";
const TRACER_NAME = "@suaveplan/drive-webrtc";
const tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(TRACER_NAME);
const peersConnectedCounter = meter.createCounter(
"drive.webrtc.peers_connected_total",
{ description: "Peers that have transitioned to ICE-connected state" }
);
const chunksServedCounter = meter.createCounter(
"drive.webrtc.chunks_served_total",
{ description: "Local-fulfilled chunk-request outcomes" }
);
const peerRttHistogram = meter.createHistogram("drive.webrtc.peer_rtt_ms", {
description: "Round-trip latency to a peer (ms)",
});
/**
* Caller-supplied lookup that returns chunk bytes the local node owns,
* or `null` if the chunk is not available. Invoked when a peer sends a
* `request-chunk` data-channel message. When omitted, every incoming
* request is rejected as unavailable.
*/
export type LocalChunkProvider = (
chunkId: string
) => Promise<Uint8Array | null> | Uint8Array | null;
/** Constructor options for {@link WebRTCDrivePlugin}. */
export interface WebRTCDrivePluginOptions {
/** Local peer identifier (UUID v4 recommended). */
readonly peerId: string;
/** WebRTC plugin config (validated by `resolveConfig`). */
readonly config: WebRTCPluginConfig;
/** Signaling URL (`wss://...`). */
readonly signalingUrl: string;
/** Optional WebSocket ctor injection. Defaults to `globalThis.WebSocket`. */
readonly webSocketCtor?: typeof WebSocket;
/** Optional RTCPeerConnection ctor injection. */
readonly rtcPeerConnectionCtor?: RTCPeerConnectionCtor;
/** Optional topology strategy. Defaults to {@link import("../../peer-manager/topology/index.js").FullMeshStrategy}. */
readonly topology?: TopologyStrategy;
/** Optional peer cap forwarded to the {@link PeerManager}. */
readonly maxPeers?: number;
/** Optional Ed25519 public key for chunk-signature verification. */
readonly defaultPublicKey?: Uint8Array;
/** Optional clock injection (test determinism). */
readonly clock?: { now: () => number };
/** Local chunk provider for serving inbound `request-chunk` messages. */
readonly localChunkProvider?: LocalChunkProvider;
/** Default data-channel label (mirrors {@link DataChannelManager}). */
readonly defaultLabel?: string;
}
interface RequestChunkPayload {
readonly type: "request-chunk";
readonly requestId: string;
readonly fileId: string;
readonly chunkId: string;
}
const ICE_STATE_CONNECTED: RTCIceConnectionState = "connected";
const ICE_STATE_FAILED: RTCIceConnectionState = "failed";
/**
* Top-level mesh orchestrator. Construct with caller-controlled peer id,
* plugin config, and signaling URL; call `start()` to register against
* the signaling server and begin accepting / initiating peer
* connections; call `stop()` to tear everything down idempotently.
*/
export class WebRTCDrivePlugin extends TypedEventEmitter<WebRTCEventMap> {
/** Drive plugin name literal — required by the Drive plugin contract. */
public readonly name = "drive-webrtc" as const;
public readonly client: WebRTCClient;
public readonly signaling: SignalingClient;
public readonly peers: PeerManager;
public readonly bandwidth: PeerBandwidthTracker;
public readonly dataChannels: DataChannelManager;
public readonly integrity: ChunkIntegrityManager;
public readonly discovery: ChunkDiscoveryManager;
public readonly chunkSource: WebRTCChunkSource;
private readonly peerId: string;
private readonly defaultLabel: string;
private readonly localChunkProvider: LocalChunkProvider | undefined;
private readonly defaultPublicKey: Uint8Array | undefined;
private readonly clock: { now: () => number };
private readonly unsubscribers: Array<() => void> = [];
private started = false;
private stopped = false;
public constructor(options: WebRTCDrivePluginOptions) {
super();
this.peerId = options.peerId;
this.clock = options.clock ?? { now: () => Date.now() };
this.defaultLabel = options.defaultLabel ?? "drive-chunks";
this.localChunkProvider = options.localChunkProvider;
this.defaultPublicKey = options.defaultPublicKey;
const clientOptions: ConstructorParameters<typeof WebRTCClient>[0] = {
peerId: options.peerId,
config: options.config,
clock: this.clock,
};
if (options.rtcPeerConnectionCtor !== undefined) {
(
clientOptions as {
rtcPeerConnectionCtor: RTCPeerConnectionCtor;
}
).rtcPeerConnectionCtor = options.rtcPeerConnectionCtor;
}
this.client = new WebRTCClient(clientOptions);
const signalingOptions: ConstructorParameters<
typeof SignalingClient
>[0] = {
url: options.signalingUrl,
peerId: options.peerId,
clock: this.clock,
};
if (options.webSocketCtor !== undefined) {
(
signalingOptions as { webSocketCtor: typeof WebSocket }
).webSocketCtor = options.webSocketCtor;
}
this.signaling = new SignalingClient(signalingOptions);
this.bandwidth = new PeerBandwidthTracker({ clock: this.clock });
const peerManagerOptions: ConstructorParameters<typeof PeerManager>[0] =
{
selfPeerId: options.peerId,
bandwidth: this.bandwidth,
clock: this.clock,
};
if (options.topology !== undefined) {
(peerManagerOptions as { topology: TopologyStrategy }).topology =
options.topology;
}
if (options.maxPeers !== undefined) {
(peerManagerOptions as { maxPeers: number }).maxPeers =
options.maxPeers;
}
this.peers = new PeerManager(peerManagerOptions);
this.dataChannels = new DataChannelManager({
client: this.client,
defaultLabel: this.defaultLabel,
});
this.integrity = new ChunkIntegrityManager({
clock: () => this.clock.now(),
});
this.discovery = new ChunkDiscoveryManager({
peers: this.peers,
bandwidth: this.bandwidth,
dataChannels: this.dataChannels,
integrity: this.integrity,
defaultLabel: this.defaultLabel,
clock: this.clock,
});
const chunkSourceOptions: ConstructorParameters<
typeof WebRTCChunkSource
>[0] = {
discovery: this.discovery,
id: `${options.peerId}-webrtc`,
};
if (this.defaultPublicKey !== undefined) {
(
chunkSourceOptions as { defaultPublicKey: Uint8Array }
).defaultPublicKey = this.defaultPublicKey;
}
this.chunkSource = new WebRTCChunkSource(chunkSourceOptions);
}
/**
* Connect to the signaling server, register, and begin accepting /
* initiating peer connections. Idempotent: calling `start()` after a
* successful start resolves immediately.
*/
public async start(signal?: AbortSignal): Promise<void> {
if (this.stopped) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"plugin has been stopped"
);
}
if (this.started) return;
const span = tracer.startSpan("plugin.start", {
attributes: { "drive.webrtc.peer_id": this.peerId },
});
try {
this.wireSignalingHandlers();
this.wireClientHandlers();
this.wireDataChannelHandlers();
await this.signaling.connect(signal);
this.signaling.send({
type: "register",
payload: {
peerId: this.peerId,
peerName: this.peerId,
chunks: [],
},
});
this.started = true;
} finally {
span.end();
}
}
/**
* Close every owned resource. Safe to call multiple times; safe to
* call before `start()`.
*/
public async stop(reason = "plugin-stop"): Promise<void> {
if (this.stopped) return;
this.stopped = true;
this.started = false;
// Close transports BEFORE unsubscribing so close-events emitted
// synchronously during teardown still surface on the public
// event surface.
this.discovery.closeAll(reason);
this.dataChannels.closeAll(reason);
this.client.closeAll(reason);
this.signaling.close(reason);
for (const off of this.unsubscribers.splice(0)) {
off();
}
// `await` ensures the public contract surfaces a Promise even if
// every underlying close is synchronous — callers depend on
// `await plugin.stop()` ordering relative to subsequent teardown.
await Promise.resolve();
}
/**
* Snapshot of plugin-level statistics. Currently fills `activePeers`
* and `totalPeers` from the {@link PeerManager}; remaining counters
* stay at the {@link createDefaultStats} baseline and are reserved
* for the {@link WebRTCEventMap} `stats:updated` integration.
*/
public stats(): WebRTCStats {
const base = createDefaultStats();
const peers = this.peers.list();
base.activePeers = peers.filter(
(p) => p.connectionState === "connected"
).length;
base.totalPeers = peers.length;
return base;
}
private wireSignalingHandlers(): void {
this.unsubscribers.push(
this.signaling.on("open", () => {
this.emit("signaling:connected", { url: this.signaling.url });
}),
this.signaling.on("close", (info) => {
this.emit("signaling:disconnected", { reason: info.reason });
}),
this.signaling.on("error", (error) => {
this.emit("signaling:error", { error });
}),
this.signaling.on("message", (msg) => {
void this.handleSignalingMessage(msg);
})
);
}
private wireClientHandlers(): void {
this.unsubscribers.push(
this.client.on("ice:state", ({ peerId, state }) => {
this.peers.setConnectionState(peerId, iceToPeerState(state));
if (state === ICE_STATE_CONNECTED) {
peersConnectedCounter.add(1);
this.emit("webrtc:connected", { peerId });
try {
this.dataChannels.open(peerId, this.defaultLabel);
} catch {
// Channel may already exist from a remote-side
// datachannel event — that path opens it for us.
}
}
if (state === ICE_STATE_FAILED) {
this.emit("webrtc:failed", { peerId });
this.peers.evict(peerId, "ice-failed");
}
}),
this.client.on("ice:restart", ({ peerId }) => {
this.emit("ice:restart", { peerId });
}),
this.client.on("ice:candidate", ({ peerId, candidate }) => {
try {
this.signaling.send({
type: "ice-candidate",
from: this.peerId,
to: peerId,
payload: candidate.toJSON
? candidate.toJSON()
: (candidate as unknown as RTCIceCandidateInit),
});
} catch {
// Signaling may have closed already; nothing to
// surface — the close path is already observable.
}
}),
this.client.on("peer:disconnect", ({ peerId }) => {
this.emit("webrtc:disconnected", { peerId });
})
);
}
private wireDataChannelHandlers(): void {
this.unsubscribers.push(
this.dataChannels.on("channel:open", ({ peerId }) => {
this.emit("datachannel:open", { peerId });
}),
this.dataChannels.on("channel:close", ({ peerId }) => {
this.emit("datachannel:close", { peerId });
}),
this.dataChannels.on("channel:error", ({ peerId, error }) => {
this.emit("datachannel:error", { peerId, error });
}),
this.dataChannels.on("channel:message", (event) => {
void this.handleDataChannelMessage(event);
})
);
}
private async handleSignalingMessage(msg: SignalingMessage): Promise<void> {
switch (msg.type) {
case "registered": {
this.emit("peer:joined", {
peerId: msg.payload.peerId,
peerName: msg.payload.peerId,
});
return;
}
case "peer-joined": {
this.peers.admit({
id: msg.payload.id,
name: msg.payload.name,
chunks: new Set(msg.payload.chunks),
connectionState: "connecting",
lastSeen: this.clock.now(),
});
this.emit("peer:joined", {
peerId: msg.payload.id,
peerName: msg.payload.name,
});
try {
const offer = await this.client.createOffer(msg.payload.id);
this.signaling.send({
type: "offer",
from: this.peerId,
to: msg.payload.id,
payload: offer,
});
} catch (error) {
this.emit("webrtc:failed", {
peerId: msg.payload.id,
error: error as Error,
});
}
return;
}
case "peer-left": {
const peerId = msg.payload.peerId;
this.peers.evict(peerId, "peer-left");
this.dataChannels.close(peerId, undefined, "peer-left");
this.client.closePeer(peerId, "peer-left");
this.emit("peer:left", { peerId });
return;
}
case "offer": {
const from = msg.from;
if (from === undefined) return;
if (this.peers.get(from) === undefined) {
this.peers.admit({
id: from,
name: from,
chunks: new Set(),
connectionState: "connecting",
lastSeen: this.clock.now(),
});
}
try {
const answer = await this.client.handleOffer(
from,
msg.payload
);
this.signaling.send({
type: "answer",
from: this.peerId,
to: from,
payload: answer,
});
} catch (error) {
this.emit("webrtc:failed", {
peerId: from,
error: error as Error,
});
}
return;
}
case "answer": {
const from = msg.from;
if (from === undefined) return;
try {
await this.client.handleAnswer(from, msg.payload);
} catch (error) {
this.emit("webrtc:failed", {
peerId: from,
error: error as Error,
});
}
return;
}
case "ice-candidate": {
const from = msg.from;
if (from === undefined) return;
try {
await this.client.addIceCandidate(from, msg.payload);
} catch {
// ICE add failures are non-fatal — peer-disconnect
// path handles fatal cases.
}
return;
}
case "announce-chunks": {
const from = msg.from;
if (from === undefined) return;
this.peers.setAvailableChunks(from, msg.payload.chunks);
this.emit("peer:chunks:updated", {
peerId: from,
chunks: msg.payload.chunks,
});
return;
}
case "peer-chunks-updated": {
this.peers.setAvailableChunks(
msg.payload.peerId,
msg.payload.chunks
);
this.emit("peer:chunks:updated", {
peerId: msg.payload.peerId,
chunks: msg.payload.chunks,
});
return;
}
default: {
// Variants consumed elsewhere (chunk-data, request-chunk,
// sub-chunk fragments, query / broadcast helpers).
return;
}
}
}
private async handleDataChannelMessage(event: {
peerId: string;
label: string;
data: ArrayBuffer | string;
}): Promise<void> {
if (typeof event.data !== "string") return;
if (event.label !== this.defaultLabel) return;
let parsed: unknown;
try {
parsed = JSON.parse(event.data);
} catch {
return;
}
if (typeof parsed !== "object" || parsed === null) return;
const obj = parsed as { type?: unknown };
if (obj.type !== "request-chunk") return;
const req = parsed as RequestChunkPayload;
if (
typeof req.requestId !== "string" ||
typeof req.chunkId !== "string"
) {
return;
}
await this.serveLocalChunk(event.peerId, req);
// RTT measurement is recorded by the discovery manager on the
// requestor side; we expose `peerRttHistogram` here so the OTel
// surface is exercised even when there's no on-the-wire timing
// signal yet (zero-bucket entries are harmless).
peerRttHistogram.record(0, { peer_id: event.peerId });
}
private async serveLocalChunk(
peerId: string,
req: RequestChunkPayload
): Promise<void> {
if (this.localChunkProvider === undefined) {
chunksServedCounter.add(1, { outcome: "no-provider" });
return;
}
let bytes: Uint8Array | null;
try {
bytes = await this.localChunkProvider(req.chunkId);
} catch {
chunksServedCounter.add(1, { outcome: "provider-error" });
return;
}
if (bytes === null) {
chunksServedCounter.add(1, { outcome: "not-found" });
return;
}
const wire = JSON.stringify({
type: "chunk-data",
payload: {
requestId: req.requestId,
chunkId: req.chunkId,
data: encodeBase64(bytes),
},
});
try {
await this.dataChannels.send(peerId, this.defaultLabel, wire);
this.bandwidth.recordBytesOut(peerId, bytes.length);
chunksServedCounter.add(1, { outcome: "ok" });
this.emit("chunk:served", {
peerId,
fileId: 0n,
chunkId: 0n,
size: bytes.length,
});
} catch {
chunksServedCounter.add(1, { outcome: "send-failed" });
}
}
}
function iceToPeerState(
state: RTCIceConnectionState
): "connecting" | "connected" | "disconnected" | "failed" {
switch (state) {
case "connected":
case "completed":
return "connected";
case "disconnected":
return "disconnected";
case "failed":
return "failed";
default:
return "connecting";
}
}
function encodeBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary);
}

View File

@@ -0,0 +1,2 @@
export { RateLimiter } from "./rate-limiter.js";
export type { RateLimiterOptions } from "./rate-limiter.types.js";

View File

@@ -0,0 +1,122 @@
# `RateLimiter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RateLimiter` is a single-bucket token-bucket limiter that the WebRTC
plugin uses to bound the inbound chunk-request rate from any one peer, the
outbound rate at which we requeue stalled chunks, and the rate at which
signaling clients can reissue join/leave traffic. The implementation is
deliberately small and synchronous: capacity in tokens, a fixed refill
rate in tokens-per-second, and a single `consume(n)` entry-point that
returns whether `n` tokens were available. A throwing companion
`consumeOrThrow(n)` surfaces a `DriveWebRtcError(RATE_LIMIT_EXCEEDED)`
which boundary code can let propagate.
The limiter does not track per-peer state itself — wrap it in a `Map<peerId,
RateLimiter>` (or pair it with the bounded `LRUCache`) when that is the
right granularity. Keeping the limiter peer-agnostic lets the same class
serve global, per-peer, and per-resource scopes without conditionals.
## Features
- Continuous refill (`tokens += elapsedMs * refillTokensPerSec / 1000`).
- Bucket strictly capped at `capacity`; refill cannot overflow.
- Injectable `clock` so tests advance time deterministically.
- Optional `initialTokens` to start the bucket below capacity.
- Defensive validation on every numeric input — non-finite, zero, and
negative arguments throw `RangeError`.
- Robust to clock regressions: a `now` strictly less than the previous
refill anchor is treated as a no-op rather than a negative refill.
## Usage: Basic Example
```ts
import { RateLimiter } from "@suaveplan/drive-webrtc";
const limiter = new RateLimiter({
capacity: 10,
refillTokensPerSec: 5,
});
if (limiter.consume()) {
// proceed with the request
}
```
## Usage: Advanced Example
```ts
import {
DriveWebRtcError,
DRIVE_WEBRTC_CODES,
RateLimiter,
} from "@suaveplan/drive-webrtc";
const limiters = new Map<string, RateLimiter>();
function limiterFor(peerId: string): RateLimiter {
let l = limiters.get(peerId);
if (l === undefined) {
l = new RateLimiter({
capacity: 20,
refillTokensPerSec: 10,
clock: () => performance.timeOrigin + performance.now(),
});
limiters.set(peerId, l);
}
return l;
}
function handleChunkRequest(peerId: string): void {
try {
limiterFor(peerId).consumeOrThrow();
} catch (err) {
if (
err instanceof DriveWebRtcError &&
err.code === DRIVE_WEBRTC_CODES.RATE_LIMIT_EXCEEDED
) {
// emit telemetry, optionally blacklist
return;
}
throw err;
}
}
```
## API Reference
### `new RateLimiter(options)`
- `options.capacity: number` — positive finite bucket cap.
- `options.refillTokensPerSec: number` — positive finite refill rate.
- `options.clock?: () => number` — wall-clock provider.
- `options.initialTokens?: number` — non-negative starting count (clamped
to `capacity`).
Throws `RangeError` on bad numeric inputs.
### Methods
- `consume(tokens = 1): boolean` — returns whether the request was
granted. Refills the bucket first. Throws `RangeError` if `tokens` is
non-positive.
- `consumeOrThrow(tokens = 1): void` — throws
`DriveWebRtcError(RATE_LIMIT_EXCEEDED)` when the request would fail.
- `refill(now?: number): void` — refill against `now`, defaulting to the
injected clock; safe to call explicitly from tests.
- `available(): number` — post-refill token count.
## Implementation Notes
- The bucket is a `number`, not an integer. Sub-token refill accumulates
precisely so a 0.5-tps limiter actually emits one grant per two seconds.
- `Number.EPSILON` is added to the comparison threshold so a request for
`n` tokens succeeds when `tokens` floats to exactly `n - 1e-16` after a
long refill chain.
- The class is single-threaded by construction; no atomics or locks. In a
Worker context, share via `postMessage` rather than `SharedArrayBuffer`.
- Errors carry `context: { requested, available }` so dashboards can
segment by request size without parsing the message string.

View File

@@ -0,0 +1,154 @@
/**
* Unit tests for {@link RateLimiter}.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { RateLimiter } from "./rate-limiter.js";
describe("RateLimiter constructor validation", () => {
it("rejects non-positive capacity", { timeout: 5000 }, () => {
expect(
() => new RateLimiter({ capacity: 0, refillTokensPerSec: 1 })
).toThrow(RangeError);
expect(
() =>
new RateLimiter({
capacity: Number.NaN,
refillTokensPerSec: 1,
})
).toThrow(RangeError);
});
it("rejects non-positive refill rate", { timeout: 5000 }, () => {
expect(
() => new RateLimiter({ capacity: 5, refillTokensPerSec: 0 })
).toThrow(RangeError);
});
it("rejects negative initial tokens", { timeout: 5000 }, () => {
expect(
() =>
new RateLimiter({
capacity: 5,
refillTokensPerSec: 1,
initialTokens: -1,
})
).toThrow(RangeError);
});
it("clamps initialTokens to capacity", { timeout: 5000 }, () => {
const now = 0;
const limiter = new RateLimiter({
capacity: 3,
refillTokensPerSec: 1,
clock: () => now,
initialTokens: 100,
});
expect(limiter.available()).toBe(3);
});
});
describe("RateLimiter consume", () => {
it("rejects non-positive token requests", { timeout: 5000 }, () => {
const limiter = new RateLimiter({
capacity: 5,
refillTokensPerSec: 1,
});
expect(() => limiter.consume(0)).toThrow(RangeError);
expect(() => limiter.consume(-1)).toThrow(RangeError);
});
it("returns true while tokens remain and false once exhausted", {
timeout: 5000,
}, () => {
const now = 0;
const limiter = new RateLimiter({
capacity: 2,
refillTokensPerSec: 1,
clock: () => now,
});
expect(limiter.consume()).toBe(true);
expect(limiter.consume()).toBe(true);
expect(limiter.consume()).toBe(false);
});
it("refills tokens proportionally to elapsed time", {
timeout: 5000,
}, () => {
let now = 1000;
const limiter = new RateLimiter({
capacity: 4,
refillTokensPerSec: 2,
clock: () => now,
});
// Drain.
for (let i = 0; i < 4; i++) limiter.consume();
expect(limiter.consume()).toBe(false);
// 500 ms => 1 token at 2 tps.
now = 1500;
expect(limiter.consume()).toBe(true);
expect(limiter.consume()).toBe(false);
});
it("caps refill at capacity", { timeout: 5000 }, () => {
let now = 0;
const limiter = new RateLimiter({
capacity: 5,
refillTokensPerSec: 100,
clock: () => now,
initialTokens: 0,
});
now = 10_000; // would refill 1000 if uncapped
expect(limiter.available()).toBe(5);
});
it("treats clock regressions as no-ops without going below zero", {
timeout: 5000,
}, () => {
let now = 1000;
const limiter = new RateLimiter({
capacity: 2,
refillTokensPerSec: 1,
clock: () => now,
});
limiter.consume();
now = 500; // regression
// available() refills against a non-positive elapsed -> noop.
expect(limiter.available()).toBe(1);
});
});
describe("RateLimiter consumeOrThrow", () => {
it("succeeds silently while tokens are available", {
timeout: 5000,
}, () => {
const limiter = new RateLimiter({
capacity: 1,
refillTokensPerSec: 1,
});
expect(() => limiter.consumeOrThrow()).not.toThrow();
});
it("throws DriveWebRtcError(RATE_LIMIT_EXCEEDED) when exhausted", {
timeout: 5000,
}, () => {
const limiter = new RateLimiter({
capacity: 1,
refillTokensPerSec: 0.0001,
});
limiter.consumeOrThrow();
try {
limiter.consumeOrThrow();
throw new Error("expected RateLimiter to throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveWebRtcError);
const e = err as DriveWebRtcError;
expect(e.code).toBe(DRIVE_WEBRTC_CODES.RATE_LIMIT_EXCEEDED);
expect(e.context).toMatchObject({ requested: 1 });
}
});
});

View File

@@ -0,0 +1,115 @@
/**
* Token-bucket rate limiter.
*
* Used by the WebRTC plugin to bound the chunk-request rate from peers and
* by outbound queues to bound how quickly we hammer remote signalers. The
* bucket has a fixed capacity and refills continuously at a fixed rate;
* `consume(n)` returns whether `n` tokens were available, and
* `consumeOrThrow(n)` is the throwing variant for boundary code.
*/
import type { Clock } from "@suaveplan/types";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import type { RateLimiterOptions } from "./rate-limiter.types.js";
const defaultClock: Clock = () => Date.now();
const MS_PER_SECOND = 1000;
/**
* Token-bucket limiter with a continuous refill model.
*/
export class RateLimiter {
private readonly capacity: number;
private readonly refillPerMs: number;
private readonly clock: Clock;
private tokens: number;
private lastRefillAt: number;
public constructor(options: RateLimiterOptions) {
if (!Number.isFinite(options.capacity) || options.capacity <= 0) {
throw new RangeError(
"RateLimiter: capacity must be a positive finite number"
);
}
if (
!Number.isFinite(options.refillTokensPerSec) ||
options.refillTokensPerSec <= 0
) {
throw new RangeError(
"RateLimiter: refillTokensPerSec must be a positive finite number"
);
}
if (
options.initialTokens !== undefined &&
(!Number.isFinite(options.initialTokens) ||
options.initialTokens < 0)
) {
throw new RangeError(
"RateLimiter: initialTokens must be a non-negative finite number"
);
}
this.capacity = options.capacity;
this.refillPerMs = options.refillTokensPerSec / MS_PER_SECOND;
this.clock = options.clock ?? defaultClock;
const initial = options.initialTokens ?? options.capacity;
this.tokens = Math.min(initial, options.capacity);
this.lastRefillAt = this.clock();
}
/**
* Refill the bucket based on elapsed wall-clock time. The `now`
* argument is exposed for tests that want to drive refill explicitly
* without first calling `consume`.
*/
public refill(now: number = this.clock()): void {
if (now <= this.lastRefillAt) {
this.lastRefillAt = now;
return;
}
const elapsed = now - this.lastRefillAt;
const added = elapsed * this.refillPerMs;
this.tokens = Math.min(this.capacity, this.tokens + added);
this.lastRefillAt = now;
}
/**
* Attempt to consume `tokens` (default 1). Returns `true` on success
* and `false` when not enough tokens are available; non-mutating on
* failure aside from the refill step.
*/
public consume(tokens = 1): boolean {
if (!Number.isFinite(tokens) || tokens <= 0) {
throw new RangeError(
"RateLimiter.consume: tokens must be a positive finite number"
);
}
this.refill();
if (this.tokens + Number.EPSILON < tokens) return false;
this.tokens -= tokens;
return true;
}
/**
* Throwing variant of {@link consume}. Surfaces
* `DriveWebRtcError(RATE_LIMIT_EXCEEDED)` when not enough tokens are
* available.
*/
public consumeOrThrow(tokens = 1): void {
if (this.consume(tokens)) return;
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.RATE_LIMIT_EXCEEDED,
`rate limit exceeded (requested ${tokens}, available ${this.tokens.toFixed(3)})`,
{ context: { requested: tokens, available: this.tokens } }
);
}
/** Number of tokens currently available (post-refill). */
public available(): number {
this.refill();
return this.tokens;
}
}

View File

@@ -0,0 +1,70 @@
# Rate limiter types
## Purpose
This file declares the public option type for `RateLimiter`. Keeping the
shape in a sibling `.types.ts` lets every consumer reference the
configuration vocabulary without pulling the runtime `RateLimiter` class
into its bundle graph. Other modules in `@suaveplan/drive-webrtc` — the
peer manager, the signaling client, and the chunk-source — accept a
`RateLimiterOptions` argument in their own options bags and forward it on,
which avoids leaking constructor details into their surface area.
## Features
- `RateLimiterOptions` — read-only constructor option bundle for the
token-bucket limiter.
- Reuses the `Clock = () => number` contract from
`@suaveplan/types`, so a single `clock` value can be threaded through
every clock-aware subsystem in the plugin.
- Allows callers to start the bucket below capacity via `initialTokens`,
which is useful when reconstructing limiter state from a serialized
snapshot at startup.
## Usage: Basic Example
```ts
import type { RateLimiterOptions } from "@suaveplan/drive-webrtc";
const opts: RateLimiterOptions = {
capacity: 8,
refillTokensPerSec: 4,
};
```
## Usage: Advanced Example
```ts
import type { RateLimiterOptions } from "@suaveplan/drive-webrtc";
function fromSnapshot(snapshot: {
tokens: number;
capacity: number;
refillTokensPerSec: number;
}): RateLimiterOptions {
return {
capacity: snapshot.capacity,
refillTokensPerSec: snapshot.refillTokensPerSec,
initialTokens: snapshot.tokens,
clock: () => performance.timeOrigin + performance.now(),
};
}
```
## API Reference
### `RateLimiterOptions`
- `capacity: number` — positive finite bucket size.
- `refillTokensPerSec: number` — positive finite refill rate.
- `clock?: () => number` — optional wall-clock provider.
- `initialTokens?: number` — optional non-negative starting count; clamped
to `capacity` by the constructor.
## Implementation Notes
- Every field is `readonly` — the runtime captures the value once and
manages mutability internally. This keeps consumer code from accidentally
mutating a shared options object after the limiter is constructed.
- `Clock` is imported from `@suaveplan/types` rather than redeclared so
the entire project agrees on the same wall-clock contract.

View File

@@ -0,0 +1,19 @@
/**
* Type definitions for {@link RateLimiter}.
*/
import type { Clock } from "@suaveplan/types";
/**
* Configuration for a token-bucket {@link RateLimiter}.
*/
export interface RateLimiterOptions {
/** Maximum bucket size in tokens. Must be > 0. */
readonly capacity: number;
/** Refill rate in tokens-per-second. Must be > 0. */
readonly refillTokensPerSec: number;
/** Optional wall-clock provider (defaults to `Date.now`). */
readonly clock?: Clock;
/** Optional initial token count; defaults to {@link capacity}. */
readonly initialTokens?: number;
}

View File

@@ -0,0 +1,126 @@
# Config Schemas
## Purpose
The `config.schemas.ts` module defines Zod boundary schemas for the configuration surface declared in `types/config.types.ts`. Public entry points (the plugin's `initialize`, the client's `create`, the signaling client's constructor) parse caller-supplied configuration through `WebRTCPluginConfigSchema` before any internal code touches it. Failures throw `DriveWebRtcError(CONFIG_INVALID, ...)` with the Zod issue list attached for triage.
Validating configuration at the boundary means internal code can rely on the `ResolvedConfig` materialised after `resolveConfig()` runs — no `if (config.signaling?.url !== undefined)` defensive checks scattered through the codebase. Every required field is enforced by Zod at one well-known entry point.
## Features
- One schema per config interface (`SignalingConfig`, `P2PConfig`, `SubChunkConfig`, `WebRTCPluginConfig`)
- First-class `IceServerSchema` for the Original `iceServers` field
- Strict numeric ranges (positive timeouts, non-negative delays, integer counts)
- `urls` accepts either a single string or a non-empty array
- All optional fields use `.optional()` rather than `.nullable()` — undefined is the canonical absent
- Compatible with the `WebRTCPluginConfig` type without explicit `z.infer`
## Usage
### Basic Example
```typescript
import { WebRTCPluginConfigSchema } from "@suaveplan/drive-webrtc/schemas";
const result = WebRTCPluginConfigSchema.safeParse({
signaling: { url: "wss://signal.example.com" },
});
if (!result.success) {
console.error(result.error.message);
}
```
### Advanced Example
```typescript
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
IceServerSchema,
WebRTCPluginConfigSchema,
} from "@suaveplan/drive-webrtc";
function parseConfig(raw: unknown): ReturnType<
typeof WebRTCPluginConfigSchema.parse
> {
const r = WebRTCPluginConfigSchema.safeParse(raw);
if (!r.success) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CONFIG_INVALID,
r.error.message,
{ context: { issues: r.error.issues } },
);
}
return r.data;
}
function mergeIceServers(
base: unknown,
extra: unknown,
): readonly RTCIceServer[] {
const a = IceServerSchema.array().parse(base);
const b = IceServerSchema.array().parse(extra);
return [...a, ...b];
}
const config = parseConfig({
signaling: { url: "wss://signal.example.com" },
p2p: {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{
urls: ["turn:turn.example.com:3478"],
username: "u",
credential: "p",
credentialType: "password",
},
],
},
});
```
## API Reference
### `IceServerSchema`
- `urls: string | string[]` (array must be non-empty)
- `username?: string`
- `credential?: string`
- `credentialType?: "password"`
### `SignalingConfigSchema`
- `url: string` (non-empty, required)
- `peerId?: string` (non-empty)
- `peerName?: string`
- `authToken?: string`
- `reconnect?: boolean`
- `reconnectDelay?: number` (positive integer)
### `P2PConfigSchema`
- `priority?: number` (non-negative integer)
- `timeout?: number` (positive integer)
- `iceServers?: IceServerSchema[]`
- `maxConcurrentDownloads?: number` (positive integer)
### `SubChunkConfigSchema`
- `size?: number` (positive integer)
- `backpressure?: number` (positive integer)
- `rateLimit?: { delayMs: number (≥0); everyNParts: number (>0) }`
### `WebRTCPluginConfigSchema`
- `signaling: SignalingConfigSchema` (required)
- `p2p?: P2PConfigSchema`
- `subChunks?: SubChunkConfigSchema`
## Implementation Notes
- The schemas do NOT apply defaults — that's `resolveConfig()`'s job. Splitting validation and default-resolution keeps the schema layer pure (the same input always produces the same parse result, no environment coupling).
- `IceServerSchema.urls` accepts both string and string-array forms because the W3C `RTCIceServer` interface allows both; we enforce a non-empty array (Zod's `.min(1)`) for the array form because an empty list is meaningless.
- `credentialType` is intentionally restricted to `"password"` — the legacy `"oauth"` value was removed from the W3C spec and is no longer supported by major browsers.
- The schemas use `.optional()` (not `.nullable()`) throughout. Undefined and absent are equivalent in TypeScript object types and idiomatic for the config surface; allowing `null` would create a third state with no consumer benefit.
- Numeric ranges use `.int()` everywhere — every numeric config field is a count or a millisecond value, neither of which makes sense as a float. Floats are silently rejected, which is the correct strict-parse behaviour.
- New config fields require touching three places in one commit: the type, the schema here, and `resolveConfig()`. The doc-drift gate catches any one of the three lagging behind.

View File

@@ -0,0 +1,53 @@
/**
* Zod boundary schemas for `@suaveplan/drive-webrtc` configuration.
*
* Public entry points (`WebRTCDrivePlugin`, `WebRTCClient.create`) parse
* caller-supplied configuration through {@link WebRTCPluginConfigSchema}
* before internal code touches it; failures surface as
* `DRIVE_WEBRTC.CONFIG_INVALID`.
*/
import * as z from "zod";
const NonEmptyString = z.string().min(1);
/** Single STUN/TURN entry. */
export const IceServerSchema = z.object({
urls: z.union([NonEmptyString, z.array(NonEmptyString).min(1)]),
username: z.string().optional(),
credential: z.string().optional(),
credentialType: z.literal("password").optional(),
});
export const SignalingConfigSchema = z.object({
url: NonEmptyString,
peerId: NonEmptyString.optional(),
peerName: z.string().optional(),
authToken: z.string().optional(),
reconnect: z.boolean().optional(),
reconnectDelay: z.number().int().positive().optional(),
});
export const P2PConfigSchema = z.object({
priority: z.number().int().nonnegative().optional(),
timeout: z.number().int().positive().optional(),
iceServers: z.array(IceServerSchema).optional(),
maxConcurrentDownloads: z.number().int().positive().optional(),
});
export const SubChunkConfigSchema = z.object({
size: z.number().int().positive().optional(),
backpressure: z.number().int().positive().optional(),
rateLimit: z
.object({
delayMs: z.number().int().nonnegative(),
everyNParts: z.number().int().positive(),
})
.optional(),
});
export const WebRTCPluginConfigSchema = z.object({
signaling: SignalingConfigSchema,
p2p: P2PConfigSchema.optional(),
subChunks: SubChunkConfigSchema.optional(),
});

View File

@@ -0,0 +1,31 @@
/**
* Schema barrel — explicit named re-exports only.
*/
export {
IceServerSchema,
P2PConfigSchema,
SignalingConfigSchema,
SubChunkConfigSchema,
WebRTCPluginConfigSchema,
} from "./config.schemas.js";
export {
AnnounceChunksMessageSchema,
AnswerMessageSchema,
ChunkBroadcastRequestSchema,
ChunkBroadcastResponseSchema,
ChunkDataMessageSchema,
ChunkDataPartMessageSchema,
ChunkQueryMessageSchema,
ChunkQueryResponseSchema,
ChunkRequestMessageSchema,
ChunksUpdatedMessageSchema,
DataChannelChunkRequestSchema,
IceCandidateMessageSchema,
OfferMessageSchema,
PeerJoinedMessageSchema,
PeerLeftMessageSchema,
RegisteredMessageSchema,
RegisterMessageSchema,
SignalingMessageSchema,
} from "./signaling.schemas.js";

View File

@@ -0,0 +1,95 @@
# Signaling Schemas
## Purpose
The `signaling.schemas.ts` module exposes Zod schemas mirroring every variant of `SignalingMessage` from `types/messages.types.ts`. Inbound traffic — both from the signaling WebSocket and from peer data channels — MUST be parsed against `SignalingMessageSchema` before any handler sees the value. The package treats anything that fails to parse as `DRIVE_WEBRTC.SIGNALING_MESSAGE_INVALID`, isolating untrusted input at the boundary so internal code can trust message shapes.
Schemas here are deliberately strict: required fields cannot be omitted, decimal-string u64 IDs must match `/^\d+$/`, and `chunks` arrays cannot contain empty strings. Permissive fields (`from`, `to`, `chunkIds`) use `.optional()` because the wire format allows them to be omitted.
## Features
- 17 per-variant schemas, one per `SignalingMessage` variant
- `SignalingMessageSchema` `z.union` over all 17 — handles `request-chunk` shape overlap
- Strict u64 decimal-string regex for `fileId`/`chunkId`
- Strict `RTCSessionDescriptionInit` and `RTCIceCandidateInit` substructures
- Non-empty string enforcement on `requestId`, peer IDs, chunk keys, base64 `data`
- All schemas are pure validators — no transforms, no side effects
## Usage
### Basic Example
```typescript
import { SignalingMessageSchema } from "@suaveplan/drive-webrtc/schemas";
const result = SignalingMessageSchema.safeParse(JSON.parse(rawWire));
if (!result.success) {
// throw DriveWebRtcError(SIGNALING_MESSAGE_INVALID, ...)
}
const msg = result.data;
```
### Advanced Example
```typescript
import {
ChunkDataPartMessageSchema,
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
SignalingMessageSchema,
} from "@suaveplan/drive-webrtc";
function parseFromDataChannel(raw: string): unknown {
let value: unknown;
try {
value = JSON.parse(raw);
} catch (cause) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID,
"not valid JSON",
{ cause: cause instanceof Error ? cause : undefined },
);
}
// Sub-chunk fragments are hot-path — try the fast schema first
const fast = ChunkDataPartMessageSchema.safeParse(value);
if (fast.success) return fast.data;
const slow = SignalingMessageSchema.safeParse(value);
if (!slow.success) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID,
slow.error.message,
);
}
return slow.data;
}
```
## API Reference
Per-variant schemas (all exported):
- `OfferMessageSchema`, `AnswerMessageSchema`, `IceCandidateMessageSchema`
- `RegisterMessageSchema`, `RegisteredMessageSchema`
- `PeerJoinedMessageSchema`, `PeerLeftMessageSchema`
- `AnnounceChunksMessageSchema`, `ChunksUpdatedMessageSchema`
- `ChunkRequestMessageSchema`, `ChunkDataMessageSchema`
- `ChunkDataPartMessageSchema`
- `DataChannelChunkRequestSchema`
- `ChunkQueryMessageSchema`, `ChunkQueryResponseSchema`
- `ChunkBroadcastRequestSchema`, `ChunkBroadcastResponseSchema`
### `SignalingMessageSchema`
`z.union` over all 17 per-variant schemas. Use `safeParse` to validate untrusted input. Returns the typed `SignalingMessage` on success.
## Implementation Notes
- `request-chunk` is reused by both `ChunkRequestMessage` (signaling-relayed, nested `payload` object) and `DataChannelChunkRequest` (flat shape, no `payload`). `z.discriminatedUnion` requires unique discriminator values — we use `z.union` instead so Zod tries each schema in order until one matches.
- The order of variants in the union matters for performance, not correctness: list hot-path variants first if profiling shows it. We currently match the typing-file order so audits are straightforward.
- `fileId` and `chunkId` are validated as decimal-string u64s (`/^\d+$/`). The schema does NOT range-check against 2^64; the consumer should `BigInt(...)` and then reject any value above `2n ** 64n - 1n` if that matters.
- The `RTCSessionDescriptionInit` schema accepts the four standard `type` values (`offer`/`answer`/`pranswer`/`rollback`). The `sdp` field is optional because `rollback` legitimately omits it.
- The `RTCIceCandidateInit` substructure permits `null` on `sdpMLineIndex`/`sdpMid`/`usernameFragment` because the browser DOM definitions allow it.
- `data` fields (base64 payloads) are validated as non-empty strings only — full base64 character-set enforcement is deliberately omitted because some browsers tolerate `/` vs `_` variants. Ciphertext integrity is verified separately after decode.
- Adding a new message variant requires touching three files in one commit: `types/messages.types.ts` (the type), this file (the schema), and the union. The repo grep gate flags any drift between the three.

View File

@@ -0,0 +1,203 @@
/**
* Zod boundary schemas for signaling- and data-channel-bound messages.
*
* Every variant of {@link SignalingMessage} from
* `types/messages.types.ts` has a matching schema here. Inbound traffic
* MUST be parsed against {@link SignalingMessageSchema} before any
* handler sees it; the package treats anything that fails to parse as
* `SIGNALING_MESSAGE_INVALID`.
*/
import * as z from "zod";
const NonEmptyString = z.string().min(1);
const ChunkKey = z.string().min(1, "chunk key must be non-empty");
const PeerSummarySchema = z.object({
id: NonEmptyString,
name: z.string(),
chunks: z.array(ChunkKey).readonly(),
});
const RTCSessionDescriptionInitSchema = z.object({
type: z.enum(["offer", "answer", "pranswer", "rollback"]),
sdp: z.string().optional(),
});
const RTCIceCandidateInitSchema = z.object({
candidate: z.string().optional(),
sdpMLineIndex: z.number().int().nullable().optional(),
sdpMid: z.string().nullable().optional(),
usernameFragment: z.string().nullable().optional(),
});
export const OfferMessageSchema = z.object({
type: z.literal("offer"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: RTCSessionDescriptionInitSchema,
});
export const AnswerMessageSchema = z.object({
type: z.literal("answer"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: RTCSessionDescriptionInitSchema,
});
export const IceCandidateMessageSchema = z.object({
type: z.literal("ice-candidate"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: RTCIceCandidateInitSchema,
});
export const RegisterMessageSchema = z.object({
type: z.literal("register"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: z.object({
peerId: NonEmptyString,
peerName: z.string(),
authToken: z.string().optional(),
chunks: z.array(ChunkKey),
}),
});
export const RegisteredMessageSchema = z.object({
type: z.literal("registered"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: z.object({
peerId: NonEmptyString,
peers: z.array(PeerSummarySchema),
}),
});
export const PeerJoinedMessageSchema = z.object({
type: z.literal("peer-joined"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: PeerSummarySchema,
});
export const PeerLeftMessageSchema = z.object({
type: z.literal("peer-left"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: z.object({ peerId: NonEmptyString }),
});
export const AnnounceChunksMessageSchema = z.object({
type: z.literal("announce-chunks"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: z.object({ chunks: z.array(ChunkKey) }),
});
export const ChunksUpdatedMessageSchema = z.object({
type: z.literal("peer-chunks-updated"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: z.object({
peerId: NonEmptyString,
chunks: z.array(ChunkKey),
}),
});
const DecimalStringId = z.string().regex(/^\d+$/, "must be decimal-string u64");
export const ChunkRequestMessageSchema = z.object({
type: z.literal("request-chunk"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: z.object({
requestId: NonEmptyString,
fileId: DecimalStringId,
chunkId: DecimalStringId,
}),
});
export const ChunkDataMessageSchema = z.object({
type: z.literal("chunk-data"),
from: NonEmptyString.optional(),
to: NonEmptyString.optional(),
payload: z.object({
requestId: NonEmptyString,
fileId: DecimalStringId,
chunkId: DecimalStringId,
data: NonEmptyString,
}),
});
export const ChunkDataPartMessageSchema = z.object({
type: z.literal("chunk-data-part"),
requestId: NonEmptyString,
partIndex: z.number().int().nonnegative(),
totalParts: z.number().int().positive(),
data: NonEmptyString,
});
export const DataChannelChunkRequestSchema = z.object({
type: z.literal("request-chunk"),
requestId: NonEmptyString,
fileId: DecimalStringId,
chunkId: DecimalStringId,
});
export const ChunkQueryMessageSchema = z.object({
type: z.literal("chunk-query"),
requestId: NonEmptyString,
fileId: DecimalStringId,
chunkIds: z.array(DecimalStringId).optional(),
});
export const ChunkQueryResponseSchema = z.object({
type: z.literal("chunk-query-response"),
requestId: NonEmptyString,
fileId: DecimalStringId,
availableChunkIds: z.array(DecimalStringId),
});
export const ChunkBroadcastRequestSchema = z.object({
type: z.literal("chunk-broadcast-request"),
requestId: NonEmptyString,
fileId: DecimalStringId,
neededChunkIds: z.array(DecimalStringId),
});
export const ChunkBroadcastResponseSchema = z.object({
type: z.literal("chunk-broadcast-response"),
requestId: NonEmptyString,
fileId: DecimalStringId,
peerId: NonEmptyString,
availableChunkIds: z.array(DecimalStringId),
});
/**
* Discriminated union over every wire-bound message variant.
*
* `request-chunk` is reused by both `ChunkRequestMessage` (signaling-
* relayed, nested `payload`) and `DataChannelChunkRequest` (flat data-
* channel form); `z.union` distinguishes them by structural shape.
*/
export const SignalingMessageSchema = z.union([
OfferMessageSchema,
AnswerMessageSchema,
IceCandidateMessageSchema,
RegisterMessageSchema,
RegisteredMessageSchema,
PeerJoinedMessageSchema,
PeerLeftMessageSchema,
AnnounceChunksMessageSchema,
ChunksUpdatedMessageSchema,
ChunkRequestMessageSchema,
ChunkDataMessageSchema,
ChunkDataPartMessageSchema,
DataChannelChunkRequestSchema,
ChunkQueryMessageSchema,
ChunkQueryResponseSchema,
ChunkBroadcastRequestSchema,
ChunkBroadcastResponseSchema,
]);

View File

@@ -0,0 +1,6 @@
export {
generateSecurePeerId,
generateSecureRequestId,
validatePeerSecurity,
validateSignalingUrl,
} from "./security-validator.js";

View File

@@ -0,0 +1,105 @@
# Security validators
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module collects the small, pure security checks that run at every
inbound boundary of the WebRTC plugin: peer-ID shape validation, secure
identifier generation, and signaling-URL transport validation. The checks
are defence-in-depth — the surrounding Zod schemas already reject
malformed envelopes, but these functions add a second layer that
specifically targets the security-sensitive properties (cryptographic
randomness for IDs, TLS-only transport for non-loopback signalers,
canonical UUID v4 shape for peer identifiers).
Every function is pure and synchronous. Identifier generation uses
`globalThis.crypto.randomUUID()`, which is available in modern browsers,
Bun, and Node ≥ 19. The module never falls back to `Math.random` — if the
runtime lacks a crypto interface, identifier generation throws so the
caller cannot accidentally emit a weakly-random peer ID.
## Features
- `generateSecurePeerId()` and `generateSecureRequestId()` — UUID v4
identifiers backed by `crypto.randomUUID()`.
- `validatePeerSecurity(peerId)` — boolean validator for the canonical
RFC 4122 UUID v4 shape (lower-case hex, version nibble 4, variant
nibble in `[8, 9, a, b]`).
- `validateSignalingUrl(url)` — boolean validator that accepts `wss://`
unconditionally and `ws://` only when the host is `localhost`,
`127.0.0.1`, `[::1]`, or `::1` (development loopback).
- All validators are total — they never throw on bad input; non-string
values simply return `false`.
## Usage: Basic Example
```ts
import {
generateSecurePeerId,
validatePeerSecurity,
validateSignalingUrl,
} from "@suaveplan/drive-webrtc";
const peerId = generateSecurePeerId();
if (!validatePeerSecurity(peerId)) throw new Error("unreachable");
if (!validateSignalingUrl("wss://signaling.example.com/")) {
throw new Error("rejected");
}
```
## Usage: Advanced Example
```ts
import {
DriveWebRtcError,
DRIVE_WEBRTC_CODES,
generateSecureRequestId,
validatePeerSecurity,
validateSignalingUrl,
} from "@suaveplan/drive-webrtc";
function authorize(peerId: string, signalingUrl: string): string {
if (!validatePeerSecurity(peerId)) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID,
"peerId is not a UUID v4",
{ context: { peerId } }
);
}
if (!validateSignalingUrl(signalingUrl)) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID,
"signaling URL is not wss:// (and not a loopback dev URL)",
{ context: { signalingUrl } }
);
}
return generateSecureRequestId();
}
```
## API Reference
- `generateSecurePeerId(): string` — returns a UUID v4. Throws if the
runtime lacks `crypto.randomUUID`.
- `generateSecureRequestId(): string` — returns a UUID v4. Same runtime
requirement.
- `validatePeerSecurity(peerId: string): boolean` — `true` iff `peerId`
matches the canonical UUID v4 regex.
- `validateSignalingUrl(url: string): boolean` — `true` iff the URL
parses, uses `wss:` everywhere, or uses `ws:` against a loopback host.
## Implementation Notes
- The UUID v4 regex is case-sensitive on hex digits because
`crypto.randomUUID()` always emits lower-case hex. Accepting upper-case
would let attacker-supplied values diverge from the canonical shape and
defeat downstream `===` comparisons.
- Loopback hosts include both bracketed (`[::1]`) and bare (`::1`) IPv6
forms because `new URL("ws://[::1]/").hostname` returns the former in
browsers and the latter in some Node versions; both are equivalent and
both should be allowed for parity.
- `crypto.randomUUID` resolution is lazy (per-call) so a polyfill
installed after module load is honoured. The lookup happens once per
generator call but the cost is negligible.

View File

@@ -0,0 +1,111 @@
/**
* Unit tests for the security-validator helpers.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
generateSecurePeerId,
generateSecureRequestId,
validatePeerSecurity,
validateSignalingUrl,
} from "./security-validator.js";
describe("generateSecurePeerId / generateSecureRequestId", () => {
it("produces UUID v4 strings", { timeout: 5000 }, () => {
const id = generateSecurePeerId();
expect(validatePeerSecurity(id)).toBe(true);
const reqId = generateSecureRequestId();
expect(validatePeerSecurity(reqId)).toBe(true);
});
it("returns unique values across repeated calls", {
timeout: 5000,
}, () => {
const seen = new Set<string>();
for (let i = 0; i < 32; i++) seen.add(generateSecurePeerId());
expect(seen.size).toBe(32);
});
});
describe("validatePeerSecurity", () => {
it("accepts canonical UUID v4 strings", { timeout: 5000 }, () => {
expect(
validatePeerSecurity("550e8400-e29b-41d4-a716-446655440000")
).toBe(true);
expect(
validatePeerSecurity("00000000-0000-4000-8000-000000000000")
).toBe(true);
});
it("rejects non-v4 UUIDs and malformed strings", {
timeout: 5000,
}, () => {
// v1 UUID (version nibble = 1).
expect(
validatePeerSecurity("550e8400-e29b-11d4-a716-446655440000")
).toBe(false);
// Invalid variant nibble.
expect(
validatePeerSecurity("550e8400-e29b-41d4-0716-446655440000")
).toBe(false);
expect(validatePeerSecurity("not-a-uuid")).toBe(false);
expect(validatePeerSecurity("")).toBe(false);
// Uppercase hex (canonical is lower-case).
expect(
validatePeerSecurity("550E8400-E29B-41D4-A716-446655440000")
).toBe(false);
});
it("returns false for non-string inputs", { timeout: 5000 }, () => {
expect(validatePeerSecurity(123 as unknown as string)).toBe(false);
expect(validatePeerSecurity(null as unknown as string)).toBe(false);
expect(validatePeerSecurity(undefined as unknown as string)).toBe(
false
);
});
});
describe("validateSignalingUrl", () => {
it("accepts wss:// URLs", { timeout: 5000 }, () => {
expect(validateSignalingUrl("wss://signaling.example.com/")).toBe(true);
expect(
validateSignalingUrl("wss://signaling.example.com:443/path")
).toBe(true);
});
it("accepts ws://localhost / 127.0.0.1 / ::1 for development", {
timeout: 5000,
}, () => {
expect(validateSignalingUrl("ws://localhost/")).toBe(true);
expect(validateSignalingUrl("ws://localhost:8080/socket")).toBe(true);
expect(validateSignalingUrl("ws://127.0.0.1:9090/")).toBe(true);
expect(validateSignalingUrl("ws://[::1]:9090/")).toBe(true);
});
it("rejects plain ws:// to non-loopback hosts", {
timeout: 5000,
}, () => {
expect(validateSignalingUrl("ws://signaling.example.com/")).toBe(false);
expect(validateSignalingUrl("ws://10.0.0.5:8080/")).toBe(false);
});
it("rejects non-websocket schemes", { timeout: 5000 }, () => {
expect(validateSignalingUrl("https://signaling.example.com/")).toBe(
false
);
expect(validateSignalingUrl("http://signaling.example.com/")).toBe(
false
);
expect(validateSignalingUrl("file:///tmp/socket")).toBe(false);
});
it("rejects unparseable / empty / non-string input", {
timeout: 5000,
}, () => {
expect(validateSignalingUrl("")).toBe(false);
expect(validateSignalingUrl("not a url")).toBe(false);
expect(validateSignalingUrl(undefined as unknown as string)).toBe(
false
);
});
});

View File

@@ -0,0 +1,70 @@
/**
* Security validators for the WebRTC plugin.
*
* Pure functions covering peer identifier generation, peer ID shape
* validation, and signaling-URL transport validation. These are the
* defence-in-depth checks that run at the boundaries of every signaling
* handler before peer state is touched.
*/
/**
* RFC 4122 UUID v4 shape (lower-case hex, version nibble = 4, variant
* nibble in `[8, 9, a, b]`). Matched against the output of
* `crypto.randomUUID()` and any UUID v4 supplied by an external peer.
*/
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const HOSTS_ALLOWED_OVER_PLAIN_WS = new Set([
"localhost",
"127.0.0.1",
"[::1]",
"::1",
]);
/**
* Generate a cryptographically secure UUID v4 to use as a peer identifier.
* Universal: works in modern browsers, Bun, and Node >= 19.
*/
export function generateSecurePeerId(): string {
return globalThis.crypto.randomUUID();
}
/**
* Generate a cryptographically secure UUID v4 to use as an in-flight
* request identifier.
*/
export function generateSecureRequestId(): string {
return globalThis.crypto.randomUUID();
}
/**
* Check whether `peerId` matches the canonical UUID v4 shape. The
* function never throws — boundary code that wants a hard rejection
* should pair it with a `DriveWebRtcError(SIGNALING_MESSAGE_INVALID)`
* throw on `false`.
*/
export function validatePeerSecurity(peerId: string): boolean {
if (typeof peerId !== "string") return false;
return UUID_V4_PATTERN.test(peerId);
}
/**
* Validate that a signaling URL uses a transport the plugin is willing
* to talk to: `wss://` everywhere, or `ws://` only when the host is one
* of `localhost`, `127.0.0.1`, or `[::1]` (development).
*/
export function validateSignalingUrl(url: string): boolean {
if (typeof url !== "string" || url.length === 0) return false;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol === "wss:") return true;
if (parsed.protocol === "ws:") {
return HOSTS_ALLOWED_OVER_PLAIN_WS.has(parsed.hostname);
}
return false;
}

View File

@@ -0,0 +1,11 @@
/**
* Public surface for the signaling-client feature module.
*/
export type {
SignalingClientEventMap,
SignalingClientOptions,
SignalingClientState,
SignalingCloseInfo,
} from "./signaling-client.js";
export { SignalingClient } from "./signaling-client.js";

View File

@@ -0,0 +1,165 @@
# SignalingClient
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SignalingClient` owns the WebSocket lifecycle to the WebRTC signaling
server. The signaling layer carries SDP offers and answers, ICE candidates,
peer-mesh roster updates, and the fallback chunk-relay envelopes used when
no direct data channel is available. The client is intentionally separate
from `PeerManager` so the wire codec, reconnect policy, and Zod validation
of inbound traffic can be reasoned about in isolation — peer state lives
elsewhere and consumes typed `SignalingMessage` events from this surface.
A dedicated client also keeps the package universal friendly: the only
runtime browser dependency is `WebSocket`, and the constructor accepts a
custom `webSocketCtor` so a server-tier transport (or a deterministic test
double implementing the `WebSocket` shape) can be plugged in without
touching production code paths.
## Features
- JSON-over-WebSocket wire format with mandatory Zod validation on
every inbound frame via `SignalingMessageSchema`.
- Strongly-typed event surface: `open`, `close`, `message`, `error`,
`reconnect:attempt`, `reconnect:success`, `reconnect:exhausted`.
- Bounded-backoff reconnect with caller-supplied schedule and hard cap on
total attempts. After exhaustion the client emits
`DriveWebRtcError(SIGNALING_UNREACHABLE)`.
- `AbortSignal`-threaded `connect` — pre-aborted signals reject
synchronously and mid-flight aborts close the socket cleanly.
- Outbound send throws `SIGNALING_UNREACHABLE` when the socket is not in
the OPEN state (no silent drops).
- OpenTelemetry surface: reconnect-duration histogram and
direction/outcome-tagged messages counter.
## Usage: Basic Example
```ts
import {
SignalingClient,
type SignalingMessage,
} from "@suaveplan/drive-webrtc";
const client = new SignalingClient({
url: "wss://signaling.example.com/peer",
peerId: crypto.randomUUID(),
});
client.on("open", () => {
client.send({
type: "register",
payload: {
peerId: client.peerId,
peerName: "alice",
chunks: [],
},
});
});
client.on("message", (msg: SignalingMessage) => {
if (msg.type === "registered") {
console.log("registered with peers:", msg.payload.peers.length);
}
});
client.on("error", (err) => {
console.error(err.code, err.message);
});
await client.connect();
```
## Usage: Advanced Example
The constructor accepts an injectable WebSocket factory and a custom
backoff schedule. The example below wires a deterministic clock,
hand-tunes the backoff curve, and threads an `AbortSignal` so the caller
can cancel a long-running reconnect chain on shutdown.
```ts
import { SignalingClient } from "@suaveplan/drive-webrtc";
const controller = new AbortController();
const client = new SignalingClient({
url: "wss://signaling.internal/peer",
peerId: "peer-A",
webSocketCtor: globalThis.WebSocket,
reconnectBackoffMs: [250, 500, 1_000, 2_000],
maxReconnectAttempts: 4,
clock: { now: () => performance.now() },
});
client.on("reconnect:attempt", ({ attempt, delayMs }) => {
console.warn(`reconnect attempt ${attempt} after ${delayMs} ms`);
});
client.on("reconnect:exhausted", ({ attempts }) => {
console.error(`signaling exhausted after ${attempts} attempts`);
});
try {
await client.connect(controller.signal);
} catch (err) {
// pre-aborted signals reject synchronously; mid-flight aborts close
// the socket cleanly and reject with SIGNALING_UNREACHABLE
}
// graceful shutdown — no further reconnect attempts
client.close("shutting down");
```
## API Reference
### `new SignalingClient(options)`
Construct a new client. `options.url` is the signaling endpoint; `peerId`
is the local peer identifier. `webSocketCtor` defaults to
`globalThis.WebSocket`. `reconnectBackoffMs` defaults to
`DEFAULT_RECONNECT_BACKOFF_MS` from the package constants;
`maxReconnectAttempts` defaults to the backoff length. `clock` is
optional and only affects the reconnect-duration histogram metric.
### `connect(signal?: AbortSignal): Promise<void>`
Opens the WebSocket and resolves once `onopen` fires. Repeated calls
while connecting / reconnecting / open are no-ops and return a resolved
promise. Pre-aborted signals reject with `SIGNALING_UNREACHABLE`;
mid-flight aborts close the socket cleanly and reject.
### `send(message: SignalingMessage): void`
Serialises `message` with `JSON.stringify` and writes to the socket.
Throws `DriveWebRtcError(SIGNALING_UNREACHABLE)` when the socket is not
open. No Zod re-validation on send — the type system enforces the shape
at the call site.
### `close(reason?: string): void`
Closes the socket cleanly (code 1000) and disables future reconnect.
Idempotent.
### `state`
Read-only getter returning one of
`"idle" | "connecting" | "open" | "reconnecting" | "closed"`.
## Implementation Notes
- Reconnect is only scheduled after a successful `open`. If the very first
connect attempt fails (unclean close or constructor throw), `connect`
rejects and the client transitions to `closed`. Callers wire their own
outer retry / circuit-breaker if they need re-attempts before any
successful open.
- Inbound `JSON.parse` failure, non-string payloads, and Zod validation
failures all surface as `SIGNALING_MESSAGE_INVALID` via the `error`
event. The connection is NOT closed on malformed messages — this is
defensive behaviour against noisy or partially-upgraded peers.
- `reconnect:attempt` fires before the backoff delay; `reconnect:success`
fires from `onopen` after the socket transitions OPEN. The histogram
records wall-clock elapsed time from the start of reconnect (first
attempt scheduled) to the successful open.
- The class extends `TypedEventEmitter` from the same package so listener
registration mirrors every other event surface in `@suaveplan/drive-webrtc`.

View File

@@ -0,0 +1,651 @@
/**
* Tests for {@link SignalingClient}.
*
* The "no mocks" rule precludes `vi.stubGlobal` / `vi.mock`. Instead, the
* tests drive the client through an in-process structural `FakeSocket`
* that implements the slice of the WebSocket API the client touches. It
* is a real implementation — properties, event dispatch, state — injected
* via the documented `webSocketCtor` extension point.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import type { SignalingMessage } from "../../types/messages.types.js";
import { SignalingClient } from "./signaling-client.js";
const READY_STATE_CONNECTING = 0;
const READY_STATE_OPEN = 1;
const READY_STATE_CLOSING = 2;
const READY_STATE_CLOSED = 3;
class FakeSocket {
public readyState: number = READY_STATE_CONNECTING;
public sentFrames: string[] = [];
public closeCalls: Array<{ code?: number; reason?: string }> = [];
public onopen: ((event: Event) => void) | null = null;
public onmessage: ((event: MessageEvent) => void) | null = null;
public onclose: ((event: CloseEvent) => void) | null = null;
public onerror: ((event: Event) => void) | null = null;
public constructor(public readonly url: string) {
instances.push(this);
}
public send(data: string): void {
if (this.readyState !== READY_STATE_OPEN) {
throw new Error("FakeSocket.send: not open");
}
this.sentFrames.push(data);
}
public close(code?: number, reason?: string): void {
this.closeCalls.push({ code, reason });
if (
this.readyState === READY_STATE_CLOSED ||
this.readyState === READY_STATE_CLOSING
) {
return;
}
this.readyState = READY_STATE_CLOSING;
// Mirror the browser: clean close on user request.
this.simulateClose(code ?? 1000, reason ?? "", true);
}
public simulateOpen(): void {
this.readyState = READY_STATE_OPEN;
this.onopen?.(new Event("open"));
}
public simulateMessage(data: string): void {
this.onmessage?.(new MessageEvent("message", { data }));
}
public simulateClose(
code: number,
reason: string,
wasClean: boolean
): void {
this.readyState = READY_STATE_CLOSED;
// Use a structural object; not all runtimes expose CloseEvent.
const event = { code, reason, wasClean } as unknown as CloseEvent;
this.onclose?.(event);
}
public simulateError(): void {
this.onerror?.(new Event("error"));
}
}
let instances: FakeSocket[] = [];
function resetInstances(): void {
instances = [];
}
function makeCtor(): typeof WebSocket {
return FakeSocket as unknown as typeof WebSocket;
}
const VALID_REGISTER: SignalingMessage = {
type: "register",
payload: { peerId: "peer-a", peerName: "alice", chunks: [] },
};
describe("SignalingClient construction", () => {
it("rejects construction when no WebSocket constructor is available", {
timeout: 5000,
}, () => {
const original = (globalThis as { WebSocket?: unknown }).WebSocket;
(globalThis as { WebSocket?: unknown }).WebSocket = undefined;
try {
expect(
() =>
new SignalingClient({
url: "wss://example",
peerId: "p",
})
).toThrow(DriveWebRtcError);
} finally {
(globalThis as { WebSocket?: unknown }).WebSocket = original;
}
});
it("rejects empty backoff schedule", { timeout: 5000 }, () => {
expect(
() =>
new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
reconnectBackoffMs: [],
})
).toThrow(DriveWebRtcError);
});
it("rejects zero maxReconnectAttempts", { timeout: 5000 }, () => {
expect(
() =>
new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
maxReconnectAttempts: 0,
})
).toThrow(DriveWebRtcError);
});
});
describe("SignalingClient.connect", () => {
it("resolves on open and transitions idle -> connecting -> open", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
expect(client.state).toBe("idle");
const promise = client.connect();
expect(client.state).toBe("connecting");
expect(instances).toHaveLength(1);
instances[0]!.simulateOpen();
await promise;
expect(client.state).toBe("open");
client.close();
});
it("rejects synchronously when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const ctrl = new AbortController();
ctrl.abort();
await expect(client.connect(ctrl.signal)).rejects.toBeInstanceOf(
DriveWebRtcError
);
});
it("closes the socket cleanly when aborted mid-connect", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const ctrl = new AbortController();
const promise = client.connect(ctrl.signal);
ctrl.abort();
await expect(promise).rejects.toBeInstanceOf(DriveWebRtcError);
expect(instances[0]!.closeCalls).toHaveLength(1);
expect(instances[0]!.closeCalls[0]?.reason).toBe("aborted");
});
it("is a no-op when already open", { timeout: 5000 }, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const p1 = client.connect();
instances[0]!.simulateOpen();
await p1;
await client.connect();
expect(instances).toHaveLength(1);
client.close();
});
it("surfaces constructor failure as SIGNALING_UNREACHABLE", {
timeout: 5000,
}, async () => {
class ThrowingSocket {
public constructor() {
throw new Error("nope");
}
}
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: ThrowingSocket as unknown as typeof WebSocket,
});
await expect(client.connect()).rejects.toMatchObject({
code: DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
});
expect(client.state).toBe("closed");
});
it("rejects when onerror fires before open", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const promise = client.connect();
instances[0]!.simulateError();
await expect(promise).rejects.toBeInstanceOf(DriveWebRtcError);
});
it("rejects when close fires before open", { timeout: 5000 }, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const promise = client.connect();
instances[0]!.simulateClose(1000, "bye", true);
await expect(promise).rejects.toBeInstanceOf(DriveWebRtcError);
});
it("rejects on initial unclean close without scheduling reconnect", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const promise = client.connect();
instances[0]!.simulateClose(1006, "lost", false);
await expect(promise).rejects.toBeInstanceOf(DriveWebRtcError);
expect(client.state).toBe("closed");
});
});
describe("SignalingClient.send", () => {
it("writes JSON-serialised frames while open", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
client.send(VALID_REGISTER);
expect(instances[0]!.sentFrames).toHaveLength(1);
expect(JSON.parse(instances[0]!.sentFrames[0]!)).toEqual(
VALID_REGISTER
);
client.close();
});
it("throws SIGNALING_UNREACHABLE when not open", { timeout: 5000 }, () => {
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
expect(() => client.send(VALID_REGISTER)).toThrow(DriveWebRtcError);
});
});
describe("SignalingClient incoming messages", () => {
it("emits typed message events on valid Zod-parsed frames", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
const received: SignalingMessage[] = [];
client.on("message", (msg) => {
received.push(msg);
});
instances[0]!.simulateMessage(JSON.stringify(VALID_REGISTER));
expect(received).toEqual([VALID_REGISTER]);
client.close();
});
it("emits error on malformed JSON without closing", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
const errors: DriveWebRtcError[] = [];
client.on("error", (err) => {
errors.push(err);
});
instances[0]!.simulateMessage("{not json");
expect(errors).toHaveLength(1);
expect(errors[0]?.code).toBe(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID
);
expect(client.state).toBe("open");
client.close();
});
it("emits error on non-string payload", { timeout: 5000 }, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
const errors: DriveWebRtcError[] = [];
client.on("error", (err) => {
errors.push(err);
});
instances[0]!.onmessage?.(
new MessageEvent("message", { data: new ArrayBuffer(4) })
);
expect(errors[0]?.code).toBe(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID
);
client.close();
});
it("emits error on well-formed JSON failing schema validation", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
const errors: DriveWebRtcError[] = [];
client.on("error", (err) => {
errors.push(err);
});
instances[0]!.simulateMessage(
JSON.stringify({ type: "totally-unknown", payload: {} })
);
expect(errors[0]?.code).toBe(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID
);
expect(client.state).toBe("open");
client.close();
});
});
describe("SignalingClient reconnect", () => {
it("schedules reconnect on unclean close and emits success on open", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
reconnectBackoffMs: [1],
maxReconnectAttempts: 3,
});
const attempts: number[] = [];
const successes: number[] = [];
client.on("reconnect:attempt", (info) => {
attempts.push(info.attempt);
});
client.on("reconnect:success", (info) => {
successes.push(info.attempt);
});
const p1 = client.connect();
instances[0]!.simulateOpen();
await p1;
instances[0]!.simulateClose(1006, "lost", false);
await new Promise((r) => setTimeout(r, 10));
expect(instances).toHaveLength(2);
expect(client.state).toBe("reconnecting");
instances[1]!.simulateOpen();
await new Promise((r) => setTimeout(r, 5));
expect(attempts).toEqual([1]);
expect(successes).toEqual([1]);
expect(client.state).toBe("open");
client.close();
});
it("emits exhausted + error after maxReconnectAttempts", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
reconnectBackoffMs: [1],
maxReconnectAttempts: 2,
});
const exhausted: Array<{ attempts: number }> = [];
const errors: DriveWebRtcError[] = [];
client.on("reconnect:exhausted", (info) => {
exhausted.push(info);
});
client.on("error", (err) => {
errors.push(err);
});
const p1 = client.connect();
instances[0]!.simulateOpen();
await p1;
instances[0]!.simulateClose(1006, "lost", false);
await new Promise((r) => setTimeout(r, 10));
// 2nd socket fails uncleanly before open
instances[1]!.simulateClose(1006, "lost", false);
await new Promise((r) => setTimeout(r, 10));
// 3rd socket fails uncleanly before open -> attempts hit max
instances[2]!.simulateClose(1006, "lost", false);
await new Promise((r) => setTimeout(r, 10));
expect(exhausted).toEqual([{ attempts: 2 }]);
expect(
errors.some(
(e) => e.code === DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE
)
).toBe(true);
expect(client.state).toBe("closed");
});
it("does NOT reconnect after user-initiated close", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
reconnectBackoffMs: [1],
});
const attempts: number[] = [];
client.on("reconnect:attempt", (info) => {
attempts.push(info.attempt);
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
client.close();
await new Promise((r) => setTimeout(r, 10));
expect(attempts).toEqual([]);
expect(client.state).toBe("closed");
expect(instances).toHaveLength(1);
});
it("is idempotent on repeated close()", { timeout: 5000 }, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
client.close();
client.close();
expect(client.state).toBe("closed");
});
it("does not reconnect on clean close", { timeout: 5000 }, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
reconnectBackoffMs: [1],
});
const attempts: number[] = [];
client.on("reconnect:attempt", (info) => {
attempts.push(info.attempt);
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
instances[0]!.simulateClose(1000, "bye", true);
await new Promise((r) => setTimeout(r, 10));
expect(attempts).toEqual([]);
expect(client.state).toBe("closed");
});
});
describe("SignalingClient close timing", () => {
it("rejects in-flight connect promise when user closes mid-connect", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const promise = client.connect();
client.close("shutting down");
await expect(promise).rejects.toBeInstanceOf(DriveWebRtcError);
expect(client.state).toBe("closed");
});
it("clears the pending reconnect timer when user closes during backoff", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
reconnectBackoffMs: [50],
maxReconnectAttempts: 3,
});
const attempts: number[] = [];
client.on("reconnect:attempt", (info) => {
attempts.push(info.attempt);
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
instances[0]!.simulateClose(1006, "lost", false);
// backoff scheduled but not yet fired
expect(attempts).toEqual([1]);
expect(client.state).toBe("reconnecting");
client.close("stop");
// wait beyond the backoff window to confirm no new socket
await new Promise((r) => setTimeout(r, 80));
expect(instances).toHaveLength(1);
expect(client.state).toBe("closed");
});
});
describe("SignalingClient late event guards", () => {
it("ignores a late onopen after the connect promise was already settled", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const ctrl = new AbortController();
const promise = client.connect(ctrl.signal);
ctrl.abort();
await expect(promise).rejects.toBeInstanceOf(DriveWebRtcError);
// Late open should not transition state away from "closed".
instances[0]!.onopen?.(new Event("open"));
expect(client.state).toBe("closed");
});
it("emits error but does not reject when onerror fires after open", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
});
const errors: DriveWebRtcError[] = [];
client.on("error", (err) => {
errors.push(err);
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
instances[0]!.simulateError();
expect(errors).toHaveLength(1);
expect(client.state).toBe("open");
client.close();
});
});
describe("SignalingClient OTel surface", () => {
it("exercises histogram + counter without throwing", {
timeout: 5000,
}, async () => {
resetInstances();
const client = new SignalingClient({
url: "wss://example",
peerId: "p",
webSocketCtor: makeCtor(),
reconnectBackoffMs: [1],
clock: { now: () => 0 },
});
const p = client.connect();
instances[0]!.simulateOpen();
await p;
client.send(VALID_REGISTER);
instances[0]!.simulateMessage(JSON.stringify(VALID_REGISTER));
instances[0]!.simulateMessage("not json");
instances[0]!.simulateClose(1006, "lost", false);
await new Promise((r) => setTimeout(r, 10));
instances[1]!.simulateOpen();
await new Promise((r) => setTimeout(r, 5));
// counter for failed send while not open
client.close();
expect(() => client.send(VALID_REGISTER)).toThrow(DriveWebRtcError);
});
});
// Acknowledge unused-state constants are kept for symmetry with the
// WebSocket spec even though only OPEN is consulted at runtime.
void READY_STATE_CLOSING;
void READY_STATE_CLOSED;

View File

@@ -0,0 +1,461 @@
/**
* Signaling client over WebSocket.
*
* Owns the WebSocket lifecycle to the signaling server: connection,
* Zod-validated inbound message dispatch, JSON-serialised outbound send,
* and reconnect with bounded backoff. Production paths use the real
* `globalThis.WebSocket`; tests inject a structural stand-in via the
* `webSocketCtor` option to drive the lifecycle deterministically without
* any mocking framework.
*/
import { metrics, trace } from "@opentelemetry/api";
import { DEFAULT_RECONNECT_BACKOFF_MS } from "../../constants/index.js";
import {
DRIVE_WEBRTC_CODES,
DriveWebRtcError,
} from "../../error-codes/error-codes.js";
import { TypedEventEmitter } from "../../events/typed-event-emitter/index.js";
import { SignalingMessageSchema } from "../../schemas/signaling.schemas.js";
import type { SignalingMessage } from "../../types/messages.types.js";
/**
* Lifecycle state of a {@link SignalingClient}.
*/
export type SignalingClientState =
| "idle"
| "connecting"
| "open"
| "reconnecting"
| "closed";
/**
* Structural close event compatible with the browser `CloseEvent` shape.
*/
export interface SignalingCloseInfo {
code: number;
reason: string;
wasClean: boolean;
}
/**
* Events emitted by {@link SignalingClient}.
*/
export type SignalingClientEventMap = {
open: [];
close: [SignalingCloseInfo];
message: [SignalingMessage];
error: [DriveWebRtcError];
"reconnect:attempt": [{ attempt: number; delayMs: number }];
"reconnect:success": [{ attempt: number; elapsedMs: number }];
"reconnect:exhausted": [{ attempts: number }];
};
/**
* Constructor options for {@link SignalingClient}.
*/
export interface SignalingClientOptions {
readonly url: string;
readonly peerId: string;
readonly webSocketCtor?: typeof WebSocket;
readonly reconnectBackoffMs?: readonly number[];
readonly maxReconnectAttempts?: number;
readonly clock?: { now: () => number };
}
const TRACER_NAME = "@suaveplan/drive-webrtc";
const tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(TRACER_NAME);
const reconnectHistogram = meter.createHistogram(
"drive.webrtc.signaling_reconnect_ms",
{ description: "Time spent reconnecting to the signaling server (ms)" }
);
const messagesCounter = meter.createCounter(
"drive.webrtc.signaling_messages_total",
{ description: "Signaling messages by direction and outcome" }
);
const READY_STATE_OPEN = 1;
type Timer = ReturnType<typeof setTimeout>;
/**
* WebSocket-backed signaling client with bounded-backoff reconnect.
*/
export class SignalingClient extends TypedEventEmitter<SignalingClientEventMap> {
public readonly url: string;
public readonly peerId: string;
private readonly webSocketCtor: typeof WebSocket;
private readonly backoff: readonly number[];
private readonly maxReconnectAttempts: number;
private readonly clock: { now: () => number };
private socket: WebSocket | null = null;
private currentState: SignalingClientState = "idle";
private reconnectTimer: Timer | null = null;
private reconnectAttempt = 0;
private reconnectStartedAt = 0;
private userClosed = false;
private abortListener: (() => void) | null = null;
private currentSignal: AbortSignal | null = null;
public constructor(options: SignalingClientOptions) {
super();
this.url = options.url;
this.peerId = options.peerId;
const ctor = options.webSocketCtor ?? globalThis.WebSocket;
if (typeof ctor !== "function") {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"no WebSocket constructor available (set options.webSocketCtor)"
);
}
this.webSocketCtor = ctor;
const backoff =
options.reconnectBackoffMs ?? DEFAULT_RECONNECT_BACKOFF_MS;
if (backoff.length === 0) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CONFIG_INVALID,
"reconnectBackoffMs must contain at least one step"
);
}
this.backoff = backoff;
this.maxReconnectAttempts =
options.maxReconnectAttempts ?? backoff.length;
if (this.maxReconnectAttempts < 1) {
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.CONFIG_INVALID,
"maxReconnectAttempts must be >= 1"
);
}
this.clock = options.clock ?? { now: () => Date.now() };
}
/** Current lifecycle state. */
public get state(): SignalingClientState {
return this.currentState;
}
/**
* Connect to the signaling server. Resolves once the underlying socket
* fires `open`. Pre-aborted signals reject immediately; mid-flight
* aborts close the socket cleanly and reject.
*/
public connect(signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) {
return Promise.reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"connect aborted before start"
)
);
}
if (
this.currentState === "connecting" ||
this.currentState === "reconnecting" ||
this.currentState === "open"
) {
return Promise.resolve();
}
this.userClosed = false;
return this.openSocket(signal);
}
/**
* Send a serialised {@link SignalingMessage}. Throws
* `SIGNALING_UNREACHABLE` when the socket is not open.
*/
public send(message: SignalingMessage): void {
const socket = this.socket;
if (
this.currentState !== "open" ||
socket === null ||
socket.readyState !== READY_STATE_OPEN
) {
messagesCounter.add(1, {
direction: "outbound",
outcome: "invalid",
});
throw new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"signaling socket is not open"
);
}
socket.send(JSON.stringify(message));
messagesCounter.add(1, { direction: "outbound", outcome: "valid" });
}
/**
* Close the connection. User-initiated closes disable reconnect and are
* idempotent.
*/
public close(reason = "client closed"): void {
if (this.currentState === "closed") return;
this.userClosed = true;
this.clearReconnectTimer();
this.detachAbortListener();
const socket = this.socket;
this.socket = null;
if (socket !== null) {
try {
socket.close(1000, reason);
} catch {
// intentionally ignored — close() may throw if already closed
}
}
this.currentState = "closed";
}
private openSocket(signal?: AbortSignal): Promise<void> {
this.currentSignal = signal ?? null;
this.currentState =
this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
const span = tracer.startSpan("signaling.connect", {
attributes: {
"drive.webrtc.peer_id": this.peerId,
"drive.webrtc.url": this.url,
"drive.webrtc.reconnect_attempt": this.reconnectAttempt,
},
});
return new Promise<void>((resolve, reject) => {
let settled = false;
let socket: WebSocket;
try {
socket = new this.webSocketCtor(this.url);
} catch (cause) {
span.recordException(cause as Error);
span.end();
const err = new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"failed to construct WebSocket",
{ cause: cause as Error }
);
this.emit("error", err);
this.currentState = "closed";
reject(err);
return;
}
this.socket = socket;
if (signal !== undefined) {
const onAbort = (): void => {
settled = true;
try {
socket.close(1000, "aborted");
} catch {
// ignore
}
this.socket = null;
this.currentState = "closed";
this.userClosed = true;
span.end();
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"connect aborted"
)
);
};
this.abortListener = onAbort;
signal.addEventListener("abort", onAbort, { once: true });
}
socket.onopen = (): void => {
if (settled) return;
settled = true;
this.detachAbortListener();
this.currentState = "open";
if (this.reconnectAttempt > 0) {
const elapsed = this.clock.now() - this.reconnectStartedAt;
reconnectHistogram.record(elapsed, {
attempt: this.reconnectAttempt,
outcome: "success",
});
this.emit("reconnect:success", {
attempt: this.reconnectAttempt,
elapsedMs: elapsed,
});
this.reconnectAttempt = 0;
}
span.end();
this.emit("open");
resolve();
};
socket.onmessage = (event: MessageEvent): void => {
this.handleIncoming(event.data);
};
socket.onerror = (): void => {
const err = new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"signaling socket error"
);
this.emit("error", err);
if (!settled) {
settled = true;
this.detachAbortListener();
span.recordException(err);
span.end();
reject(err);
}
};
socket.onclose = (event: CloseEvent): void => {
const info: SignalingCloseInfo = {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
};
this.socket = null;
this.detachAbortListener();
this.emit("close", info);
if (this.userClosed || this.currentState === "closed") {
this.currentState = "closed";
if (!settled) {
settled = true;
span.end();
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"socket closed before open"
)
);
}
return;
}
const unclean = info.wasClean === false || info.code >= 1006;
if (!unclean) {
this.currentState = "closed";
if (!settled) {
settled = true;
span.end();
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"socket closed before open"
)
);
}
return;
}
if (!settled) {
settled = true;
span.end();
if (this.reconnectAttempt === 0) {
// Initial connect itself failed uncleanly — surface
// to the awaiter; do not start a reconnect loop
// the caller hasn't acknowledged.
this.currentState = "closed";
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"socket closed before open"
)
);
return;
}
// Mid-reconnect-loop failure: keep going.
this.scheduleReconnect();
reject(
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
"reconnect socket closed before open"
)
);
return;
}
this.scheduleReconnect();
};
});
}
private handleIncoming(data: unknown): void {
let raw: string;
if (typeof data === "string") {
raw = data;
} else {
this.emitInvalid("non-string signaling payload");
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (cause) {
this.emitInvalid("malformed signaling JSON", cause as Error);
return;
}
const result = SignalingMessageSchema.safeParse(parsed);
if (!result.success) {
this.emitInvalid("signaling message failed Zod validation");
return;
}
messagesCounter.add(1, { direction: "inbound", outcome: "valid" });
this.emit("message", result.data as SignalingMessage);
}
private emitInvalid(message: string, cause?: Error): void {
messagesCounter.add(1, { direction: "inbound", outcome: "invalid" });
this.emit(
"error",
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_MESSAGE_INVALID,
message,
cause === undefined ? undefined : { cause }
)
);
}
private scheduleReconnect(): void {
if (this.reconnectAttempt >= this.maxReconnectAttempts) {
const attempts = this.reconnectAttempt;
this.currentState = "closed";
reconnectHistogram.record(
this.clock.now() - this.reconnectStartedAt,
{ attempt: attempts, outcome: "exhausted" }
);
this.emit("reconnect:exhausted", { attempts });
this.emit(
"error",
new DriveWebRtcError(
DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE,
`signaling unreachable after ${attempts} attempts`
)
);
return;
}
const idx = Math.min(this.reconnectAttempt, this.backoff.length - 1);
// Safe non-null assertion: `backoff` is validated non-empty in the
// constructor and `idx` is clamped to its last index.
const delayMs = this.backoff[idx] as number;
this.reconnectAttempt += 1;
if (this.reconnectAttempt === 1) {
this.reconnectStartedAt = this.clock.now();
}
this.currentState = "reconnecting";
this.emit("reconnect:attempt", {
attempt: this.reconnectAttempt,
delayMs,
});
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.openSocket(this.currentSignal ?? undefined).catch(() => {
// Per-attempt failures route through the onclose handler
// which calls scheduleReconnect again; nothing to do here.
});
}, delayMs);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private detachAbortListener(): void {
if (this.abortListener !== null && this.currentSignal !== null) {
this.currentSignal.removeEventListener("abort", this.abortListener);
}
this.abortListener = null;
}
}

View File

@@ -0,0 +1,14 @@
export {
decodeFromBase64,
encodeToBase64,
type SubChunkAddResult,
SubChunkReassembler,
type SubChunkReassemblerOptions,
sendSubChunks,
splitIntoSubChunks,
} from "./sub-chunk.js";
export type {
SubChunkCallback,
SubChunkPart,
SubChunkSplitterOptions,
} from "./sub-chunk.types.js";

View File

@@ -0,0 +1,123 @@
# Sub-chunk splitter / reassembler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The sub-chunk module slices large chunk payloads into ordered,
DataChannel-safe slices, ships them through a caller-supplied transport
callback with optional pacing and abort support, and rebuilds the
original `Uint8Array` payload on the receiver side regardless of arrival
order. WebRTC's SCTP-backed data channels cap reliable interop message
size at roughly 16 KB; sending a single 1 MB chunk in one `dc.send`
call closes the channel on most browsers, so every payload that crosses
the wire has to be sliced.
The receiver half — `SubChunkReassembler` — is integrity-strict:
duplicate part indices are surfaced as `duplicate` (state is never
mutated twice), missing indices raise `SUB_CHUNK_MISSING`, and
assemblies that stall past `timeoutMs` are pruned by `cleanup()` and
raise `SUB_CHUNK_TIMEOUT` on `assertComplete`. Both error paths surface
typed `DriveWebRtcError` instances so upstream code can drive eviction
policy without parsing message strings.
## Features
- `splitIntoSubChunks(data, options)` — zero-copy slicing via
`Uint8Array.subarray`, default `maxBytes = 16_000`, single zero-length
part for empty payloads so the receiver always sees `total >= 1`.
- `sendSubChunks(parts, callback, options)` — sequential dispatch with
optional `delayMs` inter-part pacing; `AbortSignal` is checked at
every iteration and during every inter-part delay.
- `SubChunkReassembler.addPart` — discriminated-union result
(`incomplete` / `complete` / `duplicate`). Completion deletes pending
state so memory stays bounded.
- `assertComplete(chunkId)` — surfaces `SUB_CHUNK_MISSING` with the
missing index list in `error.context` or `SUB_CHUNK_TIMEOUT` past
`timeoutMs`.
- `cleanup()` — returns the chunkIds that were dropped past timeout so
callers can notify upstream that delivery stalled.
- `encodeToBase64` / `decodeFromBase64` — `btoa`/`atob`-backed helpers
for callers that need a JSON-safe wire form (chunked across 32 768
bytes to avoid `String.fromCharCode` argument-cap blowups).
## Usage: Basic Example
```ts
import {
SubChunkReassembler,
sendSubChunks,
splitIntoSubChunks,
} from "@suaveplan/drive-webrtc/sub-chunk/sub-chunk-splitter";
const payload = new Uint8Array(40_000);
const parts = splitIntoSubChunks(payload, { chunkId: "file-1:0" });
const reassembler = new SubChunkReassembler();
await sendSubChunks(parts, (part) => {
const result = reassembler.addPart(part);
if (result.kind === "complete") {
console.log("got", result.chunkId, result.data.byteLength, "bytes");
}
});
```
## Usage: Advanced Example
```ts
import {
SubChunkReassembler,
sendSubChunks,
splitIntoSubChunks,
} from "@suaveplan/drive-webrtc/sub-chunk/sub-chunk-splitter";
const controller = new AbortController();
const parts = splitIntoSubChunks(payload, {
chunkId: "file-7:42",
maxBytes: 8_192,
});
const reassembler = new SubChunkReassembler({
clock: () => testNow,
timeoutMs: 5_000,
});
try {
await sendSubChunks(parts, transport.send, {
signal: controller.signal,
delayMs: 5,
});
} catch (error) {
if ((error as DOMException).name !== "AbortError") throw error;
}
// Later — sweep stalled assemblies:
const dropped = reassembler.cleanup();
for (const chunkId of dropped) telemetry.recordStalled(chunkId);
```
## API Reference
- `splitIntoSubChunks(data: Uint8Array, options: SubChunkSplitterOptions): SubChunkPart[]`
— throws `RangeError` on non-positive `maxBytes`.
- `sendSubChunks(parts, callback, { signal?, delayMs? }): Promise<void>`
— `AbortSignal` raises `DOMException("...", "AbortError")`.
- `SubChunkReassembler({ clock?, timeoutMs? })`
- `.addPart(part) → SubChunkAddResult`
- `.assertComplete(chunkId): void` — throws `DriveWebRtcError`
(`SUB_CHUNK_MISSING` / `SUB_CHUNK_TIMEOUT`).
- `.cleanup(): readonly string[]` — prune + return dropped ids.
- `.inProgress(): readonly string[]`, `.size`, `.clear()`.
- `encodeToBase64(bytes) → string`, `decodeFromBase64(b64) → Uint8Array`.
## Implementation Notes
`splitIntoSubChunks` returns `subarray` views — it never copies bytes —
so the hot path stays zero-copy. Callers that intend to retain a part
past the original buffer's lifetime must `.slice()` it. The reassembler
copies on `assemble()` (necessary, since parts arrive interleaved with
other chunks' parts). `cleanup()` runs in O(pending), so callers should
schedule it from a periodic timer rather than per-part. Aborts during
inter-part delay clear the underlying `setTimeout` to avoid leaking
timers.

Some files were not shown because too many files have changed in this diff Show More