@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.
@@ -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
+ }