@kyneta/webrtc-transport 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +228 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/__tests__/mock-data-channel.ts +94 -0
- package/src/__tests__/simple-peer-bridge.test.ts +197 -0
- package/src/__tests__/webrtc-transport.test.ts +517 -0
- package/src/data-channel-like.ts +104 -0
- package/src/index.ts +16 -0
- package/src/webrtc-transport.ts +434 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Duane Johnson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# @kyneta/webrtc-transport
|
|
2
|
+
|
|
3
|
+
BYODC (Bring Your Own Data Channel) WebRTC transport for `@kyneta/exchange`. Your application manages WebRTC connections — signaling, ICE, media streams — and this transport attaches to data channels for kyneta document synchronization.
|
|
4
|
+
|
|
5
|
+
The key design decision is `DataChannelLike`: a 5-member minimal interface that native `RTCDataChannel` satisfies structurally and that libraries like simple-peer can bridge in ~20 lines.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
- **BYODC design** — no signaling, no ICE, no connection management. The application establishes WebRTC connections however it likes; this transport hooks into the resulting data channels for sync.
|
|
10
|
+
- **Binary CBOR encoding** with transport-level fragmentation — the same `@kyneta/wire` pipeline used by the WebSocket transport.
|
|
11
|
+
- **`DataChannelLike` interface** — 5 members out of the ~30-member `RTCDataChannel` API. Native data channels conform structurally (zero wrapper code). Library bridges are trivial.
|
|
12
|
+
- **Single export** — no client/server split. Both peers use the same `WebrtcTransport` class.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```/dev/null/install.sh#L1
|
|
17
|
+
pnpm add @kyneta/webrtc-transport
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### With native RTCDataChannel
|
|
23
|
+
|
|
24
|
+
Native `RTCDataChannel` satisfies `DataChannelLike` structurally — pass it directly:
|
|
25
|
+
|
|
26
|
+
```/dev/null/native-example.ts#L1-14
|
|
27
|
+
import { Exchange } from "@kyneta/exchange"
|
|
28
|
+
import { createWebrtcTransport, WebrtcTransport } from "@kyneta/webrtc-transport"
|
|
29
|
+
|
|
30
|
+
const exchange = new Exchange({
|
|
31
|
+
identity: { peerId: "alice", name: "Alice" },
|
|
32
|
+
transports: [createWebrtcTransport()],
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// When a WebRTC connection is established:
|
|
36
|
+
const transport = exchange.getTransport("webrtc-datachannel") as WebrtcTransport
|
|
37
|
+
const cleanup = transport.attachDataChannel(remotePeerId, dataChannel)
|
|
38
|
+
|
|
39
|
+
// When done:
|
|
40
|
+
cleanup()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### With simple-peer (bridge function)
|
|
44
|
+
|
|
45
|
+
simple-peer uses an EventEmitter API instead of `addEventListener`. A ~20-line bridge maps it to `DataChannelLike`:
|
|
46
|
+
|
|
47
|
+
```/dev/null/simple-peer-bridge.ts#L1-39
|
|
48
|
+
import type { DataChannelLike } from "@kyneta/webrtc-transport"
|
|
49
|
+
|
|
50
|
+
function fromSimplePeer(peer: SimplePeer.Instance): DataChannelLike {
|
|
51
|
+
const eventMap: Record<string, string> = {
|
|
52
|
+
open: "connect", close: "close", error: "error", message: "data",
|
|
53
|
+
}
|
|
54
|
+
const wrapperMap = new Map<Function, Function>()
|
|
55
|
+
return {
|
|
56
|
+
get readyState() { return peer.connected ? "open" : "connecting" },
|
|
57
|
+
binaryType: "arraybuffer",
|
|
58
|
+
send(data) { peer.send(data) },
|
|
59
|
+
addEventListener(type, listener) {
|
|
60
|
+
const peerEvent = eventMap[type]
|
|
61
|
+
if (!peerEvent) return
|
|
62
|
+
const wrapped = type === "message"
|
|
63
|
+
? (data: any) => listener({ data })
|
|
64
|
+
: () => listener({})
|
|
65
|
+
wrapperMap.set(listener, wrapped)
|
|
66
|
+
peer.on(peerEvent, wrapped as any)
|
|
67
|
+
},
|
|
68
|
+
removeEventListener(type, listener) {
|
|
69
|
+
const peerEvent = eventMap[type]
|
|
70
|
+
if (!peerEvent) return
|
|
71
|
+
const wrapped = wrapperMap.get(listener)
|
|
72
|
+
if (wrapped) { peer.off(peerEvent, wrapped as any); wrapperMap.delete(listener) }
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Usage:
|
|
78
|
+
const channel = fromSimplePeer(peer)
|
|
79
|
+
transport.attachDataChannel(remotePeerId, channel)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## API Reference
|
|
83
|
+
|
|
84
|
+
### `createWebrtcTransport(options?)`
|
|
85
|
+
|
|
86
|
+
Factory function returning a `TransportFactory`. Pass directly to `Exchange({ transports: [...] })`.
|
|
87
|
+
|
|
88
|
+
| Option | Default | Description |
|
|
89
|
+
|--------|---------|-------------|
|
|
90
|
+
| `fragmentThreshold` | `204800` (200KB) | Payload size threshold in bytes for SCTP fragmentation. |
|
|
91
|
+
|
|
92
|
+
```/dev/null/factory-example.ts#L1-3
|
|
93
|
+
const exchange = new Exchange({
|
|
94
|
+
transports: [createWebrtcTransport({ fragmentThreshold: 100 * 1024 })],
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
To access the transport instance after creation:
|
|
99
|
+
|
|
100
|
+
```/dev/null/get-transport.ts#L1
|
|
101
|
+
const transport = exchange.getTransport("webrtc-datachannel") as WebrtcTransport
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `WebrtcTransport`
|
|
105
|
+
|
|
106
|
+
The transport class. Extends `Transport` from `@kyneta/exchange`.
|
|
107
|
+
|
|
108
|
+
| Method | Signature | Description |
|
|
109
|
+
|--------|-----------|-------------|
|
|
110
|
+
| `attachDataChannel` | `(remotePeerId: string, channel: DataChannelLike) => () => void` | Attach a data channel. Returns a cleanup function. If a channel is already attached for this peer, the old one is detached first. |
|
|
111
|
+
| `detachDataChannel` | `(remotePeerId: string) => void` | Detach a data channel. Removes event listeners but does **not** close the data channel. |
|
|
112
|
+
| `hasDataChannel` | `(remotePeerId: string) => boolean` | Check if a data channel is attached for a peer. |
|
|
113
|
+
| `getAttachedPeerIds` | `() => string[]` | List all peer IDs with attached data channels. |
|
|
114
|
+
|
|
115
|
+
### `DataChannelLike`
|
|
116
|
+
|
|
117
|
+
The minimal interface — 5 members:
|
|
118
|
+
|
|
119
|
+
```/dev/null/data-channel-like.ts#L1-7
|
|
120
|
+
interface DataChannelLike {
|
|
121
|
+
readonly readyState: string // transport checks === "open"
|
|
122
|
+
binaryType: string // transport writes "arraybuffer" on attach
|
|
123
|
+
send(data: Uint8Array): void
|
|
124
|
+
addEventListener(type: string, listener: (event: any) => void): void
|
|
125
|
+
removeEventListener(type: string, listener: (event: any) => void): void
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The transport listens for four event types: `"open"`, `"close"`, `"error"`, `"message"`. For `"message"` events, it reads `event.data` (accepting both `ArrayBuffer` and `Uint8Array`).
|
|
130
|
+
|
|
131
|
+
## `DataChannelLike` Interface
|
|
132
|
+
|
|
133
|
+
The full `RTCDataChannel` interface has ~30 members. This transport uses exactly 5. By accepting `DataChannelLike` instead of `RTCDataChannel`:
|
|
134
|
+
|
|
135
|
+
- **No DOM type dependency** — the interface uses `string` for `readyState` and `any` for event parameters, so there's no import of `lib.dom.d.ts` types like `Event`, `MessageEvent`, or `RTCDataChannelState`.
|
|
136
|
+
- **No wrapper for native WebRTC** — `RTCDataChannel` satisfies `DataChannelLike` structurally. Pass it directly.
|
|
137
|
+
- **Library bridges are trivial** — simple-peer, `werift`, `node-datachannel`, etc. can be bridged in ~20 lines by mapping their EventEmitter API to `addEventListener`/`removeEventListener`.
|
|
138
|
+
- **No double-casts** — without this design you'd need `channel as unknown as RTCDataChannel` to satisfy the type checker when using non-native implementations.
|
|
139
|
+
|
|
140
|
+
The type is intentionally loose: `readyState` is `string` (not a union), `binaryType` is `string` (not `"arraybuffer" | "blob"`), and event listeners take `any`. This maximizes the set of objects that conform structurally.
|
|
141
|
+
|
|
142
|
+
## Ownership Contract
|
|
143
|
+
|
|
144
|
+
The transport does **not** own the data channel.
|
|
145
|
+
|
|
146
|
+
- `attachDataChannel()` registers event listeners and creates an internal sync channel.
|
|
147
|
+
- `detachDataChannel()` removes event listeners and tears down the sync channel.
|
|
148
|
+
- Neither method closes the `DataChannelLike` or the peer connection.
|
|
149
|
+
|
|
150
|
+
The application manages the WebRTC connection lifecycle independently. This means you can:
|
|
151
|
+
|
|
152
|
+
- Share a peer connection across multiple transports
|
|
153
|
+
- Detach and reattach data channels without renegotiation
|
|
154
|
+
- Close data channels on your own schedule
|
|
155
|
+
|
|
156
|
+
## Fragmentation
|
|
157
|
+
|
|
158
|
+
SCTP (the underlying transport for WebRTC data channels) has a message size limit of approximately 256KB. The transport fragments messages that exceed the configured threshold using the same binary fragmentation pipeline as the WebSocket transport (`@kyneta/wire`).
|
|
159
|
+
|
|
160
|
+
| Setting | Value | Notes |
|
|
161
|
+
|---------|-------|-------|
|
|
162
|
+
| Default threshold | 200KB | Safe margin below SCTP's ~256KB limit |
|
|
163
|
+
| Disable | `fragmentThreshold: 0` | Not recommended — large messages will fail silently |
|
|
164
|
+
|
|
165
|
+
This differs from the WebSocket transport's 100KB default, which targets AWS API Gateway's 128KB frame limit. WebRTC has no such gateway constraint.
|
|
166
|
+
|
|
167
|
+
## Peer Dependencies
|
|
168
|
+
|
|
169
|
+
```/dev/null/package.json#L1-6
|
|
170
|
+
{
|
|
171
|
+
"peerDependencies": {
|
|
172
|
+
"@kyneta/exchange": "^1.1.0",
|
|
173
|
+
"@kyneta/wire": "^1.1.0"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { Transport, GeneratedChannel, TransportFactory } from '@kyneta/transport';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal interface for a WebRTC-style data channel.
|
|
5
|
+
*
|
|
6
|
+
* Native `RTCDataChannel` satisfies this structurally (no wrapper needed).
|
|
7
|
+
* Libraries like simple-peer can conform via a ~20-line bridge function
|
|
8
|
+
* that maps EventEmitter events to addEventListener calls.
|
|
9
|
+
*
|
|
10
|
+
* The transport uses exactly these members — nothing else. This is
|
|
11
|
+
* intentional: the narrower the interface, the easier it is to bridge
|
|
12
|
+
* from any WebRTC library.
|
|
13
|
+
*
|
|
14
|
+
* ## Event types used
|
|
15
|
+
*
|
|
16
|
+
* The transport registers listeners for exactly four event types:
|
|
17
|
+
* - `"open"` — data channel became ready for sending
|
|
18
|
+
* - `"close"` — data channel was closed
|
|
19
|
+
* - `"error"` — data channel encountered an error
|
|
20
|
+
* - `"message"` — data arrived; the transport reads `event.data`
|
|
21
|
+
*
|
|
22
|
+
* ## Ownership contract
|
|
23
|
+
*
|
|
24
|
+
* The transport does NOT own the data channel. Calling
|
|
25
|
+
* `detachDataChannel()` removes the sync channel but does not close
|
|
26
|
+
* the data channel or the peer connection. The application manages
|
|
27
|
+
* the WebRTC connection lifecycle independently.
|
|
28
|
+
*/
|
|
29
|
+
interface DataChannelLike {
|
|
30
|
+
/**
|
|
31
|
+
* Current state of the data channel.
|
|
32
|
+
*
|
|
33
|
+
* The transport treats `"open"` as sendable; all other values
|
|
34
|
+
* (including `"connecting"`, `"closing"`, `"closed"`) as not sendable.
|
|
35
|
+
*
|
|
36
|
+
* For native `RTCDataChannel`, this is one of:
|
|
37
|
+
* `"connecting" | "open" | "closing" | "closed"`.
|
|
38
|
+
*
|
|
39
|
+
* Wrappers may return any string — the transport only checks `=== "open"`.
|
|
40
|
+
*/
|
|
41
|
+
readonly readyState: string;
|
|
42
|
+
/**
|
|
43
|
+
* Binary type hint for incoming data.
|
|
44
|
+
*
|
|
45
|
+
* The transport writes `"arraybuffer"` on attach as a best-effort hint.
|
|
46
|
+
* It does NOT depend on this being respected — the message handler
|
|
47
|
+
* accepts both `ArrayBuffer` and `Uint8Array` data regardless.
|
|
48
|
+
*
|
|
49
|
+
* For native `RTCDataChannel`, this controls whether `MessageEvent.data`
|
|
50
|
+
* is an `ArrayBuffer` or a `Blob`. For wrappers that ignore this
|
|
51
|
+
* property (e.g. simple-peer bridges), the write is harmless.
|
|
52
|
+
*/
|
|
53
|
+
binaryType: string;
|
|
54
|
+
/**
|
|
55
|
+
* Send binary data through the data channel.
|
|
56
|
+
*
|
|
57
|
+
* The transport always sends `Uint8Array` instances (CBOR-encoded
|
|
58
|
+
* wire frames, optionally fragmented). Native `RTCDataChannel.send`
|
|
59
|
+
* accepts `ArrayBufferView` (which `Uint8Array` satisfies), so
|
|
60
|
+
* conformance is structural.
|
|
61
|
+
*/
|
|
62
|
+
send(data: Uint8Array): void;
|
|
63
|
+
/**
|
|
64
|
+
* Register an event listener.
|
|
65
|
+
*
|
|
66
|
+
* The transport uses this for `"open"`, `"close"`, `"error"`, and
|
|
67
|
+
* `"message"` events. For `"message"` events, the transport reads
|
|
68
|
+
* `event.data` and handles both `ArrayBuffer` and `Uint8Array`.
|
|
69
|
+
*
|
|
70
|
+
* @param type - Event type string
|
|
71
|
+
* @param listener - Callback. The `event` parameter is untyped to
|
|
72
|
+
* avoid coupling to DOM `Event` / `MessageEvent` types.
|
|
73
|
+
*/
|
|
74
|
+
addEventListener(type: string, listener: (event: any) => void): void;
|
|
75
|
+
/**
|
|
76
|
+
* Remove a previously registered event listener.
|
|
77
|
+
*
|
|
78
|
+
* Called during `detachDataChannel()` to clean up all four event
|
|
79
|
+
* listeners. The transport always passes the same function reference
|
|
80
|
+
* that was used in `addEventListener`.
|
|
81
|
+
*/
|
|
82
|
+
removeEventListener(type: string, listener: (event: any) => void): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Default fragment threshold in bytes.
|
|
87
|
+
*
|
|
88
|
+
* SCTP (the underlying transport for WebRTC data channels) has a message
|
|
89
|
+
* size limit of approximately 256KB. 200KB provides a safe margin.
|
|
90
|
+
*
|
|
91
|
+
* This differs from the WebSocket transport's 100KB default, which
|
|
92
|
+
* targets AWS API Gateway's 128KB limit. WebRTC has no such gateway.
|
|
93
|
+
*/
|
|
94
|
+
declare const DEFAULT_FRAGMENT_THRESHOLD: number;
|
|
95
|
+
/**
|
|
96
|
+
* Configuration options for the WebRTC transport.
|
|
97
|
+
*/
|
|
98
|
+
interface WebrtcTransportOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Fragment threshold in bytes. Messages larger than this are fragmented
|
|
101
|
+
* for SCTP compatibility. Set to 0 to disable fragmentation (not recommended).
|
|
102
|
+
*
|
|
103
|
+
* @default 204800 (200KB)
|
|
104
|
+
*/
|
|
105
|
+
fragmentThreshold?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Context for each attached data channel — stored per remotePeerId.
|
|
109
|
+
*/
|
|
110
|
+
type DataChannelContext = {
|
|
111
|
+
remotePeerId: string;
|
|
112
|
+
channel: DataChannelLike;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* WebRTC data channel transport for @kyneta/exchange.
|
|
116
|
+
*
|
|
117
|
+
* Follows a "Bring Your Own Data Channel" (BYODC) design — the application
|
|
118
|
+
* manages WebRTC connections and attaches data channels to this transport
|
|
119
|
+
* for kyneta document synchronization.
|
|
120
|
+
*
|
|
121
|
+
* Uses binary CBOR encoding with transport-level fragmentation via
|
|
122
|
+
* `@kyneta/wire` — the same pipeline as the WebSocket transport.
|
|
123
|
+
*
|
|
124
|
+
* ## Usage
|
|
125
|
+
*
|
|
126
|
+
* ```typescript
|
|
127
|
+
* import { Exchange } from "@kyneta/exchange"
|
|
128
|
+
* import { createWebrtcTransport } from "@kyneta/webrtc-transport"
|
|
129
|
+
*
|
|
130
|
+
* const webrtcTransport = createWebrtcTransport()
|
|
131
|
+
*
|
|
132
|
+
* const exchange = new Exchange({
|
|
133
|
+
* identity: { peerId: "alice", name: "Alice" },
|
|
134
|
+
* transports: [webrtcTransport],
|
|
135
|
+
* })
|
|
136
|
+
*
|
|
137
|
+
* // When a WebRTC connection is established:
|
|
138
|
+
* const cleanup = transport.attachDataChannel(remotePeerId, dataChannel)
|
|
139
|
+
*
|
|
140
|
+
* // When done:
|
|
141
|
+
* cleanup() // or transport.detachDataChannel(remotePeerId)
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* ## Ownership
|
|
145
|
+
*
|
|
146
|
+
* The transport does NOT own the data channel. `detachDataChannel()`
|
|
147
|
+
* removes the sync channel and event listeners but does not close the
|
|
148
|
+
* data channel or the peer connection. The application manages the
|
|
149
|
+
* WebRTC connection lifecycle independently.
|
|
150
|
+
*/
|
|
151
|
+
declare class WebrtcTransport extends Transport<DataChannelContext> {
|
|
152
|
+
#private;
|
|
153
|
+
constructor(options?: WebrtcTransportOptions);
|
|
154
|
+
/**
|
|
155
|
+
* Generate a channel for a data channel context.
|
|
156
|
+
*
|
|
157
|
+
* Called internally by the `Transport` base class when `addChannel()` is
|
|
158
|
+
* invoked. Users never call this directly — use `attachDataChannel()`.
|
|
159
|
+
*/
|
|
160
|
+
protected generate(context: DataChannelContext): GeneratedChannel;
|
|
161
|
+
/**
|
|
162
|
+
* Called when the transport starts.
|
|
163
|
+
*
|
|
164
|
+
* No-op for WebRTC — channels are added dynamically via
|
|
165
|
+
* `attachDataChannel()`, not at start time.
|
|
166
|
+
*/
|
|
167
|
+
onStart(): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* Called when the transport stops.
|
|
170
|
+
*
|
|
171
|
+
* Detaches all attached data channels and cleans up resources.
|
|
172
|
+
*/
|
|
173
|
+
onStop(): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* Attach a data channel for a remote peer.
|
|
176
|
+
*
|
|
177
|
+
* Creates an internal sync channel when the data channel is open
|
|
178
|
+
* (or waits for the `"open"` event if still connecting). The sync
|
|
179
|
+
* channel triggers the establishment handshake with the remote peer.
|
|
180
|
+
*
|
|
181
|
+
* If a data channel is already attached for this peer, the old one
|
|
182
|
+
* is detached first.
|
|
183
|
+
*
|
|
184
|
+
* @param remotePeerId - The stable peer ID of the remote peer
|
|
185
|
+
* @param channel - Any object satisfying `DataChannelLike`
|
|
186
|
+
* @returns A cleanup function that calls `detachDataChannel(remotePeerId)`
|
|
187
|
+
*/
|
|
188
|
+
attachDataChannel(remotePeerId: string, channel: DataChannelLike): () => void;
|
|
189
|
+
/**
|
|
190
|
+
* Detach a data channel for a remote peer.
|
|
191
|
+
*
|
|
192
|
+
* Removes the sync channel, cleans up event listeners, and disposes
|
|
193
|
+
* the reassembler. Does NOT close the data channel — the application
|
|
194
|
+
* manages the WebRTC connection lifecycle.
|
|
195
|
+
*
|
|
196
|
+
* @param remotePeerId - The peer ID to detach
|
|
197
|
+
*/
|
|
198
|
+
detachDataChannel(remotePeerId: string): void;
|
|
199
|
+
/**
|
|
200
|
+
* Check if a data channel is attached for a peer.
|
|
201
|
+
*/
|
|
202
|
+
hasDataChannel(remotePeerId: string): boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Get all peer IDs with attached data channels.
|
|
205
|
+
*/
|
|
206
|
+
getAttachedPeerIds(): string[];
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Create a WebRTC transport factory for use with `Exchange`.
|
|
210
|
+
*
|
|
211
|
+
* Returns a `TransportFactory` — pass directly to
|
|
212
|
+
* `Exchange({ transports: [...] })`. The returned transport instance
|
|
213
|
+
* exposes `attachDataChannel()` / `detachDataChannel()` for BYODC
|
|
214
|
+
* data channel management.
|
|
215
|
+
*
|
|
216
|
+
* To access the transport instance after creation, use
|
|
217
|
+
* `exchange.getTransport("webrtc-datachannel")`.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```typescript
|
|
221
|
+
* import { Exchange } from "@kyneta/exchange"
|
|
222
|
+
* import { createWebrtcTransport } from "@kyneta/webrtc-transport"
|
|
223
|
+
*
|
|
224
|
+
* const exchange = new Exchange({
|
|
225
|
+
* identity: { peerId: "alice", name: "Alice" },
|
|
226
|
+
* transports: [createWebrtcTransport()],
|
|
227
|
+
* })
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
declare function createWebrtcTransport(options?: WebrtcTransportOptions): TransportFactory;
|
|
231
|
+
|
|
232
|
+
export { DEFAULT_FRAGMENT_THRESHOLD, type DataChannelLike, WebrtcTransport, type WebrtcTransportOptions, createWebrtcTransport };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// src/webrtc-transport.ts
|
|
2
|
+
import { Transport } from "@kyneta/transport";
|
|
3
|
+
import {
|
|
4
|
+
decodeBinaryMessages,
|
|
5
|
+
encodeBinaryAndSend,
|
|
6
|
+
FragmentReassembler
|
|
7
|
+
} from "@kyneta/wire";
|
|
8
|
+
var DEFAULT_FRAGMENT_THRESHOLD = 200 * 1024;
|
|
9
|
+
var WebrtcTransport = class extends Transport {
|
|
10
|
+
/**
|
|
11
|
+
* Map of remotePeerId → attached channel tracking.
|
|
12
|
+
*/
|
|
13
|
+
#attachedChannels = /* @__PURE__ */ new Map();
|
|
14
|
+
/**
|
|
15
|
+
* Fragment threshold in bytes.
|
|
16
|
+
*/
|
|
17
|
+
#fragmentThreshold;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
super({ transportType: "webrtc-datachannel" });
|
|
20
|
+
this.#fragmentThreshold = options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
|
|
21
|
+
}
|
|
22
|
+
// ==========================================================================
|
|
23
|
+
// Transport abstract method implementations
|
|
24
|
+
// ==========================================================================
|
|
25
|
+
/**
|
|
26
|
+
* Generate a channel for a data channel context.
|
|
27
|
+
*
|
|
28
|
+
* Called internally by the `Transport` base class when `addChannel()` is
|
|
29
|
+
* invoked. Users never call this directly — use `attachDataChannel()`.
|
|
30
|
+
*/
|
|
31
|
+
generate(context) {
|
|
32
|
+
const { channel } = context;
|
|
33
|
+
return {
|
|
34
|
+
transportType: this.transportType,
|
|
35
|
+
send: (msg) => {
|
|
36
|
+
if (channel.readyState !== "open") {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
encodeBinaryAndSend(
|
|
40
|
+
msg,
|
|
41
|
+
this.#fragmentThreshold,
|
|
42
|
+
(data) => channel.send(data)
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
stop: () => {
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Called when the transport starts.
|
|
51
|
+
*
|
|
52
|
+
* No-op for WebRTC — channels are added dynamically via
|
|
53
|
+
* `attachDataChannel()`, not at start time.
|
|
54
|
+
*/
|
|
55
|
+
async onStart() {
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Called when the transport stops.
|
|
59
|
+
*
|
|
60
|
+
* Detaches all attached data channels and cleans up resources.
|
|
61
|
+
*/
|
|
62
|
+
async onStop() {
|
|
63
|
+
for (const remotePeerId of [...this.#attachedChannels.keys()]) {
|
|
64
|
+
this.detachDataChannel(remotePeerId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ==========================================================================
|
|
68
|
+
// Public API — data channel management
|
|
69
|
+
// ==========================================================================
|
|
70
|
+
/**
|
|
71
|
+
* Attach a data channel for a remote peer.
|
|
72
|
+
*
|
|
73
|
+
* Creates an internal sync channel when the data channel is open
|
|
74
|
+
* (or waits for the `"open"` event if still connecting). The sync
|
|
75
|
+
* channel triggers the establishment handshake with the remote peer.
|
|
76
|
+
*
|
|
77
|
+
* If a data channel is already attached for this peer, the old one
|
|
78
|
+
* is detached first.
|
|
79
|
+
*
|
|
80
|
+
* @param remotePeerId - The stable peer ID of the remote peer
|
|
81
|
+
* @param channel - Any object satisfying `DataChannelLike`
|
|
82
|
+
* @returns A cleanup function that calls `detachDataChannel(remotePeerId)`
|
|
83
|
+
*/
|
|
84
|
+
attachDataChannel(remotePeerId, channel) {
|
|
85
|
+
if (this.#attachedChannels.has(remotePeerId)) {
|
|
86
|
+
this.detachDataChannel(remotePeerId);
|
|
87
|
+
}
|
|
88
|
+
channel.binaryType = "arraybuffer";
|
|
89
|
+
const reassembler = new FragmentReassembler({ timeoutMs: 1e4 });
|
|
90
|
+
const onOpen = () => {
|
|
91
|
+
this.#createSyncChannel(remotePeerId);
|
|
92
|
+
};
|
|
93
|
+
const onClose = () => {
|
|
94
|
+
this.#removeSyncChannel(remotePeerId);
|
|
95
|
+
};
|
|
96
|
+
const onError = () => {
|
|
97
|
+
this.#removeSyncChannel(remotePeerId);
|
|
98
|
+
};
|
|
99
|
+
const onMessage = (event) => {
|
|
100
|
+
this.#handleMessage(remotePeerId, event);
|
|
101
|
+
};
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
channel.removeEventListener("open", onOpen);
|
|
104
|
+
channel.removeEventListener("close", onClose);
|
|
105
|
+
channel.removeEventListener("error", onError);
|
|
106
|
+
channel.removeEventListener("message", onMessage);
|
|
107
|
+
};
|
|
108
|
+
channel.addEventListener("open", onOpen);
|
|
109
|
+
channel.addEventListener("close", onClose);
|
|
110
|
+
channel.addEventListener("error", onError);
|
|
111
|
+
channel.addEventListener("message", onMessage);
|
|
112
|
+
const attached = {
|
|
113
|
+
remotePeerId,
|
|
114
|
+
channel,
|
|
115
|
+
channelId: null,
|
|
116
|
+
reassembler,
|
|
117
|
+
cleanup
|
|
118
|
+
};
|
|
119
|
+
this.#attachedChannels.set(remotePeerId, attached);
|
|
120
|
+
if (channel.readyState === "open") {
|
|
121
|
+
this.#createSyncChannel(remotePeerId);
|
|
122
|
+
}
|
|
123
|
+
return () => this.detachDataChannel(remotePeerId);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Detach a data channel for a remote peer.
|
|
127
|
+
*
|
|
128
|
+
* Removes the sync channel, cleans up event listeners, and disposes
|
|
129
|
+
* the reassembler. Does NOT close the data channel — the application
|
|
130
|
+
* manages the WebRTC connection lifecycle.
|
|
131
|
+
*
|
|
132
|
+
* @param remotePeerId - The peer ID to detach
|
|
133
|
+
*/
|
|
134
|
+
detachDataChannel(remotePeerId) {
|
|
135
|
+
const attached = this.#attachedChannels.get(remotePeerId);
|
|
136
|
+
if (!attached) return;
|
|
137
|
+
this.#removeSyncChannel(remotePeerId);
|
|
138
|
+
attached.reassembler.dispose();
|
|
139
|
+
attached.cleanup();
|
|
140
|
+
this.#attachedChannels.delete(remotePeerId);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Check if a data channel is attached for a peer.
|
|
144
|
+
*/
|
|
145
|
+
hasDataChannel(remotePeerId) {
|
|
146
|
+
return this.#attachedChannels.has(remotePeerId);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get all peer IDs with attached data channels.
|
|
150
|
+
*/
|
|
151
|
+
getAttachedPeerIds() {
|
|
152
|
+
return [...this.#attachedChannels.keys()];
|
|
153
|
+
}
|
|
154
|
+
// ==========================================================================
|
|
155
|
+
// Internal — sync channel lifecycle
|
|
156
|
+
// ==========================================================================
|
|
157
|
+
/**
|
|
158
|
+
* Create an internal sync channel for an attached data channel.
|
|
159
|
+
*
|
|
160
|
+
* Called when the data channel's `"open"` event fires (or immediately
|
|
161
|
+
* if already open on attach). The sync channel is registered with the
|
|
162
|
+
* Transport base class, which triggers the establishment handshake.
|
|
163
|
+
*/
|
|
164
|
+
#createSyncChannel(remotePeerId) {
|
|
165
|
+
const attached = this.#attachedChannels.get(remotePeerId);
|
|
166
|
+
if (!attached) return;
|
|
167
|
+
if (attached.channelId !== null) return;
|
|
168
|
+
const syncChannel = this.addChannel({
|
|
169
|
+
remotePeerId,
|
|
170
|
+
channel: attached.channel
|
|
171
|
+
});
|
|
172
|
+
attached.channelId = syncChannel.channelId;
|
|
173
|
+
this.establishChannel(syncChannel.channelId);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Remove the internal sync channel for a peer.
|
|
177
|
+
*/
|
|
178
|
+
#removeSyncChannel(remotePeerId) {
|
|
179
|
+
const attached = this.#attachedChannels.get(remotePeerId);
|
|
180
|
+
if (!attached || attached.channelId === null) return;
|
|
181
|
+
this.removeChannel(attached.channelId);
|
|
182
|
+
attached.channelId = null;
|
|
183
|
+
}
|
|
184
|
+
// ==========================================================================
|
|
185
|
+
// Internal — message handling
|
|
186
|
+
// ==========================================================================
|
|
187
|
+
/**
|
|
188
|
+
* Handle an incoming message from a data channel.
|
|
189
|
+
*
|
|
190
|
+
* Extracts binary data from the event, feeding both `ArrayBuffer`
|
|
191
|
+
* (native RTCDataChannel with binaryType "arraybuffer") and
|
|
192
|
+
* `Uint8Array` (simple-peer and other wrappers) into the shared
|
|
193
|
+
* decode pipeline.
|
|
194
|
+
*/
|
|
195
|
+
#handleMessage(remotePeerId, event) {
|
|
196
|
+
const attached = this.#attachedChannels.get(remotePeerId);
|
|
197
|
+
if (!attached || attached.channelId === null) return;
|
|
198
|
+
const syncChannel = this.channels.get(attached.channelId);
|
|
199
|
+
if (!syncChannel) return;
|
|
200
|
+
const raw = event.data;
|
|
201
|
+
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
|
|
202
|
+
if (!bytes) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const messages = decodeBinaryMessages(bytes, attached.reassembler);
|
|
207
|
+
if (messages) {
|
|
208
|
+
for (const msg of messages) {
|
|
209
|
+
syncChannel.onReceive(msg);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error(
|
|
214
|
+
`[webrtc-transport] Failed to decode message from peer ${remotePeerId}:`,
|
|
215
|
+
error
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
function createWebrtcTransport(options) {
|
|
221
|
+
return () => new WebrtcTransport(options);
|
|
222
|
+
}
|
|
223
|
+
export {
|
|
224
|
+
DEFAULT_FRAGMENT_THRESHOLD,
|
|
225
|
+
WebrtcTransport,
|
|
226
|
+
createWebrtcTransport
|
|
227
|
+
};
|
|
228
|
+
//# sourceMappingURL=index.js.map
|