@jskit-ai/realtime 0.1.177 → 0.1.178

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/realtime",
3
- "version": "0.1.177",
3
+ "version": "0.1.178",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -20,8 +20,8 @@
20
20
  "socket.io-client": "^4.8.3"
21
21
  },
22
22
  "peerDependencies": {
23
- "@jskit-ai/kernel": "0.1.180",
24
- "@jskit-ai/shell-web": "0.1.184",
23
+ "@jskit-ai/kernel": "0.1.181",
24
+ "@jskit-ai/shell-web": "0.1.185",
25
25
  "vue": "^3.5.13"
26
26
  },
27
27
  "description": "Thin, generic realtime runtime wrappers for socket.io server and client.",
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@jskit-ai/realtime": "0.1.177",
6
- "@jskit-ai/shell-web": "0.1.184"
5
+ "@jskit-ai/realtime": "0.1.178",
6
+ "@jskit-ai/shell-web": "0.1.185"
7
7
  }
8
8
  }
@@ -63,28 +63,25 @@ const RealtimeProvider = defineProvider({
63
63
  provides: {
64
64
  realtime: "runtime.realtime"
65
65
  },
66
- setup({ authService, config, database, env, events, fastify, logger, workspaces }) {
66
+ setup({ config, env, fastify, logger }) {
67
67
  const io = createSocketIoServer({ fastify });
68
68
  const providerLogger = createProviderLogger(logger, { debugEnabled: debugEnabled(config, env) });
69
- const delivery = createRealtimeDelivery({ io, database, logger: providerLogger, authService, workspaces });
70
69
  const realtime = createRealtimeCapability({ io });
71
70
  stateByCapability.set(realtime, {
72
71
  authenticationRequired: false,
73
- delivery,
72
+ delivery: null,
74
73
  io,
75
74
  providerLogger,
76
75
  redisConnection: null
77
76
  });
78
- events.register({
79
- id: "runtime.realtime.delivery",
80
- matches: (event) => Boolean(normalizeText(event?.realtime?.event)),
81
- handle: delivery.handle
82
- });
83
77
  return { realtime };
84
78
  },
85
- async boot({ authService, env, workspaces }, { outputs }) {
79
+ async boot({ authService, database, env, events, workspaces }, { outputs }) {
86
80
  const state = stateByCapability.get(outputs.realtime);
87
81
  if (!state) throw new Error("Realtime runtime state is unavailable.");
82
+ state.delivery = createRealtimeDelivery({
83
+ io: state.io, database, logger: state.providerLogger, authService, workspaces
84
+ });
88
85
  state.authenticationRequired = realtimeAuthenticationRequired(authService);
89
86
  registerSocketAudienceBootstrap({
90
87
  io: state.io,
@@ -98,11 +95,16 @@ const RealtimeProvider = defineProvider({
98
95
  redisNamespace: resolveRealtimeRedisNamespace(env)
99
96
  });
100
97
  state.delivery.start({ redisConfigured: state.redisConnection.enabled });
98
+ events.register({
99
+ id: "runtime.realtime.delivery",
100
+ matches: (event) => Boolean(normalizeText(event?.realtime?.event)),
101
+ handle: state.delivery.handle
102
+ });
101
103
  },
102
104
  async shutdown(_dependencies, { outputs }) {
103
105
  const state = stateByCapability.get(outputs.realtime);
104
106
  if (!state) return;
105
- state.delivery.stop();
107
+ state.delivery?.stop();
106
108
  await closeSocketIoServer(state.io);
107
109
  await closeSocketIoRedisConnections(state.redisConnection || {});
108
110
  stateByCapability.delete(outputs.realtime);
@@ -26,6 +26,17 @@ function roomForWorkspaceUser(workspaceId, userId) {
26
26
  return `workspace:${String(workspaceId || "").trim()}:user:${String(userId || "").trim()}`;
27
27
  }
28
28
 
29
+ function workspaceAudienceRooms(workspaceIds, actorId) {
30
+ const rooms = [];
31
+ for (const rawId of normalizeArray(workspaceIds)) {
32
+ const workspaceId = normalizeRecordId(rawId, { fallback: null });
33
+ if (workspaceId) {
34
+ rooms.push(roomForWorkspace(workspaceId), roomForWorkspaceUser(workspaceId, actorId));
35
+ }
36
+ }
37
+ return rooms;
38
+ }
39
+
29
40
  function resolveScopeWorkspaceId(scope = {}) {
30
41
  const kind = normalizeText(scope?.kind).toLowerCase();
31
42
  if (kind === "workspace") return normalizeRecordId(scope.id, { fallback: null });
@@ -242,11 +253,8 @@ function registerSocketAudienceBootstrap({ io, logger, authService = null, works
242
253
  const workspaceIds = typeof repository?.listActiveWorkspaceIdsByUserId === "function"
243
254
  ? await repository.listActiveWorkspaceIdsByUserId(actorId)
244
255
  : [];
245
- for (const rawId of normalizeArray(workspaceIds)) {
246
- const workspaceId = normalizeRecordId(rawId, { fallback: null });
247
- if (!workspaceId) continue;
248
- socket.join(roomForWorkspace(workspaceId));
249
- socket.join(roomForWorkspaceUser(workspaceId, actorId));
256
+ for (const room of workspaceAudienceRooms(workspaceIds, actorId)) {
257
+ socket.join(room);
250
258
  }
251
259
  } catch (error) {
252
260
  logger.warn({ error: String(error?.message || error) }, "Realtime socket audience bootstrap failed.");
@@ -262,21 +270,18 @@ async function revalidateSocket({ socket, authService, workspaces = null }) {
262
270
  socket.disconnect(true);
263
271
  return null;
264
272
  }
265
- if (actor) {
266
- rememberSocketActorId(socket, actor.id);
267
- const repository = workspaces?.repositories?.workspaceMemberships;
268
- if (typeof repository?.listActiveWorkspaceIdsByUserId === "function") {
269
- const workspaceIds = await repository.listActiveWorkspaceIdsByUserId(actor.id);
270
- const rooms = new Set(normalizeArray(workspaceIds).flatMap((id) => {
271
- const workspaceId = normalizeRecordId(id, { fallback: null });
272
- return workspaceId ? [roomForWorkspace(workspaceId), roomForWorkspaceUser(workspaceId, actor.id)] : [];
273
- }));
274
- for (const room of socket.rooms) {
275
- if (room.startsWith("workspace:") && !rooms.has(room)) await socket.leave(room);
276
- }
277
- for (const room of rooms) await socket.join(room);
278
- }
273
+ if (!actor) return null;
274
+
275
+ rememberSocketActorId(socket, actor.id);
276
+ const repository = workspaces?.repositories?.workspaceMemberships;
277
+ if (typeof repository?.listActiveWorkspaceIdsByUserId !== "function") return actor;
278
+
279
+ const workspaceIds = await repository.listActiveWorkspaceIdsByUserId(actor.id);
280
+ const rooms = new Set(workspaceAudienceRooms(workspaceIds, actor.id));
281
+ for (const room of socket.rooms) {
282
+ if (room.startsWith("workspace:") && !rooms.has(room)) await socket.leave(room);
279
283
  }
284
+ for (const room of rooms) await socket.join(room);
280
285
  return actor;
281
286
  }
282
287
 
@@ -6,11 +6,14 @@ const DELIVERY_EVENT = "jskit:realtime:delivery";
6
6
  function publicRealtimePayload(event) {
7
7
  const { realtime } = event && typeof event === "object" ? event : {};
8
8
  const canonical = {};
9
- for (const key of ["type", "source", "entity", "operation", "entityId", "scope", "actorId", "commandId", "sourceClientId", "occurredAt"]) {
9
+ for (const key of [
10
+ "type", "source", "entity", "operation", "entityId", "scope", "actorId",
11
+ "commandId", "sourceClientId", "occurredAt"
12
+ ]) {
10
13
  if (Object.hasOwn(event, key)) canonical[key] = event[key];
11
14
  }
12
15
  const payload = realtime?.payload;
13
- if (payload != null && (!payload || typeof payload !== "object" || Array.isArray(payload))) {
16
+ if (payload != null && (typeof payload !== "object" || Array.isArray(payload))) {
14
17
  throw new TypeError("Realtime event payload must be an object when provided.");
15
18
  }
16
19
  return Object.freeze({ ...(payload || {}), ...canonical });
@@ -35,7 +38,7 @@ function createRealtimeDelivery({ io, database = null, logger, authService = nul
35
38
  let revalidationTimer = null;
36
39
  let stopped = false;
37
40
 
38
- async function authenticate(socket) {
41
+ async function revalidate(socket) {
39
42
  try {
40
43
  return await revalidateSocket({ socket, authService, workspaces });
41
44
  } catch (error) {
@@ -48,11 +51,16 @@ function createRealtimeDelivery({ io, database = null, logger, authService = nul
48
51
  async function deliverLocal({ eventName, payload, targets }) {
49
52
  await Promise.all([...io.sockets.sockets.values()].map(async (socket) => {
50
53
  try {
51
- const actor = await authenticate(socket);
54
+ const actor = await revalidate(socket);
52
55
  if (!socket.connected) return;
53
56
  if (!targets.broadcastAllClients && !targets.rooms.some((room) => socket.rooms.has(room))) return;
54
- if (typeof authService.realtime?.authorizeEvent === "function" &&
55
- await authService.realtime.authorizeEvent({ actor, event: { name: eventName, payload } }) !== true) return;
57
+ if (typeof authService.realtime?.authorizeEvent === "function") {
58
+ const authorized = await authService.realtime.authorizeEvent({
59
+ actor,
60
+ event: { name: eventName, payload }
61
+ });
62
+ if (authorized !== true) return;
63
+ }
56
64
  if (socket.connected) socket.emit(eventName, payload);
57
65
  } catch (error) {
58
66
  logger.warn({ socketEvent: eventName, error: String(error?.message || error) }, "Realtime delivery denied after authorization failure.");
@@ -71,7 +79,7 @@ function createRealtimeDelivery({ io, database = null, logger, authService = nul
71
79
  function scheduleRevalidation() {
72
80
  if (stopped) return;
73
81
  revalidationTimer = setTimeout(async () => {
74
- await Promise.all([...io.sockets.sockets.values()].map(authenticate));
82
+ await Promise.all([...io.sockets.sockets.values()].map(revalidate));
75
83
  scheduleRevalidation();
76
84
  }, 30_000);
77
85
  revalidationTimer.unref();
@@ -8,9 +8,37 @@ import { RealtimeClientProvider } from "../src/client/RealtimeClientProvider.js"
8
8
  import { RealtimeProvider } from "../src/server/RealtimeProvider.js";
9
9
  import { registerSocketAudienceBootstrap } from "../src/server/realtimeAudience.js";
10
10
  import { createRealtimeDelivery } from "../src/server/realtimeDelivery.js";
11
+ import { attachDeferredAuthService, createDeferredAuthService } from "../../auth-core/src/server/deferredAuthService.js";
11
12
 
12
13
  const logger = Object.freeze({ debug() {}, info() {}, warn() {}, error() {} });
13
14
 
15
+ test("RealtimeProvider waits for authentication boot before accessing the auth service", async () => {
16
+ const authService = createDeferredAuthService();
17
+ const authProvider = defineProvider({
18
+ id: "test.deferred.auth",
19
+ provides: { authService: "auth.service" },
20
+ setup() { return { authService }; },
21
+ boot() {
22
+ attachDeferredAuthService(authService, {
23
+ realtime: { requireAuthentication: true },
24
+ async authenticateRequest() { return { authenticated: false, actor: null }; }
25
+ });
26
+ }
27
+ });
28
+ const runtime = createCapabilityRuntime({
29
+ inputs: {
30
+ "runtime.config": {}, "runtime.env": {},
31
+ "runtime.fastify": { server: createServer() }, "runtime.logger": logger
32
+ },
33
+ providers: [EventProvider, RealtimeProvider, authProvider]
34
+ });
35
+ try {
36
+ await runtime.start();
37
+ } finally {
38
+ await runtime.shutdown();
39
+ }
40
+ });
41
+
14
42
  function createIoDouble() {
15
43
  const emitted = [];
16
44
  return {