@robota-sdk/agent-transport-webrtc 3.0.0-beta.81

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.
Files changed (47) hide show
  1. package/LICENSE +661 -0
  2. package/dist/node/index.cjs +1 -0
  3. package/dist/node/index.d.cts +361 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +361 -0
  6. package/dist/node/index.d.ts.map +1 -0
  7. package/dist/node/index.js +2 -0
  8. package/dist/node/index.js.map +1 -0
  9. package/package.json +88 -0
  10. package/src/__tests__/admission-secure-by-default.test.ts +61 -0
  11. package/src/__tests__/admission-steps.test.ts +46 -0
  12. package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
  13. package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
  14. package/src/__tests__/handoff-grant-gate.test.ts +401 -0
  15. package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
  16. package/src/__tests__/mesh-admission-contract.test.ts +38 -0
  17. package/src/__tests__/negotiated-certificate.test.ts +80 -0
  18. package/src/__tests__/operator-approval-gate.test.ts +306 -0
  19. package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
  20. package/src/__tests__/pairing-e2e.test.ts +238 -0
  21. package/src/__tests__/pairing-gate-e3.test.ts +192 -0
  22. package/src/__tests__/pairing-gate-e4.test.ts +108 -0
  23. package/src/__tests__/pairing-gate.test.ts +276 -0
  24. package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
  25. package/src/__tests__/session-attachment-budget.test.ts +136 -0
  26. package/src/__tests__/webrtc-transport.test.ts +349 -0
  27. package/src/__tests__/werift-loader.test.ts +18 -0
  28. package/src/__tests__/ws-signaling-client.test.ts +108 -0
  29. package/src/admission-steps.ts +55 -0
  30. package/src/channel-delivery.ts +44 -0
  31. package/src/handoff-grant-gate.ts +186 -0
  32. package/src/index.ts +21 -0
  33. package/src/local-peer-proof.ts +135 -0
  34. package/src/negotiated-certificate.ts +80 -0
  35. package/src/pairing-channel-lifecycle.ts +31 -0
  36. package/src/pairing-controllers.ts +79 -0
  37. package/src/pairing-frames.ts +34 -0
  38. package/src/pairing-gate-options.ts +129 -0
  39. package/src/pairing-gate.ts +376 -0
  40. package/src/session-attachment.ts +88 -0
  41. package/src/signaling.ts +62 -0
  42. package/src/transport-lifecycle-error.ts +11 -0
  43. package/src/webrtc-delivery-lifecycle.ts +51 -0
  44. package/src/webrtc-transport-options.ts +84 -0
  45. package/src/webrtc-transport.ts +328 -0
  46. package/src/werift-loader.ts +40 -0
  47. package/src/ws-signaling-client.ts +139 -0
