@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,936 @@
1
+ // packages/server/src/realtime.ts
2
+ import {
3
+ randomUUID
4
+ } from "node:crypto";
5
+ function createMemoryRealtimeBroker() {
6
+ const listeners = /* @__PURE__ */ new Set();
7
+ return {
8
+ async publish(message) {
9
+ for (const listener of listeners) {
10
+ await listener(cloneEnvelope(message));
11
+ }
12
+ },
13
+ subscribe(listener) {
14
+ if (typeof listener !== "function") {
15
+ throw new TypeError(
16
+ "BCP Realtime: broker listener must be a function."
17
+ );
18
+ }
19
+ listeners.add(listener);
20
+ return () => {
21
+ listeners.delete(listener);
22
+ };
23
+ },
24
+ clear() {
25
+ listeners.clear();
26
+ }
27
+ };
28
+ }
29
+ function createMemoryRealtimePresenceStore() {
30
+ const members = /* @__PURE__ */ new Map();
31
+ return {
32
+ async join(member) {
33
+ members.set(
34
+ presenceKey(
35
+ member.channel,
36
+ member.connectionId
37
+ ),
38
+ clonePresence(member)
39
+ );
40
+ },
41
+ async leave(channel, connectionId) {
42
+ members.delete(
43
+ presenceKey(
44
+ normalizeChannel(channel),
45
+ normalizeId(
46
+ connectionId,
47
+ "connection id"
48
+ )
49
+ )
50
+ );
51
+ },
52
+ async leaveConnection(connectionId) {
53
+ const id = normalizeId(
54
+ connectionId,
55
+ "connection id"
56
+ );
57
+ for (const [key, member] of members) {
58
+ if (member.connectionId === id) {
59
+ members.delete(key);
60
+ }
61
+ }
62
+ },
63
+ async touch(connectionId, updatedAt) {
64
+ const id = normalizeId(
65
+ connectionId,
66
+ "connection id"
67
+ );
68
+ assertFiniteNumber(
69
+ updatedAt,
70
+ "presence updatedAt"
71
+ );
72
+ for (const member of members.values()) {
73
+ if (member.connectionId === id) {
74
+ member.updatedAt = updatedAt;
75
+ }
76
+ }
77
+ },
78
+ async list(channel) {
79
+ const normalized = normalizeChannel(channel);
80
+ return Array.from(
81
+ members.values()
82
+ ).filter(
83
+ (member) => member.channel === normalized
84
+ ).sort(
85
+ (left, right) => left.joinedAt - right.joinedAt || left.connectionId.localeCompare(
86
+ right.connectionId
87
+ )
88
+ ).map(clonePresence);
89
+ },
90
+ clear() {
91
+ members.clear();
92
+ }
93
+ };
94
+ }
95
+ function createRealtime(options = {}) {
96
+ const broker = options.broker ?? createMemoryRealtimeBroker();
97
+ const presence = options.presence ?? createMemoryRealtimePresenceStore();
98
+ const now = options.now ?? Date.now;
99
+ const idFactory = options.idFactory ?? randomUUID;
100
+ const hubId = normalizeId(
101
+ idFactory(),
102
+ "hub id"
103
+ );
104
+ const heartbeatTimeoutMs = positiveInteger(
105
+ options.heartbeatTimeoutMs ?? 6e4,
106
+ "heartbeatTimeoutMs"
107
+ );
108
+ const connections = /* @__PURE__ */ new Map();
109
+ const subscriptions = /* @__PURE__ */ new Map();
110
+ const handlers = /* @__PURE__ */ new Map();
111
+ const heartbeatRunners = /* @__PURE__ */ new Set();
112
+ const unsubscribeBroker = broker.subscribe(
113
+ async (message) => {
114
+ await dispatchBrokerMessage(
115
+ message
116
+ );
117
+ }
118
+ );
119
+ const hub = {
120
+ id: hubId,
121
+ broker,
122
+ presence,
123
+ async connect(connectOptions = {}) {
124
+ const user = connectOptions.user ?? (options.authenticate ? await options.authenticate({
125
+ request: connectOptions.request,
126
+ data: connectOptions.data
127
+ }) ?? void 0 : void 0);
128
+ const connectionId = normalizeId(
129
+ connectOptions.connectionId ?? idFactory(),
130
+ "connection id"
131
+ );
132
+ if (connections.has(connectionId)) {
133
+ throw new Error(
134
+ `BCP Realtime: connection id "${connectionId}" already exists.`
135
+ );
136
+ }
137
+ const connectedAt = now();
138
+ assertFiniteNumber(
139
+ connectedAt,
140
+ "connection timestamp"
141
+ );
142
+ const internal = {
143
+ api: null,
144
+ user,
145
+ socket: connectOptions.socket,
146
+ channels: /* @__PURE__ */ new Set(),
147
+ connectedAt,
148
+ lastSeenAt: connectedAt,
149
+ connected: true,
150
+ cleanup: []
151
+ };
152
+ const api = {
153
+ id: connectionId,
154
+ user,
155
+ connectedAt,
156
+ get lastSeenAt() {
157
+ return internal.lastSeenAt;
158
+ },
159
+ get connected() {
160
+ return internal.connected;
161
+ },
162
+ get channels() {
163
+ return Array.from(
164
+ internal.channels
165
+ ).sort();
166
+ },
167
+ async join(rawChannel, joinOptions = {}) {
168
+ assertConnected(
169
+ internal,
170
+ connectionId
171
+ );
172
+ const channel = normalizeChannel(
173
+ rawChannel
174
+ );
175
+ if (options.authorizeChannel && !await options.authorizeChannel({
176
+ connection: api,
177
+ channel
178
+ })) {
179
+ throw new Error(
180
+ `BCP Realtime: connection "${connectionId}" is not authorized for channel "${channel}".`
181
+ );
182
+ }
183
+ if (internal.channels.has(channel)) {
184
+ return;
185
+ }
186
+ internal.channels.add(channel);
187
+ const timestamp = now();
188
+ const userId = user !== void 0 && options.getUserId ? optionalId(
189
+ options.getUserId(
190
+ user
191
+ )
192
+ ) : void 0;
193
+ await presence.join({
194
+ connectionId,
195
+ channel,
196
+ userId,
197
+ data: joinOptions.presence,
198
+ joinedAt: timestamp,
199
+ updatedAt: timestamp
200
+ });
201
+ await sendPresence(
202
+ channel,
203
+ "presence.join",
204
+ connectionId
205
+ );
206
+ },
207
+ async leave(rawChannel) {
208
+ const channel = normalizeChannel(
209
+ rawChannel
210
+ );
211
+ if (!internal.channels.delete(channel)) {
212
+ return;
213
+ }
214
+ await presence.leave(
215
+ channel,
216
+ connectionId
217
+ );
218
+ await sendPresence(
219
+ channel,
220
+ "presence.leave",
221
+ connectionId
222
+ );
223
+ },
224
+ async emit(rawChannel, rawEvent, payload) {
225
+ assertConnected(
226
+ internal,
227
+ connectionId
228
+ );
229
+ const channel = normalizeChannel(
230
+ rawChannel
231
+ );
232
+ if (!internal.channels.has(channel)) {
233
+ throw new Error(
234
+ `BCP Realtime: connection "${connectionId}" has not joined channel "${channel}".`
235
+ );
236
+ }
237
+ const event = normalizeEvent(
238
+ rawEvent
239
+ );
240
+ await api.touch();
241
+ const group = handlers.get(event);
242
+ if (group) {
243
+ for (const handler of group) {
244
+ await handler({
245
+ connection: api,
246
+ channel,
247
+ event,
248
+ payload
249
+ });
250
+ }
251
+ }
252
+ },
253
+ async send(event, payload, channel = "@system") {
254
+ await sendToConnection(
255
+ internal,
256
+ createEnvelope(
257
+ normalizeChannel(channel),
258
+ normalizeEvent(event),
259
+ payload
260
+ )
261
+ );
262
+ },
263
+ async touch() {
264
+ assertConnected(
265
+ internal,
266
+ connectionId
267
+ );
268
+ internal.lastSeenAt = now();
269
+ await presence.touch(
270
+ connectionId,
271
+ internal.lastSeenAt
272
+ );
273
+ },
274
+ async disconnect(code = 1e3, reason = "connection closed") {
275
+ if (!internal.connected) {
276
+ return;
277
+ }
278
+ internal.connected = false;
279
+ connections.delete(
280
+ connectionId
281
+ );
282
+ const channels = Array.from(
283
+ internal.channels
284
+ );
285
+ internal.channels.clear();
286
+ await presence.leaveConnection(
287
+ connectionId
288
+ );
289
+ for (const channel of channels) {
290
+ await sendPresence(
291
+ channel,
292
+ "presence.leave",
293
+ connectionId
294
+ );
295
+ }
296
+ for (const cleanup of internal.cleanup.splice(0)) {
297
+ cleanup();
298
+ }
299
+ await internal.socket?.close?.(
300
+ code,
301
+ reason
302
+ );
303
+ }
304
+ };
305
+ internal.api = api;
306
+ connections.set(
307
+ connectionId,
308
+ internal
309
+ );
310
+ if (connectOptions.socket) {
311
+ bindSocket(
312
+ internal,
313
+ api
314
+ );
315
+ }
316
+ return api;
317
+ },
318
+ attachSocket(socket, attachOptions = {}) {
319
+ return hub.connect({
320
+ ...attachOptions,
321
+ socket
322
+ });
323
+ },
324
+ async broadcast(channel, event, payload, broadcastOptions = {}) {
325
+ await broker.publish(
326
+ createEnvelope(
327
+ normalizeChannel(channel),
328
+ normalizeEvent(event),
329
+ payload,
330
+ broadcastOptions.excludeConnectionId
331
+ )
332
+ );
333
+ },
334
+ subscribe(channel, listener) {
335
+ const normalized = normalizeChannel(channel);
336
+ if (typeof listener !== "function") {
337
+ throw new TypeError(
338
+ "BCP Realtime: subscriber must be a function."
339
+ );
340
+ }
341
+ let group = subscriptions.get(normalized);
342
+ if (!group) {
343
+ group = /* @__PURE__ */ new Set();
344
+ subscriptions.set(
345
+ normalized,
346
+ group
347
+ );
348
+ }
349
+ group.add(listener);
350
+ return () => {
351
+ group?.delete(listener);
352
+ if (group?.size === 0) {
353
+ subscriptions.delete(
354
+ normalized
355
+ );
356
+ }
357
+ };
358
+ },
359
+ on(event, handler) {
360
+ const normalized = normalizeEvent(event);
361
+ if (typeof handler !== "function") {
362
+ throw new TypeError(
363
+ "BCP Realtime: event handler must be a function."
364
+ );
365
+ }
366
+ let group = handlers.get(normalized);
367
+ if (!group) {
368
+ group = /* @__PURE__ */ new Set();
369
+ handlers.set(
370
+ normalized,
371
+ group
372
+ );
373
+ }
374
+ group.add(handler);
375
+ return () => {
376
+ group?.delete(handler);
377
+ if (group?.size === 0) {
378
+ handlers.delete(normalized);
379
+ }
380
+ };
381
+ },
382
+ members(channel) {
383
+ return presence.list(
384
+ normalizeChannel(channel)
385
+ );
386
+ },
387
+ connection(id) {
388
+ return connections.get(
389
+ normalizeId(id, "connection id")
390
+ )?.api ?? null;
391
+ },
392
+ connections() {
393
+ return Array.from(
394
+ connections.values(),
395
+ (connection) => connection.api
396
+ );
397
+ },
398
+ async sweepStale() {
399
+ const cutoff = now() - heartbeatTimeoutMs;
400
+ const stale = Array.from(
401
+ connections.values()
402
+ ).filter(
403
+ (connection) => connection.lastSeenAt <= cutoff
404
+ );
405
+ for (const connection of stale) {
406
+ await connection.api.disconnect(
407
+ 4e3,
408
+ "heartbeat timeout"
409
+ );
410
+ }
411
+ return stale.length;
412
+ },
413
+ startHeartbeat(heartbeatOptions = {}) {
414
+ const intervalMs = positiveInteger(
415
+ heartbeatOptions.intervalMs ?? Math.max(
416
+ 1e3,
417
+ Math.floor(
418
+ heartbeatTimeoutMs / 2
419
+ )
420
+ ),
421
+ "heartbeat interval"
422
+ );
423
+ let running = true;
424
+ let stopPromise = null;
425
+ let timer;
426
+ const tick = async () => {
427
+ if (!running) {
428
+ return;
429
+ }
430
+ try {
431
+ for (const connection of connections.values()) {
432
+ await connection.api.send(
433
+ "realtime.ping",
434
+ {
435
+ timestamp: now()
436
+ }
437
+ );
438
+ }
439
+ await hub.sweepStale();
440
+ } catch (error) {
441
+ await reportError(error);
442
+ }
443
+ if (running) {
444
+ timer = setTimeout(
445
+ tick,
446
+ intervalMs
447
+ );
448
+ }
449
+ };
450
+ timer = setTimeout(
451
+ tick,
452
+ intervalMs
453
+ );
454
+ const runner = {
455
+ get running() {
456
+ return running;
457
+ },
458
+ stop() {
459
+ if (!stopPromise) {
460
+ running = false;
461
+ if (timer) {
462
+ clearTimeout(timer);
463
+ }
464
+ heartbeatRunners.delete(
465
+ runner
466
+ );
467
+ stopPromise = Promise.resolve();
468
+ }
469
+ return stopPromise;
470
+ }
471
+ };
472
+ heartbeatRunners.add(runner);
473
+ return runner;
474
+ },
475
+ sse(channel, sseOptions = {}) {
476
+ return createRealtimeSseResponse(
477
+ hub,
478
+ channel,
479
+ sseOptions
480
+ );
481
+ },
482
+ async close() {
483
+ await Promise.all(
484
+ Array.from(
485
+ heartbeatRunners,
486
+ (runner) => runner.stop()
487
+ )
488
+ );
489
+ await Promise.all(
490
+ Array.from(
491
+ connections.values(),
492
+ (connection) => connection.api.disconnect(
493
+ 1001,
494
+ "realtime hub closing"
495
+ )
496
+ )
497
+ );
498
+ unsubscribeBroker();
499
+ subscriptions.clear();
500
+ handlers.clear();
501
+ await presence.close?.();
502
+ await broker.close?.();
503
+ }
504
+ };
505
+ return hub;
506
+ function createEnvelope(channel, event, payload, excludeConnectionId) {
507
+ return {
508
+ id: normalizeId(
509
+ idFactory(),
510
+ "message id"
511
+ ),
512
+ channel,
513
+ event,
514
+ payload,
515
+ timestamp: now(),
516
+ sourceId: hubId,
517
+ excludeConnectionId
518
+ };
519
+ }
520
+ async function dispatchBrokerMessage(message) {
521
+ const subscribers = subscriptions.get(
522
+ message.channel
523
+ );
524
+ if (subscribers) {
525
+ for (const listener of subscribers) {
526
+ try {
527
+ await listener(
528
+ cloneEnvelope(message)
529
+ );
530
+ } catch (error) {
531
+ await reportError(error);
532
+ }
533
+ }
534
+ }
535
+ for (const connection of connections.values()) {
536
+ if (!connection.connected || !connection.channels.has(
537
+ message.channel
538
+ ) || connection.api.id === message.excludeConnectionId) {
539
+ continue;
540
+ }
541
+ try {
542
+ await sendToConnection(
543
+ connection,
544
+ message
545
+ );
546
+ } catch (error) {
547
+ await reportError(error);
548
+ }
549
+ }
550
+ }
551
+ async function sendToConnection(connection, message) {
552
+ if (!connection.connected) {
553
+ return;
554
+ }
555
+ if (!connection.socket) {
556
+ return;
557
+ }
558
+ await connection.socket.send(
559
+ JSON.stringify({
560
+ type: "event",
561
+ ...message
562
+ })
563
+ );
564
+ }
565
+ async function sendPresence(channel, event, excludeConnectionId) {
566
+ const members = await presence.list(channel);
567
+ await broker.publish(
568
+ createEnvelope(
569
+ channel,
570
+ event,
571
+ {
572
+ members
573
+ },
574
+ excludeConnectionId
575
+ )
576
+ );
577
+ }
578
+ function bindSocket(internal, connection) {
579
+ const socket = internal.socket;
580
+ if (!socket) {
581
+ return;
582
+ }
583
+ internal.cleanup.push(
584
+ socket.onMessage(
585
+ async (raw) => {
586
+ try {
587
+ const message = parseSocketMessage(raw);
588
+ await connection.touch();
589
+ if (message.type === "ping") {
590
+ await connection.send(
591
+ "realtime.pong",
592
+ {
593
+ timestamp: now()
594
+ }
595
+ );
596
+ return;
597
+ }
598
+ if (message.type === "join") {
599
+ await connection.join(
600
+ normalizeChannel(
601
+ message.channel
602
+ ),
603
+ {
604
+ presence: message.presence
605
+ }
606
+ );
607
+ return;
608
+ }
609
+ if (message.type === "leave") {
610
+ await connection.leave(
611
+ normalizeChannel(
612
+ message.channel
613
+ )
614
+ );
615
+ return;
616
+ }
617
+ if (message.type === "event") {
618
+ await connection.emit(
619
+ normalizeChannel(
620
+ message.channel
621
+ ),
622
+ normalizeEvent(
623
+ message.event
624
+ ),
625
+ message.payload
626
+ );
627
+ return;
628
+ }
629
+ throw new Error(
630
+ "BCP Realtime: unsupported socket message type."
631
+ );
632
+ } catch (error) {
633
+ await reportError(error);
634
+ try {
635
+ await connection.send(
636
+ "realtime.error",
637
+ {
638
+ message: formatError(error)
639
+ }
640
+ );
641
+ } catch {
642
+ }
643
+ }
644
+ }
645
+ )
646
+ );
647
+ internal.cleanup.push(
648
+ socket.onClose(
649
+ async () => {
650
+ await connection.disconnect(
651
+ 1e3,
652
+ "socket closed"
653
+ );
654
+ }
655
+ )
656
+ );
657
+ if (socket.onError) {
658
+ internal.cleanup.push(
659
+ socket.onError(
660
+ async (error) => {
661
+ await reportError(error);
662
+ }
663
+ )
664
+ );
665
+ }
666
+ }
667
+ async function reportError(error) {
668
+ if (options.onError) {
669
+ await options.onError(error);
670
+ }
671
+ }
672
+ }
673
+ function createRealtimeSseResponse(hub, rawChannel, options = {}) {
674
+ const channel = normalizeChannel(
675
+ rawChannel
676
+ );
677
+ const encoder = new TextEncoder();
678
+ const eventName = options.event ? normalizeEvent(
679
+ options.event
680
+ ) : void 0;
681
+ const retryMs = options.retryMs === void 0 ? void 0 : positiveInteger(
682
+ options.retryMs,
683
+ "SSE retryMs"
684
+ );
685
+ const keepAliveMs = positiveInteger(
686
+ options.keepAliveMs ?? 15e3,
687
+ "SSE keepAliveMs"
688
+ );
689
+ const stream = new ReadableStream({
690
+ start(controller) {
691
+ let closed = false;
692
+ let timer;
693
+ let unsubscribe = () => void 0;
694
+ const close = () => {
695
+ if (closed) {
696
+ return;
697
+ }
698
+ closed = true;
699
+ if (timer) {
700
+ clearTimeout(timer);
701
+ }
702
+ unsubscribe();
703
+ try {
704
+ controller.close();
705
+ } catch {
706
+ }
707
+ };
708
+ const write = (value) => {
709
+ if (!closed) {
710
+ controller.enqueue(
711
+ encoder.encode(value)
712
+ );
713
+ }
714
+ };
715
+ if (retryMs !== void 0) {
716
+ write(
717
+ `retry: ${retryMs}
718
+
719
+ `
720
+ );
721
+ }
722
+ write(
723
+ ": connected\n\n"
724
+ );
725
+ unsubscribe = hub.subscribe(
726
+ channel,
727
+ (message) => {
728
+ if (eventName && message.event !== eventName) {
729
+ return;
730
+ }
731
+ write(
732
+ serializeSseMessage(
733
+ message
734
+ )
735
+ );
736
+ }
737
+ );
738
+ const keepAlive = () => {
739
+ if (closed) {
740
+ return;
741
+ }
742
+ write(
743
+ `: keep-alive ${Date.now()}
744
+
745
+ `
746
+ );
747
+ timer = setTimeout(
748
+ keepAlive,
749
+ keepAliveMs
750
+ );
751
+ };
752
+ timer = setTimeout(
753
+ keepAlive,
754
+ keepAliveMs
755
+ );
756
+ if (options.signal) {
757
+ if (options.signal.aborted) {
758
+ close();
759
+ return;
760
+ }
761
+ options.signal.addEventListener(
762
+ "abort",
763
+ close,
764
+ {
765
+ once: true
766
+ }
767
+ );
768
+ }
769
+ }
770
+ });
771
+ const headers = new Headers(
772
+ options.headers
773
+ );
774
+ headers.set(
775
+ "Content-Type",
776
+ "text/event-stream; charset=utf-8"
777
+ );
778
+ headers.set(
779
+ "Cache-Control",
780
+ "no-cache, no-transform"
781
+ );
782
+ headers.set(
783
+ "Connection",
784
+ "keep-alive"
785
+ );
786
+ headers.set(
787
+ "X-Accel-Buffering",
788
+ "no"
789
+ );
790
+ return new Response(
791
+ stream,
792
+ {
793
+ status: 200,
794
+ headers
795
+ }
796
+ );
797
+ }
798
+ function serializeSseMessage(message) {
799
+ const payload = JSON.stringify({
800
+ id: message.id,
801
+ channel: message.channel,
802
+ event: message.event,
803
+ payload: message.payload,
804
+ timestamp: message.timestamp
805
+ });
806
+ return `id: ${escapeSse(message.id)}
807
+ event: ${escapeSse(message.event)}
808
+ data: ${payload}
809
+
810
+ `;
811
+ }
812
+ function parseSocketMessage(raw) {
813
+ if (typeof raw !== "string") {
814
+ throw new TypeError(
815
+ "BCP Realtime: socket messages must be UTF-8 strings."
816
+ );
817
+ }
818
+ let parsed;
819
+ try {
820
+ parsed = JSON.parse(raw);
821
+ } catch {
822
+ throw new Error(
823
+ "BCP Realtime: socket message must be valid JSON."
824
+ );
825
+ }
826
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
827
+ throw new Error(
828
+ "BCP Realtime: socket message must be an object."
829
+ );
830
+ }
831
+ return parsed;
832
+ }
833
+ function cloneEnvelope(message) {
834
+ return {
835
+ ...message
836
+ };
837
+ }
838
+ function clonePresence(member) {
839
+ return {
840
+ ...member
841
+ };
842
+ }
843
+ function presenceKey(channel, connectionId) {
844
+ return `${channel}\0${connectionId}`;
845
+ }
846
+ function normalizeChannel(value) {
847
+ const channel = String(value ?? "").trim();
848
+ if (!channel) {
849
+ throw new TypeError(
850
+ "BCP Realtime: channel must be a non-empty string."
851
+ );
852
+ }
853
+ if (channel.length > 200) {
854
+ throw new TypeError(
855
+ "BCP Realtime: channel must not exceed 200 characters."
856
+ );
857
+ }
858
+ return channel;
859
+ }
860
+ function normalizeEvent(value) {
861
+ const event = String(value ?? "").trim();
862
+ if (!event) {
863
+ throw new TypeError(
864
+ "BCP Realtime: event must be a non-empty string."
865
+ );
866
+ }
867
+ if (event.length > 200) {
868
+ throw new TypeError(
869
+ "BCP Realtime: event must not exceed 200 characters."
870
+ );
871
+ }
872
+ return event;
873
+ }
874
+ function normalizeId(value, field) {
875
+ const id = String(value ?? "").trim();
876
+ if (!id) {
877
+ throw new TypeError(
878
+ `BCP Realtime: ${field} must be a non-empty string.`
879
+ );
880
+ }
881
+ return id;
882
+ }
883
+ function optionalId(value) {
884
+ if (value === void 0 || value === null) {
885
+ return void 0;
886
+ }
887
+ const id = String(value).trim();
888
+ return id || void 0;
889
+ }
890
+ function positiveInteger(value, field) {
891
+ if (!Number.isInteger(value) || value <= 0) {
892
+ throw new TypeError(
893
+ `BCP Realtime: ${field} must be a positive integer.`
894
+ );
895
+ }
896
+ return value;
897
+ }
898
+ function assertFiniteNumber(value, field) {
899
+ if (!Number.isFinite(value)) {
900
+ throw new TypeError(
901
+ `BCP Realtime: ${field} must be a finite number.`
902
+ );
903
+ }
904
+ }
905
+ function assertConnected(connection, id) {
906
+ if (!connection.connected) {
907
+ throw new Error(
908
+ `BCP Realtime: connection "${id}" is closed.`
909
+ );
910
+ }
911
+ }
912
+ function escapeSse(value) {
913
+ return value.replace(
914
+ /[\r\n]/g,
915
+ ""
916
+ );
917
+ }
918
+ function formatError(error) {
919
+ if (error instanceof Error) {
920
+ return error.message || error.name;
921
+ }
922
+ if (typeof error === "string") {
923
+ return error;
924
+ }
925
+ try {
926
+ return JSON.stringify(error) ?? String(error);
927
+ } catch {
928
+ return String(error);
929
+ }
930
+ }
931
+ export {
932
+ createMemoryRealtimeBroker,
933
+ createMemoryRealtimePresenceStore,
934
+ createRealtime,
935
+ createRealtimeSseResponse
936
+ };