@sylphx/lens-server 2.3.2 → 2.4.1
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 +241 -23
- package/dist/index.js +353 -19
- package/package.json +1 -1
- package/src/handlers/http.test.ts +227 -2
- package/src/handlers/http.ts +223 -22
- package/src/handlers/index.ts +2 -0
- package/src/handlers/ws-types.ts +39 -0
- package/src/handlers/ws.test.ts +559 -0
- package/src/handlers/ws.ts +99 -0
- package/src/index.ts +21 -0
- package/src/logging/index.ts +20 -0
- package/src/logging/structured-logger.test.ts +367 -0
- package/src/logging/structured-logger.ts +335 -0
- package/src/server/create.test.ts +198 -0
- package/src/server/create.ts +78 -9
- package/src/server/types.ts +1 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - WebSocket Handler Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
+
import { mutation, query } from "@sylphx/lens-core";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { WebSocketLike } from "../server/create.js";
|
|
9
|
+
import { createApp } from "../server/create.js";
|
|
10
|
+
import { createWSHandler } from "./ws.js";
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Mock WebSocket
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
interface MockWebSocket extends WebSocketLike {
|
|
17
|
+
sentMessages: unknown[];
|
|
18
|
+
closeCode?: number;
|
|
19
|
+
closeReason?: string;
|
|
20
|
+
closed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createMockWebSocket(): MockWebSocket {
|
|
24
|
+
const ws: MockWebSocket = {
|
|
25
|
+
sentMessages: [],
|
|
26
|
+
closed: false,
|
|
27
|
+
send(data: string): void {
|
|
28
|
+
ws.sentMessages.push(JSON.parse(data));
|
|
29
|
+
},
|
|
30
|
+
close(code?: number, reason?: string): void {
|
|
31
|
+
ws.closeCode = code;
|
|
32
|
+
ws.closeReason = reason;
|
|
33
|
+
ws.closed = true;
|
|
34
|
+
},
|
|
35
|
+
onmessage: null,
|
|
36
|
+
onclose: null,
|
|
37
|
+
};
|
|
38
|
+
return ws;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Helper to simulate receiving a message
|
|
42
|
+
function simulateMessage(ws: MockWebSocket, message: unknown): void {
|
|
43
|
+
if (ws.onmessage) {
|
|
44
|
+
ws.onmessage({ data: JSON.stringify(message) } as MessageEvent);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper to wait for async operations
|
|
49
|
+
function wait(ms = 10): Promise<void> {
|
|
50
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Test Queries and Mutations
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
const getUser = query()
|
|
58
|
+
.input(z.object({ id: z.string() }))
|
|
59
|
+
.resolve(({ input }) => ({
|
|
60
|
+
id: input.id,
|
|
61
|
+
name: "Test User",
|
|
62
|
+
__typename: "User",
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const listUsers = query().resolve(() => [
|
|
66
|
+
{ id: "1", name: "User 1", __typename: "User" },
|
|
67
|
+
{ id: "2", name: "User 2", __typename: "User" },
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const createUser = mutation()
|
|
71
|
+
.input(z.object({ name: z.string() }))
|
|
72
|
+
.resolve(({ input }) => ({
|
|
73
|
+
id: "new-id",
|
|
74
|
+
name: input.name,
|
|
75
|
+
__typename: "User",
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const slowQuery = query()
|
|
79
|
+
.input(z.object({ delay: z.number() }))
|
|
80
|
+
.resolve(async ({ input }) => {
|
|
81
|
+
await new Promise((r) => setTimeout(r, input.delay));
|
|
82
|
+
return { done: true };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Tests
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
describe("createWSHandler", () => {
|
|
90
|
+
let app: ReturnType<typeof createApp>;
|
|
91
|
+
let wsHandler: ReturnType<typeof createWSHandler>;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
app = createApp({
|
|
95
|
+
queries: { getUser, listUsers, slowQuery },
|
|
96
|
+
mutations: { createUser },
|
|
97
|
+
});
|
|
98
|
+
wsHandler = createWSHandler(app);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(async () => {
|
|
102
|
+
await wsHandler.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("connection handling", () => {
|
|
106
|
+
it("creates a WebSocket handler from app", () => {
|
|
107
|
+
expect(wsHandler).toBeDefined();
|
|
108
|
+
expect(typeof wsHandler.handleConnection).toBe("function");
|
|
109
|
+
expect(typeof wsHandler.close).toBe("function");
|
|
110
|
+
expect(wsHandler.handler).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("accepts new connections", async () => {
|
|
114
|
+
const ws = createMockWebSocket();
|
|
115
|
+
wsHandler.handleConnection(ws);
|
|
116
|
+
await wait();
|
|
117
|
+
|
|
118
|
+
expect(ws.closed).toBe(false);
|
|
119
|
+
expect(ws.onmessage).not.toBeNull();
|
|
120
|
+
expect(ws.onclose).not.toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects connections when at capacity", async () => {
|
|
124
|
+
const lowCapacityHandler = createWSHandler(app, { maxConnections: 2 });
|
|
125
|
+
|
|
126
|
+
const ws1 = createMockWebSocket();
|
|
127
|
+
const ws2 = createMockWebSocket();
|
|
128
|
+
const ws3 = createMockWebSocket();
|
|
129
|
+
|
|
130
|
+
lowCapacityHandler.handleConnection(ws1);
|
|
131
|
+
await wait();
|
|
132
|
+
lowCapacityHandler.handleConnection(ws2);
|
|
133
|
+
await wait();
|
|
134
|
+
lowCapacityHandler.handleConnection(ws3);
|
|
135
|
+
await wait();
|
|
136
|
+
|
|
137
|
+
expect(ws1.closed).toBe(false);
|
|
138
|
+
expect(ws2.closed).toBe(false);
|
|
139
|
+
expect(ws3.closed).toBe(true);
|
|
140
|
+
expect(ws3.closeCode).toBe(1013);
|
|
141
|
+
expect(ws3.closeReason).toBe("Server at capacity");
|
|
142
|
+
|
|
143
|
+
await lowCapacityHandler.close();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("cleans up on disconnect", async () => {
|
|
147
|
+
const ws = createMockWebSocket();
|
|
148
|
+
wsHandler.handleConnection(ws);
|
|
149
|
+
await wait();
|
|
150
|
+
|
|
151
|
+
// Simulate disconnect
|
|
152
|
+
if (ws.onclose) {
|
|
153
|
+
ws.onclose({} as CloseEvent);
|
|
154
|
+
}
|
|
155
|
+
await wait();
|
|
156
|
+
|
|
157
|
+
// Connection should be cleaned up (no error on close)
|
|
158
|
+
await wsHandler.close();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("handshake", () => {
|
|
163
|
+
it("responds to handshake with metadata", async () => {
|
|
164
|
+
const ws = createMockWebSocket();
|
|
165
|
+
wsHandler.handleConnection(ws);
|
|
166
|
+
await wait();
|
|
167
|
+
|
|
168
|
+
simulateMessage(ws, {
|
|
169
|
+
type: "handshake",
|
|
170
|
+
id: "hs-1",
|
|
171
|
+
});
|
|
172
|
+
await wait();
|
|
173
|
+
|
|
174
|
+
expect(ws.sentMessages.length).toBe(1);
|
|
175
|
+
const response = ws.sentMessages[0] as {
|
|
176
|
+
type: string;
|
|
177
|
+
id: string;
|
|
178
|
+
version: string;
|
|
179
|
+
operations: Record<string, unknown>;
|
|
180
|
+
};
|
|
181
|
+
expect(response.type).toBe("handshake");
|
|
182
|
+
expect(response.id).toBe("hs-1");
|
|
183
|
+
expect(response.version).toBeDefined();
|
|
184
|
+
expect(response.operations).toBeDefined();
|
|
185
|
+
expect(response.operations.getUser).toBeDefined();
|
|
186
|
+
expect(response.operations.createUser).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("query", () => {
|
|
191
|
+
it("executes query and returns result", async () => {
|
|
192
|
+
const ws = createMockWebSocket();
|
|
193
|
+
wsHandler.handleConnection(ws);
|
|
194
|
+
await wait();
|
|
195
|
+
|
|
196
|
+
simulateMessage(ws, {
|
|
197
|
+
type: "query",
|
|
198
|
+
id: "q-1",
|
|
199
|
+
operation: "getUser",
|
|
200
|
+
input: { id: "123" },
|
|
201
|
+
});
|
|
202
|
+
await wait();
|
|
203
|
+
|
|
204
|
+
expect(ws.sentMessages.length).toBe(1);
|
|
205
|
+
const response = ws.sentMessages[0] as {
|
|
206
|
+
type: string;
|
|
207
|
+
id: string;
|
|
208
|
+
data: { id: string; name: string };
|
|
209
|
+
};
|
|
210
|
+
expect(response.type).toBe("result");
|
|
211
|
+
expect(response.id).toBe("q-1");
|
|
212
|
+
expect(response.data.id).toBe("123");
|
|
213
|
+
expect(response.data.name).toBe("Test User");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("applies field selection to query result", async () => {
|
|
217
|
+
const ws = createMockWebSocket();
|
|
218
|
+
wsHandler.handleConnection(ws);
|
|
219
|
+
await wait();
|
|
220
|
+
|
|
221
|
+
simulateMessage(ws, {
|
|
222
|
+
type: "query",
|
|
223
|
+
id: "q-1",
|
|
224
|
+
operation: "getUser",
|
|
225
|
+
input: { id: "123" },
|
|
226
|
+
fields: ["name"], // Only request name field
|
|
227
|
+
});
|
|
228
|
+
await wait();
|
|
229
|
+
|
|
230
|
+
const response = ws.sentMessages[0] as {
|
|
231
|
+
type: string;
|
|
232
|
+
data: { id: string; name: string };
|
|
233
|
+
};
|
|
234
|
+
expect(response.data.id).toBe("123"); // id always included
|
|
235
|
+
expect(response.data.name).toBe("Test User");
|
|
236
|
+
expect((response.data as { __typename?: string }).__typename).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("returns error for unknown operation", async () => {
|
|
240
|
+
const ws = createMockWebSocket();
|
|
241
|
+
wsHandler.handleConnection(ws);
|
|
242
|
+
await wait();
|
|
243
|
+
|
|
244
|
+
simulateMessage(ws, {
|
|
245
|
+
type: "query",
|
|
246
|
+
id: "q-1",
|
|
247
|
+
operation: "unknownOp",
|
|
248
|
+
input: {},
|
|
249
|
+
});
|
|
250
|
+
await wait();
|
|
251
|
+
|
|
252
|
+
const response = ws.sentMessages[0] as {
|
|
253
|
+
type: string;
|
|
254
|
+
id: string;
|
|
255
|
+
error: { code: string; message: string };
|
|
256
|
+
};
|
|
257
|
+
expect(response.type).toBe("error");
|
|
258
|
+
expect(response.id).toBe("q-1");
|
|
259
|
+
expect(response.error.code).toBe("EXECUTION_ERROR");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("mutation", () => {
|
|
264
|
+
it("executes mutation and returns result", async () => {
|
|
265
|
+
const ws = createMockWebSocket();
|
|
266
|
+
wsHandler.handleConnection(ws);
|
|
267
|
+
await wait();
|
|
268
|
+
|
|
269
|
+
simulateMessage(ws, {
|
|
270
|
+
type: "mutation",
|
|
271
|
+
id: "m-1",
|
|
272
|
+
operation: "createUser",
|
|
273
|
+
input: { name: "New User" },
|
|
274
|
+
});
|
|
275
|
+
await wait();
|
|
276
|
+
|
|
277
|
+
const response = ws.sentMessages[0] as {
|
|
278
|
+
type: string;
|
|
279
|
+
id: string;
|
|
280
|
+
data: { id: string; name: string };
|
|
281
|
+
};
|
|
282
|
+
expect(response.type).toBe("result");
|
|
283
|
+
expect(response.id).toBe("m-1");
|
|
284
|
+
expect(response.data.id).toBe("new-id");
|
|
285
|
+
expect(response.data.name).toBe("New User");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("subscribe/unsubscribe", () => {
|
|
290
|
+
it("creates subscription without error", async () => {
|
|
291
|
+
const ws = createMockWebSocket();
|
|
292
|
+
wsHandler.handleConnection(ws);
|
|
293
|
+
await wait();
|
|
294
|
+
|
|
295
|
+
simulateMessage(ws, {
|
|
296
|
+
type: "subscribe",
|
|
297
|
+
id: "sub-1",
|
|
298
|
+
operation: "getUser",
|
|
299
|
+
input: { id: "123" },
|
|
300
|
+
fields: "*",
|
|
301
|
+
});
|
|
302
|
+
await wait(50); // Give time for async subscription setup
|
|
303
|
+
|
|
304
|
+
// Subscription should be established without errors
|
|
305
|
+
// (No error message with id "sub-1")
|
|
306
|
+
const errorMsg = ws.sentMessages.find(
|
|
307
|
+
(msg) => (msg as { type?: string; id?: string }).type === "error" && (msg as { id?: string }).id === "sub-1",
|
|
308
|
+
);
|
|
309
|
+
expect(errorMsg).toBeUndefined();
|
|
310
|
+
expect(ws.closed).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("handles unsubscribe", async () => {
|
|
314
|
+
const ws = createMockWebSocket();
|
|
315
|
+
wsHandler.handleConnection(ws);
|
|
316
|
+
await wait();
|
|
317
|
+
|
|
318
|
+
// Subscribe first
|
|
319
|
+
simulateMessage(ws, {
|
|
320
|
+
type: "subscribe",
|
|
321
|
+
id: "sub-1",
|
|
322
|
+
operation: "getUser",
|
|
323
|
+
input: { id: "123" },
|
|
324
|
+
fields: "*",
|
|
325
|
+
});
|
|
326
|
+
await wait(50);
|
|
327
|
+
|
|
328
|
+
const _messagesBeforeUnsub = ws.sentMessages.length;
|
|
329
|
+
|
|
330
|
+
// Unsubscribe
|
|
331
|
+
simulateMessage(ws, {
|
|
332
|
+
type: "unsubscribe",
|
|
333
|
+
id: "sub-1",
|
|
334
|
+
});
|
|
335
|
+
await wait();
|
|
336
|
+
|
|
337
|
+
// Should not receive any more messages (or just an ack)
|
|
338
|
+
// Main thing is no error
|
|
339
|
+
expect(ws.closed).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("enforces subscription limit per client", async () => {
|
|
343
|
+
const limitedHandler = createWSHandler(app, { maxSubscriptionsPerClient: 2 });
|
|
344
|
+
|
|
345
|
+
const ws = createMockWebSocket();
|
|
346
|
+
limitedHandler.handleConnection(ws);
|
|
347
|
+
await wait();
|
|
348
|
+
|
|
349
|
+
// Create subscriptions up to limit
|
|
350
|
+
simulateMessage(ws, {
|
|
351
|
+
type: "subscribe",
|
|
352
|
+
id: "sub-1",
|
|
353
|
+
operation: "getUser",
|
|
354
|
+
input: { id: "1" },
|
|
355
|
+
fields: "*",
|
|
356
|
+
});
|
|
357
|
+
await wait(50);
|
|
358
|
+
|
|
359
|
+
simulateMessage(ws, {
|
|
360
|
+
type: "subscribe",
|
|
361
|
+
id: "sub-2",
|
|
362
|
+
operation: "getUser",
|
|
363
|
+
input: { id: "2" },
|
|
364
|
+
fields: "*",
|
|
365
|
+
});
|
|
366
|
+
await wait(50);
|
|
367
|
+
|
|
368
|
+
// Try to exceed limit
|
|
369
|
+
simulateMessage(ws, {
|
|
370
|
+
type: "subscribe",
|
|
371
|
+
id: "sub-3",
|
|
372
|
+
operation: "getUser",
|
|
373
|
+
input: { id: "3" },
|
|
374
|
+
fields: "*",
|
|
375
|
+
});
|
|
376
|
+
await wait(50);
|
|
377
|
+
|
|
378
|
+
// Find the error message for sub-3
|
|
379
|
+
const errorMsg = ws.sentMessages.find(
|
|
380
|
+
(msg) => (msg as { type?: string; id?: string }).type === "error" && (msg as { id?: string }).id === "sub-3",
|
|
381
|
+
) as { error?: { code?: string } } | undefined;
|
|
382
|
+
|
|
383
|
+
expect(errorMsg).toBeDefined();
|
|
384
|
+
expect(errorMsg?.error?.code).toBe("SUBSCRIPTION_LIMIT");
|
|
385
|
+
|
|
386
|
+
await limitedHandler.close();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("message size limits", () => {
|
|
391
|
+
it("rejects messages exceeding size limit", async () => {
|
|
392
|
+
const limitedHandler = createWSHandler(app, { maxMessageSize: 100 });
|
|
393
|
+
|
|
394
|
+
const ws = createMockWebSocket();
|
|
395
|
+
limitedHandler.handleConnection(ws);
|
|
396
|
+
await wait();
|
|
397
|
+
|
|
398
|
+
// Create a large message
|
|
399
|
+
const largeMessage = JSON.stringify({
|
|
400
|
+
type: "query",
|
|
401
|
+
id: "q-1",
|
|
402
|
+
operation: "getUser",
|
|
403
|
+
input: { id: "x".repeat(200) },
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Simulate receiving large message directly
|
|
407
|
+
if (ws.onmessage) {
|
|
408
|
+
ws.onmessage({ data: largeMessage } as MessageEvent);
|
|
409
|
+
}
|
|
410
|
+
await wait();
|
|
411
|
+
|
|
412
|
+
const errorMsg = ws.sentMessages.find((msg) => (msg as { type?: string }).type === "error") as
|
|
413
|
+
| { error?: { code?: string } }
|
|
414
|
+
| undefined;
|
|
415
|
+
|
|
416
|
+
expect(errorMsg).toBeDefined();
|
|
417
|
+
expect(errorMsg?.error?.code).toBe("MESSAGE_TOO_LARGE");
|
|
418
|
+
|
|
419
|
+
await limitedHandler.close();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe("rate limiting", () => {
|
|
424
|
+
it("enforces rate limit on messages", async () => {
|
|
425
|
+
const limitedHandler = createWSHandler(app, {
|
|
426
|
+
rateLimit: { maxMessages: 3, windowMs: 1000 },
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const ws = createMockWebSocket();
|
|
430
|
+
limitedHandler.handleConnection(ws);
|
|
431
|
+
await wait();
|
|
432
|
+
|
|
433
|
+
// Send messages rapidly
|
|
434
|
+
for (let i = 0; i < 5; i++) {
|
|
435
|
+
simulateMessage(ws, {
|
|
436
|
+
type: "query",
|
|
437
|
+
id: `q-${i}`,
|
|
438
|
+
operation: "getUser",
|
|
439
|
+
input: { id: String(i) },
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
await wait();
|
|
443
|
+
|
|
444
|
+
// Should have some rate limited errors
|
|
445
|
+
const rateLimitErrors = ws.sentMessages.filter(
|
|
446
|
+
(msg) =>
|
|
447
|
+
(msg as { type?: string }).type === "error" &&
|
|
448
|
+
(msg as { error?: { code?: string } }).error?.code === "RATE_LIMITED",
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
expect(rateLimitErrors.length).toBeGreaterThan(0);
|
|
452
|
+
|
|
453
|
+
await limitedHandler.close();
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("error handling", () => {
|
|
458
|
+
it("handles JSON parse errors", async () => {
|
|
459
|
+
const ws = createMockWebSocket();
|
|
460
|
+
wsHandler.handleConnection(ws);
|
|
461
|
+
await wait();
|
|
462
|
+
|
|
463
|
+
// Send invalid JSON directly
|
|
464
|
+
if (ws.onmessage) {
|
|
465
|
+
ws.onmessage({ data: "not valid json {" } as MessageEvent);
|
|
466
|
+
}
|
|
467
|
+
await wait();
|
|
468
|
+
|
|
469
|
+
const errorMsg = ws.sentMessages[0] as { type?: string; error?: { code?: string } };
|
|
470
|
+
expect(errorMsg.type).toBe("error");
|
|
471
|
+
expect(errorMsg.error?.code).toBe("PARSE_ERROR");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("handles validation errors gracefully", async () => {
|
|
475
|
+
const ws = createMockWebSocket();
|
|
476
|
+
wsHandler.handleConnection(ws);
|
|
477
|
+
await wait();
|
|
478
|
+
|
|
479
|
+
// Send query with invalid input (missing required field)
|
|
480
|
+
simulateMessage(ws, {
|
|
481
|
+
type: "query",
|
|
482
|
+
id: "q-1",
|
|
483
|
+
operation: "getUser",
|
|
484
|
+
input: {}, // Missing required 'id' field
|
|
485
|
+
});
|
|
486
|
+
await wait();
|
|
487
|
+
|
|
488
|
+
const errorMsg = ws.sentMessages[0] as {
|
|
489
|
+
type?: string;
|
|
490
|
+
id?: string;
|
|
491
|
+
error?: { code?: string };
|
|
492
|
+
};
|
|
493
|
+
expect(errorMsg.type).toBe("error");
|
|
494
|
+
expect(errorMsg.id).toBe("q-1");
|
|
495
|
+
expect(errorMsg.error?.code).toBe("EXECUTION_ERROR");
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe("Bun handler interface", () => {
|
|
500
|
+
it("provides Bun-compatible handler object", () => {
|
|
501
|
+
expect(wsHandler.handler).toBeDefined();
|
|
502
|
+
expect(typeof wsHandler.handler.message).toBe("function");
|
|
503
|
+
expect(typeof wsHandler.handler.close).toBe("function");
|
|
504
|
+
expect(typeof wsHandler.handler.open).toBe("function");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("handler.message routes to correct connection", async () => {
|
|
508
|
+
const ws = createMockWebSocket();
|
|
509
|
+
|
|
510
|
+
// First establish connection via open
|
|
511
|
+
wsHandler.handler.open?.(ws);
|
|
512
|
+
await wait();
|
|
513
|
+
|
|
514
|
+
// Send message via handler.message
|
|
515
|
+
const messageData = JSON.stringify({
|
|
516
|
+
type: "query",
|
|
517
|
+
id: "q-1",
|
|
518
|
+
operation: "getUser",
|
|
519
|
+
input: { id: "123" },
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
wsHandler.handler.message(ws, messageData);
|
|
523
|
+
await wait();
|
|
524
|
+
|
|
525
|
+
const response = ws.sentMessages[0] as { type?: string; id?: string };
|
|
526
|
+
expect(response.type).toBe("result");
|
|
527
|
+
expect(response.id).toBe("q-1");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("handler.close triggers disconnect", async () => {
|
|
531
|
+
const ws = createMockWebSocket();
|
|
532
|
+
wsHandler.handleConnection(ws);
|
|
533
|
+
await wait();
|
|
534
|
+
|
|
535
|
+
// Close via handler
|
|
536
|
+
wsHandler.handler.close(ws);
|
|
537
|
+
await wait();
|
|
538
|
+
|
|
539
|
+
// Should have cleaned up without error
|
|
540
|
+
await wsHandler.close();
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe("close", () => {
|
|
545
|
+
it("closes all connections on handler close", async () => {
|
|
546
|
+
const ws1 = createMockWebSocket();
|
|
547
|
+
const ws2 = createMockWebSocket();
|
|
548
|
+
|
|
549
|
+
wsHandler.handleConnection(ws1);
|
|
550
|
+
wsHandler.handleConnection(ws2);
|
|
551
|
+
await wait();
|
|
552
|
+
|
|
553
|
+
await wsHandler.close();
|
|
554
|
+
|
|
555
|
+
expect(ws1.closed).toBe(true);
|
|
556
|
+
expect(ws2.closed).toBe(true);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
});
|
package/src/handlers/ws.ts
CHANGED
|
@@ -81,6 +81,47 @@ export type { WSHandler, WSHandlerOptions } from "./ws-types.js";
|
|
|
81
81
|
export function createWSHandler(server: LensServer, options: WSHandlerOptions = {}): WSHandler {
|
|
82
82
|
const { logger = {} } = options;
|
|
83
83
|
|
|
84
|
+
// Security limits with sensible defaults
|
|
85
|
+
const maxMessageSize = options.maxMessageSize ?? 1024 * 1024; // 1MB
|
|
86
|
+
const maxSubscriptionsPerClient = options.maxSubscriptionsPerClient ?? 100;
|
|
87
|
+
const maxConnections = options.maxConnections ?? 10000;
|
|
88
|
+
|
|
89
|
+
// Rate limiting configuration
|
|
90
|
+
const rateLimitMaxMessages = options.rateLimit?.maxMessages ?? 100;
|
|
91
|
+
const rateLimitWindowMs = options.rateLimit?.windowMs ?? 1000;
|
|
92
|
+
|
|
93
|
+
// Rate limit tracking per client (sliding window)
|
|
94
|
+
const clientMessageTimestamps = new Map<string, number[]>();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if client is rate limited using sliding window algorithm.
|
|
98
|
+
* Returns true if the message should be rejected.
|
|
99
|
+
*/
|
|
100
|
+
function isRateLimited(clientId: string): boolean {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const windowStart = now - rateLimitWindowMs;
|
|
103
|
+
|
|
104
|
+
let timestamps = clientMessageTimestamps.get(clientId);
|
|
105
|
+
if (!timestamps) {
|
|
106
|
+
timestamps = [];
|
|
107
|
+
clientMessageTimestamps.set(clientId, timestamps);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Remove expired timestamps (outside window)
|
|
111
|
+
while (timestamps.length > 0 && timestamps[0] < windowStart) {
|
|
112
|
+
timestamps.shift();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check if over limit
|
|
116
|
+
if (timestamps.length >= rateLimitMaxMessages) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Record this message
|
|
121
|
+
timestamps.push(now);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
84
125
|
// Connection tracking
|
|
85
126
|
const connections = new Map<string, ClientConnection>();
|
|
86
127
|
const wsToConnection = new WeakMap<object, ClientConnection>();
|
|
@@ -88,6 +129,13 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
88
129
|
|
|
89
130
|
// Handle new WebSocket connection
|
|
90
131
|
async function handleConnection(ws: WebSocketLike): Promise<void> {
|
|
132
|
+
// Check connection limit
|
|
133
|
+
if (connections.size >= maxConnections) {
|
|
134
|
+
logger.warn?.(`Connection limit reached (${maxConnections}), rejecting new connection`);
|
|
135
|
+
ws.close(1013, "Server at capacity");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
91
139
|
const clientId = `client_${++connectionCounter}`;
|
|
92
140
|
|
|
93
141
|
const conn: ClientConnection = {
|
|
@@ -122,6 +170,36 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
122
170
|
|
|
123
171
|
// Handle incoming message
|
|
124
172
|
function handleMessage(conn: ClientConnection, data: string): void {
|
|
173
|
+
// Check message size limit
|
|
174
|
+
if (data.length > maxMessageSize) {
|
|
175
|
+
logger.warn?.(`Message too large (${data.length} bytes > ${maxMessageSize}), rejecting`);
|
|
176
|
+
conn.ws.send(
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
type: "error",
|
|
179
|
+
error: {
|
|
180
|
+
code: "MESSAGE_TOO_LARGE",
|
|
181
|
+
message: `Message exceeds ${maxMessageSize} byte limit`,
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check rate limit
|
|
189
|
+
if (isRateLimited(conn.id)) {
|
|
190
|
+
logger.warn?.(`Rate limit exceeded for client ${conn.id}`);
|
|
191
|
+
conn.ws.send(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
type: "error",
|
|
194
|
+
error: {
|
|
195
|
+
code: "RATE_LIMITED",
|
|
196
|
+
message: `Rate limit exceeded: max ${rateLimitMaxMessages} messages per ${rateLimitWindowMs}ms`,
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
125
203
|
try {
|
|
126
204
|
const message = JSON.parse(data) as ClientMessage;
|
|
127
205
|
|
|
@@ -175,6 +253,24 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
175
253
|
async function handleSubscribe(conn: ClientConnection, message: SubscribeMessage): Promise<void> {
|
|
176
254
|
const { id, operation, input, fields } = message;
|
|
177
255
|
|
|
256
|
+
// Check subscription limit (skip if replacing existing subscription)
|
|
257
|
+
if (!conn.subscriptions.has(id) && conn.subscriptions.size >= maxSubscriptionsPerClient) {
|
|
258
|
+
logger.warn?.(
|
|
259
|
+
`Subscription limit reached for client ${conn.id} (${maxSubscriptionsPerClient}), rejecting`,
|
|
260
|
+
);
|
|
261
|
+
conn.ws.send(
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
type: "error",
|
|
264
|
+
id,
|
|
265
|
+
error: {
|
|
266
|
+
code: "SUBSCRIPTION_LIMIT",
|
|
267
|
+
message: `Maximum ${maxSubscriptionsPerClient} subscriptions per client`,
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
178
274
|
// Execute query first to get data
|
|
179
275
|
let result: { data?: unknown; error?: Error };
|
|
180
276
|
try {
|
|
@@ -550,6 +646,9 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
550
646
|
// Remove connection
|
|
551
647
|
connections.delete(conn.id);
|
|
552
648
|
|
|
649
|
+
// Cleanup rate limit tracking
|
|
650
|
+
clientMessageTimestamps.delete(conn.id);
|
|
651
|
+
|
|
553
652
|
// Server handles removal (plugin hooks + state manager cleanup)
|
|
554
653
|
server.removeClient(conn.id, subscriptionCount);
|
|
555
654
|
}
|
package/src/index.ts
CHANGED
|
@@ -186,3 +186,24 @@ export {
|
|
|
186
186
|
// =============================================================================
|
|
187
187
|
|
|
188
188
|
export { coalescePatches, estimatePatchSize, OperationLog } from "./reconnect/index.js";
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// Logging
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
export {
|
|
195
|
+
createStructuredLogger,
|
|
196
|
+
type ErrorContext,
|
|
197
|
+
jsonOutput,
|
|
198
|
+
type LogContext,
|
|
199
|
+
type LogEntry,
|
|
200
|
+
type LogLevel,
|
|
201
|
+
type LogOutput,
|
|
202
|
+
type PerformanceContext,
|
|
203
|
+
prettyOutput,
|
|
204
|
+
type RequestContext,
|
|
205
|
+
type StructuredLogger,
|
|
206
|
+
type StructuredLoggerOptions,
|
|
207
|
+
toBasicLogger,
|
|
208
|
+
type WebSocketContext,
|
|
209
|
+
} from "./logging/index.js";
|