@robota-sdk/agent-transport-webrtc 3.0.0-beta.100
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/CHANGELOG.md +694 -0
- package/LICENSE +661 -0
- package/README.md +111 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1650 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1650 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/__tests__/admission-secure-by-default.test.ts +63 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/device-mesh-relay.test.ts +441 -0
- package/src/__tests__/device-mesh.test.ts +682 -0
- package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
- package/src/__tests__/enrollment-link.test.ts +103 -0
- package/src/__tests__/fake-datachannel.ts +83 -0
- package/src/__tests__/handoff-grant-gate.test.ts +403 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/mesh-discovery.test.ts +820 -0
- package/src/__tests__/mesh-fixtures.ts +148 -0
- package/src/__tests__/mesh-internet.test.ts +942 -0
- package/src/__tests__/mesh-turn-relay.test.ts +161 -0
- package/src/__tests__/negotiated-certificate.test.ts +76 -0
- package/src/__tests__/nostr-source-condition.test.ts +27 -0
- package/src/__tests__/operator-approval-gate.test.ts +308 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
- package/src/__tests__/pairing-e2e.test.ts +237 -0
- package/src/__tests__/pairing-gate-e3.test.ts +194 -0
- package/src/__tests__/pairing-gate-e4.test.ts +110 -0
- package/src/__tests__/pairing-gate.test.ts +296 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
- package/src/__tests__/rtc-channel-close.test.ts +52 -0
- package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
- package/src/__tests__/session-attachment-app-view.test.ts +66 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/turn-server-bind.test.ts +59 -0
- package/src/__tests__/turn-server.test.ts +335 -0
- package/src/__tests__/turn-test-client.ts +256 -0
- package/src/__tests__/webrtc-implementation.test.ts +83 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/datachannel-loader.ts +86 -0
- package/src/device-mesh-node.ts +1019 -0
- package/src/discovering-mesh-relay.ts +675 -0
- package/src/enrollment-link.ts +465 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +139 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/mainline-dht-store.ts +174 -0
- package/src/mesh-dht.ts +446 -0
- package/src/mesh-discovery.ts +87 -0
- package/src/mesh-item-store.ts +287 -0
- package/src/mesh-lan-listener.ts +193 -0
- package/src/mesh-mdns.ts +406 -0
- package/src/mesh-peer-link.ts +600 -0
- package/src/mesh-public-relays.ts +25 -0
- package/src/mesh-records.ts +357 -0
- package/src/mesh-relay.ts +98 -0
- package/src/mesh-signal.ts +116 -0
- package/src/mesh-turn-relay.ts +238 -0
- package/src/negotiated-certificate.ts +72 -0
- package/src/nostr-mesh-relay.ts +391 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +81 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +133 -0
- package/src/pairing-gate.ts +397 -0
- package/src/rtc-peer.ts +429 -0
- package/src/session-attachment.ts +96 -0
- package/src/signaling.ts +62 -0
- package/src/stun-message.ts +341 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/turn-server.ts +1028 -0
- package/src/webrtc-delivery-lifecycle.ts +54 -0
- package/src/webrtc-transport-options.ts +87 -0
- package/src/webrtc-transport.ts +368 -0
- package/src/ws-mesh-relay-client.ts +117 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The part of a data channel the lifecycle closes. */
|
|
2
|
+
interface IClosableChannel {
|
|
3
|
+
close(): void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface IWebRtcDeliveryLifecycleOptions {
|
|
7
|
+
readonly cleanup: () => void;
|
|
8
|
+
readonly onDropped: () => void;
|
|
9
|
+
readonly onDeliveryError: (error: Error, event: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Owns carrier-drop state so delivery failures cannot leak into committed session operations. */
|
|
13
|
+
export class WebRtcDeliveryLifecycle {
|
|
14
|
+
private generation = 0;
|
|
15
|
+
private paired = false;
|
|
16
|
+
private dropped = false;
|
|
17
|
+
|
|
18
|
+
constructor(private readonly options: IWebRtcDeliveryLifecycleOptions) {}
|
|
19
|
+
|
|
20
|
+
reset(generation: number): void {
|
|
21
|
+
this.generation = generation;
|
|
22
|
+
this.paired = false;
|
|
23
|
+
this.dropped = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
accept(generation: number): void {
|
|
27
|
+
if (generation === this.generation) this.paired = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
handleDrop(generation: number): void {
|
|
31
|
+
if (generation !== this.generation || !this.paired || this.dropped) return;
|
|
32
|
+
this.dropped = true;
|
|
33
|
+
this.options.cleanup();
|
|
34
|
+
this.options.onDropped();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
handleFailure(channel: IClosableChannel, generation: number, error: Error, event: string): void {
|
|
38
|
+
if (generation !== this.generation || this.dropped) return;
|
|
39
|
+
const notifyDrop = this.paired;
|
|
40
|
+
if (notifyDrop) this.dropped = true;
|
|
41
|
+
try {
|
|
42
|
+
this.options.onDeliveryError(error, event);
|
|
43
|
+
} catch {
|
|
44
|
+
// Observer failure cannot interrupt carrier cleanup or a committed session operation.
|
|
45
|
+
}
|
|
46
|
+
this.options.cleanup();
|
|
47
|
+
try {
|
|
48
|
+
channel.close();
|
|
49
|
+
} catch {
|
|
50
|
+
// already closing/closed
|
|
51
|
+
}
|
|
52
|
+
if (notifyDrop) this.options.onDropped();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { IIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
/**
|
|
3
|
+
* What a caller CONFIGURES on the WebRTC host transport.
|
|
4
|
+
*
|
|
5
|
+
* Separated from the transport itself because "what may be set" and "what the transport does with
|
|
6
|
+
* it" are different subjects, and `webrtc-transport.ts` had reached the anti-monolith limit where
|
|
7
|
+
* the rule is to split rather than extend. Types only — no behaviour moved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IConnectionApproval, IHostReconnectConfig } from './pairing-gate.js';
|
|
11
|
+
import type { ILocalPeerProof } from './local-peer-proof.js';
|
|
12
|
+
import type { ISignalingClient } from './signaling.js';
|
|
13
|
+
import type { IDataChannelModule } from './datachannel-loader.js';
|
|
14
|
+
import type { IPairingResult } from '@robota-sdk/agent-remote-pairing';
|
|
15
|
+
import type {
|
|
16
|
+
ISessionMessageHandlerOptions,
|
|
17
|
+
SessionResumeBridge,
|
|
18
|
+
} from '@robota-sdk/agent-transport';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A single ICE (STUN/TURN) server for the host transport. `urls` is one `stun:`/`stuns:`/`turn:`/`turns:` url
|
|
22
|
+
* (optionally `?transport=tcp`); TURN servers carry username/credential. The browser peer uses the native DOM
|
|
23
|
+
* `RTCIceServer`, a separate, wider shape. Kept a plain interface (no DOM dependency here).
|
|
24
|
+
*/
|
|
25
|
+
export interface IIceServer {
|
|
26
|
+
readonly urls: string;
|
|
27
|
+
readonly username?: string;
|
|
28
|
+
readonly credential?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Construction options for {@link WebRtcTransport}. The signaling client is injected; the transport declares no
|
|
33
|
+
* settings schema of its own (`optionsSchema` is empty).
|
|
34
|
+
*/
|
|
35
|
+
export interface IWebRtcTransportOptions {
|
|
36
|
+
readonly cryptoContext: IIdentityContext;
|
|
37
|
+
/** Host-owned usage read models, available only after admission. */
|
|
38
|
+
readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
39
|
+
readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
40
|
+
readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
41
|
+
/** Signaling port used to exchange SDP/ICE with the remote peer by rendezvous id. */
|
|
42
|
+
readonly signaling: ISignalingClient;
|
|
43
|
+
/** Optional ICE servers (STUN/TURN). Omitted → host-candidate/loopback only. */
|
|
44
|
+
readonly iceServers?: readonly IIceServer[];
|
|
45
|
+
/**
|
|
46
|
+
* REMOTE-004 defense-in-depth: when true, restrict ICE to **relay (TURN) candidates only**, so
|
|
47
|
+
* host/server-reflexive candidates are never used. Requires a TURN server in `iceServers`. Mapped to the
|
|
48
|
+
* connection's `iceTransportPolicy: 'relay'`.
|
|
49
|
+
*/
|
|
50
|
+
readonly forceTurn?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* REMOTE-008 pairing secret. When set, the data channel is **pairing-gated**: it carries only pairing frames
|
|
53
|
+
* until the directional-HMAC handshake accepts (channel-bound to the DTLS fingerprints), and only THEN is the
|
|
54
|
+
* session exposed — fail closed on mismatch/timeout. When omitted, the constructor throws unless the caller
|
|
55
|
+
* explicitly opts out with `open: true` and an `openReason` (loopback / tests); only then is the channel
|
|
56
|
+
* exposed immediately with no pairing.
|
|
57
|
+
*/
|
|
58
|
+
readonly secret?: string;
|
|
59
|
+
/**
|
|
60
|
+
* SEC-008: run with NO pairing gate. Requires `openReason`.
|
|
61
|
+
*
|
|
62
|
+
* Omitting `secret` used to mean this implicitly, which is how a remote peer reached the session
|
|
63
|
+
* because a field was left unset. It is still a legitimate mode — loopback, tests — but it is now
|
|
64
|
+
* a thing the host says rather than a thing that happens.
|
|
65
|
+
*/
|
|
66
|
+
readonly open?: boolean;
|
|
67
|
+
/** SEC-008: why running with no pairing gate is correct here. Required when `open` is true. */
|
|
68
|
+
readonly openReason?: string;
|
|
69
|
+
/** REMOTE-008: fired when pairing accepts + the session is exposed (host lifecycle → status 'paired'). Carries the first-pair result (E4 uses its sessionKey). */
|
|
70
|
+
readonly onPaired?: (result?: IPairingResult) => void;
|
|
71
|
+
/** REMOTE-008: fired when pairing rejects/times out (host lifecycle → teardown; the channel is already closed). */
|
|
72
|
+
readonly onPairingFailed?: () => void;
|
|
73
|
+
/** REMOTE-012 E3: host reconnect/enrollment config. When set, the gate admits first-pair (with enrollment) OR a pinned-device reconnect. */
|
|
74
|
+
readonly reconnect?: IHostReconnectConfig;
|
|
75
|
+
/** SEC-010 (#1810): require a guarded-rendezvous nonce before the session is exposed. Absent → unchanged. */
|
|
76
|
+
readonly localPeer?: ILocalPeerProof;
|
|
77
|
+
/** Ask the receiving operator before each connection reaches the session. Absent → unchanged. */
|
|
78
|
+
readonly connectionApproval?: IConnectionApproval;
|
|
79
|
+
/** 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. */
|
|
80
|
+
readonly resumeBridge?: SessionResumeBridge;
|
|
81
|
+
/** 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`). */
|
|
82
|
+
readonly onDropped?: () => void;
|
|
83
|
+
/** Observe an outbound session-event delivery failure before the carrier drops. */
|
|
84
|
+
readonly onDeliveryError?: (error: Error, event: string) => void;
|
|
85
|
+
/** Test seam: inject the `node-datachannel` module (defaults to the real lazy loader). */
|
|
86
|
+
readonly loadDataChannel?: () => IDataChannelModule;
|
|
87
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { HISTORY_PAGE_MAX_BYTES, 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
|
+
|
|
9
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
10
|
+
|
|
11
|
+
import { createChannelDelivery } from './channel-delivery.js';
|
|
12
|
+
import { whenRemoteCertificateVerified } from './negotiated-certificate.js';
|
|
13
|
+
import { RtcPeer, type IRtcCandidate, type RtcChannel } from './rtc-peer.js';
|
|
14
|
+
import { PairingGate } from './pairing-gate.js';
|
|
15
|
+
import { createTransportLifecycleError } from './transport-lifecycle-error.js';
|
|
16
|
+
import { WebRtcDeliveryLifecycle } from './webrtc-delivery-lifecycle.js';
|
|
17
|
+
import type { IWebRtcTransportOptions } from './webrtc-transport-options.js';
|
|
18
|
+
|
|
19
|
+
/** Frames a peer may send before the gate exists; the pairing handshake needs only a few. */
|
|
20
|
+
const MAX_PENDING_FRAMES = 16;
|
|
21
|
+
/** Local ICE candidates held until the offer is out. */
|
|
22
|
+
const MAX_PENDING_CANDIDATES = 64;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WebRTC P2P transport (REMOTE-001/002): carries an `IProtocolSession` over an `RTCDataChannel` using the
|
|
26
|
+
* SAME transport-neutral session bridge as the WebSocket transport (`createSessionMessageHandler` from
|
|
27
|
+
* `@robota-sdk/agent-transport`). The host is the offerer: it creates the data channel + offer and wires the
|
|
28
|
+
* channel as soon as it is created. `defaultEnabled: false` — nothing starts it automatically. Admission is
|
|
29
|
+
* fixed at construction: a pairing `secret` gates the session behind the pairing handshake, and running
|
|
30
|
+
* without one requires `open: true` with an `openReason`. The signaling client is injected and can be an
|
|
31
|
+
* in-memory loopback for tests.
|
|
32
|
+
*/
|
|
33
|
+
export class WebRtcTransport implements IConfigurableTransport<IProtocolSession> {
|
|
34
|
+
public readonly name = 'webrtc';
|
|
35
|
+
public readonly lifecycle = Object.freeze({ kind: 'service' as const });
|
|
36
|
+
public readonly defaultEnabled = false;
|
|
37
|
+
public readonly optionsSchema = {} as const;
|
|
38
|
+
|
|
39
|
+
private session?: IProtocolSession;
|
|
40
|
+
private peer?: RtcPeer;
|
|
41
|
+
private unsubscribeSignal?: () => void;
|
|
42
|
+
private cleanupHandler?: () => void;
|
|
43
|
+
/** Invalidates pending async startup work and scopes pairing/drop state to one start generation. */
|
|
44
|
+
private generation = 0;
|
|
45
|
+
/** Local DTLS fingerprint captured for pairing channel binding. */
|
|
46
|
+
private localFingerprint?: string;
|
|
47
|
+
/** Pairing gate for the current channel. */
|
|
48
|
+
private pairingGate?: PairingGate;
|
|
49
|
+
/** Whether this start generation has sent its offer; local candidates wait for it. */
|
|
50
|
+
private offerSent = false;
|
|
51
|
+
private localCandidates: IRtcCandidate[] = [];
|
|
52
|
+
/** Whether this start generation has taken its one answer; any later answer is ignored. */
|
|
53
|
+
private answered = false;
|
|
54
|
+
/** Pre-gate channel frames, replayed into the gate once it exists. Bounded. */
|
|
55
|
+
private pendingFrames: string[] = [];
|
|
56
|
+
private stopAwaitingCertificate?: () => void;
|
|
57
|
+
private readonly deliveryLifecycle: WebRtcDeliveryLifecycle;
|
|
58
|
+
|
|
59
|
+
public constructor(private readonly options: IWebRtcTransportOptions) {
|
|
60
|
+
this.deliveryLifecycle = new WebRtcDeliveryLifecycle({
|
|
61
|
+
cleanup: () => {
|
|
62
|
+
this.cleanupHandler?.();
|
|
63
|
+
this.pairingGate?.cleanup();
|
|
64
|
+
},
|
|
65
|
+
onDropped: () => this.options.onDropped?.(),
|
|
66
|
+
onDeliveryError: (error, event) => this.options.onDeliveryError?.(error, event),
|
|
67
|
+
});
|
|
68
|
+
// A pairing secret and explicit open admission are contradictory, so fail before signaling.
|
|
69
|
+
if (this.options.secret && this.options.open === true) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'WebRtcTransport: `secret` and `open: true` are contradictory. A pairing secret gates the ' +
|
|
72
|
+
'data channel; `open` runs without a gate. Pass one.',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (this.options.secret === undefined || this.options.secret === '') {
|
|
76
|
+
if (this.options.open !== true) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'WebRtcTransport: no pairing `secret` and no explicit `open`. Pass a `secret` to gate the ' +
|
|
79
|
+
'data channel, or `{ open: true, openReason: "…" }` to run without pairing on purpose.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
// WebRTC has no bearer credential; use the shared seam only to validate the open reason.
|
|
83
|
+
void resolveAdmission({
|
|
84
|
+
open: true,
|
|
85
|
+
...(this.options.openReason !== undefined ? { openReason: this.options.openReason } : {}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public validateOptions(): boolean {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public attach(session: IProtocolSession): void {
|
|
95
|
+
this.session = session;
|
|
96
|
+
}
|
|
97
|
+
private createPeer(): RtcPeer {
|
|
98
|
+
return new RtcPeer({
|
|
99
|
+
...(this.options.iceServers ? { iceServers: this.options.iceServers } : {}),
|
|
100
|
+
...(this.options.forceTurn ? { forceTurn: true } : {}),
|
|
101
|
+
...(this.options.loadDataChannel ? { loadDataChannel: this.options.loadDataChannel } : {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private wireSignaling(
|
|
106
|
+
peer: RtcPeer,
|
|
107
|
+
channel: RtcChannel,
|
|
108
|
+
session: IProtocolSession,
|
|
109
|
+
generation: number,
|
|
110
|
+
): void {
|
|
111
|
+
const signaling = this.options.signaling;
|
|
112
|
+
peer.onLocalCandidate((candidate) => {
|
|
113
|
+
if (generation !== this.generation || peer !== this.peer) return;
|
|
114
|
+
// A candidate can be gathered before the offer is out; it follows the offer, never precedes it.
|
|
115
|
+
if (this.offerSent) this.sendCandidate(candidate);
|
|
116
|
+
else if (this.localCandidates.length < MAX_PENDING_CANDIDATES)
|
|
117
|
+
this.localCandidates.push(candidate);
|
|
118
|
+
});
|
|
119
|
+
let signalChain: Promise<void> = Promise.resolve();
|
|
120
|
+
this.unsubscribeSignal = signaling.onSignal((message) => {
|
|
121
|
+
if (generation !== this.generation) return;
|
|
122
|
+
signalChain = signalChain
|
|
123
|
+
.then(async () => {
|
|
124
|
+
if (generation !== this.generation || peer !== this.peer) return;
|
|
125
|
+
if (message.kind === 'answer') {
|
|
126
|
+
// One answer per start: the one fingerprint checked is the one the binding names.
|
|
127
|
+
if (this.answered) return;
|
|
128
|
+
this.answered = true;
|
|
129
|
+
const algorithm = this.remoteFingerprintAlgorithm(channel, message.data);
|
|
130
|
+
if (this.options.secret && algorithm === undefined) return;
|
|
131
|
+
const sdp = (message.data as { sdp?: unknown } | null)?.sdp;
|
|
132
|
+
if (typeof sdp !== 'string') return;
|
|
133
|
+
peer.acceptAnswer(sdp);
|
|
134
|
+
if (algorithm !== undefined)
|
|
135
|
+
this.awaitVerifiedCertificate(peer, channel, session, algorithm, generation);
|
|
136
|
+
} else if (message.kind === 'ice') {
|
|
137
|
+
const candidate = toRemoteCandidate(message.data);
|
|
138
|
+
if (candidate !== undefined) peer.addRemoteCandidate(candidate);
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
.catch(() => {
|
|
142
|
+
// A description the connection refuses ends this start, never with an unhandled rejection.
|
|
143
|
+
if (generation === this.generation) this.failPairing(channel);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private sendCandidate(candidate: IRtcCandidate): void {
|
|
149
|
+
this.options.signaling.send({
|
|
150
|
+
kind: 'ice',
|
|
151
|
+
data: { candidate: candidate.candidate, sdpMid: candidate.mid, sdpMLineIndex: 0 },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private requireCurrentPeer(peer: RtcPeer, generation: number): void {
|
|
156
|
+
if (generation === this.generation && this.peer === peer) return;
|
|
157
|
+
peer.close();
|
|
158
|
+
throw new Error('WebRtcTransport startup was stopped.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public async start(): Promise<void> {
|
|
162
|
+
const session = this.session;
|
|
163
|
+
if (!session) throw createTransportLifecycleError('not-attached');
|
|
164
|
+
if (this.peer) throw createTransportLifecycleError('already-started');
|
|
165
|
+
const generation = ++this.generation;
|
|
166
|
+
this.deliveryLifecycle.reset(generation);
|
|
167
|
+
this.answered = false;
|
|
168
|
+
this.offerSent = false;
|
|
169
|
+
this.localCandidates = [];
|
|
170
|
+
this.pendingFrames = [];
|
|
171
|
+
|
|
172
|
+
const peer = this.createPeer();
|
|
173
|
+
this.peer = peer;
|
|
174
|
+
const signaling = this.options.signaling;
|
|
175
|
+
const channel = peer.createDataChannel('agent-session');
|
|
176
|
+
this.wireChannel(channel, session, generation);
|
|
177
|
+
this.wireSignaling(peer, channel, session, generation);
|
|
178
|
+
|
|
179
|
+
const sdp = await peer.createOffer();
|
|
180
|
+
this.requireCurrentPeer(peer, generation);
|
|
181
|
+
// Capture the local DTLS fingerprint for the pairing channel-binding (offer SDP).
|
|
182
|
+
if (this.options.secret) this.localFingerprint = extractDtlsFingerprint(sdp);
|
|
183
|
+
signaling.send({ kind: 'offer', data: { type: 'offer', sdp } });
|
|
184
|
+
this.offerSent = true;
|
|
185
|
+
for (const candidate of this.localCandidates.splice(0)) this.sendCandidate(candidate);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* With a pairing secret, the answer must advertise exactly one DTLS fingerprint; its algorithm is the one the
|
|
190
|
+
* verified certificate is hashed with. A refused answer fails pairing and closes the channel.
|
|
191
|
+
*/
|
|
192
|
+
private remoteFingerprintAlgorithm(channel: RtcChannel, answer: unknown): string | undefined {
|
|
193
|
+
if (!this.options.secret) return undefined;
|
|
194
|
+
const sdp = (answer as { sdp?: unknown }).sdp;
|
|
195
|
+
try {
|
|
196
|
+
if (typeof sdp !== 'string') throw new Error('answer carries no SDP');
|
|
197
|
+
return extractDtlsFingerprintAttribute(sdp).algorithm;
|
|
198
|
+
} catch {
|
|
199
|
+
this.failPairing(channel);
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private failPairing(channel: RtcChannel): void {
|
|
205
|
+
channel.close();
|
|
206
|
+
this.options.onPairingFailed?.();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build the pairing gate once the DTLS layer has verified the remote certificate. */
|
|
210
|
+
private awaitVerifiedCertificate(
|
|
211
|
+
peer: RtcPeer,
|
|
212
|
+
channel: RtcChannel,
|
|
213
|
+
session: IProtocolSession,
|
|
214
|
+
algorithm: string,
|
|
215
|
+
generation: number,
|
|
216
|
+
): void {
|
|
217
|
+
this.stopAwaitingCertificate = whenRemoteCertificateVerified(
|
|
218
|
+
peer,
|
|
219
|
+
algorithm,
|
|
220
|
+
(remoteFingerprint) => {
|
|
221
|
+
if (generation !== this.generation) return;
|
|
222
|
+
// The gate speaks first, so it starts once the channel can carry its frame.
|
|
223
|
+
if (channel.readyState === 'open') {
|
|
224
|
+
this.startPairing(channel, session, remoteFingerprint, generation);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const unsubscribe = channel.onStateChange((state) => {
|
|
228
|
+
if (state !== 'open' || generation !== this.generation) return;
|
|
229
|
+
unsubscribe();
|
|
230
|
+
this.startPairing(channel, session, remoteFingerprint, generation);
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
() => {
|
|
234
|
+
if (generation === this.generation) this.failPairing(channel);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private startPairing(
|
|
240
|
+
channel: RtcChannel,
|
|
241
|
+
session: IProtocolSession,
|
|
242
|
+
remoteFingerprint: string,
|
|
243
|
+
generation: number,
|
|
244
|
+
): void {
|
|
245
|
+
const secret = this.options.secret;
|
|
246
|
+
if (!secret || !this.localFingerprint) return;
|
|
247
|
+
this.pairingGate = new PairingGate({
|
|
248
|
+
cryptoContext: this.options.cryptoContext,
|
|
249
|
+
channel: { send: (d) => channel.send(d), close: () => channel.close() },
|
|
250
|
+
session,
|
|
251
|
+
secret,
|
|
252
|
+
role: 'initiator',
|
|
253
|
+
localFingerprint: this.localFingerprint,
|
|
254
|
+
remoteFingerprint,
|
|
255
|
+
onAccept: (result) => {
|
|
256
|
+
if (generation !== this.generation) return;
|
|
257
|
+
this.deliveryLifecycle.accept(generation);
|
|
258
|
+
this.options.onPaired?.(result);
|
|
259
|
+
},
|
|
260
|
+
...(this.options.onPairingFailed ? { onReject: this.options.onPairingFailed } : {}),
|
|
261
|
+
...(this.options.reconnect ? { reconnect: this.options.reconnect } : {}),
|
|
262
|
+
...(this.options.localPeer ? { localPeer: this.options.localPeer } : {}),
|
|
263
|
+
...(this.options.connectionApproval
|
|
264
|
+
? { connectionApproval: this.options.connectionApproval }
|
|
265
|
+
: {}),
|
|
266
|
+
...(this.options.resumeBridge ? { resumeBridge: this.options.resumeBridge } : {}),
|
|
267
|
+
...(this.options.personalUsageReporter
|
|
268
|
+
? { personalUsageReporter: this.options.personalUsageReporter }
|
|
269
|
+
: {}),
|
|
270
|
+
...(this.options.usageReporter ? { usageReporter: this.options.usageReporter } : {}),
|
|
271
|
+
...(this.options.storedSessionUsageReporter
|
|
272
|
+
? { storedSessionUsageReporter: this.options.storedSessionUsageReporter }
|
|
273
|
+
: {}),
|
|
274
|
+
surface: 'remote',
|
|
275
|
+
// #3282 §4 part b-2: WebRTC proves no locality; installing a plugin refuses it.
|
|
276
|
+
commandSurfaceLocality: 'remote',
|
|
277
|
+
onDeliveryError: (error, event) =>
|
|
278
|
+
this.deliveryLifecycle.handleFailure(channel, generation, error, event),
|
|
279
|
+
});
|
|
280
|
+
const gate = this.pairingGate;
|
|
281
|
+
for (const frame of this.pendingFrames.splice(0)) gate.onInbound(frame);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private wireChannel(channel: RtcChannel, session: IProtocolSession, generation: number): void {
|
|
285
|
+
// Subscribe eagerly: a remote's first frame is not buffered before a listener exists.
|
|
286
|
+
// With a secret the gate keeps pre-accept non-pairing frames from the session; otherwise the session is exposed directly.
|
|
287
|
+
if (this.options.secret) {
|
|
288
|
+
channel.onMessage((frame) => {
|
|
289
|
+
if (generation !== this.generation) return;
|
|
290
|
+
if (this.pairingGate) this.pairingGate.onInbound(frame);
|
|
291
|
+
else if (this.pendingFrames.length < MAX_PENDING_FRAMES) this.pendingFrames.push(frame);
|
|
292
|
+
});
|
|
293
|
+
// A post-accept close detaches the resume bridge and starts reconnect without ending the session.
|
|
294
|
+
channel.onStateChange((state) => {
|
|
295
|
+
if (generation !== this.generation) return;
|
|
296
|
+
if (state === 'closed') {
|
|
297
|
+
// Before acceptance the gate must hear it too: a question still open with the operator is
|
|
298
|
+
// about a connection that no longer exists.
|
|
299
|
+
this.pairingGate?.onChannelClosed();
|
|
300
|
+
this.deliveryLifecycle.handleDrop(generation);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
this.cleanupHandler = () => this.pairingGate?.cleanup();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ARCH-030: the transport is the carrier on the no-secret branch — its own sink, its own lifecycle.
|
|
308
|
+
const { onMessage, cleanup } = createSessionMessageHandler({
|
|
309
|
+
session,
|
|
310
|
+
deliver: createChannelDelivery(channel, (error, event) =>
|
|
311
|
+
this.deliveryLifecycle.handleFailure(channel, generation, error, event),
|
|
312
|
+
),
|
|
313
|
+
...(this.options.personalUsageReporter
|
|
314
|
+
? { personalUsageReporter: this.options.personalUsageReporter }
|
|
315
|
+
: {}),
|
|
316
|
+
...(this.options.usageReporter ? { usageReporter: this.options.usageReporter } : {}),
|
|
317
|
+
...(this.options.storedSessionUsageReporter
|
|
318
|
+
? { storedSessionUsageReporter: this.options.storedSessionUsageReporter }
|
|
319
|
+
: {}),
|
|
320
|
+
surface: 'remote',
|
|
321
|
+
// #3282 §4 part b-2: WebRTC proves no locality; installing a plugin refuses it.
|
|
322
|
+
commandSurfaceLocality: 'remote',
|
|
323
|
+
// A data channel carries a fraction of the inbound limit (see the pairing-gate branch).
|
|
324
|
+
maxFrameBytes: HISTORY_PAGE_MAX_BYTES,
|
|
325
|
+
});
|
|
326
|
+
this.cleanupHandler = cleanup;
|
|
327
|
+
channel.onMessage((frame) => {
|
|
328
|
+
if (generation !== this.generation) return;
|
|
329
|
+
onMessage(frame);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
public async stop(): Promise<void> {
|
|
334
|
+
this.generation += 1;
|
|
335
|
+
this.cleanupHandler?.();
|
|
336
|
+
this.unsubscribeSignal?.();
|
|
337
|
+
this.cleanupHandler = undefined;
|
|
338
|
+
this.unsubscribeSignal = undefined;
|
|
339
|
+
this.pairingGate = undefined;
|
|
340
|
+
this.localFingerprint = undefined;
|
|
341
|
+
this.answered = false;
|
|
342
|
+
this.offerSent = false;
|
|
343
|
+
this.localCandidates = [];
|
|
344
|
+
this.pendingFrames = [];
|
|
345
|
+
this.stopAwaitingCertificate?.();
|
|
346
|
+
this.stopAwaitingCertificate = undefined;
|
|
347
|
+
this.deliveryLifecycle.reset(this.generation);
|
|
348
|
+
if (this.peer) {
|
|
349
|
+
this.peer.close();
|
|
350
|
+
this.peer = undefined;
|
|
351
|
+
}
|
|
352
|
+
this.session = undefined;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A remote ICE candidate as a browser sends it (`RTCIceCandidateInit`), or `undefined` for none. */
|
|
357
|
+
function toRemoteCandidate(data: unknown): { candidate: string; mid: string } | undefined {
|
|
358
|
+
if (typeof data !== 'object' || data === null) return undefined;
|
|
359
|
+
const record = data as { candidate?: unknown; sdpMid?: unknown; sdpMLineIndex?: unknown };
|
|
360
|
+
if (typeof record.candidate !== 'string' || record.candidate.length === 0) return undefined;
|
|
361
|
+
const mid =
|
|
362
|
+
typeof record.sdpMid === 'string'
|
|
363
|
+
? record.sdpMid
|
|
364
|
+
: typeof record.sdpMLineIndex === 'number'
|
|
365
|
+
? String(record.sdpMLineIndex)
|
|
366
|
+
: '0';
|
|
367
|
+
return { candidate: record.candidate, mid };
|
|
368
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link IMeshRelay} over a WebSocket to the self-hosted signaling relay's `presence` / `message`
|
|
3
|
+
* frames.
|
|
4
|
+
*
|
|
5
|
+
* Frames sent before the socket opens are queued and flushed after the presence declaration, so a
|
|
6
|
+
* message is never sent from a connection the relay does not yet consider present. A refusal from the
|
|
7
|
+
* relay, a socket error, or a close the caller did not ask for goes to `onError` — never a silent
|
|
8
|
+
* degrade.
|
|
9
|
+
*/
|
|
10
|
+
import WebSocket from 'ws';
|
|
11
|
+
|
|
12
|
+
import type { IMeshRelay } from './mesh-relay.js';
|
|
13
|
+
import type { IWebSocketLike } from './ws-signaling-client.js';
|
|
14
|
+
|
|
15
|
+
export interface IWsMeshRelayClientOptions {
|
|
16
|
+
/** Relay URL, e.g. `wss://relay.example`. */
|
|
17
|
+
readonly url: string;
|
|
18
|
+
readonly onError?: (error: Error) => void;
|
|
19
|
+
/** Called each time the relay confirms a presence declaration. */
|
|
20
|
+
readonly onPresent?: () => void;
|
|
21
|
+
/** Injectable socket factory (defaults to a real `ws` WebSocket) — for tests. */
|
|
22
|
+
readonly createSocket?: (url: string) => IWebSocketLike;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const WS_OPEN = 1;
|
|
26
|
+
/** Messages queued before the socket opens; a connection attempt has a handful. */
|
|
27
|
+
const MAX_OUTBOX = 256;
|
|
28
|
+
|
|
29
|
+
export class WsMeshRelayClient implements IMeshRelay {
|
|
30
|
+
private readonly socket: IWebSocketLike;
|
|
31
|
+
private readonly messages = new Set<(topic: string, data: unknown) => void>();
|
|
32
|
+
private readonly absents = new Set<(topic: string) => void>();
|
|
33
|
+
private readonly outbox: string[] = [];
|
|
34
|
+
private topics: readonly string[] | undefined;
|
|
35
|
+
private closed = false;
|
|
36
|
+
|
|
37
|
+
public constructor(private readonly options: IWsMeshRelayClientOptions) {
|
|
38
|
+
const factory =
|
|
39
|
+
options.createSocket ?? ((url: string) => new WebSocket(url) as unknown as IWebSocketLike);
|
|
40
|
+
this.socket = factory(options.url);
|
|
41
|
+
this.socket.on('open', () => this.flush());
|
|
42
|
+
this.socket.on('message', (data) => this.receive(data));
|
|
43
|
+
this.socket.on('error', (error) =>
|
|
44
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error))),
|
|
45
|
+
);
|
|
46
|
+
this.socket.on('close', () => {
|
|
47
|
+
if (!this.closed) this.options.onError?.(new Error('signaling relay connection closed'));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private flush(): void {
|
|
52
|
+
if (this.topics !== undefined) this.write({ type: 'presence', topics: this.topics });
|
|
53
|
+
for (const raw of this.outbox.splice(0)) this.socket.send(raw);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private write(frame: unknown): void {
|
|
57
|
+
this.socket.send(JSON.stringify(frame));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private receive(data: unknown): void {
|
|
61
|
+
let frame: unknown;
|
|
62
|
+
try {
|
|
63
|
+
frame = JSON.parse(typeof data === 'string' ? data : String(data));
|
|
64
|
+
} catch {
|
|
65
|
+
return; // the relay only emits JSON
|
|
66
|
+
}
|
|
67
|
+
if (typeof frame !== 'object' || frame === null) return;
|
|
68
|
+
const record = frame as Record<string, unknown>;
|
|
69
|
+
if (record.type === 'message' && typeof record.topic === 'string') {
|
|
70
|
+
for (const handler of this.messages) handler(record.topic, record.data);
|
|
71
|
+
} else if (record.type === 'absent' && typeof record.topic === 'string') {
|
|
72
|
+
for (const handler of this.absents) handler(record.topic);
|
|
73
|
+
} else if (record.type === 'present') {
|
|
74
|
+
this.options.onPresent?.();
|
|
75
|
+
} else if (record.type === 'error') {
|
|
76
|
+
this.options.onError?.(
|
|
77
|
+
new Error(`signaling relay refused a frame: ${String(record.reason)}`),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public declarePresence(topics: readonly string[]): void {
|
|
83
|
+
if (this.closed) return;
|
|
84
|
+
this.topics = [...topics];
|
|
85
|
+
if (this.socket.readyState === WS_OPEN) this.write({ type: 'presence', topics: this.topics });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public send(topic: string, data: unknown): void {
|
|
89
|
+
if (this.closed) return;
|
|
90
|
+
const raw = JSON.stringify({ type: 'message', topic, data });
|
|
91
|
+
if (this.socket.readyState === WS_OPEN) {
|
|
92
|
+
this.socket.send(raw);
|
|
93
|
+
} else if (this.outbox.length < MAX_OUTBOX) {
|
|
94
|
+
this.outbox.push(raw);
|
|
95
|
+
} else {
|
|
96
|
+
this.options.onError?.(new Error('signaling relay is not reachable; message dropped'));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public onMessage(handler: (topic: string, data: unknown) => void): () => void {
|
|
101
|
+
this.messages.add(handler);
|
|
102
|
+
return () => this.messages.delete(handler);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public onAbsent(handler: (topic: string) => void): () => void {
|
|
106
|
+
this.absents.add(handler);
|
|
107
|
+
return () => this.absents.delete(handler);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public close(): void {
|
|
111
|
+
this.closed = true;
|
|
112
|
+
this.messages.clear();
|
|
113
|
+
this.absents.clear();
|
|
114
|
+
this.outbox.length = 0;
|
|
115
|
+
this.socket.close();
|
|
116
|
+
}
|
|
117
|
+
}
|