Initial Commit

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

View File

@@ -0,0 +1,180 @@
# @suaveplan/drive-transport-webrtc - WebRTC datachannel transport for the drive mesh. Implements DriveTransport over @suaveplan/drive-webrtc's WebRTCClient + DataChannelManager with a length-prefixed binary framing protocol.
## Summary
WebRTC datachannel transport for the drive mesh. Implements
the canonical `DriveTransport` interface from `@suaveplan/drive-mesh`
over a `@suaveplan/drive-webrtc` `WebRTCClient` + `DataChannelManager`
pair, multiplexing chunk requests, chunk responses, and presence
advertisements over a single named data-channel label per peer.
This package re-exports `DriveTransport` / `ChunkAdvertisement` /
`AdvertisementHandler` / `Unsubscribe` from `@suaveplan/drive-mesh` —
there is no local interface mirror. A drive-mesh `DriveMesh` instance
slots a `WebRTCDriveTransport` into its transport plane and consumes
this transport's `requestChunk` / `advertise` / `onAdvertisement` /
`peers` surface directly. For the HTTPS / server-fallback path, mesh
ships its own built-in `ServerFallback` (no separate transport
package).
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the typed error-code registry and `DriveTransportWebRtcError` class used by every public boundary of `@suavepla…
- [error-codes](./src/error-codes/error-codes.md) — Defines the typed error-code registry and `DriveTransportWebRtcError` class used by every public boundary of `@suavepla…
- [frame](./src/framing/frame/frame.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [frame](./src/framing/frame/frame.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [frame.schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bitset-codec](./src/transport/webrtc-transport/bitset-codec.md) — Provides the gzip codec used to compress advertisement bitsets before they hit the wire. Bitsets describing which chunk…
- [hex](./src/transport/webrtc-transport/hex.md) — Boundary helpers converting between the on-the-wire 32-byte file id representation and the hex-encoded form used by eve…
- [payload-codec](./src/transport/webrtc-transport/payload-codec.md) — Implements the byte-level encoder/decoder for the two structured frame bodies the WebRTC drive transport carries: chunk…
- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [frame](./src/framing/frame/frame.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [frame.schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [bitset-codec](./src/transport/webrtc-transport/bitset-codec.md) — Provides the gzip codec used to compress advertisement bitsets before they hit the wire. Bitsets describing which chunk…
- [hex](./src/transport/webrtc-transport/hex.md) — Boundary helpers converting between the on-the-wire 32-byte file id representation and the hex-encoded form used by eve…
- [payload-codec](./src/transport/webrtc-transport/payload-codec.md) — Implements the byte-level encoder/decoder for the two structured frame bodies the WebRTC drive transport carries: chunk…
- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the typed error-code registry and `DriveTransportWebRtcError` class used by every public boundary of `@suavepla…
- [frame](./src/framing/frame/frame.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [frame.schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [bitset-codec](./src/transport/webrtc-transport/bitset-codec.md) — Provides the gzip codec used to compress advertisement bitsets before they hit the wire. Bitsets describing which chunk…
- [hex](./src/transport/webrtc-transport/hex.md) — Boundary helpers converting between the on-the-wire 32-byte file id representation and the hex-encoded form used by eve…
- [payload-codec](./src/transport/webrtc-transport/payload-codec.md) — Implements the byte-level encoder/decoder for the two structured frame bodies the WebRTC drive transport carries: chunk…
- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the typed error-code registry and `DriveTransportWebRtcError` class used by every public boundary of `@suavepla…
- [frame](./src/framing/frame/frame.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [frame.schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [bitset-codec](./src/transport/webrtc-transport/bitset-codec.md) — Provides the gzip codec used to compress advertisement bitsets before they hit the wire. Bitsets describing which chunk…
- [hex](./src/transport/webrtc-transport/hex.md) — Boundary helpers converting between the on-the-wire 32-byte file id representation and the hex-encoded form used by eve…
- [payload-codec](./src/transport/webrtc-transport/payload-codec.md) — Implements the byte-level encoder/decoder for the two structured frame bodies the WebRTC drive transport carries: chunk…
- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the typed error-code registry and `DriveTransportWebRtcError` class used by every public boundary of `@suavepla…
- [frame](./src/framing/frame/frame.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [frame.schemas](./src/schemas/frame.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bitset-codec](./src/transport/webrtc-transport/bitset-codec.md) — Provides the gzip codec used to compress advertisement bitsets before they hit the wire. Bitsets describing which chunk…
- [hex](./src/transport/webrtc-transport/hex.md) — Boundary helpers converting between the on-the-wire 32-byte file id representation and the hex-encoded form used by eve…
- [payload-codec](./src/transport/webrtc-transport/payload-codec.md) — Implements the byte-level encoder/decoder for the two structured frame bodies the WebRTC drive transport carries: chunk…
- [webrtc-transport](./src/transport/webrtc-transport/webrtc-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the typed error-code registry and `DriveTransportWebRtcError` class used by every public boundary of `@suavepla…
## Quick start
```ts
import {
DataChannelManager,
WebRTCClient,
} from "@suaveplan/drive-webrtc";
import { WebRTCDriveTransport } from "@suaveplan/drive-transport-webrtc";
const client = new WebRTCClient({
peerId: "self",
config: { signaling: { url: "wss://signal.example.com" } },
});
const channels = new DataChannelManager({ client });
// negotiate connection out of band via SignalingClient ...
const transport = new WebRTCDriveTransport({
client,
dataChannels: channels,
defaultLabel: "drive-chunks",
maxConcurrentRequests: 16,
requestTimeoutMs: 10_000,
});
// pull a chunk from a peer (fileId is the hex-encoded 32-byte digest)
const fileId = "ab".repeat(32);
const bytes = await transport.requestChunk("bob", fileId, 0);
// broadcast presence — single `ChunkAdvertisement` object
await transport.advertise("bob", {
fileId,
bitset: ourBitset,
totalChunks: 1024,
});
// subscribe to incoming advertisements (peerId + advert two-arg form)
const unsubscribe = transport.onAdvertisement((peerId, advert) => {
presenceIndex.update(peerId, advert.fileId, advert.bitset);
});
// teardown
await transport.dispose();
unsubscribe();
```
## Integration with `drive-mesh`
`WebRTCDriveTransport` implements the canonical `DriveTransport`
contract from `@suaveplan/drive-mesh`. A mesh instance accepts a
list of `DriveTransport` implementations:
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
import { WebRTCDriveTransport } from "@suaveplan/drive-transport-webrtc";
const mesh = new DriveMesh({
transports: [
new WebRTCDriveTransport({ client, dataChannels }),
],
// ...peer registry, signed-chunk source, optional ServerFallback
});
```
The HTTPS / server-fallback path lives inside drive-mesh itself
(`ServerFallback` — see `@suaveplan/drive-mesh`'s
`./server-fallback/server-fallback` module), so there is no
companion `drive-transport-https` package to plug in.
## Wire framing
Every datachannel message is a length-prefixed binary frame:
```text
+--------+--------+----------------------+
| 4 B | 1 B | frameLen - 1 B |
| length | type | payload |
+--------+--------+----------------------+
```
- `length` — big-endian uint32 byte count of `type + payload`.
- `type` — `0x01` chunk-request, `0x02` chunk-response, `0x03`
advertisement.
- `payload` shape varies by type:
- Chunk request: `fileId(32 raw bytes) ‖ chunkId(4 BE uint32)`.
- Chunk response: raw chunk bytes (length is already known from
the outer header).
- Advertisement: `fileId(32 raw bytes) ‖ totalChunks(4 BE uint32) ‖ gzip(bitset)`.
Public API takes / returns the hex-encoded form (`fileId: string`,
64 lowercase hex chars). The codec converts to / from raw bytes at
the boundary; on-the-wire bytes are unchanged from the pre-rewrite
representation except for the new 4-byte `totalChunks` field.
Frame bodies are capped at 16 MiB. Malformed frames are dropped on the
receive side and surface as `DriveTransportWebRtcError(FRAME_INVALID)`
on the send side.
## Backpressure
Outgoing sends route through `DataChannelManager.send`, which pauses
when `bufferedAmount` exceeds the data-channel high-water mark and
resumes on the `bufferedamountlow` event. No additional flow-control
state is held in this package.
## OTel surface
| Instrument | Kind | Attributes |
| --------------------------------------------------- | --------- | ------------------------------------- |
| `drive.transport.webrtc.requests_total` | counter | `outcome` ∈ ok / timeout / aborted / send_failed / rejected_overflow |
| `drive.transport.webrtc.advertisements_total` | counter | `direction` ∈ inbound / outbound |
| `drive.transport.webrtc.request_duration_ms` | histogram | `outcome` |
| `drive.transport.webrtc.frame_size_bytes` | histogram | `type` ∈ chunk-request / advertisement |
## Error codes
All errors are instances of `DriveTransportWebRtcError`:
- `FRAME_INVALID` — frame header or payload failed validation.
- `FRAME_TOO_LARGE` — frame body exceeded the 16 MiB cap.
- `REQUEST_TIMEOUT` — chunk request did not receive a response in time.
- `PEER_NOT_CONNECTED` — no data channel could be opened for the peer.
- `DATA_CHANNEL_CLOSED` — channel closed before / during send.
- `BITSET_COMPRESSION_FAILED` — gzip codec failed.
- `ABORTED` — `AbortSignal` fired or transport was disposed.
## License
MIT

View File

@@ -0,0 +1,98 @@
{
"name": "@suaveplan/drive-transport-webrtc",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "WebRTC datachannel transport for the drive mesh. Implements DriveTransport over @suaveplan/drive-webrtc's WebRTCClient + DataChannelManager with a length-prefixed binary framing protocol.",
"license": "MIT",
"author": "Suaveplan Team",
"browser": "./dist/index.js",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./framing/frame": {
"import": {
"types": "./dist/framing/frame/index.d.ts",
"default": "./dist/framing/frame/index.js"
}
},
"./transport/webrtc-transport": {
"import": {
"types": "./dist/transport/webrtc-transport/index.d.ts",
"default": "./dist/transport/webrtc-transport/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/drive-mesh": "workspace:^",
"@suaveplan/drive-webrtc": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,91 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the typed error-code registry and `DriveTransportWebRtcError`
class used by every public boundary of
`@suaveplan/drive-transport-webrtc`. The registry is created via the
shared `defineCodes` helper from `@suaveplan/error` so that codes are
emitted to the cross-package registry on first import; the wrapper
class extends `SuaveplanError<DriveTransportWebRtcErrorCode>` so that
consumers receive narrowly-typed `code` discriminators in catch blocks
and the stack trace carries the `DriveTransportWebRtcError` class name.
The registry deliberately ships a small, stable surface (seven codes)
matching the failure modes of a binary-framing, single-channel
transport — frame validation, request lifecycle, channel state, and a
bitset compression failure.
## Features
- `DRIVE_TRANSPORT_WEBRTC_CODES`: frozen record of the seven codes.
- `DriveTransportWebRtcErrorCode`: discriminated string-literal union.
- `DriveTransportWebRtcError`: subclass of `SuaveplanError` whose
constructor is public so call sites can `throw new`.
- `name = "DriveTransportWebRtcError"` for stack-trace clarity.
## Usage: Basic Example
```ts
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "@suaveplan/drive-transport-webrtc";
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.REQUEST_TIMEOUT,
"chunk request to alice timed out"
);
```
## Usage: Advanced Example
```ts
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
type DriveTransportWebRtcErrorCode,
} from "@suaveplan/drive-transport-webrtc";
function isRetryable(code: DriveTransportWebRtcErrorCode): boolean {
return (
code === DRIVE_TRANSPORT_WEBRTC_CODES.REQUEST_TIMEOUT ||
code === DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
try {
await transport.requestChunk(peerId, fileId, chunkId).next();
} catch (err) {
if (err instanceof DriveTransportWebRtcError && isRetryable(err.code)) {
// schedule retry with backoff
} else {
throw err;
}
}
```
## API Reference
- `DRIVE_TRANSPORT_WEBRTC_CODES` — frozen const containing
`FRAME_INVALID`, `FRAME_TOO_LARGE`, `REQUEST_TIMEOUT`,
`PEER_NOT_CONNECTED`, `DATA_CHANNEL_CLOSED`,
`BITSET_COMPRESSION_FAILED`, `ABORTED`.
- `DriveTransportWebRtcErrorCode` — union of the seven code strings.
- `class DriveTransportWebRtcError extends SuaveplanError<DriveTransportWebRtcErrorCode>` —
carries `code`, optional `cause` (`SuaveplanErrorOptions`), and
`name = "DriveTransportWebRtcError"`.
## Implementation Notes
The class explicitly assigns `this.name` rather than relying on the
default `Error.name` so that test harnesses and observability sinks
can filter by class name without resolving the prototype chain. The
codes are deliberately not numbered — the
`SuaveplanError`/`defineCodes` family keys errors by namespaced string
literals (`DRIVE_TRANSPORT_WEBRTC.REQUEST_TIMEOUT`), keeping the
registry stable across refactors.

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "./error-codes.js";
describe("DriveTransportWebRtcError", () => {
it("exposes every documented code", { timeout: 5000 }, () => {
expect(DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID).toMatch(
/FRAME_INVALID$/
);
expect(DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_TOO_LARGE).toMatch(
/FRAME_TOO_LARGE$/
);
expect(DRIVE_TRANSPORT_WEBRTC_CODES.REQUEST_TIMEOUT).toMatch(
/REQUEST_TIMEOUT$/
);
expect(DRIVE_TRANSPORT_WEBRTC_CODES.PEER_NOT_CONNECTED).toMatch(
/PEER_NOT_CONNECTED$/
);
expect(DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED).toMatch(
/DATA_CHANNEL_CLOSED$/
);
expect(DRIVE_TRANSPORT_WEBRTC_CODES.BITSET_COMPRESSION_FAILED).toMatch(
/BITSET_COMPRESSION_FAILED$/
);
expect(DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED).toMatch(/ABORTED$/);
});
it("constructs with code + message", { timeout: 5000 }, () => {
const err = new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
"bad frame"
);
expect(err).toBeInstanceOf(DriveTransportWebRtcError);
expect(err.code).toBe(DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID);
expect(err.message).toBe("bad frame");
expect(err.name).toBe("DriveTransportWebRtcError");
});
it("threads a cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED,
"outer",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Error codes for `@suaveplan/drive-transport-webrtc`.
*
* Every public boundary throws {@link DriveTransportWebRtcError}
* carrying one of the codes from {@link DRIVE_TRANSPORT_WEBRTC_CODES}.
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const DRIVE_TRANSPORT_WEBRTC_CODES = defineCodes(
"DRIVE_TRANSPORT_WEBRTC",
[
"FRAME_INVALID",
"FRAME_TOO_LARGE",
"REQUEST_TIMEOUT",
"PEER_NOT_CONNECTED",
"DATA_CHANNEL_CLOSED",
"BITSET_COMPRESSION_FAILED",
"ABORTED",
] as const
);
export type DriveTransportWebRtcErrorCode =
(typeof DRIVE_TRANSPORT_WEBRTC_CODES)[keyof typeof DRIVE_TRANSPORT_WEBRTC_CODES];
/**
* Typed error thrown by every `@suaveplan/drive-transport-webrtc`
* public boundary.
*/
export class DriveTransportWebRtcError extends SuaveplanError<DriveTransportWebRtcErrorCode> {
public constructor(
code: DriveTransportWebRtcErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "DriveTransportWebRtcError";
}
}

View File

@@ -0,0 +1,5 @@
export type { DriveTransportWebRtcErrorCode } from "./error-codes.js";
export {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "./error-codes.js";

View File

@@ -0,0 +1,96 @@
# `framing/frame`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the length-prefixed binary frame codec used by the WebRTC
drive transport. Every datachannel message is wrapped in a frame with
a 4-byte big-endian length, a 1-byte type discriminator, and a
variable-length payload. Encoding and decoding are kept in a single
module so the wire shape has exactly one source of truth.
The codec is deliberately runtime-agnostic — it consumes and produces
`Uint8Array` views with no dependency on `Buffer`, `node:stream`, or
browser-only types — and bounds every allocation against
`MAX_FRAME_BYTES` so that a hostile peer cannot induce
out-of-memory conditions by claiming a multi-gigabyte body length.
## Features
- `encodeFrame(type, payload)` produces the wire bytes for a frame.
- `decodeFrame(bytes)` parses a complete frame and returns
`{ type, payload }`.
- `FRAME_TYPE` constant for the three valid type bytes
(`CHUNK_REQUEST = 0x01`, `CHUNK_RESPONSE = 0x02`,
`ADVERTISEMENT = 0x03`).
- `MAX_FRAME_BYTES = 16 MiB` cap mirroring the data-channel high-water
mark.
- `toFrameBytes(data)` coerces a `MessageEvent.data` value into a
`Uint8Array`, returning `undefined` for string payloads which the
transport refuses to accept.
- Every malformed input maps to a typed
`DriveTransportWebRtcError(FRAME_INVALID | FRAME_TOO_LARGE)`.
## Usage: Basic Example
```ts
import { encodeFrame, decodeFrame, FRAME_TYPE } from "@suaveplan/drive-transport-webrtc";
const bytes = encodeFrame(FRAME_TYPE.CHUNK_REQUEST, new Uint8Array([1, 2, 3]));
const frame = decodeFrame(bytes);
console.log(frame.type === FRAME_TYPE.CHUNK_REQUEST); // true
console.log(frame.payload); // Uint8Array(3) [1, 2, 3]
```
## Usage: Advanced Example
```ts
import {
decodeFrame,
FRAME_TYPE,
toFrameBytes,
DriveTransportWebRtcError,
} from "@suaveplan/drive-transport-webrtc";
function onChannelMessage(event: MessageEvent): void {
const bytes = toFrameBytes(event.data);
if (bytes === undefined) return; // strings rejected by design
try {
const frame = decodeFrame(bytes);
if (frame.type === FRAME_TYPE.ADVERTISEMENT) {
handleAdvertisement(frame.payload);
}
} catch (err) {
if (err instanceof DriveTransportWebRtcError) {
logger.warn({ code: err.code }, "dropping malformed frame");
}
}
}
```
## API Reference
- `encodeFrame(type: FrameType, payload: Uint8Array): Uint8Array` —
throws `FRAME_INVALID` for unknown types and `FRAME_TOO_LARGE` when
the body exceeds `MAX_FRAME_BYTES`.
- `decodeFrame(bytes: Uint8Array): DecodedFrame` — validates length,
body cap, and type discriminator.
- `toFrameBytes(data: ArrayBuffer | string | Uint8Array): Uint8Array | undefined` —
string payloads return `undefined`.
- `FRAME_TYPE` — `{ CHUNK_REQUEST: 0x01, CHUNK_RESPONSE: 0x02, ADVERTISEMENT: 0x03 }`.
- `MAX_FRAME_BYTES` — soft cap on `frameType + payload` byte count.
- `MIN_FRAME_BYTES` — minimum body length (1).
## Implementation Notes
The 4-byte length prefix is interpreted as the byte count of
`frameType + payload` (not the whole frame including the prefix
itself) — this matches the proposal's wire format exactly. The
decoder rejects buffers whose total length disagrees with the
header so that a slicing bug at the call site surfaces as
`FRAME_INVALID` rather than silently truncated data. The codec does
not attempt to handle streamed accumulation — that responsibility
lives in the data-channel layer, which assembles full frames before
invoking `decodeFrame`.

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import {
decodeFrame,
encodeFrame,
FRAME_TYPE,
MAX_FRAME_BYTES,
MIN_FRAME_BYTES,
toFrameBytes,
} from "./frame.js";
describe("encodeFrame / decodeFrame round-trip", () => {
it("round-trips a chunk-request frame", { timeout: 5000 }, () => {
const payload = new Uint8Array(36).fill(0xab);
const wire = encodeFrame(FRAME_TYPE.CHUNK_REQUEST, payload);
const decoded = decodeFrame(wire);
expect(decoded.type).toBe(FRAME_TYPE.CHUNK_REQUEST);
expect(decoded.payload).toEqual(payload);
});
it("round-trips a chunk-response frame", { timeout: 5000 }, () => {
const payload = new Uint8Array([1, 2, 3, 4, 5]);
const wire = encodeFrame(FRAME_TYPE.CHUNK_RESPONSE, payload);
const decoded = decodeFrame(wire);
expect(decoded.type).toBe(FRAME_TYPE.CHUNK_RESPONSE);
expect(decoded.payload).toEqual(payload);
});
it("round-trips an advertisement frame", { timeout: 5000 }, () => {
const payload = new Uint8Array(64).fill(0x55);
const wire = encodeFrame(FRAME_TYPE.ADVERTISEMENT, payload);
const decoded = decodeFrame(wire);
expect(decoded.type).toBe(FRAME_TYPE.ADVERTISEMENT);
expect(decoded.payload).toEqual(payload);
});
it("encodes correct big-endian length prefix", { timeout: 5000 }, () => {
const payload = new Uint8Array(10);
const wire = encodeFrame(FRAME_TYPE.CHUNK_REQUEST, payload);
const view = new DataView(wire.buffer);
expect(view.getUint32(0, false)).toBe(11);
expect(wire[4]).toBe(FRAME_TYPE.CHUNK_REQUEST);
});
it("MIN_FRAME_BYTES is 1", { timeout: 5000 }, () => {
expect(MIN_FRAME_BYTES).toBe(1);
});
it("MAX_FRAME_BYTES is 16 MiB", { timeout: 5000 }, () => {
expect(MAX_FRAME_BYTES).toBe(16 * 1024 * 1024);
});
});
describe("encodeFrame error paths", () => {
it("rejects unknown frame type", { timeout: 5000 }, () => {
try {
encodeFrame(0xff as unknown as 0x01, new Uint8Array(0));
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DriveTransportWebRtcError);
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects oversize payload", { timeout: 5000 }, () => {
const payload = new Uint8Array(MAX_FRAME_BYTES);
try {
encodeFrame(FRAME_TYPE.CHUNK_REQUEST, payload);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_TOO_LARGE
);
}
});
});
describe("decodeFrame error paths", () => {
it("rejects frames shorter than the header", { timeout: 5000 }, () => {
try {
decodeFrame(new Uint8Array(4));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects frames with body length 0", { timeout: 5000 }, () => {
// header says bodyLen = 0; reject.
const wire = new Uint8Array(5);
// bytes[0..4] are 0 by default — bodyLen = 0.
try {
decodeFrame(wire);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects frames whose header bodyLen exceeds MAX_FRAME_BYTES", {
timeout: 5000,
}, () => {
const wire = new Uint8Array(5);
new DataView(wire.buffer).setUint32(0, MAX_FRAME_BYTES + 1, false);
try {
decodeFrame(wire);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_TOO_LARGE
);
}
});
it("rejects frames whose declared length disagrees with byte count", {
timeout: 5000,
}, () => {
// bodyLen claims 10, but only 1 body byte present.
const wire = new Uint8Array(5);
new DataView(wire.buffer).setUint32(0, 10, false);
wire[4] = FRAME_TYPE.CHUNK_REQUEST;
try {
decodeFrame(wire);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects unknown frame type discriminator", { timeout: 5000 }, () => {
const wire = new Uint8Array(6);
new DataView(wire.buffer).setUint32(0, 2, false);
wire[4] = 0xfe; // unknown
wire[5] = 0x00;
try {
decodeFrame(wire);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
});
describe("toFrameBytes", () => {
it("returns undefined for string payloads", { timeout: 5000 }, () => {
expect(toFrameBytes("hello")).toBeUndefined();
});
it("passes through Uint8Array", { timeout: 5000 }, () => {
const u = new Uint8Array([1, 2, 3]);
expect(toFrameBytes(u)).toBe(u);
});
it("wraps ArrayBuffer into Uint8Array", { timeout: 5000 }, () => {
const ab = new ArrayBuffer(3);
new Uint8Array(ab).set([4, 5, 6]);
const u = toFrameBytes(ab);
expect(u).toBeInstanceOf(Uint8Array);
expect(u).toEqual(new Uint8Array([4, 5, 6]));
});
});

View File

@@ -0,0 +1,141 @@
/**
* Length-prefixed binary frame codec for the WebRTC drive transport.
*
* Wire format:
* ```text
* [4 B uint32 BE frameLen]
* [1 B uint8 frameType] // 0x01 chunk-request, 0x02 chunk-response, 0x03 advertisement
* [frameLen-1 B payload]
* ```
*
* `frameLen` is the byte count of `frameType ‖ payload` — receivers
* read 4 bytes, then `frameLen` more bytes to obtain the full frame
* body.
*/
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
/**
* Frame type discriminator (1 byte).
*/
export const FRAME_TYPE = {
CHUNK_REQUEST: 0x01,
CHUNK_RESPONSE: 0x02,
ADVERTISEMENT: 0x03,
} as const;
export type FrameType = (typeof FRAME_TYPE)[keyof typeof FRAME_TYPE];
/**
* Soft cap on frame body size (`frameType + payload`). Mirrors the
* drive-webrtc high-water-mark default to avoid pathological memory
* pressure on poorly behaved senders.
*/
export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
/**
* Minimum frame body byte count — every frame must carry at least the
* 1-byte type discriminator.
*/
export const MIN_FRAME_BYTES = 1;
/**
* Decoded frame structure.
*/
export interface DecodedFrame {
readonly type: FrameType;
readonly payload: Uint8Array;
}
/**
* Encode a frame to its wire bytes.
*/
export function encodeFrame(type: FrameType, payload: Uint8Array): Uint8Array {
if (!isFrameType(type)) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`unknown frame type 0x${(type as number).toString(16).padStart(2, "0")}`
);
}
const bodyLen = 1 + payload.length;
if (bodyLen > MAX_FRAME_BYTES) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_TOO_LARGE,
`frame body ${bodyLen} bytes exceeds cap ${MAX_FRAME_BYTES}`
);
}
const out = new Uint8Array(4 + bodyLen);
const view = new DataView(out.buffer);
view.setUint32(0, bodyLen, false);
out[4] = type;
out.set(payload, 5);
return out;
}
/**
* Decode a complete frame from its wire bytes. Input must be exactly a
* single full frame — callers handling streamed datachannel messages
* should accumulate until `frameLen` bytes are available before
* invoking this.
*/
export function decodeFrame(bytes: Uint8Array): DecodedFrame {
if (bytes.length < 5) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`frame too short: ${bytes.length} bytes`
);
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const bodyLen = view.getUint32(0, false);
if (bodyLen < MIN_FRAME_BYTES) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`frame body length ${bodyLen} below minimum ${MIN_FRAME_BYTES}`
);
}
if (bodyLen > MAX_FRAME_BYTES) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_TOO_LARGE,
`frame body ${bodyLen} bytes exceeds cap ${MAX_FRAME_BYTES}`
);
}
if (bytes.length !== 4 + bodyLen) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`frame length mismatch: header says ${bodyLen}, got ${bytes.length - 4} body bytes`
);
}
const type = bytes[4] as number;
if (!isFrameType(type)) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`unknown frame type 0x${type.toString(16).padStart(2, "0")}`
);
}
const payload = bytes.slice(5, 4 + bodyLen);
return { type, payload };
}
/**
* Coerce a `MessageEvent.data` into a Uint8Array view. Returns
* `undefined` for string payloads — callers treat those as invalid
* since the transport only emits binary frames.
*/
export function toFrameBytes(
data: ArrayBuffer | string | Uint8Array
): Uint8Array | undefined {
if (typeof data === "string") return undefined;
if (data instanceof Uint8Array) return data;
return new Uint8Array(data);
}
function isFrameType(value: number): value is FrameType {
return (
value === FRAME_TYPE.CHUNK_REQUEST ||
value === FRAME_TYPE.CHUNK_RESPONSE ||
value === FRAME_TYPE.ADVERTISEMENT
);
}

View File

@@ -0,0 +1,9 @@
export type { DecodedFrame, FrameType } from "./frame.js";
export {
decodeFrame,
encodeFrame,
FRAME_TYPE,
MAX_FRAME_BYTES,
MIN_FRAME_BYTES,
toFrameBytes,
} from "./frame.js";

View File

@@ -0,0 +1,57 @@
// Side-effect import ensures DRIVE_TRANSPORT_WEBRTC_CODES is registered
// for subpath consumers that import only from a feature path.
import "./error-codes/index.js";
export {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
type DriveTransportWebRtcErrorCode,
} from "./error-codes/index.js";
export type {
DecodedFrame,
FrameType,
} from "./framing/frame/index.js";
export {
decodeFrame,
encodeFrame,
FRAME_TYPE,
MAX_FRAME_BYTES,
MIN_FRAME_BYTES,
toFrameBytes,
} from "./framing/frame/index.js";
export {
type AdvertisementPayload,
AdvertisementPayloadSchema,
FILE_ID_BYTES,
FILE_ID_HEX_LENGTH,
type RequestChunkPayload,
RequestChunkPayloadSchema,
} from "./schemas/index.js";
export type {
BitsetCodec,
DecodedAdvertisement,
DecodedChunkRequest,
WebRTCDriveTransportOptions,
WebRTCTransportEventMap,
} from "./transport/webrtc-transport/index.js";
export {
createDefaultBitsetCodec,
DEFAULT_LABEL,
DEFAULT_MAX_CONCURRENT_REQUESTS,
DEFAULT_REQUEST_TIMEOUT_MS,
decodeAdvertisement,
decodeChunkRequest,
encodeAdvertisement,
encodeChunkRequest,
encodeChunkResponse,
fileIdBytesToHex,
fileIdHexToBytes,
TRANSPORT_ID,
WebRTCDriveTransport,
} from "./transport/webrtc-transport/index.js";
export type {
AdvertisementHandler,
ChunkAdvertisement,
DriveTransport,
Unsubscribe,
} from "./types/index.js";

View File

@@ -0,0 +1,84 @@
# `schemas/frame.schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod boundary schemas for the two structured payload shapes the WebRTC
drive transport speaks on the wire: chunk-request and advertisement.
Frames themselves are length-prefixed binary blobs validated by the
`framing/frame` module; once the type discriminator is read, the
payload bytes are projected into one of the shapes validated here so
that consumers handling the payload at the application layer get a
Zod-safe value rather than raw bytes.
The shapes match the canonical `DriveTransport` contract from
`@suaveplan/drive-mesh`: file ids are hex-encoded (`64` lowercase
hex chars) and advertisements carry a `totalChunks` field alongside
the gzipped bitset. Anything else fails Zod validation and surfaces
as a `DriveTransportWebRtcError(FRAME_INVALID)` at the transport
boundary.
## Features
- `RequestChunkPayloadSchema` — `{ fileId: string(hex64), chunkId: uint32 }`.
- `AdvertisementPayloadSchema` — `{ fileId: string(hex64), totalChunks: uint32, bitsetGzip: Uint8Array(>0) }`.
- `FILE_ID_BYTES` — the raw 32-byte wire length constant.
- `FILE_ID_HEX_LENGTH` — the corresponding `64` hex-char length.
## Usage: Basic Example
```ts
import {
RequestChunkPayloadSchema,
} from "@suaveplan/drive-transport-webrtc";
const parsed = RequestChunkPayloadSchema.parse({
fileId: "ab".repeat(32),
chunkId: 42,
});
```
## Usage: Advanced Example
```ts
import {
AdvertisementPayloadSchema,
decodeAdvertisement,
decodeFrame,
FRAME_TYPE,
} from "@suaveplan/drive-transport-webrtc";
function projectAdvertisement(frameBytes: Uint8Array) {
const frame = decodeFrame(frameBytes);
if (frame.type !== FRAME_TYPE.ADVERTISEMENT) return undefined;
const decoded = decodeAdvertisement(frame.payload);
return AdvertisementPayloadSchema.parse(decoded);
}
```
## API Reference
- `FILE_ID_BYTES: 32` — re-exported raw-bytes constant for the
on-the-wire form.
- `FILE_ID_HEX_LENGTH: 64` — corresponding hex-char length for the
public API form.
- `RequestChunkPayloadSchema` — Zod object with exact-64-char hex
`fileId` refinement and `chunkId` clamped to non-negative uint32.
- `AdvertisementPayloadSchema` — Zod object enforcing the hex
`fileId`, a uint32 `totalChunks`, and a non-empty `bitsetGzip`
payload.
- `RequestChunkPayload` / `AdvertisementPayload` — inferred TypeScript
types.
## Implementation Notes
The schemas live outside the codec module so that consumers who want
to validate at the application boundary (e.g. before queueing a
request for retry) can do so without re-importing the binary codec.
The `bitsetGzip` field is asserted non-empty because every well-formed
gzip stream is at least a few bytes; an empty value usually indicates
a slicing bug at the sender, which we surface eagerly. `chunkId` and
`totalChunks` are clamped to uint32 to match the wire encoding
(`DataView.setUint32`).

View File

@@ -0,0 +1,53 @@
/**
* Zod boundary schemas for decoded WebRTC drive-transport payloads.
*
* Frames themselves are length-prefixed binary blobs decoded by the
* `framing/frame` module; once the type discriminator is read, the
* payload bytes are projected into one of the shapes validated here.
*
* `fileId` is the hex-encoded 32-byte file identifier (64 lowercase
* hex chars) per the canonical `DriveTransport` interface in
* `@suaveplan/drive-mesh`. Wire frames still carry the 32 raw bytes —
* the codec module converts to / from hex at the boundary.
*/
import z from "zod";
/**
* Fixed-length 32-byte file id (sha256 / blake3 digest).
*/
export const FILE_ID_BYTES = 32;
/**
* Hex-encoded 32-byte file id: exactly 64 hex chars (lower or upper).
*/
export const FILE_ID_HEX_LENGTH = 64;
const fileIdHexSchema = z.string().regex(/^[0-9a-fA-F]{64}$/u, {
message: "fileId must be a 64-character hex string (32 bytes)",
});
/**
* Chunk-request payload: `fileId(hex) ‖ chunkId(uint32 BE)`.
*/
export const RequestChunkPayloadSchema = z.object({
fileId: fileIdHexSchema,
chunkId: z.number().int().nonnegative().max(0xffffffff),
});
export type RequestChunkPayload = z.infer<typeof RequestChunkPayloadSchema>;
/**
* Advertisement payload: `fileId(hex) ‖ totalChunks(uint32) ‖ gzip(bitset)`.
*/
export const AdvertisementPayloadSchema = z.object({
fileId: fileIdHexSchema,
/** Total chunks the file resolves to. */
totalChunks: z.number().int().nonnegative().max(0xffffffff),
/** Gzipped bitset bytes; must be non-empty to round-trip. */
bitsetGzip: z.instanceof(Uint8Array).refine((b) => b.length > 0, {
message: "bitsetGzip must be non-empty",
}),
});
export type AdvertisementPayload = z.infer<typeof AdvertisementPayloadSchema>;

View File

@@ -0,0 +1,8 @@
export {
type AdvertisementPayload,
AdvertisementPayloadSchema,
FILE_ID_BYTES,
FILE_ID_HEX_LENGTH,
type RequestChunkPayload,
RequestChunkPayloadSchema,
} from "./frame.schemas.js";

View File

@@ -0,0 +1,77 @@
# `transport/webrtc-transport/bitset-codec`
## Purpose
Provides the gzip codec used to compress advertisement bitsets before
they hit the wire. Bitsets describing which chunks of a file a peer
has tend to contain long runs of zeros or ones — gzip routinely
trims them from several kilobytes down to a few hundred bytes,
materially reducing datachannel bandwidth on advertisement broadcasts.
The codec is built on the platform `CompressionStream` /
`DecompressionStream` primitives (available in browsers, Bun, and Node
≥ 18). It is exposed as an injectable interface so test environments
or legacy runtimes can supply a synchronous shim if needed.
## Features
- `BitsetCodec` injectable interface with `compress` / `decompress`
methods.
- `createDefaultBitsetCodec()` returns a platform-backed codec.
- Every failure path maps to
`DriveTransportWebRtcError(BITSET_COMPRESSION_FAILED)` carrying the
underlying cause.
## Usage: Basic Example
```ts
import { createDefaultBitsetCodec } from "@suaveplan/drive-transport-webrtc";
const codec = createDefaultBitsetCodec();
const wire = await codec.compress(new Uint8Array([0, 0, 0, 1, 0]));
const round = await codec.decompress(wire);
```
## Usage: Advanced Example
```ts
import {
type BitsetCodec,
WebRTCDriveTransport,
} from "@suaveplan/drive-transport-webrtc";
import { gzipSync, gunzipSync } from "node:zlib";
const nodeZlibCodec: BitsetCodec = {
async compress(bytes) {
return new Uint8Array(gzipSync(bytes));
},
async decompress(bytes) {
return new Uint8Array(gunzipSync(bytes));
},
};
const transport = new WebRTCDriveTransport({
client,
dataChannels,
bitsetCodec: nodeZlibCodec,
});
```
## API Reference
- `interface BitsetCodec` — `compress(bytes: Uint8Array): Promise<Uint8Array>`
and `decompress(bytes: Uint8Array): Promise<Uint8Array>`.
- `createDefaultBitsetCodec(): BitsetCodec` — picks
`CompressionStream` / `DecompressionStream` off `globalThis`. Throws
`BITSET_COMPRESSION_FAILED` at call time if the runtime does not
expose them.
## Implementation Notes
Compression is wrapped in a `Blob` + `Response` round-trip so the
codec works against the standard `ReadableStream`/`Response`
interfaces rather than reaching for `node:stream/web`. This keeps the
universal promise that the package can ship into any modern
runtime without optional native dependencies. The codec rejects with a
typed error rather than letting `TypeError` from a missing
constructor escape, so call sites can branch on `err.code`.

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
type DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import {
createDefaultBitsetCodec,
platformStreamCtorResolver,
type StreamCtorResolver,
} from "./bitset-codec.js";
describe("createDefaultBitsetCodec", () => {
it("round-trips arbitrary bytes through gzip", {
timeout: 5000,
}, async () => {
const codec = createDefaultBitsetCodec();
const original = new Uint8Array(1024);
for (let i = 0; i < original.length; i++) original[i] = i & 0xff;
const compressed = await codec.compress(original);
expect(compressed.length).toBeGreaterThan(0);
const decompressed = await codec.decompress(compressed);
expect(decompressed).toEqual(original);
});
it("compresses repetitive bitsets effectively", {
timeout: 5000,
}, async () => {
const codec = createDefaultBitsetCodec();
const original = new Uint8Array(2048).fill(0xff);
const compressed = await codec.compress(original);
expect(compressed.length).toBeLessThan(original.length);
});
it("throws BITSET_COMPRESSION_FAILED when CompressionStream missing", {
timeout: 5000,
}, async () => {
const resolver: StreamCtorResolver = {
compressionStream: () => undefined,
decompressionStream: () =>
platformStreamCtorResolver.decompressionStream(),
};
const codec = createDefaultBitsetCodec(resolver);
try {
await codec.compress(new Uint8Array([1]));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.BITSET_COMPRESSION_FAILED
);
}
});
it("throws BITSET_COMPRESSION_FAILED when DecompressionStream missing", {
timeout: 5000,
}, async () => {
const resolver: StreamCtorResolver = {
compressionStream: () =>
platformStreamCtorResolver.compressionStream(),
decompressionStream: () => undefined,
};
const codec = createDefaultBitsetCodec(resolver);
try {
await codec.decompress(new Uint8Array([1]));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.BITSET_COMPRESSION_FAILED
);
}
});
it("throws BITSET_COMPRESSION_FAILED on malformed gzip input", {
timeout: 5000,
}, async () => {
const codec = createDefaultBitsetCodec();
try {
await codec.decompress(new Uint8Array([1, 2, 3, 4]));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.BITSET_COMPRESSION_FAILED
);
}
});
it("platformStreamCtorResolver returns the global ctors", {
timeout: 5000,
}, () => {
expect(platformStreamCtorResolver.compressionStream()).toBeDefined();
expect(platformStreamCtorResolver.decompressionStream()).toBeDefined();
});
});

View File

@@ -0,0 +1,80 @@
/**
* Gzip/gunzip helpers for advertisement bitsets.
*
* Uses the platform `CompressionStream` / `DecompressionStream`
* primitives (available in browsers, Bun, and Node ≥ 18). Bitsets are
* typically sparse run-length-friendly bit arrays, so gzip yields
* meaningful wire savings versus raw payloads.
*/
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
/**
* Injectable codec — useful when a runtime ships without the platform
* streams (e.g. legacy Node, custom shims).
*/
export interface BitsetCodec {
compress(bytes: Uint8Array): Promise<Uint8Array>;
decompress(bytes: Uint8Array): Promise<Uint8Array>;
}
/**
* Resolver used to look up platform stream constructors. Exposed as a
* parameter on {@link createDefaultBitsetCodec} so unit tests can
* substitute the lookup without mutating globals.
*/
export interface StreamCtorResolver {
compressionStream(): typeof CompressionStream | undefined;
decompressionStream(): typeof DecompressionStream | undefined;
}
export const platformStreamCtorResolver: StreamCtorResolver = {
compressionStream: () =>
(globalThis as { CompressionStream?: typeof CompressionStream })
.CompressionStream,
decompressionStream: () =>
(globalThis as { DecompressionStream?: typeof DecompressionStream })
.DecompressionStream,
};
export function createDefaultBitsetCodec(
resolver: StreamCtorResolver = platformStreamCtorResolver
): BitsetCodec {
return {
compress: (bytes) => runStream(bytes, "compress", resolver),
decompress: (bytes) => runStream(bytes, "decompress", resolver),
};
}
async function runStream(
bytes: Uint8Array,
mode: "compress" | "decompress",
resolver: StreamCtorResolver
): Promise<Uint8Array> {
const Ctor =
mode === "compress"
? resolver.compressionStream()
: resolver.decompressionStream();
if (typeof Ctor !== "function") {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.BITSET_COMPRESSION_FAILED,
`${mode === "compress" ? "CompressionStream" : "DecompressionStream"} not available in this runtime`
);
}
try {
const stream = new Ctor("gzip");
const blob = new Blob([bytes as BlobPart]);
const piped = blob.stream().pipeThrough(stream);
const buf = await new Response(piped).arrayBuffer();
return new Uint8Array(buf);
} catch (cause) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.BITSET_COMPRESSION_FAILED,
`bitset ${mode} failed`,
{ cause: cause as Error }
);
}
}

