@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
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
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -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
|
+
}
|