@jskit-ai/realtime 0.1.218 → 0.1.220

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.218",
3
+ "version": "0.1.220",
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.221",
24
- "@jskit-ai/shell-web": "0.1.225",
23
+ "@jskit-ai/kernel": "0.1.223",
24
+ "@jskit-ai/shell-web": "0.1.227",
25
25
  "vue": "^3.5.13"
26
26
  },
27
27
  "description": "Thin, generic realtime runtime wrappers for socket.io server and client.",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  {
81
81
  "subpath": "./client/composables/*",
82
- "summary": "Exports component-level realtime socket composables."
82
+ "summary": "Exports realtime socket composables with subscriptions owned by their Vue effect scope."
83
83
  }
84
84
  ],
85
85
  "capabilities": {
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@jskit-ai/realtime": "0.1.218",
6
- "@jskit-ai/shell-web": "0.1.225"
5
+ "@jskit-ai/realtime": "0.1.220",
6
+ "@jskit-ai/shell-web": "0.1.227"
7
7
  }
8
8
  }
@@ -1,4 +1,4 @@
1
- import { inject, onBeforeUnmount, ref, unref, watch } from "vue";
1
+ import { inject, onScopeDispose, ref, unref, watch } from "vue";
2
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
 
4
4
  const EMPTY_REALTIME_SOCKET = Object.freeze({
@@ -130,9 +130,7 @@ function useRealtimeEvent({
130
130
  { immediate: true }
131
131
  );
132
132
 
133
- onBeforeUnmount(() => {
134
- detach();
135
- });
133
+ onScopeDispose(detach);
136
134
 
137
135
  return Object.freeze({
138
136
  active
@@ -3,8 +3,10 @@ import { createProviderLogger } from "@jskit-ai/kernel/shared/support/providerLo
3
3
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import { createRealtimeDelivery } from "./realtimeDelivery.js";
5
5
  import {
6
+ createSocketRequest,
6
7
  realtimeAuthenticationRequired,
7
- registerSocketAudienceBootstrap
8
+ registerSocketAudienceBootstrap,
9
+ revalidateSocket
8
10
  } from "./realtimeAudience.js";
9
11
  import {
10
12
  closeSocketIoRedisConnections,
@@ -32,6 +34,64 @@ function debugEnabled(config, env) {
32
34
 
33
35
  function createRealtimeCapability({ io }) {
34
36
  const capability = Object.freeze({
37
+ onConnection(listener) {
38
+ if (typeof listener !== "function") throw new TypeError("Realtime connection listener must be a function.");
39
+ const state = stateByCapability.get(capability);
40
+ if (!state) throw new Error("Realtime runtime state is unavailable.");
41
+ const releases = new Map();
42
+ function attach(socket) {
43
+ if (releases.has(socket)) return;
44
+ let detach;
45
+ async function readRequest() {
46
+ const request = createSocketRequest(socket);
47
+ const actor = await revalidateSocket({
48
+ socket, request, authService: state.authService, workspaces: state.workspaces
49
+ });
50
+ if (!socket.connected || (!actor && realtimeAuthenticationRequired(state.authService))) {
51
+ throw Object.assign(new Error("Authentication required."), { statusCode: 401 });
52
+ }
53
+ if (actor) request.user = actor;
54
+ return request;
55
+ }
56
+ function release() {
57
+ socket.off("disconnect", release);
58
+ releases.delete(socket);
59
+ try { detach?.(); }
60
+ catch (error) {
61
+ state.providerLogger.warn({ error: String(error?.message || error) }, "Realtime connection cleanup failed.");
62
+ }
63
+ }
64
+ try {
65
+ detach = listener({
66
+ socket,
67
+ // Hosts authorize this real connection request through their own
68
+ // action boundary. Reading it alone does not grant local access.
69
+ readRequest,
70
+ async authenticate() {
71
+ const request = await readRequest();
72
+ if (!request.user) throw Object.assign(new Error("Authentication required."), { statusCode: 401 });
73
+ return request;
74
+ }
75
+ });
76
+ if (detach != null && typeof detach !== "function") {
77
+ throw new TypeError("Realtime connection listeners must return a cleanup function or undefined.");
78
+ }
79
+ releases.set(socket, release);
80
+ socket.on("disconnect", release);
81
+ } catch (error) {
82
+ state.providerLogger.warn({ error: String(error?.message || error) }, "Realtime connection listener failed.");
83
+ }
84
+ }
85
+ function stop() {
86
+ io.off("connection", attach);
87
+ for (const release of [...releases.values()]) release();
88
+ state.connectionListeners.delete(stop);
89
+ }
90
+ state.connectionListeners.add(stop);
91
+ io.on("connection", attach);
92
+ for (const socket of io.sockets.sockets.values()) attach(socket);
93
+ return stop;
94
+ },
35
95
  diagnostics() {
36
96
  const state = stateByCapability.get(capability);
37
97
  return Object.freeze({
@@ -69,6 +129,9 @@ const RealtimeProvider = defineProvider({
69
129
  const realtime = createRealtimeCapability({ io });
70
130
  stateByCapability.set(realtime, {
71
131
  authenticationRequired: false,
132
+ authService: null,
133
+ workspaces: null,
134
+ connectionListeners: new Set(),
72
135
  delivery: null,
73
136
  io,
74
137
  providerLogger,
@@ -79,6 +142,8 @@ const RealtimeProvider = defineProvider({
79
142
  async boot({ authService, database, env, events, workspaces }, { outputs }) {
80
143
  const state = stateByCapability.get(outputs.realtime);
81
144
  if (!state) throw new Error("Realtime runtime state is unavailable.");
145
+ state.authService = authService;
146
+ state.workspaces = workspaces;
82
147
  state.delivery = createRealtimeDelivery({
83
148
  io: state.io, database, logger: state.providerLogger, authService, workspaces
84
149
  });
@@ -104,6 +169,7 @@ const RealtimeProvider = defineProvider({
104
169
  async shutdown(_dependencies, { outputs }) {
105
170
  const state = stateByCapability.get(outputs.realtime);
106
171
  if (!state) return;
172
+ for (const release of [...state.connectionListeners]) release();
107
173
  state.delivery?.stop();
108
174
  await closeSocketIoServer(state.io);
109
175
  await closeSocketIoRedisConnections(state.redisConnection || {});
@@ -182,8 +182,7 @@ function parseCookieHeader(value = "") {
182
182
  return cookies;
183
183
  }
184
184
 
185
- async function resolveSocketActor(authService, socket) {
186
- if (typeof authService?.authenticateRequest !== "function") return null;
185
+ function createSocketRequest(socket) {
187
186
  const handshakeHeaders = socket?.handshake?.headers || {};
188
187
  const requestHeaders = socket?.request?.headers || {};
189
188
  const host = normalizeText(handshakeHeaders.host || requestHeaders.host);
@@ -201,6 +200,11 @@ async function resolveSocketActor(authService, socket) {
201
200
  if (Object.keys(headers).length > 0) request.headers = headers;
202
201
  request.socket = { encrypted: socket?.request?.socket?.encrypted === true };
203
202
  if (remoteAddress) request.socket.remoteAddress = remoteAddress;
203
+ return request;
204
+ }
205
+
206
+ async function resolveSocketActor(authService, socket, request = createSocketRequest(socket)) {
207
+ if (typeof authService?.authenticateRequest !== "function") return null;
204
208
  const result = await authService.authenticateRequest(request);
205
209
  const id = result?.authenticated === true
206
210
  ? normalizeRecordId(result?.actor?.id, { fallback: null }) : null;
@@ -268,8 +272,8 @@ function registerSocketAudienceBootstrap({ io, logger, authService = null, works
268
272
  });
269
273
  }
270
274
 
271
- async function revalidateSocket({ socket, authService, workspaces = null }) {
272
- const actor = await resolveSocketActor(authService, socket);
275
+ async function revalidateSocket({ socket, authService, workspaces = null, request }) {
276
+ const actor = await resolveSocketActor(authService, socket, request);
273
277
  const previousActorId = socket.data?.actorId;
274
278
  if ((!actor && realtimeAuthenticationRequired(authService)) ||
275
279
  (previousActorId && previousActorId !== actor?.id)) {
@@ -292,6 +296,7 @@ async function revalidateSocket({ socket, authService, workspaces = null }) {
292
296
  }
293
297
 
294
298
  export {
299
+ createSocketRequest,
295
300
  realtimeAuthenticationRequired,
296
301
  revalidateSocket,
297
302
  registerSocketAudienceBootstrap,
@@ -2,11 +2,32 @@ import { Server as SocketIoServer } from "socket.io";
2
2
  import { createAdapter as createSocketIoRedisAdapter } from "@socket.io/redis-adapter";
3
3
  import { createClient as createRedisClient } from "redis";
4
4
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
+ import { normalizePathname } from "@jskit-ai/kernel/shared/surface/paths";
5
6
 
6
7
  const SOCKET_IO_PATH = "/socket.io";
7
8
  const REDIS_URL_ENV_KEY = "REDIS_URL";
8
9
  const REDIS_NAMESPACE_ENV_KEY = "REDIS_NAMESPACE";
9
10
 
11
+ function toRequestPathname(urlValue) {
12
+ const rawUrl = String(urlValue || "").trim() || "/";
13
+ try {
14
+ return normalizePathname(new URL(rawUrl, "http://localhost").pathname || "/");
15
+ } catch {
16
+ return normalizePathname(rawUrl.split("?")[0] || "/");
17
+ }
18
+ }
19
+
20
+ function registerSocketIoUpgradeHandoff(fastify) {
21
+ if (typeof fastify?.addHook !== "function") return;
22
+ fastify.addHook("onRequest", async (request, reply) => {
23
+ if (request?.ws !== true) return;
24
+ if (toRequestPathname(request?.raw?.url || request?.url) !== SOCKET_IO_PATH) return;
25
+ // The WebSocket plugin also routes this upgrade through Fastify. Leave it
26
+ // to Socket.IO instead of letting Fastify's missing-route response close it.
27
+ reply.hijack();
28
+ });
29
+ }
30
+
10
31
  function resolveHttpServer({ httpServer = null, fastify = null } = {}) {
11
32
  if (httpServer && typeof httpServer === "object") {
12
33
  return httpServer;
@@ -38,6 +59,7 @@ function createSocketIoServer({
38
59
  ...source,
39
60
  path: SOCKET_IO_PATH
40
61
  };
62
+ registerSocketIoUpgradeHandoff(fastify);
41
63
  return new ServerCtor(server, normalizedOptions);
42
64
  }
43
65
 
@@ -1,17 +1,104 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { createServer } from "node:http";
3
+ import { once } from "node:events";
3
4
  import test from "node:test";
4
5
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
5
6
  import { EventProvider } from "@jskit-ai/kernel/server/runtime";
6
7
  import { CLIENT_APP_CONFIG_GLOBAL_KEY, setClientAppConfig } from "../../kernel/client/appConfig.js";
7
8
  import { RealtimeClientProvider } from "../src/client/RealtimeClientProvider.js";
8
9
  import { RealtimeProvider } from "../src/server/RealtimeProvider.js";
10
+ import { createSocketIoClient } from "../src/client/runtime.js";
9
11
  import { registerSocketAudienceBootstrap } from "../src/server/realtimeAudience.js";
10
12
  import { createRealtimeDelivery } from "../src/server/realtimeDelivery.js";
11
13
  import { attachDeferredAuthService, createDeferredAuthService } from "../../auth-core/src/server/deferredAuthService.js";
12
14
 
13
15
  const logger = Object.freeze({ debug() {}, info() {}, warn() {}, error() {} });
14
16
 
17
+ async function connectionRequestFixture(t, authService = null) {
18
+ const http = createServer();
19
+ const connections = [];
20
+ const clients = [];
21
+ let realtime;
22
+ const probe = defineProvider({ id: "test.connection.request", requires: { realtime: "runtime.realtime" },
23
+ setup(dependencies) { realtime = dependencies.realtime; return {}; } });
24
+ const runtime = createCapabilityRuntime({ providers: [EventProvider, RealtimeProvider, probe], inputs: {
25
+ "runtime.config": {}, "runtime.env": {}, "runtime.fastify": { server: http }, "runtime.logger": logger,
26
+ ...(authService ? { "auth.service": authService } : {})
27
+ } });
28
+ await runtime.start();
29
+ const release = realtime.onConnection(connection => { connections.push(connection); });
30
+ t.after(async () => {
31
+ for (const client of clients) client.disconnect();
32
+ release();
33
+ await runtime.shutdown();
34
+ });
35
+ http.listen(0, "127.0.0.1");
36
+ await once(http, "listening");
37
+ const url = `http://127.0.0.1:${http.address().port}`;
38
+ return {
39
+ url,
40
+ async connect() {
41
+ const client = createSocketIoClient({ url, options: { transports: ["websocket"], autoConnect: false,
42
+ reconnection: false, extraHeaders: { Cookie: "session=fixture-session", Origin: url } } });
43
+ clients.push(client);
44
+ const connected = once(client, "connect", { signal: AbortSignal.timeout(5_000) });
45
+ client.connect();
46
+ await connected;
47
+ return { client, connection: connections.at(-1) };
48
+ }
49
+ };
50
+ }
51
+
52
+ test("connection request access preserves the local handshake without weakening authenticate", async t => {
53
+ const f = await connectionRequestFixture(t);
54
+ const { client, connection } = await f.connect();
55
+ const request = await connection.readRequest();
56
+ assert.deepEqual(request.cookies, { session: "fixture-session" });
57
+ assert.equal(request.headers.host, new URL(f.url).host);
58
+ assert.equal(request.headers.origin, f.url);
59
+ assert.equal(request.socket.remoteAddress, "127.0.0.1");
60
+ assert.equal(Object.hasOwn(request, "user"), false);
61
+ await assert.rejects(connection.authenticate(), { statusCode: 401 });
62
+ assert.equal(client.connected, true, "The host may still authorize a local request itself");
63
+ request.headers.origin = "https://forged.invalid";
64
+ request.cookies.session = "forged";
65
+ request.user = { id: "42" };
66
+ const next = await connection.readRequest();
67
+ assert.equal(next.headers.origin, f.url);
68
+ assert.equal(next.cookies.session, "fixture-session");
69
+ assert.equal(Object.hasOwn(next, "user"), false);
70
+ const disconnected = once(connection.socket, "disconnect", { signal: AbortSignal.timeout(5_000) });
71
+ client.disconnect();
72
+ await disconnected;
73
+ await assert.rejects(connection.readRequest(), { statusCode: 401 });
74
+ });
75
+
76
+ test("connection request access revalidates required hosted sessions and retires changed actors", async t => {
77
+ const sessions = new Map();
78
+ const reads = [];
79
+ const f = await connectionRequestFixture(t, {
80
+ realtime: { requireAuthentication: true },
81
+ async authenticateRequest(request) {
82
+ reads.push(request.cookies.session);
83
+ const actor = sessions.get(request.cookies.session);
84
+ return { authenticated: Boolean(actor), actor };
85
+ }
86
+ });
87
+ for (const nextActor of [null, { id: "43" }]) {
88
+ sessions.set("fixture-session", { id: "42", label: "Current actor" });
89
+ const { client, connection } = await f.connect();
90
+ assert.deepEqual((await connection.readRequest()).user, { id: "42", label: "Current actor" });
91
+ assert.equal((await connection.authenticate()).user.id, "42");
92
+ sessions.set("fixture-session", nextActor);
93
+ const disconnected = once(client, "disconnect", { signal: AbortSignal.timeout(5_000) });
94
+ await assert.rejects(connection.readRequest(), { statusCode: 401 });
95
+ await disconnected;
96
+ assert.equal(connection.socket.connected, false);
97
+ }
98
+ assert.ok(reads.length >= 8, "Every request facility call rechecks the current session");
99
+ assert.ok(reads.every(value => value === "fixture-session"));
100
+ });
101
+
15
102
  test("RealtimeProvider waits for authentication boot before accessing the auth service", async () => {
16
103
  const authService = createDeferredAuthService();
17
104
  const authProvider = defineProvider({
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import test from "node:test";
4
+ import { createRenderer, effectScope, h, nextTick, ref } from "vue";
5
+ import { useRealtimeEvent } from "../src/client/composables/useRealtimeEvent.js";
6
+
7
+ const renderer = createRenderer({
8
+ createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
9
+ setElementText() {}, setText() {}, insert() {}, remove() {}, patchProp() {},
10
+ parentNode() {}, nextSibling() {}
11
+ });
12
+
13
+ test("a component listener detaches on unmount", () => {
14
+ const socket = new EventEmitter();
15
+ const received = [];
16
+ let listener;
17
+ const app = renderer.createApp({ setup() {
18
+ listener = useRealtimeEvent({ event: "reply", onEvent: ({ payload }) => received.push(payload) });
19
+ return () => h("div");
20
+ } });
21
+ app.provide("jskit.realtime.runtime.client.socket", socket);
22
+ app.mount({});
23
+ socket.emit("reply", "first");
24
+ const wasActive = listener.active.value;
25
+ app.unmount();
26
+ assert.equal(wasActive, true);
27
+ socket.emit("reply", "late");
28
+ assert.equal(socket.listenerCount("reply"), 0);
29
+ assert.equal(listener.active.value, false);
30
+ assert.deepEqual(received, ["first"]);
31
+ });
32
+
33
+ test("a retained scope keeps its listener after screen unmount and releases it when stopped", async t => {
34
+ const socket = new EventEmitter();
35
+ const received = [];
36
+ const enabled = ref(true);
37
+ const event = ref("reply");
38
+ const retained = effectScope(true);
39
+ t.after(() => retained.stop());
40
+ const app = renderer.createApp({ setup() {
41
+ retained.run(() => useRealtimeEvent({ event, enabled, onEvent: ({ payload }) => received.push(payload) }));
42
+ return () => h("div");
43
+ } });
44
+ app.provide("jskit.realtime.runtime.client.socket", socket);
45
+ app.mount({});
46
+ app.unmount();
47
+ socket.emit("reply", "after navigation");
48
+ assert.deepEqual(received, ["after navigation"]);
49
+ enabled.value = false;
50
+ await nextTick();
51
+ assert.equal(socket.listenerCount("reply"), 0);
52
+ enabled.value = true;
53
+ event.value = "completed";
54
+ await nextTick();
55
+ assert.equal(socket.listenerCount("reply"), 0);
56
+ assert.equal(socket.listenerCount("completed"), 1);
57
+ socket.emit("completed", "saved answer");
58
+ assert.deepEqual(received, ["after navigation", "saved answer"]);
59
+ retained.stop();
60
+ assert.equal(socket.listenerCount("completed"), 0);
61
+ enabled.value = false;
62
+ enabled.value = true;
63
+ await nextTick();
64
+ assert.equal(socket.listenerCount("completed"), 0);
65
+ });
@@ -51,6 +51,41 @@ test("createSocketIoServer uses provided http server and fixed socket path", ()
51
51
  });
52
52
  });
53
53
 
54
+ test("createSocketIoServer hands only its own Fastify WebSocket upgrades to Socket.IO", async () => {
55
+ const server = {};
56
+ const events = [];
57
+ let handoff;
58
+ const fastify = {
59
+ server,
60
+ addHook(name, handler) {
61
+ assert.equal(name, "onRequest");
62
+ handoff = handler;
63
+ events.push("handoff");
64
+ }
65
+ };
66
+ class FakeServer {
67
+ constructor(httpServer) {
68
+ assert.equal(httpServer, server);
69
+ events.push("socket.io");
70
+ }
71
+ }
72
+ createSocketIoServer({ fastify, ServerCtor: FakeServer });
73
+ assert.deepEqual(events, ["handoff", "socket.io"]);
74
+ for (const [request, expected] of [
75
+ [{ ws: true, raw: { url: "/socket.io/?EIO=4&transport=websocket" } }, true],
76
+ [{ ws: true, url: "/socket.io" }, true],
77
+ [{ ws: false, raw: { url: "/socket.io/" } }, false],
78
+ [{ raw: { url: "/socket.io/" } }, false],
79
+ [{ ws: true, raw: { url: "/socket.io-other" } }, false],
80
+ [{ ws: true, raw: { url: "/api/conversations/planning/voice" }, url: "/socket.io" }, false],
81
+ [{ ws: true }, false]
82
+ ]) {
83
+ let hijacked = false;
84
+ await handoff(request, { hijack() { hijacked = true; } });
85
+ assert.equal(hijacked, expected, JSON.stringify(request));
86
+ }
87
+ });
88
+
54
89
  test("realtime leaves slow application upgrades and access denials to their route owner", { timeout: 5_000 }, async (t) => {
55
90
  const server = createServer();
56
91
  const sockets = new Set();