y-openrtc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/README.md +92 -0
- package/dist/broadcast.d.ts +25 -0
- package/dist/broadcast.js +65 -0
- package/dist/codec.d.ts +18 -0
- package/dist/codec.js +65 -0
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.js +56 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/provider.d.ts +82 -0
- package/dist/provider.js +762 -0
- package/dist/roomName.d.ts +1 -0
- package/dist/roomName.js +34 -0
- package/dist/runtimePool.d.ts +9 -0
- package/dist/runtimePool.js +93 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/dist/provider.js
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
2
|
+
import { BroadcastMeshTransport } from './broadcast.js';
|
|
3
|
+
import { applyProviderMessage, encodeAwarenessQuery, encodeAwarenessUpdate, encodeDocUpdate, encodeSyncStep1, encodeSyncStep2, } from './codec.js';
|
|
4
|
+
import { decryptFrame, deriveRoomKey, encryptFrame } from './crypto.js';
|
|
5
|
+
import { roomNameToOpenrtcRoomId } from './roomName.js';
|
|
6
|
+
import { acquireRuntimeLease } from './runtimePool.js';
|
|
7
|
+
const warnedCompatibilityOptions = new Set();
|
|
8
|
+
function createProviderId() {
|
|
9
|
+
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
10
|
+
return globalThis.crypto.randomUUID();
|
|
11
|
+
}
|
|
12
|
+
return `y-openrtc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
13
|
+
}
|
|
14
|
+
function isBinaryPayload(value) {
|
|
15
|
+
return value instanceof Uint8Array || value instanceof ArrayBuffer;
|
|
16
|
+
}
|
|
17
|
+
function normalizeBinaryPayload(value) {
|
|
18
|
+
return value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
19
|
+
}
|
|
20
|
+
function stableScore(seed) {
|
|
21
|
+
let hash = 2166136261;
|
|
22
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
23
|
+
hash ^= seed.charCodeAt(index);
|
|
24
|
+
hash = Math.imul(hash, 16777619);
|
|
25
|
+
}
|
|
26
|
+
return hash >>> 0;
|
|
27
|
+
}
|
|
28
|
+
function hasActiveMember(member) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
if (typeof member.expiresAt === 'number') {
|
|
31
|
+
return member.expiresAt >= now;
|
|
32
|
+
}
|
|
33
|
+
const lastSeenAt = member.lastSeenAt ?? member.joinedAt ?? 0;
|
|
34
|
+
return now - lastSeenAt <= 5 * 60 * 1000;
|
|
35
|
+
}
|
|
36
|
+
function shouldInitiateConnection(localNodeId, remoteNodeId) {
|
|
37
|
+
return localNodeId.localeCompare(remoteNodeId) < 0;
|
|
38
|
+
}
|
|
39
|
+
function normalizeOpenrtcRoomId(roomId) {
|
|
40
|
+
return roomId === 'demo' ? roomId : roomId.toUpperCase();
|
|
41
|
+
}
|
|
42
|
+
function errorText(error) {
|
|
43
|
+
if (error instanceof Error) {
|
|
44
|
+
return error.message;
|
|
45
|
+
}
|
|
46
|
+
if (typeof error === 'string') {
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return JSON.stringify(error);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return String(error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function isRoomNotFoundError(error) {
|
|
57
|
+
const text = errorText(error);
|
|
58
|
+
return /room not found|not found|status["']?\s*:\s*["']?not_found|code["']?\s*:\s*404/i.test(text);
|
|
59
|
+
}
|
|
60
|
+
function isRoomAlreadyExistsError(error) {
|
|
61
|
+
const text = errorText(error);
|
|
62
|
+
return /already exists|status["']?\s*:\s*["']?already-exists|code["']?\s*:\s*409/i.test(text);
|
|
63
|
+
}
|
|
64
|
+
function isRoomFullError(error) {
|
|
65
|
+
const text = errorText(error);
|
|
66
|
+
return /room is full|resource[-_\s]?exhausted|status["']?\s*:\s*["']?resource-exhausted|code["']?\s*:\s*(8|429)/i.test(text);
|
|
67
|
+
}
|
|
68
|
+
function isTransientStartupError(error) {
|
|
69
|
+
return /relay-only endpoint ticket unavailable|failed to fetch|network|timed out/i.test(errorText(error));
|
|
70
|
+
}
|
|
71
|
+
function sleep(ms) {
|
|
72
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
|
+
}
|
|
74
|
+
export class OpenrtcProvider {
|
|
75
|
+
constructor(roomName, doc, options = {}) {
|
|
76
|
+
this.providerId = createProviderId();
|
|
77
|
+
this.listeners = new Map();
|
|
78
|
+
this.runtimeLease = null;
|
|
79
|
+
this.runtime = null;
|
|
80
|
+
this.roomId = null;
|
|
81
|
+
this.localNodeId = null;
|
|
82
|
+
this.broadcastTransport = null;
|
|
83
|
+
this.stopWatchingRoom = null;
|
|
84
|
+
this.stopRuntimeConnections = null;
|
|
85
|
+
this.stopRuntimeConnectionStates = null;
|
|
86
|
+
this.roomMembers = new Map();
|
|
87
|
+
this.openrtcConnections = new Map();
|
|
88
|
+
this.bcPeers = new Map();
|
|
89
|
+
this.pendingNodeIds = new Set();
|
|
90
|
+
this.shouldConnect = false;
|
|
91
|
+
this.destroyed = false;
|
|
92
|
+
this.synced = false;
|
|
93
|
+
this.localObserversAttached = false;
|
|
94
|
+
this.connectPromise = null;
|
|
95
|
+
this.reconcileTimer = null;
|
|
96
|
+
this.runtimeRefreshTimer = null;
|
|
97
|
+
this.diagnostics = {
|
|
98
|
+
openrtcSent: 0,
|
|
99
|
+
openrtcReceived: 0,
|
|
100
|
+
broadcastSent: 0,
|
|
101
|
+
broadcastReceived: 0,
|
|
102
|
+
decryptFailed: 0,
|
|
103
|
+
providerMessagesApplied: 0,
|
|
104
|
+
providerRepliesSent: 0,
|
|
105
|
+
docUpdatesBroadcast: 0,
|
|
106
|
+
syncStep1Received: 0,
|
|
107
|
+
syncStep2Received: 0,
|
|
108
|
+
syncUpdateReceived: 0,
|
|
109
|
+
awarenessReceived: 0,
|
|
110
|
+
awarenessQueryReceived: 0,
|
|
111
|
+
};
|
|
112
|
+
this.roomName = roomName;
|
|
113
|
+
this.doc = doc;
|
|
114
|
+
this.options = options;
|
|
115
|
+
this.awareness = options.awareness ?? new awarenessProtocol.Awareness(doc);
|
|
116
|
+
this.filterBcConns = options.filterBcConns ?? true;
|
|
117
|
+
this.maxConns = options.maxConns ?? 20 + Math.floor(Math.random() * 15);
|
|
118
|
+
this.keyPromise = deriveRoomKey(options.password ?? null, roomName);
|
|
119
|
+
this.docUpdateHandler = (update, origin) => {
|
|
120
|
+
if (origin === this) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.diagnostics.docUpdatesBroadcast += 1;
|
|
124
|
+
void this.broadcastProviderPayload(encodeDocUpdate(update));
|
|
125
|
+
};
|
|
126
|
+
this.awarenessUpdateHandler = ({ added, updated, removed }, origin) => {
|
|
127
|
+
if (origin === this) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const clients = added.concat(updated, removed);
|
|
131
|
+
void this.broadcastProviderPayload(encodeAwarenessUpdate(this.awareness, clients));
|
|
132
|
+
};
|
|
133
|
+
this.destroyHandler = () => {
|
|
134
|
+
void this.destroyInternal();
|
|
135
|
+
};
|
|
136
|
+
this.warnCompatibilityOptions();
|
|
137
|
+
this.doc.on('destroy', this.destroyHandler);
|
|
138
|
+
this.connect();
|
|
139
|
+
}
|
|
140
|
+
get connected() {
|
|
141
|
+
return this.shouldConnect;
|
|
142
|
+
}
|
|
143
|
+
on(name, listener) {
|
|
144
|
+
const listeners = this.listeners.get(name) ?? new Set();
|
|
145
|
+
listeners.add(listener);
|
|
146
|
+
this.listeners.set(name, listeners);
|
|
147
|
+
return listener;
|
|
148
|
+
}
|
|
149
|
+
off(name, listener) {
|
|
150
|
+
this.listeners.get(name)?.delete(listener);
|
|
151
|
+
}
|
|
152
|
+
once(name, listener) {
|
|
153
|
+
const wrapped = ((payload) => {
|
|
154
|
+
this.off(name, wrapped);
|
|
155
|
+
listener(payload);
|
|
156
|
+
});
|
|
157
|
+
this.on(name, wrapped);
|
|
158
|
+
}
|
|
159
|
+
emit(name, payload) {
|
|
160
|
+
for (const listener of this.listeners.get(name) ?? []) {
|
|
161
|
+
listener(payload);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
connect() {
|
|
165
|
+
if (this.destroyed || this.shouldConnect) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.shouldConnect = true;
|
|
169
|
+
this.emitStatus();
|
|
170
|
+
this.connectPromise = this.connectWithRetry().catch((error) => {
|
|
171
|
+
if (!this.destroyed) {
|
|
172
|
+
console.warn('[y-openrtc] connect failed:', error);
|
|
173
|
+
}
|
|
174
|
+
if (this.shouldConnect) {
|
|
175
|
+
this.shouldConnect = false;
|
|
176
|
+
this.emitStatus();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
disconnect() {
|
|
181
|
+
void this.disconnectInternal('disconnect');
|
|
182
|
+
}
|
|
183
|
+
destroy() {
|
|
184
|
+
void this.destroyInternal();
|
|
185
|
+
}
|
|
186
|
+
diagnosticSnapshot() {
|
|
187
|
+
return { ...this.diagnostics };
|
|
188
|
+
}
|
|
189
|
+
warnCompatibilityOptions() {
|
|
190
|
+
if (this.options.signaling && !warnedCompatibilityOptions.has('signaling')) {
|
|
191
|
+
warnedCompatibilityOptions.add('signaling');
|
|
192
|
+
console.warn('[y-openrtc] `signaling` is ignored. OpenRTC owns signaling and discovery.');
|
|
193
|
+
}
|
|
194
|
+
if (this.options.peerOpts && !warnedCompatibilityOptions.has('peerOpts')) {
|
|
195
|
+
warnedCompatibilityOptions.add('peerOpts');
|
|
196
|
+
console.warn('[y-openrtc] `peerOpts` is ignored. OpenRTC owns peer transport setup.');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
emitStatus() {
|
|
200
|
+
this.emit('status', { connected: this.connected });
|
|
201
|
+
}
|
|
202
|
+
emitSynced(nextSynced) {
|
|
203
|
+
if (this.synced === nextSynced) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.synced = nextSynced;
|
|
207
|
+
this.emit('synced', { synced: nextSynced });
|
|
208
|
+
}
|
|
209
|
+
emitPeers(added, removed) {
|
|
210
|
+
const payload = {
|
|
211
|
+
added,
|
|
212
|
+
removed,
|
|
213
|
+
openrtcPeers: Array.from(this.openrtcConnections.keys()),
|
|
214
|
+
webrtcPeers: Array.from(this.openrtcConnections.keys()),
|
|
215
|
+
bcPeers: Array.from(this.bcPeers.keys()),
|
|
216
|
+
};
|
|
217
|
+
this.emit('peers', payload);
|
|
218
|
+
}
|
|
219
|
+
attachLocalObservers() {
|
|
220
|
+
if (this.localObserversAttached) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.localObserversAttached = true;
|
|
224
|
+
this.doc.on('update', this.docUpdateHandler);
|
|
225
|
+
this.awareness.on('update', this.awarenessUpdateHandler);
|
|
226
|
+
}
|
|
227
|
+
detachLocalObservers() {
|
|
228
|
+
if (!this.localObserversAttached) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.localObserversAttached = false;
|
|
232
|
+
this.doc.off('update', this.docUpdateHandler);
|
|
233
|
+
this.awareness.off('update', this.awarenessUpdateHandler);
|
|
234
|
+
}
|
|
235
|
+
async connectInternal() {
|
|
236
|
+
const lease = acquireRuntimeLease({
|
|
237
|
+
...this.options,
|
|
238
|
+
authMode: this.options.authMode ?? 'anonymous',
|
|
239
|
+
allowAnonymousHostedDefaults: this.options.allowAnonymousHostedDefaults ?? !this.options.apiKey,
|
|
240
|
+
space: this.options.space ?? this.options.spaceKey ?? (!this.options.apiKey ? this.roomName : undefined),
|
|
241
|
+
});
|
|
242
|
+
this.runtimeLease = lease;
|
|
243
|
+
const runtime = await lease.ensureReady();
|
|
244
|
+
if (!this.shouldConnect || this.destroyed || this.runtimeLease !== lease) {
|
|
245
|
+
await lease.release();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.runtime = runtime;
|
|
249
|
+
this.localNodeId = await runtime.getNodeId();
|
|
250
|
+
const preferredRoomId = normalizeOpenrtcRoomId(this.options.roomIdOverride ?? await roomNameToOpenrtcRoomId(this.roomName));
|
|
251
|
+
const roomCandidates = this.resolveRoomCandidates(preferredRoomId);
|
|
252
|
+
this.attachLocalObservers();
|
|
253
|
+
this.stopRuntimeConnections = runtime.onConnection((connection) => {
|
|
254
|
+
if (!this.shouldConnect || !this.isRelevantConnection(connection)) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.attachConnection(connection);
|
|
258
|
+
void this.reconcilePeers();
|
|
259
|
+
});
|
|
260
|
+
if (typeof runtime.onConnectionStateChange === 'function') {
|
|
261
|
+
const stopRuntimeConnectionStates = runtime.onConnectionStateChange((state) => {
|
|
262
|
+
if (!this.shouldConnect || state.state !== 'connected') {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.refreshKnownRuntimeConnections();
|
|
266
|
+
// When a peer's transport upgrades (e.g. iroh → WebRTC), the
|
|
267
|
+
// initial syncStep1 may have been sent over a transport that
|
|
268
|
+
// could not deliver it (e.g. dropped at the JSON parser on the
|
|
269
|
+
// far side). Replay sync to the affected peer so that late
|
|
270
|
+
// upgrades still converge.
|
|
271
|
+
const remoteNodeId = state.remoteNodeId ?? null;
|
|
272
|
+
if (remoteNodeId) {
|
|
273
|
+
this.resyncPeer(remoteNodeId);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
if (typeof stopRuntimeConnectionStates === 'function') {
|
|
277
|
+
this.stopRuntimeConnectionStates = stopRuntimeConnectionStates;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
void Promise.resolve(stopRuntimeConnectionStates)
|
|
281
|
+
.then((stop) => {
|
|
282
|
+
if (this.destroyed || !this.shouldConnect) {
|
|
283
|
+
stop?.();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.stopRuntimeConnectionStates = stop ?? null;
|
|
287
|
+
})
|
|
288
|
+
.catch(() => undefined);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
this.roomId = await this.joinFirstAvailableRoom(runtime, roomCandidates);
|
|
292
|
+
if (!this.shouldConnect || this.destroyed) {
|
|
293
|
+
await this.disconnectOwnedResources('connect-aborted');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
this.broadcastTransport = new BroadcastMeshTransport({
|
|
297
|
+
channelName: `y-openrtc:${this.roomId}`,
|
|
298
|
+
providerId: this.providerId,
|
|
299
|
+
nodeId: this.localNodeId,
|
|
300
|
+
onPeerMessage: (message) => {
|
|
301
|
+
if (message.action === 'add') {
|
|
302
|
+
const hadPeer = this.bcPeers.has(message.providerId);
|
|
303
|
+
this.bcPeers.set(message.providerId, message.nodeId);
|
|
304
|
+
if (!hadPeer) {
|
|
305
|
+
this.emitPeers([message.providerId], []);
|
|
306
|
+
this.broadcastTransport?.announcePresence();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else if (this.bcPeers.delete(message.providerId)) {
|
|
310
|
+
this.emitPeers([], [message.providerId]);
|
|
311
|
+
}
|
|
312
|
+
this.requestReconcile();
|
|
313
|
+
},
|
|
314
|
+
onPayload: (message) => {
|
|
315
|
+
this.diagnostics.broadcastReceived += 1;
|
|
316
|
+
void this.handleIncomingPayload(message.data, { type: 'bc', providerId: message.providerId, nodeId: message.nodeId }, (reply) => this.sendBroadcastPayload(reply));
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
this.broadcastTransport.connect();
|
|
320
|
+
this.stopWatchingRoom = runtime.watchRoom(this.roomId, (members) => {
|
|
321
|
+
this.replaceRoomMembers(members);
|
|
322
|
+
this.refreshKnownRuntimeConnections();
|
|
323
|
+
this.requestReconcile();
|
|
324
|
+
});
|
|
325
|
+
this.refreshKnownRuntimeConnections();
|
|
326
|
+
this.startRuntimeConnectionRefresh();
|
|
327
|
+
this.broadcastInitialBcState();
|
|
328
|
+
this.requestReconcile();
|
|
329
|
+
this.updateSyncedState();
|
|
330
|
+
}
|
|
331
|
+
async connectWithRetry() {
|
|
332
|
+
let lastError;
|
|
333
|
+
for (const delayMs of [0, 500, 1500, 3000]) {
|
|
334
|
+
if (delayMs > 0) {
|
|
335
|
+
await sleep(delayMs);
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
await this.connectInternal();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
lastError = error;
|
|
343
|
+
await this.disconnectOwnedResources('connect-retry').catch(() => undefined);
|
|
344
|
+
if (!this.shouldConnect || this.destroyed || !isTransientStartupError(error)) {
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
throw lastError ?? new Error('OpenRTC provider startup failed.');
|
|
350
|
+
}
|
|
351
|
+
resolveRoomCandidates(preferredRoomId) {
|
|
352
|
+
const configured = this.options.roomIdCandidates
|
|
353
|
+
?.map((roomId) => normalizeOpenrtcRoomId(roomId.trim()))
|
|
354
|
+
.filter(Boolean) ?? [];
|
|
355
|
+
const candidates = configured.length > 0 ? configured : [preferredRoomId];
|
|
356
|
+
if (!candidates.includes(preferredRoomId)) {
|
|
357
|
+
candidates.unshift(preferredRoomId);
|
|
358
|
+
}
|
|
359
|
+
return [...new Set(candidates)];
|
|
360
|
+
}
|
|
361
|
+
async joinFirstAvailableRoom(runtime, roomIds) {
|
|
362
|
+
let lastError;
|
|
363
|
+
for (const roomId of roomIds) {
|
|
364
|
+
try {
|
|
365
|
+
await this.joinOrCreateRuntimeRoom(runtime, roomId);
|
|
366
|
+
return roomId;
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
lastError = error;
|
|
370
|
+
if (!isRoomFullError(error)) {
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
throw lastError ?? new Error('No y-openrtc room candidates were available.');
|
|
376
|
+
}
|
|
377
|
+
async joinOrCreateRuntimeRoom(runtime, roomId) {
|
|
378
|
+
try {
|
|
379
|
+
await runtime.joinRoom(roomId, { bootstrapPeers: false });
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (!isRoomNotFoundError(error)) {
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
await runtime.createRoom(roomId);
|
|
387
|
+
}
|
|
388
|
+
catch (createError) {
|
|
389
|
+
if (!isRoomAlreadyExistsError(createError)) {
|
|
390
|
+
throw createError;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
await this.joinRoomAfterCreate(runtime, roomId);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async joinRoomAfterCreate(runtime, roomId) {
|
|
397
|
+
let lastError;
|
|
398
|
+
for (const delayMs of [150, 350, 750, 1500, 3000]) {
|
|
399
|
+
if (delayMs > 0) {
|
|
400
|
+
await sleep(delayMs);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
await runtime.joinRoom(roomId, { bootstrapPeers: false });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
lastError = error;
|
|
408
|
+
if (!isRoomNotFoundError(error)) {
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
throw lastError ?? new Error(`Room ${roomId} was not available after creation.`);
|
|
414
|
+
}
|
|
415
|
+
requestReconcile() {
|
|
416
|
+
if (!this.shouldConnect || this.destroyed) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (this.reconcileTimer) {
|
|
420
|
+
clearTimeout(this.reconcileTimer);
|
|
421
|
+
this.reconcileTimer = null;
|
|
422
|
+
}
|
|
423
|
+
const delayMs = this.filterBcConns && this.broadcastTransport?.available ? 25 : 0;
|
|
424
|
+
this.reconcileTimer = setTimeout(() => {
|
|
425
|
+
this.reconcileTimer = null;
|
|
426
|
+
void this.reconcilePeers();
|
|
427
|
+
}, delayMs);
|
|
428
|
+
}
|
|
429
|
+
replaceRoomMembers(members) {
|
|
430
|
+
this.roomMembers.clear();
|
|
431
|
+
for (const member of members) {
|
|
432
|
+
if (!member.nodeId || !hasActiveMember(member)) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
this.roomMembers.set(member.nodeId, member);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async reconcilePeers() {
|
|
439
|
+
const runtime = this.runtime;
|
|
440
|
+
if (!runtime || !this.shouldConnect || !this.localNodeId) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const localNodeId = this.localNodeId;
|
|
444
|
+
const candidates = Array.from(this.roomMembers.values())
|
|
445
|
+
.filter((member) => member.nodeId !== this.localNodeId)
|
|
446
|
+
.filter((member) => !!member.ticket)
|
|
447
|
+
.filter((member) => !this.filterBcConns || !this.hasBcNodeId(member.nodeId))
|
|
448
|
+
.sort((left, right) => stableScore(`${this.providerId}:${right.nodeId}`) -
|
|
449
|
+
stableScore(`${this.providerId}:${left.nodeId}`));
|
|
450
|
+
const desiredNodeIds = new Set(candidates.slice(0, this.maxConns).map((member) => member.nodeId));
|
|
451
|
+
// When acceptAllConnections is set, peers aren't discovered through
|
|
452
|
+
// room membership — they connect directly via ticket. Skip the
|
|
453
|
+
// membership-based reconciliation that would otherwise drop them.
|
|
454
|
+
if (this.options.acceptAllConnections !== true) {
|
|
455
|
+
for (const [nodeId, record] of this.openrtcConnections.entries()) {
|
|
456
|
+
if (desiredNodeIds.has(nodeId)) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
this.openrtcConnections.delete(nodeId);
|
|
460
|
+
this.emitPeers([], [nodeId]);
|
|
461
|
+
this.updateSyncedState();
|
|
462
|
+
if (this.runtimeLease?.owned) {
|
|
463
|
+
await record.connection.disconnect().catch(() => undefined);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const connectableMembers = candidates.filter((member) => desiredNodeIds.has(member.nodeId) &&
|
|
468
|
+
shouldInitiateConnection(localNodeId, member.nodeId) &&
|
|
469
|
+
!this.openrtcConnections.has(member.nodeId) &&
|
|
470
|
+
!this.pendingNodeIds.has(member.nodeId));
|
|
471
|
+
await Promise.allSettled(connectableMembers.map(async (member) => {
|
|
472
|
+
this.pendingNodeIds.add(member.nodeId);
|
|
473
|
+
try {
|
|
474
|
+
const connection = await runtime.connectByTicket({
|
|
475
|
+
ticket: member.ticket,
|
|
476
|
+
timeoutMs: 20000,
|
|
477
|
+
});
|
|
478
|
+
if (this.isRelevantConnection(connection)) {
|
|
479
|
+
this.attachConnection(connection);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
console.warn(`[y-openrtc] failed to connect to room peer ${member.nodeId}:`, error);
|
|
484
|
+
this.requestReconcile();
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
this.pendingNodeIds.delete(member.nodeId);
|
|
488
|
+
}
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
hasBcNodeId(nodeId) {
|
|
492
|
+
for (const bcNodeId of this.bcPeers.values()) {
|
|
493
|
+
if (bcNodeId === nodeId) {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
isRelevantConnection(connection) {
|
|
500
|
+
if (!connection.remoteNodeId || connection.remoteNodeId === this.localNodeId) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
return this.options.acceptAllConnections === true || this.roomMembers.has(connection.remoteNodeId);
|
|
504
|
+
}
|
|
505
|
+
refreshKnownRuntimeConnections() {
|
|
506
|
+
const runtime = this.runtime;
|
|
507
|
+
if (!runtime || !this.shouldConnect) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
for (const connection of runtime.getConnections()) {
|
|
511
|
+
if (this.isRelevantConnection(connection)) {
|
|
512
|
+
this.attachConnection(connection);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
startRuntimeConnectionRefresh() {
|
|
517
|
+
if (this.runtimeRefreshTimer) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.runtimeRefreshTimer = setInterval(() => {
|
|
521
|
+
this.refreshKnownRuntimeConnections();
|
|
522
|
+
this.requestReconcile();
|
|
523
|
+
}, 1000);
|
|
524
|
+
}
|
|
525
|
+
resyncPeer(remoteNodeId) {
|
|
526
|
+
const record = this.openrtcConnections.get(remoteNodeId);
|
|
527
|
+
if (!record) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
record.synced = false;
|
|
531
|
+
void this.sendControlToConnection(record.connection, encodeSyncStep1(this.doc));
|
|
532
|
+
void this.sendControlToConnection(record.connection, encodeAwarenessQuery());
|
|
533
|
+
this.updateSyncedState();
|
|
534
|
+
}
|
|
535
|
+
sendInitialSync(connection, record) {
|
|
536
|
+
void this.sendControlToConnection(connection, encodeSyncStep1(this.doc));
|
|
537
|
+
void this.sendControlToConnection(connection, encodeAwarenessQuery());
|
|
538
|
+
const awarenessStates = Array.from(this.awareness.getStates().keys());
|
|
539
|
+
if (awarenessStates.length > 0) {
|
|
540
|
+
void this.sendControlToConnection(connection, encodeAwarenessUpdate(this.awareness, awarenessStates));
|
|
541
|
+
}
|
|
542
|
+
for (const delayMs of [250, 1000, 3000]) {
|
|
543
|
+
setTimeout(() => {
|
|
544
|
+
if (this.destroyed
|
|
545
|
+
|| !this.shouldConnect
|
|
546
|
+
|| record.synced
|
|
547
|
+
|| connection.isClosed
|
|
548
|
+
|| this.openrtcConnections.get(connection.remoteNodeId)?.connection.id !== connection.id) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
void this.sendControlToConnection(connection, encodeSyncStep1(this.doc));
|
|
552
|
+
void this.sendControlToConnection(connection, encodeAwarenessQuery());
|
|
553
|
+
}, delayMs);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
attachConnection(connection) {
|
|
557
|
+
const nodeId = connection.remoteNodeId;
|
|
558
|
+
if (!nodeId) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const existing = this.openrtcConnections.get(nodeId);
|
|
562
|
+
if (existing?.connection.id === connection.id) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (existing) {
|
|
566
|
+
this.openrtcConnections.delete(nodeId);
|
|
567
|
+
}
|
|
568
|
+
const record = {
|
|
569
|
+
connection,
|
|
570
|
+
synced: false,
|
|
571
|
+
};
|
|
572
|
+
this.openrtcConnections.set(nodeId, record);
|
|
573
|
+
this.emitPeers([nodeId], existing ? [nodeId] : []);
|
|
574
|
+
this.updateSyncedState();
|
|
575
|
+
connection.onMessage((message) => {
|
|
576
|
+
if (!isBinaryPayload(message)) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
this.diagnostics.openrtcReceived += 1;
|
|
580
|
+
void this.handleIncomingPayload(normalizeBinaryPayload(message), { type: 'openrtc', nodeId, connectionId: connection.id }, (reply) => this.sendControlToConnection(connection, reply), record);
|
|
581
|
+
});
|
|
582
|
+
connection.onDisconnect(() => {
|
|
583
|
+
if (!this.openrtcConnections.delete(nodeId)) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
this.emitPeers([], [nodeId]);
|
|
587
|
+
this.updateSyncedState();
|
|
588
|
+
this.requestReconcile();
|
|
589
|
+
});
|
|
590
|
+
this.sendInitialSync(connection, record);
|
|
591
|
+
}
|
|
592
|
+
async sendToConnection(connection, payload) {
|
|
593
|
+
const encrypted = await encryptFrame(payload, await this.keyPromise);
|
|
594
|
+
this.diagnostics.openrtcSent += 1;
|
|
595
|
+
await connection.send(encrypted);
|
|
596
|
+
}
|
|
597
|
+
async sendControlToConnection(connection, payload) {
|
|
598
|
+
const encrypted = await encryptFrame(payload, await this.keyPromise);
|
|
599
|
+
this.diagnostics.openrtcSent += 1;
|
|
600
|
+
const transportSender = connection;
|
|
601
|
+
if (typeof transportSender.sendOnTransport === 'function') {
|
|
602
|
+
try {
|
|
603
|
+
await transportSender.sendOnTransport('iroh-relay', encrypted);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
try {
|
|
608
|
+
await transportSender.sendOnTransport('iroh', encrypted);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Fall back to the default transport order.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
await connection.send(encrypted);
|
|
617
|
+
}
|
|
618
|
+
async sendBroadcastPayload(payload) {
|
|
619
|
+
const encrypted = await encryptFrame(payload, await this.keyPromise);
|
|
620
|
+
this.diagnostics.broadcastSent += 1;
|
|
621
|
+
this.broadcastTransport?.sendPayload(encrypted);
|
|
622
|
+
}
|
|
623
|
+
async broadcastProviderPayload(payload, force = false) {
|
|
624
|
+
if (!force && !this.shouldConnect) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const encrypted = await encryptFrame(payload, await this.keyPromise);
|
|
628
|
+
this.broadcastTransport?.sendPayload(encrypted);
|
|
629
|
+
await Promise.allSettled(Array.from(this.openrtcConnections.values()).map(({ connection }) => connection.send(encrypted)));
|
|
630
|
+
}
|
|
631
|
+
broadcastInitialBcState() {
|
|
632
|
+
if (!this.broadcastTransport) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
void this.broadcastProviderPayload(encodeSyncStep1(this.doc));
|
|
636
|
+
void this.broadcastProviderPayload(encodeSyncStep2(this.doc));
|
|
637
|
+
void this.broadcastProviderPayload(encodeAwarenessQuery());
|
|
638
|
+
const awarenessStates = Array.from(this.awareness.getStates().keys());
|
|
639
|
+
if (awarenessStates.length > 0) {
|
|
640
|
+
void this.broadcastProviderPayload(encodeAwarenessUpdate(this.awareness, awarenessStates));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async handleIncomingPayload(payload, origin, reply, connectionRecord) {
|
|
644
|
+
let decrypted;
|
|
645
|
+
try {
|
|
646
|
+
decrypted = await decryptFrame(payload, await this.keyPromise);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
this.diagnostics.decryptFailed += 1;
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const messageType = decrypted[0];
|
|
653
|
+
if (messageType === 0) {
|
|
654
|
+
const syncType = decrypted[1];
|
|
655
|
+
if (syncType === 0)
|
|
656
|
+
this.diagnostics.syncStep1Received += 1;
|
|
657
|
+
else if (syncType === 1)
|
|
658
|
+
this.diagnostics.syncStep2Received += 1;
|
|
659
|
+
else if (syncType === 2)
|
|
660
|
+
this.diagnostics.syncUpdateReceived += 1;
|
|
661
|
+
}
|
|
662
|
+
else if (messageType === 1) {
|
|
663
|
+
this.diagnostics.awarenessReceived += 1;
|
|
664
|
+
}
|
|
665
|
+
else if (messageType === 3) {
|
|
666
|
+
this.diagnostics.awarenessQueryReceived += 1;
|
|
667
|
+
}
|
|
668
|
+
const replies = applyProviderMessage({
|
|
669
|
+
doc: this.doc,
|
|
670
|
+
awareness: this.awareness,
|
|
671
|
+
payload: decrypted,
|
|
672
|
+
origin,
|
|
673
|
+
onSynced: () => {
|
|
674
|
+
if (!connectionRecord) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
connectionRecord.synced = true;
|
|
678
|
+
this.updateSyncedState();
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
this.diagnostics.providerMessagesApplied += 1;
|
|
682
|
+
for (const replyPayload of replies) {
|
|
683
|
+
this.diagnostics.providerRepliesSent += 1;
|
|
684
|
+
await reply(replyPayload);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
updateSyncedState() {
|
|
688
|
+
const connections = Array.from(this.openrtcConnections.values());
|
|
689
|
+
const nextSynced = connections.every((record) => record.synced);
|
|
690
|
+
this.emitSynced(nextSynced);
|
|
691
|
+
}
|
|
692
|
+
async disconnectOwnedResources(reason) {
|
|
693
|
+
const runtime = this.runtime;
|
|
694
|
+
const roomId = this.roomId;
|
|
695
|
+
const lease = this.runtimeLease;
|
|
696
|
+
const trackedConnections = Array.from(this.openrtcConnections.values());
|
|
697
|
+
this.stopWatchingRoom?.();
|
|
698
|
+
this.stopWatchingRoom = null;
|
|
699
|
+
this.stopRuntimeConnections?.();
|
|
700
|
+
this.stopRuntimeConnections = null;
|
|
701
|
+
this.stopRuntimeConnectionStates?.();
|
|
702
|
+
this.stopRuntimeConnectionStates = null;
|
|
703
|
+
if (this.reconcileTimer) {
|
|
704
|
+
clearTimeout(this.reconcileTimer);
|
|
705
|
+
this.reconcileTimer = null;
|
|
706
|
+
}
|
|
707
|
+
if (this.runtimeRefreshTimer) {
|
|
708
|
+
clearInterval(this.runtimeRefreshTimer);
|
|
709
|
+
this.runtimeRefreshTimer = null;
|
|
710
|
+
}
|
|
711
|
+
this.broadcastTransport?.disconnect();
|
|
712
|
+
this.broadcastTransport = null;
|
|
713
|
+
this.roomMembers.clear();
|
|
714
|
+
this.bcPeers.clear();
|
|
715
|
+
this.pendingNodeIds.clear();
|
|
716
|
+
this.openrtcConnections.clear();
|
|
717
|
+
this.runtime = null;
|
|
718
|
+
this.roomId = null;
|
|
719
|
+
this.localNodeId = null;
|
|
720
|
+
this.runtimeLease = null;
|
|
721
|
+
if (runtime && roomId) {
|
|
722
|
+
await runtime.leaveRoom(roomId).catch(() => undefined);
|
|
723
|
+
}
|
|
724
|
+
if (lease?.owned) {
|
|
725
|
+
await Promise.allSettled(trackedConnections.map(({ connection }) => connection.disconnect().catch(() => undefined)));
|
|
726
|
+
}
|
|
727
|
+
await lease?.release();
|
|
728
|
+
this.emitSynced(false);
|
|
729
|
+
if (reason === 'destroy') {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
async disconnectInternal(reason) {
|
|
734
|
+
if (!this.shouldConnect && !this.runtimeLease) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (this.localObserversAttached) {
|
|
738
|
+
awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], this);
|
|
739
|
+
await this.broadcastProviderPayload(encodeAwarenessUpdate(this.awareness, [this.doc.clientID]), true);
|
|
740
|
+
}
|
|
741
|
+
this.shouldConnect = false;
|
|
742
|
+
this.emitStatus();
|
|
743
|
+
this.detachLocalObservers();
|
|
744
|
+
const removed = [
|
|
745
|
+
...this.openrtcConnections.keys(),
|
|
746
|
+
...this.bcPeers.keys(),
|
|
747
|
+
];
|
|
748
|
+
if (removed.length > 0) {
|
|
749
|
+
this.emitPeers([], removed);
|
|
750
|
+
}
|
|
751
|
+
await this.disconnectOwnedResources(reason);
|
|
752
|
+
}
|
|
753
|
+
async destroyInternal() {
|
|
754
|
+
if (this.destroyed) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
this.destroyed = true;
|
|
758
|
+
this.doc.off('destroy', this.destroyHandler);
|
|
759
|
+
await this.disconnectInternal('destroy');
|
|
760
|
+
this.listeners.clear();
|
|
761
|
+
}
|
|
762
|
+
}
|