@pluv/io 0.3.9 → 0.4.1

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 CHANGED
@@ -15,16 +15,20 @@ import type {
15
15
  JsonObject,
16
16
  Maybe,
17
17
  MaybePromise,
18
+ Spread,
18
19
  } from "@pluv/types";
19
20
  import colors from "kleur";
20
21
  import type { AbstractPlatform, InferWebSocketType } from "./AbstractPlatform";
21
22
  import { authorize } from "./authorize";
22
23
  import type {
24
+ EventResolver,
23
25
  EventResolverContext,
26
+ EventResolverObject,
24
27
  InferEventConfig,
25
28
  SendMessageOptions,
26
29
  WebSocketSession,
27
30
  } from "./types";
31
+ import { AbstractMessageEvent } from "./AbstractWebSocket";
28
32
 
29
33
  const PING_TIMEOUT_MS = 30_000;
30
34
 
@@ -46,13 +50,20 @@ export type IORoomConfig<
46
50
  TAuthorize extends IOAuthorize<any, any> = BaseIOAuthorize,
47
51
  TContext extends JsonObject = {},
48
52
  TInput extends EventRecord<string, any> = {},
49
- TOutput extends EventRecord<string, any> = {}
53
+ TOutputBroadcast extends EventRecord<string, any> = {},
54
+ TOutputSelf extends EventRecord<string, any> = {},
55
+ TOutputSync extends EventRecord<string, any> = {}
50
56
  > = Partial<IORoomListeners> & {
51
57
  authorize?: TAuthorize;
52
58
  context: TContext;
53
59
  debug: boolean;
54
- events: InferEventConfig<TContext, TInput, TOutput>;
55
- initialStorage?: () => MaybePromise<Maybe<string>>;
60
+ events: InferEventConfig<
61
+ TContext,
62
+ TInput,
63
+ TOutputBroadcast,
64
+ TOutputSelf,
65
+ TOutputSync
66
+ >;
56
67
  platform: TPlatform;
57
68
  };
58
69
 
@@ -62,7 +73,7 @@ interface SendMessageSender {
62
73
  }
63
74
 
64
75
  export interface WebsocketRegisterOptions {
65
- token?: string;
76
+ token?: string | null;
66
77
  }
67
78
 
68
79
  export class IORoom<
@@ -70,37 +81,46 @@ export class IORoom<
70
81
  TAuthorize extends IOAuthorize<any, any> = BaseIOAuthorize,
71
82
  TContext extends JsonObject = {},
72
83
  TInput extends EventRecord<string, any> = {},
73
- TOutput extends EventRecord<string, any> = {}
74
- > implements IOLike<TAuthorize, TInput, TOutput>
84
+ TOutputBroadcast extends EventRecord<string, any> = {},
85
+ TOutputSelf extends EventRecord<string, any> = {},
86
+ TOutputSync extends EventRecord<string, any> = {}
87
+ > implements
88
+ IOLike<TAuthorize, TInput, TOutputBroadcast, TOutputSelf, TOutputSync>
75
89
  {
76
90
  private readonly _context: TContext;
77
91
  private readonly _debug: boolean;
78
92
  private readonly _platform: TPlatform;
79
93
 
80
94
  private _doc: YjsDoc<any> = doc();
81
- private _initialStorage: (() => MaybePromise<Maybe<string>>) | null = null;
82
95
  private _listeners: IORoomListeners;
83
96
  private _sessions = new Map<string, WebSocketSession>();
84
97
  private _uninitialize: (() => Promise<void>) | null = null;
85
98
 
86
99
  readonly _authorize: TAuthorize | null = null;
87
- readonly _events: InferEventConfig<TContext, TInput, TOutput>;
100
+ readonly _events: InferEventConfig<
101
+ TContext,
102
+ TInput,
103
+ TOutputBroadcast,
104
+ TOutputSelf,
105
+ TOutputSync
106
+ >;
88
107
 
89
108
  readonly id: string;
90
109
 
91
110
  constructor(
92
111
  id: string,
93
- config: IORoomConfig<TPlatform, TAuthorize, TContext, TInput, TOutput>
112
+ config: IORoomConfig<
113
+ TPlatform,
114
+ TAuthorize,
115
+ TContext,
116
+ TInput,
117
+ TOutputBroadcast,
118
+ TOutputSelf,
119
+ TOutputSync
120
+ >
94
121
  ) {
95
- const {
96
- authorize,
97
- context,
98
- debug,
99
- events,
100
- initialStorage,
101
- onDestroy,
102
- platform,
103
- } = config;
122
+ const { authorize, context, debug, events, onDestroy, platform } =
123
+ config;
104
124
 
105
125
  this._context = context;
106
126
  this._debug = debug;
@@ -116,7 +136,6 @@ export class IORoom<
116
136
  };
117
137
 
118
138
  if (authorize) this._authorize = authorize;
119
- if (initialStorage) this._initialStorage = initialStorage;
120
139
  }
