retake-dev 0.4.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.
Files changed (43) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +198 -0
  3. package/bin/retake.js +319 -0
  4. package/package.json +87 -0
  5. package/src/code-versions.js +357 -0
  6. package/src/core.js +265 -0
  7. package/src/plugin.js +137 -0
  8. package/src/runtime/00-core.js +308 -0
  9. package/src/runtime/10-animations.js +362 -0
  10. package/src/runtime/20-media.js +157 -0
  11. package/src/runtime/30-recorder.js +315 -0
  12. package/src/runtime/32-state.js +315 -0
  13. package/src/runtime/35-network.js +789 -0
  14. package/src/runtime/36-scripts.js +129 -0
  15. package/src/runtime/37-next.js +150 -0
  16. package/src/runtime/38-observers.js +252 -0
  17. package/src/runtime/40-input.js +795 -0
  18. package/src/runtime/45-hover.js +78 -0
  19. package/src/runtime/50-engine.js +341 -0
  20. package/src/runtime/60-preview.js +437 -0
  21. package/src/runtime/65-timeline.js +289 -0
  22. package/src/runtime/66-activity.js +114 -0
  23. package/src/runtime/67-csssource.js +226 -0
  24. package/src/runtime/70-boot.js +460 -0
  25. package/src/server/api.js +285 -0
  26. package/src/server/child.js +174 -0
  27. package/src/server/detect.js +167 -0
  28. package/src/server/front.js +647 -0
  29. package/src/server/mcp.js +332 -0
  30. package/src/shell/00-state.js +85 -0
  31. package/src/shell/05-api.js +136 -0
  32. package/src/shell/10-dock.js +754 -0
  33. package/src/shell/12-checkpoint.js +121 -0
  34. package/src/shell/15-session.js +291 -0
  35. package/src/shell/20-timeline.js +734 -0
  36. package/src/shell/25-input.js +537 -0
  37. package/src/shell/30-notes.js +870 -0
  38. package/src/shell/40-code.js +80 -0
  39. package/src/shell/90-handle.js +15 -0
  40. package/src/shell/shell.css +295 -0
  41. package/src/shell/shell.html +59 -0
  42. package/types/client.d.ts +73 -0
  43. package/types/index.d.ts +111 -0
