@motujs/host 0.0.0-stage → 0.1.1

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.mjs ADDED
@@ -0,0 +1,1243 @@
1
+ // The lagoon host: upload a published fragment, serve it back at a URL that survives your laptop.
2
+ //
3
+ // This exists to replace the tunnel. `motu lagoon serve --host` plus an ssh -R gives a URL that is
4
+ // public while the tunnel runs and gone when the lid closes; a hosted lagoon is a URL you can put in
5
+ // a PR. What it serves is the SAME artifact — one page (or its manifest's chunks), mock fixtures, no backend —
6
+ // so hosting is a bucket and a route table, not a platform. There is no server-side browser here and
7
+ // there must not be one: Playwright runs on the publisher's machine, as it already does.
8
+ //
9
+ // ROUTES
10
+ // POST /api/publish?repo=&slug=&title=&sha=&branch= body: the fragment (text/html, gzip ok)
11
+ // GET / every repository
12
+ // GET /<repo>/ one repo: latest per slug, plus history
13
+ // GET /<repo>/<ref>/<slug> the lagoon: a shell, a rail, and the page framed
14
+ // GET /<repo>/<ref>/<slug>/__motu_frame the page's own bytes (the frame src)
15
+ // POST /api/blobs/missing body {hashes} which chunks of a chunked lagoon are not here
16
+ // PUT /api/blobs/<sha256> body: the chunk store one chunk (gzip ok), checked against its hash
17
+ // GET /__motu_assets/<manifest>/<file> one chunk of a chunked lagoon, cached forever
18
+ //
19
+ // WRITES ARE AUTHENTICATED, READS ARE NOT. The lagoon has no backend and no session, which is what
20
+ // makes it safe to host at all — but its fixtures were recorded from somewhere, so a URL is unlisted
21
+ // rather than public: unguessable, not access-controlled. That is the right posture for one person
22
+ // and the wrong one for a team, and closing it is the accounts work that gates external teams.
23
+ import { createServer } from 'node:http';
24
+ import { connect as netConnect } from 'node:net';
25
+ import { gunzipSync, brotliDecompressSync } from 'node:zlib';
26
+ import { timingSafeEqual } from 'node:crypto';
27
+ import { openStore, normalizeRepo, normalizeSegment, DEFAULT_MAX_RECORDS, DEFAULT_MAX_BYTES } from './store.mjs';
28
+ import { wrapFragment, withRepoMeta } from './document.mjs';
29
+ import { parseManifest, manifestPage, contentTypeOf, MANIFEST_TYPE } from './manifest.mjs';
30
+ import { hashBytes } from './store.mjs';
31
+ import { lagoonPage, rootIndexPage, repoIndexPage, errorPage } from './views.mjs';
32
+ import { loadAccess, isPublic, canRead, canIngest, ingestRepoOf, cookieValue, readSecretFrom, READ_COOKIE, digest } from './access.mjs';
33
+ import { randomBytes } from 'node:crypto';
34
+ import { readFileSync as readFileSyncFs, writeFileSync as writeFileSyncFs } from 'node:fs';
35
+ import { resolve as resolvePath } from 'node:path';
36
+
37
+ /**
38
+ * Two limits, because one of them was measuring the wrong thing.
39
+ *
40
+ * The wire limit alone let a 19.2 MB Twenty lagoon through at 5.9 MB gzipped — correct for that
41
+ * upload, and an open door for a compression bomb, since the decompressed size was never checked at
42
+ * all. So: a bound on what arrives, and a separate bound on what it becomes.
43
+ *
44
+ * The decompressed ceiling is sized for a real outlier (Twenty's record page inlines its whole
45
+ * front-end) rather than for the ~430 kB typical case.
46
+ */
47
+ /**
48
+ * WHO IS SERVING WHAT, LIVE — in memory, on purpose.
49
+ *
50
+ * A dev server is ephemeral. Writing it to the store would leave a dead endpoint behind every crash
51
+ * and every closed laptop, and the store is the thing that outlives the laptop. So this lives for as
52
+ * long as the host process does, and no longer.
53
+ *
54
+ * TTL rather than trust: `motu lagoon serve --watch` deregisters on exit, but a killed process cannot,
55
+ * so an entry that stops being refreshed simply expires and the member falls back to its last
56
+ * published build. That is also what happens when a proxy attempt fails — a dev server that died mid
57
+ * request should degrade to static, not to an error page.
58
+ */
59
+ /**
60
+ * A LIVE MEMBER THAT WAS NEVER PUBLISHED IS STILL A LAGOON.
61
+ *
62
+ * The index enumerates the STORE, so it can only show what someone published. That was fine while
63
+ * live meant "a preview of a member that already exists" — and it stops being fine the moment a dev
64
+ * server announces itself under a slug of its own (a branch, an agent's working copy), because such a
65
+ * member has no blob, appears in no listing, and is reachable ONLY by someone who already has the URL.
66
+ * A preview nobody can find is not a preview; it is a secret.
67
+ *
68
+ * So the index is the union: published repos, plus any repo or slug that only exists right now.
69
+ * Repos that exist solely as live members are carried with zero records, which is TRUE — nothing is
70
+ * stored — and the row still reaches the repo page where the live slug is listed and marked.
71
+ */
72
+ export function mergeLive(repos, liveMembers) {
73
+ const byRepo = new Map(repos.map((r) => [r.repo, { ...r, slugs: [...r.slugs] }]));
74
+ for (const { member } of liveMembers) {
75
+ const cut = String(member ?? '').lastIndexOf('/');
76
+ if (cut <= 0) continue;
77
+ const repo = member.slice(0, cut);
78
+ const slug = member.slice(cut + 1);
79
+ if (!repo || !slug) continue;
80
+ const row = byRepo.get(repo) ?? { repo, slugs: [], records: 0 };
81
+ if (!row.slugs.includes(slug)) row.slugs.push(slug);
82
+ row.live = true;
83
+ byRepo.set(repo, row);
84
+ }
85
+ return [...byRepo.values()].sort((a, b) => a.repo.localeCompare(b.repo));
86
+ }
87
+
88
+ /**
89
+ * AN OPEN GALLERY FOR A LOCAL AGENT — off unless asked for, and never reachable from outside.
90
+ *
91
+ * An agent working on this machine has no browser session and no cookie, so every private repo 404s
92
+ * at it. That is correct for the internet and useless for the thing the host exists to support: an
93
+ * agent that cannot open the gallery cannot check its own work, and cannot tell "this is private"
94
+ * from "I broke it" — which is exactly the confusion that produced the report this was written for.
95
+ *
96
+ * THREE CONDITIONS, ALL REQUIRED, and the third is the one that makes this safe rather than a hole:
97
+ *
98
+ * 1. `MOTU_HOST_OPEN_LOCAL=1` — the operator asks for it. Absent, nothing here changes.
99
+ * 2. the peer address is loopback.
100
+ * 3. the request carries NO forwarding header.
101
+ *
102
+ * (3) exists because (2) is not sufficient on this host, and believing it was would publish every
103
+ * private lagoon. A Funnel terminates at tailscaled and proxies to 127.0.0.1, and the host-app in
104
+ * front of this one proxies to 127.0.0.1 as well — so a request from the public internet arrives here
105
+ * from LOOPBACK. What distinguishes it is that a proxy announces itself: `x-forwarded-for`,
106
+ * `forwarded`, `x-real-ip`. Any of them present and this refuses, whatever the socket says.
107
+ *
108
+ * It grants READ only. Writes still need the token — an open gallery is for looking.
109
+ */
110
+ const OPEN_LOCAL = process.env.MOTU_HOST_OPEN_LOCAL === '1';
111
+ const FORWARD_HEADERS = ['x-forwarded-for', 'forwarded', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-proto'];
112
+
113
+ function localOpen(req) {
114
+ if (!OPEN_LOCAL) return false;
115
+ if (FORWARD_HEADERS.some((h) => req.headers[h] !== undefined)) return false;
116
+ const addr = req.socket?.remoteAddress ?? '';
117
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
118
+ }
119
+
120
+ function liveRegistry(ttlMs = 90_000) {
121
+ const entries = new Map(); // "repo/slug" -> { url, at }
122
+ const key = (repo, slug) => `${repo}/${slug}`;
123
+ return {
124
+ set(repo, slug, url) {
125
+ entries.set(key(repo, slug), { url, at: Date.now() });
126
+ },
127
+ clear(repo, slug) {
128
+ return entries.delete(key(repo, slug));
129
+ },
130
+ /** The endpoint serving this member right now, or null. Expiry is checked on read. */
131
+ endpointFor(repo, slug) {
132
+ const e = entries.get(key(repo, slug));
133
+ if (!e) return null;
134
+ if (Date.now() - e.at > ttlMs) {
135
+ entries.delete(key(repo, slug));
136
+ return null;
137
+ }
138
+ return e.url;
139
+ },
140
+ list() {
141
+ const now = Date.now();
142
+ return [...entries]
143
+ .filter(([, e]) => now - e.at <= ttlMs)
144
+ .map(([k, e]) => ({ member: k, url: e.url, ageMs: now - e.at }));
145
+ },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * DRAFTS: a live lagoon for a machine this host cannot reach.
151
+ *
152
+ * The other kind of live is a PULL — the dev server announces a URL and this host fetches it, which
153
+ * needs host -> dev reachability and is what `liveRegistry` above is for. This is the push: the dev
154
+ * machine sends the built artifact after every save, over the same outbound connection it already
155
+ * uses to announce itself, and the host serves those bytes. Nothing has to be able to reach the
156
+ * laptop, which is the whole point — a tunnel, a tailnet or a LAN route stops being a requirement.
157
+ *
158
+ * A DRAFT IS ONE PAGE OR ONE MANIFEST. A chunked draft's chunks are stored first, by content, and
159
+ * served from `/__motu_assets/<manifest>/` like a published lagoon's, so there is still nothing else for
160
+ * a viewer to ask for. Push and proxy are therefore indistinguishable from the outside — which would
161
+ * be false for an ordinary dev server, where you would lose HMR and any route the page calls.
162
+ *
163
+ * IN MEMORY, NOT IN THE STORE, and that is the decision worth defending. A draft is replaced every
164
+ * few seconds and means nothing an hour later; putting it in the blob store would mean a record per
165
+ * save, a retention cap churning against real history, and a GC liveness rule to teach about a thing
166
+ * that is not an artifact. Held here it costs a few hundred kB, expires like the live registry, and a
167
+ * host restart forgets it — which is correct, because the next save re-sends it within seconds.
168
+ *
169
+ * BOUNDED, because anyone with the upload token can push. A cap on the number of drafts and on each
170
+ * one's size turns "memory grows until the host dies" into "the oldest draft is dropped".
171
+ */
172
+ function draftRegistry(ttlMs = 90_000, maxDrafts = 32) {
173
+ const entries = new Map(); // "repo/slug" -> { body, type, at }
174
+ const key = (repo, slug) => `${repo}/${slug}`;
175
+ const fresh = (e) => e && Date.now() - e.at <= ttlMs;
176
+ return {
177
+ set(repo, slug, body) {
178
+ // Insertion order is Map order, so the first key is the least recently REPLACED. Dropping it is
179
+ // the honest eviction: a draft nobody has refreshed is a dev server that stopped.
180
+ if (!entries.has(key(repo, slug)) && entries.size >= maxDrafts) {
181
+ const oldest = entries.keys().next().value;
182
+ if (oldest !== undefined) entries.delete(oldest);
183
+ }
184
+ entries.set(key(repo, slug), { body, at: Date.now() });
185
+ },
186
+ clear(repo, slug) {
187
+ return entries.delete(key(repo, slug));
188
+ },
189
+ /** The bytes being drafted for this lagoon, or null. Expiry is checked on read, like `live`. */
190
+ bytesFor(repo, slug) {
191
+ const e = entries.get(key(repo, slug));
192
+ if (!fresh(e)) {
193
+ entries.delete(key(repo, slug));
194
+ return null;
195
+ }
196
+ return e.body;
197
+ },
198
+ list() {
199
+ const now = Date.now();
200
+ return [...entries]
201
+ .filter(([, e]) => fresh(e))
202
+ .map(([k, e]) => ({ member: k, bytes: e.body.length, ageMs: now - e.at }));
203
+ },
204
+ };
205
+ }
206
+
207
+ /**
208
+ * WHO IS WATCHING WHAT, so a new draft can tell them.
209
+ *
210
+ * When a lagoon is PROXIED, the reload channel belongs to the dev server and this host only forwards
211
+ * it. When it is PUSHED there is no dev server on the other end of the request, so the host has to
212
+ * own the channel itself: viewers hold an SSE open, and the arrival of a draft is the event.
213
+ *
214
+ * The client is unchanged — it is the same relative `__motu_reload` the CLI injects, which is what
215
+ * makes the two kinds of live interchangeable to a browser.
216
+ */
217
+ function reloadChannels() {
218
+ const waiting = new Map(); // "repo/slug" -> Set<res>
219
+ const key = (repo, slug) => `${repo}/${slug}`;
220
+ return {
221
+ open(repo, slug, res) {
222
+ const k = key(repo, slug);
223
+ if (!waiting.has(k)) waiting.set(k, new Set());
224
+ waiting.get(k).add(res);
225
+ // A DISCONNECT IS THE NORMAL CASE, not an error: viewers close tabs. Without this the set grows
226
+ // for the life of the process and every draft writes to sockets nobody is reading.
227
+ res.on('close', () => {
228
+ waiting.get(k)?.delete(res);
229
+ if (waiting.get(k)?.size === 0) waiting.delete(k);
230
+ });
231
+ },
232
+ fire(repo, slug) {
233
+ const set = waiting.get(key(repo, slug));
234
+ if (!set) return 0;
235
+ for (const res of set) res.write('data: reload\n\n');
236
+ return set.size;
237
+ },
238
+ };
239
+ }
240
+
241
+ const MAX_WIRE = 24 * 1024 * 1024;
242
+ const MAX_DECOMPRESSED = 64 * 1024 * 1024;
243
+
244
+ function send(res, status, type, body, extra = {}) {
245
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
246
+ res.writeHead(status, { 'content-type': type, 'content-length': buf.length, ...extra });
247
+ res.end(buf);
248
+ }
249
+
250
+ const html = (res, status, body, extra) => send(res, status, 'text/html; charset=utf-8', body, extra);
251
+ // `extra` matters here, unlike when this only ever answered API calls: the accepted set is fetched by
252
+ // browsers on every page load and is the request that makes the rest cheap, so it must be cacheable.
253
+ // Without this parameter a caller passing headers is silently ignored — which is how it was written
254
+ // first, and the symptom would have been a cache-control header that simply never appeared.
255
+ const json = (res, status, obj, extra) =>
256
+ send(res, status, 'application/json; charset=utf-8', JSON.stringify(obj, null, 2), extra);
257
+
258
+ /** Constant-time compare so the token cannot be recovered a byte at a time from response timing. */
259
+ function tokenMatches(given, expected) {
260
+ const a = Buffer.from(String(given ?? ''));
261
+ const b = Buffer.from(String(expected));
262
+ return a.length === b.length && timingSafeEqual(a, b);
263
+ }
264
+
265
+ function readBody(req, limit = MAX_WIRE, decompressedLimit = MAX_DECOMPRESSED) {
266
+ return new Promise((resolve, reject) => {
267
+ const chunks = [];
268
+ let size = 0;
269
+ req.on('data', (c) => {
270
+ size += c.length;
271
+ if (size > limit) {
272
+ reject(Object.assign(new Error(`body exceeds ${limit} bytes`), { status: 413 }));
273
+ req.destroy();
274
+ return;
275
+ }
276
+ chunks.push(c);
277
+ });
278
+ req.on('end', () => {
279
+ let buf = Buffer.concat(chunks);
280
+ const enc = String(req.headers['content-encoding'] ?? '').toLowerCase();
281
+ try {
282
+ // maxOutputLength makes the decompressor itself refuse the bomb, rather than allocating the
283
+ // whole thing first and checking afterwards.
284
+ if (enc === 'gzip') buf = gunzipSync(buf, { maxOutputLength: decompressedLimit });
285
+ else if (enc === 'br') buf = brotliDecompressSync(buf, { maxOutputLength: decompressedLimit });
286
+ } catch (e) {
287
+ const tooBig = /maxOutputLength|buffer.*too large/i.test(e.message);
288
+ reject(
289
+ Object.assign(new Error(tooBig ? `decompressed body exceeds ${decompressedLimit} bytes` : `could not decode ${enc} body: ${e.message}`), {
290
+ status: tooBig ? 413 : 400,
291
+ }),
292
+ );
293
+ return;
294
+ }
295
+ resolve(buf);
296
+ });
297
+ req.on('error', reject);
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Cache policy follows MUTABILITY, which is the point of having two axes at all.
303
+ * A commit URL is immutable, so it can be cached for a year; `latest` must never be cached, or a
304
+ * republish behind the bookmark serves yesterday's page and looks like a working one.
305
+ */
306
+ const IMMUTABLE = { 'cache-control': 'public, max-age=31536000, immutable' };
307
+ const NO_STORE = { 'cache-control': 'no-store' };
308
+
309
+ export function createLagoonHost({ dir, maxRecords = DEFAULT_MAX_RECORDS, maxBytes = DEFAULT_MAX_BYTES, token = null } = {}) {
310
+ const store = openStore({ dir, maxRecords, maxBytes });
311
+ const live = liveRegistry();
312
+ const drafts = draftRegistry();
313
+ const reload = reloadChannels();
314
+
315
+ /**
316
+ * Hand a request to whatever dev server is serving this member, and hand its answer straight back.
317
+ *
318
+ * The reload stream goes through here too, which is the point: the composed view is same-origin, so
319
+ * the page's `EventSource` reaches its own dev server without CORS and without the frame knowing it
320
+ * is inside a gallery. Streamed rather than buffered — an SSE response that is collected before it
321
+ * is forwarded never arrives.
322
+ */
323
+ async function proxyLive(base, subPath, req, res, member) {
324
+ const target = `${String(base).replace(/\/+$/, '')}${subPath}`;
325
+ let upstream;
326
+ try {
327
+ // WHERE ITS CHUNKS ARE, from the viewer's side: under this member's prefix, which the host
328
+ // forwards back to the dev server. Without it a chunked page asks for them at this host's root.
329
+ upstream = await fetch(target, { headers: { accept: req.headers.accept ?? '*/*', 'x-motu-base': `/${member.repo}/latest/${member.slug}` } });
330
+ } catch {
331
+ // The dev server is unreachable — it went away, or it announced an address this host cannot
332
+ // dial (`127.0.0.1` from a remote machine). Forget it and tell the caller, which serves the
333
+ // request the STORED way: the composed page for the canonical URL, the bare fragment for a
334
+ // frame. Answering here used to send the fragment for both, so the canonical URL lost its rail
335
+ // and its dock — no region switcher, no lens — for as long as the dead server kept re-announcing,
336
+ // and nothing on the page said why.
337
+ live.clear(member.repo, member.slug);
338
+ return false;
339
+ }
340
+ const type = upstream.headers.get('content-type') ?? 'text/html; charset=utf-8';
341
+ // A LIVE PAGE NEEDS THE SAME STAMP A STORED ONE GETS. `withRepoMeta` is what tells a lagoon which
342
+ // repo it belongs to, so it can ask this host for its own coverage corpus — and it was applied
343
+ // only on the stored path. A live frame therefore rendered without the one section that made
344
+ // going live worth doing, and the reason was invisible: the page works, the lens works, and the
345
+ // coverage section simply is not there.
346
+ //
347
+ // Buffered rather than streamed, for HTML only. The document has to be whole before a tag can go
348
+ // into its head, and it is one self-contained page that `fetch` has buffered anyway. Everything
349
+ // else — the reload stream above all — keeps streaming, because an SSE response collected before
350
+ // it is forwarded never arrives.
351
+ if (type.includes('text/html')) {
352
+ const body = withRepoMeta(Buffer.from(await upstream.arrayBuffer()), member.repo);
353
+ return void send(res, upstream.status, type, body, NO_STORE);
354
+ }
355
+ res.writeHead(upstream.status, {
356
+ 'content-type': type,
357
+ 'cache-control': 'no-store',
358
+ // Same reason the dev server sets it: without this the stream is buffered and nothing arrives.
359
+ ...(type.includes('text/event-stream') ? { 'x-accel-buffering': 'no', connection: 'keep-alive' } : {}),
360
+ });
361
+ if (!upstream.body) return void res.end();
362
+ const reader = upstream.body.getReader();
363
+ req.on('close', () => reader.cancel().catch(() => {}));
364
+ for (;;) {
365
+ const { done, value } = await reader.read().catch(() => ({ done: true }));
366
+ if (done) break;
367
+ res.write(Buffer.from(value));
368
+ }
369
+ res.end();
370
+ }
371
+
372
+ /**
373
+ * The page behind stored or drafted bytes: a single file as it is, a manifest as its HTML with the
374
+ * chunks' address filled in. Either is a fragment `wrapFragment` can finish.
375
+ */
376
+ function pageOf(bytes, hash) {
377
+ const m = parseManifest(bytes);
378
+ return m ? manifestPage(m, hash ?? hashBytes(bytes)) : bytes;
379
+ }
380
+
381
+ /** Recently read manifests, by hash — every chunk request reads one, and they never change. */
382
+ const manifestCache = new Map();
383
+ function manifestByHash(hash) {
384
+ if (manifestCache.has(hash)) return manifestCache.get(hash);
385
+ const bytes = /^[0-9a-f]{64}$/.test(hash) ? store.readHash(hash) : null;
386
+ const m = bytes ? parseManifest(bytes) : null;
387
+ if (m) {
388
+ manifestCache.set(hash, m);
389
+ if (manifestCache.size > 64) manifestCache.delete(manifestCache.keys().next().value);
390
+ }
391
+ return m;
392
+ }
393
+
394
+ async function handle(req, res) {
395
+ const url = new URL(req.url, 'http://host');
396
+ const path = url.pathname;
397
+
398
+ // THE ACCESS POLICY, re-read per request so editing it does not need a restart. See access.mjs;
399
+ // absent, every repo is public and the global token admits every write, which is what the host
400
+ // did before any of this existed.
401
+ // `store.root`, NOT the `dir` parameter. The parameter is undefined whenever the directory comes
402
+ // from MOTU_HOST_DIR instead of --dir — which is exactly how the systemd unit runs this — and
403
+ // `resolve(undefined, …)` throws, so EVERY request became a 500. The store already resolved it
404
+ // through the same precedence; asking it is the only way the two cannot disagree.
405
+ const access = loadAccess(store.root);
406
+ const bearer = String(req.headers.authorization ?? '').replace(/^Bearer\s+/i, '');
407
+ const adminOk = Boolean(token) && tokenMatches(bearer, token);
408
+ const readSecret = readSecretFrom({ cookieHeader: req.headers.cookie, bearer });
409
+ /** A reader's verdict for one repo, and the 404 that hides a private one's existence. */
410
+ const readable = (repo) => localOpen(req) || canRead(access, repo, { adminOk, readSecret });
411
+
412
+ // UNLOCKING A PRIVATE LINK. A browser following a URL cannot set a header, so the secret arrives
413
+ // once as `?k=`, becomes an HttpOnly cookie, and is redirected away — so it stops appearing in the
414
+ // address bar, in history, and in any Referer the page later sends.
415
+ if (url.searchParams.has('k')) {
416
+ const clean = new URL(url.href);
417
+ clean.searchParams.delete('k');
418
+ res.writeHead(302, {
419
+ location: clean.pathname + clean.search + clean.hash,
420
+ 'set-cookie': `${READ_COOKIE}=${encodeURIComponent(url.searchParams.get('k') ?? '')}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`,
421
+ 'cache-control': 'no-store',
422
+ });
423
+ return void res.end();
424
+ }
425
+
426
+ if (path === '/favicon.ico') return void res.writeHead(204).end();
427
+ if (path === '/api/health') return json(res, 200, { ok: true, ...store.stats() });
428
+ // The two read APIs the CLI needs to build a gallery without scraping the HTML index.
429
+ if (path === '/api/repos')
430
+ return json(res, 200, { repos: store.listRepos().filter((r) => readable(r.repo)) });
431
+ if (path === '/api/baselines') {
432
+ const repo = normalizeRepo(url.searchParams.get('repo'));
433
+ if (!repo) return json(res, 400, { error: 'repo is required' });
434
+ if (!readable(repo)) return json(res, 404, { error: 'no such repo' });
435
+ return json(res, 200, { repo, shots: store.listShots(repo, url.searchParams.get('island') || null) });
436
+ }
437
+ // THE ACCEPTED SET — what somebody has looked at and chosen not to preview.
438
+ //
439
+ // A client unions this with the flow-covered set baked into its bundle and stays silent about
440
+ // anything in either. That union is what makes the steady state cost NOTHING rather than merely
441
+ // less: once every state a user reaches is known, the beacon never fires again. The baked half
442
+ // only shrinks traffic on a redeploy; this half is why accepting a state takes effect at once.
443
+ if (path === '/api/coverage/known' && req.method === 'GET') {
444
+ const repo = normalizeRepo(url.searchParams.get('repo'));
445
+ const region = normalizeSegment(url.searchParams.get('region') || '');
446
+ const keysHash = normalizeSegment(url.searchParams.get('h') || '');
447
+ // AN EMPTY SET IS THE SAFE ANSWER for every kind of no, and the only safe one. Refusing loudly
448
+ // would turn a reporting tool's outage into a broken page; claiming a state is known when it is
449
+ // not would silently delete a finding. So: at worst one extra beacon, never a wrong silence.
450
+ // THE INGEST TOKEN MAY READ THE ACCEPTED SET of its own repo, and nothing else. Accepted ids are
451
+ // the states somebody chose not to preview: an application needs them to stay quiet, and they
452
+ // say nothing a beacon did not already send. Requiring a separate read secret for them was the
453
+ // fourth value a deployment had to set. The repo may be left out: the token names it.
454
+ const knownRepo = repo ?? ingestRepoOf(access, bearer);
455
+ const mayRead = knownRepo && (readable(knownRepo) || canIngest(access, knownRepo, bearer));
456
+ if (!knownRepo || !region || !keysHash || !mayRead) return json(res, 200, [], NO_STORE);
457
+ return json(res, 200, store.readAccepted(knownRepo, region, keysHash), {
458
+ 'cache-control': 'public, max-age=300, stale-while-revalidate=3600',
459
+ });
460
+ }
461
+
462
+ // THE CORPUS, READ BACK. Same gate as the pages: if you may not see this repo's lagoon, you may
463
+ // not see what its region has been through either. A published lagoon calls this on its OWN
464
+ // origin, so the page carries no address and no credential — the reader's cookie is the answer.
465
+ if (path === '/api/coverage' && req.method === 'GET') {
466
+ const repo = normalizeRepo(url.searchParams.get('repo'));
467
+ const region = normalizeSegment(url.searchParams.get('region') || '');
468
+ if (!repo || !region) return json(res, 400, { error: 'repo and region are required' });
469
+ // ONE ANSWER FOR EVERY KIND OF NOTHING. A private repo, a repo that does not exist, and a repo
470
+ // with no corpus yet all get the same 404. Answering 404 for private and 200 for unknown is a
471
+ // name oracle: guess a repo, and the status tells you whether it exists and is being hidden —
472
+ // which is exactly what the page route's 404 was chosen to avoid, given away by its neighbour.
473
+ const corpora = readable(repo) ? store.readCoverage(repo, region) : [];
474
+ if (!corpora.length) return json(res, 404, { error: 'no corpus here' });
475
+ // The newest DECLARATION only. Older key lists are kept (they are not comparable, and mixing
476
+ // them is nonsense) but a reader wants the one that describes the region as it is now.
477
+ return json(res, 200, { repo, region, corpus: corpora[0], declarations: corpora.length });
478
+ }
479
+ // GUARDED BY METHOD, unlike its neighbours, because this path also takes a POST. Without the guard
480
+ // the read branch answered the registration too — a cheerful 200 with an empty list, so the CLI
481
+ // reported itself live and the host had never heard of it.
482
+ if (path === '/api/live' && req.method === 'GET')
483
+ // ONE LIST, TWO MECHANISMS. A viewer cannot tell a pushed lagoon from a proxied one and neither
484
+ // should anything downstream: the app draws the same badge from this, and `url` is simply absent
485
+ // for a draft because there is nowhere to fetch it from.
486
+ return json(res, 200, { live: [...live.list(), ...drafts.list().map((d) => ({ ...d, draft: true }))] });
487
+
488
+ // ONE CHUNK of a chunked lagoon. The only PUT this host takes, and the same token publishing needs:
489
+ // a chunk is half a publish. Refused unless the bytes hash to the address they were sent to.
490
+ if (req.method === 'PUT' && path.startsWith('/api/blobs/')) {
491
+ if (!token) return json(res, 503, { error: 'this host accepts no uploads — start it with a token' });
492
+ if (!adminOk) return json(res, 401, { error: 'bad or missing token' });
493
+ const want = path.slice('/api/blobs/'.length);
494
+ if (!/^[0-9a-f]{64}$/.test(want)) return json(res, 400, { error: 'a chunk is addressed by its sha256' });
495
+ let body;
496
+ try {
497
+ body = await readBody(req);
498
+ } catch (e) {
499
+ return json(res, e.status ?? 400, { error: e.message });
500
+ }
501
+ const out = store.putBlob(body, want);
502
+ if (!out.ok) return json(res, 422, { error: `these bytes hash to ${out.hash.slice(0, 12)}, not ${want.slice(0, 12)}` });
503
+ return json(res, 200, { ok: true, hash: out.hash, bytes: body.length });
504
+ }
505
+
506
+ if (req.method === 'POST') {
507
+ // INGEST IS ITS OWN DOOR, checked before the admin gate and never falling through to it.
508
+ //
509
+ // This is the one write an application's server makes, so its credential lives in that
510
+ // application's environment rather than on this machine. It is therefore the credential most
511
+ // likely to leak, and it is scoped to match: one repo, one route, no reads. An ingest token
512
+ // cannot publish a lagoon, register a live frame, accept a baseline, or touch another project.
513
+ //
514
+ // The admin token still works here — one credential that can do everything is exactly what it
515
+ // is for — but nothing an app holds needs to be that credential.
516
+ if (path === '/api/coverage') {
517
+ // THE REPO MAY BE LEFT OUT: an ingest token belongs to exactly one, so it names it.
518
+ const named = url.searchParams.get('repo');
519
+ const repo = named ? normalizeRepo(named) : ingestRepoOf(access, bearer);
520
+ if (!repo)
521
+ return json(res, named ? 400 : 401, {
522
+ error: named ? 'repo must be `name` or `owner/name`, [A-Za-z0-9._-]' : 'bad or missing ingest token — it names no repo on this host',
523
+ });
524
+ if (!adminOk && !canIngest(access, repo, bearer))
525
+ return json(res, 401, { error: 'bad or missing ingest token for this repo' });
526
+ // A DRY RUN proves the path without writing: `motu coverage doctor` sends one through the
527
+ // deployed application, and a synthetic state must never land in a real corpus.
528
+ if (url.searchParams.get('dryRun') === '1') return json(res, 200, { ok: true, dryRun: true, repo });
529
+ return void (await ingestCoverage(req, res, url, repo));
530
+ }
531
+ // ACCEPTING IS A PERSON'S DECISION, so it takes the ADMIN token and not the ingest one.
532
+ //
533
+ // The rule this enforces is the one the whole design rests on: nothing promotes a state to
534
+ // "known" except a flow or a person. An ingest credential that could also accept would let the
535
+ // reporting path mark its own findings resolved — and a system that can do that reports
536
+ // nothing, which is indistinguishable from having nothing to report.
537
+ // FORGETTING IS DESTRUCTIVE, so it takes the admin token — never the ingest one, which sits in an
538
+ // application's environment. A credential that can both write a corpus and delete from it could
539
+ // quietly rewrite what the region is known to have done.
540
+ if (path === '/api/coverage/forget') {
541
+ if (!token) return json(res, 503, { error: 'this host accepts no uploads — start it with a token' });
542
+ if (!adminOk) return json(res, 401, { error: 'forgetting a state needs the admin token' });
543
+ const repo = normalizeRepo(url.searchParams.get('repo'));
544
+ const region = normalizeSegment(url.searchParams.get('region') || '');
545
+ const keysHash = normalizeSegment(url.searchParams.get('h') || '');
546
+ if (!repo || !region || !keysHash) return json(res, 400, { error: 'repo, region and h are required' });
547
+ let ids = null;
548
+ try {
549
+ const raw = JSON.parse((await readBody(req, 100_000)).toString('utf8'));
550
+ if (Array.isArray(raw)) ids = raw.filter((i) => typeof i === 'string');
551
+ } catch {
552
+ ids = null;
553
+ }
554
+ return json(res, 200, { ok: true, ...store.forgetCoverage(repo, region, keysHash, ids) });
555
+ }
556
+ if (path === '/api/coverage/accept') {
557
+ if (!token) return json(res, 503, { error: 'this host accepts no uploads — start it with a token' });
558
+ if (!adminOk) return json(res, 401, { error: 'accepting a state needs the admin token' });
559
+ const repo = normalizeRepo(url.searchParams.get('repo'));
560
+ const region = normalizeSegment(url.searchParams.get('region') || '');
561
+ const keysHash = normalizeSegment(url.searchParams.get('h') || '');
562
+ if (!repo || !region || !keysHash) return json(res, 400, { error: 'repo, region and h are required' });
563
+ let ids;
564
+ try {
565
+ ids = JSON.parse((await readBody(req, 100_000)).toString('utf8'));
566
+ } catch {
567
+ ids = null;
568
+ }
569
+ if (!Array.isArray(ids)) return json(res, 400, { error: 'body must be a JSON array of fingerprint ids' });
570
+ try {
571
+ return json(res, 200, { ok: true, ...store.acceptCoverage(repo, region, keysHash, ids) });
572
+ } catch (err) {
573
+ return json(res, 400, { error: String(err?.message ?? err) });
574
+ }
575
+ }
576
+ if (!token) return json(res, 503, { error: 'this host accepts no uploads — start it with a token' });
577
+ const auth = String(req.headers.authorization ?? '').replace(/^Bearer\s+/i, '');
578
+ if (!tokenMatches(auth, token)) return json(res, 401, { error: 'bad or missing token' });
579
+ if (path === '/api/publish') return void (await publish(req, res, url));
580
+ // MINT AN INGEST TOKEN over the admin API, so `motu coverage init` needs no shell on this
581
+ // machine. Same effect as `motu-host access --repo <r> --ingest`: the secret is returned once
582
+ // and only its hash is kept. Minting again REPLACES the previous token for that repo.
583
+ if (path === '/api/access/ingest') {
584
+ const repo = normalizeRepo(url.searchParams.get('repo'));
585
+ if (!repo) return json(res, 400, { error: 'repo must be `name` or `owner/name`, [A-Za-z0-9._-]' });
586
+ const file = resolvePath(store.root, 'access.json');
587
+ let raw = {};
588
+ try {
589
+ raw = JSON.parse(readFileSyncFs(file, 'utf8'));
590
+ } catch {
591
+ raw = {};
592
+ }
593
+ if (!raw || typeof raw !== 'object') raw = {};
594
+ if (!raw.repos || typeof raw.repos !== 'object') raw.repos = {};
595
+ const secret = randomBytes(32).toString('hex');
596
+ raw.repos[repo] = { ...(raw.repos[repo] ?? {}), ingestHash: digest(secret).toString('hex') };
597
+ writeFileSyncFs(file, `${JSON.stringify(raw, null, 2)}\n`);
598
+ return json(res, 200, { ok: true, repo, token: secret });
599
+ }
600
+ if (path === '/api/blobs/missing') {
601
+ let hashes;
602
+ try {
603
+ hashes = JSON.parse((await readBody(req, 4 * 1024 * 1024)).toString('utf8'))?.hashes;
604
+ } catch {
605
+ hashes = null;
606
+ }
607
+ if (!Array.isArray(hashes)) return json(res, 400, { error: 'body must be {"hashes": [sha256, …]}' });
608
+ return json(res, 200, { missing: store.missingBlobs(hashes.slice(0, 20_000)) });
609
+ }
610
+ if (path === '/api/baseline') return void (await baseline(req, res, url));
611
+ if (path === '/api/baseline/accept') return void (await acceptBaseline(req, res, url));
612
+ if (path === '/api/live/draft') {
613
+ const repo = normalizeRepo(url.searchParams.get('repo'));
614
+ const slug = normalizeSegment(url.searchParams.get('slug'));
615
+ if (!repo || !slug) return json(res, 400, { error: 'repo and slug are required' });
616
+ // A TOUCH IS A HEARTBEAT, not a push. A draft expires the way a live URL does, and re-sending
617
+ // half a megabyte every thirty seconds to say "still here" would be absurd — so the beat that
618
+ // keeps a PULLED member alive has a pushed equivalent that carries no bytes.
619
+ //
620
+ // 404 when there is nothing to keep alive, rather than a silent success: the alternative is a
621
+ // registry reporting a live lagoon with no bytes behind it, which serves a 404 to whoever
622
+ // follows the badge.
623
+ if (url.searchParams.get('touch') === '1') {
624
+ const held = drafts.bytesFor(repo, slug);
625
+ if (!held) return json(res, 404, { error: 'no draft to keep alive — send one first' });
626
+ drafts.set(repo, slug, held);
627
+ // A CHUNKED draft's chunks are named by no record, so they live on being mentioned.
628
+ const heldManifest = parseManifest(held);
629
+ if (heldManifest) store.missingBlobs([hashBytes(held), ...Object.values(heldManifest.files)]);
630
+ return json(res, 200, { ok: true, member: `${repo}/${slug}`, touched: true, ttlMs: 90_000 });
631
+ }
632
+ // The same wire limit publishing uses. A lagoon is one file and a large one is ~1 MB; the cap
633
+ // is here so a token holder cannot push the host's memory over instead of a page.
634
+ const body = await readBody(req, 8 * 1024 * 1024);
635
+ if (!body?.length) return json(res, 400, { error: 'a draft needs a body' });
636
+ // A CHUNKED draft is a manifest whose chunks were sent first. Refuse one naming a chunk that is
637
+ // not here — served, it would be a page that loads and then fails on a missing module.
638
+ const draftManifest = parseManifest(body);
639
+ if (draftManifest) {
640
+ const missing = store.missingBlobs(Object.values(draftManifest.files));
641
+ if (missing.length) return json(res, 422, { error: `draft names ${missing.length} chunk(s) this host does not have — send them first`, missing });
642
+ store.putBlob(body);
643
+ } else if (String(req.headers['content-type'] ?? '').startsWith(MANIFEST_TYPE)) {
644
+ return json(res, 400, { error: 'not a lagoon manifest' });
645
+ }
646
+ drafts.set(repo, slug, body);
647
+ // TELL THE PEOPLE LOOKING. This is the half a proxied live lagoon gets for free, because
648
+ // there the dev server owns the channel; pushed, the arrival of the bytes IS the event.
649
+ const told = reload.fire(repo, slug);
650
+ return json(res, 200, { ok: true, member: `${repo}/${slug}`, bytes: body.length, reloaded: told, ttlMs: 90_000 });
651
+ }
652
+ if (path === '/api/live' || path === '/api/live/off') {
653
+ const repo = normalizeRepo(url.searchParams.get('repo'));
654
+ const slug = normalizeSegment(url.searchParams.get('slug'));
655
+ if (!repo || !slug) return json(res, 400, { error: 'repo and slug are required' });
656
+ if (path === '/api/live/off') {
657
+ // BOTH KINDS. `--live-push` and `--live-url` are two ways to be the same thing to a viewer,
658
+ // so stopping is one act: leaving a draft behind after the announcement went would keep the
659
+ // badge on and serve bytes from a process that has exited.
660
+ const cleared = live.clear(repo, slug);
661
+ const draftCleared = drafts.clear(repo, slug);
662
+ return json(res, 200, { ok: true, cleared: cleared || draftCleared });
663
+ }
664
+ let where;
665
+ try {
666
+ where = JSON.parse((await readBody(req, 4096)).toString('utf8'))?.url;
667
+ } catch {
668
+ where = null;
669
+ }
670
+ // LOOPBACK BY DEFAULT, and that default does not move. This host FETCHES whatever it is told,
671
+ // so an unrestricted registry makes it an open relay for the machine it runs on — pointed at a
672
+ // cloud metadata endpoint, say, by anyone holding the token. The rule stays; what is new is
673
+ // that an operator can widen it deliberately, for the case it was refusing: a dev server on
674
+ // somebody's laptop, reachable over a tailnet or a tunnel.
675
+ //
676
+ // AN ALLOWLIST THE OPERATOR WRITES, not a flag the caller sets. "I trust these names" is a
677
+ // different claim from "anyone with the token may point me anywhere", and only the person
678
+ // running the host can make the first one. Unset means today's behaviour exactly.
679
+ //
680
+ // MOTU_LIVE_ALLOW=.tailnet.ts.net,laptop.lan
681
+ //
682
+ // KNOW WHAT THIS DOES NOT BUY YOU. A name on the list is checked as a NAME; it is not
683
+ // resolved, so a name you allow that later resolves somewhere else is a URL this host will
684
+ // fetch. That is why the list is names rather than a subnet, why it is opt-in, and why the
685
+ // metadata range below is refused whatever the list says — a broad suffix should not be able
686
+ // to open the one address that turns SSRF into credentials.
687
+ const liveAllowed = (candidate) => {
688
+ let u;
689
+ try {
690
+ u = new URL(candidate);
691
+ } catch {
692
+ return false;
693
+ }
694
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
695
+ // NEVER, on any list. 169.254.0.0/16 is where cloud metadata lives; 0.0.0.0 and [::] mean
696
+ // "this machine" by another spelling.
697
+ if (/^(169\.254\.|0\.0\.0\.0$|\[?::\]?$)/.test(u.hostname)) return false;
698
+ if (/^(127\.0\.0\.1|localhost|\[?::1\]?)$/.test(u.hostname)) return true;
699
+ const allow = String(process.env.MOTU_LIVE_ALLOW ?? '')
700
+ .split(',')
701
+ .map((x) => x.trim().toLowerCase())
702
+ .filter(Boolean);
703
+ const host = u.hostname.toLowerCase();
704
+ // A leading dot is a SUFFIX ("any name under this tailnet"); anything else is exact.
705
+ return allow.some((a) => (a.startsWith('.') ? host.endsWith(a) : host === a));
706
+ };
707
+ if (typeof where !== 'string' || !liveAllowed(where.replace(/\/+$/, '')))
708
+ return json(res, 400, {
709
+ error: process.env.MOTU_LIVE_ALLOW
710
+ ? 'url must be loopback or a host named in MOTU_LIVE_ALLOW'
711
+ : 'url must be http://127.0.0.1:<port> — set MOTU_LIVE_ALLOW to permit named hosts',
712
+ });
713
+ const endpoint = where.replace(/\/+$/, '');
714
+ // PROVE IT ANSWERS, ONCE, before taking it. Loopback passes the rule above, but loopback is
715
+ // THIS machine: a dev server on a laptop announcing its own `127.0.0.1` names an address that
716
+ // only works here through an `ssh -R` tunnel — which is legitimate, so the address alone cannot
717
+ // decide. Fetching can. Without this the host stored a URL it could never reach, every viewer
718
+ // fell back to the last publish, and the dev server kept re-announcing it every thirty
719
+ // seconds, so the fallback never ended. Probed only when the URL is NEW for this member: a
720
+ // heartbeat for an address already proven is not asked again.
721
+ if (live.endpointFor(repo, slug) !== endpoint) {
722
+ // A plain GET whose body is thrown away and whose connection is not kept: the probe must
723
+ // leave nothing behind in the pool that a later request — the reload stream above all,
724
+ // which never ends — could be handed.
725
+ const answered = await fetch(endpoint, { signal: AbortSignal.timeout(3000), headers: { connection: 'close' } })
726
+ .then(async (r) => {
727
+ await r.body?.cancel().catch(() => {});
728
+ return true;
729
+ })
730
+ .catch(() => false);
731
+ if (!answered) {
732
+ return json(res, 422, {
733
+ error: `this host could not reach ${endpoint}${/^https?:\/\/(127\.|localhost|\[?::1)/.test(endpoint) ? " — that is the host's own loopback, not the dev machine's" : ''}`,
734
+ hint: 'push builds instead (`motu lagoon serve --watch --live-push`), or announce an address this host can reach (`--live-url`)',
735
+ });
736
+ }
737
+ }
738
+ live.set(repo, slug, endpoint);
739
+ return json(res, 200, { ok: true, member: `${repo}/${slug}`, url: where, ttlMs: 90_000 });
740
+ }
741
+ return json(res, 404, { error: `no route for POST ${path}` });
742
+ }
743
+
744
+ if (req.method !== 'GET' && req.method !== 'HEAD')
745
+ return json(res, 405, { error: `${req.method} not allowed` });
746
+
747
+ if (path === '/') {
748
+ // FILTERED, or the gate leaks the very thing it hides: a private repo the visitor cannot open
749
+ // would still be listed here by name, with its lagoon count.
750
+ const repos = mergeLive(store.listRepos(), [...live.list(), ...drafts.list()]).filter((r) => readable(r.repo));
751
+ return html(res, 200, rootIndexPage({ repos, stats: store.stats() }), NO_STORE);
752
+ }
753
+
754
+ const segments = path.split('/').filter(Boolean).map(decodeURIComponent);
755
+
756
+ // The bytes of one shot, by content hash — immutable, so cache it for a year.
757
+ if (segments[0] === 'shot' && segments[1]) {
758
+ const bytes = store.readHash(segments[1]);
759
+ if (!bytes) return html(res, 404, errorPage(404, 'no such shot'), NO_STORE);
760
+ return send(res, 200, 'image/png', bytes, IMMUTABLE);
761
+ }
762
+
763
+ // ONE CHUNK of a chunked lagoon, through the manifest that names it. Keyed by the manifest's
764
+ // content, so it never changes at this address and is cached for a year. Not gated by repo: the
765
+ // address is a 256-bit hash only a page that was allowed to load already holds.
766
+ if (segments[0] === '__motu_assets' && segments.length >= 3) {
767
+ const m = manifestByHash(segments[1]);
768
+ const name = segments.slice(2).join('/');
769
+ const h = m?.files[name];
770
+ const bytes = h ? store.readHash(h) : null;
771
+ if (!bytes) return json(res, 404, { error: 'no such chunk' });
772
+ return send(res, 200, contentTypeOf(name), bytes, IMMUTABLE);
773
+ }
774
+
775
+ // --- a LIVE dev server's own sub-paths ----------------------------------------------------
776
+ //
777
+ // A published lagoon is ONE self-contained page, so proxying a single URL was always enough. A dev
778
+ // server is an ORIGIN: its page references `/@vite/client`, `/@react-refresh` and a module graph,
779
+ // and those resolved at this host's root, where nothing served them — the viewer got the HTML and
780
+ // a 404 for everything in it. Measured directly: `/@vite/client` was 404 here and 200 on the dev
781
+ // server.
782
+ //
783
+ // So the member's path is a PREFIX, and everything under it belongs to whatever is live there. The
784
+ // CLI sets Vite's `base` to the same prefix, which is what makes the dev server emit URLs that
785
+ // arrive here in the first place.
786
+ //
787
+ // Parsed around `latest` rather than from the right, because the tail is now arbitrary. The LAST
788
+ // occurrence wins, so a repository whose own name ends in `latest` still resolves.
789
+ if (segments.length >= 4) {
790
+ const at = segments.lastIndexOf('latest');
791
+ if (at >= 1 && segments.length > at + 2) {
792
+ const liveRepo = normalizeRepo(segments.slice(0, at).join('/'));
793
+ const liveSlug = normalizeSegment(segments[at + 1]);
794
+ const rest = segments.slice(at + 2);
795
+ const endpoint = liveRepo && liveSlug && readable(liveRepo) ? live.endpointFor(liveRepo, liveSlug) : null;
796
+ // `__motu_frame` IS THIS HOST'S ADDRESS, NOT THE DEV SERVER'S — the one tail under a member
797
+ // prefix that must not be forwarded.
798
+ //
799
+ // Everything else below a live member belongs to Vite, which is why the path goes whole. The
800
+ // frame suffix is the exception: it means "the bytes without the shell", it is answered by the
801
+ // member route further down (which proxies the dev server's ROOT for a live member), and Vite
802
+ // has no such route — so forwarding it returned 404 for a page that exists.
803
+ //
804
+ // The symptom was a blank frame and a dock stuck on "waiting for the lagoon…", because the
805
+ // shell renders fine and only its iframe 404s. It appears exactly when a member is LIVE, which
806
+ // is why a published lagoon looked healthy and starting a dev server "broke" it.
807
+ const tail = rest[rest.length - 1];
808
+ //
809
+ // `__motu_reload` IS THE THIRD EXCEPTION. This branch BUFFERS (`arrayBuffer()`), and a reload
810
+ // stream never ends — so a viewer's EventSource was held open with no headers, forever, and a
811
+ // live lagoon never reloaded through this host. The member route below streams it.
812
+ if (endpoint && tail !== '__motu_hmr' && tail !== '__motu_reload' && !rest.includes('__motu_frame')) {
813
+ // THE RAW TAIL, not the decoded segments re-encoded. `segments` is decoded, so rebuilding
814
+ // the path with `encodeURIComponent` turned `@vite/client` into `%40vite/client` and the
815
+ // dev server answered 404 — a bug introduced by being careful in the wrong direction.
816
+ // THE PATH IS FORWARDED WHOLE. The CLI sets Vite's `base` to this same member prefix, so the
817
+ // prefix belongs to the DEV SERVER — it serves `/<repo>/latest/<slug>/@vite/client` and
818
+ // knows nothing about `/@vite/client`. Stripping it here (the obvious reverse-proxy reflex)
819
+ // asked for a path that does not exist: 404 through the host, 200 on the dev server at the
820
+ // full address.
821
+ const target = `${endpoint.replace(/\/+$/, '')}${path}${url.search}`;
822
+ try {
823
+ const up = await fetch(target, { headers: { accept: req.headers.accept ?? '*/*' } });
824
+ const body = Buffer.from(await up.arrayBuffer());
825
+ const headers = { 'cache-control': 'no-store' };
826
+ for (const h of ['content-type', 'etag', 'last-modified']) {
827
+ const v = up.headers.get(h);
828
+ if (v) headers[h] = v;
829
+ }
830
+ res.writeHead(up.status, headers);
831
+ return void res.end(body);
832
+ } catch {
833
+ // NO FALLBACK TO PUBLISHED BYTES HERE. The page route can fall back because both sides are
834
+ // HTML; a module request answered with a published page would be handed to the browser as
835
+ // JavaScript and fail somewhere unrelated to the cause.
836
+ return json(res, 502, { error: 'the live dev server did not answer' });
837
+ }
838
+ }
839
+ }
840
+ }
841
+
842
+ // --- per-repo ---------------------------------------------------------------------------
843
+ // Parsed FROM THE RIGHT: a repo id may carry an owner segment (`acme/web`), so the last two
844
+ // segments are ref and slug and everything before them is the repo.
845
+ if (segments.length >= 3) {
846
+ // The reload stream hangs off the page's own path, so strip it before parsing repo/ref/slug —
847
+ // otherwise `/<repo>/latest/all/__motu_reload` parses as a repo called `<repo>/latest`.
848
+ //
849
+ // `__motu_frame` is stripped the same way and for the same reason. THE PAGE IS NOW A SHELL: the
850
+ // canonical URL serves a rail plus this lagoon framed, so the bytes need an address of their own
851
+ // and this is it. The name is not new — the shell's own script already derived a page href by
852
+ // splitting a frame address on `/__motu_frame` (`views.mjs`), which is how the half-built
853
+ // per-lagoon rail expected to address a member long before a route answered there.
854
+ const isReload = segments[segments.length - 1] === '__motu_reload';
855
+ const withoutReload = isReload ? segments.slice(0, -1) : segments;
856
+ const isFrame = withoutReload[withoutReload.length - 1] === '__motu_frame';
857
+ const parts = isFrame ? withoutReload.slice(0, -1) : withoutReload;
858
+ const slug = normalizeSegment(parts[parts.length - 1]);
859
+ const ref = normalizeSegment(parts[parts.length - 2]);
860
+ const repo = normalizeRepo(parts.slice(0, -2).join('/'));
861
+ if (repo && ref && slug) {
862
+ // 404, NOT 403, for a private repo somebody cannot read. A 403 confirms the repo exists,
863
+ // which is the one thing a private host should not tell an unauthenticated stranger — and the
864
+ // name of an unreleased project is often the interesting part.
865
+ //
866
+ // AND IT HAS TO SAY WHAT A MISS SAYS. This used to render `nothing at ${path}` — with the
867
+ // leading slash — while the not-found below renders `nothing at ${repo}/${ref}/${slug}`
868
+ // without one. One character apart, and enough: a refusal was distinguishable from an absence
869
+ // by reading the page, so the status code withheld what the body then handed over. The two
870
+ // are now the same string, which is the property the 404 was chosen for in the first place.
871
+ if (!readable(repo)) return html(res, 404, errorPage(404, `nothing at ${repo}/${ref}/${slug}`), NO_STORE);
872
+
873
+ // LIVE ON THE CANONICAL URL, not only inside a gallery.
874
+ //
875
+ // `latest` already means "always current"; it just meant current as of the last publish. A
876
+ // project being worked on right now has a dev server that IS the answer, and the only way to
877
+ // see it was to compose a group and open a frame — so the URL a person actually bookmarks
878
+ // was the one place liveness did not reach.
879
+ //
880
+ // `latest` ONLY. An immutable URL keyed by content must never be live: being able to say
881
+ // "this exact page, forever" is the entire reason that URL exists, and serving something else
882
+ // from it would break the one promise it makes.
883
+ // A PUSHED DRAFT WINS OVER A PULLED ONE, and over the store. Precedence rather than exclusion
884
+ // because both can briefly be true — a dev server that announced a URL and then switched to
885
+ // pushing keeps its registry entry until the TTL runs out — and the draft is the newer fact.
886
+ //
887
+ // `latest` ONLY, exactly as below: an immutable URL keyed by content must never be live.
888
+ const draft = ref === 'latest' ? drafts.bytesFor(repo, slug) : null;
889
+ if (draft) {
890
+ if (isReload) {
891
+ // THE HOST OWNS THIS CHANNEL when it is serving bytes rather than forwarding a request.
892
+ // Headers first and flushed, or the browser holds the connection as pending and the
893
+ // EventSource never reaches `open` — which looks exactly like a server that never reloads.
894
+ res.writeHead(200, {
895
+ 'content-type': 'text/event-stream',
896
+ 'cache-control': 'no-store',
897
+ connection: 'keep-alive',
898
+ 'x-accel-buffering': 'no',
899
+ });
900
+ res.write(': open\n\n');
901
+ reload.open(repo, slug, res);
902
+ return; // held open on purpose; `reloadChannels` drops it when the socket closes
903
+ }
904
+ return html(res, 200, withRepoMeta(wrapFragment(pageOf(draft), { title: slug }), repo), NO_STORE);
905
+ }
906
+
907
+ let liveUrl = ref === 'latest' ? live.endpointFor(repo, slug) : null;
908
+ if (liveUrl) {
909
+ const rec = store.resolveRef(repo, ref, slug);
910
+ // The stored record travels with it so `proxyLive` can fall back to the last published
911
+ // bytes if the dev server has gone away between resolving and asking.
912
+ const member = { repo, slug, hash: rec?.hash ?? null, title: rec?.title ?? slug };
913
+ const sub = isReload ? '/__motu_reload' : `/${url.search}`;
914
+ // `false` = the dev server did not answer; fall through to the stored path below.
915
+ if ((await proxyLive(liveUrl, sub, req, res, member)) !== false) return;
916
+ liveUrl = null; // it did not answer: the stored page below must not call itself live
917
+ }
918
+ if (isReload) return html(res, 404, errorPage(404, 'nothing is serving this page live'), NO_STORE);
919
+
920
+ const rec = store.resolveRef(repo, ref, slug);
921
+ if (!rec) return html(res, 404, errorPage(404, `nothing at ${repo}/${ref}/${slug}`), NO_STORE);
922
+
923
+ // THE SHELL, unless the bytes were asked for by name.
924
+ //
925
+ // `b1719dd` said the rail "stops belonging to a group and belongs to every lagoon:
926
+ // `/<repo>/<ref>/<slug>` serves the shell" — and then only the group route ever rendered one,
927
+ // so the canonical URL a person bookmarks stayed a bare artifact with no rail, no dock and no
928
+ // lens. That is what made a group the only way to LOOK at more than one lagoon, which is the
929
+ // reason groups outlived the decision to remove them.
930
+ //
931
+ // The rail lists everything this viewer may see, with the one they opened selected — the
932
+ // honest list rather than a curated one, which is what needs no group to maintain.
933
+ if (!isFrame) {
934
+ const here = `/${repo}/${ref}/${slug}`;
935
+ const members = store
936
+ .resolveAll(live.endpointFor)
937
+ .filter((m) => readable(m.repo))
938
+ .map((m) => ({ ...m, frameHref: `/${m.repo}/latest/${m.slug}/__motu_frame` }));
939
+ // THIS lagoon first in its own rail, and at the exact ref that was opened — an immutable
940
+ // build is a different page from `latest`, and framing `latest` here would quietly show
941
+ // someone a page other than the one their URL names.
942
+ const self = {
943
+ repo,
944
+ slug,
945
+ hash: rec.hash,
946
+ title: rec.title ?? slug,
947
+ sha: rec.sha ?? null,
948
+ live: liveUrl ?? null,
949
+ brand: members.find((m) => m.repo === repo)?.brand ?? null,
950
+ // THE STATE THE URL NAMES, CARRIED INTO THE FRAME. `?target=…&scenario=…` is addressed by
951
+ // the artifact, which only ever sees the frame's own URL — so a shell that built this
952
+ // href bare threw the name away before anything could read it, and
953
+ // `motu lagoon states --base <hosted url>` printed addresses that all served the DEFAULT
954
+ // state. Not the refusal the artifact engineers against (`__motuLagoonState.ok: false`,
955
+ // banner, console error): the frame was never told a name, so it had nothing to refuse,
956
+ // and the reader gets a plausible screen while believing it is the one they asked for.
957
+ //
958
+ // SELF ONLY. Every other member of the rail is a DIFFERENT lagoon, where this lagoon's
959
+ // scenario name means nothing — appending it there would turn one wrong page into a
960
+ // railful of them.
961
+ frameHref: `${here}/__motu_frame${url.search}`,
962
+ };
963
+ const rest = members.filter((m) => !(m.repo === repo && m.slug === slug));
964
+ const all = [self, ...rest];
965
+ return html(res, 200, lagoonPage({ members: all, focus: 0, live: true, docTitle: self.title }), NO_STORE);
966
+ }
967
+
968
+ const bytes = store.read(repo, rec.id, rec.hash);
969
+ if (!bytes) return html(res, 410, errorPage(410, 'the object behind this URL has been swept'), NO_STORE);
970
+ return html(res, 200, withRepoMeta(wrapFragment(pageOf(bytes, rec.hash), { title: rec.title }), repo), rec.mutable ? NO_STORE : IMMUTABLE);
971
+ }
972
+ }
973
+
974
+ const repo = normalizeRepo(segments.join('/'));
975
+ // LIVE SLUGS ARE PART OF THE LISTING, and a repo whose ONLY members are live still has a page.
976
+ // `store.listRepo` answers from published records alone, so without this a branch preview 404s at
977
+ // the repo level while serving perfectly one path deeper — reachable, unlistable, undiscoverable.
978
+ // BOTH KINDS OF LIVE. A member is live by PULL (a dev server this host can fetch) or by PUSH (a
979
+ // draft: the machine sends its bytes, because the host cannot reach it). `/api/live` has always
980
+ // reported both; this listing read only the first, so a pushed member served perfectly at its own
981
+ // URL and appeared nowhere — which is indistinguishable, to the person looking for it, from not
982
+ // being there at all.
983
+ //
984
+ // Push is not the exotic case. It is what a host on a VPS gets from every laptop in the team.
985
+ const liveSlugs = repo
986
+ ? [...live.list(), ...drafts.list()]
987
+ .filter((e) => e.member.slice(0, e.member.lastIndexOf('/')) === repo)
988
+ .map((e) => e.member.slice(e.member.lastIndexOf('/') + 1))
989
+ : [];
990
+ const listing = repo && readable(repo) && store.listRepo(repo);
991
+ if (listing) return html(res, 200, repoIndexPage({ ...listing, liveSlugs }), NO_STORE);
992
+ if (repo && readable(repo) && liveSlugs.length)
993
+ return html(res, 200, repoIndexPage({ repo, aliases: {}, history: [], liveSlugs }), NO_STORE);
994
+
995
+ return html(res, 404, errorPage(404, `nothing at ${path}`), NO_STORE);
996
+ }
997
+
998
+ /**
999
+ * Accept a coverage corpus for one repo and fold it into what is already stored.
1000
+ *
1001
+ * FOLDED HERE, NOT APPENDED. A corpus is a set of distinct states with counts, and two corpora for
1002
+ * the same declaration are added by merging their entries — which is arithmetic on motu's own
1003
+ * format, and the reason `mergeCorpora` lives in @motujs/coverage rather than in each backend that
1004
+ * ever stores one. Appending would give a file that grows with traffic and a report that counts the
1005
+ * same state many times.
1006
+ *
1007
+ * BUCKETED BY DECLARATION. `keysHash` stamps which key list a corpus was recorded against; a
1008
+ * corpus taken against a different declaration is not comparable to this one, so it is kept
1009
+ * separately rather than mixed. That also makes cleanup after a region changes a single delete.
1010
+ */
1011
+ async function ingestCoverage(req, res, url, repo) {
1012
+ const region = normalizeSegment(url.searchParams.get('region') || '');
1013
+ if (!region) return json(res, 400, { error: 'region is required' });
1014
+ let incoming;
1015
+ try {
1016
+ incoming = JSON.parse((await readBody(req, 1_000_000)).toString('utf8'));
1017
+ } catch {
1018
+ return json(res, 400, { error: 'body must be a JSON corpus' });
1019
+ }
1020
+ if (!Array.isArray(incoming?.entries) || !Array.isArray(incoming?.keys))
1021
+ return json(res, 400, { error: 'a corpus needs `keys` and `entries`' });
1022
+ try {
1023
+ const result = store.mergeCoverage(repo, region, incoming);
1024
+ // NOTHING ABOUT THE CORPUS COMES BACK. The caller is an application server forwarding on behalf
1025
+ // of a browser; it needs to know the write LANDED so the client can stop re-reporting that
1026
+ // state, and it has no business reading what anyone else's browser has reached.
1027
+ return json(res, 200, { ok: true, states: result.states });
1028
+ } catch (err) {
1029
+ return json(res, 400, { error: String(err?.message ?? err) });
1030
+ }
1031
+ }
1032
+
1033
+ async function publish(req, res, url) {
1034
+ const repo = normalizeRepo(url.searchParams.get('repo'));
1035
+ const slug = normalizeSegment(url.searchParams.get('slug'));
1036
+ if (!repo) return json(res, 400, { error: 'repo must be `name` or `owner/name`, [A-Za-z0-9._-]' });
1037
+ if (!slug) return json(res, 400, { error: 'slug must be [A-Za-z0-9._-]' });
1038
+ const title = (url.searchParams.get('title') || slug).slice(0, 200);
1039
+ const sha = normalizeSegment(url.searchParams.get('sha') || '') || null;
1040
+ const branch = normalizeSegment(url.searchParams.get('branch') || '') || null;
1041
+ // A CSS COLOUR, not a segment: the value is whatever the project declared, which can be a hex, an
1042
+ // hsl() or a color-mix(). Bounded and screened for the two characters that could break out of the
1043
+ // custom-property declaration it ends up in; anything else is the project's business, not ours.
1044
+ const rawBrand = (url.searchParams.get('brand') || '').slice(0, 120).trim();
1045
+ const brand = rawBrand && !/[;{}<>]/.test(rawBrand) ? rawBrand : null;
1046
+
1047
+ let body;
1048
+ try {
1049
+ body = await readBody(req);
1050
+ } catch (e) {
1051
+ return json(res, e.status ?? 400, { error: e.message });
1052
+ }
1053
+ if (!body.length) return json(res, 400, { error: 'empty body — expected the published fragment' });
1054
+
1055
+ // A CHUNKED lagoon: the manifest is the record, and the chunks it names must already be here.
1056
+ const manifest = parseManifest(body);
1057
+ if (manifest) {
1058
+ const refs = Object.values(manifest.files);
1059
+ const missing = store.missingBlobs(refs);
1060
+ if (missing.length) return json(res, 422, { error: `manifest names ${missing.length} chunk(s) this host does not have — send them first`, missing });
1061
+ const out = store.publish(repo, slug, body, { title, sha, branch, brand, refs });
1062
+ return json(res, 200, {
1063
+ ok: true,
1064
+ repo,
1065
+ slug,
1066
+ deduped: out.deduped,
1067
+ bytes: out.bytes,
1068
+ hash: out.hash,
1069
+ chunks: refs.length,
1070
+ live: !!live.endpointFor(repo, slug),
1071
+ urls: {
1072
+ latest: `/${repo}/latest/${slug}`,
1073
+ immutable: `/${repo}/${out.sha}/${slug}`,
1074
+ ...(branch ? { branch: `/${repo}/${branch}/${slug}` } : {}),
1075
+ },
1076
+ });
1077
+ }
1078
+ if (String(req.headers['content-type'] ?? '').startsWith(MANIFEST_TYPE)) return json(res, 400, { error: 'not a lagoon manifest' });
1079
+
1080
+ // The one content check worth REFUSING on: a fragment still pointing at /assets/ is a build whose
1081
+ // inlining did not happen, and it renders blank here exactly as it would under an artifact CSP.
1082
+ const text = body.toString('utf8');
1083
+ const dangling = text.match(/["'(]\/assets\/[^"')]+/);
1084
+ if (dangling) return json(res, 422, { error: `not self-contained — still references ${dangling[0].slice(1)}` });
1085
+
1086
+ // Everything else absolute is a WARNING, not a refusal, and the distinction is load-bearing.
1087
+ // Twenty's lagoon references /images/placeholders/*.png — decorative art the bundler never
1088
+ // inlines because it is an <img src>, not a CSS asset. Those 404 against the host while the page
1089
+ // itself works, so rejecting the upload would block a real lagoon over missing placeholder art;
1090
+ // staying silent claimed "self-contained" about a page that is not. Say it, and publish it.
1091
+ //
1092
+ // Matched by FILE EXTENSION, not by "starts with a slash". The first cut flagged `/login`,
1093
+ // `/verify` and a bare `/-/g` regex literal — router strings and JS, none of them ever fetched.
1094
+ // A warning that fires on those is a warning nobody reads, and the one real hit (a .png) was
1095
+ // buried among nine false ones.
1096
+ const absolute = [...new Set((text.match(/["'(](\/[A-Za-z0-9_][^"')\s]*\.(?:png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|mp4|webm|json|css|js|mjs))/g) ?? []).map((m) => m.slice(1)))]
1097
+ .filter((u) => !u.startsWith('/assets/'))
1098
+ .slice(0, 10);
1099
+
1100
+ const out = store.publish(repo, slug, body, { title, sha, branch, brand });
1101
+ return json(res, 200, {
1102
+ ok: true,
1103
+ repo,
1104
+ slug,
1105
+ deduped: out.deduped,
1106
+ bytes: out.bytes,
1107
+ // WHAT WE ACTUALLY STORED, so the publisher can check it against what it sent without
1108
+ // downloading the page back. `deduped` says "these bytes were already here" and `bytes` says
1109
+ // how many — neither says WHICH, so a host that stored the wrong blob reports success in a
1110
+ // shape nothing can contradict. The content hash is the one field that can be wrong, and a
1111
+ // publisher holding the same bytes can tell.
1112
+ hash: out.hash,
1113
+ // A LIVE DEV SERVER SHADOWS `latest`, and the publisher cannot see that from here. The member
1114
+ // route proxies a registered dev server ahead of the store, on purpose — so a publish made
1115
+ // while `lagoon dev` is running hands back a `latest` URL that serves VITE, not the build just
1116
+ // published. It looks exactly like a publish that did not land. Say so; the immutable URL is
1117
+ // never shadowed and is the one to trust in that moment.
1118
+ live: !!live.endpointFor(repo, slug),
1119
+ ...(absolute.length ? { warnings: [`references ${absolute.length} absolute path(s) that will 404 when hosted: ${absolute.join(', ')}`] } : {}),
1120
+ urls: {
1121
+ latest: `/${repo}/latest/${slug}`,
1122
+ immutable: `/${repo}/${out.sha}/${slug}`,
1123
+ ...(branch ? { branch: `/${repo}/${branch}/${slug}` } : {}),
1124
+ },
1125
+ });
1126
+ }
1127
+
1128
+ /**
1129
+ * Record one rendered shot. Does NOT move the baseline — storing and accepting are separate acts,
1130
+ * which is the whole point: `--update` overwriting everything is why a stale baseline and a real
1131
+ * regression look identical today.
1132
+ */
1133
+ async function baseline(req, res, url) {
1134
+ const repo = normalizeRepo(url.searchParams.get('repo'));
1135
+ const island = normalizeSegment(url.searchParams.get('island'));
1136
+ // `<scenario>@<viewport>` — one extra character over a plain segment, and it keeps the shot's
1137
+ // identity readable in every listing and URL.
1138
+ const shot = url.searchParams.get('shot');
1139
+ if (!repo || !island) return json(res, 400, { error: 'repo and island are required' });
1140
+ if (!shot || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,128}$/.test(shot))
1141
+ return json(res, 400, { error: 'shot must look like <scenario>@<viewport>' });
1142
+
1143
+ let body;
1144
+ try {
1145
+ body = await readBody(req, 8 * 1024 * 1024, 16 * 1024 * 1024);
1146
+ } catch (e) {
1147
+ return json(res, e.status ?? 400, { error: e.message });
1148
+ }
1149
+ // A PNG or nothing: this endpoint stores images, and a helpful error beats a stored HTML page.
1150
+ if (body.length < 8 || body.readUInt32BE(0) !== 0x89504e47)
1151
+ return json(res, 415, { error: 'body must be a PNG' });
1152
+
1153
+ const out = store.putShot(repo, island, shot, body, {
1154
+ sha: normalizeSegment(url.searchParams.get('sha') || '') || null,
1155
+ branch: normalizeSegment(url.searchParams.get('branch') || '') || null,
1156
+ });
1157
+ return json(res, 200, { ok: true, repo, island, shot, ...out, url: `/shot/${out.hash}` });
1158
+ }
1159
+
1160
+ /** Move the accepted pointer — the deliberate act that makes a later diff mean something. */
1161
+ async function acceptBaseline(req, res, url) {
1162
+ const repo = normalizeRepo(url.searchParams.get('repo'));
1163
+ const island = normalizeSegment(url.searchParams.get('island'));
1164
+ if (!repo) return json(res, 400, { error: 'repo is required' });
1165
+ const shots = store.listShots(repo, island);
1166
+ const wanted = url.searchParams.get('shot');
1167
+ const targets = shots.filter((s) => (!wanted || s.shot === wanted) && s.status !== 'match' && s.last);
1168
+ const accepted = [];
1169
+ for (const t of targets) {
1170
+ const r = store.acceptShot(repo, t.island, t.shot);
1171
+ if (r) accepted.push(`${t.island}/${t.shot}`);
1172
+ }
1173
+ return json(res, 200, { ok: true, repo, accepted, count: accepted.length });
1174
+ }
1175
+
1176
+ /**
1177
+ * THE HMR SOCKET, forwarded to whichever dev server is live for this member.
1178
+ *
1179
+ * `proxyLive` speaks HTTP with `fetch`, which cannot carry a protocol upgrade — so a viewer on the
1180
+ * canonical URL got the dev server's HTML, complete with its HMR client, and that client then had
1181
+ * nowhere to connect. The page was LIVE (a refresh showed current code) and never HOT (an edit did
1182
+ * not push itself), which is the half of the promise people actually notice.
1183
+ *
1184
+ * Addressed under the page's own path (`/<repo>/latest/<slug>/__motu_hmr`), for the same reason the
1185
+ * reload stream is: one member, one prefix, and no second route to keep in step. The CLI points
1186
+ * Vite's client at it when it announces.
1187
+ *
1188
+ * A raw socket pipe, not a framework: an upgrade is the client's bytes and the server's bytes, and
1189
+ * anything in between is a chance to corrupt a frame.
1190
+ */
1191
+ const server = createServer((req, res) => {
1192
+ handle(req, res).catch((err) => {
1193
+ // Never leak a stack to an unauthenticated reader; the operator has the console.
1194
+ console.error(`✗ ${req.method} ${req.url} — ${err.stack || err.message}`);
1195
+ if (!res.headersSent) json(res, 500, { error: 'internal error' });
1196
+ else res.end();
1197
+ });
1198
+ });
1199
+
1200
+ server.on('upgrade', (req, socket, head) => {
1201
+ const fail = () => {
1202
+ try {
1203
+ socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
1204
+ } catch {}
1205
+ socket.destroy();
1206
+ };
1207
+ try {
1208
+ const url = new URL(req.url ?? '/', 'http://placeholder');
1209
+ const segs = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
1210
+ if (segs[segs.length - 1] !== '__motu_hmr' || segs.length < 4) return fail();
1211
+ const parts = segs.slice(0, -1);
1212
+ const slug = normalizeSegment(parts[parts.length - 1]);
1213
+ const ref = normalizeSegment(parts[parts.length - 2]);
1214
+ const repo = parts.slice(0, -2).join('/');
1215
+ // `latest` only, exactly as the page route decides: an immutable URL is never live, and a
1216
+ // socket that ignored that would be a live channel hanging off a frozen address.
1217
+ const target = ref === 'latest' ? live.endpointFor(repo, slug) : null;
1218
+ if (!target) return fail();
1219
+ const dest = new URL(target);
1220
+ const upstream = netConnect(
1221
+ { host: dest.hostname, port: Number(dest.port || 80) },
1222
+ () => {
1223
+ // The dev server's own path, not ours: Vite matches on what it was configured with.
1224
+ const head1 = `GET ${url.pathname}${url.search} HTTP/1.1\r\n`;
1225
+ const headers = Object.entries(req.headers)
1226
+ .filter(([k]) => k.toLowerCase() !== 'host')
1227
+ .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}\r\n`)
1228
+ .join('');
1229
+ upstream.write(`${head1}host: ${dest.host}\r\n${headers}\r\n`);
1230
+ if (head?.length) upstream.write(head);
1231
+ socket.pipe(upstream);
1232
+ upstream.pipe(socket);
1233
+ },
1234
+ );
1235
+ upstream.on('error', fail);
1236
+ socket.on('error', () => upstream.destroy());
1237
+ } catch {
1238
+ fail();
1239
+ }
1240
+ });
1241
+
1242
+ return { server, store };
1243
+ }