@triformine/nexis-sdk 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/bun.lock ADDED
@@ -0,0 +1,111 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "@triformine/nexis-sdk",
7
+ "dependencies": {
8
+ "msgpackr": "1.11.8",
9
+ },
10
+ "devDependencies": {
11
+ "typescript": "5.9.3",
12
+ },
13
+ },
14
+ },
15
+ "packages": {
16
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": [
17
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3",
18
+ "",
19
+ { "os": "darwin", "cpu": "arm64" },
20
+ "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
21
+ ],
22
+
23
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": [
24
+ "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3",
25
+ "",
26
+ { "os": "darwin", "cpu": "x64" },
27
+ "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
28
+ ],
29
+
30
+ "@msgpackr-extract/msgpackr-extract-linux-arm": [
31
+ "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3",
32
+ "",
33
+ { "os": "linux", "cpu": "arm" },
34
+ "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
35
+ ],
36
+
37
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": [
38
+ "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3",
39
+ "",
40
+ { "os": "linux", "cpu": "arm64" },
41
+ "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
42
+ ],
43
+
44
+ "@msgpackr-extract/msgpackr-extract-linux-x64": [
45
+ "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3",
46
+ "",
47
+ { "os": "linux", "cpu": "x64" },
48
+ "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
49
+ ],
50
+
51
+ "@msgpackr-extract/msgpackr-extract-win32-x64": [
52
+ "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3",
53
+ "",
54
+ { "os": "win32", "cpu": "x64" },
55
+ "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
56
+ ],
57
+
58
+ "detect-libc": [
59
+ "detect-libc@2.1.2",
60
+ "",
61
+ {},
62
+ "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
63
+ ],
64
+
65
+ "msgpackr": [
66
+ "msgpackr@1.11.8",
67
+ "",
68
+ { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } },
69
+ "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==",
70
+ ],
71
+
72
+ "msgpackr-extract": [
73
+ "msgpackr-extract@3.0.3",
74
+ "",
75
+ {
76
+ "dependencies": { "node-gyp-build-optional-packages": "5.2.2" },
77
+ "optionalDependencies": {
78
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
79
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
80
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
81
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
82
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
83
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3",
84
+ },
85
+ "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" },
86
+ },
87
+ "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
88
+ ],
89
+
90
+ "node-gyp-build-optional-packages": [
91
+ "node-gyp-build-optional-packages@5.2.2",
92
+ "",
93
+ {
94
+ "dependencies": { "detect-libc": "^2.0.1" },
95
+ "bin": {
96
+ "node-gyp-build-optional-packages": "bin.js",
97
+ "node-gyp-build-optional-packages-optional": "optional.js",
98
+ "node-gyp-build-optional-packages-test": "build-test.js",
99
+ },
100
+ },
101
+ "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
102
+ ],
103
+
104
+ "typescript": [
105
+ "typescript@5.9.3",
106
+ "",
107
+ { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } },
108
+ "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
109
+ ],
110
+ },
111
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@triformine/nexis-sdk",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "author": "TriForMine <quentin@triformine.dev> (https://github.com/TriForMine/)",
6
+ "license": "Apache-2.0",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "type": "module",
11
+ "main": "src/index.ts",
12
+ "types": "src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "packageManager": "bun@1.3.9",
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "build": "bunx tsc -p tsconfig.json"
20
+ },
21
+ "dependencies": {
22
+ "msgpackr": "1.11.8"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "5.9.3"
26
+ }
27
+ }
package/src/client.ts ADDED
@@ -0,0 +1,949 @@
1
+ import {
2
+ applyPatch,
3
+ computeStateChecksum,
4
+ parsePatchPayload,
5
+ parseSnapshotPayload,
6
+ } from "./patch";
7
+ import { JsonCodec, MsgpackCodec, codecFor, type Codec } from "./codec";
8
+ import { RpcClient, UnknownRidError } from "./rpc";
9
+ import type {
10
+ ConnectOptions,
11
+ Envelope,
12
+ HandshakeRequest,
13
+ MatchFound,
14
+ MatchmakingDequeueResponse,
15
+ MatchmakingQueueResponse,
16
+ RoomListResponse,
17
+ RoomMessagePayload,
18
+ RoomMessageType,
19
+ } from "./types";
20
+
21
+ const DEFAULT_VERSION = 1;
22
+ const DEFAULT_RECONNECT_INITIAL_DELAY_MS = 250;
23
+ const DEFAULT_RECONNECT_MAX_DELAY_MS = 3_000;
24
+ const DEFAULT_RECONNECT_MAX_ATTEMPTS = 20;
25
+
26
+ type EventHandler = (message: Envelope) => void;
27
+ type StateHandler = (state: Record<string, unknown>) => void;
28
+ type MatchFoundHandler = (match: MatchFound, message: Envelope) => void;
29
+ type RoomMessageHandler = (data: unknown, envelope: Envelope) => void;
30
+ type SelectorHandler = (value: unknown, state: Record<string, unknown>) => void;
31
+ type SupportedCodec = "json" | "msgpack";
32
+
33
+ type SelectorRegistration = {
34
+ path: string;
35
+ callback: SelectorHandler;
36
+ lastValue: unknown;
37
+ };
38
+
39
+ type ResolvedConnectOptions = ConnectOptions & {
40
+ codecs: Array<"msgpack" | "json">;
41
+ autoJoinMatchedRoom: boolean;
42
+ autoReconnect: boolean;
43
+ reconnectInitialDelayMs: number;
44
+ reconnectMaxDelayMs: number;
45
+ reconnectMaxAttempts: number;
46
+ };
47
+
48
+ export type RoomStateChangeSubscription = {
49
+ (callback: StateHandler): () => void;
50
+ once(callback: StateHandler): () => void;
51
+ select(path: string, callback: SelectorHandler): () => void;
52
+ };
53
+
54
+ function normalizeConnectOptions(
55
+ options: ConnectOptions,
56
+ ): ResolvedConnectOptions {
57
+ const reconnectInitialDelayMs = Math.max(
58
+ 50,
59
+ options.reconnectInitialDelayMs ?? DEFAULT_RECONNECT_INITIAL_DELAY_MS,
60
+ );
61
+ const reconnectMaxDelayMs = Math.max(
62
+ reconnectInitialDelayMs,
63
+ options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
64
+ );
65
+ const reconnectMaxAttempts = Math.max(
66
+ 1,
67
+ options.reconnectMaxAttempts ?? DEFAULT_RECONNECT_MAX_ATTEMPTS,
68
+ );
69
+ return {
70
+ ...options,
71
+ codecs: options.codecs ?? ["msgpack", "json"],
72
+ autoJoinMatchedRoom: options.autoJoinMatchedRoom ?? false,
73
+ autoReconnect: options.autoReconnect ?? true,
74
+ reconnectInitialDelayMs,
75
+ reconnectMaxDelayMs,
76
+ reconnectMaxAttempts,
77
+ };
78
+ }
79
+
80
+ function readCodecName(message: Envelope): SupportedCodec {
81
+ const payload = message.p;
82
+ if (
83
+ payload &&
84
+ typeof payload === "object" &&
85
+ "codec" in payload &&
86
+ (payload as { codec?: unknown }).codec === "msgpack"
87
+ ) {
88
+ return "msgpack";
89
+ }
90
+ return "json";
91
+ }
92
+
93
+ function readSessionId(message: Envelope): string | undefined {
94
+ const payload = message.p;
95
+ if (
96
+ payload &&
97
+ typeof payload === "object" &&
98
+ "session_id" in payload &&
99
+ typeof (payload as { session_id?: unknown }).session_id === "string"
100
+ ) {
101
+ return (payload as { session_id: string }).session_id;
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ function readErrorReason(message: Envelope): string {
107
+ const payload = message.p;
108
+ if (
109
+ payload &&
110
+ typeof payload === "object" &&
111
+ "reason" in payload &&
112
+ typeof (payload as { reason?: unknown }).reason === "string"
113
+ ) {
114
+ return (payload as { reason: string }).reason;
115
+ }
116
+ return "server returned error";
117
+ }
118
+
119
+ function isStringArray(value: unknown): value is string[] {
120
+ return (
121
+ Array.isArray(value) && value.every((item) => typeof item === "string")
122
+ );
123
+ }
124
+
125
+ function deepEqual(left: unknown, right: unknown): boolean {
126
+ return JSON.stringify(left) === JSON.stringify(right);
127
+ }
128
+
129
+ function toJsonValue(bytes: Uint8Array): string {
130
+ let binary = "";
131
+ for (const value of bytes) {
132
+ binary += String.fromCharCode(value);
133
+ }
134
+ return btoa(binary);
135
+ }
136
+
137
+ function fromJsonValue(value: unknown): Uint8Array | null {
138
+ if (typeof value !== "string") {
139
+ return null;
140
+ }
141
+
142
+ try {
143
+ const decoded = atob(value);
144
+ const bytes = new Uint8Array(decoded.length);
145
+ for (let i = 0; i < decoded.length; i += 1) {
146
+ bytes[i] = decoded.charCodeAt(i);
147
+ }
148
+ return bytes;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function readRoomMessagePayload(payload: unknown): RoomMessagePayload | null {
155
+ if (!payload || typeof payload !== "object") {
156
+ return null;
157
+ }
158
+ const type = (payload as { type?: unknown }).type;
159
+ if (typeof type !== "string" && typeof type !== "number") {
160
+ return null;
161
+ }
162
+ const data = (payload as { data?: unknown }).data;
163
+ return { type, data };
164
+ }
165
+
166
+ export function parseMatchFoundPayload(payload: unknown): MatchFound | null {
167
+ if (!payload || typeof payload !== "object") {
168
+ return null;
169
+ }
170
+
171
+ const room = (payload as { room?: unknown }).room;
172
+ const roomType = (payload as { room_type?: unknown }).room_type;
173
+ const size = (payload as { size?: unknown }).size;
174
+ const participants = (payload as { participants?: unknown }).participants;
175
+ if (
176
+ typeof room !== "string" ||
177
+ typeof roomType !== "string" ||
178
+ typeof size !== "number" ||
179
+ !Number.isFinite(size) ||
180
+ !isStringArray(participants)
181
+ ) {
182
+ return null;
183
+ }
184
+
185
+ return {
186
+ room,
187
+ roomType,
188
+ size,
189
+ participants,
190
+ };
191
+ }
192
+
193
+ export class NexisRoom {
194
+ readonly id: string;
195
+ readonly onStateChange: RoomStateChangeSubscription;
196
+
197
+ constructor(
198
+ private readonly client: NexisClient,
199
+ roomId: string,
200
+ ) {
201
+ this.id = roomId;
202
+ this.onStateChange = this.buildStateChangeSubscription();
203
+ }
204
+
205
+ get state(): Record<string, unknown> {
206
+ return this.client.getRoomState(this.id);
207
+ }
208
+
209
+ send(type: RoomMessageType, message: unknown): void {
210
+ this.client.sendRoomMessage(this.id, type, message);
211
+ }
212
+
213
+ sendBytes(type: RoomMessageType, bytes: Uint8Array | number[]): void {
214
+ const normalized =
215
+ bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
216
+ this.client.sendRoomMessageBytes(this.id, type, normalized);
217
+ }
218
+
219
+ onMessage(type: RoomMessageType, callback: RoomMessageHandler): () => void {
220
+ return this.client.onRoomMessage(this.id, type, callback);
221
+ }
222
+
223
+ private buildStateChangeSubscription(): RoomStateChangeSubscription {
224
+ const subscribe = (callback: StateHandler): (() => void) =>
225
+ this.client.onRoomState(this.id, callback);
226
+
227
+ subscribe.once = (callback: StateHandler): (() => void) =>
228
+ this.client.onRoomStateOnce(this.id, callback);
229
+
230
+ subscribe.select = (
231
+ path: string,
232
+ callback: SelectorHandler,
233
+ ): (() => void) => this.client.onRoomStateSelect(this.id, path, callback);
234
+
235
+ return subscribe;
236
+ }
237
+ }
238
+
239
+ export class NexisClient {
240
+ private socket: WebSocket;
241
+ private readonly url: string;
242
+ private readonly connectOptions: ResolvedConnectOptions;
243
+ private readonly rpc = new RpcClient();
244
+ private codec: Codec;
245
+ private readonly eventHandlers = new Map<string, Set<EventHandler>>();
246
+ private readonly stateHandlers = new Set<StateHandler>();
247
+ private readonly roomStateHandlers = new Map<string, Set<StateHandler>>();
248
+ private readonly roomStateSelectors = new Map<
249
+ string,
250
+ Set<SelectorRegistration>
251
+ >();
252
+ private readonly roomMessageHandlers = new Map<
253
+ string,
254
+ Map<string, Set<RoomMessageHandler>>
255
+ >();
256
+ private readonly roomStates = new Map<string, Record<string, unknown>>();
257
+ private readonly roomSeq = new Map<string, number>();
258
+ private readonly roomChecksum = new Map<string, string>();
259
+ private sessionId: string | undefined;
260
+ private readonly autoJoinMatchedRoom: boolean;
261
+ private reconnecting = false;
262
+ private disposed = false;
263
+
264
+ private constructor(
265
+ url: string,
266
+ socket: WebSocket,
267
+ codec: Codec,
268
+ sessionId: string | undefined,
269
+ options: ResolvedConnectOptions,
270
+ ) {
271
+ this.url = url;
272
+ this.socket = socket;
273
+ this.codec = codec;
274
+ this.sessionId = sessionId;
275
+ this.connectOptions = options;
276
+ this.autoJoinMatchedRoom = options.autoJoinMatchedRoom;
277
+ this.attachSocket(socket);
278
+ }
279
+
280
+ static connect(url: string, options: ConnectOptions): Promise<NexisClient> {
281
+ const resolved = normalizeConnectOptions(options);
282
+ return NexisClient.openSocketAndHandshake(
283
+ url,
284
+ resolved,
285
+ resolved.sessionId,
286
+ ).then(
287
+ ({ socket, codec, sessionId }) =>
288
+ new NexisClient(url, socket, codec, sessionId, resolved),
289
+ );
290
+ }
291
+
292
+ close(): void {
293
+ this.disposed = true;
294
+ this.socket.close();
295
+ }
296
+
297
+ private attachSocket(socket: WebSocket): void {
298
+ this.socket = socket;
299
+ this.socket.addEventListener("message", (event) => {
300
+ void this.onRawMessage(event.data);
301
+ });
302
+ this.socket.addEventListener("close", () => {
303
+ this.rpc.rejectAll(new Error("socket closed"));
304
+ if (!this.disposed && this.connectOptions.autoReconnect) {
305
+ void this.tryReconnect();
306
+ }
307
+ });
308
+ }
309
+
310
+ private async tryReconnect(): Promise<void> {
311
+ if (this.reconnecting || this.disposed) {
312
+ return;
313
+ }
314
+ this.reconnecting = true;
315
+ this.dispatchEvent({
316
+ v: DEFAULT_VERSION,
317
+ t: "reconnect.start",
318
+ p: { session_id: this.sessionId },
319
+ });
320
+
321
+ let attempt = 0;
322
+ let delayMs = this.connectOptions.reconnectInitialDelayMs;
323
+ while (
324
+ !this.disposed &&
325
+ attempt < this.connectOptions.reconnectMaxAttempts
326
+ ) {
327
+ attempt += 1;
328
+ await NexisClient.wait(delayMs);
329
+ try {
330
+ const reconnect = await NexisClient.openSocketAndHandshake(
331
+ this.url,
332
+ this.connectOptions,
333
+ this.sessionId,
334
+ );
335
+ this.codec = reconnect.codec;
336
+ this.sessionId = reconnect.sessionId ?? this.sessionId;
337
+ this.attachSocket(reconnect.socket);
338
+ this.dispatchEvent({
339
+ v: DEFAULT_VERSION,
340
+ t: "reconnect.ok",
341
+ p: { attempt, session_id: this.sessionId },
342
+ });
343
+ this.reconnecting = false;
344
+ return;
345
+ } catch {
346
+ this.dispatchEvent({
347
+ v: DEFAULT_VERSION,
348
+ t: "reconnect.retry",
349
+ p: { attempt },
350
+ });
351
+ }
352
+ delayMs = Math.min(delayMs * 2, this.connectOptions.reconnectMaxDelayMs);
353
+ }
354
+
355
+ this.reconnecting = false;
356
+ this.dispatchEvent({
357
+ v: DEFAULT_VERSION,
358
+ t: "reconnect.failed",
359
+ p: { session_id: this.sessionId },
360
+ });
361
+ }
362
+
363
+ getSessionId(): string | undefined {
364
+ return this.sessionId;
365
+ }
366
+
367
+ room(roomId: string): NexisRoom {
368
+ return new NexisRoom(this, roomId);
369
+ }
370
+
371
+ async joinOrCreate(
372
+ roomType: string,
373
+ options?: { roomId?: string } & Record<string, unknown>,
374
+ ): Promise<NexisRoom> {
375
+ const roomId =
376
+ typeof options?.roomId === "string" ? options.roomId : undefined;
377
+ const response = await this.sendRPC(
378
+ "room.join_or_create",
379
+ {
380
+ roomType,
381
+ roomId,
382
+ options,
383
+ },
384
+ roomId,
385
+ );
386
+ if (
387
+ response &&
388
+ typeof response === "object" &&
389
+ typeof (response as { room?: unknown }).room === "string"
390
+ ) {
391
+ return this.room((response as { room: string }).room);
392
+ }
393
+ if (roomId) {
394
+ return this.room(roomId);
395
+ }
396
+ return this.room(`${roomType}:default`);
397
+ }
398
+
399
+ listRooms(roomType?: string): Promise<RoomListResponse> {
400
+ return this.sendRPC(
401
+ "room.list",
402
+ roomType ? { roomType } : {},
403
+ ) as Promise<RoomListResponse>;
404
+ }
405
+
406
+ enqueueMatchmaking(
407
+ roomType: string,
408
+ size = 2,
409
+ ): Promise<MatchmakingQueueResponse> {
410
+ return this.sendRPC("matchmaking.enqueue", {
411
+ roomType,
412
+ size,
413
+ }) as Promise<MatchmakingQueueResponse>;
414
+ }
415
+
416
+ dequeueMatchmaking(): Promise<MatchmakingDequeueResponse> {
417
+ return this.sendRPC(
418
+ "matchmaking.dequeue",
419
+ {},
420
+ ) as Promise<MatchmakingDequeueResponse>;
421
+ }
422
+
423
+ onStateChange(callback: StateHandler): () => void {
424
+ this.stateHandlers.add(callback);
425
+ return () => this.stateHandlers.delete(callback);
426
+ }
427
+
428
+ onEvent(type: string, callback: EventHandler): () => void {
429
+ const handlers = this.eventHandlers.get(type) ?? new Set<EventHandler>();
430
+ handlers.add(callback);
431
+ this.eventHandlers.set(type, handlers);
432
+ return () => {
433
+ const current = this.eventHandlers.get(type);
434
+ if (!current) {
435
+ return;
436
+ }
437
+ current.delete(callback);
438
+ if (current.size === 0) {
439
+ this.eventHandlers.delete(type);
440
+ }
441
+ };
442
+ }
443
+
444
+ onMatchFound(callback: MatchFoundHandler): () => void {
445
+ return this.onEvent("match.found", (message) => {
446
+ const parsed = parseMatchFoundPayload(message.p);
447
+ if (!parsed) {
448
+ return;
449
+ }
450
+ callback(parsed, message);
451
+ });
452
+ }
453
+
454
+ sendRPC(type: string, payload: unknown, room?: string): Promise<unknown> {
455
+ const { message, promise } = this.rpc.createRequest(type, payload, room);
456
+ this.sendEnvelope(message);
457
+ return promise;
458
+ }
459
+
460
+ getRoomState(roomId: string): Record<string, unknown> {
461
+ return this.roomStates.get(roomId) ?? {};
462
+ }
463
+
464
+ sendRoomMessage(roomId: string, type: RoomMessageType, data: unknown): void {
465
+ this.sendEnvelope({
466
+ v: DEFAULT_VERSION,
467
+ t: "room.message",
468
+ room: roomId,
469
+ p: { type: String(type), data },
470
+ });
471
+ }
472
+
473
+ sendRoomMessageBytes(
474
+ roomId: string,
475
+ type: RoomMessageType,
476
+ data: Uint8Array,
477
+ ): void {
478
+ this.sendEnvelope({
479
+ v: DEFAULT_VERSION,
480
+ t: "room.message.bytes",
481
+ room: roomId,
482
+ p: { type: String(type), data_b64: toJsonValue(data) },
483
+ });
484
+ }
485
+
486
+ onRoomMessage(
487
+ roomId: string,
488
+ type: RoomMessageType,
489
+ callback: RoomMessageHandler,
490
+ ): () => void {
491
+ const key = String(type);
492
+ const byType = this.roomMessageHandlers.get(roomId) ?? new Map();
493
+ const handlers = byType.get(key) ?? new Set<RoomMessageHandler>();
494
+ handlers.add(callback);
495
+ byType.set(key, handlers);
496
+ this.roomMessageHandlers.set(roomId, byType);
497
+
498
+ return () => {
499
+ const roomHandlers = this.roomMessageHandlers.get(roomId);
500
+ if (!roomHandlers) {
501
+ return;
502
+ }
503
+ const typeHandlers = roomHandlers.get(key);
504
+ if (!typeHandlers) {
505
+ return;
506
+ }
507
+ typeHandlers.delete(callback);
508
+ if (typeHandlers.size === 0) {
509
+ roomHandlers.delete(key);
510
+ }
511
+ if (roomHandlers.size === 0) {
512
+ this.roomMessageHandlers.delete(roomId);
513
+ }
514
+ };
515
+ }
516
+
517
+ onRoomState(roomId: string, callback: StateHandler): () => void {
518
+ const handlers =
519
+ this.roomStateHandlers.get(roomId) ?? new Set<StateHandler>();
520
+ handlers.add(callback);
521
+ this.roomStateHandlers.set(roomId, handlers);
522
+ if (this.roomStates.has(roomId)) {
523
+ callback(this.roomStates.get(roomId) ?? {});
524
+ }
525
+ return () => {
526
+ const current = this.roomStateHandlers.get(roomId);
527
+ if (!current) {
528
+ return;
529
+ }
530
+ current.delete(callback);
531
+ if (current.size === 0) {
532
+ this.roomStateHandlers.delete(roomId);
533
+ }
534
+ };
535
+ }
536
+
537
+ onRoomStateOnce(roomId: string, callback: StateHandler): () => void {
538
+ let disposed = false;
539
+ const off = this.onRoomState(roomId, (state) => {
540
+ if (disposed) {
541
+ return;
542
+ }
543
+ disposed = true;
544
+ off();
545
+ callback(state);
546
+ });
547
+ return () => {
548
+ disposed = true;
549
+ off();
550
+ };
551
+ }
552
+
553
+ onRoomStateSelect(
554
+ roomId: string,
555
+ path: string,
556
+ callback: SelectorHandler,
557
+ ): () => void {
558
+ const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
559
+ const currentState = this.getRoomState(roomId);
560
+ const registration: SelectorRegistration = {
561
+ path: normalizedPath,
562
+ callback,
563
+ lastValue: currentState[normalizedPath],
564
+ };
565
+ const selectors = this.roomStateSelectors.get(roomId) ?? new Set();
566
+ selectors.add(registration);
567
+ this.roomStateSelectors.set(roomId, selectors);
568
+
569
+ return () => {
570
+ const current = this.roomStateSelectors.get(roomId);
571
+ if (!current) {
572
+ return;
573
+ }
574
+ current.delete(registration);
575
+ if (current.size === 0) {
576
+ this.roomStateSelectors.delete(roomId);
577
+ }
578
+ };
579
+ }
580
+
581
+ private sendEnvelope(message: Envelope): void {
582
+ const bytes = this.codec.encode(message);
583
+ this.socket.send(bytes);
584
+ }
585
+
586
+ private dispatchEvent(message: Envelope): void {
587
+ const handlers = this.eventHandlers.get(message.t);
588
+ if (!handlers) {
589
+ return;
590
+ }
591
+
592
+ for (const handler of handlers) {
593
+ handler(message);
594
+ }
595
+ }
596
+
597
+ private dispatchState(
598
+ roomId: string,
599
+ nextState: Record<string, unknown>,
600
+ prevState: Record<string, unknown>,
601
+ ): void {
602
+ for (const handler of this.stateHandlers) {
603
+ handler(nextState);
604
+ }
605
+
606
+ const roomHandlers = this.roomStateHandlers.get(roomId);
607
+ if (roomHandlers) {
608
+ for (const handler of roomHandlers) {
609
+ handler(nextState);
610
+ }
611
+ }
612
+
613
+ const selectors = this.roomStateSelectors.get(roomId);
614
+ if (selectors) {
615
+ for (const registration of selectors) {
616
+ const nextValue = nextState[registration.path];
617
+ const prevValue = prevState[registration.path];
618
+ if (!deepEqual(nextValue, prevValue)) {
619
+ registration.lastValue = nextValue;
620
+ registration.callback(nextValue, nextState);
621
+ }
622
+ }
623
+ }
624
+ }
625
+
626
+ private dispatchRoomMessage(message: Envelope): void {
627
+ if (!message.room) {
628
+ return;
629
+ }
630
+
631
+ const payload = readRoomMessagePayload(message.p);
632
+ if (!payload) {
633
+ return;
634
+ }
635
+
636
+ const byType = this.roomMessageHandlers.get(message.room);
637
+ if (!byType) {
638
+ return;
639
+ }
640
+
641
+ const handlers = byType.get(String(payload.type));
642
+ if (!handlers) {
643
+ return;
644
+ }
645
+ for (const handler of handlers) {
646
+ handler(payload.data, message);
647
+ }
648
+ }
649
+
650
+ private async onRawMessage(raw: unknown): Promise<void> {
651
+ const bytes = await NexisClient.toBytes(raw);
652
+ if (!bytes) {
653
+ return;
654
+ }
655
+
656
+ let message: Envelope;
657
+ try {
658
+ message = this.codec.decode(bytes);
659
+ } catch {
660
+ return;
661
+ }
662
+
663
+ if (message.t === "rpc.response") {
664
+ try {
665
+ this.rpc.resolveResponse(message);
666
+ } catch (error) {
667
+ if (error instanceof UnknownRidError) {
668
+ this.dispatchEvent({
669
+ v: DEFAULT_VERSION,
670
+ t: "error",
671
+ p: { reason: error.message },
672
+ });
673
+ return;
674
+ }
675
+ throw error;
676
+ }
677
+ return;
678
+ }
679
+
680
+ if (message.t === "state.snapshot") {
681
+ if (!message.room) {
682
+ return;
683
+ }
684
+ const snapshot = parseSnapshotPayload(message.p);
685
+ if (!snapshot) {
686
+ return;
687
+ }
688
+ const computedChecksum = await computeStateChecksum(snapshot.state);
689
+ if (snapshot.checksum && snapshot.checksum !== computedChecksum) {
690
+ this.sendEnvelope({
691
+ v: DEFAULT_VERSION,
692
+ t: "state.resync",
693
+ room: message.room,
694
+ p: { since: this.roomSeq.get(message.room) ?? 0 },
695
+ });
696
+ return;
697
+ }
698
+ const checksum = snapshot.checksum ?? computedChecksum;
699
+
700
+ const prevState = this.roomStates.get(message.room) ?? {};
701
+ this.roomStates.set(message.room, snapshot.state);
702
+ this.roomSeq.set(message.room, snapshot.seq);
703
+ this.roomChecksum.set(message.room, checksum);
704
+ this.dispatchState(message.room, snapshot.state, prevState);
705
+ this.sendEnvelope({
706
+ v: DEFAULT_VERSION,
707
+ t: "state.ack",
708
+ room: message.room,
709
+ p: { seq: snapshot.seq, checksum },
710
+ });
711
+ return;
712
+ }
713
+
714
+ if (message.t === "state.patch") {
715
+ if (!message.room) {
716
+ return;
717
+ }
718
+ const parsedPatch = parsePatchPayload(message.p);
719
+ if (!parsedPatch) {
720
+ return;
721
+ }
722
+
723
+ const currentSeq = this.roomSeq.get(message.room) ?? 0;
724
+ const patchSeq = parsedPatch.seq > 0 ? parsedPatch.seq : currentSeq + 1;
725
+
726
+ if (patchSeq <= currentSeq) {
727
+ return;
728
+ }
729
+
730
+ if (patchSeq > currentSeq + 1) {
731
+ this.sendEnvelope({
732
+ v: DEFAULT_VERSION,
733
+ t: "state.resync",
734
+ room: message.room,
735
+ p: { since: currentSeq },
736
+ });
737
+ return;
738
+ }
739
+
740
+ const currentState = this.roomStates.get(message.room) ?? {};
741
+ const nextState = applyPatch(currentState, parsedPatch.ops);
742
+ let localChecksum: string | undefined;
743
+ if (parsedPatch.checksum) {
744
+ localChecksum = await computeStateChecksum(nextState);
745
+ if (parsedPatch.checksum !== localChecksum) {
746
+ this.sendEnvelope({
747
+ v: DEFAULT_VERSION,
748
+ t: "state.resync",
749
+ room: message.room,
750
+ p: {
751
+ since: currentSeq,
752
+ checksum: this.roomChecksum.get(message.room),
753
+ },
754
+ });
755
+ return;
756
+ }
757
+ }
758
+
759
+ this.roomStates.set(message.room, nextState);
760
+ this.roomSeq.set(message.room, patchSeq);
761
+ if (parsedPatch.checksum) {
762
+ this.roomChecksum.set(message.room, parsedPatch.checksum);
763
+ } else if (localChecksum) {
764
+ this.roomChecksum.set(message.room, localChecksum);
765
+ }
766
+ this.dispatchState(message.room, nextState, currentState);
767
+ this.sendEnvelope({
768
+ v: DEFAULT_VERSION,
769
+ t: "state.ack",
770
+ room: message.room,
771
+ p: parsedPatch.checksum
772
+ ? { seq: patchSeq, checksum: this.roomChecksum.get(message.room) }
773
+ : { seq: patchSeq },
774
+ });
775
+ return;
776
+ }
777
+
778
+ if (this.autoJoinMatchedRoom && message.t === "match.found") {
779
+ const parsed = parseMatchFoundPayload(message.p);
780
+ if (parsed) {
781
+ void this.joinOrCreate(parsed.roomType, { roomId: parsed.room }).catch(
782
+ () => undefined,
783
+ );
784
+ }
785
+ }
786
+
787
+ if (message.t === "room.message" && message.room) {
788
+ this.dispatchRoomMessage(message);
789
+ }
790
+
791
+ this.dispatchEvent(message);
792
+ }
793
+
794
+ private static openSocketAndHandshake(
795
+ url: string,
796
+ options: ResolvedConnectOptions,
797
+ sessionIdOverride?: string,
798
+ ): Promise<{
799
+ socket: WebSocket;
800
+ codec: Codec;
801
+ sessionId: string | undefined;
802
+ }> {
803
+ const socket = new WebSocket(url);
804
+ const jsonCodec = new JsonCodec();
805
+ const msgpackCodec = new MsgpackCodec();
806
+
807
+ return new Promise((resolve, reject) => {
808
+ let settled = false;
809
+ const handshakeSessionId = sessionIdOverride ?? options.sessionId;
810
+
811
+ const onOpen = () => {
812
+ const handshake: HandshakeRequest = {
813
+ v: DEFAULT_VERSION,
814
+ codecs: options.codecs,
815
+ project_id: options.projectId?.trim() || "anonymous",
816
+ token: options.token?.trim() || "",
817
+ session_id: handshakeSessionId,
818
+ };
819
+ socket.send(JSON.stringify(handshake));
820
+ };
821
+
822
+ const onError = () => {
823
+ finishReject(new Error("socket connection failed"));
824
+ };
825
+
826
+ const onClose = () => {
827
+ finishReject(new Error("socket closed before handshake"));
828
+ };
829
+
830
+ const onMessage = async (event: MessageEvent) => {
831
+ try {
832
+ const message = await NexisClient.decodeHandshakeMessage(
833
+ event.data,
834
+ jsonCodec,
835
+ msgpackCodec,
836
+ );
837
+ if (!message) {
838
+ return;
839
+ }
840
+
841
+ if (message.t === "error") {
842
+ finishReject(new Error(readErrorReason(message)));
843
+ return;
844
+ }
845
+
846
+ if (message.t !== "handshake.ok") {
847
+ return;
848
+ }
849
+
850
+ const negotiatedCodec = readCodecName(message);
851
+ const sessionId = readSessionId(message) ?? handshakeSessionId;
852
+ finishResolve({
853
+ socket,
854
+ codec: codecFor(negotiatedCodec),
855
+ sessionId,
856
+ });
857
+ } catch (error) {
858
+ finishReject(new Error(`handshake decode failed: ${String(error)}`));
859
+ }
860
+ };
861
+
862
+ const cleanup = () => {
863
+ socket.removeEventListener("open", onOpen);
864
+ socket.removeEventListener("error", onError);
865
+ socket.removeEventListener("close", onClose);
866
+ socket.removeEventListener("message", onMessage);
867
+ };
868
+
869
+ const finishResolve = (result: {
870
+ socket: WebSocket;
871
+ codec: Codec;
872
+ sessionId: string | undefined;
873
+ }) => {
874
+ if (settled) {
875
+ return;
876
+ }
877
+ settled = true;
878
+ cleanup();
879
+ resolve(result);
880
+ };
881
+
882
+ const finishReject = (error: Error) => {
883
+ if (settled) {
884
+ return;
885
+ }
886
+ settled = true;
887
+ cleanup();
888
+ reject(error);
889
+ };
890
+
891
+ socket.addEventListener("open", onOpen);
892
+ socket.addEventListener("error", onError);
893
+ socket.addEventListener("close", onClose);
894
+ socket.addEventListener("message", onMessage);
895
+ });
896
+ }
897
+
898
+ private static wait(ms: number): Promise<void> {
899
+ return new Promise((resolve) => setTimeout(resolve, ms));
900
+ }
901
+
902
+ private static async decodeHandshakeMessage(
903
+ raw: unknown,
904
+ jsonCodec: JsonCodec,
905
+ msgpackCodec: MsgpackCodec,
906
+ ): Promise<Envelope | null> {
907
+ if (typeof raw === "string") {
908
+ return JSON.parse(raw) as Envelope;
909
+ }
910
+
911
+ const bytes = await NexisClient.toBytes(raw);
912
+ if (!bytes) {
913
+ return null;
914
+ }
915
+
916
+ try {
917
+ return msgpackCodec.decode(bytes);
918
+ } catch {
919
+ return jsonCodec.decode(bytes);
920
+ }
921
+ }
922
+
923
+ private static async toBytes(raw: unknown): Promise<Uint8Array | null> {
924
+ if (raw instanceof Uint8Array) {
925
+ return raw;
926
+ }
927
+ if (raw instanceof ArrayBuffer) {
928
+ return new Uint8Array(raw);
929
+ }
930
+ if (raw instanceof Blob) {
931
+ return new Uint8Array(await raw.arrayBuffer());
932
+ }
933
+ if (typeof raw === "string") {
934
+ return new TextEncoder().encode(raw);
935
+ }
936
+ return null;
937
+ }
938
+ }
939
+
940
+ export async function connect(
941
+ url: string,
942
+ options: ConnectOptions,
943
+ ): Promise<NexisClient> {
944
+ return NexisClient.connect(url, options);
945
+ }
946
+
947
+ export function decodeRoomBytes(payload: unknown): Uint8Array | null {
948
+ return fromJsonValue(payload);
949
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { Packr, Unpackr } from "msgpackr";
2
+ import type { Envelope } from "./types";
3
+
4
+ export interface Codec {
5
+ readonly name: "json" | "msgpack";
6
+ encode(message: Envelope): Uint8Array;
7
+ decode(bytes: Uint8Array): Envelope;
8
+ }
9
+
10
+ export class JsonCodec implements Codec {
11
+ readonly name = "json" as const;
12
+
13
+ encode(message: Envelope): Uint8Array {
14
+ return new TextEncoder().encode(JSON.stringify(message));
15
+ }
16
+
17
+ decode(bytes: Uint8Array): Envelope {
18
+ const text = new TextDecoder().decode(bytes);
19
+ const parsed = JSON.parse(text);
20
+ return parsed as Envelope;
21
+ }
22
+ }
23
+
24
+ export class MsgpackCodec implements Codec {
25
+ readonly name = "msgpack" as const;
26
+ private readonly packr = new Packr({
27
+ useRecords: false,
28
+ structuredClone: false,
29
+ bundleStrings: false,
30
+ maxSharedStructures: 0,
31
+ });
32
+ private readonly unpackr = new Unpackr({
33
+ useRecords: false,
34
+ });
35
+
36
+ encode(message: Envelope): Uint8Array {
37
+ return this.packr.pack(message);
38
+ }
39
+
40
+ decode(bytes: Uint8Array): Envelope {
41
+ const decoded = this.unpackr.unpack(bytes);
42
+ return decoded as Envelope;
43
+ }
44
+ }
45
+
46
+ export function codecFor(name: "json" | "msgpack"): Codec {
47
+ return name === "msgpack" ? new MsgpackCodec() : new JsonCodec();
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./types";
2
+ export * from "./codec";
3
+ export * from "./patch";
4
+ export * from "./rpc";
5
+ export * from "./client";
package/src/patch.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { PatchOp, StatePatchPayload, StateSnapshotPayload } from "./types";
2
+
3
+ function keyFromPath(path: string): string {
4
+ if (!path.startsWith("/")) {
5
+ throw new Error(`Invalid patch path: ${path}`);
6
+ }
7
+ const key = path.slice(1);
8
+ if (!key || key.includes("/")) {
9
+ throw new Error(`Invalid patch path: ${path}`);
10
+ }
11
+ return key.replace(/~1/g, "/").replace(/~0/g, "~");
12
+ }
13
+
14
+ export function applyPatch<T extends Record<string, unknown>>(
15
+ state: T,
16
+ patch: PatchOp[],
17
+ ): T {
18
+ const next: Record<string, unknown> = { ...state };
19
+
20
+ for (const op of patch) {
21
+ const key = keyFromPath(op.path);
22
+ if (op.op === "set") {
23
+ next[key] = op.value;
24
+ continue;
25
+ }
26
+ delete next[key];
27
+ }
28
+
29
+ return next as T;
30
+ }
31
+
32
+ function canonicalize(value: unknown): unknown {
33
+ if (Array.isArray(value)) {
34
+ return value.map((item) => canonicalize(item));
35
+ }
36
+
37
+ if (value && typeof value === "object") {
38
+ const sortedEntries = Object.entries(value as Record<string, unknown>).sort(
39
+ ([left], [right]) => left.localeCompare(right),
40
+ );
41
+ const normalized: Record<string, unknown> = {};
42
+ for (const [key, item] of sortedEntries) {
43
+ normalized[key] = canonicalize(item);
44
+ }
45
+ return normalized;
46
+ }
47
+
48
+ return value;
49
+ }
50
+
51
+ export async function computeStateChecksum(state: unknown): Promise<string> {
52
+ const cryptoApi = globalThis.crypto?.subtle;
53
+ if (!cryptoApi) {
54
+ throw new Error("crypto.subtle is unavailable");
55
+ }
56
+
57
+ const canonicalJson = JSON.stringify(canonicalize(state));
58
+ const bytes = new TextEncoder().encode(canonicalJson);
59
+ const digest = await cryptoApi.digest("SHA-256", bytes);
60
+ const hashBytes = new Uint8Array(digest);
61
+
62
+ return Array.from(hashBytes)
63
+ .map((value) => value.toString(16).padStart(2, "0"))
64
+ .join("");
65
+ }
66
+
67
+ export function parsePatchPayload(payload: unknown): StatePatchPayload | null {
68
+ if (Array.isArray(payload)) {
69
+ return {
70
+ seq: 0,
71
+ checksum: undefined,
72
+ ops: payload as PatchOp[],
73
+ };
74
+ }
75
+
76
+ if (!payload || typeof payload !== "object") {
77
+ return null;
78
+ }
79
+
80
+ const candidate = payload as {
81
+ seq?: unknown;
82
+ checksum?: unknown;
83
+ ops?: unknown;
84
+ };
85
+ if (
86
+ typeof candidate.seq !== "number" ||
87
+ !Array.isArray(candidate.ops) ||
88
+ (candidate.checksum !== undefined && typeof candidate.checksum !== "string")
89
+ ) {
90
+ return null;
91
+ }
92
+
93
+ return {
94
+ seq: candidate.seq,
95
+ checksum: candidate.checksum as string | undefined,
96
+ ops: candidate.ops as PatchOp[],
97
+ };
98
+ }
99
+
100
+ export function parseSnapshotPayload(
101
+ payload: unknown,
102
+ ): StateSnapshotPayload | null {
103
+ if (!payload || typeof payload !== "object") {
104
+ return null;
105
+ }
106
+
107
+ const candidate = payload as {
108
+ seq?: unknown;
109
+ checksum?: unknown;
110
+ state?: unknown;
111
+ };
112
+ if (
113
+ typeof candidate.seq !== "number" ||
114
+ (candidate.checksum !== undefined &&
115
+ typeof candidate.checksum !== "string") ||
116
+ !candidate.state ||
117
+ typeof candidate.state !== "object" ||
118
+ Array.isArray(candidate.state)
119
+ ) {
120
+ return null;
121
+ }
122
+
123
+ return {
124
+ seq: candidate.seq,
125
+ checksum: candidate.checksum as string | undefined,
126
+ state: candidate.state as Record<string, unknown>,
127
+ };
128
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { Envelope } from "./types";
2
+
3
+ export class UnknownRidError extends Error {
4
+ constructor(rid: string) {
5
+ super(`Unknown RPC rid: ${rid}`);
6
+ }
7
+ }
8
+
9
+ export class RpcClient {
10
+ private nextId = 1;
11
+ private pending = new Map<
12
+ string,
13
+ { resolve: (payload: unknown) => void; reject: (error: Error) => void }
14
+ >();
15
+
16
+ createRequest(
17
+ type: string,
18
+ payload: unknown,
19
+ room?: string,
20
+ ): { message: Envelope; promise: Promise<unknown> } {
21
+ const rid = `rpc-${this.nextId++}`;
22
+ const message: Envelope = { v: 1, t: type, rid, p: payload };
23
+ if (room !== undefined) {
24
+ message.room = room;
25
+ }
26
+ const promise = new Promise<unknown>((resolve, reject) => {
27
+ this.pending.set(rid, { resolve, reject });
28
+ });
29
+
30
+ return { message, promise };
31
+ }
32
+
33
+ resolveResponse(response: Envelope): void {
34
+ const rid = response.rid;
35
+ if (!rid) {
36
+ throw new UnknownRidError("missing");
37
+ }
38
+
39
+ const pending = this.pending.get(rid);
40
+ if (!pending) {
41
+ throw new UnknownRidError(rid);
42
+ }
43
+
44
+ this.pending.delete(rid);
45
+ pending.resolve(response.p);
46
+ }
47
+
48
+ rejectAll(error: Error): void {
49
+ for (const pending of this.pending.values()) {
50
+ pending.reject(error);
51
+ }
52
+ this.pending.clear();
53
+ }
54
+ }
package/src/types.ts ADDED
@@ -0,0 +1,82 @@
1
+ export type Envelope = {
2
+ v: number;
3
+ t: string;
4
+ rid?: string;
5
+ room?: string;
6
+ p?: unknown;
7
+ };
8
+
9
+ export type HandshakeRequest = {
10
+ v: number;
11
+ codecs: string[];
12
+ project_id: string;
13
+ token: string;
14
+ session_id?: string;
15
+ };
16
+
17
+ export type ConnectOptions = {
18
+ projectId?: string;
19
+ token?: string;
20
+ codecs?: Array<"msgpack" | "json">;
21
+ sessionId?: string;
22
+ autoJoinMatchedRoom?: boolean;
23
+ autoReconnect?: boolean;
24
+ reconnectInitialDelayMs?: number;
25
+ reconnectMaxDelayMs?: number;
26
+ reconnectMaxAttempts?: number;
27
+ };
28
+
29
+ export type RoomSummary = {
30
+ id: string;
31
+ room_type: string;
32
+ members: number;
33
+ };
34
+
35
+ export type RoomListResponse = {
36
+ ok: boolean;
37
+ rooms: RoomSummary[];
38
+ };
39
+
40
+ export type MatchFound = {
41
+ room: string;
42
+ roomType: string;
43
+ size: number;
44
+ participants: string[];
45
+ };
46
+
47
+ export type MatchmakingQueueResponse = {
48
+ ok: boolean;
49
+ queued?: boolean;
50
+ matched?: boolean;
51
+ room_type?: string;
52
+ size?: number;
53
+ position?: number;
54
+ };
55
+
56
+ export type MatchmakingDequeueResponse = {
57
+ ok: boolean;
58
+ removed: boolean;
59
+ };
60
+
61
+ export type PatchOp =
62
+ | { op: "set"; path: string; value: unknown }
63
+ | { op: "del"; path: string };
64
+
65
+ export type StatePatchPayload = {
66
+ seq: number;
67
+ checksum?: string;
68
+ ops: PatchOp[];
69
+ };
70
+
71
+ export type StateSnapshotPayload = {
72
+ seq: number;
73
+ checksum?: string;
74
+ state: Record<string, unknown>;
75
+ };
76
+
77
+ export type RoomMessageType = string | number;
78
+
79
+ export type RoomMessagePayload = {
80
+ type: RoomMessageType;
81
+ data: unknown;
82
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "declaration": true,
8
+ "outDir": "dist",
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ },
13
+ "include": ["src"],
14
+ }