View File

@@ -0,0 +1,74 @@
# `transport/webrtc-transport/hex`
## Purpose
Boundary helpers converting between the on-the-wire 32-byte file id
representation and the hex-encoded form used by every public API in the
canonical `DriveTransport` interface (defined in
`@suaveplan/drive-mesh`). The WebRTC transport keeps the wire frames
binary — 32 raw bytes followed by metadata — but every public method
takes a hex `fileId` so callers can pass the same id they get from
`drive-plugin-chunks`' `ChunkManifest` without doing the conversion
themselves.
The module delegates to `@suaveplan/crypto-utils`' `bytesToHex` /
`hexToBytes` for the actual encoding (constant-time, throughly-tested
shared impl) and wraps the conversion in `DriveTransportWebRtcError`
(`FRAME_INVALID`) on bad input so the codec layer can rely on a single
error type.
## Features
- `fileIdBytesToHex(bytes)` — strict 32-byte input check.
- `fileIdHexToBytes(hex)` — strict 64-hex-char input check.
- Maps both length mismatches and malformed-hex parse errors to the
canonical `FRAME_INVALID` code so the caller never sees a raw
underlying error.
## Usage: Basic Example
```ts
import {
fileIdBytesToHex,
fileIdHexToBytes,
} from "@suaveplan/drive-transport-webrtc";
const bytes = new Uint8Array(32).fill(0xab);
const hex = fileIdBytesToHex(bytes); // "abab...ab"
const back = fileIdHexToBytes(hex);
console.log(back.length); // 32
```
## Usage: Advanced Example
```ts
import {
DriveTransportWebRtcError,
fileIdHexToBytes,
} from "@suaveplan/drive-transport-webrtc";
try {
fileIdHexToBytes("zzzz"); // too short AND non-hex
} catch (err) {
const e = err as DriveTransportWebRtcError;
console.log(e.code); // "DRIVE_TRANSPORT_WEBRTC.FRAME_INVALID"
}
```
## API Reference
- `fileIdBytesToHex(bytes: Uint8Array): string` — returns lower-case
64-char hex. Throws `DriveTransportWebRtcError(FRAME_INVALID)` if
`bytes.length !== 32`.
- `fileIdHexToBytes(hex: string): Uint8Array` — returns 32 raw bytes.
Throws `DriveTransportWebRtcError(FRAME_INVALID)` if the length is
wrong or the string is not valid hex.
## Implementation Notes
The hex codec is a thin pre-validation wrapper around
`@suaveplan/crypto-utils`. Strict length checks happen before the
delegating call so we surface a clear, well-typed error before
`hexToBytes` can throw its more generic parse error. The wrapping
keeps the throw site local to the transport — useful for stack-trace
hygiene when chunk requests fail at the boundary.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
type DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import { fileIdBytesToHex, fileIdHexToBytes } from "./hex.js";
const FID_BYTES = new Uint8Array(32).fill(0xab);
const FID_HEX = "ab".repeat(32);
describe("fileIdBytesToHex", () => {
it("round-trips a 32-byte file id", { timeout: 5000 }, () => {
expect(fileIdBytesToHex(FID_BYTES)).toBe(FID_HEX);
});
it("rejects fewer than 32 bytes", { timeout: 5000 }, () => {
try {
fileIdBytesToHex(new Uint8Array(16));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects more than 32 bytes", { timeout: 5000 }, () => {
try {
fileIdBytesToHex(new Uint8Array(33));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
});
describe("fileIdHexToBytes", () => {
it("round-trips a 64-char hex string", { timeout: 5000 }, () => {
expect(Array.from(fileIdHexToBytes(FID_HEX))).toEqual(
Array.from(FID_BYTES)
);
});
it("rejects wrong-length hex", { timeout: 5000 }, () => {
try {
fileIdHexToBytes("ab".repeat(8));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects non-hex characters", { timeout: 5000 }, () => {
try {
fileIdHexToBytes("z".repeat(64));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
});

View File

@@ -0,0 +1,58 @@
/**
* Hex / bytes conversion helpers scoped to the WebRTC drive transport
* wire codec. Delegates to `@suaveplan/crypto-utils` for the canonical
* fast / constant-time implementation — kept as a local module so the
* codec layer has a single import surface and the conversion is
* trivially mockable in the (rare) test that wants to substitute it.
*/
import {
toHex as bytesToHexImpl,
fromHex as hexToBytesImpl,
} from "@suaveplan/crypto-utils";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import {
FILE_ID_BYTES,
FILE_ID_HEX_LENGTH,
} from "../../schemas/frame.schemas.js";
/**
* Convert raw 32-byte file id bytes to lower-case hex. Throws
* {@link DriveTransportWebRtcError} (`FRAME_INVALID`) if the input is
* not exactly {@link FILE_ID_BYTES} bytes.
*/
export function fileIdBytesToHex(bytes: Uint8Array): string {
if (bytes.length !== FILE_ID_BYTES) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`fileId must be ${FILE_ID_BYTES} bytes, got ${bytes.length}`
);
}
return bytesToHexImpl(bytes);
}
/**
* Convert a 64-char hex file id to its 32-byte form. Throws
* {@link DriveTransportWebRtcError} (`FRAME_INVALID`) if the input is
* not exactly {@link FILE_ID_HEX_LENGTH} hex chars.
*/
export function fileIdHexToBytes(hex: string): Uint8Array {
if (hex.length !== FILE_ID_HEX_LENGTH) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`fileId must be ${FILE_ID_HEX_LENGTH} hex chars, got ${hex.length}`
);
}
try {
return hexToBytesImpl(hex);
} catch (cause) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
"fileId is not valid hex",
{ cause: cause as Error }
);
}
}

View File

@@ -0,0 +1,26 @@
export type { BitsetCodec, StreamCtorResolver } from "./bitset-codec.js";
export {
createDefaultBitsetCodec,
platformStreamCtorResolver,
} from "./bitset-codec.js";
export { fileIdBytesToHex, fileIdHexToBytes } from "./hex.js";
export {
type DecodedAdvertisement,
type DecodedChunkRequest,
decodeAdvertisement,
decodeChunkRequest,
encodeAdvertisement,
encodeChunkRequest,
} from "./payload-codec.js";
export type {
WebRTCDriveTransportOptions,
WebRTCTransportEventMap,
} from "./webrtc-transport.js";
export {
DEFAULT_LABEL,
DEFAULT_MAX_CONCURRENT_REQUESTS,
DEFAULT_REQUEST_TIMEOUT_MS,
encodeChunkResponse,
TRANSPORT_ID,
WebRTCDriveTransport,
} from "./webrtc-transport.js";

View File

@@ -0,0 +1,97 @@
# `transport/webrtc-transport/payload-codec`
## Purpose
Implements the byte-level encoder/decoder for the two structured
frame bodies the WebRTC drive transport carries: chunk-requests
(`fileId(32 raw bytes) ‖ chunkId(4)`) and advertisements
(`fileId(32 raw bytes) ‖ totalChunks(4) ‖ gzip(bitset)`). Frame headers
(length prefix + type byte) are handled by `framing/frame`; this
module operates purely on the body bytes after the type
discriminator has been consumed.
The public API takes / returns hex-encoded file ids to match the
canonical `DriveTransport` contract from `@suaveplan/drive-mesh`; the
on-the-wire representation remains the raw 32 bytes for compactness.
Hex ↔ bytes conversion happens inside this module via the sibling
`hex` helpers.
Keeping the payload codec separate from the frame codec lets callers
acting as chunk servers (i.e. peers fulfilling another peer's request
through this transport) decode requests without paying the cost of
re-validating the outer frame, and lets clients build advertisement
bodies independently of the gzip codec.
## Features
- `encodeChunkRequest(fileId: string, chunkId)` — `fileId(32) ‖ chunkId(4 BE)`.
- `decodeChunkRequest(payload)` — strict length check + uint32 read,
returns `fileId` as hex.
- `encodeAdvertisement(fileId: string, totalChunks, bitsetGzip)` —
prepends the file id and `totalChunks` to the gzipped bitset.
- `decodeAdvertisement(payload)` — splits the fixed-size header at
byte 36 (32-byte fileId + 4-byte totalChunks) and returns the
remaining bytes as `bitsetGzip`.
- All malformed inputs surface as
`DriveTransportWebRtcError(FRAME_INVALID)`.
## Usage: Basic Example
```ts
import {
encodeChunkRequest,
decodeChunkRequest,
} from "@suaveplan/drive-transport-webrtc";
const fileId = "ab".repeat(32);
const payload = encodeChunkRequest(fileId, 42);
const decoded = decodeChunkRequest(payload);
console.log(decoded.chunkId); // 42
console.log(decoded.fileId === fileId); // true
```
## Usage: Advanced Example
```ts
import {
decodeFrame,
decodeChunkRequest,
encodeChunkResponse,
FRAME_TYPE,
} from "@suaveplan/drive-transport-webrtc";
async function respondToChunkRequest(
frameBytes: Uint8Array,
channel: RTCDataChannel,
chunkStore: { get: (fid: string, cid: number) => Promise<Uint8Array> }
) {
const frame = decodeFrame(frameBytes);
if (frame.type !== FRAME_TYPE.CHUNK_REQUEST) return;
const { fileId, chunkId } = decodeChunkRequest(frame.payload);
const bytes = await chunkStore.get(fileId, chunkId);
channel.send(encodeChunkResponse(bytes));
}
```
## API Reference
- `encodeChunkRequest(fileId: string, chunkId: number): Uint8Array` —
rejects non-64-char-hex `fileId` and out-of-range `chunkId`.
- `decodeChunkRequest(payload: Uint8Array): { fileId: string; chunkId: number }` —
rejects payloads whose length is not exactly 36.
- `encodeAdvertisement(fileId: string, totalChunks: number, bitsetGzip: Uint8Array): Uint8Array` —
rejects non-uint32 `totalChunks` and empty `bitsetGzip`.
- `decodeAdvertisement(payload: Uint8Array): { fileId: string; totalChunks: number; bitsetGzip: Uint8Array }` —
rejects payloads ≤ 36 bytes.
## Implementation Notes
The codec uses `DataView.setUint32` / `getUint32` with `littleEndian = false`
so the wire form matches the standard big-endian network byte order.
Every encoder allocates a fresh `Uint8Array` rather than reusing
caller-provided buffers, which keeps the cost model predictable for
upstream callers building advertisement frames in a hot loop.
The advertisement layout deliberately places `totalChunks` ahead of
the gzipped bitset so a streaming decoder can short-circuit on
`totalChunks` mismatches before paying the decompression cost.

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
type DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import {
decodeAdvertisement,
decodeChunkRequest,
encodeAdvertisement,
encodeChunkRequest,
} from "./payload-codec.js";
const FID_HEX = "ab".repeat(32);
describe("chunk-request payload", () => {
it("round-trips fileId + chunkId", { timeout: 5000 }, () => {
const wire = encodeChunkRequest(FID_HEX, 123);
const decoded = decodeChunkRequest(wire);
expect(decoded.fileId).toBe(FID_HEX);
expect(decoded.chunkId).toBe(123);
});
it("rejects wrong fileId length on encode", { timeout: 5000 }, () => {
try {
encodeChunkRequest("ab".repeat(8), 0);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects non-hex fileId on encode", { timeout: 5000 }, () => {
try {
encodeChunkRequest("z".repeat(64), 0);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects non-integer chunkId", { timeout: 5000 }, () => {
try {
encodeChunkRequest(FID_HEX, 1.5);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects negative chunkId", { timeout: 5000 }, () => {
try {
encodeChunkRequest(FID_HEX, -1);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects chunkId > uint32", { timeout: 5000 }, () => {
try {
encodeChunkRequest(FID_HEX, 0x1_0000_0000);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects wrong-size payload on decode", { timeout: 5000 }, () => {
try {
decodeChunkRequest(new Uint8Array(35));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
});
describe("advertisement payload", () => {
it("round-trips fileId + totalChunks + bitsetGzip", {
timeout: 5000,
}, () => {
const gz = new Uint8Array([1, 2, 3, 4]);
const wire = encodeAdvertisement(FID_HEX, 257, gz);
const decoded = decodeAdvertisement(wire);
expect(decoded.fileId).toBe(FID_HEX);
expect(decoded.totalChunks).toBe(257);
expect(decoded.bitsetGzip).toEqual(gz);
});
it("rejects wrong fileId length on encode", { timeout: 5000 }, () => {
try {
encodeAdvertisement("ab", 1, new Uint8Array([1]));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects negative totalChunks on encode", { timeout: 5000 }, () => {
try {
encodeAdvertisement(FID_HEX, -1, new Uint8Array([1]));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects totalChunks > uint32 on encode", { timeout: 5000 }, () => {
try {
encodeAdvertisement(FID_HEX, 0x1_0000_0000, new Uint8Array([1]));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects non-integer totalChunks on encode", { timeout: 5000 }, () => {
try {
encodeAdvertisement(FID_HEX, 1.5, new Uint8Array([1]));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects empty bitsetGzip on encode", { timeout: 5000 }, () => {
try {
encodeAdvertisement(FID_HEX, 0, new Uint8Array(0));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
it("rejects payload too short on decode", { timeout: 5000 }, () => {
try {
decodeAdvertisement(new Uint8Array(36));
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID
);
}
});
});

View File

@@ -0,0 +1,123 @@
/**
* Payload encode/decode helpers for chunk-request and advertisement
* frame bodies. Frame headers (length + type) are handled by
* `framing/frame`; this module only manipulates the body bytes.
*
* Public functions accept / return hex-encoded file ids matching the
* canonical `DriveTransport` interface from `@suaveplan/drive-mesh`.
* The on-the-wire representation remains a fixed 32 raw bytes for
* compactness — conversion happens at this boundary.
*/
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import { FILE_ID_BYTES } from "../../schemas/frame.schemas.js";
import { fileIdBytesToHex, fileIdHexToBytes } from "./hex.js";
const CHUNK_REQUEST_PAYLOAD_LEN = FILE_ID_BYTES + 4;
const TOTAL_CHUNKS_FIELD_BYTES = 4;
const ADVERTISEMENT_HEADER_LEN = FILE_ID_BYTES + TOTAL_CHUNKS_FIELD_BYTES;
/**
* Encode a chunk-request payload: `fileId(32) ‖ chunkId(4 BE uint32)`.
*/
export function encodeChunkRequest(
fileId: string,
chunkId: number
): Uint8Array {
if (!Number.isInteger(chunkId) || chunkId < 0 || chunkId > 0xffffffff) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`chunkId ${chunkId} out of uint32 range`
);
}
const fileIdBytes = fileIdHexToBytes(fileId);
const out = new Uint8Array(CHUNK_REQUEST_PAYLOAD_LEN);
out.set(fileIdBytes, 0);
new DataView(out.buffer).setUint32(FILE_ID_BYTES, chunkId, false);
return out;
}
export interface DecodedChunkRequest {
/** Hex-encoded 32-byte file id. */
readonly fileId: string;
readonly chunkId: number;
}
export function decodeChunkRequest(payload: Uint8Array): DecodedChunkRequest {
if (payload.length !== CHUNK_REQUEST_PAYLOAD_LEN) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`chunk-request payload must be ${CHUNK_REQUEST_PAYLOAD_LEN} bytes, got ${payload.length}`
);
}
const fileId = fileIdBytesToHex(payload.slice(0, FILE_ID_BYTES));
const chunkId = new DataView(
payload.buffer,
payload.byteOffset,
payload.byteLength
).getUint32(FILE_ID_BYTES, false);
return { fileId, chunkId };
}
/**
* Encode an advertisement payload:
* `fileId(32) ‖ totalChunks(4 BE uint32) ‖ gzip(bitset)`.
*/
export function encodeAdvertisement(
fileId: string,
totalChunks: number,
bitsetGzip: Uint8Array
): Uint8Array {
if (
!Number.isInteger(totalChunks) ||
totalChunks < 0 ||
totalChunks > 0xffffffff
) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`totalChunks ${totalChunks} out of uint32 range`
);
}
if (bitsetGzip.length === 0) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
"advertisement bitset must be non-empty"
);
}
const fileIdBytes = fileIdHexToBytes(fileId);
const out = new Uint8Array(ADVERTISEMENT_HEADER_LEN + bitsetGzip.length);
out.set(fileIdBytes, 0);
new DataView(out.buffer).setUint32(FILE_ID_BYTES, totalChunks, false);
out.set(bitsetGzip, ADVERTISEMENT_HEADER_LEN);
return out;
}
export interface DecodedAdvertisement {
/** Hex-encoded 32-byte file id. */
readonly fileId: string;
readonly totalChunks: number;
readonly bitsetGzip: Uint8Array;
}
export function decodeAdvertisement(payload: Uint8Array): DecodedAdvertisement {
if (payload.length <= ADVERTISEMENT_HEADER_LEN) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.FRAME_INVALID,
`advertisement payload must exceed ${ADVERTISEMENT_HEADER_LEN} bytes, got ${payload.length}`
);
}
const fileId = fileIdBytesToHex(payload.slice(0, FILE_ID_BYTES));
const totalChunks = new DataView(
payload.buffer,
payload.byteOffset,
payload.byteLength
).getUint32(FILE_ID_BYTES, false);
return {
fileId,
totalChunks,
bitsetGzip: payload.slice(ADVERTISEMENT_HEADER_LEN),
};
}

View File

@@ -0,0 +1,152 @@
# `transport/webrtc-transport/webrtc-transport`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the canonical `DriveTransport` interface (defined in
`@suaveplan/drive-mesh`) over a `@suaveplan/drive-webrtc`
`WebRTCClient` + `DataChannelManager` pair. Wraps a single named
data-channel label per peer with a length-prefixed binary framing
protocol carrying three frame types — chunk-request, chunk-response,
and advertisement — and threads `AbortSignal` semantics through every
public boundary.
The transport does not own the underlying `RTCPeerConnection`
lifecycle: the caller passes in an already-constructed
`WebRTCClient`, which means signalling, ICE candidates, and offer /
answer exchange all happen out-of-band before any chunk traffic
flows. Once a data-channel for the configured label is open, the
transport multiplexes requests and advertisements over it.
## Features
- `id: "drive-transport-webrtc"` — stable transport identifier
consumed by the mesh's transport-selection policy.
- `requestChunk(peerId, fileId, chunkId, signal?)` returns a
`Promise<Uint8Array>` resolving with the chunk bytes on success.
Per-request timeout (default 10 s) and concurrent request cap
(default 16) are configurable. `fileId` is the hex-encoded 32-byte
identifier.
- `advertise(peerId, advert)` takes a single `ChunkAdvertisement`
object (`fileId` / `bitset` / `totalChunks`), gzips the bitset, and
ships an advertisement frame; the receiver's `onAdvertisement`
handlers fire with the gunzipped bytes.
- `onAdvertisement(handler)` registers a two-arg
`(peerId, advert: ChunkAdvertisement) => void` callback. Returns
an unsubscribe function.
- `peers()` yields every peer id with an open data-channel of the
configured label (no `PeerInfo` shape — drive-mesh resolves
per-peer state from its own `PeerRegistry`).
- `dispose()` is idempotent — tears down event listeners and rejects
every pending request with `ABORTED`. Optional under the interface
but always present.
- OTel surface: `drive.transport.webrtc.requests_total{outcome}`,
`advertisements_total{direction}`,
`request_duration_ms`, `frame_size_bytes`.
## Usage: Basic Example
```ts
import {
DataChannelManager,
WebRTCClient,
} from "@suaveplan/drive-webrtc";
import { WebRTCDriveTransport } from "@suaveplan/drive-transport-webrtc";
const client = new WebRTCClient({ peerId: "self", config: { signaling: { url: "wss://x" } } });
const channels = new DataChannelManager({ client });
const transport = new WebRTCDriveTransport({ client, dataChannels: channels });
const fileId = "ab".repeat(32);
const bytes = await transport.requestChunk("bob", fileId, 0);
console.log("got", bytes.length, "bytes");
```
## Usage: Advanced Example
```ts
import { WebRTCDriveTransport } from "@suaveplan/drive-transport-webrtc";
const transport = new WebRTCDriveTransport({
client,
dataChannels: channels,
defaultLabel: "mesh-chunks",
maxConcurrentRequests: 32,
requestTimeoutMs: 30_000,
});
const unsubscribe = transport.onAdvertisement((peerId, advert) => {
presenceIndex.update(peerId, advert.fileId, advert.bitset);
});
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(new Error("user navigated away")), 2_000);
try {
const bytes = await transport.requestChunk("bob", fileId, 17, ctrl.signal);
await sink.write(bytes);
} catch (err) {
// err.code in REQUEST_TIMEOUT | ABORTED | DATA_CHANNEL_CLOSED
}
await transport.advertise("bob", {
fileId,
bitset: ourBitset,
totalChunks: 1024,
});
await transport.dispose();
unsubscribe();
```
## API Reference
- `class WebRTCDriveTransport extends TypedEventEmitter<WebRTCTransportEventMap> implements DriveTransport`
— constructor takes `WebRTCDriveTransportOptions`.
- `id: "drive-transport-webrtc"` — stable transport identifier.
- `requestChunk(peerId, fileId, chunkId, signal?): Promise<Uint8Array>`
— resolves with the chunk bytes. Throws `REQUEST_TIMEOUT`, `ABORTED`,
`DATA_CHANNEL_CLOSED`, or `PEER_NOT_CONNECTED`.
- `advertise(peerId, advert: ChunkAdvertisement): Promise<void>` —
gzips and sends. `advert.fileId` is hex, `advert.bitset` is the raw
presence bitmap, `advert.totalChunks` is the chunk count baked
into the wire frame.
- `onAdvertisement(handler: (peerId, advert) => void): () => void` —
returns unsubscribe.
- `peers(): Iterable<PeerId>` — yields a peer id per open channel
matching the configured label.
- `dispose(): Promise<void>` — idempotent teardown.
- `encodeChunkResponse(bytes)` — convenience encoder for callers
serving as chunk servers.
## Implementation Notes
The transport does not encode an explicit request id on the wire;
instead, it FIFO-matches incoming chunk-response frames against the
oldest pending request to the responding peer. This matches the
proposal's "one outstanding request per peer per label" contract and
saves bytes on every request frame. If a future variant needs
parallel requests against the same peer, the wire format can grow a
request-id field and the matching policy widens accordingly without
breaking the public TypeScript surface.
`fileId` is hex on every public boundary (matching the canonical
`DriveTransport` interface), but the wire frames still carry the 32
raw bytes for compactness. The `hex` module at `./hex.ts` performs the
boundary conversion and surfaces malformed input as
`DriveTransportWebRtcError(FRAME_INVALID)`.
The advertisement payload now includes a 4-byte big-endian
`totalChunks` field after `fileId` and before the gzipped bitset.
This brings the wire frame in line with `ChunkAdvertisement` so the
receiver can size its presence index without a separate negotiation
round-trip.
Data-channel sends use the buffer slice
`frame.buffer.slice(byteOffset, byteOffset + byteLength)` so that
sub-array views of larger backing buffers are not accidentally
transmitted in full. The transport delegates buffered-amount
backpressure entirely to `DataChannelManager.send`, which awaits
`bufferedamountlow` when the high-water mark is exceeded.

View File

@@ -0,0 +1,696 @@
/**
* Tests for {@link WebRTCDriveTransport}. Drives a real
* `WebRTCClient` + `DataChannelManager` from `@suaveplan/drive-webrtc`
* with FakeRTCPeerConnection / FakeRTCDataChannel doubles — same
* pattern as drive-webrtc's own test suite. No `vi.mock`,
* no `vi.stubGlobal`.
*/
import type { ChunkAdvertisement } from "@suaveplan/drive-mesh";
import { DataChannelManager, WebRTCClient } from "@suaveplan/drive-webrtc";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
type DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import { encodeFrame, FRAME_TYPE } from "../../framing/frame/index.js";
import { encodeAdvertisement } from "./payload-codec.js";
import {
encodeChunkResponse,
WebRTCDriveTransport,
} from "./webrtc-transport.js";
class FakeRTCDataChannel extends EventTarget {
public readyState: RTCDataChannelState = "connecting";
public bufferedAmount = 0;
public bufferedAmountLowThreshold = 0;
public closed = false;
public sentPayloads: (ArrayBuffer | string)[] = [];
public sendImpl: (payload: ArrayBuffer | string) => void = (p) => {
this.sentPayloads.push(p);
};
public constructor(
public readonly label: string,
_init?: RTCDataChannelInit
) {
super();
}
public send(payload: ArrayBuffer | string): void {
this.sendImpl(payload);
}
public close(): void {
this.closed = true;
this.readyState = "closed";
this.dispatchEvent(new Event("close"));
}
public simulateOpen(): void {
this.readyState = "open";
this.dispatchEvent(new Event("open"));
}
public simulateClose(): void {
this.readyState = "closed";
this.dispatchEvent(new Event("close"));
}
public simulateMessage(data: ArrayBuffer | string): void {
const e = new Event("message") as MessageEvent;
(e as unknown as { data: unknown }).data = data;
this.dispatchEvent(e);
}
}
class FakeRTCPeerConnection extends EventTarget {
public connectionState: RTCPeerConnectionState = "connected";
public iceConnectionState: RTCIceConnectionState = "new";
public localDescription: RTCSessionDescriptionInit | null = null;
public remoteDescription: RTCSessionDescriptionInit | null = null;
public createdChannels: FakeRTCDataChannel[] = [];
public closed = false;
public constructor(public readonly config?: RTCConfiguration) {
super();
}
public async createOffer(): Promise<RTCSessionDescriptionInit> {
return { type: "offer", sdp: "fake" };
}
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
return { type: "answer", sdp: "fake" };
}
public async setLocalDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.localDescription = d;
}
public async setRemoteDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.remoteDescription = d;
}
public async addIceCandidate(): Promise<void> {}
public restartIce(): void {}
public close(): void {
this.closed = true;
}
public createDataChannel(
label: string,
init?: RTCDataChannelInit
): RTCDataChannel {
const ch = new FakeRTCDataChannel(label, init);
this.createdChannels.push(ch);
return ch as unknown as RTCDataChannel;
}
}
interface Harness {
transport: WebRTCDriveTransport;
client: WebRTCClient;
channels: DataChannelManager;
openChannel(peerId: string): FakeRTCDataChannel;
}
async function makeHarness(
overrides: Partial<{
requestTimeoutMs: number;
maxConcurrentRequests: number;
defaultLabel: string;
}> = {}
): Promise<Harness> {
const client = new WebRTCClient({
peerId: "self",
config: { signaling: { url: "wss://x" } },
rtcPeerConnectionCtor:
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
});
const channels = new DataChannelManager({ client });
const transport = new WebRTCDriveTransport({
client,
dataChannels: channels,
...overrides,
});
const openChannel = (peerId: string): FakeRTCDataChannel => {
// Negotiate a peer connection then open the data channel.
return openPeer(client, channels, peerId);
};
return { transport, client, channels, openChannel };
}
function openPeer(
client: WebRTCClient,
channels: DataChannelManager,
peerId: string,
label = "drive-chunks"
): FakeRTCDataChannel {
void client.createOffer(peerId);
const ch = channels.open(peerId, label) as unknown as FakeRTCDataChannel;
ch.simulateOpen();
return ch;
}
const FID_HEX = "ab".repeat(32);
function makeAdvert(
bitset: Uint8Array,
totalChunks: number = bitset.length * 8
): ChunkAdvertisement {
return { fileId: FID_HEX, bitset, totalChunks };
}
describe("WebRTCDriveTransport.id", () => {
it("exposes the canonical transport id", { timeout: 5000 }, async () => {
const h = await makeHarness();
expect(h.transport.id).toBe("drive-transport-webrtc");
await h.transport.dispose();
});
});
describe("WebRTCDriveTransport.requestChunk", () => {
it("sends a chunk-request frame and resolves on response", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
const pending = h.transport.requestChunk("bob", FID_HEX, 7);
await Promise.resolve();
// First sent frame should be a chunk-request.
expect(ch.sentPayloads).toHaveLength(1);
const sent = new Uint8Array(ch.sentPayloads[0] as ArrayBuffer);
// type byte is at offset 4.
expect(sent[4]).toBe(FRAME_TYPE.CHUNK_REQUEST);
// Simulate the peer responding.
const respBytes = new Uint8Array([10, 20, 30, 40]);
const respFrame = encodeChunkResponse(respBytes);
ch.simulateMessage(respFrame.buffer.slice(0));
const result = await pending;
expect(result).toEqual(respBytes);
await h.transport.dispose();
});
it("times out when no response arrives", { timeout: 5000 }, async () => {
const h = await makeHarness({ requestTimeoutMs: 20 });
h.openChannel("bob");
try {
await h.transport.requestChunk("bob", FID_HEX, 1);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.REQUEST_TIMEOUT
);
}
await h.transport.dispose();
});
it("aborts mid-flight via AbortSignal", { timeout: 5000 }, async () => {
const h = await makeHarness();
h.openChannel("bob");
const ctrl = new AbortController();
const pending = h.transport.requestChunk(
"bob",
FID_HEX,
1,
ctrl.signal
);
ctrl.abort(new Error("user-cancel"));
try {
await pending;
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED
);
}
await h.transport.dispose();
});
it("rejects when signal is already aborted", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
h.openChannel("bob");
const ctrl = new AbortController();
ctrl.abort(new Error("pre-abort"));
try {
await h.transport.requestChunk("bob", FID_HEX, 1, ctrl.signal);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED
);
}
await h.transport.dispose();
});
it("rejects when concurrent request cap is reached", {
timeout: 5000,
}, async () => {
const h = await makeHarness({ maxConcurrentRequests: 1 });
h.openChannel("bob");
const first = h.transport.requestChunk("bob", FID_HEX, 1);
try {
await h.transport.requestChunk("bob", FID_HEX, 2);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED
);
}
await h.transport.dispose();
// first will reject as part of dispose
await first.catch(() => {});
});
it("rejects when the data channel is closed", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
h.openChannel("bob");
// Force-close the channel from inside the manager.
const ch = h.channels.get("bob", "drive-chunks") as unknown as
| FakeRTCDataChannel
| undefined;
if (ch === undefined) throw new Error("missing channel");
ch.readyState = "closed";
try {
await h.transport.requestChunk("bob", FID_HEX, 1);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
await h.transport.dispose();
});
it("rejects when no peer connection exists yet", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
try {
await h.transport.requestChunk("ghost", FID_HEX, 1);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.PEER_NOT_CONNECTED
);
}
await h.transport.dispose();
});
it("fails pending request when its peer's channel closes", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
const pending = h.transport.requestChunk("bob", FID_HEX, 1);
ch.simulateClose();
try {
await pending;
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
await h.transport.dispose();
});
it("only fails pending requests scoped to the closing peer", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const bobCh = h.openChannel("bob");
const carolCh = h.openChannel("carol");
const carolPending = h.transport.requestChunk("carol", FID_HEX, 1);
// Close bob's channel — carol's pending entry must be skipped
// (continue branch in #failPendingForPeer).
bobCh.simulateClose();
// Carol's pending request still resolves.
carolCh.simulateMessage(
encodeChunkResponse(new Uint8Array([1])).buffer.slice(0)
);
const r = await carolPending;
expect(r).toEqual(new Uint8Array([1]));
await h.transport.dispose();
});
});
describe("WebRTCDriveTransport.advertise", () => {
it("sends an advertisement frame", { timeout: 5000 }, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
const bitset = new Uint8Array([1, 0, 1, 1, 0]);
await h.transport.advertise("bob", makeAdvert(bitset, 5));
expect(ch.sentPayloads).toHaveLength(1);
const wire = new Uint8Array(ch.sentPayloads[0] as ArrayBuffer);
expect(wire[4]).toBe(FRAME_TYPE.ADVERTISEMENT);
await h.transport.dispose();
});
it("delivers received advertisements to subscribers", {
timeout: 5000,
}, async () => {
const h1 = await makeHarness();
const h2 = await makeHarness();
h1.openChannel("bob");
const recvCh = h2.openChannel("alice");
const seen: {
peerId: string;
advert: ChunkAdvertisement;
}[] = [];
const unsub = h2.transport.onAdvertisement((peerId, advert) =>
seen.push({ peerId, advert })
);
const bitset = new Uint8Array([3, 1, 4, 1, 5, 9, 2, 6]);
await h1.transport.advertise("bob", makeAdvert(bitset, 64));
// Grab the wire bytes from h1's send-channel.
const senderCh = h1.channels.get(
"bob",
"drive-chunks"
) as unknown as FakeRTCDataChannel;
const wire = senderCh.sentPayloads[0] as ArrayBuffer;
recvCh.simulateMessage(wire.slice(0));
// Decompression is async — wait until handler fires (timeout-bound).
for (let i = 0; i < 100 && seen.length === 0; i++) {
await new Promise((r) => setTimeout(r, 1));
}
expect(seen).toHaveLength(1);
expect(seen[0]?.peerId).toBe("alice");
expect(seen[0]?.advert.fileId).toBe(FID_HEX);
expect(seen[0]?.advert.totalChunks).toBe(64);
expect(seen[0]?.advert.bitset).toEqual(bitset);
unsub();
await h1.transport.dispose();
await h2.transport.dispose();
});
it("silently drops corrupt advertisements", { timeout: 5000 }, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
const seen: unknown[] = [];
h.transport.onAdvertisement((peerId, advert) =>
seen.push({ peerId, advert })
);
// Build an advertisement frame whose body is too short.
const badPayload = new Uint8Array(10);
const badFrame = encodeFrame(FRAME_TYPE.ADVERTISEMENT, badPayload);
ch.simulateMessage(badFrame.buffer.slice(0));
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(seen).toHaveLength(0);
await h.transport.dispose();
});
it("silently drops advertisements with bad gzip", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
const seen: unknown[] = [];
h.transport.onAdvertisement((peerId, advert) =>
seen.push({ peerId, advert })
);
// fileId + totalChunks + non-gzip bytes
const payload = encodeAdvertisement(
FID_HEX,
1,
new Uint8Array([1, 2, 3])
);
const frame = encodeFrame(FRAME_TYPE.ADVERTISEMENT, payload);
ch.simulateMessage(frame.buffer.slice(0));
// Give the async decompression failure path time to settle.
await new Promise((r) => setTimeout(r, 50));
expect(seen).toHaveLength(0);
await h.transport.dispose();
});
it("returns an unsubscribe that removes the handler", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const seen: unknown[] = [];
const unsub = h.transport.onAdvertisement((peerId, advert) =>
seen.push({ peerId, advert })
);
unsub();
// The internal set should now be empty — emit an event manually
// via the public API (advertise to ourselves would require a
// loopback channel; instead, just verify the unsub does not
// throw and idempotent).
unsub();
expect(seen).toHaveLength(0);
await h.transport.dispose();
});
});
describe("WebRTCDriveTransport.peers + lifecycle", () => {
it("yields a peer id per open channel", { timeout: 5000 }, async () => {
const h = await makeHarness();
h.openChannel("bob");
h.openChannel("carol");
const peers = Array.from(h.transport.peers()).sort();
expect(peers).toEqual(["bob", "carol"]);
await h.transport.dispose();
});
it("ignores close events on non-default labels", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
void h.client.createOffer("frank");
const other = h.channels.open(
"frank",
"control"
) as unknown as FakeRTCDataChannel;
other.simulateOpen();
// Open default channel too so peers() has a known entry.
h.openChannel("frank");
other.simulateClose();
const peers = Array.from(h.transport.peers());
expect(peers).toEqual(["frank"]);
await h.transport.dispose();
});
it("ignores non-default labels for peers()", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
void h.client.createOffer("dave");
const other = h.channels.open(
"dave",
"control"
) as unknown as FakeRTCDataChannel;
other.simulateOpen();
const peers = Array.from(h.transport.peers());
expect(peers).toEqual([]);
await h.transport.dispose();
});
it("dispose() is idempotent", { timeout: 5000 }, async () => {
const h = await makeHarness();
await h.transport.dispose();
await h.transport.dispose();
});
it("dispose() rejects in-flight requests", { timeout: 5000 }, async () => {
const h = await makeHarness();
h.openChannel("bob");
const pending = h.transport.requestChunk("bob", FID_HEX, 1);
await h.transport.dispose();
try {
await pending;
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED
);
}
});
it("rejects new requests after dispose", { timeout: 5000 }, async () => {
const h = await makeHarness();
h.openChannel("bob");
await h.transport.dispose();
try {
await h.transport.requestChunk("bob", FID_HEX, 1);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED
);
}
});
it("dispose() cleans up pending requests carrying AbortSignals", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
h.openChannel("bob");
const ctrl = new AbortController();
const pending = h.transport.requestChunk(
"bob",
FID_HEX,
1,
ctrl.signal
);
await h.transport.dispose();
try {
await pending;
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED
);
}
// Signal should be detached — aborting after dispose must not
// trigger any further effects.
ctrl.abort();
});
it("maps underlying send throws to DATA_CHANNEL_CLOSED", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
ch.sendImpl = () => {
throw new Error("synthetic send failure");
};
try {
await h.transport.advertise(
"bob",
makeAdvert(new Uint8Array([1, 2]), 16)
);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
await h.transport.dispose();
});
it("propagates send failures through requestChunk", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
ch.sendImpl = () => {
throw new Error("synthetic send failure");
};
try {
await h.transport.requestChunk("bob", FID_HEX, 1);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED
);
}
await h.transport.dispose();
});
it("rejects new advertisements after dispose", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
h.openChannel("bob");
await h.transport.dispose();
try {
await h.transport.advertise(
"bob",
makeAdvert(new Uint8Array([1]), 8)
);
throw new Error("expected throw");
} catch (err) {
expect((err as DriveTransportWebRtcError).code).toBe(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED
);
}
});
});
describe("WebRTCDriveTransport frame handling", () => {
it("ignores string messages", { timeout: 5000 }, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
const pending = h.transport.requestChunk("bob", FID_HEX, 1);
// Send a string — must be ignored, request keeps waiting.
ch.simulateMessage("nope");
// Then deliver a real response.
ch.simulateMessage(
encodeChunkResponse(new Uint8Array([1])).buffer.slice(0)
);
const result = await pending;
expect(result).toEqual(new Uint8Array([1]));
await h.transport.dispose();
});
it("ignores malformed frames", { timeout: 5000 }, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
const pending = h.transport.requestChunk("bob", FID_HEX, 1);
ch.simulateMessage(new Uint8Array([0, 0, 0, 99, 0xfe]).buffer.slice(0));
ch.simulateMessage(
encodeChunkResponse(new Uint8Array([2])).buffer.slice(0)
);
const r = await pending;
expect(r).toEqual(new Uint8Array([2]));
await h.transport.dispose();
});
it("ignores chunk-request frames received locally", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const ch = h.openChannel("bob");
// A peer sending us a chunk-request must not affect our pending
// map.
const reqFrame = encodeFrame(
FRAME_TYPE.CHUNK_REQUEST,
new Uint8Array(36)
);
ch.simulateMessage(reqFrame.buffer.slice(0));
// Now actually request and confirm normal flow.
const pending = h.transport.requestChunk("bob", FID_HEX, 1);
ch.simulateMessage(
encodeChunkResponse(new Uint8Array([7])).buffer.slice(0)
);
const r = await pending;
expect(r).toEqual(new Uint8Array([7]));
await h.transport.dispose();
});
it("ignores responses arriving for an unknown peer", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
const bobCh = h.openChannel("bob");
const carolCh = h.openChannel("carol");
// Issue a pending request to carol so the pending map has an
// entry — then deliver a response on bob's channel. The
// resolver must SKIP carol's entry (continue branch) and find
// no match.
const carolPending = h.transport.requestChunk("carol", FID_HEX, 99);
bobCh.simulateMessage(
encodeChunkResponse(new Uint8Array([0xee])).buffer.slice(0)
);
// carol's request must still be pending — resolve it now.
carolCh.simulateMessage(
encodeChunkResponse(new Uint8Array([7])).buffer.slice(0)
);
const carolResult = await carolPending;
expect(carolResult).toEqual(new Uint8Array([7]));
await h.transport.dispose();
});
it("does not crash if message arrives on non-default label", {
timeout: 5000,
}, async () => {
const h = await makeHarness();
void h.client.createOffer("ed");
const other = h.channels.open(
"ed",
"control"
) as unknown as FakeRTCDataChannel;
other.simulateOpen();
// Should be ignored.
other.simulateMessage(
encodeChunkResponse(new Uint8Array([1])).buffer.slice(0)
);
await h.transport.dispose();
});
});

