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
package/src/core.js ADDED
@@ -0,0 +1,265 @@
1
+ // What every way of serving Retake shares: the runtime (as source, or as the
2
+ // script tag a server injects into a frame's page), the dock page, and an HTML
3
+ // stream transform that puts the runtime first in <head>. The Vite plugin, the
4
+ // front server (server/front.js) and the site's deployed build (apps/site) all use these.
5
+ import crypto from "node:crypto"
6
+ import fs from "node:fs"
7
+ import path from "node:path"
8
+ import zlib from "node:zlib"
9
+ import { Transform } from "node:stream"
10
+ import { fileURLToPath } from "node:url"
11
+
12
+ const SRC = path.dirname(fileURLToPath(import.meta.url))
13
+ const read = (...p) => fs.readFileSync(path.join(SRC, ...p), "utf8")
14
+
15
+ // How the runtime is set up for the server it runs behind (`RT` in the
16
+ // runtime). The defaults are the Vite plugin's: a frame marked by `?__wb=app`.
17
+ // marker: "url" (the frame's URL carries `__wb=app`) or "header" (the front
18
+ // server knows the dock's frames by Sec-Fetch-Dest; no URL marker)
19
+ // bootAt: "dcl" (the clock starts at DOMContentLoaded) or "load" (F47)
20
+ // exemptUrls: the dev server's own traffic, as regular expressions (F54)
21
+ // holdScripts: scripts added later are held to their recorded moment (F48)
22
+ // next: the Next.js major version, for its debug channel (F49)
23
+ // docId, docStored: this page's kept copy on the front server, and whether
24
+ // this is it (F56)
25
+ // The front server's setup is frontRuntime() in server/detect.js.
26
+ export const RT_DEFAULTS = Object.freeze({ marker: "url", bootAt: "dcl", exemptUrls: [], holdScripts: false, next: null, docId: null, docStored: false })
27
+
28
+ // The files of a concatenated set ("runtime" or "shell"), in the order they
29
+ // run: later files may use earlier files' top-level bindings. (The type check,
30
+ // scripts/typecheck.mjs, concatenates them the same way.)
31
+ export const setFiles = (set) => fs.readdirSync(path.join(SRC, set)).filter((f) => f.endsWith(".js")).sort()
32
+
33
+ // Read on every page load, so edits to the tool apply on refresh.
34
+ /** @param {import("../types/index.js").RuntimeConfig} [rt] */
35
+ export function runtimeSource(rt = {}) {
36
+ const files = setFiles("runtime")
37
+ const body = files.map((f) => `// ---- ${f}\n${read("runtime", f)}`).join("\n")
38
+ const config = JSON.stringify({ ...RT_DEFAULTS, ...rt })
39
+ return `;(function () {\n"use strict";\nif (window.__retake) return;\nconst RT = Object.freeze(${config});\n${body}\n})();`
40
+ }
41
+
42
+ /** @param {import("../types/index.js").ShellOptions} [options] */
43
+ export function shellHtml(options = {}) {
44
+ const config = {
45
+ codeBranches: !!options.codeBranches,
46
+ features: ["continue", "segments", "serialize"],
47
+ ...(options.appPage ? { appPage: options.appPage } : {}),
48
+ ...(options.marker && options.marker !== "url" ? { marker: options.marker } : {}),
49
+ // Behind the front server: rebuilds ask for the page as it was recorded (F56).
50
+ ...(options.docs ? { docs: true } : {}),
51
+ }
52
+ return read("shell", "shell.html")
53
+ .replace(
54
+ "/*CONFIG*/",
55
+ () => `window.__retakeConfig = ${JSON.stringify(config)};` + (options.token ? `window.__RETAKE_TOKEN = ${JSON.stringify(options.token)};` : ""),
56
+ )
57
+ .replace("/*CSS*/", () => read("shell", "shell.css"))
58
+ .replace("/*JS*/", () => {
59
+ const files = setFiles("shell")
60
+ return `;(function () {\n"use strict";\n${files.map((f) => read("shell", f)).join("\n")}\n})();`
61
+ })
62
+ }
63
+
64
+ // The script a server injects into a page it can't tell apart from the app's
65
+ // own frames (any iframe document). It takes itself out of the DOM first (so an
66
+ // app hydrating the whole document never sees it), then runs the runtime only
67
+ // in a frame the dock made (`isAppFrame`, or, with the URL marker, a URL with
68
+ // `__wb=app`). Anywhere else `__retake` is `{ inert: true }` and the runtime
69
+ // returns at its first line.
70
+ /** @param {import("../types/index.js").RuntimeConfig} [rt] */
71
+ export function runtimeScript(rt = {}) {
72
+ const marker = rt.marker || RT_DEFAULTS.marker
73
+ const guard =
74
+ `;(function(){try{var d=document.currentScript;d&&d.remove()}catch(e){}` +
75
+ `var ok=false;try{var s=window.parent!==window&&window.parent.__retakeShell;` +
76
+ `ok=!!s&&((typeof s.isAppFrame==="function"&&s.isAppFrame(window.frameElement))` +
77
+ (marker === "url" ? `||/[?&]__wb=app(&|$)/.test(location.search)` : "") +
78
+ `)}catch(e){}if(!ok)window.__retake={inert:true}})();`
79
+ return `${guard}\n${runtimeSource(rt)}`
80
+ }
81
+
82
+ /** @param {import("../types/index.js").RuntimeTagOptions} [options] */
83
+ export function runtimeTag({ rt = {}, nonce = null, script = runtimeScript(rt) } = {}) {
84
+ return `<script data-retake${nonce ? ` nonce="${String(nonce).replace(/"/g, "&quot;")}"` : ""}>${script}</script>`
85
+ }
86
+
87
+ // For a CSP without 'unsafe-inline': the hash source that allows this script.
88
+ export const scriptHash = (script) => `'sha256-${crypto.createHash("sha256").update(script, "utf8").digest("base64")}'`
89
+
90
+ const HOLD_MAX = 64 * 1024
91
+
92
+ // Where the tag goes in the HTML seen so far, or -1 to wait for more. `s` is
93
+ // the bytes as latin1 (one char per byte), so indices are byte offsets and a
94
+ // multi-byte character is never split.
95
+ function insertAt(raw, final) {
96
+ // Comments are blanked out (same length) first: a `<head>` written inside one
97
+ // (before the real one) isn't the head. One still open at the end hides the rest.
98
+ const s = raw.replace(/<!--[\s\S]*?(?:-->|$)/g, (c) => " ".repeat(c.length))
99
+ const head = /<head(?=[\s>/])[^>]*>/i.exec(s)
100
+ if (head) {
101
+ let at = head.index + head[0].length
102
+ // A <meta charset> right after it stays first (it must be in the first 1024 bytes).
103
+ const rest = s.slice(at)
104
+ const meta = /<meta\b[^>]*\bcharset\s*=[^>]*>/i.exec(rest)
105
+ if (meta && !/<script\b/i.test(rest.slice(0, meta.index))) at += meta.index + meta[0].length
106
+ return at
107
+ }
108
+ // An opening <head that hasn't closed yet: wait for it.
109
+ if (!final && /<head(?:[\s/][^>]*)?$/i.test(s)) return -1
110
+ const first = s.search(/<(body|script|link|meta|style|title)\b/i)
111
+ if (first >= 0) return first
112
+ if (!final && s.length < HOLD_MAX) return -1
113
+ // Nothing to go by (the hold cap, or the page ended): after the doctype,
114
+ // not before it (that would put the page in quirks mode).
115
+ const doc = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>/i.exec(raw)
116
+ return doc ? doc[0].length : 0
117
+ }
118
+
119
+ const decompressor = (encoding) => {
120
+ const e = String(encoding || "").trim().toLowerCase()
121
+ if (!e || e === "identity") return null
122
+ if (e === "gzip" || e === "x-gzip") return zlib.createGunzip()
123
+ if (e === "br") return zlib.createBrotliDecompress()
124
+ if (e === "deflate") return zlib.createInflate()
125
+ if (e === "zstd" && zlib.createZstdDecompress) return zlib.createZstdDecompress()
126
+ throw new Error(`can't decode content-encoding ${encoding}`)
127
+ }
128
+
129
+ /**
130
+ * A byte stream transform that inserts `tag` into an HTML document as it
131
+ * streams: after the `<head …>` opening tag (and a `<meta charset>` right
132
+ * after it), else before the first <body>/<script>/<link>/<meta>. It holds at
133
+ * most 64 KB back; past that, or if the page ends first, the tag goes after
134
+ * the doctype. Everything after the insertion point passes straight through.
135
+ * @param {string} tag
136
+ * @param {{ encoding?: string }} [options] the body's content-encoding (gzip,
137
+ * br, deflate): it's decoded first, and the output is plain.
138
+ */
139
+ export function injectHtml(tag, { encoding } = {}) {
140
+ const tagBytes = Buffer.from(tag, "utf8")
141
+ let held = []
142
+ let heldLen = 0
143
+ let done = false
144
+ const feed = (chunk, final) => {
145
+ if (done) return chunk
146
+ if (chunk && chunk.length) {
147
+ held.push(chunk)
148
+ heldLen += chunk.length
149
+ }
150
+ const buf = Buffer.concat(held, heldLen)
151
+ const at = insertAt(buf.toString("latin1"), final)
152
+ if (at < 0) {
153
+ held = [buf]
154
+ return null
155
+ }
156
+ done = true
157
+ held = []
158
+ return Buffer.concat([buf.subarray(0, at), tagBytes, buf.subarray(at)])
159
+ }
160
+ const unzip = decompressor(encoding)
161
+ if (!unzip) {
162
+ return new Transform({
163
+ transform(chunk, _, cb) {
164
+ cb(null, feed(chunk, false) || undefined)
165
+ },
166
+ flush(cb) {
167
+ cb(null, feed(null, true) || undefined)
168
+ },
169
+ })
170
+ }
171
+ /** @type {import("node:stream").TransformCallback | null} */
172
+ let flushed = null
173
+ const t = new Transform({
174
+ transform(chunk, _, cb) {
175
+ unzip.write(chunk) ? cb() : unzip.once("drain", cb)
176
+ },
177
+ flush(cb) {
178
+ flushed = cb
179
+ unzip.end()
180
+ },
181
+ })
182
+ unzip.on("data", (d) => {
183
+ const out = feed(d, false)
184
+ if (out && out.length) t.push(out)
185
+ })
186
+ unzip.on("end", () => {
187
+ const out = feed(null, true)
188
+ if (out && out.length) t.push(out)
189
+ if (flushed) flushed()
190
+ })
191
+ unzip.on("error", (err) => t.destroy(err))
192
+ return t
193
+ }
194
+
195
+ /**
196
+ * Runs an HTML response that a server writes in its own way (res.writeHead,
197
+ * write, end, or a stream piped into res) through injectHtml. Whether it's
198
+ * HTML is decided at the first write, from its Content-Type; anything else
199
+ * passes through untouched.
200
+ * @param {import("node:http").ServerResponse} res
201
+ * @param {string} tag
202
+ */
203
+ export function injectResponse(res, tag) {
204
+ const write = res.write.bind(res)
205
+ const end = res.end.bind(res)
206
+ const writeHead = res.writeHead.bind(res)
207
+ /** @type {Transform | null} */
208
+ let t = null
209
+ let decided = false
210
+ const decide = () => {
211
+ if (decided) return
212
+ decided = true
213
+ if (!/^\s*text\/html\b/i.test(String(res.getHeader("content-type") || "")) || res.statusCode === 204 || res.statusCode === 304 || res.req?.method === "HEAD") return
214
+ const encoding = String(res.getHeader("content-encoding") || "")
215
+ for (const h of ["content-length", "content-encoding", "etag"]) res.removeHeader(h)
216
+ res.setHeader("cache-control", "no-store")
217
+ let out
218
+ try {
219
+ out = t = injectHtml(tag, { encoding })
220
+ } catch {
221
+ return
222
+ }
223
+ out.on("data", (c) => {
224
+ if (!write(c)) {
225
+ out.pause()
226
+ res.once("drain", () => out.resume())
227
+ }
228
+ })
229
+ out.on("end", () => end())
230
+ out.on("error", () => res.destroy())
231
+ }
232
+ res.writeHead = /** @type {any} */ (function (status, message, headers) {
233
+ if (typeof message !== "string") {
234
+ headers = message
235
+ message = undefined
236
+ }
237
+ // Headers given here are folded in first, so they can be changed.
238
+ if (Array.isArray(headers)) for (let i = 0; i < headers.length; i += 2) res.setHeader(headers[i], headers[i + 1])
239
+ else if (headers) for (const [k, v] of Object.entries(headers)) res.setHeader(k, v)
240
+ res.statusCode = status
241
+ if (message) res.statusMessage = message
242
+ decide()
243
+ return writeHead(res.statusCode)
244
+ })
245
+ const args = (chunk, encoding, cb) => {
246
+ if (typeof chunk === "function") return [null, null, chunk]
247
+ if (typeof encoding === "function") return [chunk, null, encoding]
248
+ return [chunk, encoding, cb]
249
+ }
250
+ res.write = /** @type {any} */ (function (chunk, encoding, cb) {
251
+ decide()
252
+ if (!t) return write(chunk, encoding, cb)
253
+ ;[chunk, encoding, cb] = args(chunk, encoding, cb)
254
+ return t.write(typeof chunk === "string" ? Buffer.from(chunk, encoding || "utf8") : chunk, cb)
255
+ })
256
+ res.end = /** @type {any} */ (function (chunk, encoding, cb) {
257
+ decide()
258
+ if (!t) return end(chunk, encoding, cb)
259
+ ;[chunk, encoding, cb] = args(chunk, encoding, cb)
260
+ if (chunk != null && chunk.length) t.write(typeof chunk === "string" ? Buffer.from(chunk, encoding || "utf8") : chunk)
261
+ if (cb) res.once("finish", cb)
262
+ t.end()
263
+ return res
264
+ })
265
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,137 @@
1
+ // Vite plugin. A page request gets the dock: a small shell page with the
2
+ // timeline docked at the bottom (like DevTools) and the prototype in a frame
3
+ // above it. The frame's own request (`?__wb=app`) gets the real page with the
4
+ // time runtime injected as its first script, before any app code. Dev only.
5
+ import { AsyncLocalStorage } from "node:async_hooks"
6
+ import crypto from "node:crypto"
7
+ import fs from "node:fs"
8
+ import path from "node:path"
9
+ import { codeVersions } from "./code-versions.js"
10
+ import { injectResponse, runtimeSource, runtimeTag, shellHtml } from "./core.js"
11
+ import { createBus, sessionApi } from "./server/api.js"
12
+ import { frontRuntime } from "./server/detect.js"
13
+
14
+ // The site's deployed build (apps/site/app/retake-dock, retake-runtime) and the tests import these from here.
15
+ export { runtimeSource, shellHtml, runtimeTag, injectHtml } from "./core.js"
16
+
17
+ const NESTED_DEST = new Set(["iframe", "frame", "embed", "object"])
18
+ const pageKind = new AsyncLocalStorage()
19
+
20
+ /**
21
+ * @param {import("../types/index.js").RetakeOptions} [options]
22
+ * `codeBranches`: each timeline keeps its own version of the code; stepping
23
+ * into a timeline checks its code out on disk (see code-versions.js).
24
+ * `root`: where .retake/ goes (default: Vite's root).
25
+ * Public types: types/index.d.ts (keep them in step).
26
+ * @returns {import("vite").Plugin[]}
27
+ */
28
+ export function retake(options = {}) {
29
+ // Mutating /__retake/ requests must carry this (see CONTRACT.md).
30
+ options = { ...options, token: options.token || crypto.randomBytes(16).toString("hex") }
31
+ const bus = createBus()
32
+ let ssr = false
33
+ // The dock for a top-level page load, the runtime injected into the dock's
34
+ // frame (Sec-Fetch-Dest: iframe; isAppFrame() tells it from an app's own
35
+ // iframe), and anything else left alone. Which one a request is stays known
36
+ // while the framework renders it (pageKind), for frameworks that call
37
+ // server.transformIndexHtml themselves.
38
+ const ssrPages = (req, res, next) => {
39
+ const url = new URL(req.url || "/", "http://x")
40
+ const mode = req.headers["sec-fetch-mode"]
41
+ const dest = req.headers["sec-fetch-dest"]
42
+ const optOut = url.searchParams.get("retake") === "0"
43
+ if (url.pathname.startsWith("/__retake/") || mode !== "navigate" || optOut) return pageKind.run("plain", next)
44
+ if (req.method === "GET" && dest === "document" && req.headers["sec-fetch-site"] !== "cross-site") {
45
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
46
+ return res.end(shellHtml({ ...options, marker: "header" }))
47
+ }
48
+ if (dest !== "iframe" && dest !== "frame") return pageKind.run("plain", next)
49
+ delete req.headers["accept-encoding"]
50
+ // Set up like the front server's frames (clock at load, scripts held: see frontRuntime).
51
+ injectResponse(res, runtimeTag({ rt: { ...frontRuntime("vite"), marker: "header" } }))
52
+ pageKind.run("frame", next)
53
+ }
54
+ /** @type {import("vite").Plugin} */
55
+ const main = {
56
+ name: "retake",
57
+ apply: "serve",
58
+ // CSS source maps in dev, so cssSourceFor() can point at the original file.
59
+ config(cfg) {
60
+ if (options.enabled === false) return
61
+ if (!cfg.css || cfg.css.devSourcemap === undefined) return { css: { devSourcemap: true } }
62
+ },
63
+ configureServer(server) {
64
+ if (options.enabled === false) return
65
+ // No index.html: a framework renders the pages itself (React Router,
66
+ // SvelteKit, Astro, TanStack Start...), and transformIndexHtml never sees
67
+ // them. Then pages are told apart by their Sec-Fetch-* headers, like the
68
+ // front server does (header marker), and the frame's HTML is injected as
69
+ // it's written. The URL is never touched (a `__wb` would leak into the app).
70
+ ssr = !fs.existsSync(path.join(server.config.root, "index.html"))
71
+ if (ssr) server.middlewares.use(ssrPages)
72
+ // Only a top-level page load gets the dock. An iframe the app itself
73
+ // embeds (or a frame navigating to another page of a multi-page app)
74
+ // gets its plain page. The dock's own frame asks for `?__wb=app`.
75
+ else server.middlewares.use((req, res, next) => {
76
+ const dest = req.headers["sec-fetch-dest"]
77
+ if (dest && NESTED_DEST.has(dest) && req.url && !/[?&]__wb=/.test(req.url)) {
78
+ const add = (u) => u + (u.includes("?") ? "&" : "?") + "__wb=plain"
79
+ req.url = add(req.url)
80
+ if (req.originalUrl) req.originalUrl = add(req.originalUrl)
81
+ }
82
+ next()
83
+ })
84
+ sessionApi(server, { token: options.token, bus, root: options.root })
85
+ if (options.codeBranches) codeVersions(server, { token: options.token, bus })
86
+ const httpServer = server.httpServer
87
+ if (httpServer) {
88
+ httpServer.once("listening", () => {
89
+ const a = httpServer.address()
90
+ const port = a && typeof a === "object" ? a.port : server.config.server.port
91
+ const proto = server.config.server.https ? "https" : "http"
92
+ const base = server.config.base || "/"
93
+ if (options.banner !== false) console.log(`\n \x1b[1mRetake\x1b[0m timeline docked at ${proto}://localhost:${port}${base}${options.codeBranches ? " (code branches on)" : ""}${ssr ? " (server-rendered pages)" : ""}\n`)
94
+ })
95
+ }
96
+ },
97
+ // With code branches the dock decides when the frame reloads (it replays
98
+ // up to the current moment on the new code), so Vite's HMR stands down.
99
+ handleHotUpdate() {
100
+ if (options.codeBranches) return []
101
+ },
102
+ transformIndexHtml: {
103
+ order: "pre",
104
+ handler(html, ctx) {
105
+ if (options.enabled === false) return
106
+ // A server-rendered page (see ssrPages): the response is injected, if at all.
107
+ if (pageKind.getStore()) return
108
+ const params = new URL(ctx.originalUrl || ctx.path, "http://x").searchParams
109
+ // `?retake=0` opts a page load out entirely.
110
+ if (params.get("retake") === "0") return
111
+ if (params.get("__wb") === "plain") return
112
+ if (params.get("__wb") !== "app") return shellHtml(options)
113
+ return [{ tag: "script", attrs: { "data-retake": "" }, children: runtimeSource(), injectTo: "head-prepend" }]
114
+ },
115
+ },
116
+ }
117
+ // The dock page must not run Vite's client: a full reload (an edit HMR
118
+ // can't apply) would reload the whole dock and drop the session. Only the
119
+ // app frame keeps the client, so only the app reloads. Other plugins' module
120
+ // scripts go too (plugin-react's refresh preamble imports the client); the
121
+ // dock's own scripts are classic ones.
122
+ /** @type {import("vite").Plugin} */
123
+ const stripClient = {
124
+ name: "retake:shell-without-vite-client",
125
+ apply: "serve",
126
+ transformIndexHtml: {
127
+ order: "post",
128
+ handler(html) {
129
+ if (options.enabled === false || !html.includes('id="wb-dock"')) return
130
+ return html.replace(/<script\b[^>]*\btype=["']?module["']?[^>]*>[\s\S]*?<\/script>\s*/gi, "")
131
+ },
132
+ },
133
+ }
134
+ return [main, stripClient]
135
+ }
136
+
137
+ export default retake