@shaykec/bridge 0.1.0
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/README.md +65 -0
- package/package.json +26 -0
- package/src/protocol.js +303 -0
- package/src/protocol.test.js +373 -0
- package/src/router.js +149 -0
- package/src/router.test.js +329 -0
- package/src/server.js +497 -0
- package/src/templates.js +155 -0
- package/src/templates.test.js +256 -0
- package/templates/celebrate.html +259 -0
- package/templates/code-playground.html +294 -0
- package/templates/dashboard.html +337 -0
- package/templates/diagram-architecture.html +449 -0
- package/templates/diagram-flow.html +382 -0
- package/templates/diagram-mermaid.html +220 -0
- package/templates/quiz-drag-order.html +375 -0
- package/templates/quiz-fill-blank.html +468 -0
- package/templates/quiz-matching.html +501 -0
- package/templates/quiz-timed-choice.html +361 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ClaudeTeach bridge protocol — TierManager, generateClientId, validateHandshake.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
+
import { TierManager, generateClientId, validateHandshake } from './protocol.js';
|
|
7
|
+
import {
|
|
8
|
+
TIER_FULL,
|
|
9
|
+
TIER_CANVAS,
|
|
10
|
+
TIER_TERMINAL,
|
|
11
|
+
CLIENT_EXTENSION,
|
|
12
|
+
CLIENT_CANVAS,
|
|
13
|
+
PROTOCOL_VERSION,
|
|
14
|
+
} from '@shaykec/shared';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// generateClientId
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('generateClientId', () => {
|
|
21
|
+
it('returns a string starting with "client-"', () => {
|
|
22
|
+
const id = generateClientId();
|
|
23
|
+
expect(typeof id).toBe('string');
|
|
24
|
+
expect(id.startsWith('client-')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('generates unique IDs across multiple calls', () => {
|
|
28
|
+
const ids = new Set(Array.from({ length: 50 }, () => generateClientId()));
|
|
29
|
+
expect(ids.size).toBe(50);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// validateHandshake
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('validateHandshake', () => {
|
|
38
|
+
it('accepts a valid extension handshake', () => {
|
|
39
|
+
const result = validateHandshake({ clientType: 'extension', protocolVersion: 1 });
|
|
40
|
+
expect(result).toEqual({ valid: true, error: null });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('accepts a valid canvas handshake', () => {
|
|
44
|
+
const result = validateHandshake({ clientType: 'canvas', protocolVersion: 1 });
|
|
45
|
+
expect(result).toEqual({ valid: true, error: null });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects missing clientType', () => {
|
|
49
|
+
const result = validateHandshake({ protocolVersion: 1 });
|
|
50
|
+
expect(result.valid).toBe(false);
|
|
51
|
+
expect(result.error).toMatch(/clientType/i);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects invalid clientType', () => {
|
|
55
|
+
const result = validateHandshake({ clientType: 'unknown', protocolVersion: 1 });
|
|
56
|
+
expect(result.valid).toBe(false);
|
|
57
|
+
expect(result.error).toMatch(/clientType/i);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects missing protocolVersion', () => {
|
|
61
|
+
const result = validateHandshake({ clientType: 'extension' });
|
|
62
|
+
expect(result.valid).toBe(false);
|
|
63
|
+
expect(result.error).toMatch(/protocolVersion/i);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects protocolVersion higher than the server supports', () => {
|
|
67
|
+
const result = validateHandshake({ clientType: 'extension', protocolVersion: PROTOCOL_VERSION + 1 });
|
|
68
|
+
expect(result.valid).toBe(false);
|
|
69
|
+
expect(result.error).toMatch(/version/i);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('accepts protocolVersion equal to current version', () => {
|
|
73
|
+
const result = validateHandshake({ clientType: 'canvas', protocolVersion: PROTOCOL_VERSION });
|
|
74
|
+
expect(result).toEqual({ valid: true, error: null });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects null payload', () => {
|
|
78
|
+
const result = validateHandshake(null);
|
|
79
|
+
expect(result.valid).toBe(false);
|
|
80
|
+
expect(result.error).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects undefined payload', () => {
|
|
84
|
+
const result = validateHandshake(undefined);
|
|
85
|
+
expect(result.valid).toBe(false);
|
|
86
|
+
expect(result.error).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects a string payload', () => {
|
|
90
|
+
const result = validateHandshake('not an object');
|
|
91
|
+
expect(result.valid).toBe(false);
|
|
92
|
+
expect(result.error).toMatch(/object/i);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// TierManager
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('TierManager', () => {
|
|
101
|
+
/** @type {TierManager} */
|
|
102
|
+
let tm;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
vi.useFakeTimers();
|
|
106
|
+
tm = new TierManager();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
tm.stopHeartbeat();
|
|
111
|
+
vi.useRealTimers();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// --- Helper to create a fake WebSocket ---
|
|
115
|
+
function fakeWs() {
|
|
116
|
+
return { readyState: 1, send: vi.fn() };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Helper to create a fake SSE response ---
|
|
120
|
+
function fakeSseRes() {
|
|
121
|
+
return { write: vi.fn() };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Tier detection ---
|
|
125
|
+
|
|
126
|
+
it('starts at TIER_TERMINAL', () => {
|
|
127
|
+
expect(tm.getTier()).toBe(TIER_TERMINAL);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('becomes TIER_CANVAS when a canvas WS client connects', () => {
|
|
131
|
+
tm.addWsClient('c1', fakeWs(), CLIENT_CANVAS);
|
|
132
|
+
expect(tm.getTier()).toBe(TIER_CANVAS);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('becomes TIER_FULL when both extension and canvas WS clients connect', () => {
|
|
136
|
+
tm.addWsClient('e1', fakeWs(), CLIENT_EXTENSION);
|
|
137
|
+
tm.addWsClient('c1', fakeWs(), CLIENT_CANVAS);
|
|
138
|
+
expect(tm.getTier()).toBe(TIER_FULL);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('stays TIER_TERMINAL when only extension connects (no canvas)', () => {
|
|
142
|
+
tm.addWsClient('e1', fakeWs(), CLIENT_EXTENSION);
|
|
143
|
+
expect(tm.getTier()).toBe(TIER_TERMINAL);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('recalculates tier downward when a client disconnects', () => {
|
|
147
|
+
tm.addWsClient('e1', fakeWs(), CLIENT_EXTENSION);
|
|
148
|
+
tm.addWsClient('c1', fakeWs(), CLIENT_CANVAS);
|
|
149
|
+
expect(tm.getTier()).toBe(TIER_FULL);
|
|
150
|
+
|
|
151
|
+
tm.removeWsClient('e1');
|
|
152
|
+
expect(tm.getTier()).toBe(TIER_CANVAS);
|
|
153
|
+
|
|
154
|
+
tm.removeWsClient('c1');
|
|
155
|
+
expect(tm.getTier()).toBe(TIER_TERMINAL);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('becomes TIER_CANVAS when a canvas SSE client connects', () => {
|
|
159
|
+
tm.addSseClient('s1', fakeSseRes(), CLIENT_CANVAS);
|
|
160
|
+
expect(tm.getTier()).toBe(TIER_CANVAS);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('becomes TIER_FULL with WS extension + SSE canvas', () => {
|
|
164
|
+
tm.addWsClient('e1', fakeWs(), CLIENT_EXTENSION);
|
|
165
|
+
tm.addSseClient('s1', fakeSseRes(), CLIENT_CANVAS);
|
|
166
|
+
expect(tm.getTier()).toBe(TIER_FULL);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('recalculates when SSE client removed', () => {
|
|
170
|
+
tm.addSseClient('s1', fakeSseRes(), CLIENT_CANVAS);
|
|
171
|
+
expect(tm.getTier()).toBe(TIER_CANVAS);
|
|
172
|
+
|
|
173
|
+
tm.removeSseClient('s1');
|
|
174
|
+
expect(tm.getTier()).toBe(TIER_TERMINAL);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- getTierInfo ---
|
|
178
|
+
|
|
179
|
+
it('getTierInfo returns correct structure', () => {
|
|
180
|
+
tm.addWsClient('e1', fakeWs(), CLIENT_EXTENSION);
|
|
181
|
+
tm.addSseClient('s1', fakeSseRes(), CLIENT_CANVAS);
|
|
182
|
+
|
|
183
|
+
const info = tm.getTierInfo();
|
|
184
|
+
expect(info.tier).toBe(TIER_FULL);
|
|
185
|
+
expect(info.label).toBe('Full (Extension + Canvas)');
|
|
186
|
+
expect(info.clients.websocket).toEqual([CLIENT_EXTENSION]);
|
|
187
|
+
expect(info.clients.sse).toEqual([CLIENT_CANVAS]);
|
|
188
|
+
expect(info.clients.total).toBe(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('getTierInfo returns empty clients when none connected', () => {
|
|
192
|
+
const info = tm.getTierInfo();
|
|
193
|
+
expect(info.tier).toBe(TIER_TERMINAL);
|
|
194
|
+
expect(info.clients.total).toBe(0);
|
|
195
|
+
expect(info.clients.websocket).toEqual([]);
|
|
196
|
+
expect(info.clients.sse).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// --- hasExtension / hasCanvas ---
|
|
200
|
+
|
|
201
|
+
it('hasExtension returns false initially', () => {
|
|
202
|
+
expect(tm.hasExtension()).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('hasExtension returns true after extension WS connects', () => {
|
|
206
|
+
tm.addWsClient('e1', fakeWs(), CLIENT_EXTENSION);
|
|
207
|
+
expect(tm.hasExtension()).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('hasCanvas returns false initially', () => {
|
|
211
|
+
expect(tm.hasCanvas()).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('hasCanvas returns true after canvas WS connects', () => {
|
|
215
|
+
tm.addWsClient('c1', fakeWs(), CLIENT_CANVAS);
|
|
216
|
+
expect(tm.hasCanvas()).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('hasCanvas returns true after canvas SSE connects', () => {
|
|
220
|
+
tm.addSseClient('s1', fakeSseRes(), CLIENT_CANVAS);
|
|
221
|
+
expect(tm.hasCanvas()).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// --- onTierChange ---
|
|
225
|
+
|
|
226
|
+
it('onTierChange fires when tier changes', () => {
|
|
227
|
+
const listener = vi.fn();
|
|
228
|
+
tm.onTierChange(listener);
|
|
229
|
+
|
|
230
|
+
tm.addWsClient('c1', fakeWs(), CLIENT_CANVAS);
|
|
231
|
+
expect(listener).toHaveBeenCalledWith(TIER_TERMINAL, TIER_CANVAS);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('onTierChange does not fire when tier stays the same', () => {
|
|
235
|
+
tm.addWsClient('c1', fakeWs(), CLIENT_CANVAS);
|
|
236
|
+
|
|
237
|
+
const listener = vi.fn();
|
|
238
|
+
tm.onTierChange(listener);
|
|
239
|
+
|
|
240
|
+
// Adding a second canvas client should not change the tier
|
|
241
|
+
tm.addWsClient('c2', fakeWs(), CLIENT_CANVAS);
|
|
242
|
+
expect(listener).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('onTierChange fires multiple times across tier transitions', () => {
|
|
246
|
+
const listener = vi.fn();
|
|
247
|
+
tm.onTierChange(listener);
|
|
248
|
+
|
|
249
|
+
// TERMINAL -> CANVAS
|
|
250
|
+
tm.addWsClient('c1', fakeWs(), CLIENT_CANVAS);
|
|
251
|
+
// CANVAS -> FULL
|
|
252
|
+
tm.addWsClient('e1', fakeWs(), CLIENT_EXTENSION);
|
|
253
|
+
// FULL -> CANVAS
|
|
254
|
+
tm.removeWsClient('e1');
|
|
255
|
+
|
|
256
|
+
expect(listener).toHaveBeenCalledTimes(3);
|
|
257
|
+
expect(listener).toHaveBeenNthCalledWith(1, TIER_TERMINAL, TIER_CANVAS);
|
|
258
|
+
expect(listener).toHaveBeenNthCalledWith(2, TIER_CANVAS, TIER_FULL);
|
|
259
|
+
expect(listener).toHaveBeenNthCalledWith(3, TIER_FULL, TIER_CANVAS);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('tier change broadcasts to WS and SSE clients', () => {
|
|
263
|
+
const ws = fakeWs();
|
|
264
|
+
const sseRes = fakeSseRes();
|
|
265
|
+
tm.addSseClient('s1', sseRes, CLIENT_CANVAS);
|
|
266
|
+
|
|
267
|
+
// Clear calls from the initial addSseClient tier change broadcast
|
|
268
|
+
ws.send.mockClear();
|
|
269
|
+
sseRes.write.mockClear();
|
|
270
|
+
|
|
271
|
+
// Now add an extension WS client, which triggers another tier change
|
|
272
|
+
tm.addWsClient('e1', ws, CLIENT_EXTENSION);
|
|
273
|
+
|
|
274
|
+
// The WS client should have received the tier change message
|
|
275
|
+
expect(ws.send).toHaveBeenCalled();
|
|
276
|
+
// The SSE client should also have received the tier change message
|
|
277
|
+
expect(sseRes.write).toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// --- broadcastWs / broadcastSse with no clients ---
|
|
281
|
+
|
|
282
|
+
it('broadcastWs does not throw with no clients', () => {
|
|
283
|
+
expect(() => tm.broadcastWs('test')).not.toThrow();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('broadcastSse does not throw with no clients', () => {
|
|
287
|
+
expect(() => tm.broadcastSse('test')).not.toThrow();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('broadcastWs sends to all connected WS clients', () => {
|
|
291
|
+
const ws1 = fakeWs();
|
|
292
|
+
const ws2 = fakeWs();
|
|
293
|
+
tm.addWsClient('c1', ws1, CLIENT_CANVAS);
|
|
294
|
+
tm.addWsClient('c2', ws2, CLIENT_CANVAS);
|
|
295
|
+
|
|
296
|
+
tm.broadcastWs('hello');
|
|
297
|
+
expect(ws1.send).toHaveBeenCalledWith('hello');
|
|
298
|
+
expect(ws2.send).toHaveBeenCalledWith('hello');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('broadcastWs skips clients that are not OPEN (readyState !== 1)', () => {
|
|
302
|
+
const ws1 = { readyState: 1, send: vi.fn() };
|
|
303
|
+
const ws2 = { readyState: 3, send: vi.fn() }; // CLOSED
|
|
304
|
+
tm.addWsClient('c1', ws1, CLIENT_CANVAS);
|
|
305
|
+
tm.addWsClient('c2', ws2, CLIENT_CANVAS);
|
|
306
|
+
|
|
307
|
+
tm.broadcastWs('hello');
|
|
308
|
+
expect(ws1.send).toHaveBeenCalledWith('hello');
|
|
309
|
+
expect(ws2.send).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('broadcastSse sends to all SSE clients', () => {
|
|
313
|
+
const r1 = fakeSseRes();
|
|
314
|
+
const r2 = fakeSseRes();
|
|
315
|
+
tm.addSseClient('s1', r1, CLIENT_CANVAS);
|
|
316
|
+
tm.addSseClient('s2', r2, CLIENT_CANVAS);
|
|
317
|
+
|
|
318
|
+
tm.broadcastSse('hello');
|
|
319
|
+
expect(r1.write).toHaveBeenCalledWith('data: hello\n\n');
|
|
320
|
+
expect(r2.write).toHaveBeenCalledWith('data: hello\n\n');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('broadcastWs swallows send errors', () => {
|
|
324
|
+
const ws = { readyState: 1, send: vi.fn(() => { throw new Error('broken'); }) };
|
|
325
|
+
tm.addWsClient('c1', ws, CLIENT_CANVAS);
|
|
326
|
+
expect(() => tm.broadcastWs('data')).not.toThrow();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('broadcastSse swallows write errors', () => {
|
|
330
|
+
const res = { write: vi.fn(() => { throw new Error('broken'); }) };
|
|
331
|
+
tm.addSseClient('s1', res, CLIENT_CANVAS);
|
|
332
|
+
expect(() => tm.broadcastSse('data')).not.toThrow();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// --- Heartbeat ---
|
|
336
|
+
|
|
337
|
+
it('startHeartbeat / stopHeartbeat do not throw', () => {
|
|
338
|
+
expect(() => tm.startHeartbeat()).not.toThrow();
|
|
339
|
+
expect(() => tm.stopHeartbeat()).not.toThrow();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('heartbeat broadcasts to connected clients at interval', () => {
|
|
343
|
+
const ws = fakeWs();
|
|
344
|
+
tm.addWsClient('c1', ws, CLIENT_CANVAS);
|
|
345
|
+
ws.send.mockClear();
|
|
346
|
+
|
|
347
|
+
tm.startHeartbeat();
|
|
348
|
+
|
|
349
|
+
// Advance past one heartbeat interval (30 000 ms)
|
|
350
|
+
vi.advanceTimersByTime(30_000);
|
|
351
|
+
expect(ws.send).toHaveBeenCalled();
|
|
352
|
+
|
|
353
|
+
const msg = JSON.parse(ws.send.mock.calls[0][0]);
|
|
354
|
+
expect(msg.type).toBe('sys:heartbeat');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('stopHeartbeat stops broadcasting', () => {
|
|
358
|
+
const ws = fakeWs();
|
|
359
|
+
tm.addWsClient('c1', ws, CLIENT_CANVAS);
|
|
360
|
+
ws.send.mockClear();
|
|
361
|
+
|
|
362
|
+
tm.startHeartbeat();
|
|
363
|
+
tm.stopHeartbeat();
|
|
364
|
+
|
|
365
|
+
vi.advanceTimersByTime(60_000);
|
|
366
|
+
// No heartbeat should have been sent after stopping
|
|
367
|
+
expect(ws.send).not.toHaveBeenCalled();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('calling stopHeartbeat without start does not throw', () => {
|
|
371
|
+
expect(() => tm.stopHeartbeat()).not.toThrow();
|
|
372
|
+
});
|
|
373
|
+
});
|
package/src/router.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event routing for ClaudeTeach bridge server.
|
|
3
|
+
* Visual commands from plugin fan out to the best connected client.
|
|
4
|
+
* User events from clients are stored for the plugin to poll.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseEnvelope,
|
|
9
|
+
isTypeInCategory,
|
|
10
|
+
createEnvelope,
|
|
11
|
+
MSG_SYS_CONNECT,
|
|
12
|
+
MSG_SYS_DISCONNECT,
|
|
13
|
+
MSG_SYS_HEARTBEAT,
|
|
14
|
+
} from '@shaykec/shared';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Event router — manages message flow between plugin and clients.
|
|
18
|
+
*/
|
|
19
|
+
export class EventRouter {
|
|
20
|
+
/**
|
|
21
|
+
* @param {import('./protocol.js').TierManager} tierManager
|
|
22
|
+
*/
|
|
23
|
+
constructor(tierManager) {
|
|
24
|
+
this.tierManager = tierManager;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Queue of user events waiting to be polled by the plugin.
|
|
28
|
+
* @type {Array<object>}
|
|
29
|
+
*/
|
|
30
|
+
this.eventQueue = [];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Maximum event queue size before oldest events are dropped.
|
|
34
|
+
*/
|
|
35
|
+
this.maxQueueSize = 1000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Route a visual command from the plugin to connected clients.
|
|
40
|
+
* Fans out to all connected canvas/extension clients.
|
|
41
|
+
* @param {object} envelope - Parsed message envelope
|
|
42
|
+
*/
|
|
43
|
+
routeVisualCommand(envelope) {
|
|
44
|
+
const data = JSON.stringify(envelope);
|
|
45
|
+
|
|
46
|
+
// Send to all WebSocket clients
|
|
47
|
+
this.tierManager.broadcastWs(data);
|
|
48
|
+
|
|
49
|
+
// Send to all SSE clients
|
|
50
|
+
this.tierManager.broadcastSse(data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handle an incoming message from a WebSocket client.
|
|
55
|
+
* System messages are handled internally; user events are queued.
|
|
56
|
+
* @param {string} rawData - Raw WebSocket message
|
|
57
|
+
* @param {string} clientId - Client ID
|
|
58
|
+
* @returns {{ handled: boolean, error: string|null }}
|
|
59
|
+
*/
|
|
60
|
+
handleWsMessage(rawData, clientId) {
|
|
61
|
+
const { valid, envelope, error } = parseEnvelope(rawData);
|
|
62
|
+
|
|
63
|
+
if (!valid) {
|
|
64
|
+
return { handled: false, error };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// System messages are handled by the server, not queued
|
|
68
|
+
if (envelope.type === MSG_SYS_HEARTBEAT) {
|
|
69
|
+
return { handled: true, error: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (envelope.type === MSG_SYS_CONNECT || envelope.type === MSG_SYS_DISCONNECT) {
|
|
73
|
+
// These are handled by the server's connection logic
|
|
74
|
+
return { handled: true, error: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// User events and capture events go to the queue
|
|
78
|
+
if (isTypeInCategory(envelope.type, 'event') ||
|
|
79
|
+
isTypeInCategory(envelope.type, 'capture') ||
|
|
80
|
+
isTypeInCategory(envelope.type, 'context')) {
|
|
81
|
+
this._enqueueEvent(envelope);
|
|
82
|
+
return { handled: true, error: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Canvas commands from other sources — fan out
|
|
86
|
+
if (isTypeInCategory(envelope.type, 'canvas')) {
|
|
87
|
+
this.routeVisualCommand(envelope);
|
|
88
|
+
return { handled: true, error: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Unknown type — silently accept but don't route
|
|
92
|
+
return { handled: true, error: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Handle an incoming event from the REST API (POST /api/event).
|
|
97
|
+
* @param {object} body - Parsed request body (should be an envelope)
|
|
98
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
99
|
+
*/
|
|
100
|
+
handleRestEvent(body) {
|
|
101
|
+
const { valid, envelope, error } = parseEnvelope(body);
|
|
102
|
+
|
|
103
|
+
if (!valid) {
|
|
104
|
+
return { ok: false, error };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this._enqueueEvent(envelope);
|
|
108
|
+
return { ok: true, error: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Poll queued events. Returns and clears the queue.
|
|
113
|
+
* @returns {Array<object>}
|
|
114
|
+
*/
|
|
115
|
+
pollEvents() {
|
|
116
|
+
const events = this.eventQueue.slice();
|
|
117
|
+
this.eventQueue = [];
|
|
118
|
+
return events;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Peek at queued events without clearing.
|
|
123
|
+
* @returns {Array<object>}
|
|
124
|
+
*/
|
|
125
|
+
peekEvents() {
|
|
126
|
+
return this.eventQueue.slice();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get the number of queued events.
|
|
131
|
+
* @returns {number}
|
|
132
|
+
*/
|
|
133
|
+
getQueueSize() {
|
|
134
|
+
return this.eventQueue.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Add an event to the queue, trimming oldest if at capacity.
|
|
139
|
+
* @param {object} envelope
|
|
140
|
+
*/
|
|
141
|
+
_enqueueEvent(envelope) {
|
|
142
|
+
if (this.eventQueue.length >= this.maxQueueSize) {
|
|
143
|
+
// Drop oldest 10% to avoid constant trimming
|
|
144
|
+
const dropCount = Math.floor(this.maxQueueSize * 0.1);
|
|
145
|
+
this.eventQueue = this.eventQueue.slice(dropCount);
|
|
146
|
+
}
|
|
147
|
+
this.eventQueue.push(envelope);
|
|
148
|
+
}
|
|
149
|
+
}
|