@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.
@@ -0,0 +1,98 @@
1
+ import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
2
+ import { createProviderLogger } from "@jskit-ai/kernel/shared/support/providerLogger";
3
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { createRealtimeDelivery } from "./realtimeDelivery.js";
5
+ import { registerSocketAudienceBootstrap } from "./realtimeAudience.js";
6
+ import {
7
+ closeSocketIoRedisConnections,
8
+ closeSocketIoServer,
9
+ configureSocketIoRedisAdapter,
10
+ createSocketIoServer,
11
+ resolveRealtimeRedisNamespace,
12
+ resolveRealtimeRedisUrl
13
+ } from "./runtime.js";
14
+
15
+ const stateByCapability = new WeakMap();
16
+
17
+ function parseDebugFlag(value, fallback = null) {
18
+ if (typeof value === "boolean") return value;
19
+ const normalized = normalizeText(value).toLowerCase();
20
+ if (!normalized) return fallback;
21
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
22
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
23
+ return fallback;
24
+ }
25
+
26
+ function debugEnabled(config, env) {
27
+ return parseDebugFlag(env?.JSKIT_REALTIME_DEBUG, parseDebugFlag(config?.realtime?.debug, false));
28
+ }
29
+
30
+ function createRealtimeCapability({ io }) {
31
+ const capability = Object.freeze({
32
+ diagnostics() {
33
+ const state = stateByCapability.get(capability);
34
+ return Object.freeze({
35
+ connectedClients: Number.isInteger(Number(io?.engine?.clientsCount))
36
+ ? Number(io.engine.clientsCount)
37
+ : null,
38
+ redisConfigured: state?.redisConnection?.enabled === true
39
+ });
40
+ }
41
+ });
42
+ return capability;
43
+ }
44
+
45
+ const RealtimeProvider = defineProvider({
46
+ id: "runtime.realtime",
47
+ requires: {
48
+ config: "runtime.config",
49
+ env: "runtime.env",
50
+ events: "runtime.events",
51
+ fastify: "runtime.fastify",
52
+ logger: "runtime.logger"
53
+ },
54
+ optional: {
55
+ authService: "auth.service",
56
+ database: "runtime.database",
57
+ workspaces: "workspaces.core"
58
+ },
59
+ provides: {
60
+ realtime: "runtime.realtime"
61
+ },
62
+ setup({ config, database, env, events, fastify, logger }) {
63
+ const io = createSocketIoServer({ fastify });
64
+ const providerLogger = createProviderLogger(logger, { debugEnabled: debugEnabled(config, env) });
65
+ const delivery = createRealtimeDelivery({ io, database, logger: providerLogger });
66
+ const realtime = createRealtimeCapability({ io });
67
+ stateByCapability.set(realtime, { io, providerLogger, redisConnection: null });
68
+ events.register({
69
+ id: "runtime.realtime.delivery",
70
+ matches: (event) => Boolean(normalizeText(event?.realtime?.event)),
71
+ handle: delivery.handle
72
+ });
73
+ return { realtime };
74
+ },
75
+ async boot({ authService, env, workspaces }, { outputs }) {
76
+ const state = stateByCapability.get(outputs.realtime);
77
+ if (!state) throw new Error("Realtime runtime state is unavailable.");
78
+ registerSocketAudienceBootstrap({
79
+ io: state.io,
80
+ logger: state.providerLogger,
81
+ authService,
82
+ workspaces
83
+ });
84
+ state.redisConnection = await configureSocketIoRedisAdapter(state.io, {
85
+ redisUrl: resolveRealtimeRedisUrl(env),
86
+ redisNamespace: resolveRealtimeRedisNamespace(env)
87
+ });
88
+ },
89
+ async shutdown(_dependencies, { outputs }) {
90
+ const state = stateByCapability.get(outputs.realtime);
91
+ if (!state) return;
92
+ await closeSocketIoServer(state.io);
93
+ await closeSocketIoRedisConnections(state.redisConnection || {});
94
+ stateByCapability.delete(outputs.realtime);
95
+ }
96
+ });
97
+
98
+ export { RealtimeProvider };
@@ -0,0 +1,220 @@
1
+ import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+
3
+ const ALL_CLIENTS_ROOM = "clients";
4
+ const ALL_USERS_ROOM = "users";
5
+
6
+ function normalizeArray(value) {
7
+ const queue = Array.isArray(value) ? [...value] : [value];
8
+ const list = [];
9
+ while (queue.length > 0) {
10
+ const entry = queue.shift();
11
+ if (Array.isArray(entry)) queue.push(...entry);
12
+ else if (entry != null) list.push(entry);
13
+ }
14
+ return list;
15
+ }
16
+
17
+ function roomForUser(userId) {
18
+ return `user:${String(userId || "").trim()}`;
19
+ }
20
+
21
+ function roomForWorkspace(workspaceId) {
22
+ return `workspace:${String(workspaceId || "").trim()}`;
23
+ }
24
+
25
+ function roomForWorkspaceUser(workspaceId, userId) {
26
+ return `workspace:${String(workspaceId || "").trim()}:user:${String(userId || "").trim()}`;
27
+ }
28
+
29
+ function resolveScopeWorkspaceId(scope = {}) {
30
+ const kind = normalizeText(scope?.kind).toLowerCase();
31
+ if (kind === "workspace") return normalizeRecordId(scope.id, { fallback: null });
32
+ if (kind === "workspace_user") {
33
+ return normalizeRecordId(scope.workspaceId || scope.id, { fallback: null });
34
+ }
35
+ return normalizeRecordId(scope?.workspaceId, { fallback: null });
36
+ }
37
+
38
+ function resolveScopeUserId(scope = {}) {
39
+ const kind = normalizeText(scope?.kind).toLowerCase();
40
+ if (kind === "user") return normalizeRecordId(scope.id, { fallback: null });
41
+ return normalizeRecordId(scope?.userId, { fallback: null });
42
+ }
43
+
44
+ function applyAudiencePreset(preset, { event, rooms, flags, logger }) {
45
+ const normalized = normalizeText(preset).toLowerCase();
46
+ if (!normalized || normalized === "none") return;
47
+ if (normalized === "all_clients") {
48
+ flags.broadcastAllClients = true;
49
+ return;
50
+ }
51
+ if (normalized === "all_users") {
52
+ rooms.add(ALL_USERS_ROOM);
53
+ return;
54
+ }
55
+ if (normalized === "actor_user") {
56
+ const actorId = normalizeRecordId(event?.actorId, { fallback: null });
57
+ if (actorId) rooms.add(roomForUser(actorId));
58
+ return;
59
+ }
60
+ if (normalized === "all_workspace_users") {
61
+ const workspaceId = resolveScopeWorkspaceId(event?.scope);
62
+ if (workspaceId) rooms.add(roomForWorkspace(workspaceId));
63
+ else logger.warn({ audience: normalized, scope: event?.scope || null }, "Realtime audience requires a workspace scope.");
64
+ return;
65
+ }
66
+ if (normalized === "event_scope") {
67
+ const scopeKind = normalizeText(event?.scope?.kind).toLowerCase();
68
+ const workspaceId = resolveScopeWorkspaceId(event?.scope);
69
+ const userId = resolveScopeUserId(event?.scope);
70
+ if (scopeKind === "workspace" && workspaceId) rooms.add(roomForWorkspace(workspaceId));
71
+ else if (scopeKind === "workspace_user" && workspaceId && userId) {
72
+ rooms.add(roomForWorkspaceUser(workspaceId, userId));
73
+ } else if (scopeKind === "user" && userId) rooms.add(roomForUser(userId));
74
+ else rooms.add(ALL_USERS_ROOM);
75
+ return;
76
+ }
77
+ logger.warn({ audience: normalized }, "Realtime ignored an unknown audience preset.");
78
+ }
79
+
80
+ function addAudienceRooms(selection, state) {
81
+ if (!selection || typeof selection !== "object" || Array.isArray(selection)) return;
82
+ const { event, rooms, flags, logger } = state;
83
+ if (Object.hasOwn(selection, "preset")) {
84
+ applyAudiencePreset(selection.preset, state);
85
+ }
86
+ if (selection.broadcast === true) flags.broadcastAllClients = true;
87
+
88
+ const directRoom = normalizeText(selection.room);
89
+ if (directRoom) rooms.add(directRoom);
90
+ for (const entry of normalizeArray(selection.rooms)) {
91
+ const room = normalizeText(entry);
92
+ if (room) rooms.add(room);
93
+ }
94
+
95
+ const directUserId = normalizeRecordId(selection.userId, { fallback: null });
96
+ if (directUserId) rooms.add(roomForUser(directUserId));
97
+ for (const entry of normalizeArray(selection.userIds)) {
98
+ const userId = normalizeRecordId(entry, { fallback: null });
99
+ if (userId) rooms.add(roomForUser(userId));
100
+ }
101
+
102
+ const directWorkspaceId = normalizeRecordId(selection.workspaceId, { fallback: null });
103
+ if (directWorkspaceId) rooms.add(roomForWorkspace(directWorkspaceId));
104
+ for (const entry of normalizeArray(selection.workspaceIds)) {
105
+ const workspaceId = normalizeRecordId(entry, { fallback: null });
106
+ if (workspaceId) rooms.add(roomForWorkspace(workspaceId));
107
+ }
108
+
109
+ for (const entry of normalizeArray([selection.workspaceUser, selection.workspaceUsers])) {
110
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
111
+ const workspaceId = normalizeRecordId(entry.workspaceId, { fallback: null });
112
+ const userId = normalizeRecordId(entry.userId, { fallback: null });
113
+ if (workspaceId && userId) rooms.add(roomForWorkspaceUser(workspaceId, userId));
114
+ }
115
+
116
+ if (!event || !logger) throw new TypeError("Realtime audience state is incomplete.");
117
+ }
118
+
119
+ function collectUserIds(rows) {
120
+ const ids = new Set();
121
+ for (const row of normalizeArray(rows)) {
122
+ const rawId = typeof row === "number" ? row : row?.userId || row?.user_id || row?.id;
123
+ const id = normalizeRecordId(rawId, { fallback: null });
124
+ if (id) ids.add(id);
125
+ }
126
+ return [...ids];
127
+ }
128
+
129
+ async function resolveAudienceTargets(audience, event, { database = null, logger }) {
130
+ const rooms = new Set();
131
+ const flags = { broadcastAllClients: false };
132
+ const state = { event, rooms, flags, logger };
133
+
134
+ for (const entry of normalizeArray(audience)) {
135
+ if (typeof entry === "string") {
136
+ applyAudiencePreset(entry, state);
137
+ continue;
138
+ }
139
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
140
+ addAudienceRooms(entry, state);
141
+ if (typeof entry.userQuery === "function") {
142
+ if (typeof database?.knex !== "function") {
143
+ logger.warn("Realtime audience userQuery requires runtime.database.");
144
+ continue;
145
+ }
146
+ const rows = await entry.userQuery({ knex: database.knex, event });
147
+ for (const userId of collectUserIds(rows)) rooms.add(roomForUser(userId));
148
+ }
149
+ }
150
+
151
+ return Object.freeze({
152
+ broadcastAllClients: flags.broadcastAllClients,
153
+ rooms: Object.freeze([...rooms])
154
+ });
155
+ }
156
+
157
+ function parseCookieHeader(value = "") {
158
+ const cookies = {};
159
+ for (const entry of String(value || "").split(";")) {
160
+ const separator = entry.indexOf("=");
161
+ if (separator < 1) continue;
162
+ const key = entry.slice(0, separator).trim();
163
+ if (!key) continue;
164
+ const rawValue = entry.slice(separator + 1).trim();
165
+ try {
166
+ cookies[key] = decodeURIComponent(rawValue);
167
+ } catch {
168
+ cookies[key] = rawValue;
169
+ }
170
+ }
171
+ return cookies;
172
+ }
173
+
174
+ async function resolveSocketActorId(authService, socket) {
175
+ if (typeof authService?.authenticateRequest !== "function") return null;
176
+ const handshakeHeaders = socket?.handshake?.headers || {};
177
+ const requestHeaders = socket?.request?.headers || {};
178
+ const host = normalizeText(handshakeHeaders.host || requestHeaders.host);
179
+ const remoteAddress = normalizeText(
180
+ socket?.request?.socket?.remoteAddress || socket?.conn?.remoteAddress || socket?.handshake?.address
181
+ );
182
+ const request = {
183
+ cookies: parseCookieHeader(handshakeHeaders.cookie || requestHeaders.cookie)
184
+ };
185
+ if (host) request.headers = { host };
186
+ if (remoteAddress) request.socket = { remoteAddress };
187
+ const result = await authService.authenticateRequest(request);
188
+ return result?.authenticated === true
189
+ ? normalizeRecordId(result?.actor?.id, { fallback: null })
190
+ : null;
191
+ }
192
+
193
+ function registerSocketAudienceBootstrap({ io, logger, authService = null, workspaces = null }) {
194
+ if (typeof io?.on !== "function") return;
195
+ io.on("connection", async (socket) => {
196
+ try {
197
+ socket.join(ALL_CLIENTS_ROOM);
198
+ const actorId = await resolveSocketActorId(authService, socket);
199
+ if (!actorId) return;
200
+ socket.data = socket.data && typeof socket.data === "object" ? socket.data : {};
201
+ socket.data.actorId = actorId;
202
+ socket.join(ALL_USERS_ROOM);
203
+ socket.join(roomForUser(actorId));
204
+ const repository = workspaces?.repositories?.workspaceMemberships;
205
+ const workspaceIds = typeof repository?.listActiveWorkspaceIdsByUserId === "function"
206
+ ? await repository.listActiveWorkspaceIdsByUserId(actorId)
207
+ : [];
208
+ for (const rawId of normalizeArray(workspaceIds)) {
209
+ const workspaceId = normalizeRecordId(rawId, { fallback: null });
210
+ if (!workspaceId) continue;
211
+ socket.join(roomForWorkspace(workspaceId));
212
+ socket.join(roomForWorkspaceUser(workspaceId, actorId));
213
+ }
214
+ } catch (error) {
215
+ logger.warn({ error: String(error?.message || error) }, "Realtime socket audience bootstrap failed.");
216
+ }
217
+ });
218
+ }
219
+
220
+ export { registerSocketAudienceBootstrap, resolveAudienceTargets };
@@ -0,0 +1,41 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { resolveAudienceTargets } from "./realtimeAudience.js";
3
+
4
+ function publicRealtimePayload(event) {
5
+ const { realtime, ...canonical } = event && typeof event === "object" ? event : {};
6
+ const payload = realtime?.payload;
7
+ if (payload != null && (!payload || typeof payload !== "object" || Array.isArray(payload))) {
8
+ throw new TypeError("Realtime event payload must be an object when provided.");
9
+ }
10
+ return Object.freeze({ ...(payload || {}), ...canonical });
11
+ }
12
+
13
+ function createRealtimeDelivery({ io, database = null, logger }) {
14
+ if (!io || typeof io.emit !== "function" || typeof io.to !== "function") {
15
+ throw new TypeError("Realtime delivery requires a Socket.IO server.");
16
+ }
17
+ if (!logger || typeof logger.warn !== "function") {
18
+ throw new TypeError("Realtime delivery requires a logger.");
19
+ }
20
+
21
+ return Object.freeze({
22
+ async handle(event = {}) {
23
+ const eventName = normalizeText(event?.realtime?.event);
24
+ if (!eventName) return;
25
+ const targets = await resolveAudienceTargets(event.realtime.audience, event, { database, logger });
26
+ const payload = publicRealtimePayload(event);
27
+ if (targets.broadcastAllClients) io.emit(eventName, payload);
28
+ for (const room of targets.rooms) io.to(room).emit(eventName, payload);
29
+ logger.debug({
30
+ socketEvent: eventName,
31
+ rooms: targets.rooms,
32
+ broadcastAllClients: targets.broadcastAllClients,
33
+ connectedClients: Number.isInteger(Number(io?.engine?.clientsCount))
34
+ ? Number(io.engine.clientsCount)
35
+ : null
36
+ }, "Realtime delivered an action event.");
37
+ }
38
+ });
39
+ }
40
+
41
+ export { createRealtimeDelivery };
@@ -1,20 +1,20 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
- import * as serverApi from "../src/server/RealtimeServiceProvider.js";
4
+ import * as serverApi from "../src/server/RealtimeProvider.js";
5
5
  import * as serverRuntimeApi from "../src/server/runtime.js";
