first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.