121
140
 
122
141
  public broadcast<TEvent extends keyof InferIOInput<this>>(
@@ -210,120 +229,14 @@ export class IORoom<
210
229
  user ?? {}
211
230
  );
212
231
 
213
- this._emitRegistered(session);
214
- this._emitStorageReceived(session);
215
- this._emitUserJoined(session);
216
-
217
- const context: EventResolverContext<TContext> = {
218
- context: this._context,
219
- doc: this._doc,
220
- room: this.id,
221
- session,
222
- sessions: this._sessions,
223
- };
224
-
225
- pluvWs.addEventListener("message", (event) => {
226
- if (!this._uninitialize) return;
227
-
228
- if (session.quit) {
229
- session.webSocket.close(1011, "WebSocket broken.");
230
-
231
- return;
232
- }
233
-
234
- const message = this._parseMessage(event);
235
-
236
- if (!message) return;
237
-
238
- const eventConfig = this._getEventConfig(message);
239
-
240
- if (!eventConfig) return;
241
-
242
- this._resolveMessage(message, context).then((output) => {
243
- if (!output) return;
244
-
245
- const eventType = eventConfig.options?.type ?? "broadcast";
246
-
247
- switch (eventType) {
248
- case "self": {
249
- Object.entries(output).forEach(([type, data]) => {
250
- this._sendSelfMessage({ data, type }, session);
251
- });
252
-
253
- return;
254
- }
255
- case "sync": {
256
- this._platform.pubSub.publish(this.id, {
257
- connectionId: session.id,
258
- options: eventConfig.options,
259
- room: this.id,
260
- user: session.user,
261
- ...message,
262
- });
263
-
264
- Object.entries(output).forEach(([type, data]) => {
265
- this._sendSelfMessage({ data, type }, session);
266
- });
267
-
268
- return;
269
- }
270
- case "broadcast":
271
- default: {
272
- Object.entries(output).forEach(([type, data]: any) => {
273
- this._broadcast({
274
- message: { data, type },
275
- senderId: session.id,
276
- });
277
- });
278
- }
279
- }
280
- });
281
- });
282
-
283
- const closeHandler = () => {
284
- if (!this._uninitialize) return;
285
-
286
- session.quit = true;
287
-
288
- this._logDebug(
289
- `${colors.blue(
290
- `(Unregistering connection for room ${this.id}:`
291
- )} ${sessionId}`
292
- );
293
- this._sessions.delete(session.id);
294
-
295
- this._platform.persistance
296
- .deleteUser(this.id, session.id)
297
- .finally(() => {
298
- this._broadcast({
299
- message: {
300
- type: "$EXIT",
301
- data: { sessionId: session.id },
302
- },
303
- senderId: session.id,
304
- });
305
-
306
- uninitializeWs();
232
+ const onClose = this._onClose(session, uninitializeWs).bind(this);
233
+ const onMessage = this._onMessage(session).bind(this);
307
234
 
308
- const size = this.getSize();
309
-
310
- this._logDebug(
311
- `${colors.blue(
312
- `Unregistered connection for room ${this.id}:`
313
- )} ${sessionId}`
314
- );
315
- this._logDebug(
316
- `${colors.blue(`Room ${this.id} size:`)} ${size}`
317
- );
235
+ pluvWs.addEventListener("close", onClose);
236
+ pluvWs.addEventListener("error", onClose);
237
+ pluvWs.addEventListener("message", onMessage);
318
238
 
319
- if (size) return;
320
-
321
- this._uninitialize?.();
322
- });
323
- };
324
-
325
- pluvWs.addEventListener("close", closeHandler);
326
- pluvWs.addEventListener("error", closeHandler);
239
+ this._emitRegistered(session);
327
240
 
328
241
  this._logDebug(
329
242
  `${colors.blue(
@@ -384,44 +297,8 @@ export class IORoom<
384
297
  );
385
298
  }
386
299
 
387
- private _emitStorageReceived(session: WebSocketSession): void {
388
- this._platform.persistance
389
- .getStorageState(this.id)
390
- .then(async (_state) => {
391
- const state =
392
- _state ??
393
- (await this._initialStorage?.()) ??
394
- this._doc.encodeStateAsUpdate();
395
-
396
- this._sendSelfMessage(
397
- {
398
- type: "$STORAGE_RECEIVED",
399
- data: { state },
400
- },
401
- session
402
- );
403
- });
404
- }
405
-
406
- private _emitUserJoined(session: WebSocketSession): void {
407
- const user = session.user as any;
408
-
409
- if (!user) return;
410
-
411
- this._broadcast({
412
- message: {
413
- type: "$USER_JOINED",
414
- data: {
415
- connectionId: session.id,
416
- user,
417
- },
418
- },
419
- senderId: session.id,
420
- });
421
- }
422
-
423
300
  private async _getAuthorizedUser(
424
- token: string | undefined
301
+ token: Maybe<string>
425
302
  ): Promise<InferIOAuthorizeUser<InferIOAuthorize<this>> | null> {
426
303
  if (!this._authorize) return null;
427
304
  if (!token) return null;
@@ -461,10 +338,66 @@ export class IORoom<
461
338
 
462
339
  private _getEventConfig(
463
340
  message: EventMessage<string, any>
464
- ): InferEventConfig<TContext, TInput, TOutput>[keyof TInput] | null {
341
+ ):
342
+ | InferEventConfig<
343
+ TContext,
344
+ TInput,
345
+ TOutputBroadcast,
346
+ TOutputSelf,
347
+ TOutputSync
348
+ >[keyof TInput]
349
+ | null {
465
350
  return this._events[message.type as keyof TInput] ?? null;
466
351
  }
467
352
 
353
+ private _getEventInputs(
354
+ message: EventMessage<string, any>
355
+ ): TInput[string] {
356
+ const eventConfig = this._getEventConfig(message);
357
+
358
+ if (!eventConfig) return message.data;
359
+
360
+ /**
361
+ * !HACK
362
+ * @description If the config doesn't specify validation, we
363
+ * assume no input is to be expected, but pass through all values
364
+ * regardless. Leave this up to the implementer of the event.
365
+ * @author leedavidcs
366
+ * @date September 25, 2022
367
+ */
368
+ return eventConfig.input
369
+ ? (eventConfig.input.parse(message.data) as TInput[string])
370
+ : message.data;
371
+ }
372
+
373
+ private _getEventResolverObject(
374
+ message: EventMessage<string, any>
375
+ ): EventResolverObject<
376
+ TContext,
377
+ TInput[string],
378
+ TOutputBroadcast,
379
+ TOutputSelf,
380
+ TOutputSync
381
+ > {
382
+ const eventConfig = this._getEventConfig(message);
383
+
384
+ if (!eventConfig) return {};
385
+
386
+ const resolver:
387
+ | EventResolver<TContext, TInput[string], TOutputBroadcast>
388
+ | EventResolverObject<
389
+ TContext,
390
+ TInput[string],
391
+ TOutputBroadcast,
392
+ TOutputSelf,
393
+ TOutputSync
394
+ > = eventConfig.resolver;
395
+
396
+ return typeof resolver === "function"
397
+ ? { broadcast: resolver }
398
+ : { ...resolver };
399
+ }
400
+
468
401
  private async _initialize(): Promise<void> {
469
402
  this._logDebug(`${colors.blue(`Initializing room ${this.id}:`)}`);
470
403
 
@@ -504,6 +437,130 @@ export class IORoom<
504
437
  this._debug && console.log(...data);
505
438
  }
506
439
 
440
+ private _onClose(
441
+ session: WebSocketSession,
442
+ callback?: () => void
443
+ ): () => void {
444
+ return (): void => {
445
+ if (!this._uninitialize) return;
446
+
447
+ session.quit = true;
448
+
449
+ this._logDebug(
450
+ `${colors.blue(
451
+ `(Unregistering connection for room ${this.id}:`
452
+ )} ${session.id}`
453
+ );
454
+ this._sessions.delete(session.id);
455
+
456
+ this._platform.persistance
457
+ .deleteUser(this.id, session.id)
458
+ .finally(() => {
459
+ this._broadcast({
460
+ message: {
461
+ type: "$EXIT",
462
+ data: { sessionId: session.id },
463
+ },
464
+ senderId: session.id,
465
+ });
466
+
467
+ callback?.();
468
+
469
+ const size = this.getSize();
470
+
471
+ this._logDebug(
472
+ `${colors.blue(
473
+ `Unregistered connection for room ${this.id}:`
474
+ )} ${session.id}`
475
+ );
476
+ this._logDebug(
477
+ `${colors.blue(`Room ${this.id} size:`)} ${size}`
478
+ );
479
+
480
+ if (size) return;
481
+
482
+ this._uninitialize?.();
483
+ });
484
+ };
485
+ }
486
+
487
+ private _onMessage(
488
+ session: WebSocketSession
489
+ ): (event: AbstractMessageEvent) => void {
490
+ return (event: AbstractMessageEvent): void => {
491
+ const context: EventResolverContext<TContext> = {
492
+ context: this._context,
493
+ doc: this._doc,
494
+ room: this.id,
495
+ session,
496
+ sessions: this._sessions,
497
+ };
498
+
499
+ if (!this._uninitialize) return;
500
+
501
+ if (session.quit) {
502
+ session.webSocket.close(1011, "WebSocket broken.");
503
+
504
+ return;
505
+ }
506
+
507
+ const message = this._parseMessage(event);
508
+
509
+ if (!message) return;
510
+
511
+ const resolver = this._getEventResolverObject(message);
512
+
513
+ let inputs: TInput[string];
514
+
515
+ try {
516
+ inputs = this._getEventInputs(message);
517
+ } catch (error) {
518
+ session?.webSocket.handleError({
519
+ error,
520
+ message: "Invalid input",
521
+ session,
522
+ });
523
+
524
+ return;
525
+ }
526
+
527
+ Promise.all([
528
+ resolver.broadcast?.(inputs, context),
529
+ resolver.self?.(inputs, context),
530
+ resolver.sync?.(inputs, context),
531
+ ]).then(([broadcast, self, sync]) => {
532
+ if (broadcast) {
533
+ Object.entries(broadcast).forEach(([type, data]: any) => {
534
+ this._broadcast({
535
+ message: { data, type },
536
+ senderId: session.id,
537
+ });
538
+ });
539
+ }
540
+
541
+ if (self) {
542
+ Object.entries(self).forEach(([type, data]) => {
543
+ this._sendSelfMessage({ data, type }, session);
544
+ });
545
+ }
546
+
547
+ if (sync) {
548
+ this._platform.pubSub.publish(this.id, {
549
+ connectionId: session.id,
550
+ options: { type: "sync" },
551
+ room: this.id,
552
+ user: session.user,
553
+ ...message,
554
+ });
555
+
556
+ Object.entries(sync).forEach(([type, data]) => {
557
+ this._sendSelfMessage({ data, type }, session);
558
+ });
559
+ }
560
+ });
561
+ };
562
+ }
563
+
507
564
  private _parseMessage(message: {
508
565
  data: string | ArrayBuffer;
509
566
  }): EventMessage<string, any> | null {
@@ -520,47 +577,6 @@ export class IORoom<
520
577
  }
521
578
  }
522
579
 
523
- private async _resolveMessage(
524
- message: EventMessage<string, any>,
525
- context: EventResolverContext<TContext>
526
- ): Promise<TOutput | null> {
527
- const { session } = context;
528
-
529
- const eventConfig = this._getEventConfig(message);
530
-
531
- if (!eventConfig) return null;
532
-
533
- let input: TInput[string];
534
-
535
- /**
536
- * !HACK
537
- * @description If the config doesn't specify validation, we
538
- * assume no input is to be expected, but pass through all values
539
- * regardless. Leave this up to the implementer of the event.
540
- * @author leedavidcs
541
- * @date September 25, 2022
542
- */
543
- try {
544
- input = eventConfig.input
545
- ? (eventConfig.input.parse(message.data) as TInput[string])
546
- : message.data;
547
- } catch (error) {
548
- session?.webSocket.handleError({
549
- error,
550
- message: "Invalid input",
551
- session,
552
- });
553
-
554
- return null;
555
- }
556
-
557
- const output = await eventConfig.resolver(input, context);
558
-
559
- if (!output) return null;
560
-
561
- return output;
562
- }
563
-
564
580
  private _sendMessage(
565
581
  message: EventMessage<string, any>,
566
582
  sender: SendMessageSender | null,
@@ -638,13 +654,27 @@ export class IORoom<
638
654
 
639
655
  if (!senderId) return;
640
656
 
641
- this._resolveMessage(message, {
657
+ const context: EventResolverContext<TContext> = {
642
658
  context: this._context,
643
659
  doc: this._doc,
644
660
  room: this.id,
645
661
  session: null,
646
662
  sessions: this._sessions,
647
- }).then((output) => {
663
+ };
664
+
665
+ const resolver = this._getEventResolverObject(message).sync;
666
+
667
+ if (!resolver) return;
668
+
669
+ let inputs: TInput[string];
670
+
671
+ try {
672
+ inputs = this._getEventInputs(message);
673
+ } catch {
674
+ return;
675
+ }
676
+
677
+ Promise.resolve(resolver(inputs, context)).then((output) => {
648
678
  if (!output) return;
649
679
 
650
680
  Object.keys(output).forEach((type: string) => {