@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,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One WebRTC connection between two of one user's devices, in either role, admitted by the device
|
|
3
|
+
* handshake.
|
|
4
|
+
*
|
|
5
|
+
* The handshake is bound to the negotiated channel on both roles the same way: the remote fingerprint
|
|
6
|
+
* is read from the certificate the DTLS layer verified, never from SDP text, and the remote
|
|
7
|
+
* description must advertise exactly one fingerprint and only one is taken per link, so the certificate
|
|
8
|
+
* the DTLS layer checks and the value the binding names are one and the same.
|
|
9
|
+
*
|
|
10
|
+
* Until admission the channel carries only handshake frames. Anything that is not one — or anything
|
|
11
|
+
* after the peer's proof but before this side's verdict, beyond a small bound — is held or refused,
|
|
12
|
+
* never delivered, and every refusal closes the connection. Only an admitted link delivers messages.
|
|
13
|
+
*
|
|
14
|
+
* Files travel on data channels of their own, one per transfer, opened by the sending side once the
|
|
15
|
+
* link is admitted, so a transfer never shares a channel with messages. A file channel that arrives
|
|
16
|
+
* before admission, or beyond a small number at once, is closed unread.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
extractDtlsFingerprint,
|
|
20
|
+
extractDtlsFingerprintAttribute,
|
|
21
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
22
|
+
|
|
23
|
+
import { whenRemoteCertificateVerified } from './negotiated-certificate.js';
|
|
24
|
+
import { MAX_MESH_MESSAGE_CHARS, decodeMeshMessageFrame } from './mesh-signal.js';
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
IDeviceHandshakeController,
|
|
28
|
+
IDeviceHandshakeResult,
|
|
29
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
30
|
+
import type { IRtcPeerDiagnostics, RtcChannel, RtcPeer } from './rtc-peer.js';
|
|
31
|
+
|
|
32
|
+
/** The offerer creates the data channel and the offer; the answerer answers it. */
|
|
33
|
+
export type TMeshLinkRole = 'offerer' | 'answerer';
|
|
34
|
+
|
|
35
|
+
/** Why a link ended. `closed` is an orderly close by either side after or before admission. */
|
|
36
|
+
export type TMeshLinkEnd =
|
|
37
|
+
'closed' | 'signaling' | 'channel-binding' | 'handshake' | 'protocol' | 'timeout';
|
|
38
|
+
|
|
39
|
+
/** How far a link got before it ended. */
|
|
40
|
+
export type TMeshLinkStage =
|
|
41
|
+
'signaling' | 'connecting' | 'awaiting-channel' | 'handshake' | 'confirming' | 'admitted';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Why a link ended, with what the connection under it went through, so a refusal says more than
|
|
45
|
+
* "closed". `cause` is the underlying error when there was one (e.g. the device handshake refusal).
|
|
46
|
+
*/
|
|
47
|
+
export class MeshLinkEndedError extends Error {
|
|
48
|
+
readonly end: TMeshLinkEnd;
|
|
49
|
+
readonly stage: TMeshLinkStage;
|
|
50
|
+
readonly role: TMeshLinkRole;
|
|
51
|
+
readonly peer?: IRtcPeerDiagnostics;
|
|
52
|
+
/** Why the data channel closed, when it had. */
|
|
53
|
+
readonly channelClose?: string;
|
|
54
|
+
override readonly cause?: unknown;
|
|
55
|
+
|
|
56
|
+
constructor(details: {
|
|
57
|
+
readonly end: TMeshLinkEnd;
|
|
58
|
+
readonly stage: TMeshLinkStage;
|
|
59
|
+
readonly role: TMeshLinkRole;
|
|
60
|
+
readonly detail?: string;
|
|
61
|
+
readonly peer?: IRtcPeerDiagnostics;
|
|
62
|
+
readonly channelClose?: string;
|
|
63
|
+
readonly cause?: unknown;
|
|
64
|
+
}) {
|
|
65
|
+
const parts = [`mesh link ended (${details.end}) during ${details.stage} as ${details.role}`];
|
|
66
|
+
if (details.detail !== undefined) parts.push(details.detail);
|
|
67
|
+
if (details.cause !== undefined) {
|
|
68
|
+
parts.push(details.cause instanceof Error ? details.cause.message : String(details.cause));
|
|
69
|
+
}
|
|
70
|
+
const peer = details.peer;
|
|
71
|
+
if (peer !== undefined) {
|
|
72
|
+
parts.push(
|
|
73
|
+
`connection ${peer.state} [${peer.history.join(' > ')}], ice ${peer.ice ?? '?'}, gathering ${
|
|
74
|
+
peer.gathering ?? '?'
|
|
75
|
+
}, candidates ${peer.localCandidates} sent / ${peer.remoteCandidates} applied, closed by ${
|
|
76
|
+
peer.closedBy ?? 'the peer or the network'
|
|
77
|
+
}${peer.handlerError !== undefined ? ` (${peer.handlerError})` : ''}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (details.channelClose !== undefined) parts.push(`channel ${details.channelClose}`);
|
|
81
|
+
super(parts.join('; '));
|
|
82
|
+
this.name = 'MeshLinkEndedError';
|
|
83
|
+
this.end = details.end;
|
|
84
|
+
this.stage = details.stage;
|
|
85
|
+
this.role = details.role;
|
|
86
|
+
if (peer !== undefined) this.peer = peer;
|
|
87
|
+
if (details.channelClose !== undefined) this.channelClose = details.channelClose;
|
|
88
|
+
if (details.cause !== undefined) this.cause = details.cause;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type TMeshLinkSignal =
|
|
93
|
+
| { readonly kind: 'offer' | 'answer'; readonly sdp: string }
|
|
94
|
+
| {
|
|
95
|
+
readonly kind: 'ice';
|
|
96
|
+
readonly candidate: { candidate: string; sdpMid?: string; sdpMLineIndex?: number };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The DTLS fingerprints of one admitted connection, as each side sees them. */
|
|
100
|
+
export interface IMeshChannelBinding {
|
|
101
|
+
readonly localFingerprint: string;
|
|
102
|
+
/** Of the certificate the DTLS layer verified. */
|
|
103
|
+
readonly remoteFingerprint: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface IMeshHandshakeBinding {
|
|
107
|
+
readonly localFingerprint: string;
|
|
108
|
+
/** Of the certificate the DTLS layer verified. */
|
|
109
|
+
readonly remoteFingerprint: string;
|
|
110
|
+
readonly send: (frame: unknown) => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface IMeshPeerLinkOptions {
|
|
114
|
+
readonly role: TMeshLinkRole;
|
|
115
|
+
/**
|
|
116
|
+
* A new peer connection; each one has a DTLS certificate of its own. A failure ends the link as a
|
|
117
|
+
* signaling failure with it as the cause.
|
|
118
|
+
*/
|
|
119
|
+
readonly createPeer: () => RtcPeer | Promise<RtcPeer>;
|
|
120
|
+
readonly sendSignal: (signal: TMeshLinkSignal) => void;
|
|
121
|
+
/** Start this side of the device handshake over the bound channel. */
|
|
122
|
+
readonly startHandshake: (binding: IMeshHandshakeBinding) => IDeviceHandshakeController;
|
|
123
|
+
/** From creation to admission. */
|
|
124
|
+
readonly connectTimeoutMs: number;
|
|
125
|
+
readonly onAdmitted: (result: IDeviceHandshakeResult) => void;
|
|
126
|
+
/**
|
|
127
|
+
* Whether this side takes the peer the handshake admitted, checked before this side says so to the
|
|
128
|
+
* peer (e.g. the device the pair's inbox belongs to). Refusing ends the link as a handshake refusal.
|
|
129
|
+
*/
|
|
130
|
+
readonly accepts?: (result: IDeviceHandshakeResult) => boolean;
|
|
131
|
+
/** Called once, whatever ended the link, with why. */
|
|
132
|
+
readonly onEnded: (end: TMeshLinkEnd, error: MeshLinkEndedError) => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Frames held before the handshake exists, or between the peer's proof and this side's verdict. */
|
|
136
|
+
const MAX_HELD_FRAMES = 16;
|
|
137
|
+
/** ICE candidates per direction; a connection gathers a handful, and the relay supplies the peer's. */
|
|
138
|
+
const MAX_CANDIDATES = 64;
|
|
139
|
+
const DATA_CHANNEL_LABEL = 'agent-mesh';
|
|
140
|
+
/** The label of a channel that carries one file transfer. */
|
|
141
|
+
const FILE_CHANNEL_LABEL = 'agent-file';
|
|
142
|
+
/** File channels open at once on one link. */
|
|
143
|
+
const MAX_FILE_CHANNELS = 4;
|
|
144
|
+
/** From opening a file channel to its being open. */
|
|
145
|
+
const FILE_CHANNEL_OPEN_MS = 10_000;
|
|
146
|
+
/** Time for a channel close to leave before the connection under it is closed. */
|
|
147
|
+
const PEER_CLOSE_GRACE_MS = 250;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `confirming`: this side admitted the peer and said so; the link is admitted once the peer says the
|
|
151
|
+
* same, so a side the peer refused never believes it is connected.
|
|
152
|
+
*/
|
|
153
|
+
type TState = 'connecting' | 'confirming' | 'admitted' | 'ended';
|
|
154
|
+
|
|
155
|
+
/** Sent once this side's handshake admitted the peer; nothing else precedes a message. */
|
|
156
|
+
const ADMITTED_FRAME = JSON.stringify({ t: 'mesh-admitted' });
|
|
157
|
+
|
|
158
|
+
export class MeshPeerLink {
|
|
159
|
+
private state: TState = 'connecting';
|
|
160
|
+
private peer?: RtcPeer;
|
|
161
|
+
private channel?: RtcChannel;
|
|
162
|
+
private localFingerprint?: string;
|
|
163
|
+
private remoteFingerprint?: string;
|
|
164
|
+
private tookRemoteDescription = false;
|
|
165
|
+
private creatingPeer = false;
|
|
166
|
+
/** Local candidates wait for the description: they can be gathered before it is sent. */
|
|
167
|
+
private sentDescription = false;
|
|
168
|
+
private readonly localCandidates: Extract<TMeshLinkSignal, { kind: 'ice' }>[] = [];
|
|
169
|
+
private remoteCandidates = 0;
|
|
170
|
+
private handshake?: IDeviceHandshakeController;
|
|
171
|
+
private fedProof = false;
|
|
172
|
+
private readonly held: string[] = [];
|
|
173
|
+
private readonly handlers = new Set<(body: string) => void>();
|
|
174
|
+
private readonly endHandlers = new Set<(end: TMeshLinkEnd) => void>();
|
|
175
|
+
private readonly fileChannels = new Set<RtcChannel>();
|
|
176
|
+
private readonly fileHandlers = new Set<(channel: RtcChannel) => void>();
|
|
177
|
+
private signalChain: Promise<void> = Promise.resolve();
|
|
178
|
+
private stopAwaitingCertificate?: () => void;
|
|
179
|
+
private readonly timer: ReturnType<typeof setTimeout>;
|
|
180
|
+
private admissionResult?: IDeviceHandshakeResult;
|
|
181
|
+
|
|
182
|
+
public constructor(private readonly options: IMeshPeerLinkOptions) {
|
|
183
|
+
this.timer = setTimeout(() => this.end('timeout'), options.connectTimeoutMs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public get role(): TMeshLinkRole {
|
|
187
|
+
return this.options.role;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
public get admitted(): boolean {
|
|
191
|
+
return this.state === 'admitted';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public get ended(): boolean {
|
|
195
|
+
return this.isEnded();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A method, not a narrowed field read: the state changes across every `await`. */
|
|
199
|
+
private isEnded(): boolean {
|
|
200
|
+
return this.state === 'ended';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The admission, once admitted. */
|
|
204
|
+
public get admission(): IDeviceHandshakeResult | undefined {
|
|
205
|
+
return this.state === 'admitted' ? this.admissionResult : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The DTLS fingerprints the handshake was bound to — this side's, and the one the DTLS layer
|
|
210
|
+
* verified for the peer — once admitted. Anything bound to this connection binds to these.
|
|
211
|
+
*/
|
|
212
|
+
public get channelBinding(): IMeshChannelBinding | undefined {
|
|
213
|
+
if (this.state !== 'admitted') return undefined;
|
|
214
|
+
if (this.localFingerprint === undefined || this.remoteFingerprint === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
return { localFingerprint: this.localFingerprint, remoteFingerprint: this.remoteFingerprint };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Offerer: create the data channel and send the offer. */
|
|
220
|
+
public async offer(): Promise<void> {
|
|
221
|
+
if (this.options.role !== 'offerer' || this.creatingPeer || this.isEnded()) return;
|
|
222
|
+
this.creatingPeer = true;
|
|
223
|
+
try {
|
|
224
|
+
const peer = await this.createPeer();
|
|
225
|
+
if (peer === undefined) return;
|
|
226
|
+
this.adoptChannel(peer.createDataChannel(DATA_CHANNEL_LABEL));
|
|
227
|
+
const sdp = await peer.createOffer();
|
|
228
|
+
if (this.isEnded()) return;
|
|
229
|
+
this.localFingerprint = extractDtlsFingerprint(sdp);
|
|
230
|
+
this.sendDescription({ kind: 'offer', sdp });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this.end('signaling', error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A remote description or ICE candidate from the peer, applied in arrival order. */
|
|
237
|
+
public onSignal(signal: TMeshLinkSignal): void {
|
|
238
|
+
if (this.isEnded()) return;
|
|
239
|
+
this.signalChain = this.signalChain
|
|
240
|
+
.then(() => this.applySignal(signal))
|
|
241
|
+
.catch((error: unknown) => this.end('signaling', error));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async applySignal(signal: TMeshLinkSignal): Promise<void> {
|
|
245
|
+
if (this.isEnded()) return;
|
|
246
|
+
if (signal.kind === 'ice') {
|
|
247
|
+
// Candidates only after the one remote description; earlier ones have nothing to apply to.
|
|
248
|
+
if (!this.tookRemoteDescription || !this.peer) return;
|
|
249
|
+
if (++this.remoteCandidates > MAX_CANDIDATES) return;
|
|
250
|
+
this.peer.addRemoteCandidate({
|
|
251
|
+
candidate: signal.candidate.candidate,
|
|
252
|
+
mid: signal.candidate.sdpMid ?? String(signal.candidate.sdpMLineIndex ?? 0),
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const expected = this.options.role === 'offerer' ? 'answer' : 'offer';
|
|
257
|
+
// One remote description per link: the one fingerprint checked is the one the binding names.
|
|
258
|
+
if (signal.kind !== expected || this.tookRemoteDescription) return;
|
|
259
|
+
this.tookRemoteDescription = true;
|
|
260
|
+
let algorithm: string;
|
|
261
|
+
try {
|
|
262
|
+
algorithm = extractDtlsFingerprintAttribute(signal.sdp).algorithm;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
this.end('channel-binding', error);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (this.options.role === 'offerer') {
|
|
268
|
+
const peer = this.peer;
|
|
269
|
+
if (!peer) return;
|
|
270
|
+
peer.acceptAnswer(signal.sdp);
|
|
271
|
+
this.awaitCertificate(peer, algorithm);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const peer = await this.createPeer();
|
|
275
|
+
if (peer === undefined) return;
|
|
276
|
+
const sdp = await peer.acceptOffer(signal.sdp);
|
|
277
|
+
if (this.isEnded()) return;
|
|
278
|
+
this.localFingerprint = extractDtlsFingerprint(sdp);
|
|
279
|
+
this.sendDescription({ kind: 'answer', sdp });
|
|
280
|
+
this.awaitCertificate(peer, algorithm);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The link's connection; `undefined` when the link ended while it was being created. */
|
|
284
|
+
private async createPeer(): Promise<RtcPeer | undefined> {
|
|
285
|
+
const peer = await this.options.createPeer();
|
|
286
|
+
if (this.isEnded()) {
|
|
287
|
+
peer.close();
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
this.peer = peer;
|
|
291
|
+
peer.onLocalCandidate((candidate) => {
|
|
292
|
+
if (this.isEnded()) return;
|
|
293
|
+
const signal: Extract<TMeshLinkSignal, { kind: 'ice' }> = {
|
|
294
|
+
kind: 'ice',
|
|
295
|
+
candidate: { candidate: candidate.candidate, sdpMid: candidate.mid },
|
|
296
|
+
};
|
|
297
|
+
if (this.sentDescription) this.options.sendSignal(signal);
|
|
298
|
+
else if (this.localCandidates.length < MAX_CANDIDATES) this.localCandidates.push(signal);
|
|
299
|
+
});
|
|
300
|
+
peer.onStateChange((state) => {
|
|
301
|
+
if (state === 'failed' || state === 'closed')
|
|
302
|
+
this.end('closed', undefined, `connection ${state}`);
|
|
303
|
+
});
|
|
304
|
+
peer.onDataChannel((channel) => this.incomingChannel(channel));
|
|
305
|
+
return peer;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A channel the peer opened. The answerer's first one is the link's own; after admission a file
|
|
310
|
+
* channel goes to whoever takes files; anything else is not ours to read.
|
|
311
|
+
*/
|
|
312
|
+
private incomingChannel(channel: RtcChannel): void {
|
|
313
|
+
if (this.options.role === 'answerer' && this.channel === undefined && !this.isEnded()) {
|
|
314
|
+
this.adoptChannel(channel);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
this.state !== 'admitted' ||
|
|
319
|
+
channel.label !== FILE_CHANNEL_LABEL ||
|
|
320
|
+
this.fileHandlers.size === 0 ||
|
|
321
|
+
this.fileChannels.size >= MAX_FILE_CHANNELS
|
|
322
|
+
) {
|
|
323
|
+
channel.close();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.trackFileChannel(channel);
|
|
327
|
+
for (const handler of this.fileHandlers) handler(channel);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private trackFileChannel(channel: RtcChannel): void {
|
|
331
|
+
this.fileChannels.add(channel);
|
|
332
|
+
channel.onStateChange((state) => {
|
|
333
|
+
if (state === 'closed') this.fileChannels.delete(channel);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Open a channel for one file transfer. Only an admitted link opens one. */
|
|
338
|
+
public openFileChannel(): Promise<RtcChannel> {
|
|
339
|
+
const peer = this.peer;
|
|
340
|
+
if (this.state !== 'admitted' || peer === undefined) {
|
|
341
|
+
return Promise.reject(new Error('mesh link is not admitted'));
|
|
342
|
+
}
|
|
343
|
+
if (this.fileChannels.size >= MAX_FILE_CHANNELS) {
|
|
344
|
+
return Promise.reject(new Error('too many file transfers are open on this link'));
|
|
345
|
+
}
|
|
346
|
+
const channel = peer.createDataChannel(FILE_CHANNEL_LABEL);
|
|
347
|
+
this.trackFileChannel(channel);
|
|
348
|
+
return new Promise<RtcChannel>((resolve, reject) => {
|
|
349
|
+
if (channel.readyState === 'open') {
|
|
350
|
+
resolve(channel);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const timer = setTimeout(() => {
|
|
354
|
+
stop();
|
|
355
|
+
channel.close();
|
|
356
|
+
reject(new Error('the file channel did not open'));
|
|
357
|
+
}, FILE_CHANNEL_OPEN_MS);
|
|
358
|
+
const stop = channel.onStateChange((state) => {
|
|
359
|
+
if (state === 'connecting') return;
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
stop();
|
|
362
|
+
if (state === 'open') resolve(channel);
|
|
363
|
+
else reject(new Error('the file channel closed before it opened'));
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** File channels the admitted peer opens. Returns an unsubscribe. */
|
|
369
|
+
public onFileChannel(handler: (channel: RtcChannel) => void): () => void {
|
|
370
|
+
this.fileHandlers.add(handler);
|
|
371
|
+
return () => this.fileHandlers.delete(handler);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private sendDescription(signal: Extract<TMeshLinkSignal, { kind: 'offer' | 'answer' }>): void {
|
|
375
|
+
this.options.sendSignal(signal);
|
|
376
|
+
this.sentDescription = true;
|
|
377
|
+
for (const candidate of this.localCandidates.splice(0)) this.options.sendSignal(candidate);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private awaitCertificate(peer: RtcPeer, algorithm: string): void {
|
|
381
|
+
this.stopAwaitingCertificate = whenRemoteCertificateVerified(
|
|
382
|
+
peer,
|
|
383
|
+
algorithm,
|
|
384
|
+
(fingerprint) => {
|
|
385
|
+
if (this.isEnded()) return;
|
|
386
|
+
this.remoteFingerprint = fingerprint;
|
|
387
|
+
this.maybeStartHandshake();
|
|
388
|
+
},
|
|
389
|
+
(reason) => this.end('channel-binding', new Error(reason)),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private adoptChannel(channel: RtcChannel): void {
|
|
394
|
+
this.channel = channel;
|
|
395
|
+
// Subscribe at once: the implementation does not buffer a frame that arrives before a listener.
|
|
396
|
+
channel.onMessage((text) => this.inbound(text));
|
|
397
|
+
channel.onStateChange((state) => {
|
|
398
|
+
if (state === 'open') this.maybeStartHandshake();
|
|
399
|
+
else if (state === 'closed') this.end('closed', undefined, 'data channel closed');
|
|
400
|
+
});
|
|
401
|
+
if (channel.readyState === 'open') this.maybeStartHandshake();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private maybeStartHandshake(): void {
|
|
405
|
+
const channel = this.channel;
|
|
406
|
+
if (this.state !== 'connecting' || this.handshake !== undefined || channel === undefined)
|
|
407
|
+
return;
|
|
408
|
+
if (this.localFingerprint === undefined || this.remoteFingerprint === undefined) return;
|
|
409
|
+
if (channel.readyState !== 'open') return;
|
|
410
|
+
let controller: IDeviceHandshakeController;
|
|
411
|
+
try {
|
|
412
|
+
controller = this.options.startHandshake({
|
|
413
|
+
localFingerprint: this.localFingerprint,
|
|
414
|
+
remoteFingerprint: this.remoteFingerprint,
|
|
415
|
+
send: (frame) => this.write(JSON.stringify(frame)),
|
|
416
|
+
});
|
|
417
|
+
} catch (error) {
|
|
418
|
+
this.end('handshake', error);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this.handshake = controller;
|
|
422
|
+
controller.result.then(
|
|
423
|
+
(result) => this.confirm(result),
|
|
424
|
+
(error: unknown) => this.end('handshake', error),
|
|
425
|
+
);
|
|
426
|
+
const early = this.held.splice(0);
|
|
427
|
+
for (const frame of early) this.feed(frame);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private inbound(text: string): void {
|
|
431
|
+
if (this.isEnded()) return;
|
|
432
|
+
if (this.state === 'admitted') {
|
|
433
|
+
this.deliver(text);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (this.state === 'confirming') {
|
|
437
|
+
this.confirmed(text);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (this.handshake === undefined) {
|
|
441
|
+
this.hold(text);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
this.feed(text);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private hold(text: string): void {
|
|
448
|
+
if (this.held.length >= MAX_HELD_FRAMES) {
|
|
449
|
+
this.end('protocol');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.held.push(text);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Pre-admission: the peer's handshake frames go to the handshake; what follows its proof waits. */
|
|
456
|
+
private feed(text: string): void {
|
|
457
|
+
if (this.fedProof) {
|
|
458
|
+
this.hold(text);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
let frame: unknown;
|
|
462
|
+
try {
|
|
463
|
+
frame = JSON.parse(text);
|
|
464
|
+
} catch {
|
|
465
|
+
frame = text; // the handshake refuses it as malformed, and the link ends
|
|
466
|
+
}
|
|
467
|
+
if (
|
|
468
|
+
typeof frame === 'object' &&
|
|
469
|
+
frame !== null &&
|
|
470
|
+
(frame as { t?: unknown }).t === 'dh-prove'
|
|
471
|
+
) {
|
|
472
|
+
this.fedProof = true;
|
|
473
|
+
}
|
|
474
|
+
this.handshake?.onFrame(frame);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private confirm(result: IDeviceHandshakeResult): void {
|
|
478
|
+
if (this.state !== 'connecting') return;
|
|
479
|
+
if (this.options.accepts !== undefined && !this.options.accepts(result)) {
|
|
480
|
+
this.end('handshake', new Error('the admitted device is not the one this link is for'));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
this.state = 'confirming';
|
|
484
|
+
this.admissionResult = result;
|
|
485
|
+
this.write(ADMITTED_FRAME);
|
|
486
|
+
const held = this.held.splice(0);
|
|
487
|
+
for (const frame of held) this.inbound(frame);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The peer's first frame after its own verdict must say it admitted this side. */
|
|
491
|
+
private confirmed(text: string): void {
|
|
492
|
+
let frame: unknown;
|
|
493
|
+
try {
|
|
494
|
+
frame = JSON.parse(text);
|
|
495
|
+
} catch {
|
|
496
|
+
frame = undefined;
|
|
497
|
+
}
|
|
498
|
+
const result = this.admissionResult;
|
|
499
|
+
if (
|
|
500
|
+
result === undefined ||
|
|
501
|
+
typeof frame !== 'object' ||
|
|
502
|
+
frame === null ||
|
|
503
|
+
(frame as { t?: unknown }).t !== 'mesh-admitted'
|
|
504
|
+
) {
|
|
505
|
+
this.end('protocol');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
clearTimeout(this.timer);
|
|
509
|
+
this.state = 'admitted';
|
|
510
|
+
this.options.onAdmitted(result);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private deliver(text: string): void {
|
|
514
|
+
let frame: unknown;
|
|
515
|
+
try {
|
|
516
|
+
frame = JSON.parse(text);
|
|
517
|
+
} catch {
|
|
518
|
+
frame = undefined;
|
|
519
|
+
}
|
|
520
|
+
const message = decodeMeshMessageFrame(frame);
|
|
521
|
+
if (message === undefined) {
|
|
522
|
+
this.end('protocol');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
for (const handler of this.handlers) handler(message.body);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private write(text: string): void {
|
|
529
|
+
try {
|
|
530
|
+
this.channel?.send(text);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
this.end('closed', error);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Send one message. Only an admitted link sends. */
|
|
537
|
+
public send(body: string): void {
|
|
538
|
+
if (this.state !== 'admitted') throw new Error('mesh link is not admitted');
|
|
539
|
+
if (body.length > MAX_MESH_MESSAGE_CHARS) {
|
|
540
|
+
throw new Error(`mesh message exceeds ${MAX_MESH_MESSAGE_CHARS} characters`);
|
|
541
|
+
}
|
|
542
|
+
this.write(JSON.stringify({ t: 'mesh-msg', body }));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Messages from the admitted peer. Returns an unsubscribe. */
|
|
546
|
+
public onMessage(handler: (body: string) => void): () => void {
|
|
547
|
+
this.handlers.add(handler);
|
|
548
|
+
return () => this.handlers.delete(handler);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Fires once when the link ends, for whatever reason. Returns an unsubscribe. */
|
|
552
|
+
public onEnd(handler: (end: TMeshLinkEnd) => void): () => void {
|
|
553
|
+
this.endHandlers.add(handler);
|
|
554
|
+
return () => this.endHandlers.delete(handler);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
public close(): void {
|
|
558
|
+
this.end('closed', undefined, 'closed by this side');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** How far the link got. */
|
|
562
|
+
public get stage(): TMeshLinkStage {
|
|
563
|
+
if (this.state === 'admitted' || this.state === 'confirming') return this.state;
|
|
564
|
+
if (this.handshake !== undefined) return 'handshake';
|
|
565
|
+
if (this.remoteFingerprint !== undefined) return 'awaiting-channel';
|
|
566
|
+
if (this.tookRemoteDescription) return 'connecting';
|
|
567
|
+
return 'signaling';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private end(end: TMeshLinkEnd, cause?: unknown, detail?: string): void {
|
|
571
|
+
if (this.isEnded()) return;
|
|
572
|
+
// Read before anything below closes the channel and the connection.
|
|
573
|
+
const error = new MeshLinkEndedError({
|
|
574
|
+
end,
|
|
575
|
+
stage: this.stage,
|
|
576
|
+
role: this.options.role,
|
|
577
|
+
...(detail !== undefined ? { detail } : {}),
|
|
578
|
+
...(this.peer !== undefined ? { peer: this.peer.diagnostics() } : {}),
|
|
579
|
+
...(this.channel?.closeCause !== undefined ? { channelClose: this.channel.closeCause } : {}),
|
|
580
|
+
...(cause !== undefined ? { cause } : {}),
|
|
581
|
+
});
|
|
582
|
+
this.state = 'ended';
|
|
583
|
+
clearTimeout(this.timer);
|
|
584
|
+
this.stopAwaitingCertificate?.();
|
|
585
|
+
this.held.length = 0;
|
|
586
|
+
// A frame the handshake cannot decode settles it now, so its timer does not outlive the link.
|
|
587
|
+
this.handshake?.onFrame(undefined);
|
|
588
|
+
this.channel?.close();
|
|
589
|
+
for (const channel of this.fileChannels) channel.close();
|
|
590
|
+
this.fileChannels.clear();
|
|
591
|
+
this.fileHandlers.clear();
|
|
592
|
+
const peer = this.peer;
|
|
593
|
+
// The channel's close goes out first, so the peer learns of it rather than waiting out a timeout.
|
|
594
|
+
if (peer) setTimeout(() => peer.close(), PEER_CLOSE_GRACE_MS).unref?.();
|
|
595
|
+
this.options.onEnded(end, error);
|
|
596
|
+
for (const handler of this.endHandlers) handler(end);
|
|
597
|
+
this.endHandlers.clear();
|
|
598
|
+
this.handlers.clear();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default public infrastructure for finding and signaling a peer device beyond the local network.
|
|
3
|
+
*
|
|
4
|
+
* These are well-known relays run by different operators, chosen so that no single operator sees
|
|
5
|
+
* every record or every signal. None of them is trusted: they carry signed ciphertext under
|
|
6
|
+
* one-time keys, and a peer is admitted by the device handshake alone. They are defaults only —
|
|
7
|
+
* settings replace them, and a self-hosted relay remains an option.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Nostr relays for live signaling, one per operator. */
|
|
11
|
+
export const DEFAULT_NOSTR_RELAYS: readonly string[] = [
|
|
12
|
+
'wss://relay.damus.io',
|
|
13
|
+
'wss://nos.lol',
|
|
14
|
+
'wss://relay.primal.net',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* pkarr relays, for a client that cannot reach the Mainline DHT directly (a browser, or a network
|
|
19
|
+
* that blocks UDP). Two operators: Synonym (pubky) and n0 (iroh).
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_PKARR_RELAYS: readonly string[] = [
|
|
22
|
+
'https://pkarr.pubky.app',
|
|
23
|
+
'https://pkarr.pubky.org',
|
|
24
|
+
'https://dns.iroh.link/pkarr',
|
|
25
|
+
];
|