@jskit-ai/realtime 0.1.219 → 0.1.221
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 +4 -4
- package/patterns/realtime-application/example/package.json +2 -2
- package/src/client/composables/useRealtimeEvent.js +2 -4
- package/src/server/RealtimeProvider.js +67 -1
- package/src/server/realtimeAudience.js +9 -4
- package/src/server/runtime.js +22 -0
- package/test/providerRuntime.test.js +87 -0
- package/test/realtimeEvent.test.js +65 -0
- package/test/serverRuntime.test.js +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/realtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.221",
|
|
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.
|
|
24
|
-
"@jskit-ai/shell-web": "0.1.
|
|
23
|
+
"@jskit-ai/kernel": "0.1.224",
|
|
24
|
+
"@jskit-ai/shell-web": "0.1.228",
|
|
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
|
|
82
|
+
"summary": "Exports realtime socket composables with subscriptions owned by their Vue effect scope."
|
|
83
83
|
}
|
|
84
84
|
],
|
|
85
85
|
"capabilities": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { inject,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/server/runtime.js
CHANGED
|
@@ -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();
|