@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.
- package/README.md +228 -401
- package/docs/README.md +51 -68
- package/docs/api-manifest.json +31 -63
- package/docs/api-reference.md +103 -118
- package/docs/docs-web-manifest.json +9 -5
- package/docs/platform-manifest.json +27 -4
- package/docs/realtime-platform.md +447 -0
- package/docs/releases/0.2.12.md +147 -0
- package/docs/releases/0.2.13.md +122 -0
- package/docs/transactional-outbox-events.md +465 -0
- package/package.json +11 -1
- package/packages/bundler/src/client-boundary.ts +2 -0
- package/packages/client/src/events.mjs +889 -0
- package/packages/client/src/events.ts +31 -0
- package/packages/client/src/realtime.mjs +936 -0
- package/packages/client/src/realtime.ts +31 -0
- package/packages/server/src/events.ts +1416 -0
- package/packages/server/src/realtime.ts +1464 -0
|
@@ -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
|
+
}
|