@myrtilleio/redline 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/README.md +281 -0
- package/bin/redline.js +375 -0
- package/client/boot.js +146 -0
- package/client/check.js +926 -0
- package/client/composer.js +384 -0
- package/client/core.js +1610 -0
- package/client/devices.js +273 -0
- package/client/edits.js +389 -0
- package/client/keyboard.js +110 -0
- package/client/panel.js +222 -0
- package/client/pen.js +324 -0
- package/client/references.js +114 -0
- package/client/select.js +180 -0
- package/client/shots.js +357 -0
- package/client/variants.js +117 -0
- package/examples/demo-site/hero.svg +18 -0
- package/examples/demo-site/index.html +96 -0
- package/examples/demo-site/styles.css +64 -0
- package/integrations/claude-code/redline.md +27 -0
- package/package.json +42 -0
- package/src/account.js +101 -0
- package/src/bundle.js +33 -0
- package/src/demo.js +137 -0
- package/src/format.js +346 -0
- package/src/guards.js +108 -0
- package/src/inject.js +15 -0
- package/src/mcp.js +380 -0
- package/src/report.js +199 -0
- package/src/server.js +477 -0
- package/src/shots.js +311 -0
- package/src/store.js +544 -0
- package/src/sync.js +126 -0
- package/src/taste.js +80 -0
- package/src/verify.js +71 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import tls from 'node:tls';
|
|
5
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { watch } from 'node:fs';
|
|
7
|
+
import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
8
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
9
|
+
import { buildClient } from './bundle.js';
|
|
10
|
+
import { formatMarkdown } from './format.js';
|
|
11
|
+
import { reportHtml, reportModel } from './report.js';
|
|
12
|
+
import { CLIENT_PATH, injectScript } from './inject.js';
|
|
13
|
+
import { ShotService, checkContext } from './shots.js';
|
|
14
|
+
import { Store } from './store.js';
|
|
15
|
+
|
|
16
|
+
const API = '/__redline/api';
|
|
17
|
+
const FILES = '/__redline/files/';
|
|
18
|
+
const MAX_BODY = 12 * 1024 * 1024; // room for pasted reference images
|
|
19
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git']);
|
|
20
|
+
|
|
21
|
+
const MIME = {
|
|
22
|
+
'.html': 'text/html; charset=utf-8',
|
|
23
|
+
'.htm': 'text/html; charset=utf-8',
|
|
24
|
+
'.css': 'text/css; charset=utf-8',
|
|
25
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
26
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
27
|
+
'.json': 'application/json; charset=utf-8',
|
|
28
|
+
'.svg': 'image/svg+xml',
|
|
29
|
+
'.png': 'image/png',
|
|
30
|
+
'.jpg': 'image/jpeg',
|
|
31
|
+
'.jpeg': 'image/jpeg',
|
|
32
|
+
'.gif': 'image/gif',
|
|
33
|
+
'.webp': 'image/webp',
|
|
34
|
+
'.avif': 'image/avif',
|
|
35
|
+
'.ico': 'image/x-icon',
|
|
36
|
+
'.woff': 'font/woff',
|
|
37
|
+
'.woff2': 'font/woff2',
|
|
38
|
+
'.ttf': 'font/ttf',
|
|
39
|
+
'.otf': 'font/otf',
|
|
40
|
+
'.mp4': 'video/mp4',
|
|
41
|
+
'.webm': 'video/webm',
|
|
42
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
43
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
44
|
+
'.xml': 'application/xml',
|
|
45
|
+
'.pdf': 'application/pdf',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const CORS = {
|
|
49
|
+
'access-control-allow-origin': '*',
|
|
50
|
+
'access-control-allow-methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
51
|
+
'access-control-allow-headers': 'content-type, x-redline-key',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- access
|
|
55
|
+
// Who may read and write feedback. You, on this machine, from the page Redline
|
|
56
|
+
// serves: everything, no key (a request from another website, or through a
|
|
57
|
+
// hostname that merely resolves here, doesn't count). Anyone else needs a key:
|
|
58
|
+
// the owner key gives everything; the review key, carried by a shared Check link,
|
|
59
|
+
// lets someone read the review and give verdicts, nothing more.
|
|
60
|
+
|
|
61
|
+
const LOOPBACK = /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;
|
|
62
|
+
function localHostHeader(host) {
|
|
63
|
+
const name = String(host || '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
64
|
+
return name === 'localhost' || name.endsWith('.localhost') || LOOPBACK.test(name);
|
|
65
|
+
}
|
|
66
|
+
function sameKey(given, key) {
|
|
67
|
+
const a = Buffer.from(String(given)), b = Buffer.from(key);
|
|
68
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 'owner' | 'review' | null for a request. */
|
|
72
|
+
export function accessOf(req, url, keys) {
|
|
73
|
+
const given = req.headers['x-redline-key'] || url.searchParams.get('rk');
|
|
74
|
+
if (given && sameKey(given, keys.owner)) return 'owner';
|
|
75
|
+
if (given && sameKey(given, keys.review)) return 'review';
|
|
76
|
+
if (!LOOPBACK.test(req.socket.remoteAddress || '') || !localHostHeader(req.headers.host)) return null;
|
|
77
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return null;
|
|
78
|
+
const origin = req.headers.origin;
|
|
79
|
+
if (origin) {
|
|
80
|
+
try {
|
|
81
|
+
if (new URL(origin).host !== req.headers.host) return null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return 'owner';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// What a reviewer on a Check link may change: verdicts, not the review itself.
|
|
90
|
+
const REVIEW_FIELDS = new Set(['accepted', 'rebaseline', 'sendBack', 'unguard', 'dynamic', 'status', 'draft', 'reply', 'target']);
|
|
91
|
+
function reviewPatch(body) {
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const [k, v] of Object.entries(body || {})) if (REVIEW_FIELDS.has(k)) out[k] = v;
|
|
94
|
+
if (body && body.sendBack === true && body.verdict === 'good') out.verdict = 'good';
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function send(res, status, body, headers = {}) {
|
|
99
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body));
|
|
100
|
+
res.writeHead(status, { 'content-length': buf.length, 'cache-control': 'no-store', ...headers });
|
|
101
|
+
res.end(buf);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function json(res, status, data) {
|
|
105
|
+
send(res, status, JSON.stringify(data), { 'content-type': 'application/json; charset=utf-8', ...CORS });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readBody(req) {
|
|
109
|
+
return new Promise((ok, fail) => {
|
|
110
|
+
const chunks = [];
|
|
111
|
+
let size = 0;
|
|
112
|
+
req.on('data', (c) => {
|
|
113
|
+
size += c.length;
|
|
114
|
+
if (size > MAX_BODY) {
|
|
115
|
+
fail(Object.assign(new Error('body too large'), { status: 413 }));
|
|
116
|
+
req.destroy();
|
|
117
|
+
} else chunks.push(c);
|
|
118
|
+
});
|
|
119
|
+
req.on('end', () => {
|
|
120
|
+
try {
|
|
121
|
+
ok(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
|
|
122
|
+
} catch {
|
|
123
|
+
fail(Object.assign(new Error('invalid JSON'), { status: 400 }));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
req.on('error', fail);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Let the injected overlay run under the site's Content-Security-Policy: allow its
|
|
132
|
+
* script and its API/event-stream requests on the proxy's own origin. Policies
|
|
133
|
+
* using 'strict-dynamic' ignore host sources, so those get a nonce instead (they
|
|
134
|
+
* already rely on nonces, so a new one doesn't disable 'unsafe-inline').
|
|
135
|
+
*/
|
|
136
|
+
export function patchCsp(header, nonce) {
|
|
137
|
+
return header
|
|
138
|
+
.split(',')
|
|
139
|
+
.map((policy) => {
|
|
140
|
+
const dirs = policy.split(';').map((d) => d.trim()).filter(Boolean);
|
|
141
|
+
const find = (name) => dirs.findIndex((d) => d.split(/\s+/)[0].toLowerCase() === name);
|
|
142
|
+
const allow = (name, token) => {
|
|
143
|
+
let i = find(name);
|
|
144
|
+
if (i < 0) {
|
|
145
|
+
const fallback = find('default-src');
|
|
146
|
+
if (fallback < 0) return;
|
|
147
|
+
dirs.push(name + dirs[fallback].slice('default-src'.length));
|
|
148
|
+
i = dirs.length - 1;
|
|
149
|
+
}
|
|
150
|
+
if (/'none'/.test(dirs[i])) dirs[i] = name;
|
|
151
|
+
dirs[i] += ' ' + token;
|
|
152
|
+
};
|
|
153
|
+
for (const name of ['script-src', 'script-src-elem']) {
|
|
154
|
+
if (name === 'script-src-elem' && find(name) < 0) continue;
|
|
155
|
+
const i = find(name) >= 0 ? find(name) : find('default-src');
|
|
156
|
+
const strict = i >= 0 && /'strict-dynamic'/.test(dirs[i]);
|
|
157
|
+
allow(name, strict ? `'nonce-${nonce}'` : "'self'");
|
|
158
|
+
}
|
|
159
|
+
allow('connect-src', "'self'");
|
|
160
|
+
// Screenshots and reference images; skip when images are already allowed from anywhere.
|
|
161
|
+
const img = find('img-src') >= 0 ? dirs[find('img-src')] : find('default-src') >= 0 ? dirs[find('default-src')] : null;
|
|
162
|
+
if (img !== null && !/\s\*(\s|$)/.test(img)) allow('img-src', "'self' data: blob:");
|
|
163
|
+
return dirs.join('; ');
|
|
164
|
+
})
|
|
165
|
+
.join(', ');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function escapeHtml(s) {
|
|
169
|
+
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create the Redline server.
|
|
174
|
+
* - `root`: serve a static site directory, injecting the overlay and live-reloading on change; or
|
|
175
|
+
* - `target`: reverse-proxy a running dev server (e.g. http://localhost:5173), injecting the overlay.
|
|
176
|
+
* Feedback is stored in `storeDir` (feedback.json + FEEDBACK.md).
|
|
177
|
+
*/
|
|
178
|
+
export function createRedlineServer({ root, target, storeDir, log = () => {}, shots: wantShots = true, keys: givenKeys, demo = false }) {
|
|
179
|
+
if (!root === !target) throw new Error('pass exactly one of root or target');
|
|
180
|
+
// New keys every run unless given: a shared link stops working when the review does.
|
|
181
|
+
const keys = { owner: randomBytes(12).toString('base64url'), review: randomBytes(12).toString('base64url'), ...(givenKeys || {}) };
|
|
182
|
+
const store = new Store(storeDir);
|
|
183
|
+
let server;
|
|
184
|
+
const shots = new ShotService({
|
|
185
|
+
store,
|
|
186
|
+
log,
|
|
187
|
+
baseUrl: () => `http://127.0.0.1:${server.address().port}`,
|
|
188
|
+
onShot: (key, a) => a && broadcast('annotations', { page: a.page }),
|
|
189
|
+
...(wantShots ? {} : { chrome: null }),
|
|
190
|
+
});
|
|
191
|
+
const clients = new Set();
|
|
192
|
+
const watchers = [];
|
|
193
|
+
const upstream = target ? new URL(target) : null;
|
|
194
|
+
const siteRoot = root ? resolve(root) : null;
|
|
195
|
+
|
|
196
|
+
function broadcast(event, data = {}) {
|
|
197
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
198
|
+
for (const res of clients) res.write(msg);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function debounce(fn, ms) {
|
|
202
|
+
let t;
|
|
203
|
+
return (...args) => {
|
|
204
|
+
clearTimeout(t);
|
|
205
|
+
t = setTimeout(() => fn(...args), ms);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function api(req, res, url) {
|
|
210
|
+
const rest = url.pathname.slice(API.length);
|
|
211
|
+
const id = rest.startsWith('/annotations/') ? decodeURIComponent(rest.slice('/annotations/'.length)) : null;
|
|
212
|
+
const role = accessOf(req, url, keys);
|
|
213
|
+
if (!role) return json(res, 401, { error: 'Redline: this needs a key. Open the link you were given, or ask for a new one.' });
|
|
214
|
+
if (role === 'review') {
|
|
215
|
+
const allowed = req.method === 'GET' || (id && req.method === 'PATCH') || (rest === '/send' && req.method === 'POST') ||
|
|
216
|
+
(rest.startsWith('/rounds/') && req.method === 'PATCH');
|
|
217
|
+
if (!allowed) return json(res, 403, { error: 'Redline: a Check link can give verdicts, not change the review' });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (rest === '/events' && req.method === 'GET') {
|
|
221
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', ...CORS });
|
|
222
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ mode: root ? 'serve' : 'proxy' })}\n\n`);
|
|
223
|
+
clients.add(res);
|
|
224
|
+
req.on('close', () => clients.delete(res));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (rest === '/annotations' && req.method === 'GET') {
|
|
228
|
+
return json(res, 200, await store.list(url.searchParams.get('page')));
|
|
229
|
+
}
|
|
230
|
+
if (rest === '/annotations' && req.method === 'POST') {
|
|
231
|
+
const a = await store.create(await readBody(req));
|
|
232
|
+
broadcast('annotations', { page: a.page });
|
|
233
|
+
log(`+ ${a.verdict} ${a.kind} on ${a.page}${a.comment ? `: ${a.comment.slice(0, 60)}` : ''}`);
|
|
234
|
+
return json(res, 201, a);
|
|
235
|
+
}
|
|
236
|
+
if (id && req.method === 'PATCH') {
|
|
237
|
+
const body = await readBody(req);
|
|
238
|
+
const a = await store.update(id, role === 'review' ? reviewPatch(body) : body);
|
|
239
|
+
if (!a) return json(res, 404, { error: 'not found' });
|
|
240
|
+
broadcast('annotations', { page: a.page });
|
|
241
|
+
return json(res, 200, a);
|
|
242
|
+
}
|
|
243
|
+
if (id && req.method === 'DELETE') {
|
|
244
|
+
const ok = await store.remove(id);
|
|
245
|
+
broadcast('annotations', {});
|
|
246
|
+
return json(res, ok ? 200 : 404, { ok });
|
|
247
|
+
}
|
|
248
|
+
if (rest === '/assets' && req.method === 'POST') {
|
|
249
|
+
const { dataUrl } = await readBody(req);
|
|
250
|
+
return json(res, 201, { path: await store.saveAsset(dataUrl) });
|
|
251
|
+
}
|
|
252
|
+
if (rest === '/info' && req.method === 'GET') {
|
|
253
|
+
// The owner gets the review key, to build Check links with.
|
|
254
|
+
return json(res, 200, { mode: root ? 'serve' : 'proxy', shots: shots.available, role, demo, ...(role === 'owner' ? { reviewKey: keys.review } : {}) });
|
|
255
|
+
}
|
|
256
|
+
// Turn-taking: send the drafts as a round; read the rounds; answer a round's report.
|
|
257
|
+
if (rest === '/send' && req.method === 'POST') {
|
|
258
|
+
const round = await store.send();
|
|
259
|
+
if (round) log(`→ round ${round.n} sent to the agent (${round.count} item${round.count === 1 ? '' : 's'})`);
|
|
260
|
+
return json(res, round ? 201 : 200, round || { nothing: true });
|
|
261
|
+
}
|
|
262
|
+
if (rest === '/rounds' && req.method === 'GET') return json(res, 200, await store.rounds());
|
|
263
|
+
if (rest.startsWith('/rounds/') && req.method === 'PATCH') {
|
|
264
|
+
return json(res, 200, await store.answerRound(rest.slice('/rounds/'.length), await readBody(req)));
|
|
265
|
+
}
|
|
266
|
+
// The round as one shareable page (the ⋯ menu opens it).
|
|
267
|
+
if (rest === '/report' && req.method === 'GET') {
|
|
268
|
+
const model = reportModel(await store.all(), await store.rounds(), url.searchParams.get('round'));
|
|
269
|
+
return send(res, 200, await reportHtml(model, { storeDir }), { 'content-type': 'text/html; charset=utf-8' });
|
|
270
|
+
}
|
|
271
|
+
if (rest === '/prompt' && req.method === 'GET') {
|
|
272
|
+
const page = url.searchParams.get('page');
|
|
273
|
+
const list = await store.list(page);
|
|
274
|
+
const md = formatMarkdown(list, { cli: true, includeResolved: url.searchParams.has('resolved'), rounds: (await store.rounds()).rounds, taste: await store.taste() });
|
|
275
|
+
return send(res, 200, md, { 'content-type': 'text/markdown; charset=utf-8', ...CORS });
|
|
276
|
+
}
|
|
277
|
+
return json(res, 404, { error: 'unknown endpoint' });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function serveStatic(req, res, url) {
|
|
281
|
+
let pathname;
|
|
282
|
+
try {
|
|
283
|
+
pathname = decodeURIComponent(url.pathname);
|
|
284
|
+
} catch {
|
|
285
|
+
return send(res, 400, 'bad path');
|
|
286
|
+
}
|
|
287
|
+
let file = resolve(siteRoot, '.' + pathname);
|
|
288
|
+
if (file !== siteRoot && !file.startsWith(siteRoot + sep)) return send(res, 403, 'forbidden');
|
|
289
|
+
if (relative(siteRoot, file).split(sep).some((p) => p.startsWith('.') && p.length > 1)) return send(res, 404, 'not found');
|
|
290
|
+
|
|
291
|
+
let info = await stat(file).catch(() => null);
|
|
292
|
+
if (info?.isDirectory()) {
|
|
293
|
+
if (!pathname.endsWith('/')) return send(res, 301, '', { location: url.pathname + '/' + url.search });
|
|
294
|
+
const index = join(file, 'index.html');
|
|
295
|
+
const indexInfo = await stat(index).catch(() => null);
|
|
296
|
+
if (!indexInfo) return listing(res, file, pathname);
|
|
297
|
+
file = index;
|
|
298
|
+
info = indexInfo;
|
|
299
|
+
}
|
|
300
|
+
if (!info && !extname(file)) {
|
|
301
|
+
const html = file + '.html';
|
|
302
|
+
info = await stat(html).catch(() => null);
|
|
303
|
+
if (info) file = html;
|
|
304
|
+
}
|
|
305
|
+
if (!info) return send(res, 404, injectScript('<!doctype html><title>Not found</title><h1>404</h1>'), { 'content-type': MIME['.html'] });
|
|
306
|
+
|
|
307
|
+
const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream';
|
|
308
|
+
let body = await readFile(file);
|
|
309
|
+
if (type.startsWith('text/html')) body = Buffer.from(injectScript(body.toString('utf8')));
|
|
310
|
+
send(res, 200, req.method === 'HEAD' ? '' : body, { 'content-type': type });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function listing(res, dir, pathname) {
|
|
314
|
+
const entries = (await readdir(dir, { withFileTypes: true })).filter((e) => !e.name.startsWith('.') && !IGNORED_DIRS.has(e.name));
|
|
315
|
+
entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
316
|
+
const items = entries
|
|
317
|
+
.map((e) => {
|
|
318
|
+
const name = e.name + (e.isDirectory() ? '/' : '');
|
|
319
|
+
return `<li><a href="${escapeHtml(encodeURI(pathname + name))}">${escapeHtml(name)}</a></li>`;
|
|
320
|
+
})
|
|
321
|
+
.join('');
|
|
322
|
+
const html = `<!doctype html><meta charset="utf-8"><title>${escapeHtml(pathname)}</title>
|
|
323
|
+
<style>body{font:15px/1.6 system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px}a{color:#2563eb}</style>
|
|
324
|
+
<h1>Index of ${escapeHtml(pathname)}</h1><ul>${items || '<li>(empty)</li>'}</ul>`;
|
|
325
|
+
send(res, 200, injectScript(html), { 'content-type': MIME['.html'] });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function proxy(req, res) {
|
|
329
|
+
const lib = upstream.protocol === 'https:' ? https : http;
|
|
330
|
+
const headers = { ...req.headers, host: upstream.host, 'accept-encoding': 'identity' };
|
|
331
|
+
const up = lib.request(
|
|
332
|
+
{ protocol: upstream.protocol, hostname: upstream.hostname, port: upstream.port, method: req.method, path: req.url, headers },
|
|
333
|
+
(upRes) => {
|
|
334
|
+
const out = { ...upRes.headers };
|
|
335
|
+
if (typeof out.location === 'string' && out.location.startsWith(upstream.origin)) {
|
|
336
|
+
out.location = out.location.slice(upstream.origin.length) || '/';
|
|
337
|
+
}
|
|
338
|
+
const isHtml = String(out['content-type'] || '').includes('text/html');
|
|
339
|
+
if (!isHtml || req.method === 'HEAD') {
|
|
340
|
+
res.writeHead(upRes.statusCode, out);
|
|
341
|
+
return upRes.pipe(res);
|
|
342
|
+
}
|
|
343
|
+
const chunks = [];
|
|
344
|
+
upRes.on('data', (c) => chunks.push(c));
|
|
345
|
+
upRes.on('error', () => res.destroy());
|
|
346
|
+
upRes.on('end', () => {
|
|
347
|
+
if (res.headersSent || !upRes.complete) return res.destroy();
|
|
348
|
+
const nonce = randomBytes(12).toString('base64');
|
|
349
|
+
for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
|
|
350
|
+
if (out[name]) out[name] = [].concat(out[name]).map((v) => patchCsp(v, nonce));
|
|
351
|
+
}
|
|
352
|
+
const tag = `<script src="${CLIENT_PATH}" defer data-redline nonce="${nonce}"></script>`;
|
|
353
|
+
const body = Buffer.from(injectScript(Buffer.concat(chunks).toString('utf8'), tag));
|
|
354
|
+
delete out['content-encoding'];
|
|
355
|
+
delete out['transfer-encoding'];
|
|
356
|
+
out['content-length'] = body.length;
|
|
357
|
+
res.writeHead(upRes.statusCode, out);
|
|
358
|
+
res.end(body);
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
up.on('error', (err) => {
|
|
363
|
+
if (res.headersSent) return res.destroy();
|
|
364
|
+
send(res, 502, `Redline could not reach ${upstream.origin}: ${err.message}`, { 'content-type': 'text/plain; charset=utf-8' });
|
|
365
|
+
});
|
|
366
|
+
req.pipe(up);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Forward WebSocket upgrades (dev-server HMR) untouched.
|
|
370
|
+
function proxyUpgrade(req, socket, head) {
|
|
371
|
+
const port = upstream.port || (upstream.protocol === 'https:' ? 443 : 80);
|
|
372
|
+
const up =
|
|
373
|
+
upstream.protocol === 'https:'
|
|
374
|
+
? tls.connect({ host: upstream.hostname, port, servername: upstream.hostname })
|
|
375
|
+
: net.connect({ host: upstream.hostname, port });
|
|
376
|
+
const ready = upstream.protocol === 'https:' ? 'secureConnect' : 'connect';
|
|
377
|
+
up.once(ready, () => {
|
|
378
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
379
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
380
|
+
const name = req.rawHeaders[i];
|
|
381
|
+
lines.push(`${name}: ${name.toLowerCase() === 'host' ? upstream.host : req.rawHeaders[i + 1]}`);
|
|
382
|
+
}
|
|
383
|
+
up.write(lines.join('\r\n') + '\r\n\r\n');
|
|
384
|
+
if (head?.length) up.write(head);
|
|
385
|
+
up.pipe(socket);
|
|
386
|
+
socket.pipe(up);
|
|
387
|
+
});
|
|
388
|
+
up.on('error', () => socket.destroy());
|
|
389
|
+
socket.on('error', () => up.destroy());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Uploaded references and screenshots, from the store's assets/ and shots/ folders.
|
|
393
|
+
async function serveStoreFile(res, url) {
|
|
394
|
+
const rel = decodeURIComponent(url.pathname.slice(FILES.length));
|
|
395
|
+
if (!/^(assets|shots)\/[\w.-]{1,80}$/.test(rel)) return send(res, 404, 'not found');
|
|
396
|
+
const body = await readFile(join(storeDir, rel)).catch(() => null);
|
|
397
|
+
if (!body) return send(res, 404, 'not found');
|
|
398
|
+
send(res, 200, body, { 'content-type': MIME[extname(rel).toLowerCase()] || 'application/octet-stream', ...CORS });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
server = http.createServer(async (req, res) => {
|
|
402
|
+
const url = new URL(req.url, 'http://localhost');
|
|
403
|
+
try {
|
|
404
|
+
if (url.pathname.startsWith(FILES)) {
|
|
405
|
+
if (!accessOf(req, url, keys)) return send(res, 401, 'this needs a key');
|
|
406
|
+
return await serveStoreFile(res, url);
|
|
407
|
+
}
|
|
408
|
+
if (req.method === 'OPTIONS' && url.pathname.startsWith('/__redline/')) return send(res, 204, '', CORS);
|
|
409
|
+
if (url.pathname === CLIENT_PATH) {
|
|
410
|
+
const clientJs = buildClient(); // ~1ms; rebuilt per request so edits to the overlay show up on reload
|
|
411
|
+
return send(res, 200, clientJs, { 'content-type': MIME['.js'], ...CORS });
|
|
412
|
+
}
|
|
413
|
+
if (url.pathname.startsWith(API + '/')) return await api(req, res, url);
|
|
414
|
+
if (upstream) return proxy(req, res);
|
|
415
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'method not allowed');
|
|
416
|
+
return await serveStatic(req, res, url);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
log(`error: ${err.message}`);
|
|
419
|
+
if (!res.headersSent) json(res, err.status || 500, { error: err.message });
|
|
420
|
+
else res.destroy();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (upstream) server.on('upgrade', proxyUpgrade);
|
|
425
|
+
|
|
426
|
+
server.on('listening', async () => {
|
|
427
|
+
await mkdir(storeDir, { recursive: true });
|
|
428
|
+
const onStore = debounce(async () => {
|
|
429
|
+
broadcast('annotations', {});
|
|
430
|
+
shots.reconcile(await store.all().catch(() => []), checkContext(await store.rounds()));
|
|
431
|
+
}, 80);
|
|
432
|
+
shots.reconcile(await store.all().catch(() => []), checkContext(await store.rounds()));
|
|
433
|
+
watchers.push(watch(storeDir, onStore));
|
|
434
|
+
if (siteRoot) {
|
|
435
|
+
const absStore = resolve(storeDir);
|
|
436
|
+
const onSite = debounce((file) => {
|
|
437
|
+
log(`changed ${file} → reloading browsers`);
|
|
438
|
+
broadcast('reload', { file });
|
|
439
|
+
}, 120);
|
|
440
|
+
watchers.push(
|
|
441
|
+
watch(siteRoot, { recursive: true }, (_event, name) => {
|
|
442
|
+
if (!name) return;
|
|
443
|
+
const abs = resolve(siteRoot, name);
|
|
444
|
+
if (abs === absStore || abs.startsWith(absStore + sep)) return;
|
|
445
|
+
if (name.split(sep).some((p) => IGNORED_DIRS.has(p) || (p.startsWith('.') && p.length > 1))) return;
|
|
446
|
+
onSite(name);
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
// Keep proxies and load balancers from closing idle event streams.
|
|
451
|
+
const ping = setInterval(() => {
|
|
452
|
+
for (const res of clients) res.write(': ping\n\n');
|
|
453
|
+
}, 25000);
|
|
454
|
+
ping.unref();
|
|
455
|
+
watchers.push({ close: () => clearInterval(ping) });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
server.on('close', () => {
|
|
459
|
+
shots.close();
|
|
460
|
+
for (const w of watchers) w.close();
|
|
461
|
+
for (const res of clients) res.end();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Let server.close() finish even while browsers hold event streams open.
|
|
465
|
+
const close = server.close.bind(server);
|
|
466
|
+
server.close = (cb) => {
|
|
467
|
+
for (const res of clients) res.end();
|
|
468
|
+
clients.clear();
|
|
469
|
+
server.closeAllConnections?.();
|
|
470
|
+
return close(cb);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
server.store = store;
|
|
474
|
+
server.shots = shots;
|
|
475
|
+
server.keys = keys;
|
|
476
|
+
return server;
|
|
477
|
+
}
|