@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
package/src/rtc-peer.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One WebRTC peer connection and its data channels, over `node-datachannel`.
|
|
3
|
+
*
|
|
4
|
+
* The native binding takes a single callback per event and aborts the process when a callback throws,
|
|
5
|
+
* so every callback here is registered once, fans out to handlers, and never lets an exception reach
|
|
6
|
+
* the binding: a throwing handler closes the connection instead. Negotiation is explicit (no automatic
|
|
7
|
+
* offers or answers), so a connection produces exactly the descriptions its owner asks for, and no ICE
|
|
8
|
+
* server is contacted unless one is configured.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
loadDataChannel,
|
|
12
|
+
type IDataChannelModule,
|
|
13
|
+
type INdcDataChannel,
|
|
14
|
+
type INdcIceServer,
|
|
15
|
+
type INdcPeerConnection,
|
|
16
|
+
} from './datachannel-loader.js';
|
|
17
|
+
|
|
18
|
+
import type { IIceServer } from './webrtc-transport-options.js';
|
|
19
|
+
|
|
20
|
+
export type TRtcPeerState =
|
|
21
|
+
'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
|
22
|
+
export type TRtcChannelState = 'connecting' | 'open' | 'closed';
|
|
23
|
+
|
|
24
|
+
/** Why and how a connection or channel ended, for a refusal a person can act on. */
|
|
25
|
+
export interface IRtcPeerDiagnostics {
|
|
26
|
+
readonly state: TRtcPeerState;
|
|
27
|
+
readonly ice?: string;
|
|
28
|
+
readonly gathering?: string;
|
|
29
|
+
readonly signaling?: string;
|
|
30
|
+
/** The states the connection went through, in order. */
|
|
31
|
+
readonly history: readonly TRtcPeerState[];
|
|
32
|
+
/** `local`: this side closed it; `handler-error`: a handler threw and the connection was closed. */
|
|
33
|
+
readonly closedBy?: 'local' | 'handler-error';
|
|
34
|
+
readonly handlerError?: string;
|
|
35
|
+
readonly localCandidates: number;
|
|
36
|
+
readonly remoteCandidates: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MAX_HISTORY = 16;
|
|
40
|
+
|
|
41
|
+
function reading(read: (() => string) | undefined): string | undefined {
|
|
42
|
+
try {
|
|
43
|
+
return read?.();
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function describeError(error: unknown): string {
|
|
50
|
+
return error instanceof Error ? error.message : String(error);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IRtcCandidate {
|
|
54
|
+
readonly candidate: string;
|
|
55
|
+
readonly mid: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface IRtcPeerOptions {
|
|
59
|
+
readonly iceServers?: readonly IIceServer[];
|
|
60
|
+
/** Relay (TURN) candidates only. */
|
|
61
|
+
readonly forceTurn?: boolean;
|
|
62
|
+
/** Test seam: the `node-datachannel` module. */
|
|
63
|
+
readonly loadDataChannel?: () => IDataChannelModule;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How long a closed channel's stream stays up. The implementation resets the stream as soon as it is
|
|
68
|
+
* told to close, and a message sent just before is then lost to the peer.
|
|
69
|
+
*/
|
|
70
|
+
const CHANNEL_CLOSE_GRACE_MS = 250;
|
|
71
|
+
|
|
72
|
+
/** Remote candidates kept while the remote description is still on its way. */
|
|
73
|
+
const MAX_EARLY_CANDIDATES = 64;
|
|
74
|
+
|
|
75
|
+
const DEFAULT_PORTS: Readonly<Record<string, number>> = { stun: 3478, turn: 3478, turns: 5349 };
|
|
76
|
+
|
|
77
|
+
/** One configured ICE server in the binding's form. Throws on a url it cannot represent. */
|
|
78
|
+
export function toNativeIceServer(server: IIceServer): string | INdcIceServer {
|
|
79
|
+
const match =
|
|
80
|
+
/^(stuns?|turns?):([^:?]+|\[[^\]]+\])(?::(\d{1,5}))?(?:\?transport=(udp|tcp))?$/i.exec(
|
|
81
|
+
server.urls,
|
|
82
|
+
);
|
|
83
|
+
if (!match) throw new Error(`unsupported ICE server url: ${server.urls}`);
|
|
84
|
+
const scheme = match[1]!.toLowerCase();
|
|
85
|
+
const hostname = match[2]!.replace(/^\[|\]$/g, '');
|
|
86
|
+
const port = match[3] !== undefined ? Number(match[3]) : (DEFAULT_PORTS[scheme] ?? 3478);
|
|
87
|
+
if (scheme === 'stun' || scheme === 'stuns') return `stun:${hostname}:${port}`;
|
|
88
|
+
return {
|
|
89
|
+
hostname,
|
|
90
|
+
port,
|
|
91
|
+
...(server.username !== undefined ? { username: server.username } : {}),
|
|
92
|
+
...(server.credential !== undefined ? { password: server.credential } : {}),
|
|
93
|
+
relayType:
|
|
94
|
+
scheme === 'turns' ? 'TurnTls' : match[4]?.toLowerCase() === 'tcp' ? 'TurnTcp' : 'TurnUdp',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function textOf(message: string | Buffer | ArrayBuffer): string {
|
|
99
|
+
if (typeof message === 'string') return message;
|
|
100
|
+
return Buffer.from(message as ArrayBuffer).toString('utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class RtcChannel {
|
|
104
|
+
private stateValue: TRtcChannelState;
|
|
105
|
+
private closeCauseValue?: string;
|
|
106
|
+
private closedLocally = false;
|
|
107
|
+
private readonly messageHandlers = new Set<(text: string) => void>();
|
|
108
|
+
private readonly stateHandlers = new Set<(state: TRtcChannelState) => void>();
|
|
109
|
+
|
|
110
|
+
public constructor(
|
|
111
|
+
private readonly native: INdcDataChannel,
|
|
112
|
+
private readonly fault: (error: unknown) => void,
|
|
113
|
+
) {
|
|
114
|
+
this.stateValue = native.isOpen() ? 'open' : 'connecting';
|
|
115
|
+
native.onOpen(() => this.guard(() => this.setState('open')));
|
|
116
|
+
native.onClosed(() => this.guard(() => this.closedWith('closed by the peer or the transport')));
|
|
117
|
+
native.onError((error) => this.guard(() => this.closedWith(`error: ${error}`)));
|
|
118
|
+
native.onMessage((message) =>
|
|
119
|
+
this.guard(() => {
|
|
120
|
+
// Closed here, the stream only waits out its grace: nothing more is taken from it.
|
|
121
|
+
if (this.closedLocally) return;
|
|
122
|
+
const text = textOf(message);
|
|
123
|
+
for (const handler of this.messageHandlers) handler(text);
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private guard(action: () => void): void {
|
|
129
|
+
try {
|
|
130
|
+
action();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// A throwing handler must not reach the native binding; the connection is closed instead.
|
|
133
|
+
this.closeCauseValue ??= `handler error: ${describeError(error)}`;
|
|
134
|
+
this.fault(error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private closedWith(cause: string): void {
|
|
139
|
+
this.closeCauseValue ??= cause;
|
|
140
|
+
this.setState('closed');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Why the channel closed, once it has. */
|
|
144
|
+
public get closeCause(): string | undefined {
|
|
145
|
+
return this.closeCauseValue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private setState(state: TRtcChannelState): void {
|
|
149
|
+
if (this.stateValue === state || this.stateValue === 'closed') return;
|
|
150
|
+
this.stateValue = state;
|
|
151
|
+
for (const handler of this.stateHandlers) handler(state);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public get readyState(): TRtcChannelState {
|
|
155
|
+
return this.stateValue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The label the opening side gave the channel; empty when the implementation does not say. */
|
|
159
|
+
public get label(): string {
|
|
160
|
+
try {
|
|
161
|
+
return this.native.getLabel?.() ?? '';
|
|
162
|
+
} catch {
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public get bufferedAmount(): number {
|
|
168
|
+
try {
|
|
169
|
+
return this.native.bufferedAmount();
|
|
170
|
+
} catch {
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Throws when the channel is not open or the message cannot be sent. */
|
|
176
|
+
public send(text: string): void {
|
|
177
|
+
if (this.stateValue !== 'open') throw new Error('data channel is not open');
|
|
178
|
+
this.native.sendMessage(text);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
public onMessage(handler: (text: string) => void): () => void {
|
|
182
|
+
this.messageHandlers.add(handler);
|
|
183
|
+
return () => this.messageHandlers.delete(handler);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public onStateChange(handler: (state: TRtcChannelState) => void): () => void {
|
|
187
|
+
this.stateHandlers.add(handler);
|
|
188
|
+
return () => this.stateHandlers.delete(handler);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Closed for this side at once; the stream is reset after a grace, so what was sent just before
|
|
193
|
+
* still reaches the peer.
|
|
194
|
+
*/
|
|
195
|
+
public close(): void {
|
|
196
|
+
if (this.stateValue === 'closed') return;
|
|
197
|
+
this.closedLocally = true;
|
|
198
|
+
const native = this.native;
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
try {
|
|
201
|
+
native.close();
|
|
202
|
+
} catch {
|
|
203
|
+
/* already closing */
|
|
204
|
+
}
|
|
205
|
+
}, CHANNEL_CLOSE_GRACE_MS).unref?.();
|
|
206
|
+
this.closedWith('closed by this side');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class RtcPeer {
|
|
211
|
+
private readonly native: INdcPeerConnection;
|
|
212
|
+
private stateValue: TRtcPeerState = 'new';
|
|
213
|
+
private readonly stateHandlers = new Set<(state: TRtcPeerState) => void>();
|
|
214
|
+
private readonly candidateHandlers = new Set<(candidate: IRtcCandidate) => void>();
|
|
215
|
+
private readonly channelHandlers = new Set<(channel: RtcChannel) => void>();
|
|
216
|
+
private readonly descriptionWaiters: ((sdp: string) => void)[] = [];
|
|
217
|
+
private hasRemoteDescription = false;
|
|
218
|
+
private readonly history: TRtcPeerState[] = [];
|
|
219
|
+
private closedBy?: 'local' | 'handler-error';
|
|
220
|
+
private handlerError?: string;
|
|
221
|
+
private localCandidateCount = 0;
|
|
222
|
+
private remoteCandidateCount = 0;
|
|
223
|
+
/**
|
|
224
|
+
* Local candidates gathered before the remote description, handed out right after it. An offerer's
|
|
225
|
+
* candidates let the answerer reach it and start DTLS; the binding makes the remote description the
|
|
226
|
+
* DTLS layer checks the peer's certificate against only after the ICE layer has it, so a handshake
|
|
227
|
+
* that arrives while the answer is being applied is refused. Holding the candidates until the
|
|
228
|
+
* answer is applied keeps the answerer from reaching this side before then.
|
|
229
|
+
*/
|
|
230
|
+
private readonly heldLocalCandidates: IRtcCandidate[] = [];
|
|
231
|
+
/** Remote candidates that arrived before the remote description, applied right after it. */
|
|
232
|
+
private readonly earlyCandidates: IRtcCandidate[] = [];
|
|
233
|
+
private closed = false;
|
|
234
|
+
|
|
235
|
+
public constructor(options: IRtcPeerOptions = {}) {
|
|
236
|
+
const module = (options.loadDataChannel ?? loadDataChannel)();
|
|
237
|
+
this.native = new module.PeerConnection('agent', {
|
|
238
|
+
iceServers: (options.iceServers ?? []).map(toNativeIceServer),
|
|
239
|
+
...(options.forceTurn === true ? { iceTransportPolicy: 'relay' as const } : {}),
|
|
240
|
+
disableAutoNegotiation: true,
|
|
241
|
+
});
|
|
242
|
+
this.native.onStateChange((state) =>
|
|
243
|
+
this.guard(() => {
|
|
244
|
+
if (state === this.stateValue || this.stateValue === 'closed') return;
|
|
245
|
+
this.stateValue = state as TRtcPeerState;
|
|
246
|
+
this.remember(this.stateValue);
|
|
247
|
+
for (const handler of this.stateHandlers) handler(this.stateValue);
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
this.native.onLocalCandidate((candidate, mid) =>
|
|
251
|
+
this.guard(() => {
|
|
252
|
+
// The binding writes the SDP attribute form; the wire form has no `a=` prefix.
|
|
253
|
+
const value = candidate.startsWith('a=') ? candidate.slice(2) : candidate;
|
|
254
|
+
if (value.length === 0) return;
|
|
255
|
+
if (!this.hasRemoteDescription) {
|
|
256
|
+
this.heldLocalCandidates.push({ candidate: value, mid });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.handOut({ candidate: value, mid });
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
this.native.onLocalDescription((sdp) =>
|
|
263
|
+
this.guard(() => {
|
|
264
|
+
for (const waiter of this.descriptionWaiters.splice(0)) waiter(sdp);
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
this.native.onDataChannel((native) =>
|
|
268
|
+
this.guard(() => {
|
|
269
|
+
const channel = new RtcChannel(native, (error) => this.fault(error));
|
|
270
|
+
for (const handler of this.channelHandlers) handler(channel);
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private guard(action: () => void): void {
|
|
276
|
+
try {
|
|
277
|
+
action();
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// A throwing handler must not reach the native binding; the connection is closed instead.
|
|
280
|
+
this.fault(error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private fault(error: unknown): void {
|
|
285
|
+
if (this.closed) return;
|
|
286
|
+
this.closedBy = 'handler-error';
|
|
287
|
+
this.handlerError = describeError(error);
|
|
288
|
+
this.close();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private remember(state: TRtcPeerState): void {
|
|
292
|
+
if (this.history.length < MAX_HISTORY) this.history.push(state);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** What the connection went through, for explaining why it ended. */
|
|
296
|
+
public diagnostics(): IRtcPeerDiagnostics {
|
|
297
|
+
const ice = reading(this.native.iceState?.bind(this.native));
|
|
298
|
+
const gathering = reading(this.native.gatheringState?.bind(this.native));
|
|
299
|
+
const signaling = reading(this.native.signalingState?.bind(this.native));
|
|
300
|
+
return {
|
|
301
|
+
state: this.stateValue,
|
|
302
|
+
...(ice !== undefined ? { ice } : {}),
|
|
303
|
+
...(gathering !== undefined ? { gathering } : {}),
|
|
304
|
+
...(signaling !== undefined ? { signaling } : {}),
|
|
305
|
+
history: [...this.history],
|
|
306
|
+
...(this.closedBy !== undefined ? { closedBy: this.closedBy } : {}),
|
|
307
|
+
...(this.handlerError !== undefined ? { handlerError: this.handlerError } : {}),
|
|
308
|
+
localCandidates: this.localCandidateCount,
|
|
309
|
+
remoteCandidates: this.remoteCandidateCount,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
public get state(): TRtcPeerState {
|
|
314
|
+
return this.stateValue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
public createDataChannel(label: string): RtcChannel {
|
|
318
|
+
return new RtcChannel(this.native.createDataChannel(label), (error) => this.fault(error));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private localDescription(type: 'offer' | 'answer'): Promise<string> {
|
|
322
|
+
return new Promise<string>((resolve, reject) => {
|
|
323
|
+
this.descriptionWaiters.push(resolve);
|
|
324
|
+
try {
|
|
325
|
+
this.native.setLocalDescription(type);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const at = this.descriptionWaiters.indexOf(resolve);
|
|
328
|
+
if (at >= 0) this.descriptionWaiters.splice(at, 1);
|
|
329
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Create and apply the local offer; resolves with its SDP. */
|
|
335
|
+
public createOffer(): Promise<string> {
|
|
336
|
+
return this.localDescription('offer');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Apply the remote offer, then create and apply the answer; resolves with its SDP. */
|
|
340
|
+
public async acceptOffer(sdp: string): Promise<string> {
|
|
341
|
+
this.native.setRemoteDescription(sdp, 'offer');
|
|
342
|
+
this.remoteDescriptionApplied();
|
|
343
|
+
return this.localDescription('answer');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
public acceptAnswer(sdp: string): void {
|
|
347
|
+
this.native.setRemoteDescription(sdp, 'answer');
|
|
348
|
+
this.remoteDescriptionApplied();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private remoteDescriptionApplied(): void {
|
|
352
|
+
this.hasRemoteDescription = true;
|
|
353
|
+
for (const candidate of this.earlyCandidates.splice(0)) this.addRemoteCandidate(candidate);
|
|
354
|
+
const held = this.heldLocalCandidates.splice(0);
|
|
355
|
+
this.guard(() => {
|
|
356
|
+
for (const candidate of held) this.handOut(candidate);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private handOut(candidate: IRtcCandidate): void {
|
|
361
|
+
if (this.closed) return;
|
|
362
|
+
this.localCandidateCount += 1;
|
|
363
|
+
for (const handler of this.candidateHandlers) handler(candidate);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Apply a remote candidate. One that arrives before the remote description waits for it (bounded);
|
|
368
|
+
* one the binding refuses is dropped.
|
|
369
|
+
*/
|
|
370
|
+
public addRemoteCandidate(candidate: IRtcCandidate): void {
|
|
371
|
+
if (this.closed) return;
|
|
372
|
+
if (!this.hasRemoteDescription) {
|
|
373
|
+
if (this.earlyCandidates.length < MAX_EARLY_CANDIDATES) this.earlyCandidates.push(candidate);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
this.native.addRemoteCandidate(candidate.candidate, candidate.mid);
|
|
378
|
+
this.remoteCandidateCount += 1;
|
|
379
|
+
} catch {
|
|
380
|
+
// allow-fallback: an unusable candidate is one path fewer, not a failure of the connection
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The fingerprint of the certificate the DTLS layer accepted, once connected; `undefined` before.
|
|
386
|
+
* The DTLS layer (OpenSSL) accepts a certificate only after verifying the peer's handshake signature
|
|
387
|
+
* with it and matching it against the remote description's fingerprint.
|
|
388
|
+
*/
|
|
389
|
+
public remoteFingerprint(): { readonly value: string; readonly algorithm: string } | undefined {
|
|
390
|
+
if (this.stateValue !== 'connected') return undefined;
|
|
391
|
+
try {
|
|
392
|
+
const fingerprint = this.native.remoteFingerprint();
|
|
393
|
+
return fingerprint.value.length > 0 ? fingerprint : undefined;
|
|
394
|
+
} catch {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
public onStateChange(handler: (state: TRtcPeerState) => void): () => void {
|
|
400
|
+
this.stateHandlers.add(handler);
|
|
401
|
+
return () => this.stateHandlers.delete(handler);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
public onLocalCandidate(handler: (candidate: IRtcCandidate) => void): () => void {
|
|
405
|
+
this.candidateHandlers.add(handler);
|
|
406
|
+
return () => this.candidateHandlers.delete(handler);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
public onDataChannel(handler: (channel: RtcChannel) => void): () => void {
|
|
410
|
+
this.channelHandlers.add(handler);
|
|
411
|
+
return () => this.channelHandlers.delete(handler);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
public close(): void {
|
|
415
|
+
if (this.closed) return;
|
|
416
|
+
this.closed = true;
|
|
417
|
+
this.closedBy ??= 'local';
|
|
418
|
+
try {
|
|
419
|
+
this.native.close();
|
|
420
|
+
} catch {
|
|
421
|
+
/* already closed */
|
|
422
|
+
}
|
|
423
|
+
if (this.stateValue !== 'closed') {
|
|
424
|
+
this.stateValue = 'closed';
|
|
425
|
+
this.remember('closed');
|
|
426
|
+
for (const handler of this.stateHandlers) handler('closed');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring an admitted channel to the session — the step immediately after the gate says yes.
|
|
3
|
+
*
|
|
4
|
+
* "How session frames travel once admission is settled" is a different subject from "what may reach
|
|
5
|
+
* the session at all", which is the gate's. Split out for the same reason the frame vocabulary and
|
|
6
|
+
* the controllers already were: `pairing-gate.ts` is at its size limit, and the rule there is to
|
|
7
|
+
* split rather than extend.
|
|
8
|
+
*
|
|
9
|
+
* Two carriers, chosen by whether a resume bridge exists. Neither decides anything about admission —
|
|
10
|
+
* by the time either runs, that question is closed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
HISTORY_PAGE_MAX_BYTES,
|
|
15
|
+
createSessionMessageHandler,
|
|
16
|
+
type ISessionMessageHandlerOptions,
|
|
17
|
+
type SessionResumeBridge,
|
|
18
|
+
} from '@robota-sdk/agent-transport';
|
|
19
|
+
|
|
20
|
+
import { createChannelDelivery } from './channel-delivery.js';
|
|
21
|
+
|
|
22
|
+
import type { IPairingChannel } from './pairing-gate-options.js';
|
|
23
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
24
|
+
|
|
25
|
+
export interface IAttachSessionOptions {
|
|
26
|
+
readonly channel: IPairingChannel;
|
|
27
|
+
readonly session: IProtocolSession;
|
|
28
|
+
readonly resumeBridge?: SessionResumeBridge;
|
|
29
|
+
readonly createHandler?: typeof createSessionMessageHandler;
|
|
30
|
+
readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
31
|
+
readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
32
|
+
readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
33
|
+
readonly surface?: ISessionMessageHandlerOptions['surface'];
|
|
34
|
+
readonly commandSurfaceLocality?: ISessionMessageHandlerOptions['commandSurfaceLocality'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IAttachedSession {
|
|
38
|
+
/** Where post-accept inbound frames go. */
|
|
39
|
+
readonly onSessionMessage: (data: string) => void;
|
|
40
|
+
/** Detach (bridge) or dispose (handler). The gate calls this on teardown. */
|
|
41
|
+
readonly cleanup: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attach the admitted channel to the session.
|
|
46
|
+
*
|
|
47
|
+
* `viaReconnect` is threaded through rather than inferred: on a reconnect the bridge must hold live
|
|
48
|
+
* forwarding until the client's `resume` replays the buffered tail, and getting that backwards
|
|
49
|
+
* delivers new frames ahead of the replay — an ordering bug that looks like data loss.
|
|
50
|
+
*/
|
|
51
|
+
export function attachSession(
|
|
52
|
+
options: IAttachSessionOptions,
|
|
53
|
+
viaReconnect: boolean,
|
|
54
|
+
onDeliveryError: (error: Error, event: string) => void,
|
|
55
|
+
): IAttachedSession {
|
|
56
|
+
const bridge = options.resumeBridge;
|
|
57
|
+
if (bridge) {
|
|
58
|
+
// REMOTE-013 E4: route the session through the persistent bridge. Attach this channel as the
|
|
59
|
+
// sink; post-accept inbound frames (incl. resume/ack) go to the bridge; cleanup DETACHES it
|
|
60
|
+
// (never disposes — the bridge is owned by the transport across reconnects).
|
|
61
|
+
bridge.attach((data) => options.channel.send(data), {
|
|
62
|
+
awaitResume: viaReconnect,
|
|
63
|
+
onDeliveryError,
|
|
64
|
+
// ARCH-030: without this the bridge's boundary has no backpressure reading, so the replay
|
|
65
|
+
// path — the burst this budget exists for — runs unbudgeted while every other outbound path
|
|
66
|
+
// on the same connection is guarded. Read at call time; `bufferedAmount` is live.
|
|
67
|
+
pendingBytes: () => options.channel.bufferedAmount,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
onSessionMessage: (data) => bridge.onClientMessage(data),
|
|
71
|
+
cleanup: () => bridge.detach(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const create = options.createHandler ?? createSessionMessageHandler;
|
|
76
|
+
// ARCH-030: the gate is the carrier on this branch — its own channel sink, its own failure policy.
|
|
77
|
+
const { onMessage, cleanup } = create({
|
|
78
|
+
session: options.session,
|
|
79
|
+
deliver: createChannelDelivery(options.channel, onDeliveryError),
|
|
80
|
+
// A data channel carries a fraction of the inbound limit; a frame over it makes `send` throw and
|
|
81
|
+
// closes the channel, so an app view or tool result larger than a history page is refused.
|
|
82
|
+
maxFrameBytes: HISTORY_PAGE_MAX_BYTES,
|
|
83
|
+
...(options.personalUsageReporter
|
|
84
|
+
? { personalUsageReporter: options.personalUsageReporter }
|
|
85
|
+
: {}),
|
|
86
|
+
...(options.usageReporter ? { usageReporter: options.usageReporter } : {}),
|
|
87
|
+
...(options.storedSessionUsageReporter
|
|
88
|
+
? { storedSessionUsageReporter: options.storedSessionUsageReporter }
|
|
89
|
+
: {}),
|
|
90
|
+
...(options.surface ? { surface: options.surface } : {}),
|
|
91
|
+
...(options.commandSurfaceLocality
|
|
92
|
+
? { commandSurfaceLocality: options.commandSurfaceLocality }
|
|
93
|
+
: {}),
|
|
94
|
+
});
|
|
95
|
+
return { onSessionMessage: onMessage, cleanup };
|
|
96
|
+
}
|
package/src/signaling.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signaling port (REMOTE-002). The WebRTC transport is decoupled from any concrete signaling server via this
|
|
3
|
+
* port: it exchanges opaque SDP/ICE blobs by rendezvous id. The port carries **only** SDP offers/answers + ICE
|
|
4
|
+
* candidates — never session content — so a signaling server (or the in-memory test pair) is content-blind.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The kinds of signal a peer exchanges through the relay. */
|
|
8
|
+
export type TSignalKind = 'offer' | 'answer' | 'ice';
|
|
9
|
+
|
|
10
|
+
/** An opaque signaling message relayed by rendezvous id. `data` is an SDP description or an ICE candidate. */
|
|
11
|
+
export interface ISignalMessage {
|
|
12
|
+
readonly kind: TSignalKind;
|
|
13
|
+
/** Opaque payload (serialized SDP or ICE candidate). The signaling layer never inspects it. */
|
|
14
|
+
readonly data: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Port a WebRTC peer uses to reach its counterpart at a rendezvous id. */
|
|
18
|
+
export interface ISignalingClient {
|
|
19
|
+
/** Send a signal to the counterpart at the rendezvous. */
|
|
20
|
+
send(message: ISignalMessage): void;
|
|
21
|
+
/** Subscribe to signals from the counterpart. Returns an unsubscribe function. */
|
|
22
|
+
onSignal(handler: (message: ISignalMessage) => void): () => void;
|
|
23
|
+
/** Tear down the signaling channel. */
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An in-process pair of {@link ISignalingClient}s wired directly to each other — for tests + loopback only
|
|
29
|
+
* (no server, no network). Each side's `send` is delivered to the other side's handlers on a microtask.
|
|
30
|
+
*/
|
|
31
|
+
export function createInMemorySignalingPair(): [ISignalingClient, ISignalingClient] {
|
|
32
|
+
const handlersA: ((m: ISignalMessage) => void)[] = [];
|
|
33
|
+
const handlersB: ((m: ISignalMessage) => void)[] = [];
|
|
34
|
+
let open = true;
|
|
35
|
+
|
|
36
|
+
function make(
|
|
37
|
+
ownHandlers: ((m: ISignalMessage) => void)[],
|
|
38
|
+
peerHandlers: ((m: ISignalMessage) => void)[],
|
|
39
|
+
): ISignalingClient {
|
|
40
|
+
return {
|
|
41
|
+
send(message) {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
queueMicrotask(() => {
|
|
44
|
+
for (const h of peerHandlers) h(message);
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
onSignal(handler) {
|
|
48
|
+
ownHandlers.push(handler);
|
|
49
|
+
return () => {
|
|
50
|
+
const i = ownHandlers.indexOf(handler);
|
|
51
|
+
if (i >= 0) ownHandlers.splice(i, 1);
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
close() {
|
|
55
|
+
open = false;
|
|
56
|
+
ownHandlers.length = 0;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [make(handlersA, handlersB), make(handlersB, handlersA)];
|
|
62
|
+
}
|