Initial Commit
This commit is contained in:
180
packages/networking/drive/drive-transport-webrtc/README.md
Normal file
180
packages/networking/drive/drive-transport-webrtc/README.md
Normal 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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { DriveTransportWebRtcErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
DRIVE_TRANSPORT_WEBRTC_CODES,
|
||||
DriveTransportWebRtcError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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`.
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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`).
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type AdvertisementPayload,
|
||||
AdvertisementPayloadSchema,
|
||||
FILE_ID_BYTES,
|
||||
FILE_ID_HEX_LENGTH,
|
||||
type RequestChunkPayload,
|
||||
RequestChunkPayloadSchema,
|
||||
} from "./frame.schemas.js";
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig();
|
||||
Reference in New Issue
Block a user