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