@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,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
|
+
};
|