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,121 @@
1
+ // Checkpoints. Going back in time rebuilds the moment from the start of its
2
+ // page, which on a heavy app takes a while (the app renders every recorded
3
+ // frame again). So after a slow rewind to t, while you look around in the
4
+ // past, a hidden paused frame is built (in idle time) at a moment a little
5
+ // before t. The next rewind to anywhere at or after it (same timeline, same
6
+ // code) hands that frame the recording (PT.adopt) and it only seeks forward
7
+ // from there. Using it spends it; another is built once things are quiet again.
8
+ // Only one frame runs the app at a time: a checkpoint still being built stops
9
+ // when anything else needs to run (quietCheckpoint), and none is made for an
10
+ // app that keeps state in IndexedDB (another frame's replay would change it
11
+ // under the visible one, see storageOk in the runtime).
12
+
13
+ const CP_BACK = 1500 // how far behind the last rewind the checkpoint sits
14
+ const CP_QUIET = 300 // ms of no rebuilding before one is started
15
+ D.cpMinMs = 150 // only for apps whose replays (of the last rebuild) take at least this long
16
+
17
+ let lastBuild = null // { target, branchId, at }
18
+
19
+ // A rebuild just finished: remember how long its replay took and where it went.
20
+ // (An app whose replays are slow stays "heavy": a rewind a checkpoint made
21
+ // quick still wants the next checkpoint.)
22
+ function noteBuilt(b) {
23
+ if (!b || b.target == null || b.startedAt == null) return
24
+ const ms = performance.now() - b.startedAt
25
+ let seekMs = null
26
+ try {
27
+ seekMs = b.pt.debug().seekMs
28
+ } catch {}
29
+ if (b.via !== "checkpoint" && (seekMs != null ? seekMs : ms) >= D.cpMinMs) D.heavy = true
30
+ lastBuild = { target: b.target, branchId: b.branchId, at: performance.now() }
31
+ D.lastRebuild = { ms: Math.round(ms), seekMs, via: b.via || "replay" }
32
+ }
33
+
34
+ function dropCheckpoint() {
35
+ if (!D.cp) return
36
+ D.cp.frame.remove()
37
+ D.cp = null
38
+ }
39
+
40
+ // The frame on show is about to run its app (play, an in-place seek): a
41
+ // checkpoint still being built stops. A ready one is paused and stays.
42
+ function quietCheckpoint() {
43
+ if (D.cp && !checkpointReady(D.cp)) dropCheckpoint()
44
+ }
45
+
46
+ // Each frame: drop a checkpoint that no longer fits, start one when it's
47
+ // worth it and quiet.
48
+ function tendCheckpoint() {
49
+ const cp = D.cp
50
+ const active = activeBranch()
51
+ if (cp && (cp.branchId !== D.activeId || cp.version !== (active && active.version))) dropCheckpoint()
52
+ // The frame on show runs its app again some other way (⌥P in the app, an
53
+ // API seek): a checkpoint still being built stops.
54
+ if (D.cp && D.last && (D.last.playing || D.last.seeking) && !checkpointReady(D.cp)) dropCheckpoint()
55
+ if (D.cp || D.building || !lastBuild || !D.PT || D.switching || D.dragT != null || D.keyT != null) return
56
+ if (!D.heavy || lastBuild.branchId !== D.activeId) return
57
+ if (performance.now() - lastBuild.at < CP_QUIET) return
58
+ const s = D.last
59
+ if (!s || !s.started || s.seeking || s.previewing || s.playing || s.idb || isInteractive(s)) return
60
+ let rec
61
+ let json
62
+ try {
63
+ rec = D.PT.history()
64
+ json = JSON.stringify(rec)
65
+ } catch {
66
+ return
67
+ }
68
+ // A little before the last rewind, on the page it was on.
69
+ let page = 0
70
+ for (const sg of rec.segments || []) if (sg.t <= lastBuild.target) page = sg.t
71
+ const at = Math.max(s.start, page, lastBuild.target - CP_BACK)
72
+ const url = new URL(urlAt(rec, at), location.href)
73
+ let doc = rec.doc
74
+ for (const sg of rec.segments || []) if (sg.t <= at) doc = sg.doc
75
+ askStoredDoc(doc, url.href)
76
+ const id = ++D.buildSeq
77
+ const frame = makeFrame(url.pathname + url.search + url.hash, { rec: json, target: at, play: false, rate: 1, buildId: id })
78
+ frame.className = "checkpoint"
79
+ frame.setAttribute("aria-hidden", "true")
80
+ frame.tabIndex = -1
81
+ const vp = okViewport(rec.viewport) || recordedViewport(rec)
82
+ if (vp) {
83
+ frame.style.width = vp.w + "px"
84
+ frame.style.height = vp.h + "px"
85
+ }
86
+ D.cp = { id, frame, pt: null, at, branchId: D.activeId, version: active && active.version }
87
+ lastBuild = null
88
+ }
89
+
90
+ const checkpointReady = (cp) => {
91
+ try {
92
+ const s = cp.pt && cp.pt.state()
93
+ return !!s && s.booted && s.target == null && !s.seeking
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ // Called for every rewind: true if the checkpoint took it (it's the build in
100
+ // flight now, seeking forward from where it was parked).
101
+ function useCheckpoint(payload) {
102
+ const cp = D.cp
103
+ if (!cp || cp.branchId !== D.activeId || payload.target < cp.at || !checkpointReady(cp) || typeof cp.pt.adopt !== "function") return false
104
+ let ok = false
105
+ try {
106
+ ok = cp.pt.adopt(String(payload.rec), Number(payload.target), false)
107
+ } catch {}
108
+ D.cp = null
109
+ if (!ok) {
110
+ cp.frame.remove()
111
+ return false
112
+ }
113
+ cp.frame.className = "building"
114
+ cp.frame.removeAttribute("tabindex")
115
+ try {
116
+ if (typeof cp.pt.setBackground === "function") cp.pt.setBackground(D.dragT != null || D.keyT != null)
117
+ } catch {}
118
+ window.__retakeShell.rebuilding = true
119
+ D.building = { id: cp.id, frame: cp.frame, pt: cp.pt, stash: null, branchId: D.activeId, target: payload.target, play: false, fork: false, visible: true, startedAt: performance.now(), via: "checkpoint", restarts: 0 }
120
+ return true
121
+ }
@@ -0,0 +1,291 @@
1
+ // Keeping the session. Timelines, notes and bookmarks go to the dev server
2
+ // (/__retake/session, kept on disk under <project>/.retake/) so a reload or a
3
+ // restart doesn't lose them; each timeline's recording goes to
4
+ // /__retake/recording/:id. Agents reply to notes over /__retake/events.
5
+ // When the server has no session API (it answers 404), everything stays in
6
+ // memory for this page.
7
+ const TOKEN = window.__RETAKE_TOKEN || ""
8
+ const net = {
9
+ on: true, // false: in-memory only
10
+ restoring: true, // nothing is saved until what's on disk has been read
11
+ savedSession: null, // the last session body the server has
12
+ savedRec: new Map(), // branch id → the recording string the server has
13
+ activeSig: "", // what the active recording looked like when last saved
14
+ activeSavedAt: 0,
15
+ }
16
+
17
+ async function api(method, path, body) {
18
+ const headers = {}
19
+ if (method !== "GET") headers["x-retake-token"] = TOKEN
20
+ if (body !== undefined) headers["content-type"] = "application/json"
21
+ const res = await fetch("/__retake/" + path, {
22
+ method,
23
+ headers,
24
+ body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
25
+ })
26
+ if (res.status === 404) return { missing: true }
27
+ if (!res.ok) throw new Error(`${method} /__retake/${path}: ${res.status}`)
28
+ const text = await res.text()
29
+ return { value: text && (res.headers.get("content-type") || "").includes("json") ? JSON.parse(text) : text }
30
+ }
31
+
32
+ // The session as the server stores it (see CONTRACT.md).
33
+ function sessionBody() {
34
+ return {
35
+ branches: D.branches.map((b) => ({ id: b.id, parentId: b.parentId, forkAt: b.forkAt, name: b.name, codeVersion: b.version || null, end: b.end })),
36
+ activeId: D.activeId,
37
+ markers: D.markers,
38
+ notes: D.notes.map(noteForServer),
39
+ }
40
+ }
41
+
42
+ function applySession(s) {
43
+ D.branches = s.branches.map((b) => ({ ...b, version: b.codeVersion || undefined, json: null, end: b.end ?? b.forkAt, born: -1e9 }))
44
+ D.branchSeq = Math.max(0, ...D.branches.map((b) => b.id))
45
+ D.activeId = D.branches.some((b) => b.id === s.activeId) ? s.activeId : D.branches[0].id
46
+ D.markers = Array.isArray(s.markers) ? s.markers : []
47
+ D.markerSeq = Math.max(0, ...D.markers.map((m) => Number(m.id) || 0))
48
+ D.notes = Array.isArray(s.notes) ? s.notes.map(noteFromServer) : []
49
+ }
50
+
51
+ // A timeline's recording: in memory if we have it, else from the server.
52
+ async function recordingOf(b) {
53
+ if (b.json) return b.json
54
+ if (!net.on) return null
55
+ try {
56
+ const r = await api("GET", `recording/${b.id}`)
57
+ if (r.missing || !r.value) return null
58
+ b.json = typeof r.value === "string" ? r.value : JSON.stringify(r.value)
59
+ net.savedRec.set(b.id, b.json)
60
+ return b.json
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ // The page the user asked for, without the frame's own parameter.
67
+ const askedFor = location.pathname + location.search + location.hash
68
+ const FEATURES = (window.__retakeConfig && window.__retakeConfig.features) || []
69
+
70
+ // On load: read what was saved, then open the app at the page the user asked
71
+ // for, live. The saved timelines stay; the active one carries on from its end
72
+ // at this page (a runtime with the "continue" feature marks it there, and a
73
+ // rewind before that point brings the old page back). A runtime without it
74
+ // gets this visit as a timeline of its own, so nothing saved is overwritten.
75
+ // It's never a rebuild, and never waits long: after 1.5s the app opens fresh.
76
+ async function restore() {
77
+ let known = false // the saved session has been read (or there's none)
78
+ const fallback = setTimeout(() => !D.frame && openFirstFrame(null, null), 1500)
79
+ const ownTimeline = () => {
80
+ const b = newBranch(0, null)
81
+ b.name = `→ ${askedFor.split("?")[0] || "/"}`
82
+ D.activeId = b.id
83
+ return b.id
84
+ }
85
+ try {
86
+ const r = await api("GET", "session")
87
+ if (r.missing) {
88
+ net.on = false
89
+ return
90
+ }
91
+ const s = r.value
92
+ if (!s || !Array.isArray(s.branches) || !s.branches.length) return
93
+ applySession(s)
94
+ net.savedSession = JSON.stringify(sessionBody())
95
+ known = true
96
+ const json = await recordingOf(activeBranch())
97
+ if (D.frame) {
98
+ // Opened fresh already (the session was slow): that visit is its own.
99
+ D.frameBranch = ownTimeline()
100
+ return
101
+ }
102
+ if (json && FEATURES.includes("continue")) {
103
+ let end = activeBranch().end
104
+ try {
105
+ end = JSON.parse(json).end
106
+ } catch {}
107
+ openFirstFrame({ rec: json, target: end, play: true, continue: true, url: askedFor }, D.activeId)
108
+ } else if (json) openFirstFrame(null, ownTimeline())
109
+ else openFirstFrame(null, D.activeId)
110
+ } catch (err) {
111
+ // Don't overwrite a session we couldn't read.
112
+ console.warn("[retake] couldn't restore the session; keeping this one in memory", err)
113
+ net.on = false
114
+ } finally {
115
+ clearTimeout(fallback)
116
+ if (!known && !D.frame) openFirstFrame(null, D.activeId)
117
+ else if (!known && D.frameBranch == null) D.frameBranch = D.activeId
118
+ net.restoring = false
119
+ }
120
+ }
121
+
122
+ // The active timeline's recording is saved when it's still (paused), and every
123
+ // few seconds while it's live.
124
+ function activeSig(s) {
125
+ const h = D.PT.history()
126
+ return `${D.activeId}:${h.end}:${h.events.length}:${h.frames.length}`
127
+ }
128
+
129
+ let saving = false
130
+ async function persist() {
131
+ if (!net.on || net.restoring || net.hold || saving || D.switching) return
132
+ saving = true
133
+ try {
134
+ const body = JSON.stringify(sessionBody())
135
+ if (body !== net.savedSession) {
136
+ await api("PUT", "session", body)
137
+ net.savedSession = body
138
+ }
139
+ for (const b of D.branches) {
140
+ if (b.id === D.activeId || !b.json || net.savedRec.get(b.id) === b.json) continue
141
+ await api("PUT", `recording/${b.id}`, b.json)
142
+ net.savedRec.set(b.id, b.json)
143
+ }
144
+ const s = state()
145
+ if (D.PT && s && s.started && !D.building && D.frameBranch === D.activeId) {
146
+ const sig = activeSig(s)
147
+ const due = !s.recording || performance.now() - net.activeSavedAt > 5000
148
+ if (sig !== net.activeSig && due) {
149
+ const json = JSON.stringify(D.PT.history())
150
+ await api("PUT", `recording/${D.activeId}`, json)
151
+ net.activeSig = sig
152
+ net.activeSavedAt = performance.now()
153
+ net.savedRec.set(D.activeId, json)
154
+ }
155
+ }
156
+ } catch (err) {
157
+ console.warn("[retake] saving the session failed; will retry", err)
158
+ } finally {
159
+ saving = false
160
+ }
161
+ }
162
+
163
+ // Debounced: a change is saved once things have been still for 300ms.
164
+ let lastSeen = ""
165
+ let stillSince = 0
166
+ setInterval(() => {
167
+ if (!net.on || net.restoring || net.hold) return
168
+ let sig = ""
169
+ try {
170
+ sig = JSON.stringify(sessionBody()) + D.branches.map((b) => (b.json ? b.json.length : 0)).join(",")
171
+ if (D.PT && D.last && D.last.started) sig += activeSig(D.last)
172
+ } catch {
173
+ return
174
+ }
175
+ const now = performance.now()
176
+ if (sig !== lastSeen) {
177
+ lastSeen = sig
178
+ stillSince = now
179
+ // A live recording never stands still; save it on its own clock.
180
+ if (!(D.last && D.last.recording)) return
181
+ }
182
+ if (now - stillSince >= 300 || (D.last && D.last.recording)) persist()
183
+ }, 100)
184
+
185
+ // Agent replies and other outside changes arrive as server events.
186
+ function listen() {
187
+ if (!window.EventSource || !net.on) return
188
+ const es = new EventSource("/__retake/events")
189
+ const read = (e) => {
190
+ try {
191
+ return JSON.parse(e.data)
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+ es.addEventListener("note-updated", (e) => {
197
+ const d = read(e)
198
+ const n = d && (d.note || d)
199
+ if (n && n.id != null) mergeNote(n)
200
+ })
201
+ es.addEventListener("active-changed", (e) => {
202
+ const d = read(e)
203
+ const id = d && Number(d.activeId)
204
+ if (id && id !== D.activeId && branchById(id)) switchTo(id, D.last ? D.last.now : 0)
205
+ })
206
+ es.addEventListener("code-version", (e) => {
207
+ const d = read(e)
208
+ if (d && d.version) D.codeVersionSeen = d.version
209
+ })
210
+ // A server without events answers 404 and the stream closes for good.
211
+ es.onerror = () => es.readyState === EventSource.CLOSED && es.close()
212
+ }
213
+
214
+ // Start fresh: one empty timeline, no notes, a new recording, straight away.
215
+ // For five seconds it can be undone; the server isn't told until then.
216
+ const UNDO_MS = 5000
217
+ let undo = null
218
+ const toast = $("#wb-toast")
219
+
220
+ function startFresh() {
221
+ if (undo) commitFresh()
222
+ const snap = {
223
+ branches: D.branches.map((b) => ({ ...b })),
224
+ activeId: D.activeId,
225
+ branchSeq: D.branchSeq,
226
+ notes: D.notes,
227
+ markers: D.markers,
228
+ markerSeq: D.markerSeq,
229
+ frame: D.frame, // the frame showing when it was cleared
230
+ }
231
+ const cur = snap.branches.find((b) => b.id === snap.activeId)
232
+ try {
233
+ if (cur && D.PT) {
234
+ cur.json = JSON.stringify(D.PT.history())
235
+ cur.end = D.PT.state().end
236
+ }
237
+ } catch {}
238
+ net.hold = true
239
+ D.notes = []
240
+ D.markers = []
241
+ D.markerSeq = 0
242
+ closeCard()
243
+ resetBranches()
244
+ invalidateGutter()
245
+ freshFrame()
246
+ undo = { snap, timer: setTimeout(commitFresh, UNDO_MS) }
247
+ toast.hidden = false
248
+ }
249
+
250
+ // The five seconds are up: clear the session on the server too.
251
+ async function commitFresh() {
252
+ if (!undo) return
253
+ clearTimeout(undo.timer)
254
+ undo = null
255
+ toast.hidden = true
256
+ net.savedRec.clear()
257
+ net.activeSig = ""
258
+ net.hold = false
259
+ if (net.on) {
260
+ try {
261
+ const body = JSON.stringify(sessionBody())
262
+ await api("PUT", "session", body)
263
+ net.savedSession = body
264
+ } catch {}
265
+ }
266
+ }
267
+
268
+ // Undo: everything back as it was, the moment rebuilt where it was.
269
+ function undoFresh() {
270
+ if (!undo) return
271
+ clearTimeout(undo.timer)
272
+ const { snap } = undo
273
+ undo = null
274
+ toast.hidden = true
275
+ D.branches = snap.branches
276
+ D.activeId = snap.activeId
277
+ D.branchSeq = snap.branchSeq
278
+ D.notes = snap.notes
279
+ D.markers = snap.markers
280
+ D.markerSeq = snap.markerSeq
281
+ invalidateGutter()
282
+ const cur = activeBranch()
283
+ // Still showing the old frame (the fresh one hadn't swapped in yet)? Then
284
+ // there's nothing to rebuild: just drop the fresh one.
285
+ if (D.building) cancelBuild()
286
+ if (D.frame === snap.frame) D.frameBranch = cur.id
287
+ else if (cur && cur.json && D.PT) D.PT.load(cur.json, cur.end)
288
+ net.hold = false
289
+ }
290
+
291
+ restore().then(listen)