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