@pluv/io 0.0.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/IORoom.ts ADDED
@@ -0,0 +1,630 @@
1
+ import type { YjsDoc } from "@pluv/crdt-yjs";
2
+ import { doc } from "@pluv/crdt-yjs";
3
+ import type {
4
+ BaseIOAuthorize,
5
+ BaseIOEventRecord,
6
+ EventMessage,
7
+ EventRecord,
8
+ InferEventMessage,
9
+ InferIOAuthorize,
10
+ InferIOAuthorizeUser,
11
+ InferIOInput,
12
+ IOAuthorize,
13
+ IOLike,
14
+ JsonObject,
15
+ } from "@pluv/types";
16
+ import chalk from "chalk";
17
+ import type { AbstractPlatform, InferWebSocketType } from "./AbstractPlatform";
18
+ import { authorize } from "./authorize";
19
+ import type {
20
+ EventResolverContext,
21
+ InferEventConfig,
22
+ SendMessageOptions,
23
+ WebSocketSession,
24
+ } from "./types";
25
+
26
+ const PING_TIMEOUT_MS = 30_000;
27
+
28
+ type BroadcastMessage<TIO extends IORoom<any, any, any, any, any>> =
29
+ | InferEventMessage<InferIOInput<TIO>>
30
+ | InferEventMessage<BaseIOEventRecord<InferIOAuthorize<TIO>>>;
31
+
32
+ interface BroadcastParams<TIO extends IORoom<any, any, any, any, any>> {
33
+ message: BroadcastMessage<TIO>;
34
+ senderId?: string;
35
+ }
36
+
37
+ interface IORoomListeners {
38
+ onDestroy: () => void;
39
+ }
40
+
41
+ export interface IORoomConfig<
42
+ TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
43
+ TAuthorize extends IOAuthorize<any, any> = BaseIOAuthorize,
44
+ TContext extends JsonObject = {},
45
+ TInput extends EventRecord<string, any> = {},
46
+ TOutput extends EventRecord<string, any> = {}
47
+ > {
48
+ authorize?: TAuthorize;
49
+ context: TContext;
50
+ debug: boolean;
51
+ events: InferEventConfig<TContext, TInput, TOutput>;
52
+ onDestroy?: () => void;
53
+ platform: TPlatform;
54
+ }
55
+
56
+ interface SendMessageSender {
57
+ id: string | null;
58
+ user: JsonObject | null;
59
+ }
60
+
61
+ export interface WebsocketRegisterOptions {
62
+ token?: string;
63
+ }
64
+
65
+ export class IORoom<
66
+ TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
67
+ TAuthorize extends IOAuthorize<any, any> = BaseIOAuthorize,
68
+ TContext extends JsonObject = {},
69
+ TInput extends EventRecord<string, any> = {},
70
+ TOutput extends EventRecord<string, any> = {}
71
+ > implements IOLike<TAuthorize, TInput, TOutput>
72
+ {
73
+ private readonly _context: TContext;
74
+ private readonly _debug: boolean;
75
+ private readonly _platform: TPlatform;
76
+
77
+ private _doc: YjsDoc<any> = doc();
78
+ private _listeners: IORoomListeners;
79
+ private _sessions = new Map<string, WebSocketSession>();
80
+ private _uninitialize: (() => Promise<void>) | null = null;
81
+
82
+ readonly _authorize: TAuthorize | null = null;
83
+ readonly _events: InferEventConfig<TContext, TInput, TOutput>;
84
+
85
+ readonly room: string;
86
+
87
+ constructor(
88
+ room: string,
89
+ config: IORoomConfig<TPlatform, TAuthorize, TContext, TInput, TOutput>
90
+ ) {
91
+ const { authorize, context, debug, events, onDestroy, platform } =
92
+ config;
93
+
94
+ this._context = context;
95
+ this._debug = debug;
96
+ this._events = events;
97
+ this._platform = platform;
98
+
99
+ this.room = room;
100
+
101
+ this._listeners = {
102
+ onDestroy: () => onDestroy?.(),
103
+ };
104
+
105
+ if (authorize) this._authorize = authorize;
106
+ }
107
+
108
+ public broadcast(message: BroadcastMessage<this>): Promise<void> {
109
+ return Promise.resolve(this._broadcast({ message }));
110
+ }
111
+
112
+ public async register(
113
+ webSocket: InferWebSocketType<TPlatform>,
114
+ options?: WebsocketRegisterOptions
115
+ ): Promise<void> {
116
+ if (!this._uninitialize) await this._initialize();
117
+
118
+ const token = options?.token;
119
+ const sessionId = this._platform.randomUUID();
120
+ const pluvWs = this._platform.convertWebSocket(webSocket, {
121
+ room: this.room,
122
+ });
123
+
124
+ this._logDebug(
125
+ `${chalk.blue(
126
+ `Registering connection for room ${this.room}:`
127
+ )} ${sessionId}`
128
+ );
129
+
130
+ const uninitializeWs = await pluvWs.initialize();
131
+
132
+ const user = await this._getAuthorizedUser(token);
133
+
134
+ const isUnauthorized = !!this._authorize?.required && !user;
135
+ // There is an attempt for multiple of the same identities. Probably malicious.
136
+ const isTokenInUse =
137
+ !!user &&
138
+ Array.from(this._sessions.values()).some(
139
+ (session) => session.user?.id === user.id
140
+ );
141
+
142
+ if (isUnauthorized || isTokenInUse) {
143
+ this._logDebug(
144
+ `${chalk.blue(
145
+ "Authorization failed for connection:"
146
+ )} ${sessionId}`
147
+ );
148
+
149
+ pluvWs.handleError({ error: new Error("Not authorized") });
150
+ pluvWs.close(3000, "WebSocket unauthorized.");
151
+
152
+ return;
153
+ }
154
+
155
+ const currentTime = new Date().getTime();
156
+ const session: WebSocketSession = {
157
+ id: sessionId,
158
+ presence: null,
159
+ quit: false,
160
+ room: this.room,
161
+ timers: {
162
+ ping: currentTime,
163
+ },
164
+ webSocket: pluvWs,
165
+ user,
166
+ };
167
+
168
+ this._sessions.set(session.id, session);
169
+
170
+ await this._platform.persistance.addUser(
171
+ this.room,
172
+ sessionId,
173
+ user ?? {}
174
+ );
175
+
176
+ this._emitRegistered(session);
177
+ this._emitStorageReceived(session);
178
+ this._emitUserJoined(session);
179
+
180
+ const context: EventResolverContext<TContext> = {
181
+ context: this._context,
182
+ doc: this._doc,
183
+ room: this.room,
184
+ session,
185
+ sessions: this._sessions,
186
+ };
187
+
188
+ pluvWs.addEventListener("message", (event) => {
189
+ if (!this._uninitialize) return;
190
+
191
+ if (session.quit) {
192
+ session.webSocket.close(1011, "WebSocket broken.");
193
+
194
+ return;
195
+ }
196
+
197
+ const message = this._parseMessage(event);
198
+
199
+ if (!message) return;
200
+
201
+ const eventConfig = this._getEventConfig(message);
202
+
203
+ if (!eventConfig) return;
204
+
205
+ this._resolveMessage(message, context).then((output) => {
206
+ if (!output) return;
207
+
208
+ const eventType = eventConfig.options?.type ?? "broadcast";
209
+
210
+ switch (eventType) {
211
+ case "self": {
212
+ Object.entries(output).forEach(([type, data]) => {
213
+ this._sendSelfMessage({ data, type }, session);
214
+ });
215
+
216
+ return;
217
+ }
218
+ case "sync": {
219
+ this._platform.pubSub.publish(this.room, {
220
+ connectionId: session.id,
221
+ options: eventConfig.options,
222
+ room: this.room,
223
+ user: session.user,
224
+ ...message,
225
+ });
226
+
227
+ Object.entries(output).forEach(([type, data]) => {
228
+ this._sendSelfMessage({ data, type }, session);
229
+ });
230
+
231
+ return;
232
+ }
233
+ case "broadcast":
234
+ default: {
235
+ Object.entries(output).forEach(([type, data]: any) => {
236
+ this._broadcast({
237
+ message: { data, type },
238
+ senderId: session.id,
239
+ });
240
+ });
241
+ }
242
+ }
243
+ });
244
+ });
245
+
246
+ const closeHandler = () => {
247
+ if (!this._uninitialize) return;
248
+
249
+ session.quit = true;
250
+
251
+ this._logDebug(
252
+ `${chalk.blue(
253
+ `(Unregistering connection for room ${this.room}:`
254
+ )} ${sessionId}`
255
+ );
256
+ this._sessions.delete(session.id);
257
+
258
+ this._platform.persistance.deleteUser(this.room, session.id);
259
+
260
+ this._broadcast({
261
+ message: {
262
+ type: "$EXIT",
263
+ data: { sessionId: session.id },
264
+ },
265
+ senderId: session.id,
266
+ });
267
+
268
+ uninitializeWs();
269
+
270
+ const size = this._getSessionsSize();
271
+
272
+ this._logDebug(
273
+ `${chalk.blue(
274
+ `Unregistered connection for room ${this.room}:`
275
+ )} ${sessionId}`
276
+ );
277
+ this._logDebug(`${chalk.blue(`Room ${this.room} size:`)} ${size}`);
278
+
279
+ if (size) return;
280
+
281
+ this._uninitialize();
282
+ };
283
+
284
+ pluvWs.addEventListener("close", closeHandler);
285
+ pluvWs.addEventListener("error", closeHandler);
286
+
287
+ this._logDebug(
288
+ `${chalk.blue(
289
+ `Registered connection for room ${this.room}:`
290
+ )} ${sessionId}`
291
+ );
292
+
293
+ const size = this._getSessionsSize();
294
+
295
+ this._logDebug(`${chalk.blue(`Room ${this.room} size:`)} ${size}`);
296
+ }
297
+
298
+ private _broadcast(params: BroadcastParams<this>): void {
299
+ const { message, senderId } = params;
300
+
301
+ const currentTime = new Date().getTime();
302
+ const sender = senderId ? this._sessions.get(senderId) : null;
303
+
304
+ const quitters = Array.from(this._sessions.values()).filter(
305
+ (session) =>
306
+ session.quit ||
307
+ currentTime - session.timers.ping > PING_TIMEOUT_MS
308
+ );
309
+
310
+ quitters.forEach((session) => {
311
+ this._sessions.delete(session.id);
312
+ });
313
+
314
+ this._emitQuitters(quitters);
315
+
316
+ this._platform.pubSub.publish(this.room, {
317
+ connectionId: senderId ?? null,
318
+ room: this.room,
319
+ user: sender?.user ?? null,
320
+ ...message,
321
+ });
322
+ }
323
+
324
+ private _emitQuitters(quitters: readonly WebSocketSession[]): void {
325
+ quitters.forEach((quitter) => {
326
+ this._broadcast({
327
+ message: {
328
+ type: "$EXIT",
329
+ data: { sessionId: quitter.id },
330
+ },
331
+ senderId: quitter.id,
332
+ });
333
+ });
334
+ }
335
+
336
+ private _emitRegistered(session: WebSocketSession): void {
337
+ this._sendSelfMessage(
338
+ {
339
+ type: "$REGISTERED",
340
+ data: { sessionId: session.id },
341
+ },
342
+ session
343
+ );
344
+ }
345
+
346
+ private _emitStorageReceived(session: WebSocketSession): void {
347
+ this._platform.persistance.getStorageState(this.room).then((_state) => {
348
+ const state = _state ?? this._doc.encodeStateAsUpdate();
349
+
350
+ this._sendSelfMessage(
351
+ {
352
+ type: "$STORAGE_RECEIVED",
353
+ data: { state },
354
+ },
355
+ session
356
+ );
357
+ });
358
+ }
359
+
360
+ private _emitUserJoined(session: WebSocketSession): void {
361
+ const user = session.user as any;
362
+
363
+ if (!user) return;
364
+
365
+ this._broadcast({
366
+ message: {
367
+ type: "$USER_JOINED",
368
+ data: {
369
+ connectionId: session.id,
370
+ user,
371
+ },
372
+ },
373
+ senderId: session.id,
374
+ });
375
+ }
376
+
377
+ private async _getAuthorizedUser(
378
+ token: string | undefined
379
+ ): Promise<InferIOAuthorizeUser<InferIOAuthorize<this>> | null> {
380
+ if (!this._authorize) return null;
381
+ if (!token) return null;
382
+
383
+ const payload = await authorize({
384
+ platform: this._platform,
385
+ secret: this._authorize.secret,
386
+ }).decode(token);
387
+
388
+ if (!payload) {
389
+ this._logDebug(chalk.blue("Could not decode token:"));
390
+ this._logDebug(token);
391
+
392
+ return null;
393
+ }
394
+
395
+ if (payload.room !== this.room) {
396
+ this._logDebug(
397
+ chalk.blue(`Token is not authorized for room ${this.room}:`)
398
+ );
399
+ this._logDebug(chalk.blue("Received:"), payload.room);
400
+ this._logDebug(token);
401
+
402
+ return null;
403
+ }
404
+
405
+ try {
406
+ return this._authorize.user.parse(payload.user) ?? null;
407
+ } catch {
408
+ this._logDebug(`${chalk.blue("Token fails validation:")} ${token}`);
409
+
410
+ return null;
411
+ }
412
+ }
413
+
414
+ private _getSessionsSize(): number {
415
+ const currentTime = new Date().getTime();
416
+
417
+ /**
418
+ * @description Doing this instead of .size because some sessions
419
+ * in the map can be considered as "omitted".
420
+ * @author leedavidcs
421
+ * @date December 21, 2022
422
+ */
423
+ return Array.from(this._sessions.values()).reduce((count, session) => {
424
+ if (session.quit) return count;
425
+
426
+ return currentTime - session.timers.ping > PING_TIMEOUT_MS
427
+ ? count
428
+ : count + 1;
429
+ }, 0);
430
+ }
431
+
432
+ private _getEventConfig(
433
+ message: EventMessage<string, any>
434
+ ): InferEventConfig<TContext, TInput, TOutput>[keyof TInput] | null {
435
+ return this._events[message.type as keyof TInput] ?? null;
436
+ }
437
+
438
+ private async _initialize(): Promise<void> {
439
+ this._logDebug(`${chalk.blue(`Initializing room ${this.room}:`)}`);
440
+
441
+ if (!!this._uninitialize) {
442
+ await this._uninitialize();
443
+
444
+ this._uninitialize = null;
445
+ }
446
+
447
+ const pubSubId = await this._platform.pubSub.subscribe(
448
+ this.room,
449
+ ({ options = {}, ...message }) => {
450
+ const session = {
451
+ id: message.connectionId,
452
+ user: message.user,
453
+ };
454
+
455
+ this._sendMessage(message, session, options);
456
+ }
457
+ );
458
+
459
+ this._uninitialize = async () => {
460
+ this._platform.pubSub.unsubscribe(pubSubId);
461
+
462
+ this._doc.destroy();
463
+ this._doc = doc();
464
+
465
+ this._listeners.onDestroy();
466
+ };
467
+ }
468
+
469
+ private _logDebug(...data: any[]): void {
470
+ this._debug && console.log(...data);
471
+ }
472
+
473
+ private _parseMessage(message: {
474
+ data: string | ArrayBuffer;
475
+ }): EventMessage<string, any> | null {
476
+ const parsed = this._platform.parseData(message.data);
477
+
478
+ try {
479
+ if (typeof parsed !== "object") return null;
480
+ if (typeof parsed.type !== "string") return null;
481
+ if (typeof parsed.data !== "object") return null;
482
+
483
+ return parsed as EventMessage<string, any>;
484
+ } catch {
485
+ return null;
486
+ }
487
+ }
488
+
489
+ private async _resolveMessage(
490
+ message: EventMessage<string, any>,
491
+ context: EventResolverContext<TContext>
492
+ ): Promise<TOutput | null> {
493
+ const { session } = context;
494
+
495
+ const eventConfig = this._getEventConfig(message);
496
+
497
+ if (!eventConfig) return null;
498
+
499
+ let input: TInput[string];
500
+
501
+ /**
502
+ * !HACK
503
+ * @description If the config doesn't specify validation, we
504
+ * assume no input is to be expected, but pass through all values
505
+ * regardless. Leave this up to the implementer of the event.
506
+ * @author leedavidcs
507
+ * @date September 25, 2022
508
+ */
509
+ try {
510
+ input = eventConfig.input
511
+ ? (eventConfig.input.parse(message.data) as TInput[string])
512
+ : message.data;
513
+ } catch (error) {
514
+ session?.webSocket.handleError({
515
+ error,
516
+ message: "Invalid input",
517
+ session,
518
+ });
519
+
520
+ return null;
521
+ }
522
+
523
+ const output = await eventConfig.resolver(input, context);
524
+
525
+ if (!output) return null;
526
+
527
+ return output;
528
+ }
529
+
530
+ private _sendMessage(
531
+ message: EventMessage<string, any>,
532
+ sender: SendMessageSender | null,
533
+ options: SendMessageOptions = {}
534
+ ): void {
535
+ switch (options.type) {
536
+ case "self": {
537
+ this._sendSelfMessage(message, sender);
538
+
539
+ return;
540
+ }
541
+ case "sync": {
542
+ this._sendSyncMessage(message, sender);
543
+
544
+ return;
545
+ }
546
+ case "broadcast":
547
+ default: {
548
+ const sessionIds = options.sessionIds;
549
+
550
+ this._sendBroadcastMessage(message, sender, sessionIds);
551
+ }
552
+ }
553
+ }
554
+
555
+ private _sendBroadcastMessage(
556
+ message: EventMessage<string, any>,
557
+ sender: SendMessageSender | null,
558
+ sessionIds?: readonly string[]
559
+ ): void {
560
+ const senderId = sender?.id ?? null;
561
+
562
+ const sessions =
563
+ sessionIds?.reduce((dict, id) => {
564
+ const _session = this._sessions.get(id);
565
+
566
+ return _session ? dict.set(id, _session) : dict;
567
+ }, new Map<string, WebSocketSession>()) ?? this._sessions;
568
+
569
+ Array.from(sessions.values()).forEach((_session) => {
570
+ _session.webSocket.sendMessage({
571
+ connectionId: senderId,
572
+ room: this.room,
573
+ user: _session.user,
574
+ ...message,
575
+ });
576
+ });
577
+ }
578
+
579
+ private _sendSelfMessage(
580
+ message: EventMessage<string, any>,
581
+ sender: SendMessageSender | null
582
+ ): void {
583
+ const senderId = sender?.id;
584
+
585
+ if (!senderId) return;
586
+
587
+ const session = this._sessions.get(senderId);
588
+
589
+ if (!session) return;
590
+
591
+ session.webSocket.sendMessage({
592
+ connectionId: sender.id,
593
+ room: this.room,
594
+ user: sender.user,
595
+ ...message,
596
+ });
597
+ }
598
+
599
+ private _sendSyncMessage(
600
+ message: EventMessage<string, any>,
601
+ sender: SendMessageSender | null
602
+ ): void {
603
+ const senderId = sender?.id;
604
+
605
+ if (!senderId) return;
606
+
607
+ this._resolveMessage(message, {
608
+ context: this._context,
609
+ doc: this._doc,
610
+ room: this.room,
611
+ session: null,
612
+ sessions: this._sessions,
613
+ }).then((output) => {
614
+ if (!output) return;
615
+
616
+ Object.keys(output).forEach((type: string) => {
617
+ const data = output[type] ?? {};
618
+
619
+ this._platform.pubSub.publish(this.room, {
620
+ connectionId: senderId,
621
+ data,
622
+ options: { type: "self" },
623
+ room: this.room,
624
+ type,
625
+ user: sender.user ?? null,
626
+ });
627
+ });
628
+ });
629
+ }
630
+ }
@@ -0,0 +1,77 @@
1
+ import type { JsonObject } from "@pluv/types";
2
+ import { AbstractPersistance } from "./AbstractPersistance";
3
+
4
+ export class Persistance extends AbstractPersistance {
5
+ private _storages = new Map<string, string>();
6
+ private _users = new Map<string, Map<string, JsonObject | null>>();
7
+
8
+ public addUser(
9
+ room: string,
10
+ connectionId: string,
11
+ user: JsonObject | null
12
+ ): Promise<void> {
13
+ const users =
14
+ this._users.get(room) ?? new Map<string, JsonObject | null>();
15
+
16
+ this._users.set(room, users);
17
+ users.set(connectionId, user);
18
+
19
+ return Promise.resolve();
20
+ }
21
+
22
+ public deleteStorageState(room: string): Promise<void> {
23
+ this._storages.delete(room);
24
+
25
+ return Promise.resolve();
26
+ }
27
+
28
+ public async deleteUser(room: string, connectionId: string): Promise<void> {
29
+ const users = this._users.get(room);
30
+
31
+ if (!users) return;
32
+
33
+ users.delete(connectionId);
34
+
35
+ if (!users.size) {
36
+ await this.deleteUsers(room);
37
+ await this.deleteStorageState(room);
38
+ }
39
+
40
+ return Promise.resolve();
41
+ }
42
+
43
+ public deleteUsers(room: string): Promise<void> {
44
+ this._users.delete(room);
45
+
46
+ return Promise.resolve();
47
+ }
48
+
49
+ public getStorageState(room: string): Promise<string | null> {
50
+ const storage = this._storages.get(room) ?? null;
51
+
52
+ return Promise.resolve(storage);
53
+ }
54
+
55
+ public getUser(
56
+ room: string,
57
+ connectionId: string
58
+ ): Promise<JsonObject | null> {
59
+ const user = this._users.get(room)?.get(connectionId);
60
+
61
+ return Promise.resolve(user ?? null);
62
+ }
63
+
64
+ public getUsers(room: string): Promise<readonly (JsonObject | null)[]> {
65
+ const users = this._users.get(room);
66
+
67
+ if (!users) return Promise.resolve([]);
68
+
69
+ return Promise.resolve(Array.from(users.values()));
70
+ }
71
+
72
+ public setStorageState(room: string, state: string): Promise<void> {
73
+ this._storages.set(room, state);
74
+
75
+ return Promise.resolve();
76
+ }
77
+ }