@vgai/p2p-colyseus 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/src/engine.ts ADDED
@@ -0,0 +1,680 @@
1
+ import { Callbacks } from './callbacks';
2
+ import { Client, type CompatRoom, connectRoomOverChannel, registerLoopbackRoom } from './client';
3
+ import type { SignalEnvelope } from './cloudflare/protocol';
4
+ import { createLoopbackPair } from './loopback';
5
+ import type { Envelope } from './protocol';
6
+ import { type RoomClass, UniversalRoomRuntime } from './runtime';
7
+ import { WebRTCDataChannelPacketChannel } from './webrtc';
8
+
9
+ const HOST_LOSS_CLOSE_DELAY_MS = 1_000;
10
+
11
+ export type P2PColyseusMode =
12
+ | { kind: 'colyseus'; url: string }
13
+ | { kind: 'universal-server'; url: string }
14
+ | {
15
+ kind: 'p2p-host';
16
+ signalingUrl: string;
17
+ iceServers: RTCIceServer[];
18
+ forceRelay?: boolean | undefined;
19
+ heartbeatIntervalMs?: number | undefined;
20
+ accessToken?: string | undefined;
21
+ }
22
+ | {
23
+ kind: 'p2p-join';
24
+ signalingUrl: string;
25
+ roomId?: string | undefined;
26
+ iceServers: RTCIceServer[];
27
+ forceRelay?: boolean | undefined;
28
+ directTimeoutMs?: number | undefined;
29
+ accessToken?: string | undefined;
30
+ }
31
+ | { kind: 'loopback' };
32
+
33
+ export interface P2PColyseusBackendOptions {
34
+ mode: P2PColyseusMode;
35
+ rooms?: Record<string, RoomClass> | undefined;
36
+ }
37
+
38
+ export interface EngineNetConnectOptions {
39
+ url: string;
40
+ room: string;
41
+ joinOptions?: Record<string, unknown> | undefined;
42
+ }
43
+
44
+ export interface EngineNetTransport {
45
+ readonly sessionId: string;
46
+ readonly roomId?: string | undefined;
47
+ readonly replication: EngineStateReplication;
48
+ send(type: string, payload?: unknown): void;
49
+ onMessage<T = unknown>(type: string, handler: (payload: T) => void): () => void;
50
+ onLeave(handler: (code?: number) => void): () => void;
51
+ leave(): void;
52
+ }
53
+
54
+ export interface EngineStateReplication {
55
+ onAdd<T extends Record<string, unknown> = Record<string, unknown>>(
56
+ collection: string,
57
+ handler: (item: T, key: string) => void,
58
+ ): () => void;
59
+ onRemove<T extends Record<string, unknown> = Record<string, unknown>>(
60
+ collection: string,
61
+ handler: (item: T, key: string) => void,
62
+ ): () => void;
63
+ onChange(item: Record<string, unknown>, handler: () => void): () => void;
64
+ onStateChange<T extends Record<string, unknown> = Record<string, unknown>>(
65
+ handler: (state: T) => void,
66
+ ): () => void;
67
+ }
68
+
69
+ export interface EngineNetBackend {
70
+ connect(opts: EngineNetConnectOptions): Promise<EngineNetTransport>;
71
+ }
72
+
73
+ export function createP2PColyseusBackend(options: P2PColyseusBackendOptions): EngineNetBackend {
74
+ for (const [roomName, roomClass] of Object.entries(options.rooms ?? {})) {
75
+ registerLoopbackRoom(roomName, roomClass);
76
+ }
77
+
78
+ return {
79
+ async connect(_opts: EngineNetConnectOptions): Promise<EngineNetTransport> {
80
+ if (options.mode.kind === 'colyseus') {
81
+ return connectRealColyseus(options.mode.url, _opts);
82
+ }
83
+
84
+ if (options.mode.kind === 'p2p-host') {
85
+ const room = await connectP2PHostRoom(options.mode, options.rooms, _opts);
86
+ return adaptCompatRoom(room, room.roomId);
87
+ }
88
+
89
+ if (options.mode.kind === 'p2p-join') {
90
+ const room = await connectP2PJoinRoom(options.mode, _opts);
91
+ return adaptCompatRoom(room, room.roomId);
92
+ }
93
+
94
+ const client = new Client(
95
+ options.mode.kind === 'universal-server' ? options.mode.url : 'loopback://engine',
96
+ );
97
+ const room = await client.joinOrCreate(_opts.room, _opts.joinOptions);
98
+ return adaptCompatRoom(room);
99
+ },
100
+ };
101
+ }
102
+
103
+ export async function connectP2PHostRoom(
104
+ mode: Extract<P2PColyseusMode, { kind: 'p2p-host' }>,
105
+ rooms: Record<string, RoomClass> | undefined,
106
+ opts: EngineNetConnectOptions,
107
+ ): Promise<CompatRoom> {
108
+ const roomClass = rooms?.[opts.room];
109
+ if (!roomClass) throw new Error(`P2P host room not registered: ${opts.room}`);
110
+ const hostSignalId = `host-${randomId()}`;
111
+ const registered = await signal(mode.signalingUrl, {
112
+ kind: 'host-register',
113
+ roomName: opts.room,
114
+ from: hostSignalId,
115
+ accessToken: mode.accessToken,
116
+ });
117
+ if (registered.kind !== 'host-registered') {
118
+ throw new Error(`P2P host registration failed: ${JSON.stringify(registered)}`);
119
+ }
120
+
121
+ // Real Colyseus passes the creating client's join options to onCreate
122
+ // (joinOrCreate semantics) — the hosted room must see them too.
123
+ const runtime = new UniversalRoomRuntime(roomClass, opts.room, opts.joinOptions);
124
+ const [hostLoopback, localLoopback] = createLoopbackPair('host-local', 'host-runtime');
125
+ runtime.attach(hostLoopback);
126
+ const localRoom = await connectRoomOverChannel(localLoopback, opts.room, opts.joinOptions);
127
+ localRoom.roomId = registered.roomId;
128
+ localRoom.reconnectionToken = `${localRoom.roomId}:${localRoom.sessionId}`;
129
+ const stop = startHostSignalLoop({
130
+ signalingUrl: mode.signalingUrl,
131
+ roomId: registered.roomId,
132
+ hostSignalId,
133
+ runtime,
134
+ iceServers: mode.iceServers,
135
+ forceRelay: mode.forceRelay === true,
136
+ heartbeatIntervalMs: mode.heartbeatIntervalMs,
137
+ });
138
+ const originalLeave = localRoom.leave.bind(localRoom);
139
+ localRoom.leave = async (consented = true) => {
140
+ stop();
141
+ return originalLeave(consented);
142
+ };
143
+ return localRoom;
144
+ }
145
+
146
+ export async function connectP2PJoinRoom(
147
+ mode: Extract<P2PColyseusMode, { kind: 'p2p-join' }>,
148
+ opts: EngineNetConnectOptions,
149
+ ): Promise<CompatRoom> {
150
+ const clientSignalId = `client-${randomId()}`;
151
+ const joined = await signal(mode.signalingUrl, {
152
+ kind: 'join-request',
153
+ roomName: opts.room,
154
+ roomId: mode.roomId,
155
+ from: clientSignalId,
156
+ options: opts.joinOptions,
157
+ accessToken: mode.accessToken,
158
+ });
159
+ if (joined.kind !== 'join-routed') {
160
+ throw new Error(`P2P join failed: ${JSON.stringify(joined)}`);
161
+ }
162
+
163
+ if (mode.forceRelay === true) {
164
+ return connectP2PJoinRelayRoom(mode, joined, clientSignalId, opts);
165
+ }
166
+
167
+ const peer = new RTCPeerConnection({ iceServers: mode.iceServers });
168
+ const dataChannel = peer.createDataChannel('p2p-colyseus');
169
+ const pendingCandidates: RTCIceCandidateInit[] = [];
170
+ peer.onicecandidate = (event) => {
171
+ if (!event.candidate) return;
172
+ void signal(mode.signalingUrl, {
173
+ kind: 'rtc-ice',
174
+ roomId: joined.roomId,
175
+ target: joined.hostSignalId,
176
+ from: clientSignalId,
177
+ candidate: event.candidate.toJSON(),
178
+ });
179
+ };
180
+
181
+ const offer = await peer.createOffer();
182
+ await peer.setLocalDescription(offer);
183
+ await signal(mode.signalingUrl, {
184
+ kind: 'rtc-offer',
185
+ roomId: joined.roomId,
186
+ target: joined.hostSignalId,
187
+ from: clientSignalId,
188
+ sdp: peer.localDescription?.toJSON() ?? offer,
189
+ });
190
+
191
+ try {
192
+ await waitForDataChannelOpen({
193
+ channel: dataChannel,
194
+ poll: async () => {
195
+ const drained = await drain(mode.signalingUrl, clientSignalId);
196
+ for (const message of drained) {
197
+ if (message.kind === 'rtc-answer') {
198
+ await peer.setRemoteDescription(message.sdp);
199
+ for (const candidate of pendingCandidates.splice(0))
200
+ await peer.addIceCandidate(candidate);
201
+ } else if (message.kind === 'rtc-ice') {
202
+ if (peer.remoteDescription) await peer.addIceCandidate(message.candidate);
203
+ else pendingCandidates.push(message.candidate);
204
+ }
205
+ }
206
+ },
207
+ ...(mode.directTimeoutMs === undefined ? {} : { timeoutMs: mode.directTimeoutMs }),
208
+ });
209
+ } catch {
210
+ peer.close();
211
+ return connectP2PJoinRelayRoom(mode, joined, clientSignalId, opts);
212
+ }
213
+
214
+ const room = await connectRoomOverChannel(
215
+ new WebRTCDataChannelPacketChannel(joined.hostSignalId, dataChannel),
216
+ opts.room,
217
+ opts.joinOptions,
218
+ );
219
+ room.roomId = joined.roomId;
220
+ room.reconnectionToken = `${room.roomId}:${room.sessionId}`;
221
+ return room;
222
+ }
223
+
224
+ async function connectP2PJoinRelayRoom(
225
+ mode: Extract<P2PColyseusMode, { kind: 'p2p-join' }>,
226
+ joined: Extract<SignalEnvelope, { kind: 'join-routed' }>,
227
+ clientSignalId: string,
228
+ opts: EngineNetConnectOptions,
229
+ ): Promise<CompatRoom> {
230
+ const room = await connectRoomOverChannel(
231
+ new HttpRelayPacketChannel({
232
+ signalingUrl: mode.signalingUrl,
233
+ roomId: joined.roomId,
234
+ peerId: clientSignalId,
235
+ signalPeerId: clientSignalId,
236
+ targetPeerId: joined.hostSignalId,
237
+ }),
238
+ opts.room,
239
+ opts.joinOptions,
240
+ );
241
+ room.roomId = joined.roomId;
242
+ room.reconnectionToken = `${room.roomId}:${room.sessionId}`;
243
+ return room;
244
+ }
245
+
246
+ function startHostSignalLoop(options: {
247
+ signalingUrl: string;
248
+ roomId: string;
249
+ hostSignalId: string;
250
+ runtime: UniversalRoomRuntime;
251
+ iceServers: RTCIceServer[];
252
+ forceRelay: boolean;
253
+ heartbeatIntervalMs?: number | undefined;
254
+ }): () => void {
255
+ let stopped = false;
256
+ const heartbeatInterval = setInterval(() => {
257
+ if (stopped) return;
258
+ void signal(options.signalingUrl, {
259
+ kind: 'heartbeat',
260
+ roomId: options.roomId,
261
+ from: options.hostSignalId,
262
+ });
263
+ }, options.heartbeatIntervalMs ?? 5_000);
264
+ const peers = new Map<string, RTCPeerConnection>();
265
+ const pending = new Map<string, RTCIceCandidateInit[]>();
266
+ const attachedChannels = new Set<{
267
+ send(envelope: Envelope): void;
268
+ close(reason?: string): void;
269
+ }>();
270
+ const relayChannels = new Map<string, HostRelayPacketChannel>();
271
+ const getRelayChannel = (target: string): HostRelayPacketChannel => {
272
+ let channel = relayChannels.get(target);
273
+ if (!channel) {
274
+ channel = new HostRelayPacketChannel({
275
+ signalingUrl: options.signalingUrl,
276
+ roomId: options.roomId,
277
+ peerId: target,
278
+ signalPeerId: options.hostSignalId,
279
+ targetPeerId: target,
280
+ });
281
+ relayChannels.set(target, channel);
282
+ attachedChannels.add(channel);
283
+ options.runtime.attach(channel);
284
+ }
285
+ return channel;
286
+ };
287
+ const loop = async () => {
288
+ while (!stopped) {
289
+ const messages = await drain(options.signalingUrl, options.hostSignalId);
290
+ for (const message of messages) {
291
+ if (message.kind === 'relay-open') {
292
+ const target = message.from ?? 'client';
293
+ getRelayChannel(target);
294
+ continue;
295
+ }
296
+ if (message.kind === 'relay-data') {
297
+ const target = message.from ?? 'client';
298
+ getRelayChannel(target).deliver(message.envelope);
299
+ continue;
300
+ }
301
+ if (message.kind === 'rtc-offer') {
302
+ const target = message.from ?? 'client';
303
+ const peer = new RTCPeerConnection({ iceServers: options.iceServers });
304
+ peers.set(target, peer);
305
+ peer.ondatachannel = (event) => {
306
+ const channel = new WebRTCDataChannelPacketChannel(target, event.channel);
307
+ attachedChannels.add(channel);
308
+ options.runtime.attach(channel);
309
+ };
310
+ peer.onicecandidate = (event) => {
311
+ if (!event.candidate) return;
312
+ void signal(options.signalingUrl, {
313
+ kind: 'rtc-ice',
314
+ roomId: options.roomId,
315
+ target,
316
+ from: options.hostSignalId,
317
+ candidate: event.candidate.toJSON(),
318
+ });
319
+ };
320
+ await peer.setRemoteDescription(message.sdp);
321
+ for (const candidate of pending.get(target) ?? []) await peer.addIceCandidate(candidate);
322
+ pending.delete(target);
323
+ const answer = await peer.createAnswer();
324
+ await peer.setLocalDescription(answer);
325
+ await signal(options.signalingUrl, {
326
+ kind: 'rtc-answer',
327
+ roomId: options.roomId,
328
+ target,
329
+ from: options.hostSignalId,
330
+ sdp: peer.localDescription?.toJSON() ?? answer,
331
+ });
332
+ } else if (message.kind === 'rtc-ice') {
333
+ const target = message.from ?? 'client';
334
+ const peer = peers.get(target);
335
+ if (peer?.remoteDescription) await peer.addIceCandidate(message.candidate);
336
+ else {
337
+ const list = pending.get(target) ?? [];
338
+ list.push(message.candidate);
339
+ pending.set(target, list);
340
+ }
341
+ }
342
+ }
343
+ await sleep(10);
344
+ }
345
+ for (const channel of attachedChannels) closeRemoteForHostLoss(channel);
346
+ for (const peer of peers.values()) closePeerAfterHostLoss(peer);
347
+ };
348
+ void loop();
349
+ return () => {
350
+ stopped = true;
351
+ clearInterval(heartbeatInterval);
352
+ for (const channel of attachedChannels) closeRemoteForHostLoss(channel);
353
+ for (const peer of peers.values()) closePeerAfterHostLoss(peer);
354
+ };
355
+ }
356
+
357
+ function closeRemoteForHostLoss(channel: {
358
+ send(envelope: Envelope): void;
359
+ close(reason?: string): void;
360
+ closeAfterFlush?(delayMs?: number): void;
361
+ }): void {
362
+ const sendLeave = () => {
363
+ try {
364
+ channel.send({ kind: 'leave', code: 1000, reason: 'host-left' });
365
+ } catch {
366
+ // The underlying transport may already be closed; close handlers still run below.
367
+ }
368
+ };
369
+ sendLeave();
370
+ setTimeout(sendLeave, 50);
371
+ setTimeout(sendLeave, 150);
372
+ setTimeout(sendLeave, 300);
373
+ if (channel.closeAfterFlush) {
374
+ channel.closeAfterFlush(HOST_LOSS_CLOSE_DELAY_MS);
375
+ } else {
376
+ setTimeout(() => channel.close('host-left'), HOST_LOSS_CLOSE_DELAY_MS);
377
+ }
378
+ }
379
+
380
+ function closePeerAfterHostLoss(peer: RTCPeerConnection): void {
381
+ setTimeout(() => peer.close(), HOST_LOSS_CLOSE_DELAY_MS);
382
+ }
383
+
384
+ function adaptCompatRoom(room: CompatRoom, roomId?: string): EngineNetTransport {
385
+ const sourceReplication = Callbacks.get(room);
386
+ const transport: EngineNetTransport = {
387
+ get sessionId() {
388
+ return room.sessionId;
389
+ },
390
+ roomId,
391
+ replication: {
392
+ onAdd(collection, handler) {
393
+ return sourceReplication.onAdd(collection, (item, key) => handler(item as never, key));
394
+ },
395
+ onRemove(collection, handler) {
396
+ return sourceReplication.onRemove(collection, (item, key) => handler(item as never, key));
397
+ },
398
+ onChange(item, handler) {
399
+ return sourceReplication.onChange(item, handler);
400
+ },
401
+ onStateChange(handler) {
402
+ return sourceReplication.onStateChange((state) => handler(state as never));
403
+ },
404
+ },
405
+ send(type, payload) {
406
+ room.send(type, payload);
407
+ },
408
+ onMessage(type, handler) {
409
+ return room.onMessage(type, handler);
410
+ },
411
+ onLeave(handler) {
412
+ return room.onLeave.add(handler);
413
+ },
414
+ leave() {
415
+ room.leave();
416
+ },
417
+ };
418
+ return transport;
419
+ }
420
+
421
+ class HttpRelayPacketChannel {
422
+ readonly mode = 'relay' as const;
423
+ private readonly envelopeHandlers = new Set<(envelope: Envelope) => void>();
424
+ private readonly closeHandlers = new Set<(reason?: string) => void>();
425
+ private closed = false;
426
+ private opened = false;
427
+ private sendQueue: Promise<void> = Promise.resolve();
428
+
429
+ constructor(
430
+ private readonly options: {
431
+ signalingUrl: string;
432
+ roomId: string;
433
+ peerId: string;
434
+ signalPeerId: string;
435
+ targetPeerId: string;
436
+ },
437
+ ) {
438
+ void this.poll();
439
+ }
440
+
441
+ get peerId(): string {
442
+ return this.options.peerId;
443
+ }
444
+
445
+ send(envelope: Envelope): void {
446
+ this.sendQueue = this.sendQueue
447
+ .catch(() => undefined)
448
+ .then(() => this.sendAsync(envelope))
449
+ .catch((error) => {
450
+ this.close(error instanceof Error ? error.message : String(error));
451
+ });
452
+ }
453
+
454
+ private async sendAsync(envelope: Envelope): Promise<void> {
455
+ if (!this.opened) {
456
+ const opened = await signal(this.options.signalingUrl, {
457
+ kind: 'relay-open',
458
+ roomId: this.options.roomId,
459
+ target: this.options.targetPeerId,
460
+ from: this.options.signalPeerId,
461
+ });
462
+ if (opened.kind === 'error') throw new Error(opened.message);
463
+ this.opened = true;
464
+ }
465
+ const result = await signal(this.options.signalingUrl, {
466
+ kind: 'relay-data',
467
+ roomId: this.options.roomId,
468
+ target: this.options.targetPeerId,
469
+ from: this.options.signalPeerId,
470
+ envelope,
471
+ });
472
+ if (result.kind === 'error') throw new Error(result.message);
473
+ }
474
+
475
+ onEnvelope(handler: (envelope: Envelope) => void): () => void {
476
+ this.envelopeHandlers.add(handler);
477
+ return () => this.envelopeHandlers.delete(handler);
478
+ }
479
+
480
+ onClose(handler: (reason?: string) => void): () => void {
481
+ this.closeHandlers.add(handler);
482
+ return () => this.closeHandlers.delete(handler);
483
+ }
484
+
485
+ close(reason?: string): void {
486
+ if (this.closed) return;
487
+ this.closed = true;
488
+ for (const handler of this.closeHandlers) handler(reason);
489
+ }
490
+
491
+ private async poll(): Promise<void> {
492
+ while (!this.closed) {
493
+ const messages = await drain(this.options.signalingUrl, this.options.signalPeerId);
494
+ for (const message of messages) {
495
+ if (message.kind === 'relay-data') {
496
+ for (const handler of this.envelopeHandlers) handler(message.envelope);
497
+ }
498
+ }
499
+ await sleep(10);
500
+ }
501
+ }
502
+ }
503
+
504
+ class HostRelayPacketChannel {
505
+ readonly mode = 'relay' as const;
506
+ private readonly envelopeHandlers = new Set<(envelope: Envelope) => void>();
507
+ private readonly closeHandlers = new Set<(reason?: string) => void>();
508
+ private closed = false;
509
+ private opened = false;
510
+ private sendQueue: Promise<void> = Promise.resolve();
511
+
512
+ constructor(
513
+ private readonly options: {
514
+ signalingUrl: string;
515
+ roomId: string;
516
+ peerId: string;
517
+ signalPeerId: string;
518
+ targetPeerId: string;
519
+ },
520
+ ) {}
521
+
522
+ get peerId(): string {
523
+ return this.options.peerId;
524
+ }
525
+
526
+ send(envelope: Envelope): void {
527
+ this.sendQueue = this.sendQueue
528
+ .catch(() => undefined)
529
+ .then(() => this.sendAsync(envelope))
530
+ .catch((error) => {
531
+ this.close(error instanceof Error ? error.message : String(error));
532
+ });
533
+ }
534
+
535
+ deliver(envelope: Envelope): void {
536
+ if (this.closed) return;
537
+ for (const handler of this.envelopeHandlers) handler(envelope);
538
+ }
539
+
540
+ private async sendAsync(envelope: Envelope): Promise<void> {
541
+ if (!this.opened) {
542
+ const opened = await signal(this.options.signalingUrl, {
543
+ kind: 'relay-open',
544
+ roomId: this.options.roomId,
545
+ target: this.options.targetPeerId,
546
+ from: this.options.signalPeerId,
547
+ });
548
+ if (opened.kind === 'error') throw new Error(opened.message);
549
+ this.opened = true;
550
+ }
551
+ const result = await signal(this.options.signalingUrl, {
552
+ kind: 'relay-data',
553
+ roomId: this.options.roomId,
554
+ target: this.options.targetPeerId,
555
+ from: this.options.signalPeerId,
556
+ envelope,
557
+ });
558
+ if (result.kind === 'error') throw new Error(result.message);
559
+ }
560
+
561
+ onEnvelope(handler: (envelope: Envelope) => void): () => void {
562
+ this.envelopeHandlers.add(handler);
563
+ return () => this.envelopeHandlers.delete(handler);
564
+ }
565
+
566
+ onClose(handler: (reason?: string) => void): () => void {
567
+ this.closeHandlers.add(handler);
568
+ return () => this.closeHandlers.delete(handler);
569
+ }
570
+
571
+ close(reason?: string): void {
572
+ if (this.closed) return;
573
+ this.closed = true;
574
+ for (const handler of this.closeHandlers) handler(reason);
575
+ }
576
+ }
577
+
578
+ async function connectRealColyseus(
579
+ url: string,
580
+ opts: EngineNetConnectOptions,
581
+ ): Promise<EngineNetTransport> {
582
+ const sdk = (await import('@colyseus/sdk')) as typeof import('@colyseus/sdk');
583
+ const client = new sdk.Client(url);
584
+ const room = await client.joinOrCreate(opts.room, opts.joinOptions);
585
+ const sourceReplication = sdk.Callbacks.get(room);
586
+ const callbackReplication = sourceReplication as unknown as {
587
+ onAdd(collection: string, handler: (item: unknown, key: unknown) => void): () => void;
588
+ onRemove(collection: string, handler: (item: unknown, key: unknown) => void): () => void;
589
+ onChange(item: unknown, handler: () => void): () => void;
590
+ };
591
+ const replication: EngineStateReplication = {
592
+ onAdd(collection, handler) {
593
+ return callbackReplication.onAdd(collection, (item, key) =>
594
+ handler(item as never, String(key)),
595
+ );
596
+ },
597
+ onRemove(collection, handler) {
598
+ return callbackReplication.onRemove(collection, (item, key) =>
599
+ handler(item as never, String(key)),
600
+ );
601
+ },
602
+ onChange(item, handler) {
603
+ return callbackReplication.onChange(item, handler);
604
+ },
605
+ onStateChange(handler) {
606
+ const callable = room.onStateChange as unknown as
607
+ | ((handler: (state: unknown) => void) => () => void)
608
+ | { add(handler: (state: unknown) => void): () => void }
609
+ | undefined;
610
+ if (typeof callable === 'function')
611
+ return callable((state: unknown) => handler(state as never));
612
+ if (callable && typeof callable.add === 'function') {
613
+ return callable.add((state: unknown) => handler(state as never));
614
+ }
615
+ return () => undefined;
616
+ },
617
+ };
618
+
619
+ return {
620
+ get sessionId() {
621
+ return room.sessionId;
622
+ },
623
+ replication,
624
+ send(type, payload) {
625
+ room.send(type, payload);
626
+ },
627
+ onMessage(type, handler) {
628
+ room.onMessage(type, handler);
629
+ return () => undefined;
630
+ },
631
+ onLeave(handler) {
632
+ room.onLeave((code) => handler(code));
633
+ return () => undefined;
634
+ },
635
+ leave() {
636
+ void room.leave();
637
+ },
638
+ };
639
+ }
640
+
641
+ async function signal(signalingUrl: string, body: SignalEnvelope): Promise<SignalEnvelope> {
642
+ const response = await fetch(signalingUrl, {
643
+ method: 'POST',
644
+ headers: { 'Content-Type': 'application/json' },
645
+ body: JSON.stringify(body),
646
+ });
647
+ if (!response.ok) throw new Error(`P2P signaling failed: ${response.status}`);
648
+ return (await response.json()) as SignalEnvelope;
649
+ }
650
+
651
+ async function drain(signalingUrl: string, peerId: string): Promise<SignalEnvelope[]> {
652
+ const response = await signal(signalingUrl, { kind: 'relay-drain', peerId });
653
+ return response.kind === 'relay-drained' ? [...response.messages] : [];
654
+ }
655
+
656
+ async function waitForDataChannelOpen(options: {
657
+ channel: RTCDataChannel;
658
+ poll: () => Promise<void>;
659
+ timeoutMs?: number;
660
+ }): Promise<void> {
661
+ if (options.timeoutMs !== undefined && options.timeoutMs <= 0) {
662
+ throw new Error('P2P WebRTC data channel open timeout');
663
+ }
664
+ const started = Date.now();
665
+ while (options.channel.readyState !== 'open') {
666
+ if (Date.now() - started > (options.timeoutMs ?? 5_000)) {
667
+ throw new Error('P2P WebRTC data channel open timeout');
668
+ }
669
+ await options.poll();
670
+ await sleep(10);
671
+ }
672
+ }
673
+
674
+ function sleep(ms: number): Promise<void> {
675
+ return new Promise((resolve) => setTimeout(resolve, ms));
676
+ }
677
+
678
+ function randomId(): string {
679
+ return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
680
+ }