@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,582 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
|
|
5
|
+
import { installServiceRegistrationApi } from "@jskit-ai/kernel/server/runtime";
|
|
6
|
+
|
|
7
|
+
import { RealtimeServiceProvider } from "../src/server/RealtimeServiceProvider.js";
|
|
8
|
+
import { RealtimeClientProvider } from "../src/client/RealtimeClientProvider.js";
|
|
9
|
+
import { registerRealtimeClientListener } from "../src/client/listeners.js";
|
|
10
|
+
import {
|
|
11
|
+
REALTIME_RUNTIME_SERVER_TOKEN,
|
|
12
|
+
REALTIME_SOCKET_IO_SERVER_TOKEN
|
|
13
|
+
} from "../src/server/tokens.js";
|
|
14
|
+
import {
|
|
15
|
+
REALTIME_RUNTIME_CLIENT_TOKEN,
|
|
16
|
+
REALTIME_SOCKET_CLIENT_TOKEN
|
|
17
|
+
} from "../src/client/tokens.js";
|
|
18
|
+
|
|
19
|
+
const DOMAIN_EVENT_LISTENER_TAG = Symbol.for("jskit.runtime.domainEvent.listeners");
|
|
20
|
+
|
|
21
|
+
function normalizeDomainEventListener(entry) {
|
|
22
|
+
if (typeof entry === "function") {
|
|
23
|
+
return {
|
|
24
|
+
listenerId: String(entry.name || "anonymous"),
|
|
25
|
+
matches: null,
|
|
26
|
+
handle: entry
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (entry && typeof entry === "object" && typeof entry.handle === "function") {
|
|
30
|
+
return {
|
|
31
|
+
...entry,
|
|
32
|
+
listenerId: String(entry.listenerId || "anonymous"),
|
|
33
|
+
matches: typeof entry.matches === "function" ? entry.matches : null
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createDomainEvents(scope) {
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
async publish(event = {}) {
|
|
42
|
+
const payload = event && typeof event === "object" && !Array.isArray(event) ? event : {};
|
|
43
|
+
const listeners = typeof scope?.resolveTag === "function" ? scope.resolveTag(DOMAIN_EVENT_LISTENER_TAG) : [];
|
|
44
|
+
for (const listenerEntry of listeners) {
|
|
45
|
+
const listener = normalizeDomainEventListener(listenerEntry);
|
|
46
|
+
if (!listener) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (listener.matches && listener.matches(payload) !== true) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
await listener.handle(payload);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createSingletonApp() {
|
|
60
|
+
const instances = new Map();
|
|
61
|
+
const singletons = new Map();
|
|
62
|
+
const tags = new Map();
|
|
63
|
+
return {
|
|
64
|
+
instances,
|
|
65
|
+
singletons,
|
|
66
|
+
tags,
|
|
67
|
+
singleton(token, factory) {
|
|
68
|
+
singletons.set(token, factory);
|
|
69
|
+
},
|
|
70
|
+
instance(token, value) {
|
|
71
|
+
instances.set(token, value);
|
|
72
|
+
},
|
|
73
|
+
has(token) {
|
|
74
|
+
return instances.has(token) || singletons.has(token);
|
|
75
|
+
},
|
|
76
|
+
tag(token, tagName) {
|
|
77
|
+
const normalizedTagName = String(tagName || "").trim();
|
|
78
|
+
if (!tags.has(normalizedTagName)) {
|
|
79
|
+
tags.set(normalizedTagName, new Set());
|
|
80
|
+
}
|
|
81
|
+
tags.get(normalizedTagName).add(token);
|
|
82
|
+
},
|
|
83
|
+
resolveTag(tagName) {
|
|
84
|
+
const normalizedTagName = String(tagName || "").trim();
|
|
85
|
+
const tagged = tags.get(normalizedTagName);
|
|
86
|
+
if (!tagged || tagged.size < 1) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
return [...tagged].map((token) => this.make(token));
|
|
90
|
+
},
|
|
91
|
+
make(token) {
|
|
92
|
+
if (instances.has(token)) {
|
|
93
|
+
return instances.get(token);
|
|
94
|
+
}
|
|
95
|
+
if (!singletons.has(token)) {
|
|
96
|
+
throw new Error(`Missing token: ${String(token)}`);
|
|
97
|
+
}
|
|
98
|
+
const resolved = singletons.get(token)(this);
|
|
99
|
+
instances.set(token, resolved);
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("RealtimeServiceProvider registers runtime realtime server api", () => {
|
|
106
|
+
const app = createSingletonApp();
|
|
107
|
+
app.instance(KERNEL_TOKENS.Fastify, {
|
|
108
|
+
server: createServer()
|
|
109
|
+
});
|
|
110
|
+
const provider = new RealtimeServiceProvider();
|
|
111
|
+
provider.register(app);
|
|
112
|
+
|
|
113
|
+
assert.equal(app.singletons.has(REALTIME_RUNTIME_SERVER_TOKEN), true);
|
|
114
|
+
assert.equal(app.singletons.has(REALTIME_SOCKET_IO_SERVER_TOKEN), true);
|
|
115
|
+
|
|
116
|
+
const api = app.make(REALTIME_RUNTIME_SERVER_TOKEN);
|
|
117
|
+
assert.equal(typeof api.createSocketIoServer, "function");
|
|
118
|
+
assert.equal(typeof api.closeSocketIoServer, "function");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("RealtimeServiceProvider boot starts socket io and shutdown closes it", async () => {
|
|
122
|
+
const app = createSingletonApp();
|
|
123
|
+
app.instance(KERNEL_TOKENS.Fastify, {
|
|
124
|
+
server: createServer()
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const provider = new RealtimeServiceProvider();
|
|
128
|
+
provider.register(app);
|
|
129
|
+
provider.boot(app);
|
|
130
|
+
|
|
131
|
+
const io = app.make(REALTIME_SOCKET_IO_SERVER_TOKEN);
|
|
132
|
+
assert.equal(Boolean(io), true);
|
|
133
|
+
assert.equal(typeof io.on, "function");
|
|
134
|
+
|
|
135
|
+
await provider.shutdown(app);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("RealtimeClientProvider registers runtime realtime client api", () => {
|
|
139
|
+
const app = createSingletonApp();
|
|
140
|
+
const provider = new RealtimeClientProvider();
|
|
141
|
+
provider.register(app);
|
|
142
|
+
|
|
143
|
+
assert.equal(app.singletons.has(REALTIME_RUNTIME_CLIENT_TOKEN), true);
|
|
144
|
+
assert.equal(app.singletons.has(REALTIME_SOCKET_CLIENT_TOKEN), true);
|
|
145
|
+
assert.equal(app.singletons.has("realtime.web.connection.indicator"), true);
|
|
146
|
+
const api = app.make(REALTIME_RUNTIME_CLIENT_TOKEN);
|
|
147
|
+
assert.equal(typeof api.createSocketIoClient, "function");
|
|
148
|
+
assert.equal(typeof api.disconnectSocketIoClient, "function");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("RealtimeClientProvider boots socket listeners and disconnects on shutdown", async () => {
|
|
152
|
+
const app = createSingletonApp();
|
|
153
|
+
const provider = new RealtimeClientProvider();
|
|
154
|
+
provider.register(app);
|
|
155
|
+
|
|
156
|
+
const handlers = new Map();
|
|
157
|
+
const anyHandlers = new Set();
|
|
158
|
+
const socket = {
|
|
159
|
+
on(event, handler) {
|
|
160
|
+
handlers.set(event, handler);
|
|
161
|
+
},
|
|
162
|
+
off(event, handler) {
|
|
163
|
+
if (handlers.get(event) === handler) {
|
|
164
|
+
handlers.delete(event);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
onAny(handler) {
|
|
168
|
+
anyHandlers.add(handler);
|
|
169
|
+
},
|
|
170
|
+
offAny(handler) {
|
|
171
|
+
anyHandlers.delete(handler);
|
|
172
|
+
},
|
|
173
|
+
emitEvent(event, payload) {
|
|
174
|
+
const handler = handlers.get(event);
|
|
175
|
+
if (typeof handler === "function") {
|
|
176
|
+
handler(payload);
|
|
177
|
+
}
|
|
178
|
+
for (const next of anyHandlers) {
|
|
179
|
+
next(event, payload);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
let disconnectCalls = 0;
|
|
185
|
+
app.instance(REALTIME_RUNTIME_CLIENT_TOKEN, {
|
|
186
|
+
createSocketIoClient() {
|
|
187
|
+
return socket;
|
|
188
|
+
},
|
|
189
|
+
disconnectSocketIoClient() {
|
|
190
|
+
disconnectCalls += 1;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const received = [];
|
|
195
|
+
registerRealtimeClientListener(app, "test.realtime.listener", () => ({
|
|
196
|
+
listenerId: "test.realtime.listener",
|
|
197
|
+
event: "customers.record.changed",
|
|
198
|
+
handle({ event, payload }) {
|
|
199
|
+
received.push({
|
|
200
|
+
event,
|
|
201
|
+
payload
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
await provider.boot(app);
|
|
207
|
+
socket.emitEvent("customers.record.changed", {
|
|
208
|
+
id: 10
|
|
209
|
+
});
|
|
210
|
+
await Promise.resolve();
|
|
211
|
+
await provider.shutdown(app);
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(received, [
|
|
214
|
+
{
|
|
215
|
+
event: "customers.record.changed",
|
|
216
|
+
payload: {
|
|
217
|
+
id: 10
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
]);
|
|
221
|
+
assert.equal(disconnectCalls, 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("RealtimeServiceProvider bridges service event metadata to socket emissions", async () => {
|
|
225
|
+
const app = createSingletonApp();
|
|
226
|
+
app.instance(KERNEL_TOKENS.Fastify, {
|
|
227
|
+
server: createServer()
|
|
228
|
+
});
|
|
229
|
+
app.singleton("authService", () => ({
|
|
230
|
+
async authenticateRequest() {
|
|
231
|
+
return {
|
|
232
|
+
authenticated: false
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
236
|
+
app.singleton("workspaceMembershipsRepository", () => ({
|
|
237
|
+
async listActiveWorkspaceIdsByUserId() {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
}));
|
|
241
|
+
installServiceRegistrationApi(app);
|
|
242
|
+
app.singleton("domainEvents", (scope) => createDomainEvents(scope));
|
|
243
|
+
app.service(
|
|
244
|
+
"test.customers.service",
|
|
245
|
+
() => ({
|
|
246
|
+
async createRecord() {
|
|
247
|
+
return { id: 17, name: "Ada" };
|
|
248
|
+
}
|
|
249
|
+
}),
|
|
250
|
+
{
|
|
251
|
+
events: {
|
|
252
|
+
createRecord: [
|
|
253
|
+
{
|
|
254
|
+
type: "entity.changed",
|
|
255
|
+
source: "crud",
|
|
256
|
+
entity: "record",
|
|
257
|
+
operation: "created",
|
|
258
|
+
realtime: {
|
|
259
|
+
event: "customers.record.changed"
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const provider = new RealtimeServiceProvider();
|
|
268
|
+
provider.register(app);
|
|
269
|
+
await provider.boot(app);
|
|
270
|
+
|
|
271
|
+
const io = app.make(REALTIME_SOCKET_IO_SERVER_TOKEN);
|
|
272
|
+
const emitted = [];
|
|
273
|
+
io.to = (room) => {
|
|
274
|
+
return {
|
|
275
|
+
emit(eventName, payload) {
|
|
276
|
+
emitted.push({
|
|
277
|
+
room,
|
|
278
|
+
eventName,
|
|
279
|
+
payload
|
|
280
|
+
});
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const service = app.make("test.customers.service");
|
|
287
|
+
await service.createRecord({
|
|
288
|
+
context: {
|
|
289
|
+
visibilityContext: {
|
|
290
|
+
visibility: "workspace",
|
|
291
|
+
scopeOwnerId: 24
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
await provider.shutdown(app);
|
|
296
|
+
|
|
297
|
+
assert.equal(emitted.length, 1);
|
|
298
|
+
assert.equal(emitted[0].room, "workspace:24");
|
|
299
|
+
assert.equal(emitted[0].eventName, "customers.record.changed");
|
|
300
|
+
assert.equal(emitted[0].payload?.source, "crud");
|
|
301
|
+
assert.equal(emitted[0].payload?.operation, "created");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("RealtimeServiceProvider resolves custom audience callback", async () => {
|
|
305
|
+
const app = createSingletonApp();
|
|
306
|
+
app.instance(KERNEL_TOKENS.Fastify, {
|
|
307
|
+
server: createServer()
|
|
308
|
+
});
|
|
309
|
+
app.singleton("authService", () => ({
|
|
310
|
+
async authenticateRequest() {
|
|
311
|
+
return {
|
|
312
|
+
authenticated: false
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}));
|
|
316
|
+
app.singleton("workspaceMembershipsRepository", () => ({
|
|
317
|
+
async listActiveWorkspaceIdsByUserId() {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
}));
|
|
321
|
+
installServiceRegistrationApi(app);
|
|
322
|
+
app.singleton("domainEvents", (scope) => createDomainEvents(scope));
|
|
323
|
+
app.service(
|
|
324
|
+
"test.customers.service",
|
|
325
|
+
() => ({
|
|
326
|
+
async updateRecord() {
|
|
327
|
+
return { id: 88 };
|
|
328
|
+
}
|
|
329
|
+
}),
|
|
330
|
+
{
|
|
331
|
+
events: {
|
|
332
|
+
updateRecord: [
|
|
333
|
+
{
|
|
334
|
+
type: "entity.changed",
|
|
335
|
+
source: "crud",
|
|
336
|
+
entity: "record",
|
|
337
|
+
operation: "updated",
|
|
338
|
+
realtime: {
|
|
339
|
+
event: "customers.record.changed",
|
|
340
|
+
audience: ({ event }) => ({
|
|
341
|
+
userId: event?.actorId
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
]
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const provider = new RealtimeServiceProvider();
|
|
351
|
+
provider.register(app);
|
|
352
|
+
await provider.boot(app);
|
|
353
|
+
|
|
354
|
+
const io = app.make(REALTIME_SOCKET_IO_SERVER_TOKEN);
|
|
355
|
+
const emitted = [];
|
|
356
|
+
io.to = (room) => {
|
|
357
|
+
return {
|
|
358
|
+
emit(eventName, payload) {
|
|
359
|
+
emitted.push({
|
|
360
|
+
room,
|
|
361
|
+
eventName,
|
|
362
|
+
payload
|
|
363
|
+
});
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const service = app.make("test.customers.service");
|
|
370
|
+
await service.updateRecord(
|
|
371
|
+
{
|
|
372
|
+
id: 88
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
context: {
|
|
376
|
+
actor: {
|
|
377
|
+
id: 9
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
);
|
|
382
|
+
await provider.shutdown(app);
|
|
383
|
+
|
|
384
|
+
assert.equal(emitted.length, 1);
|
|
385
|
+
assert.equal(emitted[0].room, "user:9");
|
|
386
|
+
assert.equal(emitted[0].eventName, "customers.record.changed");
|
|
387
|
+
assert.equal(emitted[0].payload?.operation, "updated");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("RealtimeServiceProvider merges custom realtime payload with canonical domain event fields", async () => {
|
|
391
|
+
const app = createSingletonApp();
|
|
392
|
+
app.instance(KERNEL_TOKENS.Fastify, {
|
|
393
|
+
server: createServer()
|
|
394
|
+
});
|
|
395
|
+
app.singleton("authService", () => ({
|
|
396
|
+
async authenticateRequest() {
|
|
397
|
+
return {
|
|
398
|
+
authenticated: false
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}));
|
|
402
|
+
app.singleton("workspaceMembershipsRepository", () => ({
|
|
403
|
+
async listActiveWorkspaceIdsByUserId() {
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
}));
|
|
407
|
+
installServiceRegistrationApi(app);
|
|
408
|
+
app.singleton("domainEvents", (scope) => createDomainEvents(scope));
|
|
409
|
+
app.service(
|
|
410
|
+
"test.workspace.service",
|
|
411
|
+
() => ({
|
|
412
|
+
async updateWorkspace() {
|
|
413
|
+
return { id: 11, slug: "acme" };
|
|
414
|
+
}
|
|
415
|
+
}),
|
|
416
|
+
{
|
|
417
|
+
events: {
|
|
418
|
+
updateWorkspace: [
|
|
419
|
+
{
|
|
420
|
+
type: "entity.changed",
|
|
421
|
+
source: "workspace",
|
|
422
|
+
entity: "settings",
|
|
423
|
+
operation: "updated",
|
|
424
|
+
realtime: {
|
|
425
|
+
event: "workspace.settings.changed",
|
|
426
|
+
payload: ({ result }) => ({
|
|
427
|
+
workspaceSlug: result?.slug || ""
|
|
428
|
+
}),
|
|
429
|
+
audience: "event_scope"
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
]
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const provider = new RealtimeServiceProvider();
|
|
438
|
+
provider.register(app);
|
|
439
|
+
await provider.boot(app);
|
|
440
|
+
|
|
441
|
+
const io = app.make(REALTIME_SOCKET_IO_SERVER_TOKEN);
|
|
442
|
+
const emitted = [];
|
|
443
|
+
io.to = (room) => {
|
|
444
|
+
return {
|
|
445
|
+
emit(eventName, payload) {
|
|
446
|
+
emitted.push({
|
|
447
|
+
room,
|
|
448
|
+
eventName,
|
|
449
|
+
payload
|
|
450
|
+
});
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const service = app.make("test.workspace.service");
|
|
457
|
+
await service.updateWorkspace(
|
|
458
|
+
{
|
|
459
|
+
id: 11,
|
|
460
|
+
slug: "acme"
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
context: {
|
|
464
|
+
visibilityContext: {
|
|
465
|
+
visibility: "workspace",
|
|
466
|
+
scopeOwnerId: 11
|
|
467
|
+
},
|
|
468
|
+
actor: {
|
|
469
|
+
id: 4
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
await provider.shutdown(app);
|
|
475
|
+
|
|
476
|
+
assert.equal(emitted.length, 1);
|
|
477
|
+
assert.equal(emitted[0].room, "workspace:11");
|
|
478
|
+
assert.equal(emitted[0].eventName, "workspace.settings.changed");
|
|
479
|
+
assert.equal(emitted[0].payload?.workspaceSlug, "acme");
|
|
480
|
+
assert.equal(emitted[0].payload?.source, "workspace");
|
|
481
|
+
assert.equal(emitted[0].payload?.entity, "settings");
|
|
482
|
+
assert.equal(emitted[0].payload?.operation, "updated");
|
|
483
|
+
assert.equal(emitted[0].payload?.scope?.kind, "workspace");
|
|
484
|
+
assert.equal(emitted[0].payload?.scope?.id, 11);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("RealtimeServiceProvider emits only the matching dispatcher event for each service method event", async () => {
|
|
488
|
+
const app = createSingletonApp();
|
|
489
|
+
app.instance(KERNEL_TOKENS.Fastify, {
|
|
490
|
+
server: createServer()
|
|
491
|
+
});
|
|
492
|
+
app.singleton("authService", () => ({
|
|
493
|
+
async authenticateRequest() {
|
|
494
|
+
return {
|
|
495
|
+
authenticated: false
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}));
|
|
499
|
+
app.singleton("workspaceMembershipsRepository", () => ({
|
|
500
|
+
async listActiveWorkspaceIdsByUserId() {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
}));
|
|
504
|
+
installServiceRegistrationApi(app);
|
|
505
|
+
app.singleton("domainEvents", (scope) => createDomainEvents(scope));
|
|
506
|
+
app.service(
|
|
507
|
+
"test.workspace.settings.service",
|
|
508
|
+
() => ({
|
|
509
|
+
async updateSettings() {
|
|
510
|
+
return { id: 11 };
|
|
511
|
+
}
|
|
512
|
+
}),
|
|
513
|
+
{
|
|
514
|
+
events: {
|
|
515
|
+
updateSettings: [
|
|
516
|
+
{
|
|
517
|
+
type: "entity.changed",
|
|
518
|
+
source: "workspace",
|
|
519
|
+
entity: "settings",
|
|
520
|
+
operation: "updated",
|
|
521
|
+
realtime: {
|
|
522
|
+
event: "workspace.settings.changed",
|
|
523
|
+
audience: "event_scope"
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
type: "entity.changed",
|
|
528
|
+
source: "users",
|
|
529
|
+
entity: "bootstrap",
|
|
530
|
+
operation: "updated",
|
|
531
|
+
realtime: {
|
|
532
|
+
event: "users.bootstrap.changed",
|
|
533
|
+
audience: "event_scope"
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
]
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const provider = new RealtimeServiceProvider();
|
|
542
|
+
provider.register(app);
|
|
543
|
+
await provider.boot(app);
|
|
544
|
+
|
|
545
|
+
const io = app.make(REALTIME_SOCKET_IO_SERVER_TOKEN);
|
|
546
|
+
const emitted = [];
|
|
547
|
+
io.to = (room) => {
|
|
548
|
+
return {
|
|
549
|
+
emit(eventName, payload) {
|
|
550
|
+
emitted.push({
|
|
551
|
+
room,
|
|
552
|
+
eventName,
|
|
553
|
+
payload
|
|
554
|
+
});
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const service = app.make("test.workspace.settings.service");
|
|
561
|
+
await service.updateSettings(
|
|
562
|
+
{ id: 11 },
|
|
563
|
+
{
|
|
564
|
+
context: {
|
|
565
|
+
actor: {
|
|
566
|
+
id: 4
|
|
567
|
+
},
|
|
568
|
+
visibilityContext: {
|
|
569
|
+
visibility: "workspace",
|
|
570
|
+
scopeOwnerId: 11
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
await provider.shutdown(app);
|
|
576
|
+
|
|
577
|
+
assert.equal(emitted.length, 2);
|
|
578
|
+
assert.deepEqual(
|
|
579
|
+
emitted.map((entry) => entry.eventName).sort(),
|
|
580
|
+
["users.bootstrap.changed", "workspace.settings.changed"]
|
|
581
|
+
);
|
|
582
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
REALTIME_REDIS_URL_ENV_KEY,
|
|
6
|
+
createSocketIoServer,
|
|
7
|
+
closeSocketIoServer,
|
|
8
|
+
resolveRealtimeRedisUrl,
|
|
9
|
+
configureSocketIoRedisAdapter,
|
|
10
|
+
closeSocketIoRedisConnections
|
|
11
|
+
} from "../src/server/runtime.js";
|
|
12
|
+
|
|
13
|
+
test("createSocketIoServer uses provided http server and fixed socket path", () => {
|
|
14
|
+
const httpServer = {
|
|
15
|
+
id: "http-server"
|
|
16
|
+
};
|
|
17
|
+
const calls = [];
|
|
18
|
+
class FakeServer {
|
|
19
|
+
constructor(server, options) {
|
|
20
|
+
calls.push({
|
|
21
|
+
server,
|
|
22
|
+
options
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
createSocketIoServer({
|
|
28
|
+
httpServer,
|
|
29
|
+
options: {
|
|
30
|
+
path: "ws",
|
|
31
|
+
cors: {
|
|
32
|
+
origin: "*"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
ServerCtor: FakeServer
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.equal(calls.length, 1);
|
|
39
|
+
assert.equal(calls[0].server, httpServer);
|
|
40
|
+
assert.deepEqual(calls[0].options, {
|
|
41
|
+
path: "/socket.io",
|
|
42
|
+
cors: {
|
|
43
|
+
origin: "*"
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("createSocketIoServer falls back to fastify.server", () => {
|
|
49
|
+
const fastifyServer = {
|
|
50
|
+
id: "fastify-server"
|
|
51
|
+
};
|
|
52
|
+
const calls = [];
|
|
53
|
+
class FakeServer {
|
|
54
|
+
constructor(server) {
|
|
55
|
+
calls.push(server);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
createSocketIoServer({
|
|
60
|
+
fastify: {
|
|
61
|
+
server: fastifyServer
|
|
62
|
+
},
|
|
63
|
+
ServerCtor: FakeServer
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(calls.length, 1);
|
|
67
|
+
assert.equal(calls[0], fastifyServer);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("createSocketIoServer throws when no server target is provided", () => {
|
|
71
|
+
assert.throws(
|
|
72
|
+
() => createSocketIoServer({ ServerCtor: class {} }),
|
|
73
|
+
/requires httpServer or fastify\.server/
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("closeSocketIoServer resolves when io closes", async () => {
|
|
78
|
+
let closed = false;
|
|
79
|
+
const io = {
|
|
80
|
+
close(done) {
|
|
81
|
+
closed = true;
|
|
82
|
+
done();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
await closeSocketIoServer(io);
|
|
87
|
+
assert.equal(closed, true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("closeSocketIoServer is a no-op without close method", async () => {
|
|
91
|
+
await closeSocketIoServer(null);
|
|
92
|
+
await closeSocketIoServer({});
|
|
93
|
+
assert.equal(true, true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("closeSocketIoServer swallows ERR_SERVER_NOT_RUNNING", async () => {
|
|
97
|
+
const io = {
|
|
98
|
+
close(done) {
|
|
99
|
+
const error = new Error("Server is not running.");
|
|
100
|
+
error.code = "ERR_SERVER_NOT_RUNNING";
|
|
101
|
+
done(error);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await closeSocketIoServer(io);
|
|
106
|
+
assert.equal(true, true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("resolveRealtimeRedisUrl reads and normalizes env URL", () => {
|
|
110
|
+
assert.equal(resolveRealtimeRedisUrl({}), "");
|
|
111
|
+
assert.equal(resolveRealtimeRedisUrl({ [REALTIME_REDIS_URL_ENV_KEY]: " redis://localhost:6379 " }), "redis://localhost:6379");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("configureSocketIoRedisAdapter keeps memory mode when URL is empty", async () => {
|
|
115
|
+
let adapterCalls = 0;
|
|
116
|
+
const io = {
|
|
117
|
+
adapter() {
|
|
118
|
+
adapterCalls += 1;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = await configureSocketIoRedisAdapter(io, {
|
|
123
|
+
redisUrl: ""
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(result.enabled, false);
|
|
127
|
+
assert.equal(adapterCalls, 0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("closeSocketIoRedisConnections closes both clients when present", async () => {
|
|
131
|
+
const calls = [];
|
|
132
|
+
const pubClient = {
|
|
133
|
+
async quit() {
|
|
134
|
+
calls.push("pub.quit");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const subClient = {
|
|
138
|
+
async quit() {
|
|
139
|
+
calls.push("sub.quit");
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await closeSocketIoRedisConnections({
|
|
144
|
+
pubClient,
|
|
145
|
+
subClient
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
assert.deepEqual(calls, ["sub.quit", "pub.quit"]);
|
|
149
|
+
});
|