@@ -0,0 +1,647 @@
1
+ // The front server: Retake in front of any dev server (Next, Nuxt, React
2
+ // Router, Astro, SvelteKit, a Vite SPA...). It listens on Retake's port and
3
+ // proxies to the app's dev server ("upstream"):
4
+ //
5
+ // /__retake/health 200 once the upstream has answered, 503 before
6
+ // /__retake/* the session API (no code-version routes here)
7
+ // Service-Worker: script 404 (a worker would take the dock's origin over)
8
+ // a top-level page load the dock (no app code on it)
9
+ // the dock's frame the app's page, with the runtime injected
10
+ // anything else streamed through untouched (assets, data
11
+ // fetches, server actions, API routes, SSE)
12
+ // WebSocket upgrades a raw socket pipe (HMR)
13
+ //
14
+ // Every frame document is also kept as it came (.retake/docs/<id>), and the
15
+ // runtime told its id (RT.docId). A rebuild asks for the copy its recording
16
+ // was made on with a one-shot `__retake_doc=<id>` cookie (the dock sets it
17
+ // before it loads the frame), so it isn't rendered again by a server whose
18
+ // data or clock has moved on (F56). The cookie never reaches the dev server.
19
+ //
20
+ // A kept copy is only served while the project's code is the one it was
21
+ // rendered with: once a source file changes after it was kept, a build gets
22
+ // the page rendered again (old HTML with new client code never hydrates).
23
+ //
24
+ // The dock's frame is known by its request headers (Sec-Fetch-Dest: iframe),
25
+ // not by its URL, so the app never sees a `__wb` marker (CONTRACT.md "Front
26
+ // server"). Browsers without Sec-Fetch-* headers get the URL marker instead,
27
+ // stripped before the upstream sees it.
28
+ //
29
+ // Only requests for localhost, *.localhost, an IP address or the --host given
30
+ // are served (like Vite's allowedHosts): through DNS rebinding a web page
31
+ // could otherwise read the dock's token and the recordings.
32
+ import crypto from "node:crypto"
33
+ import fs from "node:fs"
34
+ import http from "node:http"
35
+ import https from "node:https"
36
+ import net from "node:net"
37
+ import path from "node:path"
38
+ import tls from "node:tls"
39
+ import { injectHtml, runtimeScript, runtimeTag, scriptHash, shellHtml } from "../core.js"
40
+ import { createBus, createSessionHandler, writeServerInfo } from "./api.js"
41
+ import { frontRuntime } from "./detect.js"
42
+
43
+ const HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer", "proxy-authenticate", "proxy-authorization"])
44
+ const WAIT_MS = 60_000 // how long a frame request waits for a dev server that isn't up yet
45
+
46
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
47
+ const DOC_ID = /^[0-9a-f]{16}$/
48
+ const DOCS_KEPT = 200 // stored frame documents (oldest go first)
49
+
50
+ // The `__retake_doc` cookie out of a Cookie header: [id, the header without it].
51
+ function docCookie(header) {
52
+ if (!header) return [null, header]
53
+ /** @type {string | null} */
54
+ let id = null
55
+ const rest = String(header)
56
+ .split(";")
57
+ .filter((part) => {
58
+ const m = /^\s*__retake_doc=([^;]*)$/.exec(part)
59
+ if (m) id = m[1].trim()
60
+ return !m
61
+ })
62
+ .join(";")
63
+ .trim()
64
+ return [id && DOC_ID.test(id) ? id : null, rest]
65
+ }
66
+ const isHtml = (type) => /^\s*text\/html\b/i.test(type || "")
67
+
68
+ // Is a Host header one Retake answers to? `extra`: more names (Set), or true for any.
69
+ export function hostAllowed(header, extra) {
70
+ if (header == null || extra === true) return true // (no Host: not a browser)
71
+ const name = String(header).trim().toLowerCase().replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "")
72
+ if (name === "localhost" || name.endsWith(".localhost") || net.isIP(name)) return true
73
+ return !!(extra && extra.has(name))
74
+ }
75
+
76
+ // Folders a project's source changes aren't in: dependencies, build output,
77
+ // the frameworks' generated files, and Retake's own.
78
+ const NOT_SOURCE = /(^|[\\/])(node_modules|\.git|\.retake|\.next|\.nuxt|\.output|\.svelte-kit|\.astro|\.react-router|\.vercel|\.netlify|\.turbo|\.cache|\.vite|dist|build|coverage)([\\/]|$)|\.(log|tmp|swp)$|~$/
79
+
80
+ // Does a CSP source list let an inline <script> run as it is?
81
+ function inlineAllowed(list) {
82
+ const l = ` ${list} `
83
+ if (/'nonce-|'sha(256|384|512)-|'strict-dynamic'/i.test(l)) return false // 'unsafe-inline' is ignored then
84
+ return /'unsafe-inline'/i.test(l)
85
+ }
86
+
87
+ /**
88
+ * The CSP of a frame document, made to let the injected runtime run and the
89
+ * dock frame the page. Returns { csp, nonce } (csp null: drop the header).
90
+ * Framing that already allows the dock (same origin) is left alone.
91
+ */
92
+ export function adaptCsp(csp, script) {
93
+ if (!csp) return { csp, nonce: null }
94
+ // Several policies in one header: too many to reason about; dev only, drop it.
95
+ if (/,/.test(csp.replace(/'[^']*'/g, ""))) return { csp: null, nonce: null }
96
+ const dirs = csp.split(";").map((d) => d.trim()).filter(Boolean)
97
+ const find = (name) => dirs.findIndex((d) => d.toLowerCase().split(/\s+/)[0] === name)
98
+ /** @type {string | null} */
99
+ let nonce = null
100
+ // The directive that rules an inline <script>: script-src-elem, script-src, then default-src.
101
+ const at = [find("script-src-elem"), find("script-src"), find("default-src")].find((i) => i >= 0)
102
+ if (at != null) {
103
+ const list = dirs[at].replace(/^\S+/, "")
104
+ const m = /'nonce-([^']+)'/.exec(list)
105
+ if (m) nonce = m[1]
106
+ else if (!inlineAllowed(list)) {
107
+ if (/'none'/i.test(list)) dirs[at] = dirs[at].replace(/'none'/i, "")
108
+ dirs[at] += ` ${scriptHash(script)}`
109
+ }
110
+ }
111
+ const fa = find("frame-ancestors")
112
+ if (fa >= 0) {
113
+ const list = dirs[fa].replace(/^\S+/, "")
114
+ if (/'none'/i.test(list)) dirs[fa] = "frame-ancestors 'self'"
115
+ else if (!/'self'|\*/.test(list)) dirs[fa] += " 'self'"
116
+ }
117
+ return { csp: dirs.join("; "), nonce }
118
+ }
119
+
120
+ /**
121
+ * @param {{ upstream: string | Promise<string>, port?: number, host?: string,
122
+ * root: string, token?: string, rt?: object, verbose?: boolean, label?: string,
123
+ * quiet?: boolean, framework?: string, dir?: string, watch?: string | null,
124
+ * allowedHosts?: string[] | true }} options
125
+ * `upstream`: the dev server's URL, or a promise of it (a dev command that
126
+ * hasn't said where it listens yet). `label`: what to call it on the waiting
127
+ * page ("next dev on :3015"). `framework` (detect.js), and the project `dir`
128
+ * it's in, pick the runtime's setup (frontRuntime); unknown, a Next dev
129
+ * server is recognised by its first page. `rt` overrides any of it.
130
+ * `watch`: the project folder whose source changes retire kept pages (default
131
+ * `dir`). `allowedHosts`: more Host names to answer to (true: any).
132
+ * @returns {Promise<{ url: string, port: number, token: string, readonly upstream: string | null, readonly healthy: boolean, close(): Promise<void> }>}
133
+ */
134
+ export async function startFront(options) {
135
+ const { root, verbose = false, quiet = false } = options
136
+ const token = options.token || crypto.randomBytes(16).toString("hex")
137
+ // The runtime's setup for this framework. Fronting a bare URL or a dev
138
+ // command, Next is recognised by its first page's X-Powered-By.
139
+ let framework = options.framework || null
140
+ let rtBase = { ...frontRuntime(framework, options.dir), ...options.rt }
141
+ const sniff = (h) => {
142
+ if (framework || !/next\.js/i.test(String(h["x-powered-by"] || ""))) return
143
+ framework = "next"
144
+ rtBase = { ...frontRuntime(framework, options.dir), ...options.rt }
145
+ }
146
+ const bus = createBus()
147
+ const api = createSessionHandler({ root, token, bus })
148
+ const hosts = options.allowedHosts === true ? true : new Set([options.host, ...(options.allowedHosts || [])].filter(Boolean).map((h) => String(h).toLowerCase()))
149
+ /** @type {URL | null} */
150
+ let UP = null // URL of the upstream, once known
151
+ const upstreamKnown = Promise.resolve(options.upstream).then((u) => (UP = new URL(u)))
152
+ upstreamKnown.catch(() => {})
153
+ const agents = {
154
+ "http:": new http.Agent({ keepAlive: true }),
155
+ "https:": new https.Agent({ keepAlive: true, rejectUnauthorized: false }),
156
+ }
157
+
158
+ // --verbose: JSON lines in <root>/.retake/front.log.
159
+ const T0 = Date.now()
160
+ /** @type {import("node:fs").WriteStream | null} */
161
+ let logStream = null
162
+ if (verbose) {
163
+ fs.mkdirSync(path.join(root, ".retake"), { recursive: true })
164
+ logStream = fs.createWriteStream(path.join(root, ".retake", "front.log"), { flags: "a" })
165
+ }
166
+ const log = (o) => logStream && logStream.write(JSON.stringify({ t: Date.now() - T0, ...o }) + "\n")
167
+ const warned = new Set()
168
+ const warnOnce = (key, msg) => {
169
+ if (warned.has(key) || quiet) return
170
+ warned.add(key)
171
+ console.warn(`retake: ${msg}`)
172
+ }
173
+
174
+ // ---- health: has the upstream answered anything yet? ---------------------
175
+ let healthy = false
176
+ /** @type {Promise<unknown> | null} */
177
+ let probing = null
178
+ const probe = () => {
179
+ if (healthy || probing || !UP) return
180
+ probing = new Promise((resolve) => {
181
+ const r = request({ method: "GET", path: "/", headers: { accept: "text/html", "user-agent": "retake-health" } }, (res) => {
182
+ healthy = true
183
+ res.resume()
184
+ resolve(undefined)
185
+ })
186
+ r.setTimeout(WAIT_MS, () => r.destroy())
187
+ r.on("error", resolve)
188
+ r.end()
189
+ }).then(() => (probing = null))
190
+ }
191
+ // Only once the upstream is known (UP).
192
+ const request = (opts, cb) => {
193
+ const up = /** @type {URL} */ (UP)
194
+ const lib = up.protocol === "https:" ? https : http
195
+ return lib.request({ host: up.hostname, port: up.port || (up.protocol === "https:" ? 443 : 80), agent: agents[up.protocol], autoSelectFamily: true, ...opts }, cb)
196
+ }
197
+
198
+ // ---- proxying ------------------------------------------------------------
199
+ const ourOrigin = (req) => `${req.socket.encrypted ? "https" : "http"}://${req.headers.host}`
200
+ // A redirect in a URL-marked frame keeps the marker (`wb`), or the page it
201
+ // goes to would be the dock again, inside the dock.
202
+ const rewriteLocation = (loc, req, wb) => {
203
+ try {
204
+ const absolute = /^[a-z][a-z0-9+.-]*:/i.test(loc) || loc.startsWith("//")
205
+ const up = /** @type {URL} */ (UP)
206
+ const u = new URL(loc, absolute ? up : ourOrigin(req) + req.url)
207
+ const ours = u.host === up.host || u.host === req.headers.host
208
+ if (!ours || !/^https?:/.test(u.protocol)) return loc
209
+ if (wb) u.searchParams.set("__wb", "app")
210
+ if (absolute) return ourOrigin(req) + u.pathname + u.search + u.hash
211
+ if (wb) return u.pathname + u.search + u.hash
212
+ } catch {}
213
+ return loc
214
+ }
215
+ const upstreamPath = (req, url) => {
216
+ if (!url.searchParams.has("__wb")) return req.url
217
+ url.searchParams.delete("__wb")
218
+ return url.pathname + url.search
219
+ }
220
+
221
+ let seq = 0
222
+ /**
223
+ * @param {any} req
224
+ * @param {any} res
225
+ * @param {{ frame: boolean, marker?: "url" | "header", why?: string }} how
226
+ */
227
+ async function forward(req, res, { frame, marker, why }) {
228
+ const id = ++seq
229
+ const t0 = performance.now()
230
+ const url = new URL(req.url, "http://x")
231
+ const headers = {}
232
+ let docId = null
233
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
234
+ const k = req.rawHeaders[i]
235
+ let v = req.rawHeaders[i + 1]
236
+ // Our one-shot cookie is ours: the dev server never sees it.
237
+ if (k.toLowerCase() === "cookie") {
238
+ ;[docId, v] = docCookie(v)
239
+ if (!v) continue
240
+ }
241
+ if (!HOP.has(k.toLowerCase())) headers[k] = headers[k] != null ? `${headers[k]}${k.toLowerCase() === "cookie" ? "; " : ", "}${v}` : v
242
+ }
243
+ // Keep the browser's Host: Next builds redirects and checks a Server
244
+ // Action's Origin and its dev origins against it; Vite checks allowedHosts.
245
+ headers["X-Forwarded-Host"] = req.headers.host || ""
246
+ headers["X-Forwarded-Proto"] = req.socket.encrypted ? "https" : "http"
247
+ headers["X-Forwarded-Port"] = String(req.socket.localPort)
248
+ if (frame) {
249
+ for (const k of Object.keys(headers)) if (/^(accept-encoding|if-none-match|if-modified-since)$/i.test(k)) delete headers[k]
250
+ headers["Accept-Encoding"] = "identity"
251
+ }
252
+ const pathq = upstreamPath(req, url)
253
+ // A rebuild asking for the page as it was recorded.
254
+ if (frame && docId && req.method === "GET" && serveStored(req, res, { docId, pathq, marker, id, t0 })) return
255
+ // (Not served from a copy: the one-shot cookie still goes.)
256
+ const clearDoc = frame && docId ? `__retake_doc=; Path=${url.pathname}; Max-Age=0; SameSite=Strict` : null
257
+ const replayable = req.method === "GET" || req.method === "HEAD"
258
+ const deadline = Date.now() + WAIT_MS
259
+ // A dev server that isn't up yet (or not known yet): page loads wait for it.
260
+ if (!UP) {
261
+ await Promise.race([upstreamKnown, sleep(WAIT_MS)]).catch(() => {})
262
+ if (!UP) return waiting(res)
263
+ }
264
+ /** @type {import("node:http").ClientRequest | null} */
265
+ let current = null
266
+ res.on("close", () => {
267
+ if (!res.writableFinished && current) current.destroy()
268
+ })
269
+ const attempt = () => {
270
+ const up = (current = request({ method: req.method, path: pathq, headers }, (r) => onResponse(r)))
271
+ up.on("error", async (/** @type {NodeJS.ErrnoException} */ e) => {
272
+ if (replayable && !res.headersSent && (e.code === "ECONNREFUSED" || e.code === "ECONNRESET") && Date.now() < deadline && !req.destroyed) {
273
+ await sleep(250)
274
+ return attempt()
275
+ }
276
+ log({ kind: "http", id, method: req.method, url: req.url, err: e.code || e.message })
277
+ if (replayable && !res.headersSent && e.code === "ECONNREFUSED") return waiting(res)
278
+ if (!res.headersSent) {
279
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" })
280
+ res.end(`retake: the dev server at ${UP?.href} didn't answer (${e.code || e.message})`)
281
+ } else res.destroy()
282
+ })
283
+ if (replayable) up.end()
284
+ else req.pipe(up)
285
+ }
286
+ const onResponse = (r) => {
287
+ healthy = true
288
+ const h = { ...r.headers }
289
+ for (const k of Object.keys(h)) if (HOP.has(k)) delete h[k]
290
+ if (h.location) h.location = rewriteLocation(h.location, req, frame && marker === "url")
291
+ if (clearDoc) h["set-cookie"] = [...[].concat(h["set-cookie"] || []), clearDoc]
292
+ const bodyless = req.method === "HEAD" || r.statusCode === 204 || r.statusCode === 304 || r.statusCode < 200
293
+ const inject = frame && isHtml(h["content-type"]) && !bodyless
294
+ if (inject) sniff(h)
295
+ const done = (extra) =>
296
+ log({ kind: "http", id, method: req.method, url: req.url, up: pathq !== req.url ? pathq : undefined, dest: req.headers["sec-fetch-dest"], status: r.statusCode, inject: inject || undefined, why, ms: Math.round(performance.now() - t0), loc: h.location, ...extra })
297
+ // The dev server dropping the answer mid-body (a restart, a crash) drops
298
+ // the browser's too: an EventSource then reconnects, and a cut-off page
299
+ // doesn't load for ever.
300
+ r.on("close", () => {
301
+ if (r.complete || res.writableFinished) return
302
+ log({ kind: "http", id, method: req.method, url: req.url, err: "upstream closed mid-body" })
303
+ res.destroy()
304
+ })
305
+ if (!inject) {
306
+ res.writeHead(r.statusCode, r.statusMessage, h)
307
+ r.pipe(res)
308
+ r.on("end", () => done())
309
+ return
310
+ }
311
+ const stored = keepDoc(r, pathq)
312
+ const rt = { ...rtBase, marker, ...(stored ? { docId: stored } : {}) }
313
+ const script = runtimeScript(rt)
314
+ /** @type {string | null} */
315
+ let nonce = null
316
+ if (h["content-security-policy"]) {
317
+ const a = adaptCsp(h["content-security-policy"], script)
318
+ nonce = a.nonce
319
+ if (a.csp) h["content-security-policy"] = a.csp
320
+ else delete h["content-security-policy"]
321
+ }
322
+ // Framing: SAMEORIGIN already lets the dock (same origin through us) frame it.
323
+ const xfo = String(h["x-frame-options"] || "").toLowerCase()
324
+ if (xfo && xfo !== "sameorigin") delete h["x-frame-options"]
325
+ const encoding = h["content-encoding"]
326
+ delete h["content-encoding"]
327
+ delete h["content-length"]
328
+ delete h.etag
329
+ h["cache-control"] = "no-store"
330
+ if (!/charset=/i.test(h["content-type"])) h["content-type"] = `${h["content-type"].trim()}; charset=utf-8`
331
+ let t
332
+ try {
333
+ t = injectHtml(runtimeTag({ script, nonce }), { encoding })
334
+ } catch (err) {
335
+ warnOnce("enc", err.message)
336
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" })
337
+ r.resume()
338
+ return res.end(`retake: ${err.message}`)
339
+ }
340
+ res.writeHead(r.statusCode, r.statusMessage, h)
341
+ t.on("error", () => res.destroy())
342
+ r.pipe(t).pipe(res)
343
+ t.on("end", () => done())
344
+ }
345
+ attempt()
346
+ }
347
+
348
+ // ---- frame documents, kept for rebuilds (F56) -------------------------------
349
+ const docsDir = path.join(root, ".retake", "docs")
350
+ let docsWritten = 0
351
+ // When the project's source last changed (kept pages from before it are stale).
352
+ let codeChangedAt = 0
353
+ const watchers = []
354
+ const watchDir = options.watch !== undefined ? options.watch : options.dir
355
+ if (watchDir) {
356
+ // The folder's own files, and each source folder in it recursively
357
+ // (never node_modules: on Linux a recursive watch walks every folder).
358
+ const watch = (p, recursive) => {
359
+ try {
360
+ const w = fs.watch(p, { recursive }, (_, file) => {
361
+ if (file && NOT_SOURCE.test(String(file))) return
362
+ codeChangedAt = Date.now()
363
+ })
364
+ w.on("error", () => {})
365
+ watchers.push(w)
366
+ } catch {} // can't watch it: its edits don't retire kept pages
367
+ }
368
+ watch(watchDir, false)
369
+ try {
370
+ for (const e of fs.readdirSync(watchDir, { withFileTypes: true })) if (e.isDirectory() && !NOT_SOURCE.test(e.name)) watch(path.join(watchDir, e.name), true)
371
+ } catch {}
372
+ }
373
+ // Keep this response's body (as it comes, still encoded) and what it was;
374
+ // returns its id, or null if it can't be kept.
375
+ function keepDoc(r, pathq) {
376
+ try {
377
+ fs.mkdirSync(docsDir, { recursive: true })
378
+ const docId = crypto.randomBytes(8).toString("hex")
379
+ const headers = { ...r.headers }
380
+ for (const k of Object.keys(headers)) if (HOP.has(k) || k === "set-cookie") delete headers[k]
381
+ fs.writeFileSync(path.join(docsDir, `${docId}.json`), JSON.stringify({ path: pathq, status: r.statusCode, headers, kept: Date.now() }))
382
+ const out = fs.createWriteStream(path.join(docsDir, `${docId}.body`))
383
+ out.on("error", () => {})
384
+ r.on("data", (c) => out.write(c))
385
+ r.on("end", () => out.end())
386
+ // Cut off: not a page to build from.
387
+ r.on("close", () => {
388
+ if (r.complete) return
389
+ out.destroy()
390
+ for (const ext of ["json", "body"]) fs.rmSync(path.join(docsDir, `${docId}.${ext}`), { force: true })
391
+ })
392
+ if (++docsWritten % 20 === 0) pruneDocs()
393
+ return docId
394
+ } catch {
395
+ return null
396
+ }
397
+ }
398
+ function pruneDocs() {
399
+ try {
400
+ const metas = fs
401
+ .readdirSync(docsDir)
402
+ .filter((f) => f.endsWith(".json"))
403
+ .map((f) => ({ f, t: fs.statSync(path.join(docsDir, f)).mtimeMs }))
404
+ .sort((a, b) => b.t - a.t)
405
+ for (const { f } of metas.slice(DOCS_KEPT)) {
406
+ fs.rmSync(path.join(docsDir, f), { force: true })
407
+ fs.rmSync(path.join(docsDir, f.replace(/\.json$/, ".body")), { force: true })
408
+ }
409
+ } catch {}
410
+ }
411
+ // Serve a kept copy (true), or nothing if there isn't one for this page.
412
+ function serveStored(req, res, { docId, pathq, marker, id, t0 }) {
413
+ let meta
414
+ try {
415
+ meta = JSON.parse(fs.readFileSync(path.join(docsDir, `${docId}.json`), "utf8"))
416
+ if (meta.path !== pathq || !fs.existsSync(path.join(docsDir, `${docId}.body`))) return false
417
+ } catch {
418
+ return false
419
+ }
420
+ // Rendered with code that has changed since: rendered again instead.
421
+ if (!(meta.kept > codeChangedAt)) {
422
+ log({ kind: "doc-stale", id, docId, url: req.url })
423
+ return false
424
+ }
425
+ const h = { ...meta.headers }
426
+ const script = runtimeScript({ ...rtBase, marker, docId, docStored: true })
427
+ /** @type {string | null} */
428
+ let nonce = null
429
+ if (h["content-security-policy"]) {
430
+ const a = adaptCsp(h["content-security-policy"], script)
431
+ nonce = a.nonce
432
+ if (a.csp) h["content-security-policy"] = a.csp
433
+ else delete h["content-security-policy"]
434
+ }
435
+ const xfo = String(h["x-frame-options"] || "").toLowerCase()
436
+ if (xfo && xfo !== "sameorigin") delete h["x-frame-options"]
437
+ const encoding = h["content-encoding"]
438
+ for (const k of ["content-encoding", "content-length", "etag"]) delete h[k]
439
+ h["cache-control"] = "no-store"
440
+ h["x-retake-doc"] = "stored"
441
+ // One shot: the cookie goes as it's used.
442
+ h["set-cookie"] = `__retake_doc=; Path=${new URL(pathq, "http://x").pathname}; Max-Age=0; SameSite=Strict`
443
+ let t
444
+ try {
445
+ t = injectHtml(runtimeTag({ script, nonce }), { encoding })
446
+ } catch {
447
+ return false
448
+ }
449
+ res.writeHead(meta.status, h)
450
+ const body = fs.createReadStream(path.join(docsDir, `${docId}.body`))
451
+ body.on("error", () => res.destroy())
452
+ t.on("error", () => res.destroy())
453
+ body.pipe(t).pipe(res)
454
+ t.on("end", () => log({ kind: "http", id, method: req.method, url: req.url, dest: req.headers["sec-fetch-dest"], status: meta.status, inject: true, stored: docId, ms: Math.round(performance.now() - t0) }))
455
+ return true
456
+ }
457
+
458
+ // A page that says what it's waiting for and tries again every 2 s.
459
+ function waiting(res) {
460
+ if (res.headersSent) return res.destroy()
461
+ const what = options.label || (UP ? `the dev server at ${UP.href}` : "the dev server")
462
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "retry-after": "2" })
463
+ res.end(
464
+ `<!doctype html><meta charset="utf-8"><title>Waiting for the dev server</title>` +
465
+ `<body style="font:14px system-ui;color:#555;display:grid;place-items:center;height:90vh;margin:0">` +
466
+ `<p>Waiting for ${what.replace(/[<&]/g, (c) => (c === "<" ? "&lt;" : "&amp;"))}…</p>` +
467
+ `<script>setTimeout(function(){location.reload()},2000)</script>`,
468
+ )
469
+ }
470
+
471
+ const NESTED = new Set(["iframe", "frame"])
472
+ function refuse(req, res) {
473
+ warnOnce("host", `refused a request for host "${req.headers.host}" (only localhost is served)`)
474
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" })
475
+ res.end(`Blocked request. This host (${JSON.stringify(String(req.headers.host))}) is not allowed.`)
476
+ }
477
+ async function onRequest(req, res) {
478
+ req.socket.setNoDelay(true)
479
+ if (!hostAllowed(req.headers.host, hosts)) return refuse(req, res)
480
+ let url
481
+ try {
482
+ url = new URL(req.url, "http://x")
483
+ } catch {
484
+ res.writeHead(400)
485
+ return res.end()
486
+ }
487
+ const p = url.pathname
488
+ if (p === "/__retake/health") {
489
+ probe()
490
+ if (probing && !healthy) await Promise.race([probing, sleep(1500)])
491
+ res.writeHead(healthy ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store" })
492
+ return res.end(JSON.stringify({ ok: healthy, upstream: UP ? UP.href : null }))
493
+ }
494
+ if (p.startsWith("/__retake/")) return api(req, res)
495
+ if (req.headers["service-worker"] === "script") {
496
+ warnOnce("sw", `blocked a service worker (${p}): it would take over the dock's pages. Service workers are off while Retake is in front.`)
497
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
498
+ return res.end("retake: service workers are off while Retake is in front")
499
+ }
500
+ const mode = req.headers["sec-fetch-mode"]
501
+ const dest = req.headers["sec-fetch-dest"]
502
+ const site = req.headers["sec-fetch-site"]
503
+ const wb = url.searchParams.get("__wb")
504
+ const optOut = url.searchParams.get("retake") === "0"
505
+ const get = req.method === "GET"
506
+ // The dock: a top-level page load. A cross-site one (an OAuth callback)
507
+ // gets the plain page, so sign-in completes; a reload brings the dock back.
508
+ if (get && !wb && !optOut && mode === "navigate" && dest === "document" && site !== "cross-site") {
509
+ log({ kind: "dock", url: req.url })
510
+ return dock(res, "header")
511
+ }
512
+ // No Sec-Fetch-* at all (an older Safari): the dock with the URL marker,
513
+ // unless the dock's frame is what navigated (its URL has the marker; a
514
+ // browser without the Navigation API doesn't put it on every link).
515
+ if (get && !wb && !optOut && !dest && !mode && /text\/html/.test(req.headers.accept || "")) {
516
+ if (fromFrame(req)) return forward(req, res, { frame: true, marker: "url", why: "url-marker-referer" })
517
+ log({ kind: "dock", url: req.url, marker: "url" })
518
+ return dock(res, "url")
519
+ }
520
+ if (wb === "app") return forward(req, res, { frame: true, marker: "url", why: "url-marker" })
521
+ if (mode === "navigate" && NESTED.has(dest) && !optOut) return forward(req, res, { frame: true, marker: "header", why: dest })
522
+ return forward(req, res, { frame: false })
523
+ }
524
+
525
+ function fromFrame(req) {
526
+ try {
527
+ const ref = new URL(req.headers.referer)
528
+ return ref.host === req.headers.host && ref.searchParams.get("__wb") === "app"
529
+ } catch {
530
+ return false
531
+ }
532
+ }
533
+
534
+ function dock(res, marker) {
535
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
536
+ res.end(shellHtml({ token, marker, docs: true }))
537
+ }
538
+
539
+ // ---- WebSockets (HMR): a raw pipe -----------------------------------------
540
+ async function onUpgrade(req, sock, head) {
541
+ sock.on("error", () => {})
542
+ if (!hostAllowed(req.headers.host, hosts)) {
543
+ warnOnce("host", `refused a request for host "${req.headers.host}" (only localhost is served)`)
544
+ return sock.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n")
545
+ }
546
+ if (!UP) await Promise.race([upstreamKnown, sleep(WAIT_MS)]).catch(() => {})
547
+ if (!UP) return sock.destroy()
548
+ const url = new URL(req.url, "http://x")
549
+ const pathq = upstreamPath(req, url)
550
+ const port = Number(UP.port || (UP.protocol === "https:" ? 443 : 80))
551
+ const opts = { host: UP.hostname, port, autoSelectFamily: true }
552
+ const up = UP.protocol === "https:" ? tls.connect({ ...opts, servername: UP.hostname, rejectUnauthorized: false }) : net.connect(opts)
553
+ up.once(UP.protocol === "https:" ? "secureConnect" : "connect", () => {
554
+ up.setNoDelay(true)
555
+ sock.setNoDelay(true)
556
+ const lines = [`${req.method} ${pathq} HTTP/1.1`]
557
+ for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`)
558
+ up.write(lines.join("\r\n") + "\r\n\r\n")
559
+ if (head && head.length) up.write(head)
560
+ sock.pipe(up).pipe(sock)
561
+ log({ kind: "ws-open", url: req.url })
562
+ })
563
+ // Either side closing (or half-closing: HTTP server sockets allow half-open) ends both.
564
+ const both = () => {
565
+ sock.destroy()
566
+ up.destroy()
567
+ }
568
+ for (const s of [sock, up]) for (const e of ["end", "close", "error"]) s.on(e, both)
569
+ sockets.add(sock)
570
+ sock.on("close", () => sockets.delete(sock))
571
+ }
572
+ const sockets = new Set()
573
+
574
+ // ---- listening -----------------------------------------------------------
575
+ // On localhost only (both families, so http://localhost works however it
576
+ // resolves), like Vite: the session API serves recordings without a token.
577
+ const servers = []
578
+ const make = () => {
579
+ const s = http.createServer(onRequest)
580
+ s.on("upgrade", onUpgrade)
581
+ s.requestTimeout = 0 // long uploads and streams are fine
582
+ s.keepAliveTimeout = 30_000
583
+ return s
584
+ }
585
+ const listen = (s, port, host) =>
586
+ new Promise((resolve, reject) => {
587
+ s.once("error", reject)
588
+ s.listen(port, host, () => {
589
+ s.off("error", reject)
590
+ resolve(s.address().port)
591
+ })
592
+ })
593
+ const host = options.host || "127.0.0.1"
594
+ const first = make()
595
+ const port = await listen(first, options.port ?? 0, host)
596
+ servers.push(first)
597
+ if (!options.host) {
598
+ const v6 = make()
599
+ try {
600
+ await listen(v6, port, "::1")
601
+ servers.push(v6)
602
+ } catch (err) {
603
+ if (err.code === "EADDRINUSE") {
604
+ await new Promise((r) => first.close(r))
605
+ throw err
606
+ } // no IPv6 loopback: fine
607
+ }
608
+ }
609
+ const url = `http://localhost:${port}`
610
+ writeServerInfo({ root, url, token })
611
+ upstreamKnown.then(() => {
612
+ log({ kind: "start", upstream: UP?.href, port })
613
+ probe()
614
+ }, () => {})
615
+
616
+ return {
617
+ url,
618
+ port,
619
+ token,
620
+ get upstream() {
621
+ return UP ? UP.href : null
622
+ },
623
+ get healthy() {
624
+ return healthy
625
+ },
626
+ async close() {
627
+ api.close()
628
+ for (const w of watchers) w.close()
629
+ for (const s of sockets) s.destroy()
630
+ for (const a of Object.values(agents)) a.destroy()
631
+ await Promise.all(
632
+ servers.map(
633
+ (s) =>
634
+ new Promise((r) => {
635
+ s.close(() => r(undefined))
636
+ s.closeAllConnections && s.closeAllConnections()
637
+ }),
638
+ ),
639
+ )
640
+ if (logStream) logStream.end()
641
+ try {
642
+ const f = path.join(root, ".retake", "server.json")
643
+ if (JSON.parse(fs.readFileSync(f, "utf8")).url === url) fs.rmSync(f, { force: true })
644
+ } catch {}
645
+ },
646
+ }
647
+ }