@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 ADDED
@@ -0,0 +1,65 @@
1
+ # @shaykec/bridge
2
+
3
+ Communication hub for ClaudeTeach -- HTTP server with WebSocket, SSE, and REST endpoints. Routes visual commands from the Claude Code plugin to browser clients (canvas app and Chrome extension) and user events back.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Plugin (Claude Code)
9
+ | POST /api/visual
10
+ v
11
+ Bridge Server (:3456)
12
+ |
13
+ +---> WebSocket /ws (Tier 1: Extension + Canvas)
14
+ +---> SSE /sse (Tier 2: Canvas only)
15
+ +---> REST /api/event (Tier 2: Canvas user events)
16
+ +---> Static files / (Serves canvas app)
17
+ ```
18
+
19
+ ## Endpoints
20
+
21
+ | Endpoint | Method | Purpose | Tier |
22
+ |----------|--------|---------|------|
23
+ | `/ws` | WebSocket | Bidirectional connection for extension + canvas | 1 |
24
+ | `/sse` | GET (SSE) | Server-push event stream for canvas | 2 |
25
+ | `/api/event` | POST | Canvas sends user events back | 2 |
26
+ | `/api/visual` | POST | Plugin pushes visual commands | All |
27
+ | `/api/tier` | GET | Query current connection tier | All |
28
+ | `/api/capture` | POST | Web captures from extension | 1 |
29
+ | `/api/progress` | GET | Current progress data | 2, 3 |
30
+ | `/api/events` | GET | Poll for pending browser events | 2 |
31
+ | `/health` | GET | Health check | All |
32
+
33
+ ## Key Files
34
+
35
+ - `src/server.js` -- HTTP + WebSocket server (Node.js `http` + `ws`)
36
+ - `src/protocol.js` -- Tier negotiation logic
37
+ - `src/router.js` -- Event routing between clients
38
+ - `src/templates.js` -- HTML template engine for visual types
39
+
40
+ ## Templates
41
+
42
+ Located in `templates/`. Pre-built HTML for each visual type:
43
+
44
+ - `dashboard.html` -- Progress dashboard
45
+ - `diagram-mermaid.html`, `diagram-flow.html`, `diagram-architecture.html` -- Diagrams
46
+ - `quiz-drag-order.html`, `quiz-matching.html`, `quiz-fill-blank.html`, `quiz-timed-choice.html` -- Quizzes
47
+ - `code-playground.html` -- Monaco editor with sandboxed execution
48
+ - `celebrate.html` -- Confetti and level-up animations
49
+
50
+ ## Dependencies
51
+
52
+ - `ws` -- WebSocket server
53
+ - `@shaykec/shared` -- Protocol and constants
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ # Start directly
59
+ node packages/bridge/src/server.js
60
+
61
+ # Or via CLI
62
+ claude-teach serve --port 3456
63
+ ```
64
+
65
+ See the [root README](../../README.md) for full documentation.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@shaykec/bridge",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Communication hub — HTTP + WebSocket + SSE server with template engine",
6
+ "main": "src/server.js",
7
+ "exports": {
8
+ ".": "./src/server.js",
9
+ "./src/server.js": "./src/server.js",
10
+ "./protocol": "./src/protocol.js",
11
+ "./router": "./src/router.js",
12
+ "./templates": "./src/templates.js"
13
+ },
14
+ "scripts": {
15
+ "start": "node src/server.js",
16
+ "test": "vitest run --config ../../vitest.config.js"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "ws": "^8.16.0",
24
+ "@shaykec/shared": "0.1.0"
25
+ }
26
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Tier negotiation logic for ClaudeTeach bridge server.
3
+ * Tracks connected clients and auto-detects the current tier.
4
+ */
5
+
6
+ import {
7
+ TIER_FULL,
8
+ TIER_CANVAS,
9
+ TIER_TERMINAL,
10
+ TIER_LABELS,
11
+ CLIENT_EXTENSION,
12
+ CLIENT_CANVAS,
13
+ HEARTBEAT_INTERVAL_MS,
14
+ } from '@shaykec/shared';
15
+
16
+ import {
17
+ PROTOCOL_VERSION,
18
+ createEnvelope,
19
+ MSG_SYS_TIER_CHANGE,
20
+ MSG_SYS_HEARTBEAT,
21
+ } from '@shaykec/shared';
22
+
23
+ /**
24
+ * Client connection tracker and tier negotiation.
25
+ */
26
+ export class TierManager {
27
+ constructor() {
28
+ /** @type {Map<string, { ws: object, clientType: string, connectedAt: number }>} */
29
+ this.wsClients = new Map();
30
+
31
+ /** @type {Map<string, { res: object, clientType: string, connectedAt: number }>} */
32
+ this.sseClients = new Map();
33
+
34
+ /** Current detected tier */
35
+ this.currentTier = TIER_TERMINAL;
36
+
37
+ /** Tier change listeners */
38
+ this._listeners = [];
39
+
40
+ /** Heartbeat interval handle */
41
+ this._heartbeatInterval = null;
42
+ }
43
+
44
+ /**
45
+ * Start heartbeat broadcasting.
46
+ */
47
+ startHeartbeat() {
48
+ this._heartbeatInterval = setInterval(() => {
49
+ this.broadcastHeartbeat();
50
+ }, HEARTBEAT_INTERVAL_MS);
51
+ }
52
+
53
+ /**
54
+ * Stop heartbeat broadcasting.
55
+ */
56
+ stopHeartbeat() {
57
+ if (this._heartbeatInterval) {
58
+ clearInterval(this._heartbeatInterval);
59
+ this._heartbeatInterval = null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Register a WebSocket client.
65
+ * @param {string} id - Unique client ID
66
+ * @param {object} ws - WebSocket connection
67
+ * @param {string} clientType - 'extension' or 'canvas'
68
+ */
69
+ addWsClient(id, ws, clientType) {
70
+ this.wsClients.set(id, {
71
+ ws,
72
+ clientType,
73
+ connectedAt: Date.now(),
74
+ });
75
+ this._recalculateTier();
76
+ }
77
+
78
+ /**
79
+ * Register an SSE client.
80
+ * @param {string} id - Unique client ID
81
+ * @param {object} res - HTTP response object for SSE stream
82
+ * @param {string} clientType - 'extension' or 'canvas'
83
+ */
84
+ addSseClient(id, res, clientType) {
85
+ this.sseClients.set(id, {
86
+ res,
87
+ clientType,
88
+ connectedAt: Date.now(),
89
+ });
90
+ this._recalculateTier();
91
+ }
92
+
93
+ /**
94
+ * Remove a WebSocket client.
95
+ * @param {string} id
96
+ */
97
+ removeWsClient(id) {
98
+ this.wsClients.delete(id);
99
+ this._recalculateTier();
100
+ }
101
+
102
+ /**
103
+ * Remove an SSE client.
104
+ * @param {string} id
105
+ */
106
+ removeSseClient(id) {
107
+ this.sseClients.delete(id);
108
+ this._recalculateTier();
109
+ }
110
+
111
+ /**
112
+ * Get the current tier.
113
+ * @returns {number}
114
+ */
115
+ getTier() {
116
+ return this.currentTier;
117
+ }
118
+
119
+ /**
120
+ * Get tier info for the API response.
121
+ * @returns {{ tier: number, label: string, clients: object }}
122
+ */
123
+ getTierInfo() {
124
+ const wsClientTypes = [];
125
+ for (const [, client] of this.wsClients) {
126
+ wsClientTypes.push(client.clientType);
127
+ }
128
+ const sseClientTypes = [];
129
+ for (const [, client] of this.sseClients) {
130
+ sseClientTypes.push(client.clientType);
131
+ }
132
+
133
+ return {
134
+ tier: this.currentTier,
135
+ label: TIER_LABELS[this.currentTier],
136
+ clients: {
137
+ websocket: wsClientTypes,
138
+ sse: sseClientTypes,
139
+ total: this.wsClients.size + this.sseClients.size,
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Check if an extension is connected (via WS).
146
+ * @returns {boolean}
147
+ */
148
+ hasExtension() {
149
+ for (const [, client] of this.wsClients) {
150
+ if (client.clientType === CLIENT_EXTENSION) return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /**
156
+ * Check if a canvas client is connected (via WS or SSE).
157
+ * @returns {boolean}
158
+ */
159
+ hasCanvas() {
160
+ for (const [, client] of this.wsClients) {
161
+ if (client.clientType === CLIENT_CANVAS) return true;
162
+ }
163
+ for (const [, client] of this.sseClients) {
164
+ if (client.clientType === CLIENT_CANVAS) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * Listen for tier changes.
171
+ * @param {function} fn - Callback(oldTier, newTier)
172
+ */
173
+ onTierChange(fn) {
174
+ this._listeners.push(fn);
175
+ }
176
+
177
+ /**
178
+ * Broadcast heartbeat to all connected clients.
179
+ */
180
+ broadcastHeartbeat() {
181
+ const msg = JSON.stringify(createEnvelope(MSG_SYS_HEARTBEAT, {}, 'bridge'));
182
+
183
+ for (const [, client] of this.wsClients) {
184
+ try {
185
+ if (client.ws.readyState === 1) { // WebSocket.OPEN
186
+ client.ws.send(msg);
187
+ }
188
+ } catch {
189
+ // Client may have disconnected
190
+ }
191
+ }
192
+
193
+ for (const [, client] of this.sseClients) {
194
+ try {
195
+ client.res.write(`data: ${msg}\n\n`);
196
+ } catch {
197
+ // Client may have disconnected
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Send a message to all WebSocket clients.
204
+ * @param {string} data - Serialized JSON message
205
+ */
206
+ broadcastWs(data) {
207
+ for (const [, client] of this.wsClients) {
208
+ try {
209
+ if (client.ws.readyState === 1) {
210
+ client.ws.send(data);
211
+ }
212
+ } catch {
213
+ // Ignore send errors
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Send a message to all SSE clients.
220
+ * @param {string} data - Serialized JSON message
221
+ */
222
+ broadcastSse(data) {
223
+ for (const [, client] of this.sseClients) {
224
+ try {
225
+ client.res.write(`data: ${data}\n\n`);
226
+ } catch {
227
+ // Ignore write errors
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Recalculate the current tier based on connected clients.
234
+ */
235
+ _recalculateTier() {
236
+ const oldTier = this.currentTier;
237
+ let newTier;
238
+
239
+ if (this.hasExtension() && this.hasCanvas()) {
240
+ newTier = TIER_FULL;
241
+ } else if (this.hasCanvas()) {
242
+ newTier = TIER_CANVAS;
243
+ } else {
244
+ newTier = TIER_TERMINAL;
245
+ }
246
+
247
+ this.currentTier = newTier;
248
+
249
+ if (oldTier !== newTier) {
250
+ const tierChangeMsg = JSON.stringify(
251
+ createEnvelope(MSG_SYS_TIER_CHANGE, { oldTier, newTier }, 'bridge')
252
+ );
253
+
254
+ // Notify all connected clients
255
+ this.broadcastWs(tierChangeMsg);
256
+ this.broadcastSse(tierChangeMsg);
257
+
258
+ // Notify listeners
259
+ for (const fn of this._listeners) {
260
+ try {
261
+ fn(oldTier, newTier);
262
+ } catch {
263
+ // Ignore listener errors
264
+ }
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Generate a unique client ID.
272
+ * @returns {string}
273
+ */
274
+ export function generateClientId() {
275
+ return `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
276
+ }
277
+
278
+ /**
279
+ * Validate a sys:connect handshake payload.
280
+ * @param {object} payload
281
+ * @returns {{ valid: boolean, error: string|null }}
282
+ */
283
+ export function validateHandshake(payload) {
284
+ if (!payload || typeof payload !== 'object') {
285
+ return { valid: false, error: 'Handshake payload must be an object' };
286
+ }
287
+
288
+ const { clientType, protocolVersion } = payload;
289
+
290
+ if (clientType !== CLIENT_EXTENSION && clientType !== CLIENT_CANVAS) {
291
+ return { valid: false, error: `Invalid clientType: ${clientType}. Must be "extension" or "canvas"` };
292
+ }
293
+
294
+ if (typeof protocolVersion !== 'number') {
295
+ return { valid: false, error: 'Missing protocolVersion' };
296
+ }
297
+
298
+ if (protocolVersion > PROTOCOL_VERSION) {
299
+ return { valid: false, error: `Incompatible protocol version: ${protocolVersion} (server: ${PROTOCOL_VERSION})` };
300
+ }
301
+
302
+ return { valid: true, error: null };
303
+ }