Initial Commit
This commit is contained in:
112
openspec/specs/drive-transport-webrtc/spec.md
Normal file
112
openspec/specs/drive-transport-webrtc/spec.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# @suaveplan/drive-transport-webrtc — WebRTC DriveTransport
|
||||
|
||||
WebRTC datachannel implementation of the `DriveTransport` interface for
|
||||
browser-to-browser chunk transfer. Composes `WebRTCClient` +
|
||||
`DataChannelManager` from `@suaveplan/drive-webrtc`; consumed by
|
||||
`@suaveplan/drive-mesh` alongside `drive-transport-https` as one of the
|
||||
pluggable wire transports.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: DriveTransport contract
|
||||
|
||||
The package SHALL expose `WebRTCDriveTransport` implementing the
|
||||
structural `DriveTransport` interface:
|
||||
|
||||
- `name: "drive-transport-webrtc"`
|
||||
- `requestChunk(peerId, fileId, chunkId, signal?): AsyncIterable<Uint8Array>`
|
||||
- `advertise(peerId, fileId, bitset): Promise<void>`
|
||||
- `onAdvertisement(handler): () => void`
|
||||
- `peers(): Iterable<PeerInfo>`
|
||||
- `dispose(): Promise<void>`
|
||||
|
||||
The local `DriveTransport` interface MUST remain structurally compatible
|
||||
with the canonical one defined in `@suaveplan/drive-mesh` so consumers can
|
||||
swap to the mesh-canonical type without code changes when mesh lands.
|
||||
|
||||
### Requirement: Wire framing
|
||||
|
||||
Each datachannel message SHALL be a length-prefixed binary frame:
|
||||
|
||||
```text
|
||||
[4 B uint32 BE frameLen]
|
||||
[1 B uint8 frameType] // 0x01 chunk-request, 0x02 chunk-response, 0x03 advertisement
|
||||
[frameLen-1 B payload]
|
||||
```
|
||||
|
||||
- `MAX_FRAME_BYTES = 16 MiB`; over-cap frames MUST be rejected with
|
||||
`DriveTransportWebRtcError(FRAME_TOO_LARGE)`.
|
||||
- Malformed frames (length mismatch, unknown type) MUST raise
|
||||
`FRAME_INVALID`.
|
||||
- Chunk request payload: `fileId(32) ‖ chunkId(4 BE uint32)`.
|
||||
- Chunk response payload: raw chunk bytes (no extra length prefix — frame
|
||||
already carries `frameLen`).
|
||||
- Advertisement payload: `fileId(32) ‖ gzipped-bitset`. Compression uses
|
||||
the platform `CompressionStream`/`DecompressionStream` API; the stream
|
||||
ctors are injectable via `StreamCtorResolver` so tests cover missing-
|
||||
stream branches without `vi.stubGlobal`.
|
||||
|
||||
### Requirement: requestChunk semantics
|
||||
|
||||
`requestChunk(peerId, fileId, chunkId, signal?)`:
|
||||
|
||||
1. Reuse existing channel via `dataChannels.get(peerId, defaultLabel)` or
|
||||
`open(peerId, defaultLabel)`. Unknown peer → `PEER_NOT_CONNECTED`.
|
||||
2. Encode + send chunk-request frame.
|
||||
3. Wait for the next chunk-response frame from the same `(peerId, label)`
|
||||
within `requestTimeoutMs`. AbortSignal cancels the wait + throws
|
||||
`ABORTED`. Timeout throws `REQUEST_TIMEOUT`.
|
||||
4. Yield the response payload as a single value of the
|
||||
`AsyncIterable<Uint8Array>`.
|
||||
5. Chunk-responses FIFO-match per peer — the wire format has no request-id
|
||||
field. Documented in implementation notes as a known design constraint.
|
||||
|
||||
### Requirement: advertise + onAdvertisement
|
||||
|
||||
`advertise(peerId, fileId, bitset)`:
|
||||
|
||||
- Gzip the bitset via `CompressionStream`. Compression failure throws
|
||||
`BITSET_COMPRESSION_FAILED`.
|
||||
- Encode advertisement frame, send via `DataChannelManager.send`.
|
||||
- Underlying send failure (channel closed) maps to `DATA_CHANNEL_CLOSED`.
|
||||
|
||||
`onAdvertisement(handler)` subscribes to `dataChannels.on("channel:message")`,
|
||||
decodes incoming frames, invokes `handler` only for `frameType === 0x03`,
|
||||
returns an unsubscribe function.
|
||||
|
||||
### Requirement: AbortSignal threading
|
||||
|
||||
Every async path (`requestChunk`, `advertise`, listener wait) SHALL accept
|
||||
and honour an optional `AbortSignal`. Pre-aborted signals throw
|
||||
`DriveTransportWebRtcError(ABORTED)` before any side effect.
|
||||
|
||||
### Requirement: dispose idempotency
|
||||
|
||||
`dispose()` SHALL unsubscribe from `DataChannelManager` listeners and
|
||||
release internal state. It MUST be safe to call multiple times. It MUST NOT
|
||||
close the underlying `WebRTCClient` — client lifetime is managed by the
|
||||
parent (typically `WebRTCDrivePlugin` or `DriveMesh`).
|
||||
|
||||
### Requirement: OTel observability
|
||||
|
||||
The package SHALL register the following instruments under tracer
|
||||
`@suaveplan/drive-transport-webrtc`:
|
||||
|
||||
- Counter `drive.transport.webrtc.requests_total{outcome}`
|
||||
- Counter `drive.transport.webrtc.advertisements_total{direction}`
|
||||
- Histogram `drive.transport.webrtc.request_duration_ms`
|
||||
- Histogram `drive.transport.webrtc.frame_size_bytes`
|
||||
|
||||
### Requirement: Tests use injected real fakes
|
||||
|
||||
Tests MUST NOT use `vi.mock` or `vi.stubGlobal`. The
|
||||
`FakeRTCPeerConnection` / `FakeRTCDataChannel` real-implementation pattern
|
||||
from `@suaveplan/drive-webrtc` MUST be reused (structural `EventTarget`
|
||||
subclasses with `simulate*` driver methods). All test imports come from
|
||||
`@suaveplan/testing/runner`.
|
||||
|
||||
### Requirement: Coverage + gate
|
||||
|
||||
`bunx turbo lint typecheck test:coverage --filter=@suaveplan/drive-transport-webrtc`
|
||||
MUST be green and istanbul coverage MUST be 100% on every touched module
|
||||
before any release.
|
||||
Reference in New Issue
Block a user