@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/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
+ }
@@ -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, '&lt;')
134
+ .replace(/>/g, '&gt;')
135
+ .replace(/"/g, '&quot;')
136
+ .replace(/'/g, '&#39;');
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
+ }