@@ -0,0 +1,51 @@
1
+ import type { RTCDataChannel } from 'werift';
2
+
3
+ interface IWebRtcDeliveryLifecycleOptions {
4
+ readonly cleanup: () => void;
5
+ readonly onDropped: () => void;
6
+ readonly onDeliveryError: (error: Error, event: string) => void;
7
+ }
8
+
9
+ /** Owns carrier-drop state so delivery failures cannot leak into committed session operations. */
10
+ export class WebRtcDeliveryLifecycle {
11
+ private generation = 0;
12
+ private paired = false;
13
+ private dropped = false;
14
+
15
+ constructor(private readonly options: IWebRtcDeliveryLifecycleOptions) {}
16
+
17
+ reset(generation: number): void {
18
+ this.generation = generation;
19
+ this.paired = false;
20
+ this.dropped = false;
21
+ }
22
+
23
+ accept(generation: number): void {
24
+ if (generation === this.generation) this.paired = true;
25
+ }
26
+
27
+ handleDrop(generation: number): void {
28
+ if (generation !== this.generation || !this.paired || this.dropped) return;
29
+ this.dropped = true;
30
+ this.options.cleanup();
31
+ this.options.onDropped();
32
+ }
33
+
34
+ handleFailure(channel: RTCDataChannel, generation: number, error: Error, event: string): void {
35
+ if (generation !== this.generation || this.dropped) return;
36
+ const notifyDrop = this.paired;
37
+ if (notifyDrop) this.dropped = true;
38
+ try {
39
+ this.options.onDeliveryError(error, event);
40
+ } catch {
41
+ // Observer failure cannot interrupt carrier cleanup or a committed session operation.
42
+ }
43
+ this.options.cleanup();
44
+ try {
45
+ channel.close();
46
+ } catch {
47
+ // already closing/closed
48
+ }
49
+ if (notifyDrop) this.options.onDropped();
50
+ }
51
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What a caller CONFIGURES on the WebRTC host transport.
3
+ *
4
+ * Separated from the transport itself because "what may be set" and "what the transport does with
5
+ * it" are different subjects, and `webrtc-transport.ts` had reached the anti-monolith limit where
6
+ * the rule is to split rather than extend. Types only — no behaviour moved.
7
+ */
8
+
9
+ import type { IConnectionApproval, IHostReconnectConfig } from './pairing-gate.js';
10
+ import type { ILocalPeerProof } from './local-peer-proof.js';
11
+ import type { ISignalingClient } from './signaling.js';
12
+ import type { IWeriftModule } from './werift-loader.js';
13
+ import type { IPairingResult } from '@robota-sdk/agent-remote-pairing';
14
+ import type {
15
+ ISessionMessageHandlerOptions,
16
+ SessionResumeBridge,
17
+ } from '@robota-sdk/agent-transport';
18
+
19
+ /**
20
+ * A single ICE (STUN/TURN) server for the HOST (werift) transport (REMOTE-010). `urls` is a SINGLE string with a
21
+ * `turn:`/`turns:`/`stun:`/`stuns:` scheme — werift's ICE gatherer (`parseIceServers`) consumes only a single-string
22
+ * url and silently drops array `urls`, so the host reader (`agent-cli` `parseIceServers`) must narrow to this shape
23
+ * and reject what werift would drop (fail-closed). (The browser peer uses the native DOM `RTCIceServer`, which does
24
+ * support array urls / `turns:` — a separate, wider validator.) Kept a plain interface (no DOM dependency here).
25
+ */
26
+ export interface IIceServer {
27
+ readonly urls: string;
28
+ readonly username?: string;
29
+ readonly credential?: string;
30
+ }
31
+
32
+ /** Construction options for {@link WebRtcTransport}. The signaling client is injected (Stage A: no settings). */
33
+ export interface IWebRtcTransportOptions {
34
+ /** Host-owned usage read models, available only after admission. */
35
+ readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
36
+ readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
37
+ readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
38
+ /** Signaling port used to exchange SDP/ICE with the remote peer by rendezvous id. */
39
+ readonly signaling: ISignalingClient;
40
+ /** Optional ICE servers (STUN/TURN). Omitted → host-candidate/loopback only. */
41
+ readonly iceServers?: readonly IIceServer[];
42
+ /**
43
+ * REMOTE-004 defense-in-depth: when true, restrict ICE to **relay (TURN) candidates only**, so
44
+ * host/server-reflexive candidates — and the local-interface gathering that touches the (unreachable, but
45
+ * belt-and-braces) `ip` code path — are never used. Requires a TURN server in `iceServers`. Mapped to werift's
46
+ * `iceTransportPolicy: 'relay'` (REMOTE-010) — werift IGNORES a top-level `forceTurn`, so it must NOT be passed.
47
+ */
48
+ readonly forceTurn?: boolean;
49
+ /**
50
+ * REMOTE-008 pairing secret. When set, the data channel is **pairing-gated**: it carries only pairing frames
51
+ * until the directional-HMAC handshake accepts (channel-bound to the DTLS fingerprints), and only THEN is the
52
+ * session exposed — fail closed on mismatch/timeout. When omitted (Stage-A loopback / tests), the channel is
53
+ * exposed immediately with no pairing (unchanged behavior).
54
+ */
55
+ readonly secret?: string;
56
+ /**
57
+ * SEC-008: run with NO pairing gate. Requires `openReason`.
58
+ *
59
+ * Omitting `secret` used to mean this implicitly, which is how a remote peer reached the session
60
+ * because a field was left unset. It is still a legitimate mode — loopback, tests — but it is now
61
+ * a thing the host says rather than a thing that happens.
62
+ */
63
+ readonly open?: boolean;
64
+ /** SEC-008: why running with no pairing gate is correct here. Required when `open` is true. */
65
+ readonly openReason?: string;
66
+ /** REMOTE-008: fired when pairing accepts + the session is exposed (host lifecycle → status 'paired'). Carries the first-pair result (E4 uses its sessionKey). */
67
+ readonly onPaired?: (result?: IPairingResult) => void;
68
+ /** REMOTE-008: fired when pairing rejects/times out (host lifecycle → teardown; the channel is already closed). */
69
+ readonly onPairingFailed?: () => void;
70
+ /** REMOTE-012 E3: host reconnect/enrollment config. When set, the gate admits first-pair (with enrollment) OR a pinned-device reconnect. */
71
+ readonly reconnect?: IHostReconnectConfig;
72
+ /** SEC-010 (#1810): require a guarded-rendezvous nonce before the session is exposed. Absent → unchanged. */
73
+ readonly localPeer?: ILocalPeerProof;
74
+ /** Ask the receiving operator before each connection reaches the session. Absent → unchanged. */
75
+ readonly connectionApproval?: IConnectionApproval;
76
+ /** REMOTE-013 E4: a session-scoped resume bridge (owned by the controller across reconnects). Passed to the gate so the paired session flows through it (seq/buffer) and survives channel drops. */
77
+ readonly resumeBridge?: SessionResumeBridge;
78
+ /** REMOTE-013 E4: fired when a PAIRED data channel drops (so the controller can run the reconnect loop). Not fired for a pre-accept failure (that is `onPairingFailed`). */
79
+ readonly onDropped?: () => void;
80
+ /** Observe an outbound session-event delivery failure before the carrier drops. */
81
+ readonly onDeliveryError?: (error: Error, event: string) => void;
82
+ /** Test seam: inject the werift module (defaults to the real lazy loader). */
83
+ readonly loadWerift?: () => IWeriftModule;
84
+ }
@@ -0,0 +1,328 @@
1
+ import { createSessionMessageHandler } from '@robota-sdk/agent-transport';
2
+ import { resolveAdmission } from '@robota-sdk/agent-transport/node';
3
+ import {
4
+ extractDtlsFingerprint,
5
+ extractDtlsFingerprintAttribute,
6
+ } from '@robota-sdk/agent-remote-pairing';
7
+ import type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';
8
+ import type { RTCDataChannel, RTCPeerConnection } from 'werift';
9
+
10
+ import type { IProtocolSession } from '@robota-sdk/agent-transport';
11
+
12
+ import { createChannelDelivery } from './channel-delivery.js';
13
+ import { whenRemoteCertificateVerified } from './negotiated-certificate.js';
14
+ import { loadWerift } from './werift-loader.js';
15
+ import { PairingGate } from './pairing-gate.js';
16
+ import { createTransportLifecycleError } from './transport-lifecycle-error.js';
17
+ import { WebRtcDeliveryLifecycle } from './webrtc-delivery-lifecycle.js';
18
+ import type { IWebRtcTransportOptions } from './webrtc-transport-options.js';
19
+
20
+ /** Frames a peer may send before the gate exists; the pairing handshake needs only a few. */
21
+ const MAX_PENDING_FRAMES = 16;
22
+
23
+ /**
24
+ * WebRTC P2P transport (REMOTE-001/002): carries an `IProtocolSession` over an `RTCDataChannel` using the
25
+ * SAME transport-neutral session bridge as the WebSocket transport (`createSessionMessageHandler` from
26
+ * `@robota-sdk/agent-transport`). The host is the offerer: it creates the data channel + offer, and on
27
+ * data-channel open wires the handler. **Stage A: `defaultEnabled: false`, no pairing/auth** — the signaling
28
+ * client is injected and can be an in-memory loopback for tests.
29
+ */
30
+ export class WebRtcTransport implements IConfigurableTransport<IProtocolSession> {
31
+ public readonly name = 'webrtc';
32
+ public readonly lifecycle = Object.freeze({ kind: 'service' as const });
33
+ public readonly defaultEnabled = false;
34
+ public readonly optionsSchema = {} as const;
35
+
36
+ private session?: IProtocolSession;
37
+ private peer?: RTCPeerConnection;
38
+ private unsubscribeSignal?: () => void;
39
+ private cleanupHandler?: () => void;
40
+ /** Invalidates pending async startup work and scopes pairing/drop state to one start generation. */
41
+ private generation = 0;
42
+ /** Local DTLS fingerprint captured for pairing channel binding. */
43
+ private localFingerprint?: string;
44
+ /** Pairing gate for the current channel. */
45
+ private pairingGate?: PairingGate;
46
+ /** Whether this start generation has taken its one answer; any later answer is ignored. */
47
+ private answered = false;
48
+ /** Pre-gate channel frames, replayed into the gate once it exists. Bounded. */
49
+ private pendingFrames: string[] = [];
50
+ private stopAwaitingCertificate?: () => void;
51
+ private readonly deliveryLifecycle: WebRtcDeliveryLifecycle;
52
+
53
+ public constructor(private readonly options: IWebRtcTransportOptions) {
54
+ this.deliveryLifecycle = new WebRtcDeliveryLifecycle({
55
+ cleanup: () => {
56
+ this.cleanupHandler?.();
57
+ this.pairingGate?.cleanup();
58
+ },
59
+ onDropped: () => this.options.onDropped?.(),
60
+ onDeliveryError: (error, event) => this.options.onDeliveryError?.(error, event),
61
+ });
62
+ // A pairing secret and explicit open admission are contradictory, so fail before signaling.
63
+ if (this.options.secret && this.options.open === true) {
64
+ throw new Error(
65
+ 'WebRtcTransport: `secret` and `open: true` are contradictory. A pairing secret gates the ' +
66
+ 'data channel; `open` runs without a gate. Pass one.',
67
+ );
68
+ }
69
+ if (this.options.secret === undefined || this.options.secret === '') {
70
+ if (this.options.open !== true) {
71
+ throw new Error(
72
+ 'WebRtcTransport: no pairing `secret` and no explicit `open`. Pass a `secret` to gate the ' +
73
+ 'data channel, or `{ open: true, openReason: "…" }` to run without pairing on purpose.',
74
+ );
75
+ }
76
+ // WebRTC has no bearer credential; use the shared seam only to validate the open reason.
77
+ void resolveAdmission({
78
+ open: true,
79
+ ...(this.options.openReason !== undefined ? { openReason: this.options.openReason } : {}),
80
+ });
81
+ }
82
+ }
83
+
84
+ public validateOptions(): boolean {
85
+ return true;
86
+ }
87
+
88
+ public attach(session: IProtocolSession): void {
89
+ this.session = session;
90
+ }
91
+ private createPeer(): RTCPeerConnection {
92
+ const { RTCPeerConnection } = (this.options.loadWerift ?? loadWerift)();
93
+ const config: {
94
+ iceServers?: { urls: string; username?: string; credential?: string }[];
95
+ iceTransportPolicy?: 'all' | 'relay';
96
+ } = {};
97
+ if (this.options.iceServers)
98
+ config.iceServers = this.options.iceServers.map((server) => ({ ...server }));
99
+ if (this.options.forceTurn) config.iceTransportPolicy = 'relay';
100
+ return new RTCPeerConnection(Object.keys(config).length > 0 ? config : undefined);
101
+ }
102
+
103
+ private wireSignaling(
104
+ peer: RTCPeerConnection,
105
+ channel: RTCDataChannel,
106
+ session: IProtocolSession,
107
+ generation: number,
108
+ ): void {
109
+ const signaling = this.options.signaling;
110
+ peer.onIceCandidate.subscribe((candidate) => {
111
+ if (candidate && generation === this.generation && peer === this.peer) {
112
+ signaling.send({ kind: 'ice', data: candidate.toJSON() });
113
+ }
114
+ });
115
+ let signalChain: Promise<void> = Promise.resolve();
116
+ this.unsubscribeSignal = signaling.onSignal((message) => {
117
+ if (generation !== this.generation) return;
118
+ signalChain = signalChain.then(async () => {
119
+ if (generation !== this.generation || peer !== this.peer) return;
120
+ if (message.kind === 'answer') {
121
+ // One answer per start. A later answer would add fingerprints the DTLS layer then also accepts.
122
+ if (this.answered) return;
123
+ this.answered = true;
124
+ const algorithm = this.remoteFingerprintAlgorithm(channel, message.data);
125
+ if (this.options.secret && algorithm === undefined) return;
126
+ await peer.setRemoteDescription(
127
+ message.data as Parameters<typeof peer.setRemoteDescription>[0],
128
+ );
129
+ if (algorithm !== undefined)
130
+ this.awaitVerifiedCertificate(peer, channel, session, algorithm, generation);
131
+ } else if (message.kind === 'ice') {
132
+ await peer.addIceCandidate(message.data as Parameters<typeof peer.addIceCandidate>[0]);
133
+ }
134
+ });
135
+ });
136
+ }
137
+
138
+ private async requireCurrentPeer(peer: RTCPeerConnection, generation: number): Promise<void> {
139
+ if (generation === this.generation && this.peer === peer) return;
140
+ await peer.close();
141
+ throw new Error('WebRtcTransport startup was stopped.');
142
+ }
143
+
144
+ public async start(): Promise<void> {
145
+ const session = this.session;
146
+ if (!session) throw createTransportLifecycleError('not-attached');
147
+ if (this.peer) throw createTransportLifecycleError('already-started');
148
+ const generation = ++this.generation;
149
+ this.deliveryLifecycle.reset(generation);
150
+ this.answered = false;
151
+ this.pendingFrames = [];
152
+
153
+ const peer = this.createPeer();
154
+ this.peer = peer;
155
+ const signaling = this.options.signaling;
156
+ const channel = peer.createDataChannel('robota-session');
157
+ this.wireChannel(channel, session, generation);
158
+ this.wireSignaling(peer, channel, session, generation);
159
+
160
+ const offer = await peer.createOffer();
161
+ await this.requireCurrentPeer(peer, generation);
162
+ await peer.setLocalDescription(offer);
163
+ await this.requireCurrentPeer(peer, generation);
164
+ // Capture the local DTLS fingerprint for the pairing channel-binding (offer SDP).
165
+ if (this.options.secret && peer.localDescription) {
166
+ this.localFingerprint = extractDtlsFingerprint(peer.localDescription.sdp);
167
+ }
168
+ signaling.send({ kind: 'offer', data: peer.localDescription });
169
+ }
170
+
171
+ /**
172
+ * With a pairing secret, the answer must advertise exactly one DTLS fingerprint; its algorithm is the one the
173
+ * verified certificate is hashed with. A refused answer fails pairing and closes the channel.
174
+ */
175
+ private remoteFingerprintAlgorithm(channel: RTCDataChannel, answer: unknown): string | undefined {
176
+ if (!this.options.secret) return undefined;
177
+ const sdp = (answer as { sdp?: unknown }).sdp;
178
+ try {
179
+ if (typeof sdp !== 'string') throw new Error('answer carries no SDP');
180
+ return extractDtlsFingerprintAttribute(sdp).algorithm;
181
+ } catch {
182
+ this.failPairing(channel);
183
+ return undefined;
184
+ }
185
+ }
186
+
187
+ private failPairing(channel: RTCDataChannel): void {
188
+ try {
189
+ void channel.close();
190
+ } catch {
191
+ /* already closing */
192
+ }
193
+ this.options.onPairingFailed?.();
194
+ }
195
+
196
+ /** Build the pairing gate once the DTLS layer has verified the remote certificate. */
197
+ private awaitVerifiedCertificate(
198
+ peer: RTCPeerConnection,
199
+ channel: RTCDataChannel,
200
+ session: IProtocolSession,
201
+ algorithm: string,
202
+ generation: number,
203
+ ): void {
204
+ this.stopAwaitingCertificate = whenRemoteCertificateVerified(
205
+ peer,
206
+ algorithm,
207
+ (remoteFingerprint) => {
208
+ if (generation !== this.generation) return;
209
+ this.startPairing(channel, session, remoteFingerprint, generation);
210
+ },
211
+ () => {
212
+ if (generation === this.generation) this.failPairing(channel);
213
+ },
214
+ );
215
+ }
216
+
217
+ private startPairing(
218
+ channel: RTCDataChannel,
219
+ session: IProtocolSession,
220
+ remoteFingerprint: string,
221
+ generation: number,
222
+ ): void {
223
+ const secret = this.options.secret;
224
+ if (!secret || !this.localFingerprint) return;
225
+ this.pairingGate = new PairingGate({
226
+ channel: { send: (d) => channel.send(d), close: () => void channel.close() },
227
+ session,
228
+ secret,
229
+ role: 'initiator',
230
+ localFingerprint: this.localFingerprint,
231
+ remoteFingerprint,
232
+ onAccept: (result) => {
233
+ if (generation !== this.generation) return;
234
+ this.deliveryLifecycle.accept(generation);
235
+ this.options.onPaired?.(result);
236
+ },
237
+ ...(this.options.onPairingFailed ? { onReject: this.options.onPairingFailed } : {}),
238
+ ...(this.options.reconnect ? { reconnect: this.options.reconnect } : {}),
239
+ ...(this.options.localPeer ? { localPeer: this.options.localPeer } : {}),
240
+ ...(this.options.connectionApproval
241
+ ? { connectionApproval: this.options.connectionApproval }
242
+ : {}),
243
+ ...(this.options.resumeBridge ? { resumeBridge: this.options.resumeBridge } : {}),
244
+ ...(this.options.personalUsageReporter
245
+ ? { personalUsageReporter: this.options.personalUsageReporter }
246
+ : {}),
247
+ ...(this.options.usageReporter ? { usageReporter: this.options.usageReporter } : {}),
248
+ ...(this.options.storedSessionUsageReporter
249
+ ? { storedSessionUsageReporter: this.options.storedSessionUsageReporter }
250
+ : {}),
251
+ surface: 'remote',
252
+ onDeliveryError: (error, event) =>
253
+ this.deliveryLifecycle.handleFailure(channel, generation, error, event),
254
+ });
255
+ const gate = this.pairingGate;
256
+ for (const frame of this.pendingFrames.splice(0)) gate.onInbound(frame);
257
+ }
258
+
259
+ private wireChannel(
260
+ channel: RTCDataChannel,
261
+ session: IProtocolSession,
262
+ generation: number,
263
+ ): void {
264
+ // Subscribe eagerly: werift does not buffer a remote's first frame before a listener exists.
265
+ // With a secret the gate drops pre-accept non-pairing frames; otherwise the session is exposed directly.
266
+ if (this.options.secret) {
267
+ channel.onMessage.subscribe((data) => {
268
+ if (generation !== this.generation) return;
269
+ const frame = typeof data === 'string' ? data : data.toString();
270
+ if (this.pairingGate) this.pairingGate.onInbound(frame);
271
+ else if (this.pendingFrames.length < MAX_PENDING_FRAMES) this.pendingFrames.push(frame);
272
+ });
273
+ // A post-accept close detaches the resume bridge and starts reconnect without ending the session.
274
+ channel.stateChanged.subscribe((state) => {
275
+ if (generation !== this.generation) return;
276
+ if (state === 'closed' || state === 'closing') {
277
+ // Before acceptance the gate must hear it too: a question still open with the operator is
278
+ // about a connection that no longer exists.
279
+ this.pairingGate?.onChannelClosed();
280
+ this.deliveryLifecycle.handleDrop(generation);
281
+ }
282
+ });
283
+ this.cleanupHandler = () => this.pairingGate?.cleanup();
284
+ return;
285
+ }
286
+
287
+ // ARCH-030: the transport is the carrier on the no-secret branch — its own sink, its own lifecycle.
288
+ const { onMessage, cleanup } = createSessionMessageHandler({
289
+ session,
290
+ deliver: createChannelDelivery(channel, (error, event) =>
291
+ this.deliveryLifecycle.handleFailure(channel, generation, error, event),
292
+ ),
293
+ ...(this.options.personalUsageReporter
294
+ ? { personalUsageReporter: this.options.personalUsageReporter }
295
+ : {}),
296
+ ...(this.options.usageReporter ? { usageReporter: this.options.usageReporter } : {}),
297
+ ...(this.options.storedSessionUsageReporter
298
+ ? { storedSessionUsageReporter: this.options.storedSessionUsageReporter }
299
+ : {}),
300
+ surface: 'remote',
301
+ });
302
+ this.cleanupHandler = cleanup;
303
+ channel.onMessage.subscribe((data) => {
304
+ if (generation !== this.generation) return;
305
+ onMessage(typeof data === 'string' ? data : data.toString());
306
+ });
307
+ }
308
+
309
+ public async stop(): Promise<void> {
310
+ this.generation += 1;
311
+ this.cleanupHandler?.();
312
+ this.unsubscribeSignal?.();
313
+ this.cleanupHandler = undefined;
314
+ this.unsubscribeSignal = undefined;
315
+ this.pairingGate = undefined;
316
+ this.localFingerprint = undefined;
317
+ this.answered = false;
318
+ this.pendingFrames = [];
319
+ this.stopAwaitingCertificate?.();
320
+ this.stopAwaitingCertificate = undefined;
321
+ this.deliveryLifecycle.reset(this.generation);
322
+ if (this.peer) {
323
+ await this.peer.close();
324
+ this.peer = undefined;
325
+ }
326
+ this.session = undefined;
327
+ }
328
+ }
@@ -0,0 +1,40 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import type { RTCPeerConnection } from 'werift';
4
+
5
+ /** The subset of the `werift` module surface this transport constructs. */
6
+ export interface IWeriftModule {
7
+ RTCPeerConnection: new (configuration?: {
8
+ // REMOTE-010: werift's ICE gatherer consumes only a SINGLE-string `urls` (array urls / `turns:` are silently
9
+ // dropped by its `parseIceServers`); TURN servers carry username/credential. Relay-only comes from
10
+ // `iceTransportPolicy:'relay'` — werift IGNORES a top-level `forceTurn`, so this type must NOT declare it.
11
+ iceServers?: { urls: string; username?: string; credential?: string }[];
12
+ iceTransportPolicy?: 'all' | 'relay';
13
+ }) => RTCPeerConnection;
14
+ }
15
+
16
+ /**
17
+ * Lazily load the optional `werift` (pure-TypeScript WebRTC) peer dependency (REMOTE-002). `werift` is kept
18
+ * OUT of the transport's runtime dependency graph (peer + optional) so this package is importable without it;
19
+ * absence surfaces an **explicit "WebRTC transport unavailable" error at point-of-use** (a throw, mirroring
20
+ * `agent-cli`'s `loadReplayProvider`) — never a silent no-op or degraded path (no-fallback rule). The
21
+ * werift-vs-native choice is a recorded design decision, not a runtime fallback.
22
+ */
23
+ /** Resolve a module by id. The default resolves the real `werift`; tests inject a throwing resolver. */
24
+ export type TModuleResolver = (id: string) => unknown;
25
+
26
+ const defaultResolver: TModuleResolver = (id) => {
27
+ const requireFrom = createRequire(import.meta.url);
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer; must resolve at runtime, not bundle
29
+ return requireFrom(id);
30
+ };
31
+
32
+ export function loadWerift(resolve: TModuleResolver = defaultResolver): IWeriftModule {
33
+ try {
34
+ return resolve('werift') as IWeriftModule;
35
+ } catch {
36
+ throw new Error(
37
+ 'WebRTC transport unavailable — install the optional peer dependency "werift" to use @robota-sdk/agent-transport-webrtc.',
38
+ );
39
+ }
40
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Production `ISignalingClient` over a WebSocket connection to the `@robota-sdk/remote-signaling` relay
3
+ * (REMOTE-004 Stage B2). This is the **Node host-side** client (the browser remote client, Stage D, implements
4
+ * `ISignalingClient` on the native `WebSocket`).
5
+ *
6
+ * On open it `join`s the rendezvous and flushes any signals produced before the socket opened (the offer is
7
+ * created in `WebRtcTransport.start()`, which can run before the socket connects — buffering avoids dropping it).
8
+ * Relay `error` frames, socket errors, and a close-before-join are surfaced through an explicit `onError`
9
+ * callback — **never a silent degrade** (no-fallback, mirroring `loadReplayProvider`/`loadWerift`). The relay is
10
+ * content-blind: this client only ever emits `join` + `signal` frames and only ever consumes `joined` / `signal`
11
+ * / `error` frames.
12
+ */
13
+ import WebSocket from 'ws';
14
+
15
+ import type { ISignalingClient, ISignalMessage, TSignalKind } from './signaling.js';
16
+
17
+ /** A minimal WebSocket-like surface — so tests can inject a fake without a real socket. */
18
+ export interface IWebSocketLike {
19
+ send(data: string): void;
20
+ close(): void;
21
+ readyState: number;
22
+ on(event: 'open' | 'message' | 'error' | 'close', handler: (arg: unknown) => void): void;
23
+ }
24
+
25
+ export interface IWsSignalingClientOptions {
26
+ /** Relay URL, e.g. `ws://127.0.0.1:1234`. */
27
+ readonly url: string;
28
+ /** Rendezvous id to join. */
29
+ readonly rendezvous: string;
30
+ /**
31
+ * Explicit error sink. Called on a relay `error` frame, a socket error, or a close-before-join — the signaling
32
+ * channel cannot function and the caller must know (no silent degrade).
33
+ */
34
+ readonly onError?: (error: Error) => void;
35
+ /** Called once the relay confirms the rendezvous `join` (the `joined` frame). */
36
+ readonly onReady?: () => void;
37
+ /** Injectable socket factory (defaults to a real `ws` WebSocket) — for tests. */
38
+ readonly createSocket?: (url: string) => IWebSocketLike;
39
+ }
40
+
41
+ const SIGNAL_KINDS: ReadonlySet<string> = new Set<TSignalKind>(['offer', 'answer', 'ice']);
42
+ const WS_OPEN = 1;
43
+
44
+ function defaultCreateSocket(url: string): IWebSocketLike {
45
+ return new WebSocket(url) as unknown as IWebSocketLike;
46
+ }
47
+
48
+ export class WsSignalingClient implements ISignalingClient {
49
+ private readonly socket: IWebSocketLike;
50
+ private readonly handlers: ((message: ISignalMessage) => void)[] = [];
51
+ private readonly outbox: ISignalMessage[] = [];
52
+ private joined = false;
53
+ private closed = false;
54
+
55
+ public constructor(private readonly options: IWsSignalingClientOptions) {
56
+ const factory = options.createSocket ?? defaultCreateSocket;
57
+ this.socket = factory(options.url);
58
+ this.socket.on('open', () => this.handleOpen());
59
+ this.socket.on('message', (data) => this.handleMessage(data));
60
+ this.socket.on('error', (err) =>
61
+ this.fail(err instanceof Error ? err : new Error(String(err))),
62
+ );
63
+ this.socket.on('close', () => this.handleClose());
64
+ }
65
+
66
+ private handleOpen(): void {
67
+ this.socket.send(JSON.stringify({ type: 'join', rendezvous: this.options.rendezvous }));
68
+ // Flush any signals produced before the socket opened (e.g. the offer from WebRtcTransport.start()).
69
+ for (const message of this.outbox) this.writeSignal(message);
70
+ this.outbox.length = 0;
71
+ }
72
+
73
+ private handleMessage(data: unknown): void {
74
+ let frame: unknown;
75
+ try {
76
+ frame = JSON.parse(typeof data === 'string' ? data : String(data));
77
+ } catch {
78
+ return; // a non-JSON frame from the relay is ignored (the relay only emits JSON)
79
+ }
80
+ if (typeof frame !== 'object' || frame === null) return;
81
+ const record = frame as Record<string, unknown>;
82
+ if (record.type === 'joined') {
83
+ this.joined = true;
84
+ this.options.onReady?.();
85
+ return;
86
+ }
87
+ if (record.type === 'error') {
88
+ this.fail(new Error(`signaling relay rejected the connection: ${String(record.reason)}`));
89
+ return;
90
+ }
91
+ if (
92
+ record.type === 'signal' &&
93
+ typeof record.kind === 'string' &&
94
+ SIGNAL_KINDS.has(record.kind)
95
+ ) {
96
+ const message: ISignalMessage = { kind: record.kind as TSignalKind, data: record.data };
97
+ for (const handler of this.handlers) handler(message);
98
+ }
99
+ }
100
+
101
+ private handleClose(): void {
102
+ if (this.closed) return; // an intentional close() is not an error
103
+ if (!this.joined) {
104
+ this.fail(new Error('signaling socket closed before the rendezvous was joined'));
105
+ }
106
+ }
107
+
108
+ private fail(error: Error): void {
109
+ this.options.onError?.(error);
110
+ }
111
+
112
+ private writeSignal(message: ISignalMessage): void {
113
+ this.socket.send(JSON.stringify({ type: 'signal', kind: message.kind, data: message.data }));
114
+ }
115
+
116
+ public send(message: ISignalMessage): void {
117
+ if (this.closed) return;
118
+ if (this.socket.readyState === WS_OPEN) {
119
+ this.writeSignal(message);
120
+ } else {
121
+ this.outbox.push(message); // buffer until open (see handleOpen)
122
+ }
123
+ }
124
+
125
+ public onSignal(handler: (message: ISignalMessage) => void): () => void {
126
+ this.handlers.push(handler);
127
+ return () => {
128
+ const index = this.handlers.indexOf(handler);
129
+ if (index >= 0) this.handlers.splice(index, 1);
130
+ };
131
+ }
132
+
133
+ public close(): void {
134
+ this.closed = true;
135
+ this.handlers.length = 0;
136
+ this.outbox.length = 0;
137
+ this.socket.close();
138
+ }
139
+ }