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.
- package/LICENSE +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- 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 === "<" ? "<" : "&"))}…</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
|
+
}
|