@sylphx/lens-server 1.0.2
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/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1437 -0
- package/dist/server/create.d.ts +226 -0
- package/dist/server/create.d.ts.map +1 -0
- package/dist/sse/handler.d.ts +78 -0
- package/dist/sse/handler.d.ts.map +1 -0
- package/dist/state/graph-state-manager.d.ts +146 -0
- package/dist/state/graph-state-manager.d.ts.map +1 -0
- package/dist/state/index.d.ts +7 -0
- package/dist/state/index.d.ts.map +1 -0
- package/package.json +39 -0
- package/src/e2e/server.test.ts +666 -0
- package/src/index.ts +67 -0
- package/src/server/create.test.ts +807 -0
- package/src/server/create.ts +1536 -0
- package/src/sse/handler.ts +185 -0
- package/src/state/graph-state-manager.test.ts +334 -0
- package/src/state/graph-state-manager.ts +443 -0
- package/src/state/index.ts +16 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - SSE Transport Adapter
|
|
3
|
+
*
|
|
4
|
+
* Thin transport adapter for Server-Sent Events.
|
|
5
|
+
* Connects SSE streams to GraphStateManager.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphStateManager, StateClient } from "../state/graph-state-manager";
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
/** SSE handler configuration */
|
|
15
|
+
export interface SSEHandlerConfig {
|
|
16
|
+
/** GraphStateManager instance (required) */
|
|
17
|
+
stateManager: GraphStateManager;
|
|
18
|
+
/** Heartbeat interval in ms (default: 30000) */
|
|
19
|
+
heartbeatInterval?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** SSE client info */
|
|
23
|
+
export interface SSEClientInfo {
|
|
24
|
+
id: string;
|
|
25
|
+
connectedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// SSE Handler (Transport Adapter)
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* SSE transport adapter for GraphStateManager.
|
|
34
|
+
*
|
|
35
|
+
* This is a thin adapter that:
|
|
36
|
+
* - Creates SSE connections
|
|
37
|
+
* - Registers clients with GraphStateManager
|
|
38
|
+
* - Forwards updates to SSE streams
|
|
39
|
+
*
|
|
40
|
+
* All state/subscription logic is handled by GraphStateManager.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* const stateManager = new GraphStateManager();
|
|
45
|
+
* const sse = new SSEHandler({ stateManager });
|
|
46
|
+
*
|
|
47
|
+
* // Handle SSE connection
|
|
48
|
+
* app.get('/events', (req) => sse.handleConnection(req));
|
|
49
|
+
*
|
|
50
|
+
* // Subscribe via separate endpoint or message
|
|
51
|
+
* stateManager.subscribe(clientId, "Post", "123", "*");
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export class SSEHandler {
|
|
55
|
+
private stateManager: GraphStateManager;
|
|
56
|
+
private heartbeatInterval: number;
|
|
57
|
+
private clients = new Map<
|
|
58
|
+
string,
|
|
59
|
+
{ controller: ReadableStreamDefaultController; heartbeat: ReturnType<typeof setInterval> }
|
|
60
|
+
>();
|
|
61
|
+
private clientCounter = 0;
|
|
62
|
+
|
|
63
|
+
constructor(config: SSEHandlerConfig) {
|
|
64
|
+
this.stateManager = config.stateManager;
|
|
65
|
+
this.heartbeatInterval = config.heartbeatInterval ?? 30000;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handle new SSE connection
|
|
70
|
+
* Returns a Response with SSE stream
|
|
71
|
+
*/
|
|
72
|
+
handleConnection(req?: Request): Response {
|
|
73
|
+
const clientId = `sse_${++this.clientCounter}_${Date.now()}`;
|
|
74
|
+
const encoder = new TextEncoder();
|
|
75
|
+
|
|
76
|
+
const stream = new ReadableStream({
|
|
77
|
+
start: (controller) => {
|
|
78
|
+
// Register with GraphStateManager
|
|
79
|
+
const stateClient: StateClient = {
|
|
80
|
+
id: clientId,
|
|
81
|
+
send: (msg) => {
|
|
82
|
+
try {
|
|
83
|
+
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
|
84
|
+
controller.enqueue(encoder.encode(data));
|
|
85
|
+
} catch {
|
|
86
|
+
// Connection closed
|
|
87
|
+
this.removeClient(clientId);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
this.stateManager.addClient(stateClient);
|
|
92
|
+
|
|
93
|
+
// Send connected event
|
|
94
|
+
controller.enqueue(
|
|
95
|
+
encoder.encode(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Setup heartbeat
|
|
99
|
+
const heartbeat = setInterval(() => {
|
|
100
|
+
try {
|
|
101
|
+
controller.enqueue(encoder.encode(`: heartbeat ${Date.now()}\n\n`));
|
|
102
|
+
} catch {
|
|
103
|
+
this.removeClient(clientId);
|
|
104
|
+
}
|
|
105
|
+
}, this.heartbeatInterval);
|
|
106
|
+
|
|
107
|
+
// Track client
|
|
108
|
+
this.clients.set(clientId, { controller, heartbeat });
|
|
109
|
+
},
|
|
110
|
+
cancel: () => {
|
|
111
|
+
this.removeClient(clientId);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return new Response(stream, {
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "text/event-stream",
|
|
118
|
+
"Cache-Control": "no-cache",
|
|
119
|
+
Connection: "keep-alive",
|
|
120
|
+
"Access-Control-Allow-Origin": "*",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remove client and cleanup
|
|
127
|
+
*/
|
|
128
|
+
private removeClient(clientId: string): void {
|
|
129
|
+
const client = this.clients.get(clientId);
|
|
130
|
+
if (client) {
|
|
131
|
+
clearInterval(client.heartbeat);
|
|
132
|
+
this.clients.delete(clientId);
|
|
133
|
+
}
|
|
134
|
+
this.stateManager.removeClient(clientId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Close specific client connection
|
|
139
|
+
*/
|
|
140
|
+
closeClient(clientId: string): void {
|
|
141
|
+
const client = this.clients.get(clientId);
|
|
142
|
+
if (client) {
|
|
143
|
+
try {
|
|
144
|
+
client.controller.close();
|
|
145
|
+
} catch {
|
|
146
|
+
// Already closed
|
|
147
|
+
}
|
|
148
|
+
this.removeClient(clientId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get connected client count
|
|
154
|
+
*/
|
|
155
|
+
getClientCount(): number {
|
|
156
|
+
return this.clients.size;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get connected client IDs
|
|
161
|
+
*/
|
|
162
|
+
getClientIds(): string[] {
|
|
163
|
+
return Array.from(this.clients.keys());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Close all connections
|
|
168
|
+
*/
|
|
169
|
+
closeAll(): void {
|
|
170
|
+
for (const clientId of this.clients.keys()) {
|
|
171
|
+
this.closeClient(clientId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =============================================================================
|
|
177
|
+
// Factory
|
|
178
|
+
// =============================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Create SSE handler (transport adapter)
|
|
182
|
+
*/
|
|
183
|
+
export function createSSEHandler(config: SSEHandlerConfig): SSEHandler {
|
|
184
|
+
return new SSEHandler(config);
|
|
185
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GraphStateManager
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
GraphStateManager,
|
|
8
|
+
type StateClient,
|
|
9
|
+
type StateUpdateMessage,
|
|
10
|
+
} from "./graph-state-manager";
|
|
11
|
+
|
|
12
|
+
describe("GraphStateManager", () => {
|
|
13
|
+
let manager: GraphStateManager;
|
|
14
|
+
let mockClient: StateClient & { messages: StateUpdateMessage[] };
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
manager = new GraphStateManager();
|
|
18
|
+
mockClient = {
|
|
19
|
+
id: "client-1",
|
|
20
|
+
messages: [],
|
|
21
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
22
|
+
mockClient.messages.push(msg);
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
manager.addClient(mockClient);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("client management", () => {
|
|
29
|
+
it("adds and removes clients", () => {
|
|
30
|
+
expect(manager.getStats().clients).toBe(1);
|
|
31
|
+
|
|
32
|
+
manager.removeClient("client-1");
|
|
33
|
+
expect(manager.getStats().clients).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles removing non-existent client", () => {
|
|
37
|
+
expect(() => manager.removeClient("non-existent")).not.toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("subscription", () => {
|
|
42
|
+
it("subscribes client to entity", () => {
|
|
43
|
+
manager.subscribe("client-1", "Post", "123", ["title", "content"]);
|
|
44
|
+
|
|
45
|
+
expect(manager.hasSubscribers("Post", "123")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("unsubscribes client from entity", () => {
|
|
49
|
+
manager.subscribe("client-1", "Post", "123");
|
|
50
|
+
manager.unsubscribe("client-1", "Post", "123");
|
|
51
|
+
|
|
52
|
+
expect(manager.hasSubscribers("Post", "123")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("sends initial data when subscribing to existing state", () => {
|
|
56
|
+
// Emit data first
|
|
57
|
+
manager.emit("Post", "123", { title: "Hello", content: "World" });
|
|
58
|
+
|
|
59
|
+
// Then subscribe
|
|
60
|
+
manager.subscribe("client-1", "Post", "123", ["title"]);
|
|
61
|
+
|
|
62
|
+
// Should receive initial data
|
|
63
|
+
expect(mockClient.messages.length).toBe(1);
|
|
64
|
+
expect(mockClient.messages[0]).toMatchObject({
|
|
65
|
+
type: "update",
|
|
66
|
+
entity: "Post",
|
|
67
|
+
id: "123",
|
|
68
|
+
});
|
|
69
|
+
expect(mockClient.messages[0].updates.title).toMatchObject({
|
|
70
|
+
strategy: "value",
|
|
71
|
+
data: "Hello",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("subscribes to all fields with *", () => {
|
|
76
|
+
manager.emit("Post", "123", { title: "Hello", content: "World" });
|
|
77
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
78
|
+
|
|
79
|
+
expect(mockClient.messages.length).toBe(1);
|
|
80
|
+
expect(mockClient.messages[0].updates).toHaveProperty("title");
|
|
81
|
+
expect(mockClient.messages[0].updates).toHaveProperty("content");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("emit", () => {
|
|
86
|
+
it("updates canonical state", () => {
|
|
87
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
88
|
+
|
|
89
|
+
expect(manager.getState("Post", "123")).toEqual({ title: "Hello" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("merges partial updates by default", () => {
|
|
93
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
94
|
+
manager.emit("Post", "123", { content: "World" });
|
|
95
|
+
|
|
96
|
+
expect(manager.getState("Post", "123")).toEqual({
|
|
97
|
+
title: "Hello",
|
|
98
|
+
content: "World",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("replaces state when replace option is true", () => {
|
|
103
|
+
manager.emit("Post", "123", { title: "Hello", content: "World" });
|
|
104
|
+
manager.emit("Post", "123", { title: "New" }, { replace: true });
|
|
105
|
+
|
|
106
|
+
expect(manager.getState("Post", "123")).toEqual({ title: "New" });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("pushes updates to subscribed clients", () => {
|
|
110
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
111
|
+
mockClient.messages = []; // Clear initial subscription message
|
|
112
|
+
|
|
113
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
114
|
+
|
|
115
|
+
expect(mockClient.messages.length).toBe(1);
|
|
116
|
+
expect(mockClient.messages[0]).toMatchObject({
|
|
117
|
+
type: "update",
|
|
118
|
+
entity: "Post",
|
|
119
|
+
id: "123",
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("only sends updates for changed fields", () => {
|
|
124
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
125
|
+
manager.emit("Post", "123", { title: "Hello", content: "World" });
|
|
126
|
+
mockClient.messages = [];
|
|
127
|
+
|
|
128
|
+
// Emit same title, different content
|
|
129
|
+
manager.emit("Post", "123", { title: "Hello", content: "Updated" });
|
|
130
|
+
|
|
131
|
+
expect(mockClient.messages.length).toBe(1);
|
|
132
|
+
expect(mockClient.messages[0].updates).toHaveProperty("content");
|
|
133
|
+
expect(mockClient.messages[0].updates).not.toHaveProperty("title");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("does not send if no fields changed", () => {
|
|
137
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
138
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
139
|
+
mockClient.messages = [];
|
|
140
|
+
|
|
141
|
+
// Emit same data
|
|
142
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
143
|
+
|
|
144
|
+
expect(mockClient.messages.length).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("only sends subscribed fields", () => {
|
|
148
|
+
manager.subscribe("client-1", "Post", "123", ["title"]);
|
|
149
|
+
mockClient.messages = [];
|
|
150
|
+
|
|
151
|
+
manager.emit("Post", "123", { title: "Hello", content: "World" });
|
|
152
|
+
|
|
153
|
+
expect(mockClient.messages.length).toBe(1);
|
|
154
|
+
expect(mockClient.messages[0].updates).toHaveProperty("title");
|
|
155
|
+
expect(mockClient.messages[0].updates).not.toHaveProperty("content");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does not send to unsubscribed clients", () => {
|
|
159
|
+
const otherClient = {
|
|
160
|
+
id: "client-2",
|
|
161
|
+
messages: [] as StateUpdateMessage[],
|
|
162
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
163
|
+
otherClient.messages.push(msg);
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
manager.addClient(otherClient);
|
|
167
|
+
|
|
168
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
169
|
+
// client-2 not subscribed
|
|
170
|
+
|
|
171
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
172
|
+
|
|
173
|
+
expect(mockClient.messages.length).toBe(1);
|
|
174
|
+
expect(otherClient.messages.length).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("update strategies", () => {
|
|
179
|
+
it("uses value strategy for short strings", () => {
|
|
180
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
181
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
182
|
+
mockClient.messages = [];
|
|
183
|
+
|
|
184
|
+
manager.emit("Post", "123", { title: "World" });
|
|
185
|
+
|
|
186
|
+
expect(mockClient.messages[0].updates.title.strategy).toBe("value");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("uses delta strategy for long strings with small changes", () => {
|
|
190
|
+
const longText = "A".repeat(200);
|
|
191
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
192
|
+
manager.emit("Post", "123", { content: longText });
|
|
193
|
+
mockClient.messages = [];
|
|
194
|
+
|
|
195
|
+
manager.emit("Post", "123", { content: `${longText} appended` });
|
|
196
|
+
|
|
197
|
+
// Should use delta for efficient transfer
|
|
198
|
+
const update = mockClient.messages[0].updates.content;
|
|
199
|
+
expect(["delta", "value"]).toContain(update.strategy);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("uses patch strategy for objects", () => {
|
|
203
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
204
|
+
manager.emit("Post", "123", {
|
|
205
|
+
metadata: { views: 100, likes: 10, tags: ["a", "b"] },
|
|
206
|
+
});
|
|
207
|
+
mockClient.messages = [];
|
|
208
|
+
|
|
209
|
+
manager.emit("Post", "123", {
|
|
210
|
+
metadata: { views: 101, likes: 10, tags: ["a", "b"] },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const update = mockClient.messages[0].updates.metadata;
|
|
214
|
+
expect(["patch", "value"]).toContain(update.strategy);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("multiple clients", () => {
|
|
219
|
+
it("sends updates to all subscribed clients", () => {
|
|
220
|
+
const client2 = {
|
|
221
|
+
id: "client-2",
|
|
222
|
+
messages: [] as StateUpdateMessage[],
|
|
223
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
224
|
+
client2.messages.push(msg);
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
manager.addClient(client2);
|
|
228
|
+
|
|
229
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
230
|
+
manager.subscribe("client-2", "Post", "123", "*");
|
|
231
|
+
mockClient.messages = [];
|
|
232
|
+
client2.messages = [];
|
|
233
|
+
|
|
234
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
235
|
+
|
|
236
|
+
expect(mockClient.messages.length).toBe(1);
|
|
237
|
+
expect(client2.messages.length).toBe(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("tracks state independently per client", () => {
|
|
241
|
+
const client2 = {
|
|
242
|
+
id: "client-2",
|
|
243
|
+
messages: [] as StateUpdateMessage[],
|
|
244
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
245
|
+
client2.messages.push(msg);
|
|
246
|
+
}),
|
|
247
|
+
};
|
|
248
|
+
manager.addClient(client2);
|
|
249
|
+
|
|
250
|
+
// Emit initial state
|
|
251
|
+
manager.emit("Post", "123", { title: "Hello", content: "World" });
|
|
252
|
+
|
|
253
|
+
// Subscribe clients at different times
|
|
254
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
255
|
+
mockClient.messages = [];
|
|
256
|
+
|
|
257
|
+
// Emit update
|
|
258
|
+
manager.emit("Post", "123", { title: "Updated" });
|
|
259
|
+
|
|
260
|
+
// Now subscribe client-2 (should get current state)
|
|
261
|
+
manager.subscribe("client-2", "Post", "123", "*");
|
|
262
|
+
|
|
263
|
+
// client-1 got incremental update
|
|
264
|
+
expect(mockClient.messages.length).toBe(1);
|
|
265
|
+
expect(mockClient.messages[0].updates.title.data).toBe("Updated");
|
|
266
|
+
|
|
267
|
+
// client-2 got full current state
|
|
268
|
+
expect(client2.messages.length).toBe(1);
|
|
269
|
+
expect(client2.messages[0].updates.title.data).toBe("Updated");
|
|
270
|
+
expect(client2.messages[0].updates.content.data).toBe("World");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("cleanup", () => {
|
|
275
|
+
it("calls onEntityUnsubscribed when last client unsubscribes", () => {
|
|
276
|
+
const onUnsubscribe = mock(() => {});
|
|
277
|
+
const mgr = new GraphStateManager({
|
|
278
|
+
onEntityUnsubscribed: onUnsubscribe,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const client = {
|
|
282
|
+
id: "c1",
|
|
283
|
+
send: mock(() => {}),
|
|
284
|
+
};
|
|
285
|
+
mgr.addClient(client);
|
|
286
|
+
mgr.subscribe("c1", "Post", "123", "*");
|
|
287
|
+
mgr.unsubscribe("c1", "Post", "123");
|
|
288
|
+
|
|
289
|
+
expect(onUnsubscribe).toHaveBeenCalledWith("Post", "123");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("cleans up subscriptions when client is removed", () => {
|
|
293
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
294
|
+
manager.subscribe("client-1", "Post", "456", "*");
|
|
295
|
+
|
|
296
|
+
manager.removeClient("client-1");
|
|
297
|
+
|
|
298
|
+
expect(manager.hasSubscribers("Post", "123")).toBe(false);
|
|
299
|
+
expect(manager.hasSubscribers("Post", "456")).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("clear() removes all state", () => {
|
|
303
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
304
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
305
|
+
|
|
306
|
+
manager.clear();
|
|
307
|
+
|
|
308
|
+
expect(manager.getStats()).toEqual({
|
|
309
|
+
clients: 0,
|
|
310
|
+
entities: 0,
|
|
311
|
+
totalSubscriptions: 0,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("stats", () => {
|
|
317
|
+
it("returns correct stats", () => {
|
|
318
|
+
const client2 = { id: "client-2", send: mock(() => {}) };
|
|
319
|
+
manager.addClient(client2);
|
|
320
|
+
|
|
321
|
+
manager.emit("Post", "123", { title: "Hello" });
|
|
322
|
+
manager.emit("Post", "456", { title: "World" });
|
|
323
|
+
|
|
324
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
325
|
+
manager.subscribe("client-1", "Post", "456", "*");
|
|
326
|
+
manager.subscribe("client-2", "Post", "123", "*");
|
|
327
|
+
|
|
328
|
+
const stats = manager.getStats();
|
|
329
|
+
expect(stats.clients).toBe(2);
|
|
330
|
+
expect(stats.entities).toBe(2);
|
|
331
|
+
expect(stats.totalSubscriptions).toBe(3);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|