@khanglvm/relay 0.2.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,371 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
6
+ import { openUrl } from './open.js';
7
+
8
+ const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
9
+ const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const VENDOR_DIR = path.join(PKG_ROOT, 'vendor');
11
+
12
+ const escapeHtml = (s) =>
13
+ s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
14
+
15
+ // Concurrently authored UI assets (blocks/annotate) may not exist yet at this
16
+ // phase's runtime — read-guard so the server still boots with empty fallbacks.
17
+ function readUi(name) {
18
+ try {
19
+ return fs.readFileSync(path.join(UI_DIR, name), 'utf8');
20
+ } catch {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ // Strips block bodies for the client payload: html blocks ship only metadata
26
+ // (their bodies are served via /html/b/<id>), everything else ships as-is.
27
+ function clientBlock(b) {
28
+ if (b && b.type === 'html') {
29
+ return { id: b.id, type: 'html', height: b.height, hasHtml: Boolean(b.html) };
30
+ }
31
+ return b;
32
+ }
33
+
34
+ // True when any block in the spec needs a given vendored library.
35
+ function specNeeds(spec, type) {
36
+ const has = (blocks) => Array.isArray(blocks) && blocks.some((b) => b && b.type === type);
37
+ if (has(spec.blocks)) return true;
38
+ return spec.questions.some((q) => has(q.blocks));
39
+ }
40
+
41
+ function vendorPresent(file) {
42
+ try {
43
+ return fs.existsSync(path.join(VENDOR_DIR, file));
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function buildPage(record) {
50
+ const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
51
+ const css = readUi('style.css');
52
+ const blocksCss = readUi('blocks.css');
53
+ const annotateCss = readUi('annotate.css');
54
+ const blocksJs = readUi('blocks.js');
55
+ const annotateJs = readUi('annotate.js');
56
+ const appJs = readUi('app.js');
57
+ const spec = record.spec;
58
+ // Block bodies (html) are served via /html/b/* iframes, so strip them from
59
+ // the embedded payload and only ship metadata.
60
+ const clientSpec = {
61
+ ...spec,
62
+ blocks: (spec.blocks || []).map(clientBlock),
63
+ questions: spec.questions.map((q) => ({ ...q, blocks: (q.blocks || []).map(clientBlock) })),
64
+ };
65
+ // Tell the client which vendored libraries to lazy-load — true only when a
66
+ // block needs it AND the vendored asset is actually present.
67
+ // Filenames must match what the clients request (blocks.js / kit.js load
68
+ // /vendor/chart.umd.js and /vendor/mermaid.min.js).
69
+ const vendor = {
70
+ chart: specNeeds(spec, 'chart') && vendorPresent('chart.umd.js'),
71
+ mermaid: specNeeds(spec, 'mermaid') && vendorPresent('mermaid.min.js'),
72
+ };
73
+ // Prefill from the LIVE draft at request time (drafts autosave in real time),
74
+ // so a mid-fill page reload restores everything the user already entered.
75
+ const prefill = record.draft
76
+ ? {
77
+ answers: record.draft.answers || {},
78
+ comment: record.draft.comment || '',
79
+ notes: record.draft.notes || {},
80
+ annotations: record.draft.annotations || [],
81
+ }
82
+ : null;
83
+ const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor };
84
+ const json = JSON.stringify(boot).replace(/</g, '\\u003c');
85
+ return html
86
+ .split('__TITLE__').join(escapeHtml(spec.title))
87
+ .split('/*__CSS__*/').join(css)
88
+ .split('/*__BLOCKS_CSS__*/').join(blocksCss)
89
+ .split('/*__ANNOTATE_CSS__*/').join(annotateCss)
90
+ .split('/*__BLOCKS_JS__*/').join(blocksJs)
91
+ .split('/*__ANNOTATE_JS__*/').join(annotateJs)
92
+ .split('/*__APP_JS__*/').join(appJs)
93
+ .split('__BOOT_JSON__').join(json);
94
+ }
95
+
96
+ // Validates + sanitizes an incoming annotations array (from draft/submit).
97
+ // Drops anything that isn't a well-formed annotation object; caps at 500.
98
+ function sanitizeAnnotations(value) {
99
+ if (!Array.isArray(value)) return [];
100
+ const out = [];
101
+ for (const a of value) {
102
+ if (out.length >= 500) break;
103
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
104
+ if (typeof a.text !== 'string' || a.text.length > 5000) continue;
105
+ if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
106
+ out.push(a);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ // Resolves an html block body by id from the board or any question scope.
112
+ function findHtmlBlock(spec, blockId) {
113
+ const scan = (blocks) => (Array.isArray(blocks) ? blocks.find((b) => b && b.id === blockId && b.type === 'html') : undefined);
114
+ const board = scan(spec.blocks);
115
+ if (board) return board;
116
+ for (const q of spec.questions) {
117
+ const hit = scan(q.blocks);
118
+ if (hit) return hit;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ // The board's first html block (legacy /html/board alias).
124
+ function firstBoardHtml(spec) {
125
+ return (spec.blocks || []).find((b) => b && b.type === 'html');
126
+ }
127
+
128
+ // A question's first html block (legacy /html/q/<id> alias).
129
+ function firstQuestionHtml(q) {
130
+ return (q.blocks || []).find((b) => b && b.type === 'html');
131
+ }
132
+
133
+ function sendJson(res, code, obj) {
134
+ if (res.headersSent) return;
135
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
136
+ res.end(JSON.stringify(obj));
137
+ }
138
+
139
+ function sendHtml(res, body) {
140
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
141
+ res.end(body);
142
+ }
143
+
144
+ function sendJs(res, body) {
145
+ res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' });
146
+ res.end(body);
147
+ }
148
+
149
+ // Serves a file from a fixed directory, rejecting any path that escapes it
150
+ // (no traversal). Returns false (404 not written) when the file is missing.
151
+ function sendFromDir(res, dir, name, contentType) {
152
+ const target = path.resolve(dir, name);
153
+ if (target !== dir && !target.startsWith(dir + path.sep)) return false;
154
+ let body;
155
+ try {
156
+ body = fs.readFileSync(target);
157
+ } catch {
158
+ return false;
159
+ }
160
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' });
161
+ res.end(body);
162
+ return true;
163
+ }
164
+
165
+ // Custom-HTML fragments (no <html> tag) get wrapped in a minimal document that
166
+ // matches the user's theme, so e.g. "<b>hi</b>" doesn't paint a stark white
167
+ // block in dark mode. Full documents are served verbatim — their authors can
168
+ // read the ?theme=light|dark query param themselves.
169
+ function wrapFragment(content, theme) {
170
+ if (/<html[\s>]/i.test(content)) return content;
171
+ const dark = theme === 'dark';
172
+ const bg = dark ? '#282624' : '#ffffff';
173
+ const fg = dark ? '#edeae4' : '#1c1b19';
174
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}</body></html>`;
175
+ }
176
+
177
+ function readBody(req, limit = 5 * 1024 * 1024) {
178
+ return new Promise((resolve, reject) => {
179
+ let size = 0;
180
+ const chunks = [];
181
+ req.on('data', (c) => {
182
+ size += c.length;
183
+ if (size > limit) {
184
+ reject(new Error('payload too large'));
185
+ req.destroy();
186
+ } else {
187
+ chunks.push(c);
188
+ }
189
+ });
190
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
191
+ req.on('error', reject);
192
+ });
193
+ }
194
+
195
+ // Serves one board on 127.0.0.1 and resolves `done` when it finishes
196
+ // (submitted / acknowledged / timeout / cancelled). The result is also
197
+ // persisted into the board record so `rly wait` / `rly result` can read it
198
+ // from another process.
199
+ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false }) {
200
+ const record = loadBoard(id);
201
+ if (!record) throw new Error(`board ${id} not found`);
202
+ const spec = record.spec;
203
+
204
+ // Reopening a finished board: seed the draft from its last submitted answers
205
+ // (so the page prefills them), archive the old result, and go back to "open"
206
+ // so wait/result reflect this run only.
207
+ if (record.result) {
208
+ if (record.result.answers) {
209
+ record.draft = {
210
+ answers: record.result.answers,
211
+ comment: record.result.comment || '',
212
+ notes: record.result.notes || {},
213
+ annotations: record.result.annotations || [],
214
+ updatedAt: new Date().toISOString(),
215
+ };
216
+ }
217
+ record.pastResults = [...(record.pastResults || []), record.result].slice(-10);
218
+ record.result = null;
219
+ saveBoard(record);
220
+ }
221
+
222
+ const startedAt = Date.now();
223
+ let status = 'open';
224
+ let finished = false;
225
+ let resolveDone;
226
+ const done = new Promise((r) => {
227
+ resolveDone = r;
228
+ });
229
+
230
+ const server = http.createServer(async (req, res) => {
231
+ const reqUrl = new URL(req.url, 'http://localhost');
232
+ const pathname = reqUrl.pathname;
233
+ const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
234
+ try {
235
+ if (req.method === 'GET' && pathname === '/') {
236
+ sendHtml(res, buildPage(record));
237
+ } else if (req.method === 'GET' && pathname === '/api/board') {
238
+ sendJson(res, 200, { id: record.id, spec, draft: record.draft, result: record.result });
239
+ } else if (req.method === 'GET' && pathname === '/api/status') {
240
+ sendJson(res, 200, { status });
241
+ } else if (req.method === 'GET' && pathname === '/kit.js') {
242
+ if (!sendFromDir(res, UI_DIR, 'kit.js', 'application/javascript; charset=utf-8')) {
243
+ sendJs(res, ''); // kit.js authored concurrently — empty fallback keeps iframes working
244
+ }
245
+ } else if (req.method === 'GET' && pathname.startsWith('/vendor/')) {
246
+ const name = decodeURIComponent(pathname.slice('/vendor/'.length));
247
+ if (!sendFromDir(res, VENDOR_DIR, name, 'application/javascript; charset=utf-8')) {
248
+ sendJson(res, 404, { error: `no vendor file "${name}"` });
249
+ }
250
+ } else if (req.method === 'GET' && pathname.startsWith('/html/b/')) {
251
+ const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
252
+ const block = findHtmlBlock(spec, blockId);
253
+ if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
254
+ sendHtml(res, wrapFragment(block.html || '', theme));
255
+ } else if (req.method === 'GET' && pathname === '/html/board') {
256
+ // Legacy alias → the board's first html block.
257
+ const block = firstBoardHtml(spec);
258
+ sendHtml(res, wrapFragment((block && block.html) || '', theme));
259
+ } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
260
+ const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
261
+ const q = spec.questions.find((q) => q.id === qid);
262
+ if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
263
+ const block = firstQuestionHtml(q);
264
+ sendHtml(res, wrapFragment((block && block.html) || '', theme));
265
+ } else if (req.method === 'POST' && pathname === '/api/pref') {
266
+ const body = JSON.parse((await readBody(req)) || '{}');
267
+ if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
268
+ sendJson(res, 200, { ok: true });
269
+ } else if (req.method === 'POST' && pathname === '/api/draft') {
270
+ const body = JSON.parse((await readBody(req)) || '{}');
271
+ record.draft = {
272
+ answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
273
+ comment: typeof body.comment === 'string' ? body.comment : '',
274
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
275
+ annotations: sanitizeAnnotations(body.annotations),
276
+ updatedAt: new Date().toISOString(),
277
+ };
278
+ saveBoard(record);
279
+ sendJson(res, 200, { ok: true });
280
+ } else if (req.method === 'POST' && pathname === '/api/submit') {
281
+ if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
282
+ const body = JSON.parse((await readBody(req)) || '{}');
283
+ const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
284
+ const skipped = spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
285
+ sendJson(res, 200, { ok: true });
286
+ finish({
287
+ status: spec.questions.length ? 'submitted' : 'acknowledged',
288
+ answers,
289
+ skipped,
290
+ comment: typeof body.comment === 'string' ? body.comment : '',
291
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
292
+ annotations: sanitizeAnnotations(body.annotations),
293
+ });
294
+ } else {
295
+ sendJson(res, 404, { error: 'not found' });
296
+ }
297
+ } catch (err) {
298
+ sendJson(res, 400, { error: String((err && err.message) || err) });
299
+ }
300
+ });
301
+
302
+ await new Promise((resolve, reject) => {
303
+ server.once('error', reject);
304
+ server.listen(port, '127.0.0.1', resolve);
305
+ });
306
+ const actualPort = server.address().port;
307
+ const url = `http://127.0.0.1:${actualPort}/`;
308
+ saveRunning({
309
+ id: record.id,
310
+ pid: process.pid,
311
+ port: actualPort,
312
+ url,
313
+ title: spec.title,
314
+ startedAt: new Date().toISOString(),
315
+ });
316
+
317
+ let timer = null;
318
+ if (timeoutSec > 0) timer = setTimeout(() => finish({ status: 'timeout' }), timeoutSec * 1000);
319
+ const onSignal = () => finish({ status: 'cancelled' });
320
+ process.on('SIGINT', onSignal);
321
+ process.on('SIGTERM', onSignal);
322
+
323
+ function finish(partial) {
324
+ if (finished) return;
325
+ finished = true;
326
+ status = partial.status;
327
+ const result = {
328
+ status: partial.status,
329
+ boardId: record.id,
330
+ title: spec.title,
331
+ url,
332
+ answers: partial.answers ?? null,
333
+ skipped: partial.skipped ?? null,
334
+ comment: partial.comment ?? '',
335
+ notes: partial.notes ?? null,
336
+ annotations: partial.annotations ?? (record.draft?.annotations || []),
337
+ createdAt: record.createdAt,
338
+ finishedAt: new Date().toISOString(),
339
+ durationMs: Date.now() - startedAt,
340
+ };
341
+ // Drafts autosave in real time, so even an abandoned board surfaces the
342
+ // partial answers the user had typed.
343
+ if ((partial.status === 'timeout' || partial.status === 'cancelled') && record.draft) {
344
+ result.draft = record.draft;
345
+ }
346
+ record.result = result;
347
+ saveBoard(record);
348
+ removeRunning(record.id);
349
+ if (timer) clearTimeout(timer);
350
+ process.removeListener('SIGINT', onSignal);
351
+ process.removeListener('SIGTERM', onSignal);
352
+ // Grace period so the success page renders (and auto-closes) first.
353
+ setTimeout(() => {
354
+ try {
355
+ server.closeAllConnections?.();
356
+ } catch {
357
+ // best effort
358
+ }
359
+ server.close(() => resolveDone(result));
360
+ setTimeout(() => resolveDone(result), 1500).unref();
361
+ }, 600);
362
+ }
363
+
364
+ if (open) openUrl(url);
365
+ if (!quiet) {
366
+ process.stderr.write(
367
+ `[relay] ${record.id} open: ${url} (waiting for submit; timeout: ${timeoutSec > 0 ? `${timeoutSec}s` : 'none'})\n`
368
+ );
369
+ }
370
+ return { url, port: actualPort, done };
371
+ }