@limeade-labs/sparkui 1.0.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,620 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const express = require('express');
8
+ const { v4: uuidv4 } = require('uuid');
9
+ const { WebSocketServer } = require('ws');
10
+ const { PageStore } = require('./lib/store');
11
+ const templates = require('./lib/templates');
12
+ const components = require('./lib/components');
13
+
14
+ // ── Config ───────────────────────────────────────────────────────────────────
15
+
16
+ const ENV_FILE = path.join(__dirname, '.env');
17
+
18
+ // Load .env file early so PORT and PUSH_TOKEN can come from it
19
+ if (fs.existsSync(ENV_FILE)) {
20
+ for (const line of fs.readFileSync(ENV_FILE, 'utf-8').split('\n')) {
21
+ const m = line.match(/^([A-Z_]+)=(.+)$/);
22
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
23
+ }
24
+ }
25
+
26
+ const PORT = parseInt(process.env.SPARKUI_PORT, 10) || 3456;
27
+
28
+ // Resolve PUSH_TOKEN: env > .env file > generate new
29
+ function resolvePushToken() {
30
+ if (process.env.PUSH_TOKEN) return process.env.PUSH_TOKEN;
31
+
32
+ // Try to read from .env
33
+ if (fs.existsSync(ENV_FILE)) {
34
+ const envContent = fs.readFileSync(ENV_FILE, 'utf-8');
35
+ const match = envContent.match(/^PUSH_TOKEN=(.+)$/m);
36
+ if (match) {
37
+ process.env.PUSH_TOKEN = match[1];
38
+ return match[1];
39
+ }
40
+ }
41
+
42
+ // Generate new token
43
+ const token = 'spk_' + crypto.randomBytes(24).toString('hex');
44
+ const line = `PUSH_TOKEN=${token}\n`;
45
+ fs.appendFileSync(ENV_FILE, line);
46
+ process.env.PUSH_TOKEN = token;
47
+ return token;
48
+ }
49
+
50
+ const PUSH_TOKEN = resolvePushToken();
51
+
52
+ // OpenClaw hooks config
53
+ const OPENCLAW_HOOKS_URL = process.env.OPENCLAW_HOOKS_URL || null;
54
+ const OPENCLAW_HOOKS_TOKEN = process.env.OPENCLAW_HOOKS_TOKEN || null;
55
+
56
+ // ── App ──────────────────────────────────────────────────────────────────────
57
+
58
+ const app = express();
59
+ const server = http.createServer(app);
60
+ const store = new PageStore();
61
+
62
+ // Middleware
63
+ app.use(express.json({ limit: '2mb' }));
64
+ app.use((req, res, next) => {
65
+ // CORS
66
+ res.set({
67
+ 'Access-Control-Allow-Origin': '*',
68
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
69
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
70
+ });
71
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
72
+ next();
73
+ });
74
+
75
+ // Request logging
76
+ app.use((req, res, next) => {
77
+ const start = Date.now();
78
+ res.on('finish', () => {
79
+ const ms = Date.now() - start;
80
+ console.log(`${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
81
+ });
82
+ next();
83
+ });
84
+
85
+ // ── Auth Middleware ───────────────────────────────────────────────────────────
86
+
87
+ function requireAuth(req, res, next) {
88
+ const auth = req.headers.authorization;
89
+ if (!auth || !auth.startsWith('Bearer ')) {
90
+ return res.status(401).json({ error: 'Missing or invalid Authorization header' });
91
+ }
92
+ const token = auth.slice(7);
93
+ if (token !== PUSH_TOKEN) {
94
+ return res.status(401).json({ error: 'Invalid push token' });
95
+ }
96
+ next();
97
+ }
98
+
99
+ // ── Webhook Callback ─────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Forward a browser event to the page's callbackUrl via HTTP POST.
103
+ * Fire-and-forget with logging.
104
+ */
105
+ function forwardToCallback(pageId, type, data) {
106
+ const cb = store.getCallback(pageId);
107
+ if (!cb || !cb.callbackUrl) return;
108
+
109
+ const payload = JSON.stringify({
110
+ type,
111
+ pageId,
112
+ data: data || {},
113
+ timestamp: Date.now(),
114
+ });
115
+
116
+ const url = new URL(cb.callbackUrl);
117
+ const isHttps = url.protocol === 'https:';
118
+ const transport = isHttps ? require('https') : require('http');
119
+
120
+ const headers = {
121
+ 'Content-Type': 'application/json',
122
+ 'Content-Length': Buffer.byteLength(payload),
123
+ 'User-Agent': 'SparkUI/1.0',
124
+ };
125
+ if (cb.callbackToken) {
126
+ headers['Authorization'] = `Bearer ${cb.callbackToken}`;
127
+ }
128
+
129
+ const req = transport.request({
130
+ hostname: url.hostname,
131
+ port: url.port || (isHttps ? 443 : 80),
132
+ path: url.pathname + url.search,
133
+ method: 'POST',
134
+ headers,
135
+ }, (res) => {
136
+ // Drain the response
137
+ res.resume();
138
+ if (res.statusCode >= 400) {
139
+ console.warn(`[callback] POST ${cb.callbackUrl} returned ${res.statusCode} for page ${pageId}`);
140
+ } else {
141
+ console.log(`[callback] Forwarded ${type} event for page ${pageId} -> ${res.statusCode}`);
142
+ }
143
+ });
144
+
145
+ req.on('error', (err) => {
146
+ console.warn(`[callback] Failed to POST ${cb.callbackUrl} for page ${pageId}: ${err.message}`);
147
+ });
148
+
149
+ req.write(payload);
150
+ req.end();
151
+ }
152
+
153
+ // ── OpenClaw Webhook Forwarding ──────────────────────────────────────────────
154
+
155
+ /**
156
+ * Forward a browser event to OpenClaw hooks endpoint.
157
+ * Only fires if page has openclaw config and event type is in eventTypes.
158
+ */
159
+ function forwardToOpenClaw(pageId, type, data) {
160
+ const oc = store.getOpenclaw(pageId);
161
+ if (!oc || !oc.enabled) return;
162
+ if (!OPENCLAW_HOOKS_URL || !OPENCLAW_HOOKS_TOKEN) {
163
+ console.warn('[openclaw] OPENCLAW_HOOKS_URL or OPENCLAW_HOOKS_TOKEN not set, skipping');
164
+ return;
165
+ }
166
+
167
+ // Check if this event type should be forwarded
168
+ const eventTypes = oc.eventTypes || ['completion'];
169
+ if (!eventTypes.includes(type)) return;
170
+
171
+ const page = store.get(pageId);
172
+ const pageMeta = page ? page.meta : {};
173
+ const pageTitle = pageMeta.title || (page && page.meta && page.meta.title) || 'Untitled';
174
+ const templateName = pageMeta.template || 'unknown';
175
+
176
+ // Build message
177
+ let message;
178
+ if (type === 'completion') {
179
+ // Richer message for completion events
180
+ const dataStr = JSON.stringify(data, null, 2);
181
+ message = `[SparkUI Completion] Page ${pageId}: Form submitted!\n\n` +
182
+ `📝 **Page:** ${pageTitle}\n` +
183
+ `📋 **Template:** ${templateName}\n\n` +
184
+ `**Submitted Data:**\n\`\`\`\n${dataStr}\n\`\`\``;
185
+ } else {
186
+ const dataStr = JSON.stringify(data);
187
+ message = `[SparkUI Event] Page ${pageId}: ${type} event received.\n\n` +
188
+ `Data: ${dataStr}\n\n` +
189
+ `Page title: ${pageTitle}\nTemplate: ${templateName}`;
190
+ }
191
+
192
+ const payload = JSON.stringify({
193
+ message,
194
+ deliver: true,
195
+ channel: oc.channel || 'slack',
196
+ to: oc.to || undefined,
197
+ sessionKey: oc.sessionKey || undefined,
198
+ });
199
+
200
+ const url = new URL(OPENCLAW_HOOKS_URL);
201
+ const isHttps = url.protocol === 'https:';
202
+ const transport = isHttps ? require('https') : require('http');
203
+
204
+ const headers = {
205
+ 'Content-Type': 'application/json',
206
+ 'Content-Length': Buffer.byteLength(payload),
207
+ 'User-Agent': 'SparkUI/1.1',
208
+ 'Authorization': `Bearer ${OPENCLAW_HOOKS_TOKEN}`,
209
+ };
210
+
211
+ const req = transport.request({
212
+ hostname: url.hostname,
213
+ port: url.port || (isHttps ? 443 : 80),
214
+ path: url.pathname + url.search,
215
+ method: 'POST',
216
+ headers,
217
+ }, (res) => {
218
+ let body = '';
219
+ res.on('data', (chunk) => { body += chunk; });
220
+ res.on('end', () => {
221
+ if (res.statusCode >= 400) {
222
+ console.warn(`[openclaw] POST ${OPENCLAW_HOOKS_URL} returned ${res.statusCode} for page ${pageId}: ${body}`);
223
+ } else {
224
+ console.log(`[openclaw] Forwarded ${type} event for page ${pageId} -> ${res.statusCode}`);
225
+ }
226
+ });
227
+ });
228
+
229
+ req.on('error', (err) => {
230
+ console.warn(`[openclaw] Failed to POST ${OPENCLAW_HOOKS_URL} for page ${pageId}: ${err.message}`);
231
+ });
232
+
233
+ req.write(payload);
234
+ req.end();
235
+ }
236
+
237
+ // ── WebSocket ────────────────────────────────────────────────────────────────
238
+
239
+ const wss = new WebSocketServer({ server, path: '/ws' });
240
+
241
+ // Track clients per page ID
242
+ const pageClients = new Map(); // pageId -> Set<ws>
243
+
244
+ wss.on('connection', (ws, req) => {
245
+ const url = new URL(req.url, `http://${req.headers.host}`);
246
+ const pageId = url.searchParams.get('page');
247
+
248
+ // Mark connection as alive for heartbeat
249
+ ws._isAlive = true;
250
+ ws._pageId = pageId;
251
+
252
+ if (pageId) {
253
+ if (!pageClients.has(pageId)) pageClients.set(pageId, new Set());
254
+ pageClients.get(pageId).add(ws);
255
+
256
+ ws.on('close', () => {
257
+ const clients = pageClients.get(pageId);
258
+ if (clients) {
259
+ clients.delete(ws);
260
+ if (clients.size === 0) pageClients.delete(pageId);
261
+ }
262
+ });
263
+ }
264
+
265
+ // ── Handle incoming messages from browser ──
266
+ ws.on('message', (raw) => {
267
+ let msg;
268
+ try {
269
+ msg = JSON.parse(raw);
270
+ } catch {
271
+ return; // ignore non-JSON
272
+ }
273
+
274
+ const msgPageId = msg.pageId || pageId;
275
+
276
+ switch (msg.type) {
277
+ case 'heartbeat':
278
+ // Respond with pong to keep client happy
279
+ ws._isAlive = true;
280
+ try { ws.send(JSON.stringify({ type: 'pong' })); } catch {}
281
+ break;
282
+
283
+ case 'event':
284
+ console.log(`[ws] Event from page ${msgPageId}:`, JSON.stringify(msg.data).slice(0, 200));
285
+ forwardToCallback(msgPageId, 'event', msg.data);
286
+ forwardToOpenClaw(msgPageId, 'event', msg.data);
287
+ break;
288
+
289
+ case 'completion':
290
+ console.log(`[ws] Completion from page ${msgPageId}:`, JSON.stringify(msg.data).slice(0, 200));
291
+ forwardToCallback(msgPageId, 'completion', msg.data);
292
+ forwardToOpenClaw(msgPageId, 'completion', msg.data);
293
+ break;
294
+
295
+ default:
296
+ // Unknown type — forward anyway if it has data
297
+ if (msg.data) {
298
+ forwardToCallback(msgPageId, msg.type || 'unknown', msg.data);
299
+ forwardToOpenClaw(msgPageId, msg.type || 'unknown', msg.data);
300
+ }
301
+ break;
302
+ }
303
+ });
304
+
305
+ ws.on('error', () => {}); // swallow errors
306
+
307
+ // Respond to WS-level pong frames (from server ping)
308
+ ws.on('pong', () => {
309
+ ws._isAlive = true;
310
+ });
311
+ });
312
+
313
+ // ── Server-side heartbeat: ping every 30s, drop stale after 60s ──
314
+
315
+ const WS_PING_INTERVAL = setInterval(() => {
316
+ wss.clients.forEach((ws) => {
317
+ if (ws._isAlive === false) {
318
+ // Stale — hasn't responded since last ping
319
+ console.log(`[ws] Dropping stale client for page ${ws._pageId || 'unknown'}`);
320
+ return ws.terminate();
321
+ }
322
+ ws._isAlive = false;
323
+ try { ws.ping(); } catch {}
324
+ });
325
+ }, 30000);
326
+
327
+ WS_PING_INTERVAL.unref();
328
+
329
+ /** Notify all WS clients watching a page to reload */
330
+ function notifyPageUpdate(pageId) {
331
+ const clients = pageClients.get(pageId);
332
+ if (!clients) return;
333
+ const msg = JSON.stringify({ type: 'update', pageId });
334
+ for (const ws of clients) {
335
+ try { ws.send(msg); } catch {}
336
+ }
337
+ }
338
+
339
+ /** Notify all WS clients watching a page that it's destroyed */
340
+ function notifyPageDestroy(pageId) {
341
+ const clients = pageClients.get(pageId);
342
+ if (!clients) return;
343
+ const msg = JSON.stringify({ type: 'destroy', pageId });
344
+ for (const ws of clients) {
345
+ try { ws.send(msg); } catch {}
346
+ }
347
+ }
348
+
349
+ // ── Routes ───────────────────────────────────────────────────────────────────
350
+
351
+ // Health check
352
+ app.get('/', (req, res) => {
353
+ res.json({
354
+ status: 'ok',
355
+ service: 'sparkui',
356
+ version: '1.1.0',
357
+ pages: store.size,
358
+ wsClients: wss.clients.size,
359
+ templates: templates.list(),
360
+ uptime: Math.floor(process.uptime()),
361
+ });
362
+ });
363
+
364
+ // Serve a page
365
+ app.get('/s/:id', (req, res) => {
366
+ const page = store.get(req.params.id);
367
+ if (!page) {
368
+ return res.status(410).set('Content-Type', 'text/html').send(
369
+ `<!DOCTYPE html><html><head><title>Gone</title></head><body style="background:#111;color:#888;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><h1 style="font-size:3rem;margin-bottom:8px">⚡</h1><p>This page has expired or been removed.</p><p style="color:#555;font-size:0.85rem">SparkUI pages are ephemeral by design.</p></div></body></html>`
370
+ );
371
+ }
372
+ store.recordView(req.params.id);
373
+ res.set('Content-Type', 'text/html').send(page.html);
374
+ });
375
+
376
+ /**
377
+ * Prettify a template name for OG defaults.
378
+ * e.g. "macro-tracker" → "Macro Tracker"
379
+ */
380
+ function prettifyTemplateName(name) {
381
+ if (!name) return 'SparkUI';
382
+ return name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
383
+ }
384
+
385
+ // Push API — create a page
386
+ app.post('/api/push', requireAuth, (req, res) => {
387
+ try {
388
+ const { html, template, data, ttl, callbackUrl, callbackToken, meta, openclaw, og } = req.body;
389
+
390
+ if (!html && !template) {
391
+ return res.status(400).json({ error: 'Provide either "html" or "template" + "data"' });
392
+ }
393
+
394
+ const id = uuidv4();
395
+ const baseUrl = process.env.SPARKUI_BASE_URL || `http://localhost:${PORT}`;
396
+ let finalHtml;
397
+
398
+ // Build OG defaults
399
+ const ogDefaults = {
400
+ title: (og && og.title) || prettifyTemplateName(template) || 'SparkUI',
401
+ description: (og && og.description) || 'An ephemeral micro-app powered by SparkUI ⚡',
402
+ image: (og && og.image) || `${baseUrl}/og/${id}.svg`,
403
+ url: `${baseUrl}/s/${id}`,
404
+ };
405
+
406
+ if (template) {
407
+ if (!templates.has(template)) {
408
+ return res.status(400).json({ error: `Unknown template "${template}". Available: ${templates.list().join(', ')}` });
409
+ }
410
+ const templateData = { ...data, _pageId: id, _og: ogDefaults };
411
+ finalHtml = templates.render(template, templateData);
412
+ } else {
413
+ finalHtml = html;
414
+ }
415
+
416
+ // Enrich meta with template/title for OpenClaw forwarding context
417
+ const enrichedMeta = {
418
+ ...(meta || {}),
419
+ template: template || (meta && meta.template) || null,
420
+ title: (data && data.title) || (meta && meta.title) || null,
421
+ og: ogDefaults,
422
+ };
423
+
424
+ store.set(id, {
425
+ html: finalHtml,
426
+ ttl: ttl || undefined,
427
+ callbackUrl: callbackUrl || null,
428
+ callbackToken: callbackToken || null,
429
+ meta: { ...enrichedMeta, data: data || null },
430
+ openclaw: openclaw || null,
431
+ });
432
+
433
+ res.status(201).json({ id, url: `/s/${id}`, fullUrl: `${baseUrl}/s/${id}` });
434
+ } catch (err) {
435
+ console.error('Push error:', err.message);
436
+ res.status(500).json({ error: err.message });
437
+ }
438
+ });
439
+
440
+ // List pages
441
+ app.get('/api/pages', requireAuth, (req, res) => {
442
+ const status = req.query.status || 'active';
443
+ const template = req.query.template || undefined;
444
+ const pages = store.list({ status, template });
445
+ res.json({ pages, total: pages.length });
446
+ });
447
+
448
+ // Page details
449
+ app.get('/api/pages/:id', requireAuth, (req, res) => {
450
+ const details = store.getDetails(req.params.id);
451
+ if (!details) {
452
+ return res.status(404).json({ error: 'Page not found' });
453
+ }
454
+ res.json(details);
455
+ });
456
+
457
+ // Update a page
458
+ app.patch('/api/pages/:id', requireAuth, (req, res) => {
459
+ const { id } = req.params;
460
+ const page = store.get(id);
461
+
462
+ if (!page) {
463
+ return res.status(store.has(id) ? 410 : 404).json({ error: store.has(id) ? 'Page expired' : 'Page not found' });
464
+ }
465
+
466
+ const { html, template, data, ttl } = req.body;
467
+ let finalHtml = null;
468
+
469
+ if (template) {
470
+ if (!templates.has(template)) {
471
+ return res.status(400).json({ error: `Unknown template "${template}"` });
472
+ }
473
+ finalHtml = templates.render(template, { ...data, _pageId: id });
474
+ } else if (html) {
475
+ finalHtml = html;
476
+ } else if (data && page.meta && page.meta.template && templates.has(page.meta.template)) {
477
+ // Re-render existing template with new data
478
+ finalHtml = templates.render(page.meta.template, { ...data, _pageId: id });
479
+ }
480
+
481
+ // Update data in meta if provided
482
+ if (data && page.meta) {
483
+ page.meta.data = data;
484
+ }
485
+
486
+ // Update HTML if we have new content, or just extend TTL
487
+ if (finalHtml) {
488
+ store.update(id, { html: finalHtml, ttl });
489
+ } else if (ttl) {
490
+ // TTL-only update
491
+ store.update(id, { html: page.html, ttl });
492
+ } else if (!finalHtml && !ttl) {
493
+ return res.status(400).json({ error: 'Provide "html", "template" + "data", "data" (for template pages), or "ttl"' });
494
+ }
495
+
496
+ notifyPageUpdate(id);
497
+
498
+ const details = store.getDetails(id);
499
+ res.json(details);
500
+ });
501
+
502
+ // Delete a page
503
+ app.delete('/api/pages/:id', requireAuth, (req, res) => {
504
+ const { id } = req.params;
505
+ const deleted = store.delete(id);
506
+ if (!deleted) {
507
+ return res.status(404).json({ error: 'Page not found' });
508
+ }
509
+ notifyPageDestroy(id); // notify clients the page is gone
510
+ res.json({ id, deleted: true });
511
+ });
512
+
513
+ // ── Dynamic OG Image (SVG) ───────────────────────────────────────────────────
514
+
515
+ app.get('/og/:id.svg', (req, res) => {
516
+ const page = store.get(req.params.id);
517
+ const ogTitle = (page && page.meta && page.meta.og && page.meta.og.title) || 'SparkUI';
518
+ const templateName = (page && page.meta && page.meta.template) || '';
519
+ const subtitle = templateName ? prettifyTemplateName(templateName) : 'Ephemeral Micro-App';
520
+
521
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
522
+ <rect width="1200" height="630" fill="#111111"/>
523
+ <rect x="0" y="0" width="1200" height="4" fill="#00ff88"/>
524
+ <text x="100" y="260" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="56" font-weight="800" fill="#e0e0e0">${escapeXml(ogTitle)}</text>
525
+ <text x="100" y="320" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="28" fill="#888888">${escapeXml(subtitle)}</text>
526
+ <text x="100" y="530" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="32" fill="#00ff88" font-weight="600">⚡ SparkUI</text>
527
+ <text x="1100" y="530" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="22" fill="#555555" text-anchor="end">sparkui</text>
528
+ </svg>`;
529
+
530
+ res.set('Content-Type', 'image/svg+xml').set('Cache-Control', 'public, max-age=3600').send(svg);
531
+ });
532
+
533
+ /** Escape text for safe XML/SVG embedding */
534
+ function escapeXml(str) {
535
+ if (!str) return '';
536
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
537
+ }
538
+
539
+ // ── Compose API ──────────────────────────────────────────────────────────────
540
+
541
+ app.post('/api/compose', requireAuth, (req, res) => {
542
+ try {
543
+ const layout = req.body;
544
+
545
+ if (!layout || !layout.sections || !Array.isArray(layout.sections)) {
546
+ return res.status(400).json({ error: 'Provide a layout with "sections" array' });
547
+ }
548
+
549
+ const { html, pushBody } = components.compose(layout);
550
+ const id = uuidv4();
551
+
552
+ // Replace placeholder page ID with actual UUID
553
+ const finalHtml = html.replace(/__PAGE_ID__/g, id);
554
+
555
+ const ttl = layout.ttl || undefined;
556
+ const openclaw = layout.openclaw || null;
557
+
558
+ store.set(id, {
559
+ html: finalHtml,
560
+ ttl,
561
+ meta: { title: layout.title || 'Composed', template: 'composed' },
562
+ openclaw,
563
+ });
564
+
565
+ const baseUrl = process.env.SPARKUI_BASE_URL || `http://localhost:${PORT}`;
566
+ res.status(201).json({ id, url: `/s/${id}`, fullUrl: `${baseUrl}/s/${id}` });
567
+ } catch (err) {
568
+ console.error('Compose error:', err.message);
569
+ res.status(500).json({ error: err.message });
570
+ }
571
+ });
572
+
573
+ // ── Test Echo Endpoint ───────────────────────────────────────────────────────
574
+
575
+ const echoLog = []; // In-memory log of received webhooks (last 50)
576
+
577
+ app.post('/api/test/echo', requireAuth, (req, res) => {
578
+ const entry = {
579
+ receivedAt: new Date().toISOString(),
580
+ body: req.body,
581
+ };
582
+ echoLog.push(entry);
583
+ if (echoLog.length > 50) echoLog.shift();
584
+ console.log(`[echo] Received webhook:`, JSON.stringify(req.body).slice(0, 300));
585
+ res.json({ ok: true, received: entry });
586
+ });
587
+
588
+ // View echo log
589
+ app.get('/api/test/echo', requireAuth, (req, res) => {
590
+ res.json({ entries: echoLog, count: echoLog.length });
591
+ });
592
+
593
+ // ── Start ────────────────────────────────────────────────────────────────────
594
+
595
+ server.listen(PORT, () => {
596
+ console.log(`⚡ SparkUI server running on port ${PORT}`);
597
+ console.log(` Health: http://localhost:${PORT}/`);
598
+ console.log(` Push token: ${PUSH_TOKEN.slice(0, 8)}...${PUSH_TOKEN.slice(-4)}`);
599
+ console.log(` Templates: ${templates.list().join(', ')}`);
600
+ console.log(` WebSocket: ws://localhost:${PORT}/ws`);
601
+ });
602
+
603
+ // Graceful shutdown
604
+ function shutdown(signal) {
605
+ console.log(`\n${signal} received — shutting down...`);
606
+ clearInterval(WS_PING_INTERVAL);
607
+ server.close(() => {
608
+ store.destroy();
609
+ wss.close();
610
+ console.log('SparkUI stopped.');
611
+ process.exit(0);
612
+ });
613
+ // Force exit after 5s
614
+ setTimeout(() => process.exit(1), 5000).unref();
615
+ }
616
+
617
+ process.on('SIGINT', () => shutdown('SIGINT'));
618
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
619
+
620
+ module.exports = { app, server }; // for testing
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const { getClientScript } = require('../lib/ws-client');
4
+
5
+ /**
6
+ * Base HTML template wrapper.
7
+ * Dark theme, responsive, mobile-friendly, includes full WS client with sparkui API.
8
+ *
9
+ * @param {object} opts
10
+ * @param {string} opts.title - Page title
11
+ * @param {string} opts.body - Inner HTML body content
12
+ * @param {string} [opts.id] - Page ID for WebSocket connection
13
+ * @param {number} [opts.refreshSeconds] - Auto-refresh interval (0 to disable)
14
+ * @param {string} [opts.extraHead] - Extra tags for <head>
15
+ * @param {object} [opts.og] - Open Graph metadata { title, description, image, url }
16
+ * @returns {string} Full HTML document
17
+ */
18
+ function base({ title = 'SparkUI', body = '', id = '', refreshSeconds = 0, extraHead = '', og = {} } = {}) {
19
+ const refreshMeta = refreshSeconds > 0
20
+ ? `<meta http-equiv="refresh" content="${refreshSeconds}">`
21
+ : '';
22
+
23
+ const wsScript = id ? `<script>${getClientScript(id)}</script>` : '';
24
+
25
+ // Open Graph meta tags
26
+ const ogTitle = og.title || title || 'SparkUI';
27
+ const ogDescription = og.description || 'An ephemeral micro-app powered by SparkUI ⚡';
28
+ const ogUrl = og.url || '';
29
+ const ogImage = og.image || '';
30
+
31
+ const ogTags = `
32
+ <meta property="og:title" content="${ogTitle}" />
33
+ <meta property="og:description" content="${ogDescription}" />
34
+ <meta property="og:type" content="website" />
35
+ ${ogUrl ? `<meta property="og:url" content="${ogUrl}" />` : ''}
36
+ ${ogImage ? `<meta property="og:image" content="${ogImage}" />` : ''}
37
+ <meta name="twitter:card" content="summary" />
38
+ <meta name="twitter:title" content="${ogTitle}" />
39
+ <meta name="twitter:description" content="${ogDescription}" />
40
+ ${ogImage ? `<meta name="twitter:image" content="${ogImage}" />` : ''}`;
41
+
42
+ return `<!DOCTYPE html>
43
+ <html lang="en">
44
+ <head>
45
+ <meta charset="UTF-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
47
+ ${refreshMeta}
48
+ <title>${title}</title>
49
+ ${ogTags}
50
+ <style>
51
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
52
+ html, body {
53
+ background: #111;
54
+ color: #e0e0e0;
55
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
56
+ font-size: 16px;
57
+ line-height: 1.5;
58
+ min-height: 100vh;
59
+ -webkit-font-smoothing: antialiased;
60
+ -moz-osx-font-smoothing: grayscale;
61
+ }
62
+ .sparkui-container {
63
+ max-width: 600px;
64
+ margin: 0 auto;
65
+ padding: 20px 16px;
66
+ }
67
+ @media (min-width: 768px) {
68
+ .sparkui-container { padding: 32px 24px; }
69
+ }
70
+ </style>
71
+ ${extraHead}
72
+ </head>
73
+ <body>
74
+ <div class="sparkui-container">
75
+ ${body}
76
+ </div>
77
+ ${wsScript}
78
+ </body>
79
+ </html>`;
80
+ }
81
+
82
+ module.exports = base;