View File

@@ -0,0 +1,491 @@
/**
* WebRTCDriveTransport — canonical `DriveTransport` implementation
* over the `@suaveplan/drive-webrtc` `WebRTCClient` +
* `DataChannelManager`. Conforms to the interface defined in
* `@suaveplan/drive-mesh` (no local mirror).
*
* Wraps a single data-channel label per peer with a length-prefixed
* binary framing protocol carrying chunk-request, chunk-response, and
* advertisement frames. See sibling `webrtc-transport.md` for the
* full design notes.
*/
import { metrics } from "@opentelemetry/api";
import type {
AdvertisementHandler,
ChunkAdvertisement,
DriveTransport,
Unsubscribe,
} from "@suaveplan/drive-mesh";
import {
type DataChannelManager,
TypedEventEmitter,
type WebRTCClient,
} from "@suaveplan/drive-webrtc";
import type { PeerId } from "@suaveplan/types";
import {
DRIVE_TRANSPORT_WEBRTC_CODES,
DriveTransportWebRtcError,
} from "../../error-codes/error-codes.js";
import {
decodeFrame,
encodeFrame,
FRAME_TYPE,
toFrameBytes,
} from "../../framing/frame/index.js";
import { type BitsetCodec, createDefaultBitsetCodec } from "./bitset-codec.js";
import {
decodeAdvertisement,
decodeChunkRequest,
encodeAdvertisement,
encodeChunkRequest,
} from "./payload-codec.js";
/**
* Default datachannel label used for the drive-mesh wire protocol.
*/
export const DEFAULT_LABEL = "drive-chunks";
/**
* Default per-request timeout (ms).
*/
export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
/**
* Default cap on in-flight chunk requests (per transport instance).
*/
export const DEFAULT_MAX_CONCURRENT_REQUESTS = 16;
/**
* Stable transport identifier surfaced by `DriveTransport.id`.
*/
export const TRANSPORT_ID = "drive-transport-webrtc";
/**
* Options for {@link WebRTCDriveTransport}.
*/
export interface WebRTCDriveTransportOptions {
readonly client: WebRTCClient;
readonly dataChannels: DataChannelManager;
readonly defaultLabel?: string;
readonly maxConcurrentRequests?: number;
readonly requestTimeoutMs?: number;
readonly clock?: { now: () => number };
readonly bitsetCodec?: BitsetCodec;
}
/**
* Event surface emitted by the transport. Currently informational only
* — the public `DriveTransport` contract drives all hot-path
* interactions.
*/
export type WebRTCTransportEventMap = {
"transport:request": [{ peerId: PeerId; chunkId: number }];
"transport:response": [{ peerId: PeerId; chunkId: number; bytes: number }];
"transport:advertisement": [{ peerId: PeerId; advert: ChunkAdvertisement }];
};
interface PendingRequest {
readonly fileId: string;
readonly chunkId: number;
readonly resolve: (bytes: Uint8Array) => void;
readonly reject: (err: unknown) => void;
readonly cleanup: () => void;
}
const meter = metrics.getMeter("drive-transport-webrtc", "0.1.0");
const requestsCounter = meter.createCounter(
"drive.transport.webrtc.requests_total",
{ description: "Chunk requests by outcome" }
);
const advertisementsCounter = meter.createCounter(
"drive.transport.webrtc.advertisements_total",
{ description: "Advertisements by direction" }
);
const requestDuration = meter.createHistogram(
"drive.transport.webrtc.request_duration_ms",
{ description: "Chunk request wall-clock duration", unit: "ms" }
);
const frameSize = meter.createHistogram(
"drive.transport.webrtc.frame_size_bytes",
{ description: "Outgoing frame body size", unit: "By" }
);
/**
* `DriveTransport` implementation over WebRTC datachannels.
*
* Implements the canonical `DriveTransport` interface from
* `@suaveplan/drive-mesh`.
*/
export class WebRTCDriveTransport
extends TypedEventEmitter<WebRTCTransportEventMap>
implements DriveTransport
{
public readonly id = TRANSPORT_ID;
readonly #client: WebRTCClient;
readonly #channels: DataChannelManager;
readonly #defaultLabel: string;
readonly #maxConcurrentRequests: number;
readonly #requestTimeoutMs: number;
readonly #clock: { now: () => number };
readonly #bitsetCodec: BitsetCodec;
readonly #pending = new Map<string, PendingRequest>();
readonly #advertisementHandlers = new Set<AdvertisementHandler>();
readonly #knownPeers = new Set<PeerId>();
readonly #unsubscribeMessage: () => void;
readonly #unsubscribeOpen: () => void;
readonly #unsubscribeClose: () => void;
#disposed = false;
public constructor(options: WebRTCDriveTransportOptions) {
super();
this.#client = options.client;
this.#channels = options.dataChannels;
this.#defaultLabel = options.defaultLabel ?? DEFAULT_LABEL;
this.#maxConcurrentRequests =
options.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS;
this.#requestTimeoutMs =
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.#clock = options.clock ?? { now: () => Date.now() };
this.#bitsetCodec = options.bitsetCodec ?? createDefaultBitsetCodec();
const onMessage = (evt: {
peerId: string;
label: string;
data: ArrayBuffer | string;
}): void => {
if (evt.label !== this.#defaultLabel) return;
this.#handleIncomingMessage(evt.peerId, evt.data);
};
const onOpen = (evt: { peerId: string; label: string }): void => {
if (evt.label !== this.#defaultLabel) return;
this.#knownPeers.add(evt.peerId);
};
const onClose = (evt: { peerId: string; label: string }): void => {
if (evt.label !== this.#defaultLabel) return;
this.#knownPeers.delete(evt.peerId);
this.#failPendingForPeer(evt.peerId);
};
this.#unsubscribeMessage = this.#channels.on(
"channel:message",
onMessage
);
this.#unsubscribeOpen = this.#channels.on("channel:open", onOpen);
this.#unsubscribeClose = this.#channels.on("channel:close", onClose);
}
/**
* Request a chunk from `peerId`. Resolves with the full locked
* chunk bytes on success.
*/
public requestChunk(
peerId: PeerId,
fileId: string,
chunkId: number,
signal?: AbortSignal
): Promise<Uint8Array> {
return this.#enqueueRequest(peerId, fileId, chunkId, signal);
}
/**
* Push an availability advertisement to `peerId`. Compresses the
* bitset before serialising.
*/
public async advertise(
peerId: PeerId,
advert: ChunkAdvertisement
): Promise<void> {
this.#ensureNotDisposed();
const gzipped = await this.#bitsetCodec.compress(advert.bitset);
const payload = encodeAdvertisement(
advert.fileId,
advert.totalChunks,
gzipped
);
const frame = encodeFrame(FRAME_TYPE.ADVERTISEMENT, payload);
frameSize.record(frame.length, { type: "advertisement" });
await this.#sendFrame(peerId, frame);
advertisementsCounter.add(1, { direction: "outbound" });
this.emit("transport:advertisement", { peerId, advert });
}
/**
* Subscribe to incoming advertisements. Returns an unsubscribe
* function.
*/
public onAdvertisement(handler: AdvertisementHandler): Unsubscribe {
this.#advertisementHandlers.add(handler);
return () => {
this.#advertisementHandlers.delete(handler);
};
}
/**
* Iterate the peers this transport currently has an open
* data-channel to.
*/
public *peers(): Iterable<PeerId> {
for (const peerId of this.#knownPeers) yield peerId;
}
/**
* Tear down listeners and reject every in-flight request. Idempotent.
*/
public async dispose(): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
this.#unsubscribeMessage();
this.#unsubscribeOpen();
this.#unsubscribeClose();
for (const [, pending] of this.#pending) {
pending.cleanup();
pending.reject(
new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED,
"transport disposed"
)
);
}
this.#pending.clear();
this.#advertisementHandlers.clear();
this.#knownPeers.clear();
}
#enqueueRequest(
peerId: PeerId,
fileId: string,
chunkId: number,
signal: AbortSignal | undefined
): Promise<Uint8Array> {
this.#ensureNotDisposed();
if (this.#pending.size >= this.#maxConcurrentRequests) {
requestsCounter.add(1, { outcome: "rejected_overflow" });
return Promise.reject(
new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED,
`concurrent request cap ${this.#maxConcurrentRequests} reached`
)
);
}
if (signal?.aborted) {
requestsCounter.add(1, { outcome: "aborted" });
return Promise.reject(
new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED,
"request aborted before dispatch",
{ cause: signal.reason as Error }
)
);
}
const key = pendingKey(peerId, chunkId);
const start = this.#clock.now();
return new Promise<Uint8Array>((resolve, reject) => {
let abortListener: (() => void) | undefined;
const cleanup = (): void => {
clearTimeout(timer);
if (abortListener !== undefined && signal !== undefined) {
signal.removeEventListener("abort", abortListener);
}
};
const timer = setTimeout(() => {
this.#pending.delete(key);
cleanup();
requestsCounter.add(1, { outcome: "timeout" });
reject(
new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.REQUEST_TIMEOUT,
`chunk request to ${peerId} for chunk ${chunkId} timed out after ${this.#requestTimeoutMs}ms`
)
);
}, this.#requestTimeoutMs);
if (signal !== undefined) {
abortListener = () => {
this.#pending.delete(key);
cleanup();
requestsCounter.add(1, { outcome: "aborted" });
reject(
new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED,
"chunk request aborted",
{ cause: signal.reason as Error }
)
);
};
signal.addEventListener("abort", abortListener, { once: true });
}
this.#pending.set(key, {
fileId,
chunkId,
resolve: (bytes) => {
cleanup();
requestsCounter.add(1, { outcome: "ok" });
requestDuration.record(this.#clock.now() - start, {
outcome: "ok",
});
resolve(bytes);
},
reject: (err) => {
cleanup();
reject(err);
},
cleanup,
});
const payload = encodeChunkRequest(fileId, chunkId);
const frame = encodeFrame(FRAME_TYPE.CHUNK_REQUEST, payload);
frameSize.record(frame.length, { type: "chunk-request" });
this.emit("transport:request", { peerId, chunkId });
this.#sendFrame(peerId, frame).catch((err) => {
this.#pending.delete(key);
cleanup();
requestsCounter.add(1, { outcome: "send_failed" });
reject(err);
});
});
}
async #sendFrame(peerId: PeerId, frame: Uint8Array): Promise<void> {
let channel = this.#channels.get(peerId, this.#defaultLabel);
if (channel === undefined) {
try {
channel = this.#channels.open(peerId, this.#defaultLabel);
} catch (cause) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.PEER_NOT_CONNECTED,
`cannot open data channel for ${peerId}`,
{ cause: cause as Error }
);
}
}
if (channel.readyState !== "open") {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
`data channel for ${peerId} is not open (state=${channel.readyState})`
);
}
try {
const buf = new ArrayBuffer(frame.byteLength);
new Uint8Array(buf).set(frame);
await this.#channels.send(peerId, this.#defaultLabel, buf);
} catch (cause) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
`failed to send frame to ${peerId}`,
{ cause: cause as Error }
);
}
}
#handleIncomingMessage(peerId: PeerId, data: ArrayBuffer | string): void {
const bytes = toFrameBytes(data);
if (bytes === undefined) return;
let frame: ReturnType<typeof decodeFrame>;
try {
frame = decodeFrame(bytes);
} catch {
return;
}
switch (frame.type) {
case FRAME_TYPE.CHUNK_REQUEST:
// Server-side handling lives outside the transport
// (the mesh consumer responds via `requestChunk`'s
// reply path); the transport itself only emits
// chunk-response frames.
break;
case FRAME_TYPE.CHUNK_RESPONSE:
this.#resolveChunkResponse(peerId, frame.payload);
break;
case FRAME_TYPE.ADVERTISEMENT:
void this.#deliverAdvertisement(peerId, frame.payload);
break;
}
}
#resolveChunkResponse(peerId: PeerId, payload: Uint8Array): void {
// Match the oldest pending request to this peer; the wire
// protocol does not carry an explicit request id, so we
// FIFO-match per peer. This mirrors the contract: a peer
// responds to one request at a time over the label.
for (const [key, pending] of this.#pending) {
if (!key.startsWith(`${peerId}::`)) continue;
this.#pending.delete(key);
this.emit("transport:response", {
peerId,
chunkId: pending.chunkId,
bytes: payload.length,
});
pending.resolve(payload);
return;
}
}
async #deliverAdvertisement(
peerId: PeerId,
payload: Uint8Array
): Promise<void> {
try {
const decoded = decodeAdvertisement(payload);
const bitset = await this.#bitsetCodec.decompress(
decoded.bitsetGzip
);
advertisementsCounter.add(1, { direction: "inbound" });
const advert: ChunkAdvertisement = {
fileId: decoded.fileId,
bitset,
totalChunks: decoded.totalChunks,
};
for (const handler of this.#advertisementHandlers)
handler(peerId, advert);
this.emit("transport:advertisement", { peerId, advert });
} catch {
// Malformed/corrupt advertisements are dropped silently —
// mesh layer treats absence of an advertisement as
// "peer has nothing".
}
}
#failPendingForPeer(peerId: PeerId): void {
for (const [key, pending] of this.#pending) {
if (!key.startsWith(`${peerId}::`)) continue;
this.#pending.delete(key);
pending.reject(
new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.DATA_CHANNEL_CLOSED,
`data channel to ${peerId} closed while request was in-flight`
)
);
}
}
#ensureNotDisposed(): void {
if (this.#disposed) {
throw new DriveTransportWebRtcError(
DRIVE_TRANSPORT_WEBRTC_CODES.ABORTED,
"transport has been disposed"
);
}
}
}
function pendingKey(peerId: PeerId, chunkId: number): string {
return `${peerId}::${chunkId}`;
}
/**
* Convenience encoder for senders building chunk-response frames in
* response to incoming chunk-request frames (e.g. from a peer
* fulfilling another's request via the same transport).
*/
export function encodeChunkResponse(bytes: Uint8Array): Uint8Array {
return encodeFrame(FRAME_TYPE.CHUNK_RESPONSE, bytes);
}
/**
* Convenience decoder mirroring {@link encodeChunkRequest}'s wire
* shape — useful for upstream callers acting as chunk servers.
*/
export { decodeChunkRequest };

View File

@@ -0,0 +1,13 @@
/**
* Type re-exports — the canonical `DriveTransport` / `ChunkAdvertisement`
* / `AdvertisementHandler` / `Unsubscribe` shapes live in
* `@suaveplan/drive-mesh`. This package implements those shapes; we
* forward them so consumers can import either from the mesh package or
* from this transport package without a structural mismatch.
*/
export type {
AdvertisementHandler,
ChunkAdvertisement,
DriveTransport,
Unsubscribe,
} from "@suaveplan/drive-mesh";

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/drive-transport-webrtc",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/drive-webrtc",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();