Initial Commit
This commit is contained in:
395
packages/networking/drive/drive-webrtc/README.md
Normal file
395
packages/networking/drive/drive-webrtc/README.md
Normal 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.
|
||||
|
||||
145
packages/networking/drive/drive-webrtc/package.json
Normal file
145
packages/networking/drive/drive-webrtc/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type ChunkSourceOptions,
|
||||
WebRTCChunkSource,
|
||||
type WebRTCChunkSourceHealth,
|
||||
} from "./chunk-source.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Public surface for the WebRTCClient orchestrator.
|
||||
*/
|
||||
|
||||
export type {
|
||||
RTCPeerConnectionCtor,
|
||||
WebRTCClientEventMap,
|
||||
WebRTCClientOptions,
|
||||
} from "./client.js";
|
||||
export { resolveConfig, WebRTCClient } from "./client.js";
|
||||
116
packages/networking/drive/drive-webrtc/src/client/ice/ice.md
Normal file
116
packages/networking/drive/drive-webrtc/src/client/ice/ice.md
Normal 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.
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
142
packages/networking/drive/drive-webrtc/src/client/ice/ice.ts
Normal file
142
packages/networking/drive/drive-webrtc/src/client/ice/ice.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Public surface for the SDP offer/answer primitives.
|
||||
*/
|
||||
|
||||
export { createOffer, handleAnswer, handleOffer } from "./offer-answer.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Public surface for the reconnect controller.
|
||||
*/
|
||||
|
||||
export type { ReconnectPolicy, ReconnectStep } from "./reconnect.js";
|
||||
export { ReconnectController } from "./reconnect.js";
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type DataChannelEventMap,
|
||||
DataChannelManager,
|
||||
type DataChannelManagerOptions,
|
||||
} from "./data-channel-manager.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type ChunkDiscoveryEventMap,
|
||||
ChunkDiscoveryManager,
|
||||
type ChunkDiscoveryManagerOptions,
|
||||
} from "./chunk-discovery-manager.js";
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { DriveWebRtcErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
DRIVE_WEBRTC_CODES,
|
||||
DriveWebRtcError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { EventArgsMap } from "./typed-event-emitter.js";
|
||||
export { TypedEventEmitter } from "./typed-event-emitter.js";
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
200
packages/networking/drive/drive-webrtc/src/index.ts
Normal file
200
packages/networking/drive/drive-webrtc/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
buildSignatureMessage,
|
||||
ChunkIntegrityManager,
|
||||
computeChunkHash,
|
||||
verifyChunkIntegrity,
|
||||
} from "./chunk-integrity.js";
|
||||
export type {
|
||||
ChunkIntegrityManagerOptions,
|
||||
ChunkMetadata,
|
||||
ChunkVerificationResult,
|
||||
Ed25519Verifier,
|
||||
} from "./chunk-integrity.types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { LRUCache } from "./lru-cache.js";
|
||||
export type {
|
||||
LRUCacheEvictionReason,
|
||||
LRUCacheOptions,
|
||||
} from "./lru-cache.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { PeerBandwidthTracker } from "./peer-bandwidth-tracker.js";
|
||||
export type {
|
||||
PeerBandwidthTrackerConfig,
|
||||
PeerMetrics,
|
||||
} from "./peer-bandwidth-tracker.types.js";
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PeerManager,
|
||||
type PeerManagerEventMap,
|
||||
type PeerManagerOptions,
|
||||
} from "./peer-manager.js";
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
LocalChunkProvider,
|
||||
WebRTCDrivePluginOptions,
|
||||
} from "./webrtc-plugin.js";
|
||||
export { WebRTCDrivePlugin } from "./webrtc-plugin.js";
|
||||
@@ -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).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RateLimiter } from "./rate-limiter.js";
|
||||
export type { RateLimiterOptions } from "./rate-limiter.types.js";
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
});
|
||||
31
packages/networking/drive/drive-webrtc/src/schemas/index.ts
Normal file
31
packages/networking/drive/drive-webrtc/src/schemas/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
generateSecurePeerId,
|
||||
generateSecureRequestId,
|
||||
validatePeerSecurity,
|
||||
validateSignalingUrl,
|
||||
} from "./security-validator.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
Reference in New Issue
Block a user