@jskit-ai/realtime 0.1.4
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/package.descriptor.mjs +142 -0
- package/package.json +26 -0
- package/src/client/RealtimeClientProvider.js +302 -0
- package/src/client/components/RealtimeConnectionIndicator.js +122 -0
- package/src/client/composables/useRealtimeEvent.js +147 -0
- package/src/client/listeners.js +69 -0
- package/src/client/runtime.js +37 -0
- package/src/client/tokens.js +11 -0
- package/src/server/RealtimeServiceProvider.js +743 -0
- package/src/server/runtime.js +134 -0
- package/src/server/tokens.js +7 -0
- package/test/clientListeners.test.js +66 -0
- package/test/clientRuntime.test.js +81 -0
- package/test/entrypoints.boundary.test.js +45 -0
- package/test/providerRuntime.test.js +582 -0
- package/test/serverRuntime.test.js +149 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
|
|
2
|
+
import { normalizePositiveInteger, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
import {
|
|
4
|
+
registerDomainEventListener,
|
|
5
|
+
resolveServiceRegistrations
|
|
6
|
+
} from "@jskit-ai/kernel/server/runtime";
|
|
7
|
+
import {
|
|
8
|
+
createSocketIoServer,
|
|
9
|
+
closeSocketIoServer,
|
|
10
|
+
resolveRealtimeRedisUrl,
|
|
11
|
+
configureSocketIoRedisAdapter,
|
|
12
|
+
closeSocketIoRedisConnections
|
|
13
|
+
} from "./runtime.js";
|
|
14
|
+
import {
|
|
15
|
+
REALTIME_RUNTIME_SERVER_TOKEN,
|
|
16
|
+
REALTIME_SOCKET_IO_SERVER_TOKEN
|
|
17
|
+
} from "./tokens.js";
|
|
18
|
+
|
|
19
|
+
const REALTIME_RUNTIME_SERVER_API = Object.freeze({
|
|
20
|
+
createSocketIoServer,
|
|
21
|
+
closeSocketIoServer
|
|
22
|
+
});
|
|
23
|
+
const REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN = "runtime.realtime.domain-event-bridge";
|
|
24
|
+
|
|
25
|
+
const REALTIME_ROOM_ALL_CLIENTS = "clients";
|
|
26
|
+
const REALTIME_ROOM_ALL_USERS = "users";
|
|
27
|
+
|
|
28
|
+
function normalizeArray(value) {
|
|
29
|
+
const queue = Array.isArray(value) ? [...value] : [value];
|
|
30
|
+
const list = [];
|
|
31
|
+
|
|
32
|
+
while (queue.length > 0) {
|
|
33
|
+
const entry = queue.shift();
|
|
34
|
+
if (Array.isArray(entry)) {
|
|
35
|
+
queue.push(...entry);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (entry == null) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
list.push(entry);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return list;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function roomForUser(userId) {
|
|
48
|
+
return `user:${Number(userId)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function roomForWorkspace(workspaceId) {
|
|
52
|
+
return `workspace:${Number(workspaceId)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function roomForWorkspaceUser(workspaceId, userId) {
|
|
56
|
+
return `workspace:${Number(workspaceId)}:user:${Number(userId)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseCookieHeader(value = "") {
|
|
60
|
+
const source = String(value || "").trim();
|
|
61
|
+
if (!source) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return source.split(";").reduce((cookies, entry) => {
|
|
66
|
+
const separator = entry.indexOf("=");
|
|
67
|
+
if (separator < 1) {
|
|
68
|
+
return cookies;
|
|
69
|
+
}
|
|
70
|
+
const key = entry.slice(0, separator).trim();
|
|
71
|
+
const rawValue = entry.slice(separator + 1).trim();
|
|
72
|
+
if (!key) {
|
|
73
|
+
return cookies;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
cookies[key] = decodeURIComponent(rawValue);
|
|
78
|
+
} catch {
|
|
79
|
+
cookies[key] = rawValue;
|
|
80
|
+
}
|
|
81
|
+
return cookies;
|
|
82
|
+
}, {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createProviderLogger(scope, { debugEnabled = false } = {}) {
|
|
86
|
+
const logger =
|
|
87
|
+
scope && typeof scope.has === "function" && scope.has(KERNEL_TOKENS.Logger) ? scope.make(KERNEL_TOKENS.Logger) : null;
|
|
88
|
+
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
debug: (...args) => {
|
|
91
|
+
if (debugEnabled !== true) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (logger && typeof logger.info === "function") {
|
|
95
|
+
logger.info(...args);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
console.info(...args);
|
|
99
|
+
},
|
|
100
|
+
info: (...args) => {
|
|
101
|
+
if (logger && typeof logger.info === "function") {
|
|
102
|
+
logger.info(...args);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.info(...args);
|
|
106
|
+
},
|
|
107
|
+
warn: (...args) => {
|
|
108
|
+
if (logger && typeof logger.warn === "function") {
|
|
109
|
+
logger.warn(...args);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.warn(...args);
|
|
113
|
+
},
|
|
114
|
+
error: (...args) => {
|
|
115
|
+
if (logger && typeof logger.error === "function") {
|
|
116
|
+
logger.error(...args);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.error(...args);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseDebugFlag(value, fallback = null) {
|
|
125
|
+
if (typeof value === "boolean") {
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
130
|
+
if (!normalized) {
|
|
131
|
+
return fallback;
|
|
132
|
+
}
|
|
133
|
+
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveRealtimeServerDebugEnabled(scope) {
|
|
144
|
+
const appConfig = scope && typeof scope.has === "function" && scope.has("appConfig") ? scope.make("appConfig") : {};
|
|
145
|
+
const env = scope && typeof scope.has === "function" && scope.has(KERNEL_TOKENS.Env) ? scope.make(KERNEL_TOKENS.Env) : {};
|
|
146
|
+
const realtime = appConfig && typeof appConfig === "object" ? appConfig.realtime : null;
|
|
147
|
+
|
|
148
|
+
const envFlag = parseDebugFlag(env?.JSKIT_REALTIME_DEBUG);
|
|
149
|
+
if (envFlag !== null) {
|
|
150
|
+
return envFlag;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const configFlag = parseDebugFlag(realtime?.debug);
|
|
154
|
+
if (configFlag !== null) {
|
|
155
|
+
return configFlag;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildRealtimeDispatchIndex(registrations = []) {
|
|
162
|
+
const index = new Map();
|
|
163
|
+
|
|
164
|
+
for (const registration of registrations) {
|
|
165
|
+
const serviceToken = String(registration?.serviceToken || "").trim();
|
|
166
|
+
if (!serviceToken) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const eventsByMethod = registration?.metadata?.events;
|
|
171
|
+
if (!eventsByMethod || typeof eventsByMethod !== "object" || Array.isArray(eventsByMethod)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const [methodName, specs] of Object.entries(eventsByMethod)) {
|
|
176
|
+
const normalizedMethodName = String(methodName || "").trim();
|
|
177
|
+
if (!normalizedMethodName) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const key = `${serviceToken}:${normalizedMethodName}`;
|
|
181
|
+
const list = [];
|
|
182
|
+
const entries = Array.isArray(specs) ? specs : [];
|
|
183
|
+
|
|
184
|
+
for (const spec of entries) {
|
|
185
|
+
const realtime = spec?.realtime;
|
|
186
|
+
const event = normalizeText(realtime?.event);
|
|
187
|
+
if (!event) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
list.push(
|
|
191
|
+
Object.freeze({
|
|
192
|
+
event,
|
|
193
|
+
audience: realtime?.audience
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (list.length > 0) {
|
|
199
|
+
index.set(key, Object.freeze(list));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return index;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function mergeRealtimePayload(event, payloadPatch) {
|
|
208
|
+
const basePayload = event && typeof event === "object" && !Array.isArray(event) ? event : {};
|
|
209
|
+
if (payloadPatch == null) {
|
|
210
|
+
return basePayload;
|
|
211
|
+
}
|
|
212
|
+
if (!payloadPatch || typeof payloadPatch !== "object" || Array.isArray(payloadPatch)) {
|
|
213
|
+
throw new TypeError("Realtime payload callback must return an object.");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Keep canonical domain-event fields authoritative while allowing additive metadata.
|
|
217
|
+
return {
|
|
218
|
+
...payloadPatch,
|
|
219
|
+
...basePayload
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resolveScopeWorkspaceId(scope = {}) {
|
|
224
|
+
const source = scope && typeof scope === "object" && !Array.isArray(scope) ? scope : {};
|
|
225
|
+
if (String(source.kind || "").trim().toLowerCase() === "workspace") {
|
|
226
|
+
return normalizePositiveInteger(source.id);
|
|
227
|
+
}
|
|
228
|
+
if (String(source.kind || "").trim().toLowerCase() === "workspace_user") {
|
|
229
|
+
return normalizePositiveInteger(source.workspaceId || source.id);
|
|
230
|
+
}
|
|
231
|
+
return normalizePositiveInteger(source.workspaceId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function resolveScopeUserId(scope = {}) {
|
|
235
|
+
const source = scope && typeof scope === "object" && !Array.isArray(scope) ? scope : {};
|
|
236
|
+
if (String(source.kind || "").trim().toLowerCase() === "user") {
|
|
237
|
+
return normalizePositiveInteger(source.id);
|
|
238
|
+
}
|
|
239
|
+
if (String(source.kind || "").trim().toLowerCase() === "workspace_user") {
|
|
240
|
+
return normalizePositiveInteger(source.userId);
|
|
241
|
+
}
|
|
242
|
+
return normalizePositiveInteger(source.userId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function applyAudiencePreset(preset, { event, rooms, flags, logger } = {}) {
|
|
246
|
+
const normalizedPreset = normalizeText(preset).toLowerCase();
|
|
247
|
+
if (!normalizedPreset || normalizedPreset === "none") {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (normalizedPreset === "all_clients") {
|
|
252
|
+
flags.broadcastAllClients = true;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (normalizedPreset === "all_users") {
|
|
257
|
+
rooms.add(REALTIME_ROOM_ALL_USERS);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (normalizedPreset === "actor_user") {
|
|
262
|
+
const actorId = normalizePositiveInteger(event?.actorId);
|
|
263
|
+
if (actorId > 0) {
|
|
264
|
+
rooms.add(roomForUser(actorId));
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (normalizedPreset === "all_workspace_users") {
|
|
270
|
+
const workspaceId = resolveScopeWorkspaceId(event?.scope);
|
|
271
|
+
if (workspaceId > 0) {
|
|
272
|
+
rooms.add(roomForWorkspace(workspaceId));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
logger.warn(
|
|
276
|
+
{
|
|
277
|
+
audience: normalizedPreset,
|
|
278
|
+
scope: event?.scope || null
|
|
279
|
+
},
|
|
280
|
+
"Realtime audience preset requires a workspace scope."
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (normalizedPreset === "event_scope") {
|
|
286
|
+
const scopeKind = normalizeText(event?.scope?.kind).toLowerCase();
|
|
287
|
+
if (scopeKind === "workspace") {
|
|
288
|
+
const workspaceId = resolveScopeWorkspaceId(event?.scope);
|
|
289
|
+
if (workspaceId > 0) {
|
|
290
|
+
rooms.add(roomForWorkspace(workspaceId));
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (scopeKind === "workspace_user") {
|
|
295
|
+
const workspaceId = resolveScopeWorkspaceId(event?.scope);
|
|
296
|
+
const userId = resolveScopeUserId(event?.scope);
|
|
297
|
+
if (workspaceId > 0 && userId > 0) {
|
|
298
|
+
rooms.add(roomForWorkspaceUser(workspaceId, userId));
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (scopeKind === "user") {
|
|
303
|
+
const userId = resolveScopeUserId(event?.scope);
|
|
304
|
+
if (userId > 0) {
|
|
305
|
+
rooms.add(roomForUser(userId));
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
rooms.add(REALTIME_ROOM_ALL_USERS);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
logger.warn(
|
|
314
|
+
{
|
|
315
|
+
audience: normalizedPreset
|
|
316
|
+
},
|
|
317
|
+
"Realtime bridge ignored unknown audience preset."
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function addAudienceRoomsFromObject(selection, { event, rooms, flags, logger } = {}) {
|
|
322
|
+
if (!selection || typeof selection !== "object" || Array.isArray(selection)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (Object.hasOwn(selection, "preset")) {
|
|
327
|
+
applyAudiencePreset(selection.preset, {
|
|
328
|
+
event,
|
|
329
|
+
rooms,
|
|
330
|
+
flags,
|
|
331
|
+
logger
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (selection.broadcast === true) {
|
|
335
|
+
flags.broadcastAllClients = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const room = normalizeText(selection.room);
|
|
339
|
+
if (room) {
|
|
340
|
+
rooms.add(room);
|
|
341
|
+
}
|
|
342
|
+
for (const entry of normalizeArray(selection.rooms)) {
|
|
343
|
+
const normalizedRoom = normalizeText(entry);
|
|
344
|
+
if (normalizedRoom) {
|
|
345
|
+
rooms.add(normalizedRoom);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const userId = normalizePositiveInteger(selection.userId);
|
|
350
|
+
if (userId > 0) {
|
|
351
|
+
rooms.add(roomForUser(userId));
|
|
352
|
+
}
|
|
353
|
+
for (const entry of normalizeArray(selection.userIds)) {
|
|
354
|
+
const normalizedUserId = normalizePositiveInteger(entry);
|
|
355
|
+
if (normalizedUserId > 0) {
|
|
356
|
+
rooms.add(roomForUser(normalizedUserId));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const workspaceId = normalizePositiveInteger(selection.workspaceId);
|
|
361
|
+
if (workspaceId > 0) {
|
|
362
|
+
rooms.add(roomForWorkspace(workspaceId));
|
|
363
|
+
}
|
|
364
|
+
for (const entry of normalizeArray(selection.workspaceIds)) {
|
|
365
|
+
const normalizedWorkspaceId = normalizePositiveInteger(entry);
|
|
366
|
+
if (normalizedWorkspaceId > 0) {
|
|
367
|
+
rooms.add(roomForWorkspace(normalizedWorkspaceId));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const workspaceUser = selection.workspaceUser;
|
|
372
|
+
if (workspaceUser && typeof workspaceUser === "object") {
|
|
373
|
+
const targetWorkspaceId = normalizePositiveInteger(workspaceUser.workspaceId);
|
|
374
|
+
const targetUserId = normalizePositiveInteger(workspaceUser.userId);
|
|
375
|
+
if (targetWorkspaceId > 0 && targetUserId > 0) {
|
|
376
|
+
rooms.add(roomForWorkspaceUser(targetWorkspaceId, targetUserId));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const entry of normalizeArray(selection.workspaceUsers)) {
|
|
381
|
+
if (!entry || typeof entry !== "object") {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const targetWorkspaceId = normalizePositiveInteger(entry.workspaceId);
|
|
385
|
+
const targetUserId = normalizePositiveInteger(entry.userId);
|
|
386
|
+
if (targetWorkspaceId > 0 && targetUserId > 0) {
|
|
387
|
+
rooms.add(roomForWorkspaceUser(targetWorkspaceId, targetUserId));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function collectUserIdsFromQueryRows(rows = []) {
|
|
393
|
+
const result = new Set();
|
|
394
|
+
for (const row of normalizeArray(rows)) {
|
|
395
|
+
if (typeof row === "number") {
|
|
396
|
+
const directId = normalizePositiveInteger(row);
|
|
397
|
+
if (directId > 0) {
|
|
398
|
+
result.add(directId);
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const userId = normalizePositiveInteger(row.userId || row.user_id || row.id);
|
|
406
|
+
if (userId > 0) {
|
|
407
|
+
result.add(userId);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return [...result];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function resolveAudienceQueryRooms(userQuery, { scope, event, logger } = {}) {
|
|
414
|
+
if (typeof userQuery !== "function") {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!scope || typeof scope.has !== "function" || typeof scope.make !== "function" || !scope.has(KERNEL_TOKENS.Knex)) {
|
|
419
|
+
logger.warn("Realtime audience userQuery requires runtime database token.");
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const knex = scope.make(KERNEL_TOKENS.Knex);
|
|
424
|
+
const queryResult = await userQuery({
|
|
425
|
+
knex,
|
|
426
|
+
event
|
|
427
|
+
});
|
|
428
|
+
const userIds = collectUserIdsFromQueryRows(await Promise.resolve(queryResult));
|
|
429
|
+
return userIds.map((userId) => roomForUser(userId));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function resolveAudienceTargets(dispatcher, event, { scope, logger } = {}) {
|
|
433
|
+
const rooms = new Set();
|
|
434
|
+
const flags = {
|
|
435
|
+
broadcastAllClients: false
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
let selection = dispatcher?.audience;
|
|
439
|
+
if (typeof selection === "function") {
|
|
440
|
+
selection = await selection({
|
|
441
|
+
event
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for (const entry of normalizeArray(selection)) {
|
|
446
|
+
if (typeof entry === "string") {
|
|
447
|
+
applyAudiencePreset(entry, {
|
|
448
|
+
event,
|
|
449
|
+
rooms,
|
|
450
|
+
flags,
|
|
451
|
+
logger
|
|
452
|
+
});
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
addAudienceRoomsFromObject(entry, {
|
|
461
|
+
event,
|
|
462
|
+
rooms,
|
|
463
|
+
flags,
|
|
464
|
+
logger
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
if (typeof entry.userQuery === "function") {
|
|
468
|
+
const queryRooms = await resolveAudienceQueryRooms(entry.userQuery, {
|
|
469
|
+
scope,
|
|
470
|
+
event,
|
|
471
|
+
logger
|
|
472
|
+
});
|
|
473
|
+
for (const room of queryRooms) {
|
|
474
|
+
rooms.add(room);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return Object.freeze({
|
|
480
|
+
broadcastAllClients: flags.broadcastAllClients,
|
|
481
|
+
rooms: Object.freeze([...rooms])
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function resolveSocketActorId(authService, socket) {
|
|
486
|
+
if (!authService || typeof authService.authenticateRequest !== "function") {
|
|
487
|
+
return 0;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const cookies = parseCookieHeader(socket?.request?.headers?.cookie);
|
|
491
|
+
const authResult = await authService.authenticateRequest({
|
|
492
|
+
cookies
|
|
493
|
+
});
|
|
494
|
+
if (!authResult || authResult.authenticated !== true) {
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
return normalizePositiveInteger(authResult?.profile?.id);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function resolveActorWorkspaceIds(workspaceMembershipsRepository, actorId) {
|
|
501
|
+
if (!workspaceMembershipsRepository || typeof workspaceMembershipsRepository.listActiveWorkspaceIdsByUserId !== "function") {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const workspaceIds = await workspaceMembershipsRepository.listActiveWorkspaceIdsByUserId(actorId);
|
|
506
|
+
return normalizeArray(workspaceIds)
|
|
507
|
+
.map((entry) => normalizePositiveInteger(entry))
|
|
508
|
+
.filter((entry) => entry > 0);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function registerRealtimeSocketAudienceBootstrap(scope, io, logger) {
|
|
512
|
+
if (!io || typeof io.on !== "function") {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const authService =
|
|
517
|
+
scope && typeof scope.has === "function" && scope.has("authService") ? scope.make("authService") : null;
|
|
518
|
+
const workspaceMembershipsRepository =
|
|
519
|
+
scope && typeof scope.has === "function" && scope.has("workspaceMembershipsRepository")
|
|
520
|
+
? scope.make("workspaceMembershipsRepository")
|
|
521
|
+
: null;
|
|
522
|
+
|
|
523
|
+
io.on("connection", async (socket) => {
|
|
524
|
+
try {
|
|
525
|
+
socket.join(REALTIME_ROOM_ALL_CLIENTS);
|
|
526
|
+
logger.debug(
|
|
527
|
+
{
|
|
528
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
529
|
+
stage: "socket.connection",
|
|
530
|
+
room: REALTIME_ROOM_ALL_CLIENTS
|
|
531
|
+
},
|
|
532
|
+
"Realtime socket joined default clients room."
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const actorId = await resolveSocketActorId(authService, socket);
|
|
536
|
+
if (actorId < 1) {
|
|
537
|
+
logger.debug(
|
|
538
|
+
{
|
|
539
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
540
|
+
stage: "socket.connection",
|
|
541
|
+
authenticated: false
|
|
542
|
+
},
|
|
543
|
+
"Realtime socket connected without authenticated actor."
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
socket.data = socket.data && typeof socket.data === "object" ? socket.data : {};
|
|
549
|
+
socket.data.actorId = actorId;
|
|
550
|
+
|
|
551
|
+
socket.join(REALTIME_ROOM_ALL_USERS);
|
|
552
|
+
socket.join(roomForUser(actorId));
|
|
553
|
+
logger.debug(
|
|
554
|
+
{
|
|
555
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
556
|
+
stage: "socket.connection",
|
|
557
|
+
actorId,
|
|
558
|
+
joinedRooms: [REALTIME_ROOM_ALL_USERS, roomForUser(actorId)]
|
|
559
|
+
},
|
|
560
|
+
"Realtime socket joined actor/user rooms."
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const workspaceIds = await resolveActorWorkspaceIds(workspaceMembershipsRepository, actorId);
|
|
564
|
+
for (const workspaceId of workspaceIds) {
|
|
565
|
+
socket.join(roomForWorkspace(workspaceId));
|
|
566
|
+
socket.join(roomForWorkspaceUser(workspaceId, actorId));
|
|
567
|
+
}
|
|
568
|
+
logger.debug(
|
|
569
|
+
{
|
|
570
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
571
|
+
stage: "socket.connection",
|
|
572
|
+
actorId,
|
|
573
|
+
workspaceIds
|
|
574
|
+
},
|
|
575
|
+
"Realtime socket joined workspace rooms."
|
|
576
|
+
);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
logger.warn(
|
|
579
|
+
{
|
|
580
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
581
|
+
error: String(error?.message || error || "unknown error")
|
|
582
|
+
},
|
|
583
|
+
"Realtime socket audience bootstrap failed."
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
class RealtimeServiceProvider {
|
|
590
|
+
static id = REALTIME_RUNTIME_SERVER_TOKEN;
|
|
591
|
+
|
|
592
|
+
register(app) {
|
|
593
|
+
if (!app || typeof app.singleton !== "function" || typeof app.tag !== "function") {
|
|
594
|
+
throw new Error("RealtimeServiceProvider requires application singleton()/tag().");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
app.singleton(REALTIME_RUNTIME_SERVER_TOKEN, () => REALTIME_RUNTIME_SERVER_API);
|
|
598
|
+
app.singleton(REALTIME_SOCKET_IO_SERVER_TOKEN, (scope) => {
|
|
599
|
+
const fastify = scope.make(KERNEL_TOKENS.Fastify);
|
|
600
|
+
return createSocketIoServer({
|
|
601
|
+
fastify
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
registerDomainEventListener(app, REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN, (scope) => {
|
|
606
|
+
const io = scope.make(REALTIME_SOCKET_IO_SERVER_TOKEN);
|
|
607
|
+
const realtimeDispatchIndex = buildRealtimeDispatchIndex(resolveServiceRegistrations(scope));
|
|
608
|
+
const logger = createProviderLogger(scope, {
|
|
609
|
+
debugEnabled: resolveRealtimeServerDebugEnabled(scope)
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
614
|
+
async handle(event = {}) {
|
|
615
|
+
const serviceToken = normalizeText(event?.meta?.service?.token);
|
|
616
|
+
const methodName = normalizeText(event?.meta?.service?.method);
|
|
617
|
+
const emittedRealtimeEvent = normalizeText(event?.meta?.realtime?.event);
|
|
618
|
+
if (!serviceToken || !methodName) {
|
|
619
|
+
logger.warn(
|
|
620
|
+
{
|
|
621
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
622
|
+
reason: "missing-service-meta",
|
|
623
|
+
meta: event?.meta || null
|
|
624
|
+
},
|
|
625
|
+
"Realtime bridge skipped domain event."
|
|
626
|
+
);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const dispatchersForMethod = realtimeDispatchIndex.get(`${serviceToken}:${methodName}`) || [];
|
|
631
|
+
const dispatchers =
|
|
632
|
+
emittedRealtimeEvent.length > 0
|
|
633
|
+
? dispatchersForMethod.filter((dispatcher) => normalizeText(dispatcher?.event) === emittedRealtimeEvent)
|
|
634
|
+
: dispatchersForMethod;
|
|
635
|
+
logger.debug(
|
|
636
|
+
{
|
|
637
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
638
|
+
serviceToken,
|
|
639
|
+
methodName,
|
|
640
|
+
emittedRealtimeEvent: emittedRealtimeEvent || null,
|
|
641
|
+
dispatcherCount: dispatchers.length,
|
|
642
|
+
methodDispatcherCount: dispatchersForMethod.length,
|
|
643
|
+
eventType: event?.type || null,
|
|
644
|
+
scope: event?.scope || null,
|
|
645
|
+
entityId: event?.entityId || null
|
|
646
|
+
},
|
|
647
|
+
"Realtime bridge received service event."
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
if (dispatchers.length < 1) {
|
|
651
|
+
logger.warn(
|
|
652
|
+
{
|
|
653
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
654
|
+
serviceToken,
|
|
655
|
+
methodName,
|
|
656
|
+
emittedRealtimeEvent: emittedRealtimeEvent || null
|
|
657
|
+
},
|
|
658
|
+
"Realtime bridge found no matching dispatcher for service event."
|
|
659
|
+
);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
for (const dispatcher of dispatchers) {
|
|
664
|
+
const payloadPatch =
|
|
665
|
+
event?.meta?.realtime && typeof event.meta.realtime === "object"
|
|
666
|
+
? event.meta.realtime.payload
|
|
667
|
+
: null;
|
|
668
|
+
const payload = mergeRealtimePayload(event, payloadPatch);
|
|
669
|
+
const targets = await resolveAudienceTargets(dispatcher, event, {
|
|
670
|
+
scope,
|
|
671
|
+
logger
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
if (targets.broadcastAllClients === true) {
|
|
675
|
+
io.emit(dispatcher.event, payload);
|
|
676
|
+
}
|
|
677
|
+
for (const room of targets.rooms) {
|
|
678
|
+
io.to(room).emit(dispatcher.event, payload);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
logger.debug(
|
|
682
|
+
{
|
|
683
|
+
listenerId: REALTIME_DOMAIN_EVENT_BRIDGE_TOKEN,
|
|
684
|
+
socketEvent: dispatcher.event,
|
|
685
|
+
serviceToken,
|
|
686
|
+
methodName,
|
|
687
|
+
rooms: targets.rooms,
|
|
688
|
+
broadcastAllClients: targets.broadcastAllClients,
|
|
689
|
+
connectedClients:
|
|
690
|
+
io && io.engine && Number.isInteger(Number(io.engine.clientsCount))
|
|
691
|
+
? Number(io.engine.clientsCount)
|
|
692
|
+
: null,
|
|
693
|
+
payloadScope: payload?.scope || null,
|
|
694
|
+
payloadEntityId: payload?.entityId || null
|
|
695
|
+
},
|
|
696
|
+
"Realtime bridge emitted socket event."
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async boot(app) {
|
|
705
|
+
if (!app || typeof app.make !== "function") {
|
|
706
|
+
throw new Error("RealtimeServiceProvider requires application make().");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
this.socketIoServer = app.make(REALTIME_SOCKET_IO_SERVER_TOKEN);
|
|
710
|
+
const debugEnabled = resolveRealtimeServerDebugEnabled(app);
|
|
711
|
+
const logger = createProviderLogger(app, {
|
|
712
|
+
debugEnabled
|
|
713
|
+
});
|
|
714
|
+
logger.debug(
|
|
715
|
+
{
|
|
716
|
+
providerId: RealtimeServiceProvider.id,
|
|
717
|
+
debugEnabled
|
|
718
|
+
},
|
|
719
|
+
"Realtime server debug mode enabled."
|
|
720
|
+
);
|
|
721
|
+
registerRealtimeSocketAudienceBootstrap(app, this.socketIoServer, logger);
|
|
722
|
+
|
|
723
|
+
const env = typeof app.has === "function" && app.has(KERNEL_TOKENS.Env) ? app.make(KERNEL_TOKENS.Env) : {};
|
|
724
|
+
const redisUrl = resolveRealtimeRedisUrl(env);
|
|
725
|
+
this.redisConnection = await configureSocketIoRedisAdapter(this.socketIoServer, {
|
|
726
|
+
redisUrl
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async shutdown() {
|
|
731
|
+
if (!this.socketIoServer) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
await closeSocketIoServer(this.socketIoServer);
|
|
735
|
+
await closeSocketIoRedisConnections(this.redisConnection || {});
|
|
736
|
+
this.redisConnection = null;
|
|
737
|
+
this.socketIoServer = null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export {
|
|
742
|
+
RealtimeServiceProvider
|
|
743
|
+
};
|