@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/LICENSE +202 -0
- package/README.md +125 -0
- package/package.json +51 -0
- package/src/access-token.ts +74 -0
- package/src/browser.ts +202 -0
- package/src/callbacks.ts +7 -0
- package/src/channels.ts +12 -0
- package/src/client.ts +480 -0
- package/src/cloudflare/coordinator.ts +383 -0
- package/src/cloudflare/durable-room.ts +377 -0
- package/src/cloudflare/limits.ts +22 -0
- package/src/cloudflare/protocol.ts +75 -0
- package/src/cloudflare/worker.ts +110 -0
- package/src/codec.ts +78 -0
- package/src/diagnostics.ts +55 -0
- package/src/engine.ts +680 -0
- package/src/events.ts +58 -0
- package/src/index.ts +196 -0
- package/src/loopback.ts +65 -0
- package/src/node-websocket.ts +77 -0
- package/src/platform.ts +866 -0
- package/src/protocol.ts +56 -0
- package/src/relay.ts +60 -0
- package/src/replication.ts +164 -0
- package/src/room.ts +282 -0
- package/src/runtime.ts +371 -0
- package/src/schema.ts +287 -0
- package/src/webrtc.ts +78 -0
- package/src/websocket.ts +59 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import type { PacketChannel } from './channels';
|
|
2
|
+
import { applyStatePatch } from './codec';
|
|
3
|
+
import type { P2PColyseusMode } from './engine';
|
|
4
|
+
import { createSignal } from './events';
|
|
5
|
+
import { createLoopbackPair } from './loopback';
|
|
6
|
+
import { type Envelope, P2P_CLOSE_CODES } from './protocol';
|
|
7
|
+
import { ClientReplication } from './replication';
|
|
8
|
+
import { type RoomClass, UniversalRoomRuntime } from './runtime';
|
|
9
|
+
import { createWebSocketPacketChannel } from './websocket';
|
|
10
|
+
|
|
11
|
+
const loopbackRooms = new Map<string, RoomClass>();
|
|
12
|
+
let configuredP2P:
|
|
13
|
+
| {
|
|
14
|
+
mode: Extract<P2PColyseusMode, { kind: 'p2p-host' | 'p2p-join' }>;
|
|
15
|
+
rooms?: Record<string, RoomClass> | undefined;
|
|
16
|
+
maxLocalClientsPerRoom?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
| undefined;
|
|
19
|
+
const hostedP2PRoomIds = new Map<string, string>();
|
|
20
|
+
const localP2PClientCounts = new Map<string, number>();
|
|
21
|
+
|
|
22
|
+
export function registerLoopbackRoom(roomName: string, roomClass: RoomClass): void {
|
|
23
|
+
loopbackRooms.set(roomName, roomClass);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function configureP2PColyseusClient(options: {
|
|
27
|
+
mode: Extract<P2PColyseusMode, { kind: 'p2p-host' | 'p2p-join' }>;
|
|
28
|
+
rooms?: Record<string, RoomClass> | undefined;
|
|
29
|
+
maxLocalClientsPerRoom?: number | undefined;
|
|
30
|
+
}): void {
|
|
31
|
+
configuredP2P = options;
|
|
32
|
+
publishP2PMode(options.mode.kind);
|
|
33
|
+
hostedP2PRoomIds.clear();
|
|
34
|
+
localP2PClientCounts.clear();
|
|
35
|
+
for (const [roomName, roomClass] of Object.entries(options.rooms ?? {})) {
|
|
36
|
+
registerLoopbackRoom(roomName, roomClass);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resetP2PColyseusClient(): void {
|
|
41
|
+
configuredP2P = undefined;
|
|
42
|
+
hostedP2PRoomIds.clear();
|
|
43
|
+
localP2PClientCounts.clear();
|
|
44
|
+
publishP2PMode('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class AbortError extends Error {
|
|
48
|
+
constructor(message = 'aborted') {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'AbortError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class MatchMakeError extends Error {
|
|
55
|
+
constructor(
|
|
56
|
+
message: string,
|
|
57
|
+
readonly code?: number,
|
|
58
|
+
) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'MatchMakeError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class Auth<UserData = unknown> {
|
|
65
|
+
token: string | undefined;
|
|
66
|
+
userData: UserData | undefined;
|
|
67
|
+
|
|
68
|
+
constructor(readonly client?: Client) {}
|
|
69
|
+
|
|
70
|
+
async login(options?: { token?: string; userData?: UserData }): Promise<UserData | undefined> {
|
|
71
|
+
this.token = options?.token;
|
|
72
|
+
this.userData = options?.userData;
|
|
73
|
+
return this.userData;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async logout(): Promise<void> {
|
|
77
|
+
this.token = undefined;
|
|
78
|
+
this.userData = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AvailableRoom {
|
|
83
|
+
clients: number;
|
|
84
|
+
maxClients: number;
|
|
85
|
+
metadata: unknown;
|
|
86
|
+
name: string;
|
|
87
|
+
roomId: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class Client {
|
|
91
|
+
static VERSION = 'p2p-colyseus-compat';
|
|
92
|
+
readonly auth = new Auth(this);
|
|
93
|
+
|
|
94
|
+
constructor(readonly url: string) {}
|
|
95
|
+
|
|
96
|
+
async create(roomName: string, options?: unknown): Promise<CompatRoom> {
|
|
97
|
+
return this.joinOrCreate(roomName, options);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async join(roomName: string, options?: unknown): Promise<CompatRoom> {
|
|
101
|
+
return this.joinOrCreate(roomName, options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async joinById(roomId: string, options?: unknown): Promise<CompatRoom> {
|
|
105
|
+
return this.joinOrCreate(roomId, options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async reconnect(reconnectionToken: string): Promise<CompatRoom> {
|
|
109
|
+
const [roomId, token] = reconnectionToken.split(':');
|
|
110
|
+
if (!roomId || !token) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
'Invalid reconnection token format.\nThe format should be roomId:reconnectionToken',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const room = await this.joinById(roomId, { reconnectionToken: token });
|
|
116
|
+
room.reconnectionToken = reconnectionToken;
|
|
117
|
+
return room;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async consumeSeatReservation(response: {
|
|
121
|
+
name: string;
|
|
122
|
+
sessionId: string;
|
|
123
|
+
roomId: string;
|
|
124
|
+
reconnectionToken?: string;
|
|
125
|
+
expiresAt?: number | string | Date;
|
|
126
|
+
}): Promise<CompatRoom> {
|
|
127
|
+
if (isExpiredReservation(response.expiresAt)) {
|
|
128
|
+
throw new MatchMakeError('Seat reservation expired', P2P_CLOSE_CODES.unauthorized);
|
|
129
|
+
}
|
|
130
|
+
const room = await this.joinOrCreate(response.name, {
|
|
131
|
+
sessionId: response.sessionId,
|
|
132
|
+
reconnectionToken: response.reconnectionToken,
|
|
133
|
+
});
|
|
134
|
+
room.roomId = response.roomId;
|
|
135
|
+
room.sessionId = response.sessionId;
|
|
136
|
+
room.reconnectionToken =
|
|
137
|
+
response.reconnectionToken ?? `${response.roomId}:${response.sessionId}`;
|
|
138
|
+
return room;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async getLatency(options: { pingCount?: number } = {}): Promise<number> {
|
|
142
|
+
if (this.url.startsWith('loopback://')) return 0;
|
|
143
|
+
const pingCount = Math.max(1, options.pingCount ?? 1);
|
|
144
|
+
const room = await this.joinOrCreate('__latency__').catch(() => undefined);
|
|
145
|
+
if (!room) return Number.POSITIVE_INFINITY;
|
|
146
|
+
const latencies: number[] = [];
|
|
147
|
+
for (let i = 0; i < pingCount; i++) {
|
|
148
|
+
latencies.push(await new Promise<number>((resolve) => room.ping(resolve)));
|
|
149
|
+
}
|
|
150
|
+
await room.leave();
|
|
151
|
+
return latencies.reduce((sum, latency) => sum + latency, 0) / latencies.length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async getAvailableRooms(roomName?: string): Promise<AvailableRoom[]> {
|
|
155
|
+
const roomNames = configuredP2P
|
|
156
|
+
? Object.keys(configuredP2P.rooms ?? {})
|
|
157
|
+
: [...loopbackRooms.keys()];
|
|
158
|
+
return roomNames
|
|
159
|
+
.filter((name) => roomName === undefined || name === roomName)
|
|
160
|
+
.map((name) => ({
|
|
161
|
+
clients: localP2PClientCounts.get(name) ?? 0,
|
|
162
|
+
maxClients: Number.POSITIVE_INFINITY,
|
|
163
|
+
metadata: {},
|
|
164
|
+
name,
|
|
165
|
+
roomId: hostedP2PRoomIds.get(name) ?? name,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async joinOrCreate(roomName: string, options?: unknown): Promise<CompatRoom> {
|
|
170
|
+
if (configuredP2P) {
|
|
171
|
+
reserveLocalP2PClient(roomName);
|
|
172
|
+
const engine = await import('./engine');
|
|
173
|
+
if (configuredP2P.mode.kind === 'p2p-host') {
|
|
174
|
+
const hostedRoomId = hostedP2PRoomIds.get(roomName);
|
|
175
|
+
if (hostedRoomId) {
|
|
176
|
+
const room = await engine.connectP2PJoinRoom(
|
|
177
|
+
{ ...configuredP2P.mode, kind: 'p2p-join', roomId: hostedRoomId },
|
|
178
|
+
{
|
|
179
|
+
url: this.url,
|
|
180
|
+
room: roomName,
|
|
181
|
+
joinOptions: options as Record<string, unknown> | undefined,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
releaseLocalP2PClientOnLeave(roomName, room);
|
|
185
|
+
return room;
|
|
186
|
+
}
|
|
187
|
+
const room = await engine.connectP2PHostRoom(configuredP2P.mode, configuredP2P.rooms, {
|
|
188
|
+
url: this.url,
|
|
189
|
+
room: roomName,
|
|
190
|
+
joinOptions: options as Record<string, unknown> | undefined,
|
|
191
|
+
});
|
|
192
|
+
hostedP2PRoomIds.set(roomName, room.roomId);
|
|
193
|
+
publishHostedP2PRoomId(room.roomId);
|
|
194
|
+
releaseLocalP2PClientOnLeave(roomName, room);
|
|
195
|
+
return room;
|
|
196
|
+
}
|
|
197
|
+
const room = await engine.connectP2PJoinRoom(configuredP2P.mode, {
|
|
198
|
+
url: this.url,
|
|
199
|
+
room: roomName,
|
|
200
|
+
joinOptions: options as Record<string, unknown> | undefined,
|
|
201
|
+
});
|
|
202
|
+
releaseLocalP2PClientOnLeave(roomName, room);
|
|
203
|
+
return room;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (this.url.startsWith('ws://') || this.url.startsWith('wss://')) {
|
|
207
|
+
const socket = await openWebSocket(this.url);
|
|
208
|
+
return connectRoomOverChannel(
|
|
209
|
+
createWebSocketPacketChannel(`client-${randomId()}`, socket),
|
|
210
|
+
roomName,
|
|
211
|
+
options,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!this.url.startsWith('loopback://')) {
|
|
216
|
+
throw new Error(`Unsupported @vgai/p2p-colyseus client URL: ${this.url}`);
|
|
217
|
+
}
|
|
218
|
+
const roomClass = loopbackRooms.get(roomName);
|
|
219
|
+
if (!roomClass) throw new Error(`Loopback room not registered: ${roomName}`);
|
|
220
|
+
|
|
221
|
+
// joinOrCreate semantics: the creating client's options reach onCreate.
|
|
222
|
+
const runtime = new UniversalRoomRuntime(roomClass, roomName, options);
|
|
223
|
+
const [hostChannel, clientChannel] = createLoopbackPair('client-1', 'host');
|
|
224
|
+
runtime.attach(hostChannel);
|
|
225
|
+
return connectRoomOverChannel(clientChannel, roomName, options);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function reserveLocalP2PClient(roomName: string): void {
|
|
230
|
+
const max = configuredP2P?.maxLocalClientsPerRoom;
|
|
231
|
+
if (max === undefined) {
|
|
232
|
+
localP2PClientCounts.set(roomName, (localP2PClientCounts.get(roomName) ?? 0) + 1);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const current = localP2PClientCounts.get(roomName) ?? 0;
|
|
236
|
+
if (current >= max) {
|
|
237
|
+
throw new Error(`P2P local client limit reached for room: ${roomName}`);
|
|
238
|
+
}
|
|
239
|
+
localP2PClientCounts.set(roomName, current + 1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function releaseLocalP2PClientOnLeave(roomName: string, room: CompatRoom): void {
|
|
243
|
+
let released = false;
|
|
244
|
+
const release = () => {
|
|
245
|
+
if (released) return;
|
|
246
|
+
released = true;
|
|
247
|
+
localP2PClientCounts.set(roomName, Math.max(0, (localP2PClientCounts.get(roomName) ?? 1) - 1));
|
|
248
|
+
};
|
|
249
|
+
room.onLeave.add(release);
|
|
250
|
+
const originalLeave = room.leave.bind(room);
|
|
251
|
+
room.leave = async (consented = true) => {
|
|
252
|
+
try {
|
|
253
|
+
return await originalLeave(consented);
|
|
254
|
+
} finally {
|
|
255
|
+
release();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function publishHostedP2PRoomId(roomId: string): void {
|
|
261
|
+
const maybeWindow = globalThis as unknown as {
|
|
262
|
+
window?: { __VGAI_P2P_COLYSEUS_ROOM_ID__?: string };
|
|
263
|
+
__VGAI_P2P_COLYSEUS_ROOM_ID__?: string;
|
|
264
|
+
};
|
|
265
|
+
maybeWindow.__VGAI_P2P_COLYSEUS_ROOM_ID__ = roomId;
|
|
266
|
+
if (maybeWindow.window) maybeWindow.window.__VGAI_P2P_COLYSEUS_ROOM_ID__ = roomId;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function publishP2PMode(mode: string): void {
|
|
270
|
+
const maybeWindow = globalThis as unknown as {
|
|
271
|
+
window?: { __VGAI_P2P_COLYSEUS_MODE__?: string };
|
|
272
|
+
__VGAI_P2P_COLYSEUS_MODE__?: string;
|
|
273
|
+
};
|
|
274
|
+
maybeWindow.__VGAI_P2P_COLYSEUS_MODE__ = mode;
|
|
275
|
+
if (maybeWindow.window) maybeWindow.window.__VGAI_P2P_COLYSEUS_MODE__ = mode;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const ColyseusSDK = Client;
|
|
279
|
+
|
|
280
|
+
export function getStateCallbacks(room: CompatRoom): ClientReplication {
|
|
281
|
+
return room.replication;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function registerSerializer(): void {}
|
|
285
|
+
|
|
286
|
+
export class CompatRoom {
|
|
287
|
+
readonly replication = new ClientReplication();
|
|
288
|
+
readonly onLeave = createSignal<(code?: number, reason?: string) => void>();
|
|
289
|
+
readonly onError = createSignal<(code: number, message?: string) => void>();
|
|
290
|
+
readonly onReconnect = createSignal<() => void>();
|
|
291
|
+
readonly onDrop = createSignal<(code?: number, reason?: string) => void>();
|
|
292
|
+
readonly onStateChange = createSignal<(state: Record<string, unknown>) => void>();
|
|
293
|
+
roomId = '';
|
|
294
|
+
sessionId = '';
|
|
295
|
+
reconnectionToken = '';
|
|
296
|
+
state: Record<string, unknown> = this.replication.state;
|
|
297
|
+
private readonly messages = new Map<string, Set<(payload: unknown) => void>>();
|
|
298
|
+
private readonly wildcardMessages = new Set<(type: string, payload: unknown) => void>();
|
|
299
|
+
private readonly pendingPings = new Map<number, (ms: number) => void>();
|
|
300
|
+
private clock = 0;
|
|
301
|
+
|
|
302
|
+
constructor(
|
|
303
|
+
readonly name: string,
|
|
304
|
+
private readonly channel: PacketChannel,
|
|
305
|
+
) {
|
|
306
|
+
this.channel.onEnvelope((envelope) => this.handleEnvelope(envelope));
|
|
307
|
+
this.channel.onClose((reason) => {
|
|
308
|
+
this.onDrop.emit(undefined, reason);
|
|
309
|
+
this.onLeave.emit(undefined, reason);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async join(options?: unknown): Promise<void> {
|
|
314
|
+
const requestId = crypto.randomUUID?.() ?? `join-${Date.now()}`;
|
|
315
|
+
const joined = new Promise<void>((resolve, reject) => {
|
|
316
|
+
const off = this.channel.onEnvelope((envelope) => {
|
|
317
|
+
if (envelope.kind === 'join-ok' && envelope.requestId === requestId) {
|
|
318
|
+
off();
|
|
319
|
+
this.roomId = this.name;
|
|
320
|
+
this.sessionId = envelope.sessionId;
|
|
321
|
+
this.reconnectionToken = `${this.roomId}:${this.sessionId}`;
|
|
322
|
+
this.clock = envelope.clock;
|
|
323
|
+
this.replication.applySnapshot(envelope.snapshot.state);
|
|
324
|
+
this.state = this.replication.state;
|
|
325
|
+
resolve();
|
|
326
|
+
} else if (envelope.kind === 'join-error' && envelope.requestId === requestId) {
|
|
327
|
+
off();
|
|
328
|
+
reject(new Error(envelope.message));
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
this.channel.send({ kind: 'join', requestId, room: this.name, options });
|
|
333
|
+
await joined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
send(type: string, payload?: unknown): void {
|
|
337
|
+
this.channel.send({ kind: 'message', type, payload });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
sendUnreliable<T = unknown>(type: string, payload?: T): void {
|
|
341
|
+
this.send(type, payload);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
sendBytes(type: string | number, bytes: Uint8Array): void {
|
|
345
|
+
this.send(String(type), bytes);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
ping(callback: (ms: number) => void): void {
|
|
349
|
+
const t = Date.now();
|
|
350
|
+
this.pendingPings.set(t, callback);
|
|
351
|
+
this.channel.send({ kind: 'ping', t });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
onMessage<T = unknown>(type: '*', handler: (type: string, payload: T) => void): () => void;
|
|
355
|
+
onMessage<T = unknown>(type: string, handler: (payload: T) => void): () => void;
|
|
356
|
+
onMessage<T = unknown>(
|
|
357
|
+
type: string,
|
|
358
|
+
handler: ((payload: T) => void) | ((type: string, payload: T) => void),
|
|
359
|
+
): () => void {
|
|
360
|
+
if (type === '*') {
|
|
361
|
+
const wrapped = handler as (type: string, payload: unknown) => void;
|
|
362
|
+
this.wildcardMessages.add(wrapped);
|
|
363
|
+
return () => this.wildcardMessages.delete(wrapped);
|
|
364
|
+
}
|
|
365
|
+
const handlers = this.messages.get(type) ?? new Set<(payload: unknown) => void>();
|
|
366
|
+
const wrapped = handler as (payload: unknown) => void;
|
|
367
|
+
handlers.add(wrapped);
|
|
368
|
+
this.messages.set(type, handlers);
|
|
369
|
+
return () => handlers.delete(wrapped);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async leave(_consented = true): Promise<number> {
|
|
373
|
+
const left = new Promise<number>((resolve) => {
|
|
374
|
+
this.onLeave.once((code) => resolve(code ?? 1000));
|
|
375
|
+
});
|
|
376
|
+
this.channel.send({ kind: 'leave', code: 1000 });
|
|
377
|
+
return left;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
removeAllListeners(): void {
|
|
381
|
+
this.messages.clear();
|
|
382
|
+
this.wildcardMessages.clear();
|
|
383
|
+
this.onLeave.clear();
|
|
384
|
+
this.onDrop.clear();
|
|
385
|
+
this.onError.clear();
|
|
386
|
+
this.onReconnect.clear();
|
|
387
|
+
this.onStateChange.clear();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private handleEnvelope(envelope: Envelope): void {
|
|
391
|
+
if (envelope.kind === 'state-snapshot') {
|
|
392
|
+
if (envelope.clock <= this.clock) return;
|
|
393
|
+
this.clock = envelope.clock;
|
|
394
|
+
this.replication.applySnapshot(envelope.snapshot.state);
|
|
395
|
+
this.state = this.replication.state;
|
|
396
|
+
this.onStateChange.emit(this.state);
|
|
397
|
+
} else if (envelope.kind === 'state-patch') {
|
|
398
|
+
if (envelope.clock <= this.clock) return;
|
|
399
|
+
if (envelope.clock > this.clock + 1) {
|
|
400
|
+
this.channel.send({ kind: 'state-resync', clock: this.clock });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.clock = envelope.clock;
|
|
404
|
+
const patched = applyStatePatch(this.replication.state, envelope.patch);
|
|
405
|
+
this.replication.applySnapshot(patched);
|
|
406
|
+
this.state = this.replication.state;
|
|
407
|
+
this.onStateChange.emit(this.state);
|
|
408
|
+
} else if (envelope.kind === 'message') {
|
|
409
|
+
if (envelope.type === 'error' && isErrorPayload(envelope.payload)) {
|
|
410
|
+
this.onError.emit(envelope.payload.code, envelope.payload.message);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
for (const handler of this.messages.get(envelope.type) ?? []) handler(envelope.payload);
|
|
414
|
+
for (const handler of this.wildcardMessages) handler(envelope.type, envelope.payload);
|
|
415
|
+
} else if (envelope.kind === 'leave') {
|
|
416
|
+
this.onLeave.emit(envelope.code, envelope.reason);
|
|
417
|
+
queueMicrotask(() => this.channel.close('left'));
|
|
418
|
+
} else if (envelope.kind === 'ping') {
|
|
419
|
+
this.channel.send({ kind: 'pong', t: envelope.t });
|
|
420
|
+
} else if (envelope.kind === 'pong') {
|
|
421
|
+
const callback = this.pendingPings.get(envelope.t);
|
|
422
|
+
if (callback) {
|
|
423
|
+
this.pendingPings.delete(envelope.t);
|
|
424
|
+
callback(Math.max(0, Date.now() - envelope.t));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isExpiredReservation(expiresAt: number | string | Date | undefined): boolean {
|
|
431
|
+
if (expiresAt === undefined) return false;
|
|
432
|
+
const timestamp =
|
|
433
|
+
expiresAt instanceof Date
|
|
434
|
+
? expiresAt.getTime()
|
|
435
|
+
: typeof expiresAt === 'string'
|
|
436
|
+
? Date.parse(expiresAt)
|
|
437
|
+
: expiresAt;
|
|
438
|
+
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isErrorPayload(value: unknown): value is { code: number; message?: string } {
|
|
442
|
+
return (
|
|
443
|
+
typeof value === 'object' &&
|
|
444
|
+
value !== null &&
|
|
445
|
+
typeof (value as { code?: unknown }).code === 'number'
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function connectRoomOverChannel(
|
|
450
|
+
channel: PacketChannel,
|
|
451
|
+
roomName: string,
|
|
452
|
+
options?: unknown,
|
|
453
|
+
): Promise<CompatRoom> {
|
|
454
|
+
const room = new CompatRoom(roomName, channel);
|
|
455
|
+
await room.join(options);
|
|
456
|
+
return room;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function openWebSocket(url: string): Promise<WebSocket> {
|
|
460
|
+
if (typeof WebSocket === 'undefined') {
|
|
461
|
+
throw new Error('WebSocket is not available in this JavaScript runtime');
|
|
462
|
+
}
|
|
463
|
+
const socket = new WebSocket(url);
|
|
464
|
+
if (socket.readyState === WebSocket.OPEN) return socket;
|
|
465
|
+
await new Promise<void>((resolve, reject) => {
|
|
466
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
467
|
+
socket.addEventListener(
|
|
468
|
+
'error',
|
|
469
|
+
() => reject(new Error(`WebSocket connection failed: ${url}`)),
|
|
470
|
+
{
|
|
471
|
+
once: true,
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
return socket;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function randomId(): string {
|
|
479
|
+
return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
|
480
|
+
}
|