agentvibes-avatars 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/server.js ADDED
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // talking-head/server.js
4
+ // AgentVibes TalkingHead server — pure Node.js, no npm deps.
5
+ //
6
+ // Endpoints:
7
+ // GET / → serves index.html
8
+ // GET /avatars.json → avatar config
9
+ // GET /audio/:file → serves temp TTS audio files
10
+ // GET /events → SSE stream (browser connects here)
11
+ // GET /health → 200 OK (used by play-tts.ps1 to probe)
12
+ // POST /speak → {audioBase64, voice, project, origin, llm} → broadcasts to browser
13
+ // GET /has-browser → {"connected": true/false}
14
+ // POST /reload → broadcast reload signal
15
+ // POST /clientlog → browser debug lines
16
+ //
17
+ // Security model: binds 127.0.0.1 only. State-changing endpoints reject any
18
+ // request carrying a non-loopback Origin header (blocks CSRF from a drive-by
19
+ // browser tab). Server-side callers (curl / Invoke-RestMethod) send no Origin
20
+ // and are allowed. Request bodies are size-capped to prevent OOM/disk DoS.
21
+ //
22
+
23
+ 'use strict';
24
+
25
+ const http = require('http');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const crypto = require('crypto');
29
+
30
+ const PORT = parseInt(process.env.TALKING_HEAD_PORT || '3747', 10);
31
+ const AUDIO_DIR = path.join(__dirname, 'public', 'audio');
32
+ const PUBLIC_DIR = path.join(__dirname, 'public');
33
+
34
+ const MAX_BODY = 8 * 1024 * 1024; // 8 MB hard cap on any request body
35
+ const MAX_LOG = 4 * 1024; // 4 KB cap on /clientlog payloads
36
+
37
+ // Ensure audio temp dir exists
38
+ fs.mkdirSync(AUDIO_DIR, { recursive: true });
39
+
40
+ // SSE clients: Map<id, res>
41
+ const sseClients = new Map();
42
+
43
+ // Scheduled cleanup timers for audio files: Map<filename, timer>
44
+ const audioCleanupTimers = new Map();
45
+
46
+ // CSRF guard: requests from another website carry a foreign Origin header.
47
+ // No Origin (server-side curl/PowerShell) or a loopback Origin is allowed.
48
+ function originOk(req) {
49
+ const o = req.headers.origin;
50
+ if (!o) return true;
51
+ try { const u = new URL(o); return u.hostname === '127.0.0.1' || u.hostname === 'localhost'; }
52
+ catch { return false; }
53
+ }
54
+
55
+ function broadcastSSE(payload) {
56
+ const data = `data: ${JSON.stringify(payload)}\n\n`;
57
+ for (const res of sseClients.values()) {
58
+ try { res.write(data); } catch {}
59
+ }
60
+ }
61
+
62
+ function serveFile(filePath, res) {
63
+ const ext = path.extname(filePath).toLowerCase();
64
+ const mimeTypes = {
65
+ '.html': 'text/html; charset=utf-8',
66
+ '.js': 'application/javascript',
67
+ '.mjs': 'application/javascript',
68
+ '.css': 'text/css',
69
+ '.json': 'application/json',
70
+ '.wav': 'audio/wav',
71
+ '.mp3': 'audio/mpeg',
72
+ '.glb': 'model/gltf-binary',
73
+ };
74
+ const mime = mimeTypes[ext] || 'application/octet-stream';
75
+
76
+ fs.stat(filePath, (err, stat) => {
77
+ if (err || !stat.isFile()) { res.writeHead(404); res.end('Not found'); return; }
78
+ res.writeHead(200, {
79
+ 'Content-Type': mime,
80
+ 'Content-Length': stat.size,
81
+ 'Cache-Control': 'no-cache',
82
+ });
83
+ const stream = fs.createReadStream(filePath);
84
+ // Without this, a file deleted mid-read (120s cleanup) or any read error
85
+ // emits an unhandled 'error' event that crashes the whole server.
86
+ stream.on('error', () => { try { res.destroy(); } catch {} });
87
+ stream.pipe(res);
88
+ });
89
+ }
90
+
91
+ function parseBody(req) {
92
+ return new Promise((resolve, reject) => {
93
+ const chunks = []; let size = 0;
94
+ req.on('data', c => {
95
+ size += c.length;
96
+ if (size > MAX_BODY) { reject(new Error('body too large')); try { req.destroy(); } catch {} return; }
97
+ chunks.push(c);
98
+ });
99
+ req.on('end', () => {
100
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
101
+ catch (e) { reject(e); }
102
+ });
103
+ req.on('error', reject);
104
+ });
105
+ }
106
+
107
+ // Security: validate that a filename is safe (no path traversal)
108
+ function safeName(name) {
109
+ return /^[a-zA-Z0-9_\-]+\.wav$/.test(name);
110
+ }
111
+
112
+ const server = http.createServer(async (req, res) => {
113
+ const urlObj = new URL(req.url, `http://localhost:${PORT}`);
114
+ const urlPath = urlObj.pathname;
115
+
116
+ // CORS preflight — echo a loopback Origin only; foreign origins get no ACAO
117
+ // so their preflight fails and the browser never sends the real request.
118
+ if (req.method === 'OPTIONS') {
119
+ const h = { 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' };
120
+ if (originOk(req) && req.headers.origin) h['Access-Control-Allow-Origin'] = req.headers.origin;
121
+ res.writeHead(204, h);
122
+ res.end();
123
+ return;
124
+ }
125
+
126
+ // Health check
127
+ if (req.method === 'GET' && urlPath === '/health') {
128
+ res.writeHead(200, { 'Content-Type': 'application/json' });
129
+ res.end(JSON.stringify({ ok: true, clients: sseClients.size }));
130
+ return;
131
+ }
132
+
133
+ // Reload — broadcast a reload signal so open browsers refresh in place
134
+ if (req.method === 'POST' && urlPath === '/reload') {
135
+ if (!originOk(req)) { res.writeHead(403); res.end(); return; }
136
+ broadcastSSE({ type: 'reload' });
137
+ res.writeHead(204);
138
+ res.end();
139
+ return;
140
+ }
141
+
142
+ // Client log — browser posts debug lines here so they appear in server.log
143
+ if (req.method === 'POST' && urlPath === '/clientlog') {
144
+ if (!originOk(req)) { res.writeHead(403); res.end(); return; }
145
+ let body = '';
146
+ req.on('data', c => { body += c; if (body.length > MAX_LOG) { try { req.destroy(); } catch {} } });
147
+ req.on('end', () => {
148
+ // Strip CR/LF so a payload can't forge extra log lines (log injection).
149
+ const clean = body.slice(0, MAX_LOG).replace(/[\r\n]+/g, ' ');
150
+ console.log('[CLIENT] ' + clean);
151
+ res.writeHead(204); res.end();
152
+ });
153
+ return;
154
+ }
155
+
156
+ // Browser connection count
157
+ if (req.method === 'GET' && urlPath === '/has-browser') {
158
+ res.writeHead(200, { 'Content-Type': 'application/json' });
159
+ res.end(JSON.stringify({ connected: sseClients.size > 0 }));
160
+ return;
161
+ }
162
+
163
+ // SSE stream
164
+ if (req.method === 'GET' && urlPath === '/events') {
165
+ const clientId = crypto.randomBytes(4).toString('hex');
166
+ res.writeHead(200, {
167
+ 'Content-Type': 'text/event-stream',
168
+ 'Cache-Control': 'no-cache',
169
+ 'Connection': 'keep-alive',
170
+ 'X-Accel-Buffering': 'no',
171
+ });
172
+ res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
173
+ sseClients.set(clientId, res);
174
+ console.log(`[SSE] Browser connected: ${clientId} (total: ${sseClients.size})`);
175
+
176
+ // Keepalive ping every 25s
177
+ const ping = setInterval(() => {
178
+ try { res.write(': ping\n\n'); } catch { clearInterval(ping); }
179
+ }, 25000);
180
+
181
+ req.on('close', () => {
182
+ clearInterval(ping);
183
+ sseClients.delete(clientId);
184
+ console.log(`[SSE] Browser disconnected: ${clientId} (total: ${sseClients.size})`);
185
+ });
186
+ return;
187
+ }
188
+
189
+ // POST /speak — receive audio from play-tts.ps1 / forward-to-avatar.sh
190
+ if (req.method === 'POST' && urlPath === '/speak') {
191
+ if (!originOk(req)) { res.writeHead(403); res.end(); return; }
192
+ let body;
193
+ try { body = await parseBody(req); }
194
+ catch {
195
+ res.writeHead(400, { 'Content-Type': 'application/json' });
196
+ res.end(JSON.stringify({ error: 'Invalid JSON or body too large' }));
197
+ return;
198
+ }
199
+
200
+ const hasBrowser = sseClients.size > 0;
201
+ const payload = {
202
+ type: 'speak',
203
+ audioUrl: null,
204
+ text: body.text || '',
205
+ voice: body.voice || '',
206
+ project: body.project || '',
207
+ origin: body.origin || 'remote',
208
+ llm: body.llm || '',
209
+ };
210
+
211
+ if (hasBrowser && body.audioBase64) {
212
+ const buf = Buffer.from(String(body.audioBase64), 'base64');
213
+ if (buf.length === 0 || buf.length > MAX_BODY) {
214
+ res.writeHead(413, { 'Content-Type': 'application/json' });
215
+ res.end(JSON.stringify({ error: 'audio too large or empty' }));
216
+ return;
217
+ }
218
+ const fileName = `${crypto.randomBytes(8).toString('hex')}.wav`;
219
+ const filePath = path.join(AUDIO_DIR, fileName);
220
+ // Async write — never block the event loop. Broadcast only after the
221
+ // file is on disk so the browser can't 404 fetching it.
222
+ fs.writeFile(filePath, buf, (err) => {
223
+ if (err) { console.error('[SPEAK] Audio save failed:', err.message); return; }
224
+ payload.audioUrl = `/audio/${fileName}`;
225
+ broadcastSSE(payload);
226
+ const timer = setTimeout(() => { fs.unlink(filePath, () => {}); audioCleanupTimers.delete(fileName); }, 120000);
227
+ audioCleanupTimers.set(fileName, timer);
228
+ console.log(`[SPEAK] Queued ${fileName} for ${sseClients.size} browser(s) | project=${body.project || '-'} voice=${body.voice || '-'}`);
229
+ });
230
+ } else if (hasBrowser) {
231
+ // No audio bytes — just notify browser (e.g. SAPI plays inline, no file)
232
+ broadcastSSE(payload);
233
+ } else {
234
+ console.log('[SPEAK] No browser connected — skipping SSE broadcast');
235
+ }
236
+
237
+ res.writeHead(200, { 'Content-Type': 'application/json' });
238
+ res.end(JSON.stringify({ browserConnected: hasBrowser }));
239
+ return;
240
+ }
241
+
242
+ // Static files
243
+ if (req.method === 'GET') {
244
+ // Audio files
245
+ const audioMatch = urlPath.match(/^\/audio\/([a-zA-Z0-9_\-]+\.wav)$/);
246
+ if (audioMatch && safeName(audioMatch[1])) {
247
+ serveFile(path.join(AUDIO_DIR, audioMatch[1]), res);
248
+ return;
249
+ }
250
+
251
+ // avatars.json
252
+ if (urlPath === '/avatars.json') {
253
+ serveFile(path.join(__dirname, 'avatars.json'), res);
254
+ return;
255
+ }
256
+
257
+ // index.html for /
258
+ if (urlPath === '/' || urlPath === '/index.html') {
259
+ serveFile(path.join(PUBLIC_DIR, 'index.html'), res);
260
+ return;
261
+ }
262
+
263
+ // Other public files
264
+ const safePubPath = path.resolve(PUBLIC_DIR, urlPath.replace(/^\//, ''));
265
+ if (safePubPath.startsWith(PUBLIC_DIR + path.sep) || safePubPath === PUBLIC_DIR) {
266
+ serveFile(safePubPath, res);
267
+ return;
268
+ }
269
+ }
270
+
271
+ res.writeHead(404);
272
+ res.end('Not found');
273
+ });
274
+
275
+ server.listen(PORT, '127.0.0.1', () => {
276
+ console.log(`[TalkingHead] Server listening on http://127.0.0.1:${PORT}`);
277
+ console.log(`[TalkingHead] Open in browser: http://localhost:${PORT}`);
278
+ });
279
+
280
+ server.on('error', err => {
281
+ if (err.code === 'EADDRINUSE') {
282
+ console.error(`[TalkingHead] Port ${PORT} already in use — server may already be running.`);
283
+ process.exit(1);
284
+ }
285
+ throw err;
286
+ });