6
6
  import * as clientApi from "../src/client/RealtimeClientProvider.js";
7
7
  import * as clientRuntimeApi from "../src/client/runtime.js";
8
- import * as clientListenerApi from "../src/client/listeners.js";
9
8
 
10
9
  test("server entrypoint exports provider only", () => {
11
- assert.equal(typeof serverApi.RealtimeServiceProvider, "function");
12
- assert.deepEqual(Object.keys(serverApi).sort(), ["RealtimeServiceProvider"]);
10
+ assert.equal(typeof serverApi.RealtimeProvider, "object");
11
+ assert.deepEqual(Object.keys(serverApi).sort(), ["RealtimeProvider"]);
13
12
  });
14
13
 
15
14
  test("client entrypoint exports provider only", () => {
16
- assert.equal(typeof clientApi.RealtimeClientProvider, "function");
17
- assert.deepEqual(Object.keys(clientApi).sort(), ["RealtimeClientProvider"]);
15
+ assert.equal(typeof clientApi.RealtimeClientProvider, "object");
16
+ assert.equal(typeof clientApi.resolveRealtimeClientConfig, "function");
17
+ assert.deepEqual(Object.keys(clientApi).sort(), ["RealtimeClientProvider", "resolveRealtimeClientConfig"]);
18
18
  });
19
19
 
20
20
  test("server runtime entrypoint exports server-only helpers", () => {
@@ -26,9 +26,3 @@ test("client runtime entrypoint exports client-only helpers", () => {
26
26
  assert.equal(typeof clientRuntimeApi.createSocketIoClient, "function");
27
27
  assert.equal(typeof clientRuntimeApi.disconnectSocketIoClient, "function");
28
28
  });
29
-
30
- test("client listeners entrypoint exports realtime listener registration helpers", () => {
31
- assert.equal(typeof clientListenerApi.registerRealtimeClientListener, "function");
32
- assert.equal(typeof clientListenerApi.resolveRealtimeClientListeners, "function");
33
- assert.equal(typeof clientListenerApi.normalizeRealtimeClientListener, "function");
34
- });