first commit
This commit is contained in:
395
docs/networking/drive/drive-webrtc/README.md
Normal file
395
docs/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.
|
||||
|
||||
@@ -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.
|
||||
157
docs/networking/drive/drive-webrtc/src/client/client/client.md
Normal file
157
docs/networking/drive/drive-webrtc/src/client/client/client.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# WebRTCClient
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`WebRTCClient` is the primary orchestrator surface in `@suaveplan/drive-webrtc`.
|
||||
It owns a `Map<peerId, RTCPeerConnection>`, wires every peer's ICE,
|
||||
connection-state, and data-channel listeners through the `ice` and
|
||||
`offer-answer` helpers in this package, and emits a focused, typed event
|
||||
stream describing peer-connection lifecycle. The class is intentionally
|
||||
**not** the full P2P mesh — chunk transfer, peer discovery, and the
|
||||
signaling-protocol glue land in sibling subsystems that consume the
|
||||
connection lifecycle this class exposes.
|
||||
|
||||
Configuration goes through Zod at the boundary: `resolveConfig` runs
|
||||
`WebRTCPluginConfigSchema.safeParse` and throws
|
||||
`DriveWebRtcError(CONFIG_INVALID)` (with the Zod error chained via
|
||||
`cause`) on any failure, then derives a fully-defaulted `ResolvedConfig`.
|
||||
Tests inject a structural `FakeRTCPeerConnection` via the
|
||||
`rtcPeerConnectionCtor` option — the same injection pattern
|
||||
`SignalingClient` uses for `WebSocket` — so the package never relies on
|
||||
`vi.stubGlobal` or any module-level monkey patching.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy per-peer `RTCPeerConnection` creation cached in an internal map.
|
||||
- Spec-faithful offer/answer/ICE primitives delegated to the
|
||||
`offer-answer` and `ice` modules so the orchestrator stays focused on
|
||||
lifecycle and eventing.
|
||||
- Bounded ICE auto-restart (default 3 attempts) with deterministic
|
||||
`ice:restart` events carrying `{ peerId, attempt, timestamp }`.
|
||||
- Typed event map (`WebRTCClientEventMap`) covering ICE candidates,
|
||||
ICE state, connection state, data-channel open, and peer disconnect.
|
||||
- `closePeer` / `closeAll` tear down listeners, close the underlying
|
||||
connections, and emit a final `peer:disconnect`; both are idempotent.
|
||||
- Every thrown WebRTC error is wrapped as `DriveWebRtcError` with the
|
||||
original error preserved via `cause`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WebRTCClient } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const client = new WebRTCClient({
|
||||
peerId: "alice",
|
||||
config: { signaling: { url: "wss://signal.example" } },
|
||||
});
|
||||
|
||||
client.on("ice:candidate", ({ peerId, candidate }) =>
|
||||
signaling.send({ type: "ice-candidate", to: peerId, payload: candidate })
|
||||
);
|
||||
|
||||
const offer = await client.createOffer("bob");
|
||||
signaling.send({ type: "offer", to: "bob", payload: offer });
|
||||
|
||||
// Later, after bob's answer arrives:
|
||||
await client.handleAnswer("bob", remoteAnswer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { resolveConfig, WebRTCClient } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const config = resolveConfig({
|
||||
signaling: { url: "wss://signal.example" },
|
||||
p2p: {
|
||||
iceServers: [{ urls: "turn:turn.example", username: "u", credential: "p" }],
|
||||
timeout: 15_000,
|
||||
},
|
||||
});
|
||||
|
||||
const client = new WebRTCClient({
|
||||
peerId: "alice",
|
||||
config,
|
||||
// Tests / non-browser runtimes: inject the ctor explicitly.
|
||||
rtcPeerConnectionCtor: FakeRTCPeerConnection,
|
||||
clock: { now: () => deterministicClock.now() },
|
||||
maxIceRestartAttempts: 5,
|
||||
});
|
||||
|
||||
client.on("ice:restart", (info) => log.warn("ice restart", info));
|
||||
client.on("peer:disconnect", (info) => peerManager.drop(info.peerId));
|
||||
|
||||
// Inbound handshake:
|
||||
const answer = await client.handleOffer("bob", remoteOffer);
|
||||
signaling.send({ type: "answer", to: "bob", payload: answer });
|
||||
|
||||
// Trickled candidates:
|
||||
await client.addIceCandidate("bob", remoteCandidate);
|
||||
|
||||
// Teardown:
|
||||
client.closePeer("bob", "session-end");
|
||||
client.closeAll("client-shutdown");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new WebRTCClient(options)`
|
||||
|
||||
- `options.peerId: string`
|
||||
- `options.config: WebRTCPluginConfig` — Zod-validated.
|
||||
- `options.rtcPeerConnectionCtor?: typeof RTCPeerConnection`
|
||||
- `options.clock?: { now: () => number }`
|
||||
- `options.maxIceRestartAttempts?: number` (default 3)
|
||||
|
||||
Throws `DriveWebRtcError(CONFIG_INVALID)` when config validation fails
|
||||
or when no `RTCPeerConnection` ctor is available.
|
||||
|
||||
### `createOffer(remotePeerId, signal?)`
|
||||
|
||||
Creates the peer connection if needed and returns the local SDP offer.
|
||||
Throws `DriveWebRtcError(PEER_HANDSHAKE_FAILED)` on any failure.
|
||||
|
||||
### `handleOffer(remotePeerId, offer, signal?)`
|
||||
|
||||
Accepts a remote offer and returns the local answer.
|
||||
|
||||
### `handleAnswer(remotePeerId, answer)`
|
||||
|
||||
Applies a remote answer to an outbound offer. Throws
|
||||
`DriveWebRtcError(PEER_NOT_FOUND)` if the peer was never registered.
|
||||
|
||||
### `addIceCandidate(remotePeerId, candidate)`
|
||||
|
||||
Wraps `pc.addIceCandidate`. Throws `DriveWebRtcError(ICE_FAILED)` on
|
||||
failure or `PEER_NOT_FOUND` if the peer is unknown.
|
||||
|
||||
### `getPeerConnection(remotePeerId)`
|
||||
|
||||
Returns the underlying `RTCPeerConnection` or `undefined`. Stable across
|
||||
calls for the same peer; distinct instances per peer.
|
||||
|
||||
### `closePeer(remotePeerId, reason?)` / `closeAll(reason?)`
|
||||
|
||||
Idempotent teardown. Removes listeners, closes the connection, deletes
|
||||
the map entry, and emits a final `peer:disconnect`.
|
||||
|
||||
### `resolveConfig(input): ResolvedConfig`
|
||||
|
||||
Exported standalone so consumers can pre-validate configuration.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class extends `TypedEventEmitter<WebRTCClientEventMap>` so handler
|
||||
signatures are statically checked at every `emit`/`on` site.
|
||||
- Construction-time Zod validation means downstream code can assume
|
||||
`resolvedConfig` is always well-formed.
|
||||
- `wirePeer` keeps the per-peer listener setup in one place; both
|
||||
inbound and outbound flows share the exact same wiring.
|
||||
- `connection:state === "closed" | "failed"` triggers an automatic
|
||||
`peer:disconnect` so callers don't have to mirror the two state
|
||||
machines.
|
||||
- `closePeer` is idempotent (lookup-then-return on miss); duplicate
|
||||
calls during teardown are harmless.
|
||||
116
docs/networking/drive/drive-webrtc/src/client/ice/ice.md
Normal file
116
docs/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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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).
|
||||
@@ -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,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.
|
||||
126
docs/networking/drive/drive-webrtc/src/schemas/config.schemas.md
Normal file
126
docs/networking/drive/drive-webrtc/src/schemas/config.schemas.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Config Schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
The `config.schemas.ts` module defines Zod boundary schemas for the configuration surface declared in `types/config.types.ts`. Public entry points (the plugin's `initialize`, the client's `create`, the signaling client's constructor) parse caller-supplied configuration through `WebRTCPluginConfigSchema` before any internal code touches it. Failures throw `DriveWebRtcError(CONFIG_INVALID, ...)` with the Zod issue list attached for triage.
|
||||
|
||||
Validating configuration at the boundary means internal code can rely on the `ResolvedConfig` materialised after `resolveConfig()` runs — no `if (config.signaling?.url !== undefined)` defensive checks scattered through the codebase. Every required field is enforced by Zod at one well-known entry point.
|
||||
|
||||
## Features
|
||||
|
||||
- One schema per config interface (`SignalingConfig`, `P2PConfig`, `SubChunkConfig`, `WebRTCPluginConfig`)
|
||||
- First-class `IceServerSchema` for the Original `iceServers` field
|
||||
- Strict numeric ranges (positive timeouts, non-negative delays, integer counts)
|
||||
- `urls` accepts either a single string or a non-empty array
|
||||
- All optional fields use `.optional()` rather than `.nullable()` — undefined is the canonical absent
|
||||
- Compatible with the `WebRTCPluginConfig` type without explicit `z.infer`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import { WebRTCPluginConfigSchema } from "@suaveplan/drive-webrtc/schemas";
|
||||
|
||||
const result = WebRTCPluginConfigSchema.safeParse({
|
||||
signaling: { url: "wss://signal.example.com" },
|
||||
});
|
||||
if (!result.success) {
|
||||
console.error(result.error.message);
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DRIVE_WEBRTC_CODES,
|
||||
DriveWebRtcError,
|
||||
IceServerSchema,
|
||||
WebRTCPluginConfigSchema,
|
||||
} from "@suaveplan/drive-webrtc";
|
||||
|
||||
function parseConfig(raw: unknown): ReturnType<
|
||||
typeof WebRTCPluginConfigSchema.parse
|
||||
> {
|
||||
const r = WebRTCPluginConfigSchema.safeParse(raw);
|
||||
if (!r.success) {
|
||||
throw new DriveWebRtcError(
|
||||
DRIVE_WEBRTC_CODES.CONFIG_INVALID,
|
||||
r.error.message,
|
||||
{ context: { issues: r.error.issues } },
|
||||
);
|
||||
}
|
||||
return r.data;
|
||||
}
|
||||
|
||||
function mergeIceServers(
|
||||
base: unknown,
|
||||
extra: unknown,
|
||||
): readonly RTCIceServer[] {
|
||||
const a = IceServerSchema.array().parse(base);
|
||||
const b = IceServerSchema.array().parse(extra);
|
||||
return [...a, ...b];
|
||||
}
|
||||
|
||||
const config = parseConfig({
|
||||
signaling: { url: "wss://signal.example.com" },
|
||||
p2p: {
|
||||
iceServers: [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
{
|
||||
urls: ["turn:turn.example.com:3478"],
|
||||
username: "u",
|
||||
credential: "p",
|
||||
credentialType: "password",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IceServerSchema`
|
||||
|
||||
- `urls: string | string[]` (array must be non-empty)
|
||||
- `username?: string`
|
||||
- `credential?: string`
|
||||
- `credentialType?: "password"`
|
||||
|
||||
### `SignalingConfigSchema`
|
||||
|
||||
- `url: string` (non-empty, required)
|
||||
- `peerId?: string` (non-empty)
|
||||
- `peerName?: string`
|
||||
- `authToken?: string`
|
||||
- `reconnect?: boolean`
|
||||
- `reconnectDelay?: number` (positive integer)
|
||||
|
||||
### `P2PConfigSchema`
|
||||
|
||||
- `priority?: number` (non-negative integer)
|
||||
- `timeout?: number` (positive integer)
|
||||
- `iceServers?: IceServerSchema[]`
|
||||
- `maxConcurrentDownloads?: number` (positive integer)
|
||||
|
||||
### `SubChunkConfigSchema`
|
||||
|
||||
- `size?: number` (positive integer)
|
||||
- `backpressure?: number` (positive integer)
|
||||
- `rateLimit?: { delayMs: number (≥0); everyNParts: number (>0) }`
|
||||
|
||||
### `WebRTCPluginConfigSchema`
|
||||
|
||||
- `signaling: SignalingConfigSchema` (required)
|
||||
- `p2p?: P2PConfigSchema`
|
||||
- `subChunks?: SubChunkConfigSchema`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schemas do NOT apply defaults — that's `resolveConfig()`'s job. Splitting validation and default-resolution keeps the schema layer pure (the same input always produces the same parse result, no environment coupling).
|
||||
- `IceServerSchema.urls` accepts both string and string-array forms because the W3C `RTCIceServer` interface allows both; we enforce a non-empty array (Zod's `.min(1)`) for the array form because an empty list is meaningless.
|
||||
- `credentialType` is intentionally restricted to `"password"` — the legacy `"oauth"` value was removed from the W3C spec and is no longer supported by major browsers.
|
||||
- The schemas use `.optional()` (not `.nullable()`) throughout. Undefined and absent are equivalent in TypeScript object types and idiomatic for the config surface; allowing `null` would create a third state with no consumer benefit.
|
||||
- Numeric ranges use `.int()` everywhere — every numeric config field is a count or a millisecond value, neither of which makes sense as a float. Floats are silently rejected, which is the correct strict-parse behaviour.
|
||||
- New config fields require touching three places in one commit: the type, the schema here, and `resolveConfig()`. The doc-drift gate catches any one of the three lagging behind.
|
||||
@@ -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,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,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,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.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Sub-chunk splitter types
|
||||
|
||||
## Purpose
|
||||
|
||||
This file defines the type-level contract between the producer half of the
|
||||
sub-chunk pipeline (`splitIntoSubChunks` / `sendSubChunks`) and the
|
||||
consumer half (`SubChunkReassembler`). The split path emits an ordered
|
||||
sequence of `SubChunkPart` records tagged with the originating chunk's
|
||||
identifier so the receiver can rebuild the original `Uint8Array` payload
|
||||
deterministically — and irrespective of arrival order over a lossy
|
||||
WebRTC data channel.
|
||||
|
||||
WebRTC SCTP-backed data channels have a per-message size cap that the
|
||||
spec does not fix but every browser limits in practice (~16 KB for
|
||||
reliable interop; some implementations accept up to 256 KB). The
|
||||
splitter targets the conservative 16 000-byte default and exposes
|
||||
`maxBytes` so callers tuning for a known channel can lift the cap.
|
||||
|
||||
## Features
|
||||
|
||||
- `SubChunkPart` carries `chunkId` (shared across every part of one
|
||||
payload), `partId` (`${chunkId}:${index}`, unique per part), `index`,
|
||||
`total`, and `bytes` — the immutable type signals that the receiver
|
||||
must not mutate the buffer in place.
|
||||
- `SubChunkSplitterOptions` requires `chunkId` and optionally accepts
|
||||
`maxBytes`, defaulting to 16 000 inside the splitter implementation.
|
||||
- `SubChunkCallback` is the per-part hook `sendSubChunks` invokes; it
|
||||
may be `async` so callers can stall on backpressure or AbortSignal.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SubChunkPart } from "@suaveplan/drive-webrtc/sub-chunk/sub-chunk-splitter";
|
||||
|
||||
declare const part: SubChunkPart;
|
||||
console.log(part.chunkId, part.index, part.total, part.bytes.byteLength);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SubChunkCallback,
|
||||
SubChunkSplitterOptions,
|
||||
} from "@suaveplan/drive-webrtc/sub-chunk/sub-chunk-splitter";
|
||||
|
||||
const options: SubChunkSplitterOptions = {
|
||||
chunkId: "f1:42",
|
||||
maxBytes: 8_192,
|
||||
};
|
||||
const send: SubChunkCallback = async (part) => {
|
||||
await transport.send(part.bytes);
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SubChunkPart` — `{ chunkId, partId, index, total, bytes }`. All fields
|
||||
`readonly`.
|
||||
- `SubChunkSplitterOptions` — `{ chunkId: string; maxBytes?: number }`.
|
||||
- `SubChunkCallback` — `(part: SubChunkPart) => Promise<void> | void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Types are intentionally `readonly` to discourage in-place mutation of the
|
||||
underlying `Uint8Array`. The splitter does not copy bytes — every
|
||||
`SubChunkPart.bytes` is a `subarray` view over the caller's buffer to
|
||||
keep the hot path zero-copy. Consumers that intend to retain a part past
|
||||
the original buffer's lifetime must `.slice()` it themselves.
|
||||
103
docs/networking/drive/drive-webrtc/src/types/config.types.md
Normal file
103
docs/networking/drive/drive-webrtc/src/types/config.types.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Configuration Types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `config.types.ts` module defines the TypeScript interfaces consumed by `@suaveplan/drive-webrtc` to configure signaling, WebRTC peer-to-peer transport, and sub-chunk splitting. These types are the single shape contract between consumers (apps, the plugin host, the signaling server bootstrap) and the package's internals. They are deliberately type-only — runtime validation lives in `schemas/config.schemas.ts`.
|
||||
|
||||
Consumers supply a `WebRTCPluginConfig`. The package merges it with sensible defaults to produce a `ResolvedConfig`, used internally where every field is required. Splitting the surface this way means consumers can omit anything they don't care about while the implementation never has to handle `undefined`.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-safe configuration surface for signaling, P2P, and sub-chunk splitting
|
||||
- `iceServers` is a first-class `P2PConfig` field — Original addition for production NAT traversal
|
||||
- Optional fields throughout with documented runtime defaults
|
||||
- `ResolvedConfig` materialises every field as required for internal use
|
||||
- Sub-chunk rate-limit shape supports throttled large-payload transmission
|
||||
- Compatible with the `WebRTCPluginConfigSchema` Zod validator
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import type { WebRTCPluginConfig } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const config: WebRTCPluginConfig = {
|
||||
signaling: { url: "wss://signal.example.com/p2p" },
|
||||
};
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { WebRTCPluginConfig } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const config: WebRTCPluginConfig = {
|
||||
signaling: {
|
||||
url: "wss://signal.example.com/p2p",
|
||||
peerId: "peer-alice",
|
||||
peerName: "Alice Browser",
|
||||
authToken: "jwt-token-here",
|
||||
reconnect: true,
|
||||
reconnectDelay: 2000,
|
||||
},
|
||||
p2p: {
|
||||
priority: 5,
|
||||
timeout: 60_000,
|
||||
iceServers: [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
{
|
||||
urls: "turn:turn.example.com:3478",
|
||||
username: "user",
|
||||
credential: "pass",
|
||||
},
|
||||
],
|
||||
maxConcurrentDownloads: 10,
|
||||
},
|
||||
subChunks: {
|
||||
size: 32 * 1024,
|
||||
backpressure: 512 * 1024,
|
||||
rateLimit: { delayMs: 10, everyNParts: 5 },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SignalingConfig`
|
||||
|
||||
- `url: string` — required WebSocket URL.
|
||||
- `peerId?: string` — auto-generated if omitted.
|
||||
- `peerName?: string` — display name.
|
||||
- `authToken?: string` — bearer token for the signaling server.
|
||||
- `reconnect?: boolean` — default `true`.
|
||||
- `reconnectDelay?: number` — default `1000` ms.
|
||||
|
||||
### `P2PConfig`
|
||||
|
||||
- `priority?: number` — chunk-source priority, default `10`.
|
||||
- `timeout?: number` — per-request timeout, default `30_000` ms.
|
||||
- `iceServers?: RTCIceServer[]` — STUN/TURN entries (first-class).
|
||||
- `maxConcurrentDownloads?: number` — default `5`.
|
||||
|
||||
### `SubChunkConfig`
|
||||
|
||||
- `size?: number` — default `16_000` bytes.
|
||||
- `backpressure?: number` — default `262_144` bytes.
|
||||
- `rateLimit?: { delayMs: number; everyNParts: number }`.
|
||||
|
||||
### `WebRTCPluginConfig`
|
||||
|
||||
Top-level shape combining the three domains. Only `signaling.url` is required.
|
||||
|
||||
### `ResolvedConfig`
|
||||
|
||||
Internal materialised config — every field required. Returned by `resolveConfig()` in the client module (forthcoming).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The suaveplan migration promotes `iceServers` from an undocumented internal default to a first-class option on `P2PConfig`. The reference implementation falls back to two Google public STUN servers when omitted, but production deployments behind restrictive NATs should always pass TURN credentials.
|
||||
- `priority` follows Drive's convention — lower numbers mean higher priority — so a P2P source at `10` will be tried after a CDN source at `1`.
|
||||
- Sub-chunk defaults (`16_000` size, `262_144` backpressure) are tuned for typical WebRTC SCTP buffers; deployments with larger buffers can safely raise these.
|
||||
- The `authToken` is for signaling-server registration only — WebRTC connection encryption is handled by DTLS-SRTP unconditionally.
|
||||
- All fields here are validated at the package boundary by `WebRTCPluginConfigSchema` (see `schemas/config.schemas.ts`); internal code receives a `ResolvedConfig` and never re-validates.
|
||||
151
docs/networking/drive/drive-webrtc/src/types/drive.types.md
Normal file
151
docs/networking/drive/drive-webrtc/src/types/drive.types.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Drive Integration Types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `drive.types.ts` module declares the subset of `@suaveplan/drive` interfaces that `@suaveplan/drive-webrtc` consumes — `Plugin`, `ChunkSource`, and `P2PTransport`. They are duplicated locally (rather than imported) so this package can type-check without `@suaveplan/drive` being built first; the structural shapes are intentionally identical to the upstream definitions, so the duplicates are assignable to the originals at runtime.
|
||||
|
||||
This pattern keeps the build graph acyclic and lets the `drive-webrtc` package compile independently in CI even when `@suaveplan/drive` itself is mid-rewrite. When `@suaveplan/drive` graduates and stabilises, these locals can be replaced with direct imports without changing any consumer code.
|
||||
|
||||
## Features
|
||||
|
||||
- `Plugin<TConfig>` lifecycle interface (`initialize`/`destroy`)
|
||||
- `PluginType` four-value union (`storage`/`transform`/`hook`/`middleware`)
|
||||
- `ChunkSourceHealth` four-state health enum
|
||||
- `ChunkSource` with `getChunk`/`getChunks`/`hasChunk`/`isAvailable` + optional `updateHealth`
|
||||
- `P2PTransport` with `requestChunk`/`announceChunks`/`getPeerCount` + optional event hooks
|
||||
- Structurally compatible with the canonical `@suaveplan/drive` interfaces
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import type { ChunkSource } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const cdn: ChunkSource = {
|
||||
id: "cdn",
|
||||
type: "https",
|
||||
priority: 1,
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async getChunk(fileId, chunkId) {
|
||||
return new Uint8Array(0);
|
||||
},
|
||||
async getChunks(fileId, chunkIds) {
|
||||
return chunkIds.map(() => new Uint8Array(0));
|
||||
},
|
||||
async hasChunk(fileId, chunkId) {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
ChunkSource,
|
||||
ChunkSourceHealth,
|
||||
Plugin,
|
||||
P2PTransport,
|
||||
} from "@suaveplan/drive-webrtc";
|
||||
|
||||
class HealthTrackingSource implements ChunkSource {
|
||||
public readonly id = "p2p";
|
||||
public readonly type = "webrtc";
|
||||
public readonly priority = 10;
|
||||
public health: ChunkSourceHealth = "unknown";
|
||||
|
||||
private fails = 0;
|
||||
|
||||
public async isAvailable(): Promise<boolean> {
|
||||
return this.health !== "failed";
|
||||
}
|
||||
|
||||
public async getChunk(
|
||||
fileId: bigint,
|
||||
chunkId: bigint,
|
||||
): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const data = await this.fetchFromPeers(fileId, chunkId);
|
||||
this.fails = 0;
|
||||
this.updateHealth("healthy");
|
||||
return data;
|
||||
} catch {
|
||||
this.fails += 1;
|
||||
if (this.fails > 3) this.updateHealth("failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async getChunks(
|
||||
fileId: bigint,
|
||||
chunkIds: bigint[],
|
||||
): Promise<(Uint8Array | null)[]> {
|
||||
return Promise.all(chunkIds.map((id) => this.getChunk(fileId, id)));
|
||||
}
|
||||
|
||||
public async hasChunk(_: bigint, __: bigint): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
public updateHealth(h: ChunkSourceHealth): void {
|
||||
this.health = h;
|
||||
}
|
||||
|
||||
private async fetchFromPeers(
|
||||
_fileId: bigint,
|
||||
_chunkId: bigint,
|
||||
): Promise<Uint8Array> {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
}
|
||||
|
||||
declare const transport: P2PTransport;
|
||||
declare const plugin: Plugin;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PluginType`
|
||||
|
||||
Union: `"storage" | "transform" | "hook" | "middleware"`.
|
||||
|
||||
### `Plugin<TConfig>`
|
||||
|
||||
- `readonly name: string`
|
||||
- `readonly version: string`
|
||||
- `readonly type: PluginType`
|
||||
- `initialize(config: TConfig): Promise<void>`
|
||||
- `destroy(): Promise<void>`
|
||||
|
||||
### `ChunkSourceHealth`
|
||||
|
||||
Union: `"healthy" | "degraded" | "failed" | "unknown"`.
|
||||
|
||||
### `ChunkSource`
|
||||
|
||||
- `readonly id`, `readonly type`, `readonly priority`
|
||||
- `readonly health?: ChunkSourceHealth`
|
||||
- `isAvailable(): Promise<boolean>`
|
||||
- `getChunk(fileId, chunkId): Promise<Uint8Array | null>`
|
||||
- `getChunks(fileId, chunkIds): Promise<(Uint8Array | null)[]>`
|
||||
- `hasChunk(fileId, chunkId): Promise<boolean>`
|
||||
- `updateHealth?(health): void`
|
||||
|
||||
### `P2PTransport`
|
||||
|
||||
- `readonly id`, `readonly type`
|
||||
- `connect()` / `disconnect()` / `isConnected()`
|
||||
- `requestChunk(fileId, chunkId): Promise<Uint8Array | null>`
|
||||
- `announceChunks(chunks: string[]): Promise<void>`
|
||||
- `getPeerCount(): number`
|
||||
- `on?` / `off?` event hooks
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- These types are duplicated rather than imported on purpose. `@suaveplan/drive` itself is still under suaveplan-rewrite; cross-package import dependencies would create a build-order cycle. Once both packages graduate to `1.0.0`, the duplicates here should be replaced with direct re-exports from `@suaveplan/drive`.
|
||||
- Structural equivalence is verified at integration time by passing instances of these types to the real `@suaveplan/drive` API and confirming the call sites compile. If the upstream shape evolves, update these locals in the same PR.
|
||||
- All methods returning `Promise<Uint8Array | null>` use `null` (not `undefined`) for "not available" so consumers can pattern-match cleanly with `=== null`.
|
||||
- Optional methods (`updateHealth`, `on`, `off`) are guarded by `?:` rather than always-defined no-op stubs so implementations that don't need them can omit the method entirely without a TypeScript error.
|
||||
120
docs/networking/drive/drive-webrtc/src/types/events.types.md
Normal file
120
docs/networking/drive/drive-webrtc/src/types/events.types.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Event Types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `events.types.ts` module defines `WebRTCEventMap`, the canonical event map the WebRTC plugin emits over `@suaveplan/events`. Every observable state transition in the plugin — signaling connect/disconnect, peer join/leave, WebRTC handshake, ICE restart, data-channel lifecycle, chunk transfer, integrity verification, statistics tick — has a typed entry here, so consumers can subscribe with full type-checking on payload shape.
|
||||
|
||||
The map uses the EventEmitter-compatible tuple form (single-element arrays). This matches the structural contract `@suaveplan/events` expects and lets consumers destructure the payload in their handler signature without an extra wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- 28 typed events covering every observable transition in the package
|
||||
- Signaling lifecycle: connect/disconnect/error/reconnect
|
||||
- Peer lifecycle: join/leave/chunks-updated/blacklisted
|
||||
- WebRTC handshake: connected/disconnected/failed/security:failed/ice:restart
|
||||
- Data channel: open/close/error
|
||||
- Chunk transfer: request/response/received/served/rejected/download lifecycle/announce/evicted
|
||||
- Integrity failures: hash mismatch + signature mismatch are distinct events
|
||||
- Stats tick: aggregate `WebRTCStats` snapshot
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { WebRTCEventMap } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const bus = new EventBus<WebRTCEventMap>();
|
||||
|
||||
bus.on("peer:joined", ({ peerId, peerName }) => {
|
||||
console.log(`welcome ${peerName} (${peerId})`);
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { WebRTCEventMap, WebRTCStats } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const bus = new EventBus<WebRTCEventMap>();
|
||||
let latest: WebRTCStats | undefined;
|
||||
|
||||
bus.on("stats:updated", ({ stats }) => {
|
||||
latest = stats;
|
||||
});
|
||||
|
||||
bus.on("chunk:integrity:failed", (info) => {
|
||||
console.error(
|
||||
`[integrity] peer=${info.peerId} fileId=${info.fileId} chunkId=${info.chunkId}` +
|
||||
` expected=${info.expectedHash} actual=${info.actualHash}`,
|
||||
);
|
||||
});
|
||||
|
||||
bus.on("signaling:reconnect", ({ attempt, delayMs }) => {
|
||||
console.warn(`signaling reconnect attempt ${attempt} after ${delayMs}ms`);
|
||||
});
|
||||
|
||||
bus.on("ice:restart", ({ peerId }) => {
|
||||
console.log(`ICE restart for ${peerId}`);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (latest) console.log("stats", latest);
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Signaling
|
||||
|
||||
- `signaling:connected` — `{ url }`
|
||||
- `signaling:disconnected` — `{ reason? }`
|
||||
- `signaling:error` — `{ error }`
|
||||
- `signaling:reconnect` — `{ attempt, delayMs }`
|
||||
|
||||
### Peer
|
||||
|
||||
- `peer:joined` — `{ peerId, peerName }`
|
||||
- `peer:left` — `{ peerId }`
|
||||
- `peer:chunks:updated` — `{ peerId, chunks }`
|
||||
- `peer:blacklisted` — `{ peerId, reason, requestCount? }`
|
||||
|
||||
### WebRTC
|
||||
|
||||
- `webrtc:connected` / `webrtc:disconnected` — `{ peerId }`
|
||||
- `webrtc:failed` — `{ peerId, error? }`
|
||||
- `webrtc:security:failed` — `{ peerId, error }`
|
||||
- `ice:restart` — `{ peerId }`
|
||||
|
||||
### Data channel
|
||||
|
||||
- `datachannel:open` / `datachannel:close` — `{ peerId }`
|
||||
- `datachannel:error` — `{ peerId, error }`
|
||||
|
||||
### Chunk transfer
|
||||
|
||||
- `chunk:request` / `chunk:response` — `{ peerId, fileId, chunkId, size? }`
|
||||
- `chunk:request:denied` / `chunk:rejected` — `{ peerId, fileId, chunkId, reason }`
|
||||
- `chunk:received` / `chunk:served` — `{ peerId, fileId, chunkId, size }`
|
||||
- `chunk:download:start` — `{ fileId, chunkId, source }`
|
||||
- `chunk:download:complete` — `{ fileId, chunkId, size }`
|
||||
- `chunk:download:failed` — `{ fileId, chunkId, error }`
|
||||
- `chunk:announce` — `{ chunks }`
|
||||
- `chunk:evicted` — `{ chunkKey, size }`
|
||||
- `chunk:integrity:failed` — `{ fileId, chunkId, peerId, expectedHash, actualHash }`
|
||||
- `chunk:signature:failed` — `{ fileId, chunkId, peerId }`
|
||||
|
||||
### Stats
|
||||
|
||||
- `stats:updated` — `{ stats: WebRTCStats }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All chunk-transfer events carry `fileId`/`chunkId` as `bigint` because the wire format uses u64. Stringify before logging — `JSON.stringify` will throw on `bigint`.
|
||||
- `chunk:received` / `chunk:served` / `chunk:rejected` are new in the migration; they make every chunk-transfer outcome observable without inferring from byte-count deltas.
|
||||
- `signaling:reconnect` carries the attempt number and the upcoming delay so dashboards can show a backoff curve; the actual reconnect succeeds via the separate `signaling:connected` event.
|
||||
- `ice:restart` fires when the package issues an ICE restart on an existing peer connection (driven by `iceconnectionstatechange === "failed"`). It does not fire for a brand-new connection.
|
||||
- `peer:blacklisted` is emitted by the rate-limiter when a peer exceeds the configured request-rate threshold. The optional `requestCount` field is included when available.
|
||||
- `stats:updated` fires at a fixed interval (configurable in the plugin) — handlers should be cheap; subscribers needing high-frequency updates should sample the source counters directly rather than coupling to this tick.
|
||||
103
docs/networking/drive/drive-webrtc/src/types/messages.types.md
Normal file
103
docs/networking/drive/drive-webrtc/src/types/messages.types.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Signaling Message Types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `messages.types.ts` module defines the on-the-wire shapes for every message the WebRTC plugin exchanges with the signaling server and with peers over data channels. The `SignalingMessage` discriminated union is the single source of truth for inbound traffic; `schemas/signaling.schemas.ts` mirrors the union as a Zod validator so untrusted input is rejected at the boundary before any handler sees it.
|
||||
|
||||
Variants split into three groups: signaling-server control (register, registered, peer-joined, peer-left, peer-chunks-updated, announce-chunks), WebRTC handshake relay (offer, answer, ice-candidate), and chunk-transfer payloads carried either over signaling fallback or over the data channel (request-chunk, chunk-data, chunk-data-part, chunk-query, chunk-query-response, chunk-broadcast-request, chunk-broadcast-response).
|
||||
|
||||
## Features
|
||||
|
||||
- 17 message variants covering control, handshake, and data-plane traffic
|
||||
- Discriminator-based union (`type` field) with consistent shape per variant
|
||||
- Signaling-bound variants carry optional `from`/`to` routing fields
|
||||
- Data-channel-only variants omit routing — they are point-to-point
|
||||
- u64 file/chunk IDs serialised as decimal strings for JSON-safe transport
|
||||
- Compatible with the `SignalingMessageSchema` Zod validator (validated at the boundary)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import type { OfferMessage } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const offer: OfferMessage = {
|
||||
type: "offer",
|
||||
from: "peer-a",
|
||||
to: "peer-b",
|
||||
payload: { type: "offer", sdp: "v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\n..." },
|
||||
};
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
ChunkBroadcastRequest,
|
||||
ChunkQueryResponse,
|
||||
SignalingMessage,
|
||||
} from "@suaveplan/drive-webrtc";
|
||||
|
||||
function handle(msg: SignalingMessage): void {
|
||||
switch (msg.type) {
|
||||
case "chunk-broadcast-request": {
|
||||
const req: ChunkBroadcastRequest = msg;
|
||||
console.log(req.neededChunkIds.length, "chunks wanted");
|
||||
break;
|
||||
}
|
||||
case "chunk-query-response": {
|
||||
const resp: ChunkQueryResponse = msg;
|
||||
console.log("peer has", resp.availableChunkIds.length);
|
||||
break;
|
||||
}
|
||||
case "peer-joined":
|
||||
console.log("welcome", msg.payload.id);
|
||||
break;
|
||||
// every other variant is reachable by the discriminator
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Signaling-control variants
|
||||
|
||||
- `RegisterMessage` — peer → server, includes auth token + owned-chunk list.
|
||||
- `RegisteredMessage` — server → peer, returns the current peer roster.
|
||||
- `PeerJoinedMessage` / `PeerLeftMessage` — server → all, mesh membership.
|
||||
- `ChunksUpdatedMessage` — server → all, peer's chunk list changed.
|
||||
- `AnnounceChunksMessage` — peer → server, refresh owned-chunk set.
|
||||
|
||||
### Handshake-relay variants
|
||||
|
||||
- `OfferMessage` — `RTCSessionDescriptionInit` SDP offer.
|
||||
- `AnswerMessage` — `RTCSessionDescriptionInit` SDP answer.
|
||||
- `IceCandidateMessage` — `RTCIceCandidateInit` candidate.
|
||||
|
||||
### Chunk transfer (signaling fallback)
|
||||
|
||||
- `ChunkRequestMessage` — `type: "request-chunk"`, signaling-bound payload.
|
||||
- `ChunkDataMessage` — `type: "chunk-data"`, base64-encoded payload.
|
||||
|
||||
### Chunk transfer (data channel)
|
||||
|
||||
- `DataChannelChunkRequest` — `type: "request-chunk"`, flat shape (no nested `payload`).
|
||||
- `ChunkDataPartMessage` — sub-chunk fragment with `partIndex` / `totalParts`.
|
||||
- `ChunkQueryMessage` / `ChunkQueryResponse` — availability probe.
|
||||
- `ChunkBroadcastRequest` / `ChunkBroadcastResponse` — discovery sweep.
|
||||
|
||||
### `SignalingMessage`
|
||||
|
||||
Discriminated union over all 17 variants above. Use `SignalingMessageSchema.safeParse` to validate untrusted input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `request-chunk` is intentionally reused by both `ChunkRequestMessage` (signaling with nested `payload`) and `DataChannelChunkRequest` (flat, no `payload`). The schema validator distinguishes by structural shape using `z.union` rather than `z.discriminatedUnion`, since the discriminator alone is not unique.
|
||||
- `fileId` and `chunkId` are decimal-string-serialised u64s — never parse them as JS numbers (precision loss); use `BigInt(...)` at the consumer.
|
||||
- Optional `from`/`to` fields on signaling variants are populated by the signaling server when relaying; clients may omit them on send and the server fills them in.
|
||||
- Data-channel-only variants intentionally omit `from`/`to` — the channel is already point-to-point, so adding routing fields would be redundant and a vector for spoofing.
|
||||
- The schema validator enforces that base64-encoded payloads (e.g. `ChunkDataMessage.payload.data`) are non-empty and match a permissive base64 character set; ciphertext integrity is verified separately by `chunk-integrity` after decode.
|
||||
- Any new variant added here MUST be added to `SignalingMessageSchema` in the same commit; otherwise the runtime validator will reject valid traffic.
|
||||
95
docs/networking/drive/drive-webrtc/src/types/peer.types.md
Normal file
95
docs/networking/drive/drive-webrtc/src/types/peer.types.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Peer Types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `peer.types.ts` module defines the in-memory representation of remote peers in the mesh and the chunk records the local client tracks. `PeerInfo` is the canonical record kept by `PeerManager` for every peer the signaling server has surfaced; `ChunkInfo` is the record `ChunkSource` tracks for chunks the local peer owns or is downloading.
|
||||
|
||||
These types are deliberately mutable — they evolve in place as connection state changes, chunks arrive, and progress updates fire. The `WebRTCEventMap` events provide hooks to react to these mutations without scanning the maps.
|
||||
|
||||
## Features
|
||||
|
||||
- `PeerConnectionState` four-state lifecycle (`connecting`/`connected`/`disconnected`/`failed`)
|
||||
- `PeerInfo` carries the WebRTC primitives (`RTCPeerConnection`, `RTCDataChannel`) plus mesh metadata
|
||||
- `ChunkInfo` tracks owned, cached, and in-flight chunks with progress + integrity hash
|
||||
- u64 file/chunk IDs use `bigint` (not JS number) — matches packer v2
|
||||
- Optional WebRTC handles allow representing peers known only via signaling (pre-connect)
|
||||
- Compatible with `PeerInfo` event payloads in `events.types.ts`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import type { PeerInfo } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const peer: PeerInfo = {
|
||||
id: "peer-alice",
|
||||
name: "Alice Browser",
|
||||
chunks: new Set(["1:0", "1:1", "2:0"]),
|
||||
connectionState: "connected",
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { ChunkInfo, PeerInfo } from "@suaveplan/drive-webrtc";
|
||||
|
||||
function isReadyToServe(peer: PeerInfo): boolean {
|
||||
return (
|
||||
peer.connectionState === "connected" &&
|
||||
peer.rtcDataChannel?.readyState === "open"
|
||||
);
|
||||
}
|
||||
|
||||
function ownedChunkOf(fileId: bigint, chunkId: bigint): ChunkInfo {
|
||||
return {
|
||||
fileId,
|
||||
chunkId,
|
||||
data: new Uint8Array(0),
|
||||
size: 0,
|
||||
hash: undefined,
|
||||
isOwned: true,
|
||||
isCached: true,
|
||||
isDownloading: false,
|
||||
progress: 100,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PeerConnectionState`
|
||||
|
||||
Union: `"connecting" | "connected" | "disconnected" | "failed"`. Mirrors `RTCPeerConnectionState` but normalised — we collapse `"new"`/`"checking"` into `"connecting"` and `"closed"` into `"disconnected"`.
|
||||
|
||||
### `PeerInfo`
|
||||
|
||||
- `id: string` — unique peer ID.
|
||||
- `name: string` — display name.
|
||||
- `chunks: Set<string>` — owned-chunk keys formatted `"fileId:chunkId"`.
|
||||
- `rtcConnection?: RTCPeerConnection` — populated once the offer/answer exchange resolves.
|
||||
- `rtcDataChannel?: RTCDataChannel` — populated once the channel opens.
|
||||
- `connectionState: PeerConnectionState`.
|
||||
- `lastSeen: number` — epoch ms updated on every inbound message.
|
||||
|
||||
### `ChunkInfo`
|
||||
|
||||
- `fileId: bigint` / `chunkId: bigint` — u64 IDs.
|
||||
- `data?: Uint8Array` — raw bytes (absent when not cached).
|
||||
- `size: number` — declared chunk size in bytes.
|
||||
- `hash?: string` — SHA-256 hex digest used by `chunk-integrity` verification.
|
||||
- `isOwned: boolean` — local peer is the publisher.
|
||||
- `isCached: boolean` — bytes are in the local cache.
|
||||
- `isDownloading: boolean` — request is in flight.
|
||||
- `progress: number` — 0–100, monotonic except on retry.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `PeerInfo.chunks` uses `Set<string>` rather than `Set<bigint>` because the chunk-key format `"fileId:chunkId"` collapses the two IDs into a single hash key for O(1) lookup. Consumers wanting bigint IDs should `key.split(":")` and `BigInt(...)`.
|
||||
- `PeerInfo` is intentionally mutable so the `PeerManager` can update it in place — this avoids garbage churn on every state transition. Tests should snapshot fields they care about (e.g. `peer.connectionState`) rather than holding the whole object across awaits.
|
||||
- `ChunkInfo.data` is omitted (not `null`) when the chunk is not cached — `'data' in info` is the correct presence check.
|
||||
- `progress` resets to 0 on retry; consumers wanting "best-ever" progress should track it themselves.
|
||||
- `hash` is absent until the chunk has been received and verified; once set it is immutable for the lifetime of the `ChunkInfo`.
|
||||
- `rtcConnection` / `rtcDataChannel` may be undefined even when `connectionState === "connected"` if the channel itself failed to open — always null-check before touching them.
|
||||
80
docs/networking/drive/drive-webrtc/src/types/stats.types.md
Normal file
80
docs/networking/drive/drive-webrtc/src/types/stats.types.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Stats Types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `stats.types.ts` module defines the aggregate statistics record (`WebRTCStats`) used by the WebRTC plugin to expose runtime metrics — byte counts, chunk counts, connection success/failure rates, and peer roster size. The companion `createDefaultStats()` factory produces a zero-initialised record so consumers can start a session without hand-listing every field.
|
||||
|
||||
Statistics are surfaced on the event bus via the `"stats:updated"` event (see `events.types.md`); they are also useful as a snapshot return value for diagnostic UIs. The shape is intentionally flat — every field is a plain `number` — so consumers can diff two snapshots cheaply without recursive comparison.
|
||||
|
||||
## Features
|
||||
|
||||
- Flat numeric record covering bytes, chunks, connections, and peers
|
||||
- Separate P2P vs WebSocket-fallback byte/chunk counters
|
||||
- Cache-hit counter distinguishes local-cache serves from network fetches
|
||||
- `createDefaultStats()` factory returns a fresh zero-initialised record per call
|
||||
- Compatible with the `"stats:updated"` event payload in `WebRTCEventMap`
|
||||
- No bigint fields — counters use safe-integer range (≤ 2^53 − 1)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import { createDefaultStats } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const stats = createDefaultStats();
|
||||
stats.activePeers = 3;
|
||||
stats.bytesDownloadedP2P += 16_384;
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createDefaultStats, type WebRTCStats } from "@suaveplan/drive-webrtc";
|
||||
|
||||
function deltaSnapshot(prev: WebRTCStats, next: WebRTCStats): Partial<WebRTCStats> {
|
||||
const out: Partial<WebRTCStats> = {};
|
||||
for (const key of Object.keys(next) as (keyof WebRTCStats)[]) {
|
||||
const diff = next[key] - prev[key];
|
||||
if (diff !== 0) {
|
||||
out[key] = diff;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const before = createDefaultStats();
|
||||
// ... session runs ...
|
||||
const after = createDefaultStats();
|
||||
after.chunksFromP2P = 42;
|
||||
after.bytesDownloadedP2P = 1_048_576;
|
||||
|
||||
console.log(deltaSnapshot(before, after));
|
||||
// { chunksFromP2P: 42, bytesDownloadedP2P: 1048576 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `WebRTCStats`
|
||||
|
||||
- `bytesDownloadedP2P: number` — cumulative bytes from peers.
|
||||
- `bytesDownloadedWS: number` — cumulative bytes via signaling-server fallback.
|
||||
- `chunksFromP2P: number` — chunks delivered over a data channel.
|
||||
- `chunksFromWS: number` — chunks delivered over signaling-server fallback.
|
||||
- `chunksFromCache: number` — chunks served from local cache (no network).
|
||||
- `webRTCConnections: number` — successful `RTCPeerConnection` opens this session.
|
||||
- `failedConnections: number` — peer-connection failures this session.
|
||||
- `activePeers: number` — currently connected peers.
|
||||
- `totalPeers: number` — total distinct peers seen this session (monotonic).
|
||||
|
||||
### `createDefaultStats(): WebRTCStats`
|
||||
|
||||
Returns a fresh `WebRTCStats` with every field set to `0`. Each call returns a new object — never reuse the result across sessions (it would alias and leak state).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Counters are plain `number` and stay inside the safe-integer range. A session moving 10 TB of data at 16 KB chunks still falls under 2^40 bytes — well below 2^53. If a deployment ever approaches this, swap to `bigint` (breaking change; bump major).
|
||||
- `totalPeers` is monotonic — peers that leave still count. `activePeers` reflects current state and can decrease. Tests that snapshot these values should account for that asymmetry.
|
||||
- `chunksFromCache` is a hit counter, not a size counter — for cache-served-bytes, sum `chunksFromCache × chunkSize` at the call site.
|
||||
- `createDefaultStats()` does not memoise — every call returns a freshly-allocated record so mutations to one snapshot never leak into another.
|
||||
- This module is one of two type files in `types/` that ship runtime code (the factory function); `vite-plugin-dts` and the package's `sideEffects: false` flag both correctly handle it because the function is pure.
|
||||
Reference in New Issue
Block a user