@chidchanun/bcp 0.2.11 → 0.2.13

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.
@@ -0,0 +1,1464 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ export interface RealtimeEnvelope<TPayload = unknown> {
6
+ id: string;
7
+ channel: string;
8
+ event: string;
9
+ payload: TPayload;
10
+ timestamp: number;
11
+ sourceId?: string;
12
+ excludeConnectionId?: string;
13
+ }
14
+
15
+ export interface RealtimePresenceMember<TData = unknown> {
16
+ connectionId: string;
17
+ channel: string;
18
+ userId?: string;
19
+ data?: TData;
20
+ joinedAt: number;
21
+ updatedAt: number;
22
+ }
23
+
24
+ export interface RealtimeBroker {
25
+ publish(message: RealtimeEnvelope): Promise<void>;
26
+ subscribe(
27
+ listener: (
28
+ message: RealtimeEnvelope
29
+ ) => void | Promise<void>
30
+ ): () => void;
31
+ close?(): Promise<void>;
32
+ }
33
+
34
+ export interface RealtimePresenceStore {
35
+ join(member: RealtimePresenceMember): Promise<void>;
36
+ leave(
37
+ channel: string,
38
+ connectionId: string
39
+ ): Promise<void>;
40
+ leaveConnection(
41
+ connectionId: string
42
+ ): Promise<void>;
43
+ touch(
44
+ connectionId: string,
45
+ updatedAt: number
46
+ ): Promise<void>;
47
+ list(
48
+ channel: string
49
+ ): Promise<RealtimePresenceMember[]>;
50
+ close?(): Promise<void>;
51
+ }
52
+
53
+ export interface MemoryRealtimeBroker
54
+ extends RealtimeBroker {
55
+ clear(): void;
56
+ }
57
+
58
+ export interface MemoryRealtimePresenceStore
59
+ extends RealtimePresenceStore {
60
+ clear(): void;
61
+ }
62
+
63
+ export interface RealtimeSocket {
64
+ send(data: string): void | Promise<void>;
65
+ close?(
66
+ code?: number,
67
+ reason?: string
68
+ ): void | Promise<void>;
69
+ onMessage(
70
+ listener: (
71
+ data: string
72
+ ) => void | Promise<void>
73
+ ): () => void;
74
+ onClose(
75
+ listener: () => void | Promise<void>
76
+ ): () => void;
77
+ onError?(
78
+ listener: (
79
+ error: unknown
80
+ ) => void | Promise<void>
81
+ ): () => void;
82
+ }
83
+
84
+ export interface RealtimeAuthenticationContext<TData = unknown> {
85
+ request?: Request;
86
+ data?: TData;
87
+ }
88
+
89
+ export type RealtimeAuthenticate<TUser = unknown, TData = unknown> = (
90
+ context: RealtimeAuthenticationContext<TData>
91
+ ) => TUser | null | undefined | Promise<TUser | null | undefined>;
92
+
93
+ export interface RealtimeChannelAuthorizationContext<TUser = unknown> {
94
+ connection: RealtimeConnection<TUser>;
95
+ channel: string;
96
+ }
97
+
98
+ export type RealtimeAuthorizeChannel<TUser = unknown> = (
99
+ context: RealtimeChannelAuthorizationContext<TUser>
100
+ ) => boolean | Promise<boolean>;
101
+
102
+ export interface RealtimeOptions<TUser = unknown> {
103
+ broker?: RealtimeBroker;
104
+ presence?: RealtimePresenceStore;
105
+ authenticate?: RealtimeAuthenticate<TUser>;
106
+ authorizeChannel?: RealtimeAuthorizeChannel<TUser>;
107
+ getUserId?: (
108
+ user: TUser
109
+ ) => string | number | null | undefined;
110
+ now?: () => number;
111
+ idFactory?: () => string;
112
+ heartbeatTimeoutMs?: number;
113
+ onError?: (
114
+ error: unknown
115
+ ) => void | Promise<void>;
116
+ }
117
+
118
+ export interface RealtimeConnectOptions<TUser = unknown, TData = unknown> {
119
+ request?: Request;
120
+ user?: TUser;
121
+ data?: TData;
122
+ socket?: RealtimeSocket;
123
+ connectionId?: string;
124
+ }
125
+
126
+ export interface RealtimeJoinOptions<TData = unknown> {
127
+ presence?: TData;
128
+ }
129
+
130
+ export interface RealtimeBroadcastOptions {
131
+ excludeConnectionId?: string;
132
+ }
133
+
134
+ export interface RealtimeConnection<TUser = unknown> {
135
+ readonly id: string;
136
+ readonly user: TUser | undefined;
137
+ readonly connectedAt: number;
138
+ readonly lastSeenAt: number;
139
+ readonly connected: boolean;
140
+ readonly channels: readonly string[];
141
+ join<TData = unknown>(
142
+ channel: string,
143
+ options?: RealtimeJoinOptions<TData>
144
+ ): Promise<void>;
145
+ leave(channel: string): Promise<void>;
146
+ emit<TPayload = unknown>(
147
+ channel: string,
148
+ event: string,
149
+ payload: TPayload
150
+ ): Promise<void>;
151
+ send<TPayload = unknown>(
152
+ event: string,
153
+ payload: TPayload,
154
+ channel?: string
155
+ ): Promise<void>;
156
+ touch(): Promise<void>;
157
+ disconnect(
158
+ code?: number,
159
+ reason?: string
160
+ ): Promise<void>;
161
+ }
162
+
163
+ export interface RealtimeEventContext<TUser = unknown, TPayload = unknown> {
164
+ connection: RealtimeConnection<TUser>;
165
+ channel: string;
166
+ event: string;
167
+ payload: TPayload;
168
+ }
169
+
170
+ export type RealtimeEventHandler<TUser = unknown, TPayload = unknown> = (
171
+ context: RealtimeEventContext<TUser, TPayload>
172
+ ) => void | Promise<void>;
173
+
174
+ export interface RealtimeHeartbeatRunner {
175
+ readonly running: boolean;
176
+ stop(): Promise<void>;
177
+ }
178
+
179
+ export interface RealtimeHeartbeatOptions {
180
+ intervalMs?: number;
181
+ }
182
+
183
+ export interface RealtimeSseOptions {
184
+ signal?: AbortSignal;
185
+ event?: string;
186
+ retryMs?: number;
187
+ keepAliveMs?: number;
188
+ headers?: HeadersInit;
189
+ }
190
+
191
+ export interface RealtimeHub<TUser = unknown> {
192
+ readonly id: string;
193
+ readonly broker: RealtimeBroker;
194
+ readonly presence: RealtimePresenceStore;
195
+ connect<TData = unknown>(
196
+ options?: RealtimeConnectOptions<TUser, TData>
197
+ ): Promise<RealtimeConnection<TUser>>;
198
+ attachSocket<TData = unknown>(
199
+ socket: RealtimeSocket,
200
+ options?: Omit<RealtimeConnectOptions<TUser, TData>, "socket">
201
+ ): Promise<RealtimeConnection<TUser>>;
202
+ broadcast<TPayload = unknown>(
203
+ channel: string,
204
+ event: string,
205
+ payload: TPayload,
206
+ options?: RealtimeBroadcastOptions
207
+ ): Promise<void>;
208
+ subscribe(
209
+ channel: string,
210
+ listener: (
211
+ message: RealtimeEnvelope
212
+ ) => void | Promise<void>
213
+ ): () => void;
214
+ on<TPayload = unknown>(
215
+ event: string,
216
+ handler: RealtimeEventHandler<TUser, TPayload>
217
+ ): () => void;
218
+ members(
219
+ channel: string
220
+ ): Promise<RealtimePresenceMember[]>;
221
+ connection(
222
+ id: string
223
+ ): RealtimeConnection<TUser> | null;
224
+ connections(): RealtimeConnection<TUser>[];
225
+ sweepStale(): Promise<number>;
226
+ startHeartbeat(
227
+ options?: RealtimeHeartbeatOptions
228
+ ): RealtimeHeartbeatRunner;
229
+ sse(
230
+ channel: string,
231
+ options?: RealtimeSseOptions
232
+ ): Response;
233
+ close(): Promise<void>;
234
+ }
235
+
236
+ interface InternalConnection<TUser> {
237
+ api: RealtimeConnection<TUser>;
238
+ user: TUser | undefined;
239
+ socket?: RealtimeSocket;
240
+ channels: Set<string>;
241
+ connectedAt: number;
242
+ lastSeenAt: number;
243
+ connected: boolean;
244
+ cleanup: Array<() => void>;
245
+ }
246
+
247
+ interface SocketClientMessage {
248
+ type?: unknown;
249
+ channel?: unknown;
250
+ event?: unknown;
251
+ payload?: unknown;
252
+ presence?: unknown;
253
+ }
254
+
255
+ export function createMemoryRealtimeBroker():
256
+ MemoryRealtimeBroker {
257
+ const listeners =
258
+ new Set<(
259
+ message: RealtimeEnvelope
260
+ ) => void | Promise<void>>();
261
+
262
+ return {
263
+ async publish(message) {
264
+ for (const listener of listeners) {
265
+ await listener(cloneEnvelope(message));
266
+ }
267
+ },
268
+
269
+ subscribe(listener) {
270
+ if (typeof listener !== "function") {
271
+ throw new TypeError(
272
+ "BCP Realtime: broker listener must be a function."
273
+ );
274
+ }
275
+ listeners.add(listener);
276
+ return () => {
277
+ listeners.delete(listener);
278
+ };
279
+ },
280
+
281
+ clear() {
282
+ listeners.clear();
283
+ },
284
+ };
285
+ }
286
+
287
+ export function createMemoryRealtimePresenceStore():
288
+ MemoryRealtimePresenceStore {
289
+ const members =
290
+ new Map<string, RealtimePresenceMember>();
291
+
292
+ return {
293
+ async join(member) {
294
+ members.set(
295
+ presenceKey(
296
+ member.channel,
297
+ member.connectionId
298
+ ),
299
+ clonePresence(member)
300
+ );
301
+ },
302
+
303
+ async leave(channel, connectionId) {
304
+ members.delete(
305
+ presenceKey(
306
+ normalizeChannel(channel),
307
+ normalizeId(
308
+ connectionId,
309
+ "connection id"
310
+ )
311
+ )
312
+ );
313
+ },
314
+
315
+ async leaveConnection(connectionId) {
316
+ const id =
317
+ normalizeId(
318
+ connectionId,
319
+ "connection id"
320
+ );
321
+ for (const [key, member] of members) {
322
+ if (member.connectionId === id) {
323
+ members.delete(key);
324
+ }
325
+ }
326
+ },
327
+
328
+ async touch(connectionId, updatedAt) {
329
+ const id =
330
+ normalizeId(
331
+ connectionId,
332
+ "connection id"
333
+ );
334
+ assertFiniteNumber(
335
+ updatedAt,
336
+ "presence updatedAt"
337
+ );
338
+ for (const member of members.values()) {
339
+ if (member.connectionId === id) {
340
+ member.updatedAt = updatedAt;
341
+ }
342
+ }
343
+ },
344
+
345
+ async list(channel) {
346
+ const normalized =
347
+ normalizeChannel(channel);
348
+ return Array.from(
349
+ members.values()
350
+ )
351
+ .filter(member =>
352
+ member.channel === normalized
353
+ )
354
+ .sort((left, right) =>
355
+ left.joinedAt - right.joinedAt ||
356
+ left.connectionId.localeCompare(
357
+ right.connectionId
358
+ )
359
+ )
360
+ .map(clonePresence);
361
+ },
362
+
363
+ clear() {
364
+ members.clear();
365
+ },
366
+ };
367
+ }
368
+
369
+ export function createRealtime<TUser = unknown>(
370
+ options: RealtimeOptions<TUser> = {}
371
+ ): RealtimeHub<TUser> {
372
+ const broker =
373
+ options.broker ??
374
+ createMemoryRealtimeBroker();
375
+ const presence =
376
+ options.presence ??
377
+ createMemoryRealtimePresenceStore();
378
+ const now =
379
+ options.now ?? Date.now;
380
+ const idFactory =
381
+ options.idFactory ?? randomUUID;
382
+ const hubId =
383
+ normalizeId(
384
+ idFactory(),
385
+ "hub id"
386
+ );
387
+ const heartbeatTimeoutMs =
388
+ positiveInteger(
389
+ options.heartbeatTimeoutMs ?? 60_000,
390
+ "heartbeatTimeoutMs"
391
+ );
392
+ const connections =
393
+ new Map<string, InternalConnection<TUser>>();
394
+ const subscriptions =
395
+ new Map<
396
+ string,
397
+ Set<(
398
+ message: RealtimeEnvelope
399
+ ) => void | Promise<void>>
400
+ >();
401
+ const handlers =
402
+ new Map<
403
+ string,
404
+ Set<RealtimeEventHandler<TUser, any>>
405
+ >();
406
+ const heartbeatRunners =
407
+ new Set<RealtimeHeartbeatRunner>();
408
+
409
+ const unsubscribeBroker =
410
+ broker.subscribe(
411
+ async message => {
412
+ await dispatchBrokerMessage(
413
+ message
414
+ );
415
+ }
416
+ );
417
+
418
+ const hub: RealtimeHub<TUser> = {
419
+ id: hubId,
420
+ broker,
421
+ presence,
422
+
423
+ async connect(connectOptions = {}) {
424
+ const user =
425
+ connectOptions.user ??
426
+ (
427
+ options.authenticate
428
+ ? await options.authenticate({
429
+ request:
430
+ connectOptions.request,
431
+ data:
432
+ connectOptions.data,
433
+ }) ?? undefined
434
+ : undefined
435
+ );
436
+ const connectionId =
437
+ normalizeId(
438
+ connectOptions.connectionId ??
439
+ idFactory(),
440
+ "connection id"
441
+ );
442
+ if (connections.has(connectionId)) {
443
+ throw new Error(
444
+ `BCP Realtime: connection id "${connectionId}" already exists.`
445
+ );
446
+ }
447
+ const connectedAt = now();
448
+ assertFiniteNumber(
449
+ connectedAt,
450
+ "connection timestamp"
451
+ );
452
+
453
+ const internal:
454
+ InternalConnection<TUser> = {
455
+ api: null as unknown as RealtimeConnection<TUser>,
456
+ user,
457
+ socket:
458
+ connectOptions.socket,
459
+ channels:
460
+ new Set<string>(),
461
+ connectedAt,
462
+ lastSeenAt:
463
+ connectedAt,
464
+ connected: true,
465
+ cleanup: [],
466
+ };
467
+
468
+ const api:
469
+ RealtimeConnection<TUser> = {
470
+ id: connectionId,
471
+ user,
472
+ connectedAt,
473
+
474
+ get lastSeenAt() {
475
+ return internal.lastSeenAt;
476
+ },
477
+
478
+ get connected() {
479
+ return internal.connected;
480
+ },
481
+
482
+ get channels() {
483
+ return Array.from(
484
+ internal.channels
485
+ ).sort();
486
+ },
487
+
488
+ async join(
489
+ rawChannel,
490
+ joinOptions = {}
491
+ ) {
492
+ assertConnected(
493
+ internal,
494
+ connectionId
495
+ );
496
+ const channel =
497
+ normalizeChannel(
498
+ rawChannel
499
+ );
500
+ if (
501
+ options.authorizeChannel &&
502
+ !await options.authorizeChannel({
503
+ connection: api,
504
+ channel,
505
+ })
506
+ ) {
507
+ throw new Error(
508
+ `BCP Realtime: connection "${connectionId}" is not authorized for channel "${channel}".`
509
+ );
510
+ }
511
+ if (internal.channels.has(channel)) {
512
+ return;
513
+ }
514
+ internal.channels.add(channel);
515
+ const timestamp = now();
516
+ const userId =
517
+ user !== undefined &&
518
+ options.getUserId
519
+ ? optionalId(
520
+ options.getUserId(
521
+ user
522
+ )
523
+ )
524
+ : undefined;
525
+ await presence.join({
526
+ connectionId,
527
+ channel,
528
+ userId,
529
+ data:
530
+ joinOptions.presence,
531
+ joinedAt:
532
+ timestamp,
533
+ updatedAt:
534
+ timestamp,
535
+ });
536
+ await sendPresence(
537
+ channel,
538
+ "presence.join",
539
+ connectionId
540
+ );
541
+ },
542
+
543
+ async leave(rawChannel) {
544
+ const channel =
545
+ normalizeChannel(
546
+ rawChannel
547
+ );
548
+ if (!internal.channels.delete(channel)) {
549
+ return;
550
+ }
551
+ await presence.leave(
552
+ channel,
553
+ connectionId
554
+ );
555
+ await sendPresence(
556
+ channel,
557
+ "presence.leave",
558
+ connectionId
559
+ );
560
+ },
561
+
562
+ async emit(
563
+ rawChannel,
564
+ rawEvent,
565
+ payload
566
+ ) {
567
+ assertConnected(
568
+ internal,
569
+ connectionId
570
+ );
571
+ const channel =
572
+ normalizeChannel(
573
+ rawChannel
574
+ );
575
+ if (!internal.channels.has(channel)) {
576
+ throw new Error(
577
+ `BCP Realtime: connection "${connectionId}" has not joined channel "${channel}".`
578
+ );
579
+ }
580
+ const event =
581
+ normalizeEvent(
582
+ rawEvent
583
+ );
584
+ await api.touch();
585
+ const group =
586
+ handlers.get(event);
587
+ if (group) {
588
+ for (const handler of group) {
589
+ await handler({
590
+ connection: api,
591
+ channel,
592
+ event,
593
+ payload,
594
+ });
595
+ }
596
+ }
597
+ },
598
+
599
+ async send(
600
+ event,
601
+ payload,
602
+ channel = "@system"
603
+ ) {
604
+ await sendToConnection(
605
+ internal,
606
+ createEnvelope(
607
+ normalizeChannel(channel),
608
+ normalizeEvent(event),
609
+ payload
610
+ )
611
+ );
612
+ },
613
+
614
+ async touch() {
615
+ assertConnected(
616
+ internal,
617
+ connectionId
618
+ );
619
+ internal.lastSeenAt = now();
620
+ await presence.touch(
621
+ connectionId,
622
+ internal.lastSeenAt
623
+ );
624
+ },
625
+
626
+ async disconnect(
627
+ code = 1000,
628
+ reason = "connection closed"
629
+ ) {
630
+ if (!internal.connected) {
631
+ return;
632
+ }
633
+ internal.connected = false;
634
+ connections.delete(
635
+ connectionId
636
+ );
637
+ const channels =
638
+ Array.from(
639
+ internal.channels
640
+ );
641
+ internal.channels.clear();
642
+ await presence.leaveConnection(
643
+ connectionId
644
+ );
645
+ for (const channel of channels) {
646
+ await sendPresence(
647
+ channel,
648
+ "presence.leave",
649
+ connectionId
650
+ );
651
+ }
652
+ for (const cleanup of internal.cleanup.splice(0)) {
653
+ cleanup();
654
+ }
655
+ await internal.socket?.close?.(
656
+ code,
657
+ reason
658
+ );
659
+ },
660
+ };
661
+
662
+ internal.api = api;
663
+ connections.set(
664
+ connectionId,
665
+ internal
666
+ );
667
+
668
+ if (connectOptions.socket) {
669
+ bindSocket(
670
+ internal,
671
+ api
672
+ );
673
+ }
674
+
675
+ return api;
676
+ },
677
+
678
+ attachSocket(socket, attachOptions = {}) {
679
+ return hub.connect({
680
+ ...attachOptions,
681
+ socket,
682
+ });
683
+ },
684
+
685
+ async broadcast(
686
+ channel,
687
+ event,
688
+ payload,
689
+ broadcastOptions = {}
690
+ ) {
691
+ await broker.publish(
692
+ createEnvelope(
693
+ normalizeChannel(channel),
694
+ normalizeEvent(event),
695
+ payload,
696
+ broadcastOptions.excludeConnectionId
697
+ )
698
+ );
699
+ },
700
+
701
+ subscribe(channel, listener) {
702
+ const normalized =
703
+ normalizeChannel(channel);
704
+ if (typeof listener !== "function") {
705
+ throw new TypeError(
706
+ "BCP Realtime: subscriber must be a function."
707
+ );
708
+ }
709
+ let group =
710
+ subscriptions.get(normalized);
711
+ if (!group) {
712
+ group = new Set();
713
+ subscriptions.set(
714
+ normalized,
715
+ group
716
+ );
717
+ }
718
+ group.add(listener);
719
+ return () => {
720
+ group?.delete(listener);
721
+ if (group?.size === 0) {
722
+ subscriptions.delete(
723
+ normalized
724
+ );
725
+ }
726
+ };
727
+ },
728
+
729
+ on(event, handler) {
730
+ const normalized =
731
+ normalizeEvent(event);
732
+ if (typeof handler !== "function") {
733
+ throw new TypeError(
734
+ "BCP Realtime: event handler must be a function."
735
+ );
736
+ }
737
+ let group =
738
+ handlers.get(normalized);
739
+ if (!group) {
740
+ group = new Set();
741
+ handlers.set(
742
+ normalized,
743
+ group
744
+ );
745
+ }
746
+ group.add(handler);
747
+ return () => {
748
+ group?.delete(handler);
749
+ if (group?.size === 0) {
750
+ handlers.delete(normalized);
751
+ }
752
+ };
753
+ },
754
+
755
+ members(channel) {
756
+ return presence.list(
757
+ normalizeChannel(channel)
758
+ );
759
+ },
760
+
761
+ connection(id) {
762
+ return connections.get(
763
+ normalizeId(id, "connection id")
764
+ )?.api ?? null;
765
+ },
766
+
767
+ connections() {
768
+ return Array.from(
769
+ connections.values(),
770
+ connection =>
771
+ connection.api
772
+ );
773
+ },
774
+
775
+ async sweepStale() {
776
+ const cutoff =
777
+ now() -
778
+ heartbeatTimeoutMs;
779
+ const stale =
780
+ Array.from(
781
+ connections.values()
782
+ ).filter(connection =>
783
+ connection.lastSeenAt <= cutoff
784
+ );
785
+ for (const connection of stale) {
786
+ await connection.api.disconnect(
787
+ 4000,
788
+ "heartbeat timeout"
789
+ );
790
+ }
791
+ return stale.length;
792
+ },
793
+
794
+ startHeartbeat(
795
+ heartbeatOptions = {}
796
+ ) {
797
+ const intervalMs =
798
+ positiveInteger(
799
+ heartbeatOptions.intervalMs ??
800
+ Math.max(
801
+ 1_000,
802
+ Math.floor(
803
+ heartbeatTimeoutMs / 2
804
+ )
805
+ ),
806
+ "heartbeat interval"
807
+ );
808
+ let running = true;
809
+ let stopPromise:
810
+ Promise<void> | null =
811
+ null;
812
+ let timer:
813
+ ReturnType<typeof setTimeout> |
814
+ undefined;
815
+
816
+ const tick = async () => {
817
+ if (!running) {
818
+ return;
819
+ }
820
+ try {
821
+ for (const connection of connections.values()) {
822
+ await connection.api.send(
823
+ "realtime.ping",
824
+ {
825
+ timestamp: now(),
826
+ }
827
+ );
828
+ }
829
+ await hub.sweepStale();
830
+ } catch (error) {
831
+ await reportError(error);
832
+ }
833
+ if (running) {
834
+ timer =
835
+ setTimeout(
836
+ tick,
837
+ intervalMs
838
+ );
839
+ }
840
+ };
841
+
842
+ timer =
843
+ setTimeout(
844
+ tick,
845
+ intervalMs
846
+ );
847
+
848
+ const runner:
849
+ RealtimeHeartbeatRunner = {
850
+ get running() {
851
+ return running;
852
+ },
853
+ stop() {
854
+ if (!stopPromise) {
855
+ running = false;
856
+ if (timer) {
857
+ clearTimeout(timer);
858
+ }
859
+ heartbeatRunners.delete(
860
+ runner
861
+ );
862
+ stopPromise =
863
+ Promise.resolve();
864
+ }
865
+ return stopPromise;
866
+ },
867
+ };
868
+ heartbeatRunners.add(runner);
869
+ return runner;
870
+ },
871
+
872
+ sse(channel, sseOptions = {}) {
873
+ return createRealtimeSseResponse(
874
+ hub,
875
+ channel,
876
+ sseOptions
877
+ );
878
+ },
879
+
880
+ async close() {
881
+ await Promise.all(
882
+ Array.from(
883
+ heartbeatRunners,
884
+ runner => runner.stop()
885
+ )
886
+ );
887
+ await Promise.all(
888
+ Array.from(
889
+ connections.values(),
890
+ connection =>
891
+ connection.api.disconnect(
892
+ 1001,
893
+ "realtime hub closing"
894
+ )
895
+ )
896
+ );
897
+ unsubscribeBroker();
898
+ subscriptions.clear();
899
+ handlers.clear();
900
+ await presence.close?.();
901
+ await broker.close?.();
902
+ },
903
+ };
904
+
905
+ return hub;
906
+
907
+ function createEnvelope<TPayload>(
908
+ channel: string,
909
+ event: string,
910
+ payload: TPayload,
911
+ excludeConnectionId?: string
912
+ ): RealtimeEnvelope<TPayload> {
913
+ return {
914
+ id: normalizeId(
915
+ idFactory(),
916
+ "message id"
917
+ ),
918
+ channel,
919
+ event,
920
+ payload,
921
+ timestamp: now(),
922
+ sourceId: hubId,
923
+ excludeConnectionId,
924
+ };
925
+ }
926
+
927
+ async function dispatchBrokerMessage(
928
+ message: RealtimeEnvelope
929
+ ): Promise<void> {
930
+ const subscribers =
931
+ subscriptions.get(
932
+ message.channel
933
+ );
934
+ if (subscribers) {
935
+ for (const listener of subscribers) {
936
+ try {
937
+ await listener(
938
+ cloneEnvelope(message)
939
+ );
940
+ } catch (error) {
941
+ await reportError(error);
942
+ }
943
+ }
944
+ }
945
+
946
+ for (const connection of connections.values()) {
947
+ if (
948
+ !connection.connected ||
949
+ !connection.channels.has(
950
+ message.channel
951
+ ) ||
952
+ connection.api.id ===
953
+ message.excludeConnectionId
954
+ ) {
955
+ continue;
956
+ }
957
+ try {
958
+ await sendToConnection(
959
+ connection,
960
+ message
961
+ );
962
+ } catch (error) {
963
+ await reportError(error);
964
+ }
965
+ }
966
+ }
967
+
968
+ async function sendToConnection(
969
+ connection: InternalConnection<TUser>,
970
+ message: RealtimeEnvelope
971
+ ): Promise<void> {
972
+ if (!connection.connected) {
973
+ return;
974
+ }
975
+ if (!connection.socket) {
976
+ return;
977
+ }
978
+ await connection.socket.send(
979
+ JSON.stringify({
980
+ type: "event",
981
+ ...message,
982
+ })
983
+ );
984
+ }
985
+
986
+ async function sendPresence(
987
+ channel: string,
988
+ event: string,
989
+ excludeConnectionId?: string
990
+ ): Promise<void> {
991
+ const members =
992
+ await presence.list(channel);
993
+ await broker.publish(
994
+ createEnvelope(
995
+ channel,
996
+ event,
997
+ {
998
+ members,
999
+ },
1000
+ excludeConnectionId
1001
+ )
1002
+ );
1003
+ }
1004
+
1005
+ function bindSocket(
1006
+ internal: InternalConnection<TUser>,
1007
+ connection: RealtimeConnection<TUser>
1008
+ ): void {
1009
+ const socket =
1010
+ internal.socket;
1011
+ if (!socket) {
1012
+ return;
1013
+ }
1014
+
1015
+ internal.cleanup.push(
1016
+ socket.onMessage(
1017
+ async raw => {
1018
+ try {
1019
+ const message =
1020
+ parseSocketMessage(raw);
1021
+ await connection.touch();
1022
+ if (message.type === "ping") {
1023
+ await connection.send(
1024
+ "realtime.pong",
1025
+ {
1026
+ timestamp: now(),
1027
+ }
1028
+ );
1029
+ return;
1030
+ }
1031
+ if (message.type === "join") {
1032
+ await connection.join(
1033
+ normalizeChannel(
1034
+ message.channel
1035
+ ),
1036
+ {
1037
+ presence:
1038
+ message.presence,
1039
+ }
1040
+ );
1041
+ return;
1042
+ }
1043
+ if (message.type === "leave") {
1044
+ await connection.leave(
1045
+ normalizeChannel(
1046
+ message.channel
1047
+ )
1048
+ );
1049
+ return;
1050
+ }
1051
+ if (message.type === "event") {
1052
+ await connection.emit(
1053
+ normalizeChannel(
1054
+ message.channel
1055
+ ),
1056
+ normalizeEvent(
1057
+ message.event
1058
+ ),
1059
+ message.payload
1060
+ );
1061
+ return;
1062
+ }
1063
+ throw new Error(
1064
+ "BCP Realtime: unsupported socket message type."
1065
+ );
1066
+ } catch (error) {
1067
+ await reportError(error);
1068
+ try {
1069
+ await connection.send(
1070
+ "realtime.error",
1071
+ {
1072
+ message:
1073
+ formatError(error),
1074
+ }
1075
+ );
1076
+ } catch {
1077
+ // Preserve the original socket message error.
1078
+ }
1079
+ }
1080
+ }
1081
+ )
1082
+ );
1083
+ internal.cleanup.push(
1084
+ socket.onClose(
1085
+ async () => {
1086
+ await connection.disconnect(
1087
+ 1000,
1088
+ "socket closed"
1089
+ );
1090
+ }
1091
+ )
1092
+ );
1093
+ if (socket.onError) {
1094
+ internal.cleanup.push(
1095
+ socket.onError(
1096
+ async error => {
1097
+ await reportError(error);
1098
+ }
1099
+ )
1100
+ );
1101
+ }
1102
+ }
1103
+
1104
+ async function reportError(
1105
+ error: unknown
1106
+ ): Promise<void> {
1107
+ if (options.onError) {
1108
+ await options.onError(error);
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ export function createRealtimeSseResponse(
1114
+ hub: Pick<RealtimeHub, "subscribe">,
1115
+ rawChannel: string,
1116
+ options: RealtimeSseOptions = {}
1117
+ ): Response {
1118
+ const channel =
1119
+ normalizeChannel(
1120
+ rawChannel
1121
+ );
1122
+ const encoder =
1123
+ new TextEncoder();
1124
+ const eventName =
1125
+ options.event
1126
+ ? normalizeEvent(
1127
+ options.event
1128
+ )
1129
+ : undefined;
1130
+ const retryMs =
1131
+ options.retryMs === undefined
1132
+ ? undefined
1133
+ : positiveInteger(
1134
+ options.retryMs,
1135
+ "SSE retryMs"
1136
+ );
1137
+ const keepAliveMs =
1138
+ positiveInteger(
1139
+ options.keepAliveMs ?? 15_000,
1140
+ "SSE keepAliveMs"
1141
+ );
1142
+
1143
+ const stream =
1144
+ new ReadableStream<Uint8Array>({
1145
+ start(controller) {
1146
+ let closed = false;
1147
+ let timer:
1148
+ ReturnType<typeof setTimeout> |
1149
+ undefined;
1150
+ let unsubscribe:
1151
+ () => void =
1152
+ () => undefined;
1153
+
1154
+ const close = () => {
1155
+ if (closed) {
1156
+ return;
1157
+ }
1158
+ closed = true;
1159
+ if (timer) {
1160
+ clearTimeout(timer);
1161
+ }
1162
+ unsubscribe();
1163
+ try {
1164
+ controller.close();
1165
+ } catch {
1166
+ // Stream may already be closed by the runtime.
1167
+ }
1168
+ };
1169
+
1170
+ const write = (
1171
+ value: string
1172
+ ) => {
1173
+ if (!closed) {
1174
+ controller.enqueue(
1175
+ encoder.encode(value)
1176
+ );
1177
+ }
1178
+ };
1179
+
1180
+ if (retryMs !== undefined) {
1181
+ write(
1182
+ `retry: ${retryMs}\n\n`
1183
+ );
1184
+ }
1185
+ write(
1186
+ ": connected\n\n"
1187
+ );
1188
+
1189
+ unsubscribe =
1190
+ hub.subscribe(
1191
+ channel,
1192
+ message => {
1193
+ if (
1194
+ eventName &&
1195
+ message.event !== eventName
1196
+ ) {
1197
+ return;
1198
+ }
1199
+ write(
1200
+ serializeSseMessage(
1201
+ message
1202
+ )
1203
+ );
1204
+ }
1205
+ );
1206
+
1207
+ const keepAlive = () => {
1208
+ if (closed) {
1209
+ return;
1210
+ }
1211
+ write(
1212
+ `: keep-alive ${Date.now()}\n\n`
1213
+ );
1214
+ timer =
1215
+ setTimeout(
1216
+ keepAlive,
1217
+ keepAliveMs
1218
+ );
1219
+ };
1220
+ timer =
1221
+ setTimeout(
1222
+ keepAlive,
1223
+ keepAliveMs
1224
+ );
1225
+
1226
+ if (options.signal) {
1227
+ if (options.signal.aborted) {
1228
+ close();
1229
+ return;
1230
+ }
1231
+ options.signal.addEventListener(
1232
+ "abort",
1233
+ close,
1234
+ {
1235
+ once: true,
1236
+ }
1237
+ );
1238
+ }
1239
+ },
1240
+ });
1241
+
1242
+ const headers =
1243
+ new Headers(
1244
+ options.headers
1245
+ );
1246
+ headers.set(
1247
+ "Content-Type",
1248
+ "text/event-stream; charset=utf-8"
1249
+ );
1250
+ headers.set(
1251
+ "Cache-Control",
1252
+ "no-cache, no-transform"
1253
+ );
1254
+ headers.set(
1255
+ "Connection",
1256
+ "keep-alive"
1257
+ );
1258
+ headers.set(
1259
+ "X-Accel-Buffering",
1260
+ "no"
1261
+ );
1262
+
1263
+ return new Response(
1264
+ stream,
1265
+ {
1266
+ status: 200,
1267
+ headers,
1268
+ }
1269
+ );
1270
+ }
1271
+
1272
+ function serializeSseMessage(
1273
+ message: RealtimeEnvelope
1274
+ ): string {
1275
+ const payload =
1276
+ JSON.stringify({
1277
+ id: message.id,
1278
+ channel: message.channel,
1279
+ event: message.event,
1280
+ payload: message.payload,
1281
+ timestamp: message.timestamp,
1282
+ });
1283
+ return `id: ${escapeSse(message.id)}\nevent: ${escapeSse(message.event)}\ndata: ${payload}\n\n`;
1284
+ }
1285
+
1286
+ function parseSocketMessage(
1287
+ raw: string
1288
+ ): SocketClientMessage {
1289
+ if (typeof raw !== "string") {
1290
+ throw new TypeError(
1291
+ "BCP Realtime: socket messages must be UTF-8 strings."
1292
+ );
1293
+ }
1294
+ let parsed:
1295
+ unknown;
1296
+ try {
1297
+ parsed =
1298
+ JSON.parse(raw);
1299
+ } catch {
1300
+ throw new Error(
1301
+ "BCP Realtime: socket message must be valid JSON."
1302
+ );
1303
+ }
1304
+ if (
1305
+ !parsed ||
1306
+ typeof parsed !== "object" ||
1307
+ Array.isArray(parsed)
1308
+ ) {
1309
+ throw new Error(
1310
+ "BCP Realtime: socket message must be an object."
1311
+ );
1312
+ }
1313
+ return parsed as SocketClientMessage;
1314
+ }
1315
+
1316
+ function cloneEnvelope<TPayload>(
1317
+ message: RealtimeEnvelope<TPayload>
1318
+ ): RealtimeEnvelope<TPayload> {
1319
+ return {
1320
+ ...message,
1321
+ };
1322
+ }
1323
+
1324
+ function clonePresence<TData>(
1325
+ member: RealtimePresenceMember<TData>
1326
+ ): RealtimePresenceMember<TData> {
1327
+ return {
1328
+ ...member,
1329
+ };
1330
+ }
1331
+
1332
+ function presenceKey(
1333
+ channel: string,
1334
+ connectionId: string
1335
+ ): string {
1336
+ return `${channel}\u0000${connectionId}`;
1337
+ }
1338
+
1339
+ function normalizeChannel(
1340
+ value: unknown
1341
+ ): string {
1342
+ const channel =
1343
+ String(value ?? "").trim();
1344
+ if (!channel) {
1345
+ throw new TypeError(
1346
+ "BCP Realtime: channel must be a non-empty string."
1347
+ );
1348
+ }
1349
+ if (channel.length > 200) {
1350
+ throw new TypeError(
1351
+ "BCP Realtime: channel must not exceed 200 characters."
1352
+ );
1353
+ }
1354
+ return channel;
1355
+ }
1356
+
1357
+ function normalizeEvent(
1358
+ value: unknown
1359
+ ): string {
1360
+ const event =
1361
+ String(value ?? "").trim();
1362
+ if (!event) {
1363
+ throw new TypeError(
1364
+ "BCP Realtime: event must be a non-empty string."
1365
+ );
1366
+ }
1367
+ if (event.length > 200) {
1368
+ throw new TypeError(
1369
+ "BCP Realtime: event must not exceed 200 characters."
1370
+ );
1371
+ }
1372
+ return event;
1373
+ }
1374
+
1375
+ function normalizeId(
1376
+ value: unknown,
1377
+ field: string
1378
+ ): string {
1379
+ const id =
1380
+ String(value ?? "").trim();
1381
+ if (!id) {
1382
+ throw new TypeError(
1383
+ `BCP Realtime: ${field} must be a non-empty string.`
1384
+ );
1385
+ }
1386
+ return id;
1387
+ }
1388
+
1389
+ function optionalId(
1390
+ value: unknown
1391
+ ): string | undefined {
1392
+ if (
1393
+ value === undefined ||
1394
+ value === null
1395
+ ) {
1396
+ return undefined;
1397
+ }
1398
+ const id =
1399
+ String(value).trim();
1400
+ return id || undefined;
1401
+ }
1402
+
1403
+ function positiveInteger(
1404
+ value: number,
1405
+ field: string
1406
+ ): number {
1407
+ if (
1408
+ !Number.isInteger(value) ||
1409
+ value <= 0
1410
+ ) {
1411
+ throw new TypeError(
1412
+ `BCP Realtime: ${field} must be a positive integer.`
1413
+ );
1414
+ }
1415
+ return value;
1416
+ }
1417
+
1418
+ function assertFiniteNumber(
1419
+ value: number,
1420
+ field: string
1421
+ ): void {
1422
+ if (!Number.isFinite(value)) {
1423
+ throw new TypeError(
1424
+ `BCP Realtime: ${field} must be a finite number.`
1425
+ );
1426
+ }
1427
+ }
1428
+
1429
+ function assertConnected<TUser>(
1430
+ connection: InternalConnection<TUser>,
1431
+ id: string
1432
+ ): void {
1433
+ if (!connection.connected) {
1434
+ throw new Error(
1435
+ `BCP Realtime: connection "${id}" is closed.`
1436
+ );
1437
+ }
1438
+ }
1439
+
1440
+ function escapeSse(
1441
+ value: string
1442
+ ): string {
1443
+ return value.replace(
1444
+ /[\r\n]/g,
1445
+ ""
1446
+ );
1447
+ }
1448
+
1449
+ function formatError(
1450
+ error: unknown
1451
+ ): string {
1452
+ if (error instanceof Error) {
1453
+ return error.message || error.name;
1454
+ }
1455
+ if (typeof error === "string") {
1456
+ return error;
1457
+ }
1458
+ try {
1459
+ return JSON.stringify(error) ??
1460
+ String(error);
1461
+ } catch {
1462
+ return String(error);
1463
+ }
1464
+ }