@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/src/server.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeTeach Bridge Server — WebSocket + SSE + REST communication hub.
|
|
3
|
+
* Upgraded from packages/core/src/bridge-server.js.
|
|
4
|
+
*
|
|
5
|
+
* Endpoints:
|
|
6
|
+
* /ws — WebSocket upgrade (bidirectional)
|
|
7
|
+
* /sse — SSE stream (server-push for canvas-only mode)
|
|
8
|
+
* /api/event — POST, receives user events from canvas
|
|
9
|
+
* /api/tier — GET, returns current tier
|
|
10
|
+
* /api/capture — POST, receives web captures
|
|
11
|
+
* /api/progress — GET, returns progress data
|
|
12
|
+
* /health — GET, health check
|
|
13
|
+
* / — Serves canvas app static files (if available)
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import { startServer } from '@shaykec/bridge';
|
|
17
|
+
* startServer({ port: 3456 });
|
|
18
|
+
*
|
|
19
|
+
* Standalone:
|
|
20
|
+
* node packages/bridge/src/server.js
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createServer } from 'http';
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
25
|
+
import { join, dirname, extname } from 'path';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import { WebSocketServer } from 'ws';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
PROTOCOL_VERSION,
|
|
31
|
+
parseEnvelope,
|
|
32
|
+
createEnvelope,
|
|
33
|
+
serializeEnvelope,
|
|
34
|
+
MSG_SYS_CONNECT,
|
|
35
|
+
MSG_SYS_DISCONNECT,
|
|
36
|
+
DEFAULT_PORT,
|
|
37
|
+
INBOX_DIR,
|
|
38
|
+
} from '@shaykec/shared';
|
|
39
|
+
|
|
40
|
+
import { TierManager, generateClientId, validateHandshake } from './protocol.js';
|
|
41
|
+
import { EventRouter } from './router.js';
|
|
42
|
+
import { renderTemplate, listTemplates } from './templates.js';
|
|
43
|
+
|
|
44
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
45
|
+
const __dirname = dirname(__filename);
|
|
46
|
+
|
|
47
|
+
/** Path to canvas app static files (built by packages/canvas) */
|
|
48
|
+
const CANVAS_DIST = join(__dirname, '..', '..', 'canvas', 'dist');
|
|
49
|
+
|
|
50
|
+
/** MIME types for static file serving */
|
|
51
|
+
const MIME_TYPES = {
|
|
52
|
+
'.html': 'text/html',
|
|
53
|
+
'.js': 'application/javascript',
|
|
54
|
+
'.css': 'text/css',
|
|
55
|
+
'.json': 'application/json',
|
|
56
|
+
'.png': 'image/png',
|
|
57
|
+
'.jpg': 'image/jpeg',
|
|
58
|
+
'.svg': 'image/svg+xml',
|
|
59
|
+
'.ico': 'image/x-icon',
|
|
60
|
+
'.woff': 'font/woff',
|
|
61
|
+
'.woff2': 'font/woff2',
|
|
62
|
+
'.ttf': 'font/ttf',
|
|
63
|
+
'.map': 'application/json',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Start the ClaudeTeach bridge server.
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {number} [options.port=3456] - Port number
|
|
70
|
+
* @param {function} [options.onReady] - Callback when server is listening
|
|
71
|
+
* @param {function} [options.onTierChange] - Callback(oldTier, newTier) on tier changes
|
|
72
|
+
* @param {object} [options.progressProvider] - Object with getProgress() method
|
|
73
|
+
* @returns {{ server: object, tierManager: TierManager, router: EventRouter, close: function }}
|
|
74
|
+
*/
|
|
75
|
+
export function startServer(options = {}) {
|
|
76
|
+
const port = options.port || DEFAULT_PORT;
|
|
77
|
+
const tierManager = new TierManager();
|
|
78
|
+
const router = new EventRouter(tierManager);
|
|
79
|
+
|
|
80
|
+
if (options.onTierChange) {
|
|
81
|
+
tierManager.onTierChange(options.onTierChange);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Ensure inbox directory exists
|
|
85
|
+
ensureDir(INBOX_DIR);
|
|
86
|
+
|
|
87
|
+
// Create HTTP server
|
|
88
|
+
const server = createServer((req, res) => {
|
|
89
|
+
handleRequest(req, res, tierManager, router, options);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Create WebSocket server on the same HTTP server
|
|
93
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
94
|
+
|
|
95
|
+
wss.on('connection', (ws, req) => {
|
|
96
|
+
const clientId = generateClientId();
|
|
97
|
+
let clientType = null;
|
|
98
|
+
let handshakeComplete = false;
|
|
99
|
+
|
|
100
|
+
// Wait for sys:connect handshake
|
|
101
|
+
const handshakeTimeout = setTimeout(() => {
|
|
102
|
+
if (!handshakeComplete) {
|
|
103
|
+
ws.close(4001, 'Handshake timeout');
|
|
104
|
+
}
|
|
105
|
+
}, 10000);
|
|
106
|
+
|
|
107
|
+
ws.on('message', (rawData) => {
|
|
108
|
+
const data = rawData.toString();
|
|
109
|
+
|
|
110
|
+
if (!handshakeComplete) {
|
|
111
|
+
// First message must be sys:connect
|
|
112
|
+
const { valid, envelope, error } = parseEnvelope(data);
|
|
113
|
+
if (!valid || envelope.type !== MSG_SYS_CONNECT) {
|
|
114
|
+
ws.close(4002, 'First message must be sys:connect');
|
|
115
|
+
clearTimeout(handshakeTimeout);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const validation = validateHandshake(envelope.payload);
|
|
120
|
+
if (!validation.valid) {
|
|
121
|
+
ws.close(4003, validation.error);
|
|
122
|
+
clearTimeout(handshakeTimeout);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clientType = envelope.payload.clientType;
|
|
127
|
+
handshakeComplete = true;
|
|
128
|
+
clearTimeout(handshakeTimeout);
|
|
129
|
+
|
|
130
|
+
tierManager.addWsClient(clientId, ws, clientType);
|
|
131
|
+
|
|
132
|
+
// Send connect acknowledgment
|
|
133
|
+
ws.send(serializeEnvelope(MSG_SYS_CONNECT, {
|
|
134
|
+
clientId,
|
|
135
|
+
serverVersion: PROTOCOL_VERSION,
|
|
136
|
+
tier: tierManager.getTier(),
|
|
137
|
+
}, 'bridge'));
|
|
138
|
+
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// After handshake, route messages through the router
|
|
143
|
+
const result = router.handleWsMessage(data, clientId);
|
|
144
|
+
if (!result.handled && result.error) {
|
|
145
|
+
ws.send(JSON.stringify({ error: result.error }));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
ws.on('close', () => {
|
|
150
|
+
clearTimeout(handshakeTimeout);
|
|
151
|
+
if (clientType) {
|
|
152
|
+
tierManager.removeWsClient(clientId);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
ws.on('error', () => {
|
|
157
|
+
clearTimeout(handshakeTimeout);
|
|
158
|
+
if (clientType) {
|
|
159
|
+
tierManager.removeWsClient(clientId);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Start heartbeat
|
|
165
|
+
tierManager.startHeartbeat();
|
|
166
|
+
|
|
167
|
+
// Start listening
|
|
168
|
+
server.listen(port, () => {
|
|
169
|
+
console.log(`ClaudeTeach bridge server running on http://localhost:${port}`);
|
|
170
|
+
console.log(` WebSocket /ws — bidirectional (extension + canvas)`);
|
|
171
|
+
console.log(` SSE /sse — server-push (canvas-only)`);
|
|
172
|
+
console.log(` POST /api/event — receive user events`);
|
|
173
|
+
console.log(` GET /api/tier — current tier info`);
|
|
174
|
+
console.log(` POST /api/capture — receive web captures`);
|
|
175
|
+
console.log(` GET /api/progress — progress data`);
|
|
176
|
+
console.log(` GET /health — health check`);
|
|
177
|
+
if (existsSync(CANVAS_DIST)) {
|
|
178
|
+
console.log(` Static / — canvas app from ${CANVAS_DIST}`);
|
|
179
|
+
}
|
|
180
|
+
console.log(`\nPress Ctrl+C to stop.`);
|
|
181
|
+
|
|
182
|
+
if (options.onReady) {
|
|
183
|
+
options.onReady({ port, tierManager, router });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const close = () => {
|
|
188
|
+
tierManager.stopHeartbeat();
|
|
189
|
+
wss.close();
|
|
190
|
+
server.close();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return { server, wss, tierManager, router, close };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Handle HTTP requests.
|
|
198
|
+
*/
|
|
199
|
+
function handleRequest(req, res, tierManager, router, options) {
|
|
200
|
+
// CORS headers
|
|
201
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
202
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
203
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
204
|
+
|
|
205
|
+
if (req.method === 'OPTIONS') {
|
|
206
|
+
res.writeHead(204);
|
|
207
|
+
res.end();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
212
|
+
const pathname = url.pathname;
|
|
213
|
+
|
|
214
|
+
// --- SSE endpoint ---
|
|
215
|
+
if (req.method === 'GET' && pathname === '/sse') {
|
|
216
|
+
handleSse(req, res, tierManager);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- REST API endpoints ---
|
|
221
|
+
if (req.method === 'POST' && pathname === '/api/event') {
|
|
222
|
+
handleApiEvent(req, res, router);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (req.method === 'GET' && pathname === '/api/tier') {
|
|
227
|
+
handleApiTier(res, tierManager);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (req.method === 'POST' && pathname === '/api/capture') {
|
|
232
|
+
handleApiCapture(req, res);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (req.method === 'GET' && pathname === '/api/progress') {
|
|
237
|
+
handleApiProgress(res, options);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (req.method === 'GET' && pathname === '/api/events') {
|
|
242
|
+
handleApiPollEvents(res, router);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (req.method === 'GET' && pathname === '/api/templates') {
|
|
247
|
+
handleApiTemplates(res);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (req.method === 'POST' && pathname === '/api/visual') {
|
|
252
|
+
handleApiVisual(req, res, router);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Health check ---
|
|
257
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
258
|
+
sendJson(res, 200, {
|
|
259
|
+
status: 'ok',
|
|
260
|
+
version: '0.1.0',
|
|
261
|
+
tier: tierManager.getTierInfo(),
|
|
262
|
+
uptime: process.uptime(),
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Static file serving (canvas app) ---
|
|
268
|
+
if (req.method === 'GET') {
|
|
269
|
+
if (serveStatic(res, pathname)) return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 404
|
|
273
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
274
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Handle SSE connections.
|
|
279
|
+
*/
|
|
280
|
+
function handleSse(req, res, tierManager) {
|
|
281
|
+
const clientId = generateClientId();
|
|
282
|
+
|
|
283
|
+
res.writeHead(200, {
|
|
284
|
+
'Content-Type': 'text/event-stream',
|
|
285
|
+
'Cache-Control': 'no-cache',
|
|
286
|
+
'Connection': 'keep-alive',
|
|
287
|
+
'Access-Control-Allow-Origin': '*',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Send initial connection event
|
|
291
|
+
const connectMsg = JSON.stringify(createEnvelope(MSG_SYS_CONNECT, {
|
|
292
|
+
clientId,
|
|
293
|
+
serverVersion: PROTOCOL_VERSION,
|
|
294
|
+
tier: tierManager.getTier(),
|
|
295
|
+
}, 'bridge'));
|
|
296
|
+
res.write(`data: ${connectMsg}\n\n`);
|
|
297
|
+
|
|
298
|
+
// Register as canvas SSE client
|
|
299
|
+
tierManager.addSseClient(clientId, res, 'canvas');
|
|
300
|
+
|
|
301
|
+
// Handle disconnect
|
|
302
|
+
req.on('close', () => {
|
|
303
|
+
tierManager.removeSseClient(clientId);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
req.on('error', () => {
|
|
307
|
+
tierManager.removeSseClient(clientId);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* POST /api/event — receive user events from canvas.
|
|
313
|
+
*/
|
|
314
|
+
function handleApiEvent(req, res, router) {
|
|
315
|
+
readBody(req, (err, body) => {
|
|
316
|
+
if (err) {
|
|
317
|
+
sendJson(res, 400, { error: 'Invalid request body' });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const result = router.handleRestEvent(body);
|
|
321
|
+
if (result.ok) {
|
|
322
|
+
sendJson(res, 200, { ok: true });
|
|
323
|
+
} else {
|
|
324
|
+
sendJson(res, 400, { error: result.error });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* GET /api/tier — return current tier info.
|
|
331
|
+
*/
|
|
332
|
+
function handleApiTier(res, tierManager) {
|
|
333
|
+
sendJson(res, 200, tierManager.getTierInfo());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* POST /api/capture — receive web captures, write to .teach/inbox/.
|
|
338
|
+
*/
|
|
339
|
+
function handleApiCapture(req, res) {
|
|
340
|
+
readBody(req, (err, data) => {
|
|
341
|
+
if (err) {
|
|
342
|
+
sendJson(res, 400, { error: 'Invalid request body' });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
ensureDir(INBOX_DIR);
|
|
348
|
+
const title = data.title || data.pageTitle || 'untitled';
|
|
349
|
+
const filename = `${Date.now()}-${slugify(title)}.json`;
|
|
350
|
+
const filePath = join(INBOX_DIR, filename);
|
|
351
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
352
|
+
sendJson(res, 200, { ok: true, saved: filename });
|
|
353
|
+
} catch (writeErr) {
|
|
354
|
+
sendJson(res, 500, { error: writeErr.message });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* GET /api/progress — return progress data.
|
|
361
|
+
*/
|
|
362
|
+
function handleApiProgress(res, options) {
|
|
363
|
+
if (options.progressProvider && typeof options.progressProvider.getProgress === 'function') {
|
|
364
|
+
const progress = options.progressProvider.getProgress();
|
|
365
|
+
sendJson(res, 200, progress);
|
|
366
|
+
} else {
|
|
367
|
+
sendJson(res, 200, { user: { xp: 0 }, modules: {} });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* GET /api/events — poll queued user events.
|
|
373
|
+
*/
|
|
374
|
+
function handleApiPollEvents(res, router) {
|
|
375
|
+
const events = router.pollEvents();
|
|
376
|
+
sendJson(res, 200, { events, count: events.length });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* GET /api/templates — list available templates.
|
|
381
|
+
*/
|
|
382
|
+
function handleApiTemplates(res) {
|
|
383
|
+
sendJson(res, 200, { templates: listTemplates() });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* POST /api/visual — push a visual command to connected clients.
|
|
388
|
+
* Used by the plugin to send canvas:* commands.
|
|
389
|
+
*/
|
|
390
|
+
function handleApiVisual(req, res, router) {
|
|
391
|
+
readBody(req, (err, body) => {
|
|
392
|
+
if (err) {
|
|
393
|
+
sendJson(res, 400, { error: 'Invalid request body' });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const { valid, envelope, error } = parseEnvelope(body);
|
|
398
|
+
if (!valid) {
|
|
399
|
+
sendJson(res, 400, { error });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check if we should render a template
|
|
404
|
+
if (envelope.payload && envelope.payload.template) {
|
|
405
|
+
const rendered = renderTemplate(envelope.payload.template, envelope.payload.data || {});
|
|
406
|
+
if (rendered) {
|
|
407
|
+
envelope.payload.renderedHtml = rendered;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
router.routeVisualCommand(envelope);
|
|
412
|
+
sendJson(res, 200, { ok: true, tier: router.tierManager.getTier() });
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Serve static files from the canvas dist directory.
|
|
418
|
+
* @param {object} res
|
|
419
|
+
* @param {string} pathname
|
|
420
|
+
* @returns {boolean} true if file was served
|
|
421
|
+
*/
|
|
422
|
+
function serveStatic(res, pathname) {
|
|
423
|
+
if (!existsSync(CANVAS_DIST)) return false;
|
|
424
|
+
|
|
425
|
+
// Map / to /index.html
|
|
426
|
+
let filePath = join(CANVAS_DIST, pathname === '/' ? 'index.html' : pathname);
|
|
427
|
+
|
|
428
|
+
// Security: prevent directory traversal
|
|
429
|
+
if (!filePath.startsWith(CANVAS_DIST)) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!existsSync(filePath)) {
|
|
434
|
+
// SPA fallback: serve index.html for non-file paths
|
|
435
|
+
const indexPath = join(CANVAS_DIST, 'index.html');
|
|
436
|
+
if (existsSync(indexPath) && !extname(pathname)) {
|
|
437
|
+
filePath = indexPath;
|
|
438
|
+
} else {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const stat = statSync(filePath);
|
|
445
|
+
if (stat.isDirectory()) {
|
|
446
|
+
filePath = join(filePath, 'index.html');
|
|
447
|
+
if (!existsSync(filePath)) return false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const ext = extname(filePath);
|
|
451
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
452
|
+
const content = readFileSync(filePath);
|
|
453
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
454
|
+
res.end(content);
|
|
455
|
+
return true;
|
|
456
|
+
} catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// --- Helpers ---
|
|
462
|
+
|
|
463
|
+
function readBody(req, callback) {
|
|
464
|
+
let body = '';
|
|
465
|
+
req.on('data', chunk => { body += chunk; });
|
|
466
|
+
req.on('end', () => {
|
|
467
|
+
try {
|
|
468
|
+
callback(null, JSON.parse(body));
|
|
469
|
+
} catch {
|
|
470
|
+
callback(new Error('Invalid JSON'));
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
req.on('error', (err) => {
|
|
474
|
+
callback(err);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function sendJson(res, status, data) {
|
|
479
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
480
|
+
res.end(JSON.stringify(data));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function slugify(text) {
|
|
484
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 50);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function ensureDir(dirPath) {
|
|
488
|
+
if (!existsSync(dirPath)) {
|
|
489
|
+
mkdirSync(dirPath, { recursive: true });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- Standalone execution ---
|
|
494
|
+
if (process.argv[1] && process.argv[1].includes('bridge/src/server.js')) {
|
|
495
|
+
const port = parseInt(process.env.PORT || process.argv[2] || DEFAULT_PORT, 10);
|
|
496
|
+
startServer({ port });
|
|
497
|
+
}
|
package/src/templates.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template engine for ClaudeTeach bridge server.
|
|
3
|
+
* Loads HTML templates from the templates/ directory and fills
|
|
4
|
+
* placeholder slots with data from visual command payloads.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
const TEMPLATES_DIR = join(__dirname, '..', 'templates');
|
|
14
|
+
|
|
15
|
+
/** In-memory template cache */
|
|
16
|
+
const templateCache = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load a template by name. Cached after first read.
|
|
20
|
+
* @param {string} name - Template filename (without or with .html extension)
|
|
21
|
+
* @returns {string|null} Template HTML or null if not found
|
|
22
|
+
*/
|
|
23
|
+
export function loadTemplate(name) {
|
|
24
|
+
const filename = name.endsWith('.html') ? name : `${name}.html`;
|
|
25
|
+
|
|
26
|
+
if (templateCache.has(filename)) {
|
|
27
|
+
return templateCache.get(filename);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const filePath = join(TEMPLATES_DIR, filename);
|
|
31
|
+
if (!existsSync(filePath)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
36
|
+
templateCache.set(filename, content);
|
|
37
|
+
return content;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render a template with data. Replaces {{key}} placeholders with values.
|
|
42
|
+
* Supports nested keys via dot notation: {{progress.xp}}.
|
|
43
|
+
* Also supports {{#items}}...{{/items}} for array iteration (simple blocks).
|
|
44
|
+
* @param {string} templateName - Template name
|
|
45
|
+
* @param {object} data - Data to fill placeholders
|
|
46
|
+
* @returns {string|null} Rendered HTML or null if template not found
|
|
47
|
+
*/
|
|
48
|
+
export function renderTemplate(templateName, data) {
|
|
49
|
+
const template = loadTemplate(templateName);
|
|
50
|
+
if (!template) return null;
|
|
51
|
+
|
|
52
|
+
return fillTemplate(template, data);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fill a template string with data.
|
|
57
|
+
* @param {string} template - Template HTML string
|
|
58
|
+
* @param {object} data - Data object
|
|
59
|
+
* @returns {string} Rendered HTML
|
|
60
|
+
*/
|
|
61
|
+
export function fillTemplate(template, data) {
|
|
62
|
+
let result = template;
|
|
63
|
+
|
|
64
|
+
// Handle array blocks: {{#items}}...{{/items}}
|
|
65
|
+
result = result.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (match, key, block) => {
|
|
66
|
+
const arr = resolveValue(data, key);
|
|
67
|
+
if (!Array.isArray(arr)) return '';
|
|
68
|
+
|
|
69
|
+
return arr.map((item, index) => {
|
|
70
|
+
// Within a block, {{.}} refers to the item itself (for primitives)
|
|
71
|
+
// {{.property}} refers to a property of the item (for objects)
|
|
72
|
+
let rendered = block;
|
|
73
|
+
|
|
74
|
+
if (typeof item === 'object' && item !== null) {
|
|
75
|
+
// Replace {{.key}} with item properties
|
|
76
|
+
rendered = rendered.replace(/\{\{\.(\w+)\}\}/g, (_, prop) => {
|
|
77
|
+
return escapeHtml(String(item[prop] ?? ''));
|
|
78
|
+
});
|
|
79
|
+
// Replace {{.}} with JSON of the object
|
|
80
|
+
rendered = rendered.replace(/\{\{\.\}\}/g, escapeHtml(JSON.stringify(item)));
|
|
81
|
+
} else {
|
|
82
|
+
// Primitive — replace {{.}} with the value
|
|
83
|
+
rendered = rendered.replace(/\{\{\.\}\}/g, escapeHtml(String(item)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Replace {{@index}} with the iteration index
|
|
87
|
+
rendered = rendered.replace(/\{\{@index\}\}/g, String(index));
|
|
88
|
+
|
|
89
|
+
return rendered;
|
|
90
|
+
}).join('');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Handle JSON injection: {{{json:key}}} (triple braces = unescaped JSON)
|
|
94
|
+
result = result.replace(/\{\{\{json:([^}]+)\}\}\}/g, (_, key) => {
|
|
95
|
+
const value = resolveValue(data, key.trim());
|
|
96
|
+
return JSON.stringify(value ?? null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Handle simple placeholders: {{key}} and {{key.nested}}
|
|
100
|
+
result = result.replace(/\{\{([^#/][^}]*)\}\}/g, (match, key) => {
|
|
101
|
+
const value = resolveValue(data, key.trim());
|
|
102
|
+
if (value === undefined || value === null) return '';
|
|
103
|
+
return escapeHtml(String(value));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a dot-notation key against a data object.
|
|
111
|
+
* @param {object} data
|
|
112
|
+
* @param {string} key - e.g. 'progress.xp' or 'question'
|
|
113
|
+
* @returns {*}
|
|
114
|
+
*/
|
|
115
|
+
function resolveValue(data, key) {
|
|
116
|
+
const parts = key.split('.');
|
|
117
|
+
let current = data;
|
|
118
|
+
for (const part of parts) {
|
|
119
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
120
|
+
current = current[part];
|
|
121
|
+
}
|
|
122
|
+
return current;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Escape HTML special characters.
|
|
127
|
+
* @param {string} str
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function escapeHtml(str) {
|
|
131
|
+
return str
|
|
132
|
+
.replace(/&/g, '&')
|
|
133
|
+
.replace(/</g, '<')
|
|
134
|
+
.replace(/>/g, '>')
|
|
135
|
+
.replace(/"/g, '"')
|
|
136
|
+
.replace(/'/g, ''');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* List available templates.
|
|
141
|
+
* @returns {string[]} Array of template names (without .html extension)
|
|
142
|
+
*/
|
|
143
|
+
export function listTemplates() {
|
|
144
|
+
if (!existsSync(TEMPLATES_DIR)) return [];
|
|
145
|
+
return readdirSync(TEMPLATES_DIR)
|
|
146
|
+
.filter(f => f.endsWith('.html'))
|
|
147
|
+
.map(f => f.replace(/\.html$/, ''));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Clear the template cache (useful for development).
|
|
152
|
+
*/
|
|
153
|
+
export function clearTemplateCache() {
|
|
154
|
+
templateCache.clear();
|
|
155
|
+
}
|