grilling-workbench 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/runtime.js ADDED
@@ -0,0 +1,59 @@
1
+ import { readFile, rm } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { randomBytes, randomUUID } from 'node:crypto';
4
+ import { once } from 'node:events';
5
+ import { createWorkbenchServer } from './server.js';
6
+ import { createSubmissionSocket } from './submission-socket.js';
7
+ import { acquireLock, atomicWrite } from './storage.js';
8
+
9
+ export function validPort(value) {
10
+ if (!Number.isInteger(value) || value < 0 || value > 65535) throw new Error('Ports must be integers from 0 to 65535 (0 chooses a free port).');
11
+ return value;
12
+ }
13
+
14
+ export async function readRuntime(dataDir) {
15
+ let info;
16
+ try { info = JSON.parse(await readFile(join(dataDir, 'runtime.json'), 'utf8')); }
17
+ catch { throw new Error('No readable running-session descriptor. Start serve for this session first.'); }
18
+ if (info.protocolVersion !== 1 || info.dataDir !== resolve(dataDir) || typeof info.instanceId !== 'string' || typeof info.token !== 'string' || !/^[a-f0-9]{64}$/.test(info.token)
19
+ || !Number.isInteger(info.signalPort) || info.signalPort < 1 || info.signalPort > 65535
20
+ || !Number.isInteger(info.port) || info.port < 1 || info.port > 65535
21
+ || info.url !== `http://127.0.0.1:${info.port}/`) throw new Error('Invalid running-session descriptor. Restart this session.');
22
+ return info;
23
+ }
24
+
25
+ export async function startWorkbench({ dataDir, questionsPath, port = 0, signalPort = 0 }) {
26
+ validPort(port); validPort(signalPort);
27
+ dataDir = resolve(dataDir);
28
+ const release = await acquireLock(join(dataDir, 'server.lock'));
29
+ const server = createWorkbenchServer({ dataDir, questionsPath });
30
+ server.instanceId = randomUUID();
31
+ const token = randomBytes(32).toString('hex');
32
+ const signals = createSubmissionSocket(server, { dataDir, token });
33
+ let closing;
34
+ const close = () => closing ||= (async () => {
35
+ signals.closeClients();
36
+ if (signals.listening) await new Promise(resolve => signals.close(resolve));
37
+ if (server.listening) await new Promise(resolve => server.close(resolve));
38
+ await server.drain();
39
+ await rm(join(dataDir, 'runtime.json'), { force: true });
40
+ await release();
41
+ })();
42
+ try {
43
+ await server.prepare();
44
+ signals.listen(signalPort, '127.0.0.1'); await once(signals, 'listening');
45
+ server.listen(port, '127.0.0.1'); await once(server, 'listening');
46
+ const info = { protocolVersion: 1, instanceId: server.instanceId, pid: process.pid, dataDir, port: server.address().port, signalPort: signals.address().port, url: `http://127.0.0.1:${server.address().port}/`, token };
47
+ await atomicWrite(join(dataDir, 'runtime.json'), `${JSON.stringify(info, null, 2)}\n`);
48
+ return { server, signals, info, close };
49
+ } catch (error) { await close(); throw error; }
50
+ }
51
+
52
+ export function handleShutdown(running) {
53
+ const shutdown = () => { void running.close().catch(error => { console.error(error.message); process.exitCode = 1; }); };
54
+ process.once('SIGINT', shutdown);
55
+ process.once('SIGTERM', shutdown);
56
+ for (const server of [running.server, running.signals]) server.on('error', error => {
57
+ console.error(error.message); process.exitCode = 1; shutdown();
58
+ });
59
+ }
package/src/server.js ADDED
@@ -0,0 +1,110 @@
1
+ import http from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { initialState, restoreState, transition, validateQuestionnaire } from './core.js';
6
+ import { atomicWrite } from './storage.js';
7
+ import { readReceipts } from './delivery.js';
8
+ export { atomicWrite } from './storage.js';
9
+
10
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const publicFiles = { '/': ['public/index.html', 'text/html'], '/app.js': ['public/app.js', 'text/javascript'], '/styles.css': ['public/styles.css', 'text/css'], '/core.js': ['src/core.js', 'text/javascript'] };
12
+
13
+ export function createWorkbenchServer({ dataDir = join(root, '.workbench'), questionsPath = join(root, 'data/questions.json'), write = atomicWrite } = {}) {
14
+ const statePath = join(dataDir, 'session.json');
15
+ let saved;
16
+ let queue = Promise.resolve();
17
+ const serial = fn => {
18
+ const next = queue.then(fn);
19
+ queue = next.catch(() => {});
20
+ return next;
21
+ };
22
+ async function commit(next) {
23
+ const previousIds = new Set(saved?.state.submissions.map(s => s.id));
24
+ try { await write(statePath, `${JSON.stringify(next, null, 2)}\n`); }
25
+ catch { throw new Error('The form could not be saved on this computer. Keep the page open and retry.'); }
26
+ saved = next;
27
+ for (const submission of saved.state.submissions) {
28
+ if (!previousIds.has(submission.id)) server.emit('submission', structuredClone(submission));
29
+ }
30
+ return saved;
31
+ }
32
+ async function load() {
33
+ let doc;
34
+ try { doc = validateQuestionnaire(JSON.parse(await readFile(questionsPath, 'utf8'))); }
35
+ catch { throw new Error('Question definitions could not be loaded. Ask the agent to check the question file, then retry. Saved answers have been kept.'); }
36
+ if (!saved) {
37
+ let raw;
38
+ try { raw = JSON.parse(await readFile(statePath, 'utf8')); }
39
+ catch (error) { if (error.code !== 'ENOENT') throw new Error('Saved work could not be read. The file has been kept; restore a valid backup to continue.'); }
40
+ if (raw) {
41
+ if (!Number.isSafeInteger(raw.version) || raw.version < 1 || !Array.isArray(raw.operations) || !raw.operations.every(id => typeof id === 'string')) throw new Error('Saved work has an unsupported format. The file has been kept.');
42
+ saved = { version: raw.version, operations: raw.operations, state: restoreState(raw.state) };
43
+ } else await commit({ version: 1, operations: [], state: initialState(doc) });
44
+ }
45
+ const state = transition(saved.state, { type: 'definitions', questionnaire: doc });
46
+ if (state !== saved.state) await commit({ ...saved, version: saved.version + 1, state });
47
+ return saved;
48
+ }
49
+ const envelope = value => ({ version: value.version, state: value.state });
50
+ function respond(res, status, body) {
51
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
52
+ res.end(JSON.stringify(body));
53
+ }
54
+ async function readBody(req) {
55
+ if (req.headers['content-type'] !== 'application/json') throw Object.assign(new Error('JSON is required.'), { status: 415 });
56
+ let body = '';
57
+ for await (const chunk of req) {
58
+ body += chunk;
59
+ if (Buffer.byteLength(body) > 2_000_000) throw Object.assign(new Error('This update is too large.'), { status: 413 });
60
+ }
61
+ try { return JSON.parse(body); } catch { throw Object.assign(new Error('Invalid JSON.'), { status: 400 }); }
62
+ }
63
+ const server = http.createServer(async (req, res) => {
64
+ res.setHeader('X-Content-Type-Options', 'nosniff');
65
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'");
66
+ const host = req.headers.host || '';
67
+ if (!/^(127\.0\.0\.1|localhost):\d+$/.test(host) || (req.headers.origin && req.headers.origin !== `http://${host}`)) return respond(res, 403, { error: 'This workbench accepts requests from its own local page only.' });
68
+ const path = new URL(req.url, `http://${host}`).pathname;
69
+ try {
70
+ if (path === '/api/health' && req.method === 'GET') {
71
+ await serial(load);
72
+ return respond(res, 200, { status: 'ok', instanceId: server.instanceId ?? null });
73
+ }
74
+ if (path === '/api/delivery' && req.method === 'GET') return respond(res, 200, await readReceipts(dataDir));
75
+ if (path === '/api/session' && req.method === 'GET') return respond(res, 200, envelope(await serial(load)));
76
+ if (path === '/api/actions' && req.method === 'POST') {
77
+ const input = await readBody(req);
78
+ const result = await serial(async () => {
79
+ await load();
80
+ if (!input || typeof input.requestId !== 'string' || input.requestId.length > 100 || !input.requestId.length) throw Object.assign(new Error('A request ID is required.'), { status: 400 });
81
+ if (saved.operations.includes(input.requestId)) return envelope(saved);
82
+ // A lost submission response can be retried even after another save.
83
+ if (input.action?.type === 'submit' && saved.state.submissions.some(s => s.id === input.action.review?.id)) {
84
+ transition(saved.state, input.action);
85
+ return envelope(saved);
86
+ }
87
+ if (input.version !== saved.version) throw Object.assign(new Error('The saved form changed in another tab or the questions were updated. Your unsaved work is still available here.'), { status: 409, current: envelope(saved) });
88
+ if (!['edit', 'defer', 'adopt', 'submit'].includes(input.action?.type)) throw Object.assign(new Error('Unsupported form action.'), { status: 400 });
89
+ let state;
90
+ try { state = transition(saved.state, input.action); }
91
+ catch (error) { throw Object.assign(error, { status: 422 }); }
92
+ return envelope(await commit({ version: saved.version + 1, operations: [...saved.operations, input.requestId].slice(-100), state }));
93
+ });
94
+ return respond(res, 200, result);
95
+ }
96
+ if (req.method === 'GET' && publicFiles[path]) {
97
+ const [file, type] = publicFiles[path];
98
+ const body = await readFile(join(root, file));
99
+ res.writeHead(200, { 'Content-Type': `${type}; charset=utf-8`, 'Cache-Control': 'no-store' });
100
+ return res.end(body);
101
+ }
102
+ respond(res, 404, { error: 'Not found.' });
103
+ } catch (error) {
104
+ respond(res, error.status || 500, { error: error.status ? error.message : `Could not load or save the form. ${error.message}`, ...(error.current ? { current: error.current } : {}) });
105
+ }
106
+ });
107
+ server.prepare = () => serial(load);
108
+ server.drain = () => queue;
109
+ return server;
110
+ }
package/src/storage.js ADDED
@@ -0,0 +1,35 @@
1
+ import { mkdir, open, rename, rm } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export async function atomicWrite(path, text) {
6
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
7
+ const temporary = `${path}.${randomUUID()}.tmp`;
8
+ let handle;
9
+ try {
10
+ handle = await open(temporary, 'wx', 0o600);
11
+ await handle.writeFile(text);
12
+ await handle.sync();
13
+ await handle.close();
14
+ handle = undefined;
15
+ await rename(temporary, path);
16
+ } finally {
17
+ if (handle) await handle.close();
18
+ await rm(temporary, { force: true });
19
+ }
20
+ }
21
+
22
+ // Never steal a lock: a reused PID or slow writer must not lose ownership.
23
+ export async function acquireLock(path) {
24
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
25
+ let handle;
26
+ try { handle = await open(path, 'wx', 0o600); }
27
+ catch (error) {
28
+ if (error.code === 'EEXIST') throw new Error(`Session is locked: ${path}. Stop its owner before recovery; see the deployment guide.`);
29
+ throw error;
30
+ }
31
+ try { await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })); }
32
+ catch (error) { await handle.close(); await rm(path, { force: true }); throw error; }
33
+ await handle.close();
34
+ return () => rm(path, { force: true });
35
+ }
@@ -0,0 +1,87 @@
1
+ import net from 'node:net';
2
+ import { pendingSubmissions } from './delivery.js';
3
+
4
+ // One newline-delimited JSON event per saved form. No interval or polling.
5
+ export function createSubmissionSocket(workbench, { dataDir, token } = {}) {
6
+ const clients = new Set();
7
+ const socketServer = net.createServer(socket => {
8
+ clients.add(socket);
9
+ const sent = new Set();
10
+ const send = submission => {
11
+ if (socket.destroyed || sent.has(submission.id)) return;
12
+ sent.add(submission.id);
13
+ socket.write(`${JSON.stringify({ type: 'submission', submission })}\n`);
14
+ };
15
+ socket.on('error', () => socket.destroy());
16
+ socket.on('close', () => { clients.delete(socket); workbench.off('submission', send); });
17
+ function subscribe() {
18
+ // Subscribe before replay so a submission cannot fall between the two.
19
+ workbench.on('submission', send);
20
+ void pendingSubmissions(dataDir).then(submissions => {
21
+ if (socket.destroyed) return;
22
+ for (const submission of submissions) send(submission);
23
+ socket.write(`${JSON.stringify({ type: 'ready' })}\n`);
24
+ }).catch(() => {
25
+ socket.end(`${JSON.stringify({ type: 'error', error: 'Saved submissions could not be read. They have been retained.' })}\n`);
26
+ });
27
+ }
28
+ if (!token) subscribe(); // Compatibility for direct library users.
29
+ else {
30
+ let input = '';
31
+ const timer = setTimeout(() => socket.destroy(), 5000);
32
+ timer.unref();
33
+ socket.on('close', () => clearTimeout(timer));
34
+ const authenticate = chunk => {
35
+ input += chunk;
36
+ if (input.length > 4096) return socket.destroy();
37
+ const newline = input.indexOf('\n');
38
+ if (newline < 0) return;
39
+ socket.off('data', authenticate);
40
+ clearTimeout(timer);
41
+ let request;
42
+ try { request = JSON.parse(input.slice(0, newline)); } catch { return socket.destroy(); }
43
+ if (request.type !== 'subscribe' || request.protocolVersion !== 1 || request.token !== token) {
44
+ return socket.end(`${JSON.stringify({ type: 'error', error: 'Wrong session or unsupported socket protocol. Restart the listener with the current session.' })}\n`);
45
+ }
46
+ subscribe();
47
+ };
48
+ socket.setEncoding('utf8');
49
+ socket.on('data', authenticate);
50
+ }
51
+ });
52
+ socketServer.closeClients = () => { for (const socket of clients) socket.destroy(); };
53
+ workbench.on('close', () => { socketServer.closeClients(); if (socketServer.listening) socketServer.close(); });
54
+ return socketServer;
55
+ }
56
+
57
+ export function waitForSubmission({ port = 4311, host = '127.0.0.1', signal, token, onReady = () => {} } = {}) {
58
+ if (!['127.0.0.1', 'localhost'].includes(host)) throw new Error('The submission listener must be local.');
59
+ return new Promise((resolve, reject) => {
60
+ const socket = net.createConnection({ port, host, signal });
61
+ if (token) socket.on('connect', () => socket.write(`${JSON.stringify({ type: 'subscribe', protocolVersion: 1, token })}\n`));
62
+ let buffer = '', settled = false;
63
+ const finish = (error, submission) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ socket.destroy();
67
+ if (error) reject(error); else resolve(submission);
68
+ };
69
+ socket.setEncoding('utf8');
70
+ socket.on('data', chunk => {
71
+ buffer += chunk;
72
+ if (buffer.length > 20_000_000) return finish(new Error('The submission event is too large. The saved form is retained.'));
73
+ let newline;
74
+ while (!settled && (newline = buffer.indexOf('\n')) >= 0) {
75
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
76
+ try {
77
+ const event = JSON.parse(line);
78
+ if (event.type === 'submission' && event.submission?.id) finish(null, event.submission);
79
+ else if (event.type === 'ready') onReady();
80
+ else if (event.type === 'error') finish(new Error(event.error));
81
+ } catch (error) { finish(error); }
82
+ }
83
+ });
84
+ socket.on('error', error => finish(error));
85
+ socket.on('close', () => finish(new Error('The submission socket disconnected. Reconnect to receive the saved form.')));
86
+ });
87
+ }