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,460 @@
1
+ // Boot. The prototype runs inside the dock's frame; `shell` is the dock in the
2
+ // parent window. A rewind leaves the history with the shell and reloads this
3
+ // frame, so on boot we either pick that up or begin a fresh history.
4
+
5
+ const shell = (() => {
6
+ try {
7
+ return W.parent !== W && W.parent.__retakeShell ? W.parent.__retakeShell : null
8
+ } catch {
9
+ return null
10
+ }
11
+ })()
12
+
13
+ // A full reload of this frame (Vite's full-reload after an edit HMR can't
14
+ // apply, or the app calling location.reload()) keeps the recording: on the way
15
+ // out it's left on the shell object, keyed by our iframe element, and picked
16
+ // up here. A frame the dock is replacing (removed from the page) leaves nothing.
17
+ function takeResume() {
18
+ try {
19
+ const r = shell && shell.__resume
20
+ if (r && r.frame === W.frameElement) {
21
+ shell.__resume = null
22
+ return r.payload
23
+ }
24
+ } catch {}
25
+ return null
26
+ }
27
+ const pending = (shell && shell.take()) || takeResume()
28
+ // Time doesn't start until state is in place (IndexedDB is async).
29
+ let stateReady
30
+ // Set when this page is about to reload itself into another segment's URL.
31
+ let handingOff = false
32
+ if (pending && (pending.reloaded || pending.continue)) {
33
+ // The app reloaded (or navigated) itself mid-recording: a fresh page, so a
34
+ // new segment of the recording starts here, at this URL, with the storage
35
+ // as it is now. Nothing is replayed; later rebuilds to a moment in this
36
+ // segment start from here too.
37
+ rec = readRec(pending.rec)
38
+ /** @type {Record<string, any>} */
39
+ const seg = {
40
+ t: pending.target,
41
+ ev: rec.events.length,
42
+ fr: rec.frames.length,
43
+ url: location.href,
44
+ storage: { local: snapshotStorage(real.local), session: snapshotStorage(real.session) },
45
+ cookies: snapshotCookies(),
46
+ seed: (rec.seed ^ Math.imul((rec.segments || []).length + 1, 0x85ebca6b)) >>> 0,
47
+ ...(RT.docId ? { doc: RT.docId } : {}), // the front server's kept copy of this page (F56)
48
+ }
49
+ ;(rec.segments || (rec.segments = [])).push(seg)
50
+ // A route marker for where the timeline carried on (a reload, or the URL
51
+ // the user opened the dock at).
52
+ const shown = location.pathname + location.search.replace(/([?&])__wb=app&?/, "$1").replace(/[?&]$/, "") + location.hash
53
+ ;(rec.routes || (rec.routes = [])).push({ t: seg.t, path: shown })
54
+ if (pending.continue) rec.reloads = [...(rec.reloads || []), seg.t]
55
+ clock.now = seg.t
56
+ cursor.event = seg.ev
57
+ cursor.frame = seg.fr
58
+ clock.rate = pending.rate || 1
59
+ stateReady = withIDBGate(async () => {
60
+ const snap = await snapshotIDB()
61
+ if (snap) seg.idb = snap
62
+ })
63
+ } else if (pending) {
64
+ rec = readRec(pending.rec)
65
+ const seg = segmentAt(pending.target)
66
+ if (seg.url && sameDocUrl(seg.url) === false) {
67
+ // This moment lives in a segment that started on another URL: go there
68
+ // (the payload rides along on the shell), and rebuild from that page.
69
+ handingOff = true
70
+ try {
71
+ shell.__resume = { frame: W.frameElement, payload: pending, handoff: true }
72
+ } catch {}
73
+ askStoredDoc(seg.doc, seg.url)
74
+ location.replace(seg.url)
75
+ }
76
+ // (If handing off, this page is going away: its clock never starts.)
77
+ // Web storage, cookies and IndexedDB go back to how they were when this
78
+ // segment began (the recording's start, or the reload that began it).
79
+ restoreStorage(real.local, seg.storage.local)
80
+ restoreStorage(real.session, seg.storage.session)
81
+ restoreCookies(seg.cookies)
82
+ stateReady = handingOff ? new Promise(() => {}) : seg.idb != null ? withIDBGate(() => restoreIDB(seg.idb)) : Promise.resolve()
83
+ if (seg.t > 0) {
84
+ clock.now = seg.t
85
+ cursor.event = seg.ev
86
+ cursor.frame = seg.fr
87
+ }
88
+ clock.rate = pending.rate || 1
89
+ } else {
90
+ rec = newRecording()
91
+ // Recording is always on from page load (CONTRACT.md).
92
+ rec.start = 0
93
+ rec.cookies = snapshotCookies()
94
+ stateReady = withIDBGate(async () => {
95
+ const snap = await snapshotIDB()
96
+ if (snap) rec.idb = snap // empty too: a replay then clears databases made later
97
+ })
98
+ }
99
+ epoch = rec.epoch
100
+ seedRandom(pending ? segmentAt(clock.now).seed : rec.seed)
101
+ // This frame's app is the one about to run: the origin's storage is its own
102
+ // now (a rebuild has just put it back to its segment's start).
103
+ if (shell && !handingOff) shell.storageOwner = storageId
104
+
105
+ // Record starts the timeline, or resumes it from where it last got to.
106
+ function record() {
107
+ if (previewing) endPreview()
108
+ // Back in time? Record carries on from the end of this timeline, not here.
109
+ if (hasFuture()) return seek(rec.end, play)
110
+ if (rec.start == null) rec.start = clock.now
111
+ play()
112
+ }
113
+
114
+ Object.assign(PT, {
115
+ version: "0.4.0",
116
+ now: () => clock.now,
117
+ record,
118
+ // Play from here: replays the recorded future, then carries on live.
119
+ play: () => {
120
+ if (previewing) endPreview()
121
+ play()
122
+ },
123
+ pause,
124
+ preview,
125
+ endPreview,
126
+ // Going to a moment ends a live preview first (the dock does this itself,
127
+ // but API and MCP callers shouldn't have to).
128
+ seek: (t, andPlay) => {
129
+ if (rec.start == null) return false
130
+ if (previewing) endPreview()
131
+ return seek(Math.max(t, rec.start), andPlay ? play : undefined)
132
+ },
133
+ // Build the moment for real in a new frame (the dock's rebuild), leaving
134
+ // whatever this frame shows (a preview of that moment) as it is meanwhile.
135
+ buildAt(t) {
136
+ if (rec.start == null) return false
137
+ rewind(Math.max(t, rec.start), false)
138
+ return true
139
+ },
140
+ // A frame built (or being built) to one moment goes on to a later one on
141
+ // the same page instead of the dock starting another: before it boots, while
142
+ // it replays, or once it's there. False if it can't (that moment is behind
143
+ // where it has got to, or after a reload).
144
+ retarget(t) {
145
+ if (!pending || pending.reloaded || pending.continue || previewing || clock.playing) return false
146
+ t = Math.max(t, rec.start ?? 0)
147
+ const at = !clock.booted ? pending.target : (seekTarget ?? clock.now)
148
+ if (segmentIndex(t) !== segmentIndex(at)) return false
149
+ if (!clock.booted) {
150
+ pending.target = t // boot() reads it
151
+ return true
152
+ }
153
+ if (t < clock.now) return false
154
+ if (seekTarget != null) {
155
+ seekTarget = t // runSeek reads it at every frame
156
+ return true
157
+ }
158
+ seek(t)
159
+ return true
160
+ },
161
+ // Do a and b show exactly the same thing? Same page, under 50ms apart, and
162
+ // nothing recorded in between: no frame boundary in (lo, hi], no input in [lo, hi).
163
+ sameMoment(a, b) {
164
+ if (a == null || b == null) return false
165
+ const lo = Math.min(a, b)
166
+ const hi = Math.max(a, b)
167
+ if (hi - lo >= 50 || segmentIndex(lo) !== segmentIndex(hi)) return false
168
+ const fr = rec.frames
169
+ let i = 0
170
+ let j = fr.length
171
+ while (i < j) {
172
+ const m = (i + j) >> 1
173
+ if (fr[m] <= lo) i = m + 1
174
+ else j = m
175
+ }
176
+ if (i < fr.length && fr[i] <= hi) return false
177
+ const ev = rec.events
178
+ i = 0
179
+ j = ev.length
180
+ while (i < j) {
181
+ const m = (i + j) >> 1
182
+ if (ev[m].t < lo) i = m + 1
183
+ else j = m
184
+ }
185
+ return !(i < ev.length && ev[i].t < hi)
186
+ },
187
+ // The scrolling the user did to look while paused, and taking it on from
188
+ // the frame this one replaces (see viewScroll in 40-input).
189
+ viewScroll,
190
+ applyView,
191
+ // Which build this frame is (the dock's number, through the stash); a frame
192
+ // that reloaded itself mid-build has none, and isn't swapped in.
193
+ buildId: (pending && pending.buildId) || null,
194
+ // Start a new branch at this moment, even if nothing lies ahead yet.
195
+ // The new timeline starts paused at this moment; recording into it starts
196
+ // when the user presses Play.
197
+ forkHere() {
198
+ pause()
199
+ if (hasFuture()) return fork()
200
+ if (shell && rec.start != null) shell.branchOff(JSON.stringify(rec), rec.end, clock.now)
201
+ },
202
+ isPaused: () => !clock.playing,
203
+ // The recording as JSON, built in idle slices (~8ms each) so saving a long
204
+ // session never blocks a frame. Same format as JSON.stringify(history()).
205
+ async serialize() {
206
+ const r = rec
207
+ const n = r.events.length
208
+ const pk = packer(r)
209
+ const idleWait = () => new Promise((res) => (real.idle ? real.idle(res, { timeout: 100 }) : real.setTimeout(res, 0)))
210
+ const parts = []
211
+ let slice = real.perfNow()
212
+ for (let i = 0; i < n; i += 1000) {
213
+ const chunk = []
214
+ for (let j = i; j < Math.min(n, i + 1000); j++) chunk.push(pk.event(r.events[j]))
215
+ const str = JSON.stringify(chunk)
216
+ if (str.length > 2) parts.push(str.slice(1, -1))
217
+ if (real.perfNow() - slice > 8) {
218
+ await idleWait()
219
+ slice = real.perfNow()
220
+ }
221
+ }
222
+ // Tables are complete once every event (and clip) has been packed.
223
+ const head = pk.head("__EVENTS__")
224
+ return JSON.stringify(head).replace('"__EVENTS__"', () => "[" + parts.join(",") + "]")
225
+ },
226
+ // Checkpoint frames: build in idle slices (true) or at full speed (false).
227
+ setBackground(on) {
228
+ background = !!on
229
+ },
230
+ // Stop a seek in progress where it is (e.g. a checkpoint build the user overtook).
231
+ cancelSeek() {
232
+ if (seekTarget == null) return false
233
+ seekTarget = clock.now
234
+ afterSeek = null
235
+ return true
236
+ },
237
+ // fn({ playing, now }) whenever play state changes; returns an unsubscribe.
238
+ onPlayState(fn) {
239
+ playListeners.add(fn)
240
+ return () => playListeners.delete(fn)
241
+ },
242
+ setRate,
243
+ history: () => rec,
244
+ activity,
245
+ // Jump into another branch's history (a JSON string from history()).
246
+ load: (json, t) => rewind(t, false, json),
247
+ // Checkpoints: a frame already rebuilt to some moment can take a newer copy
248
+ // of the same recording (one that only grew after that moment) and seek
249
+ // forward in place, instead of a rebuild from zero. Returns false (and
250
+ // changes nothing) if the recording doesn't continue this frame's past.
251
+ adopt(json, t, andPlay) {
252
+ const no = (why) => {
253
+ PT.adoptRefused = why
254
+ return false
255
+ }
256
+ let next
257
+ try {
258
+ next = readRec(json)
259
+ } catch {
260
+ return no("not JSON")
261
+ }
262
+ if (!next || clock.seeking || previewing) return no("busy")
263
+ if (!storageOk()) return no("another frame has changed this app's IndexedDB since")
264
+ if (next.seed !== rec.seed || next.epoch !== rec.epoch) return no("a different recording")
265
+ if (next.events.length < cursor.event || next.frames.length < cursor.frame) return no("shorter than this frame's past")
266
+ const same = (a, b) => a === b || (!!a && !!b && a.t === b.t && a.type === b.type)
267
+ for (let i = Math.max(0, cursor.event - 64); i < cursor.event; i++) if (!same(next.events[i], rec.events[i])) return no(`event ${i} differs`)
268
+ if (cursor.frame && next.frames[cursor.frame - 1] !== rec.frames[cursor.frame - 1]) return no("frames differ")
269
+ if (t < clock.now) return no("that moment is behind this frame")
270
+ if (segmentIndex(t, next) !== segmentIndex(clock.now, next)) return no("that moment is after a reload")
271
+ PT.adoptRefused = null
272
+ rec = next
273
+ seek(t, andPlay ? play : undefined)
274
+ PT.emit()
275
+ return true
276
+ },
277
+ state: () => ({
278
+ recording: rec.start != null && clock.playing,
279
+ booted: clock.booted,
280
+ started: rec.start != null,
281
+ previewing,
282
+ previewAt,
283
+ route: previewing ? routeAt(previewAt) : null, // the app's route at the moment previewed (its own location doesn't change)
284
+ start: rec.start ?? 0,
285
+ now: clock.now,
286
+ end: Math.max(rec.end, clock.now),
287
+ target: seekTarget,
288
+ playing: clock.playing,
289
+ seeking: clock.seeking,
290
+ rate: clock.rate,
291
+ future: hasFuture(),
292
+ // This document's page: where it starts on the timeline (a preview can't
293
+ // show earlier), and where the next page starts (null: none).
294
+ docStart: segmentAt(clock.now).t,
295
+ segEnd: nextSegmentStart(),
296
+ from: seekFrom, // where the current (or last) seek started
297
+ storageOk: storageOk(), // this frame can run its app as it is (see takeStorage)
298
+ idb: usesIDB(),
299
+ buildId: PT.buildId,
300
+ }),
301
+ timeline,
302
+ clipAt,
303
+ clipsFor,
304
+ cssSourceFor,
305
+ isInteractive,
306
+ setToolActive,
307
+ debug: () => ({
308
+ ...stats,
309
+ activity: { ...actStats },
310
+ appMessages,
311
+ timers: timers.size,
312
+ dom: domLog.length,
313
+ anims: animLog.map((e) => ({ target: e.target.id || e.target.getAttribute("class"), vStart: Math.round(e.vStart), vEnd: e.vEnd && Math.round(e.vEnd), state: stateOf(e.anim), kf: e.keyframes.length, fill: e.timing.fill })),
314
+ }),
315
+ })
316
+
317
+ function nextSegmentStart() {
318
+ for (const s of rec.segments || []) if (s.t > clock.now) return s.t
319
+ return null
320
+ }
321
+
322
+ // Alt+P record/pause while focus is inside the prototype. A frame built behind
323
+ // the one on show (or parked as a checkpoint) can have the window's focus (its
324
+ // replay focused a field), but it must never run by itself: there, the key is
325
+ // the dock's Play/Pause, as if pressed in the dock.
326
+ PT.shortcut = function (e) {
327
+ if (!e.altKey || e.metaKey || e.ctrlKey || e.code !== "KeyP") return false
328
+ e.preventDefault()
329
+ e.stopImmediatePropagation()
330
+ if (e.type !== "keydown" || e.repeat) return true
331
+ if (!onShow()) {
332
+ try {
333
+ shell.key(e)
334
+ } catch {}
335
+ return true
336
+ }
337
+ clock.playing && rec.start != null ? pause() : record()
338
+ return true
339
+ }
340
+ // Is this the frame the dock shows? (Without a dock, or one that can't say: yes.)
341
+ function onShow() {
342
+ try {
343
+ return !shell || typeof shell.shows !== "function" || !!shell.shows(W)
344
+ } catch {
345
+ return true
346
+ }
347
+ }
348
+
349
+ // The app navigating its frame to another of its pages (location.href = …)
350
+ // must stay in the time machine: the frame's marker (?__wb=app) is kept on
351
+ // the new URL, so that page gets the runtime and starts a new segment. (With
352
+ // the header marker the server knows the frame's navigations without it.)
353
+ try {
354
+ if (W.navigation && shell && RT.marker !== "header") {
355
+ W.navigation.addEventListener("navigate", (e) => {
356
+ if (e.hashChange || !e.cancelable || e.downloadRequest || (e.destination && e.destination.sameDocument)) return
357
+ const url = new URL(e.destination.url)
358
+ if (url.origin !== location.origin || url.searchParams.get("__wb") === "app") return
359
+ url.searchParams.set("__wb", "app")
360
+ e.preventDefault()
361
+ location.assign(url.href)
362
+ })
363
+ }
364
+ } catch {}
365
+
366
+ // A reload (or navigation) fires beforeunload first; a frame the dock removes
367
+ // doesn't. Only a reload leaves its recording on the shell to resume.
368
+ let unloading = false
369
+ W.addEventListener("beforeunload", () => (unloading = true))
370
+ W.addEventListener("pagehide", () => {
371
+ try {
372
+ if (!unloading || handingOff || !shell || !rec || rec.start == null || clock.seeking) return
373
+ const el = W.frameElement
374
+ if (!el || !el.isConnected) return
375
+ rec.reloads = [...(rec.reloads || []), clock.now]
376
+ shell.__resume = { frame: el, payload: { rec: JSON.stringify(rec), target: clock.now, play: clock.playing, rate: clock.rate, reloaded: true } }
377
+ } catch {}
378
+ })
379
+
380
+ function boot() {
381
+ observe()
382
+ // The page's own SVG animations (SMIL) run from the page's start.
383
+ findSmil(document)
384
+ syncSmil(clock.now, null, segmentAt(clock.now).t)
385
+ if (shell) shell.attach(PT)
386
+ if (shell) W.addEventListener("blur", () => shell.meta && shell.meta(false))
387
+ // Let the first render settle on real frames before time starts moving.
388
+ real.raf(() =>
389
+ real.raf(async () => {
390
+ await stateReady
391
+ clock.booted = true
392
+ if (pending) {
393
+ seekTarget = pending.target
394
+ afterSeek = pending.play ? play : null
395
+ } else {
396
+ play()
397
+ }
398
+ }),
399
+ )
400
+ }
401
+
402
+ // When the clock starts. At DOMContentLoaded (the Vite plugin's default) on
403
+ // an index.html app, whose scripts have all run by then. A server-rendered
404
+ // page streams most of its scripts in after that, live in real time but from
405
+ // the cache on a rebuild, so hydration landed at different virtual moments
406
+ // (F47): behind the front server (RT.bootAt "load") the clock starts at the
407
+ // window's load, when they've all run, live and rebuilt alike. A page that
408
+ // takes over 10s to load after DOMContentLoaded starts anyway (rec.bootCap).
409
+ // Then it waits until nothing has finished loading for BOOT_QUIET ms (at most
410
+ // BOOT_QUIET_CAP): a framework that imports its app after load (Nuxt's entry,
411
+ // Astro's islands: native import(), which can't be held, F48) mounted at +60
412
+ // to +300 ms live but before the clock started on a rebuild (from the cache),
413
+ // or after the replay had run past it (still loading), so every timer the app
414
+ // started on mount was off (F61). Now it has mounted before the clock starts,
415
+ // live and rebuilt alike.
416
+ const BOOT_CAP = 10000
417
+ const BOOT_QUIET = 150
418
+ const BOOT_QUIET_CAP = 3000
419
+ function whenQuiet(fn) {
420
+ const start = real.perfNow()
421
+ let last = start
422
+ let po = null
423
+ try {
424
+ po = new PerformanceObserver(() => (last = real.perfNow()))
425
+ po.observe({ type: "resource" })
426
+ } catch {}
427
+ const check = () => {
428
+ const now = real.perfNow()
429
+ if (now - last < BOOT_QUIET && now - start < BOOT_QUIET_CAP) return real.setTimeout(check, BOOT_QUIET - (now - last))
430
+ if (po) po.disconnect()
431
+ fn()
432
+ }
433
+ real.setTimeout(check, BOOT_QUIET)
434
+ }
435
+ function whenLoaded(fn) {
436
+ if (RT.bootAt !== "load") {
437
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn, { once: true })
438
+ else fn()
439
+ return
440
+ }
441
+ if (document.readyState === "complete") return whenQuiet(fn)
442
+ let done = false
443
+ const go = () => {
444
+ if (done) return
445
+ done = true
446
+ whenQuiet(fn)
447
+ }
448
+ W.addEventListener("load", go, { once: true })
449
+ const cap = () =>
450
+ real.setTimeout(() => {
451
+ if (done) return
452
+ console.warn(`[retake] this page took over ${BOOT_CAP / 1000}s to load; its clock starts before it has`)
453
+ if (rec) rec.bootCap = (rec.bootCap || 0) + 1
454
+ go()
455
+ }, BOOT_CAP)
456
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", cap, { once: true })
457
+ else cap()
458
+ }
459
+ whenLoaded(boot)
460
+ drive()