@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/.env.example +9 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/SKILL.md +242 -0
- package/bin/deploy +23 -0
- package/bin/sparkui.js +390 -0
- package/docs/README.md +51 -0
- package/docs/api-reference.md +428 -0
- package/docs/chatgpt-setup.md +206 -0
- package/docs/components.md +432 -0
- package/docs/getting-started.md +179 -0
- package/docs/mcp-setup.md +195 -0
- package/docs/openclaw-setup.md +177 -0
- package/docs/templates.md +289 -0
- package/lib/components.js +474 -0
- package/lib/store.js +193 -0
- package/lib/templates.js +48 -0
- package/lib/ws-client.js +197 -0
- package/mcp-server/README.md +189 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +15 -0
- package/package.json +52 -0
- package/server.js +620 -0
- package/templates/base.js +82 -0
- package/templates/checkout.js +271 -0
- package/templates/feedback-form.js +140 -0
- package/templates/macro-tracker.js +205 -0
- package/templates/workout-timer.js +510 -0
- package/templates/ws-test.js +136 -0
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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;
|