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/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "retake-dev",
3
+ "version": "0.4.0",
4
+ "description": "Retake: a time machine for your dev server (Vite, Next.js, React Router, Remix, Astro, SvelteKit, Nuxt). Drag back on a timeline and your app is at that moment; branch a new take from there.",
5
+ "homepage": "https://retake-omega.vercel.app",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/LightningDesigner/retake.git",
9
+ "directory": "packages/retake"
10
+ },
11
+ "bugs": "https://github.com/LightningDesigner/retake/issues",
12
+ "author": "Rushil",
13
+ "type": "module",
14
+ "main": "./src/plugin.js",
15
+ "types": "./types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./types/index.d.ts",
19
+ "default": "./src/plugin.js"
20
+ },
21
+ "./client": {
22
+ "types": "./types/client.d.ts"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "bin": {
27
+ "retake": "bin/retake.js",
28
+ "retake-dev": "bin/retake.js"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "src",
33
+ "!src/shell/mock",
34
+ "types",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "peerDependencies": {
42
+ "vite": ">=5"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "vite": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "devDependencies": {
50
+ "@playwright/test": "^1.63.0",
51
+ "@react-spring/web": "^10.1.2",
52
+ "@types/node": "^26.6.4",
53
+ "@vitejs/plugin-react": "^5.2.0",
54
+ "framer-motion": "^12.43.0",
55
+ "gsap": "^3.15.0",
56
+ "lottie-web": "^5.13.0",
57
+ "react": "^19.3.0",
58
+ "react-dom": "^19.3.0",
59
+ "react-router-dom": "^7.18.4",
60
+ "typescript": "^7.0.2",
61
+ "vite": "^8.3.1"
62
+ },
63
+ "scripts": {
64
+ "check": "node test/check-syntax.js",
65
+ "typecheck": "node scripts/typecheck.mjs",
66
+ "test": "node test/check-syntax.js && node scripts/typecheck.mjs && playwright test",
67
+ "test:engine": "playwright test --project=engine",
68
+ "test:shell": "playwright test --project=shell"
69
+ },
70
+ "keywords": [
71
+ "vite",
72
+ "vite-plugin",
73
+ "nextjs",
74
+ "react-router",
75
+ "time-travel",
76
+ "prototype",
77
+ "devtools",
78
+ "replay",
79
+ "remix",
80
+ "astro",
81
+ "sveltekit",
82
+ "nuxt",
83
+ "time-machine",
84
+ "mcp"
85
+ ],
86
+ "license": "PolyForm-Shield-1.0.0"
87
+ }
@@ -0,0 +1,357 @@
1
+ // Code branches (opt-in): every time the project's source changes, snapshot it
2
+ // as a version. The dock gives each timeline a version and checks an older one
3
+ // back out when you step into its timeline.
4
+ //
5
+ // Nothing here may lose work:
6
+ // - Versions live on disk (<root>/.retake/versions + content-addressed blobs),
7
+ // not in memory, so a crash or quit never takes a version with it.
8
+ // - Every checkout first snapshots what's on disk right now, synchronously, and
9
+ // refuses to touch anything if that snapshot can't be made in full.
10
+ // - Only files a snapshot captured are ever written or deleted. A path the
11
+ // target version didn't capture (skipped for size, ignored, never seen) is
12
+ // left alone, never deleted.
13
+ // - Writes are atomic (temp file + rename) and journaled; a checkout that was
14
+ // interrupted is rolled back on the next start.
15
+ // - When the dev server exits, the newest version is put back on disk.
16
+ import crypto from "node:crypto"
17
+ import fs from "node:fs"
18
+ import path from "node:path"
19
+ import { execFileSync } from "node:child_process"
20
+
21
+ const SKIP = new Set(["node_modules", ".git", ".retake", "dist", "build", ".cache", ".vite", ".next", "coverage"])
22
+ const MAX_FILES = 5000
23
+ const MAX_BYTES = 5 * 1024 * 1024
24
+
25
+ const sha1 = (buf) => crypto.createHash("sha1").update(buf).digest("hex")
26
+
27
+ export function createStore(root) {
28
+ const dir = path.join(root, ".retake")
29
+ const blobs = path.join(dir, "blobs")
30
+ const versionsDir = path.join(dir, "versions")
31
+ const journalFile = path.join(dir, "journal.json")
32
+ const stateFile = path.join(dir, "code-state.json")
33
+ fs.mkdirSync(blobs, { recursive: true })
34
+ fs.mkdirSync(versionsDir, { recursive: true })
35
+ const ignore = path.join(dir, ".gitignore")
36
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n")
37
+
38
+ function atomicWrite(file, data) {
39
+ fs.mkdirSync(path.dirname(file), { recursive: true })
40
+ const tmp = `${file}.retake-${process.pid}-${Date.now()}.tmp`
41
+ fs.writeFileSync(tmp, data)
42
+ fs.renameSync(tmp, file)
43
+ }
44
+
45
+ const readJson = (file, /** @type {any} */ fallback = null) => {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(file, "utf8"))
48
+ } catch {
49
+ return fallback
50
+ }
51
+ }
52
+
53
+ function isGit() {
54
+ try {
55
+ return execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim().length > 0
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+ const git = isGit()
61
+
62
+ // The files a snapshot owns: in a git repo, tracked + untracked-not-ignored;
63
+ // otherwise a walk that skips dot entries and build/dependency folders.
64
+ function candidates() {
65
+ if (git) {
66
+ const out = execFileSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--full-name", "."], { maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] })
67
+ const top = execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim()
68
+ const rels = new Set()
69
+ for (const name of out.toString().split("\0")) {
70
+ if (!name) continue
71
+ const rel = path.relative(root, path.join(top, name))
72
+ if (rel.startsWith("..") || rel.split(path.sep).some((p) => SKIP.has(p))) continue
73
+ rels.add(rel)
74
+ }
75
+ return [...rels].sort()
76
+ }
77
+ const rels = []
78
+ const walk = (d) => {
79
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
80
+ if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue
81
+ const full = path.join(d, entry.name)
82
+ if (entry.isDirectory()) walk(full)
83
+ else if (entry.isFile()) rels.push(path.relative(root, full))
84
+ if (rels.length > MAX_FILES) return
85
+ }
86
+ }
87
+ walk(root)
88
+ return rels.sort()
89
+ }
90
+
91
+ // Snapshot what's on disk now. Returns the version id. Throws if it can't
92
+ // capture the tree completely (too many files, unreadable file): callers
93
+ // must not change anything then.
94
+ function snapshot() {
95
+ const rels = candidates()
96
+ if (rels.length > MAX_FILES) throw new Error(`more than ${MAX_FILES} files; code branches are for small prototypes`)
97
+ const files = {}
98
+ const skipped = []
99
+ for (const rel of rels) {
100
+ const full = path.join(root, rel)
101
+ let st
102
+ try {
103
+ st = fs.lstatSync(full)
104
+ } catch {
105
+ continue // listed but gone (e.g. a tracked file deleted on disk): not part of this version
106
+ }
107
+ if (!st.isFile()) continue
108
+ if (st.size > MAX_BYTES) {
109
+ skipped.push(rel)
110
+ continue
111
+ }
112
+ const buf = fs.readFileSync(full)
113
+ const h = sha1(buf)
114
+ const blob = path.join(blobs, h)
115
+ if (!fs.existsSync(blob)) atomicWrite(blob, buf)
116
+ files[rel] = h
117
+ }
118
+ const manifest = { files, skipped: skipped.sort() }
119
+ const id = sha1(JSON.stringify(manifest)).slice(0, 10)
120
+ const file = path.join(versionsDir, `${id}.json`)
121
+ if (!fs.existsSync(file)) atomicWrite(file, JSON.stringify({ id, at: Date.now(), ...manifest }))
122
+ return id
123
+ }
124
+
125
+ const load = (id) => (id && /^[0-9a-f]{10}$/.test(id) ? readJson(path.join(versionsDir, `${id}.json`)) : null)
126
+
127
+ // What turning version `from` (what's on disk, just snapshotted) into `to`
128
+ // means: writes for paths `to` captured that differ, deletes for paths `from`
129
+ // captured that `to` positively didn't have. Nothing else is touched.
130
+ function plan(from, to) {
131
+ const writes = []
132
+ const deletes = []
133
+ for (const [rel, h] of Object.entries(to.files)) if (from.files[rel] !== h) writes.push([rel, h])
134
+ const toSkipped = new Set(to.skipped)
135
+ for (const rel of Object.keys(from.files)) {
136
+ if (rel in to.files || toSkipped.has(rel)) continue
137
+ deletes.push(rel)
138
+ }
139
+ return { writes, deletes }
140
+ }
141
+
142
+ function apply(p, onFile) {
143
+ for (const [rel, h] of p.writes) {
144
+ const full = path.join(root, rel)
145
+ atomicWrite(full, fs.readFileSync(path.join(blobs, h)))
146
+ onFile && onFile(full, h)
147
+ }
148
+ for (const rel of p.deletes) {
149
+ const full = path.join(root, rel)
150
+ try {
151
+ fs.unlinkSync(full)
152
+ } catch {}
153
+ onFile && onFile(full, null)
154
+ }
155
+ }
156
+
157
+ // Put version `id` on disk. Snapshots first; returns { ok, from, error }.
158
+ function checkout(id, onFile) {
159
+ const to = load(id)
160
+ if (!to) return { ok: false, error: `unknown version ${id}` }
161
+ let fromId
162
+ try {
163
+ fromId = snapshot()
164
+ } catch (err) {
165
+ return { ok: false, error: `refusing to switch code: couldn't snapshot the current files first (${err.message})` }
166
+ }
167
+ if (fromId === id) return { ok: true, from: fromId }
168
+ const from = load(fromId)
169
+ atomicWrite(journalFile, JSON.stringify({ from: fromId, to: id, at: Date.now() }))
170
+ apply(plan(from, to), onFile)
171
+ fs.rmSync(journalFile, { force: true })
172
+ return { ok: true, from: fromId }
173
+ }
174
+
175
+ // An interrupted checkout leaves a journal: put the `from` version back.
176
+ function recover(onFile) {
177
+ const j = readJson(journalFile)
178
+ if (!j) return null
179
+ const from = load(j.from)
180
+ if (!from) {
181
+ fs.rmSync(journalFile, { force: true })
182
+ return null
183
+ }
184
+ let nowId
185
+ try {
186
+ nowId = snapshot() // the half-switched state is kept as a version too
187
+ } catch {
188
+ return { error: "journal found but the tree can't be snapshotted; leaving files as they are" }
189
+ }
190
+ apply(plan(load(nowId), from), onFile)
191
+ fs.rmSync(journalFile, { force: true })
192
+ return { restored: j.from }
193
+ }
194
+
195
+ const state = () => readJson(stateFile, {})
196
+ const saveState = (s) => atomicWrite(stateFile, JSON.stringify(s))
197
+
198
+ return { dir, snapshot, load, checkout, recover, state, saveState, plan }
199
+ }
200
+
201
+ /**
202
+ * @param {import("vite").ViteDevServer} server
203
+ * @param {{ token?: string, bus?: ReturnType<typeof import("./server/api.js").createBus> }} [options]
204
+ */
205
+ export function codeVersions(server, { token, bus } = {}) {
206
+ const announce = () => bus && bus.emit("code-version", { version: current, newest })
207
+ const root = server.config.root
208
+ const store = createStore(root)
209
+ const log = (msg) => server.config.logger.info(` retake: ${msg}`, { timestamp: true })
210
+ const warn = (msg) => server.config.logger.warn(` retake: ${msg}`, { timestamp: true })
211
+
212
+ // Files this process wrote or deleted during a checkout, so the watcher can
213
+ // tell our own writes from the user's edits.
214
+ const selfWrites = new Map() // full path -> hash (null for a delete)
215
+ function invalidate(full) {
216
+ /** @type {any[]} Vite's module graph and each environment's (Vite 6+) */
217
+ const graphs = [server.moduleGraph, ...Object.values(server.environments || {}).map((e) => e.moduleGraph)]
218
+ for (const graph of graphs) {
219
+ for (const mod of (graph && graph.getModulesByFile && graph.getModulesByFile(full)) || []) graph.invalidateModule(mod)
220
+ }
221
+ }
222
+ const onFile = (full, h) => {
223
+ selfWrites.set(full, h)
224
+ invalidate(full)
225
+ }
226
+
227
+ const rec = store.recover(onFile)
228
+ // True while the files on disk are what this server put back at startup
229
+ // (a rolled-back checkout, or the newest code after a crash), until the next
230
+ // edit or checkout. The dock then checks out its active timeline's own code
231
+ // instead of adopting what it finds on disk.
232
+ let restoredAtStart = false
233
+ if (rec && rec.restored) {
234
+ warn(`an interrupted code checkout was rolled back to version ${rec.restored}`)
235
+ restoredAtStart = true
236
+ }
237
+ if (rec && rec.error) warn(rec.error)
238
+
239
+ let current
240
+ let newest
241
+ try {
242
+ current = store.snapshot()
243
+ } catch (err) {
244
+ warn(`code branches are off: ${err.message}`)
245
+ return
246
+ }
247
+ // Last run ended on an older timeline's code without getting to restore
248
+ // (a crash)? If nothing changed since, put the newest version back.
249
+ const prev = store.state()
250
+ if (prev.newest && prev.current && prev.current !== prev.newest && prev.current === current && store.load(prev.newest)) {
251
+ const r = store.checkout(prev.newest, onFile)
252
+ if (r.ok) {
253
+ log(`restored the newest code (version ${prev.newest}) left behind by the last run`)
254
+ restoredAtStart = true
255
+ current = prev.newest
256
+ }
257
+ }
258
+ newest = current
259
+ const persist = () => store.saveState({ current, newest })
260
+ persist()
261
+
262
+ /** @type {NodeJS.Timeout | undefined} */
263
+ let pending
264
+ server.watcher.on("all", (event, file) => {
265
+ const rel = path.relative(root, file)
266
+ if (rel.startsWith("..") || rel.split(path.sep).some((p) => SKIP.has(p))) return
267
+ if (selfWrites.has(file)) {
268
+ const expected = selfWrites.get(file)
269
+ /** @type {string | null} */
270
+ let actual = null
271
+ try {
272
+ actual = sha1(fs.readFileSync(file))
273
+ } catch {}
274
+ if (actual === expected) return // our own checkout write
275
+ selfWrites.delete(file)
276
+ }
277
+ clearTimeout(pending)
278
+ pending = setTimeout(() => {
279
+ try {
280
+ const id = store.snapshot()
281
+ const changed = id !== current
282
+ if (changed) restoredAtStart = false
283
+ current = newest = id
284
+ persist()
285
+ if (changed) announce()
286
+ } catch (err) {
287
+ warn(`couldn't snapshot: ${err.message}`)
288
+ }
289
+ }, 120)
290
+ })
291
+
292
+ function checkout(id) {
293
+ clearTimeout(pending)
294
+ const r = store.checkout(id, onFile)
295
+ if (r.ok) {
296
+ restoredAtStart = false
297
+ // Anything the snapshot-before-checkout captured that we hadn't seen yet
298
+ // is the user's latest work.
299
+ if (r.from !== current && r.from !== id) newest = r.from
300
+ current = id
301
+ persist()
302
+ announce()
303
+ } else warn(r.error)
304
+ return r
305
+ }
306
+
307
+ // Leaving on an older timeline's code? Put the newest back first.
308
+ let restored = false
309
+ function restoreNewest() {
310
+ if (restored) return
311
+ restored = true
312
+ if (current === newest) return
313
+ const r = store.checkout(newest, onFile)
314
+ if (r.ok) {
315
+ current = newest
316
+ persist()
317
+ console.log(`\n retake: restored the newest code (version ${newest}) on exit`)
318
+ }
319
+ }
320
+ process.once("exit", restoreNewest)
321
+ // A plain SIGINT/SIGHUP kills the process without an "exit" event, so catch
322
+ // them, restore, then leave the way the signal would have.
323
+ for (const [sig, code] of /** @type {[NodeJS.Signals, number][]} */ ([["SIGINT", 130], ["SIGHUP", 129], ["SIGTERM", 143]])) {
324
+ process.once(sig, () => {
325
+ restoreNewest()
326
+ process.exit(code)
327
+ })
328
+ }
329
+ server.httpServer && server.httpServer.once("close", restoreNewest)
330
+
331
+ const json = (res, code, body) => {
332
+ res.statusCode = code
333
+ res.setHeader("Content-Type", "application/json")
334
+ res.end(JSON.stringify(body))
335
+ }
336
+
337
+ server.middlewares.use((req, res, next) => {
338
+ const url = new URL(req.url || "/", "http://x")
339
+ if (url.pathname === "/__retake/version") return json(res, 200, { version: current, newest, restored: restoredAtStart })
340
+ if (url.pathname === "/__retake/checkout") {
341
+ // POST + token. A GET is accepted only from the dock's own origin
342
+ // (Sec-Fetch-Site can't be forged by other sites) while the dock moves over.
343
+ const tokenOk = token && req.headers["x-retake-token"] === token
344
+ const sameOrigin = req.headers["sec-fetch-site"] === "same-origin"
345
+ if (!(req.method === "POST" && tokenOk) && !(req.method === "GET" && sameOrigin)) {
346
+ return json(res, 403, { ok: false, error: "checkout needs POST with x-retake-token" })
347
+ }
348
+ const r = checkout(url.searchParams.get("v"))
349
+ // left: the snapshot taken just before switching, i.e. the real code of
350
+ // the timeline being left (edits the version poll hadn't seen yet included).
351
+ return json(res, r.ok ? 200 : 409, { ok: r.ok, version: current, left: r.from || null, error: r.error })
352
+ }
353
+ next()
354
+ })
355
+
356
+ return { store, checkout, get current() { return current }, get newest() { return newest } }
357
+ }