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,78 @@
1
+ // :hover in replays. Synthetic pointer events don't set :hover, so hover
2
+ // styles (and the transitions they start) never showed in a rebuild or a
3
+ // preview. Every :hover rule gets a twin that matches [data-rt-hover], and
4
+ // while a replay or preview shows the past, that attribute follows the
5
+ // recorded pointer (on the hovered element and its ancestors, like :hover).
6
+ // At the live edge it's cleared and the real :hover takes over.
7
+
8
+ const HOVER_ATTR = "data-rt-hover"
9
+ const mirrored = new WeakMap() // sheet -> rule count after mirroring
10
+ let hoverChain = []
11
+
12
+ function mirrorRules(list, owner) {
13
+ // Walk backwards so inserting a twin right after its rule doesn't shift the rest.
14
+ for (let i = list.length - 1; i >= 0; i--) {
15
+ const r = list[i]
16
+ if (r.cssRules && r.cssRules.length) mirrorRules(r.cssRules, r)
17
+ if (r.selectorText && r.selectorText.includes(":hover") && !r.selectorText.includes(HOVER_ATTR)) {
18
+ const twin = r.cssText.replace(r.selectorText, r.selectorText.replace(/:hover\b/g, `[${HOVER_ATTR}]`))
19
+ try {
20
+ owner.insertRule(twin, i + 1)
21
+ } catch {}
22
+ }
23
+ }
24
+ }
25
+
26
+ function mirrorHover() {
27
+ for (const sheet of document.styleSheets) {
28
+ let rules
29
+ try {
30
+ rules = sheet.cssRules
31
+ } catch {
32
+ continue // cross-origin
33
+ }
34
+ if (mirrored.get(sheet) === rules.length) continue
35
+ mirrorRules(rules, sheet)
36
+ mirrored.set(sheet, sheet.cssRules.length)
37
+ }
38
+ }
39
+
40
+ function setHover(el) {
41
+ const chain = []
42
+ for (let n = el && el.nodeType === 1 ? el : null; n; n = n.parentElement) chain.push(n)
43
+ if (chain.length) mirrorHover()
44
+ let changed = false
45
+ for (const n of hoverChain) if (!chain.includes(n)) n.removeAttribute(HOVER_ATTR), (changed = true)
46
+ for (const n of chain) if (!n.hasAttribute(HOVER_ATTR)) n.setAttribute(HOVER_ATTR, ""), (changed = true)
47
+ hoverChain = chain
48
+ // A hover change can start transitions: the next frame must look (see appRan).
49
+ if (changed) appRan = true
50
+ }
51
+
52
+ function clearHover() {
53
+ if (!hoverChain.length) return
54
+ for (const n of hoverChain) n.removeAttribute(HOVER_ATTR)
55
+ hoverChain = []
56
+ appRan = true
57
+ }
58
+
59
+ // A replayed pointer event: where the pointer is now.
60
+ function replayHover(ev, target) {
61
+ if (ev.type === "pointerover" || ev.type === "mouseover") setHover(target)
62
+ else if ((ev.type === "pointerout" || ev.type === "mouseout") && !ev.related) setHover(null)
63
+ }
64
+
65
+ // For a preview at t: the element the pointer was over then. Input recorded
66
+ // at exactly t comes just after it (as in a rebuild to t). Without t: what the
67
+ // input this frame has replayed so far says.
68
+ function hoverAt(t) {
69
+ let path = null
70
+ const n = t == null ? cursor.event : rec.events.length
71
+ for (let i = 0; i < n; i++) {
72
+ const ev = rec.events[i]
73
+ if (t != null && ev.t >= t) break
74
+ if (ev.type === "pointerover" || ev.type === "mouseover") path = ev.path
75
+ else if ((ev.type === "pointerout" || ev.type === "mouseout") && !ev.related) path = null
76
+ }
77
+ return path ? resolvePath(path) : null
78
+ }
@@ -0,0 +1,341 @@
1
+ // The driver. Time moves in "frames": at each frame boundary B, due timers run,
2
+ // the clock lands on B, rAF callbacks run, then any input recorded at B is
3
+ // re-dispatched. Recording keeps the boundaries, so a replay walks exactly the
4
+ // same frames and the app sees an identical sequence of moments.
5
+
6
+ const FF_STEP = 50 // max virtual ms between frames when skipping ahead live
7
+ let pace = 0 // where playback wants the clock to be, in virtual ms
8
+ let seekTarget = null
9
+ let afterSeek = null
10
+ let seekFrom = null // where the current (or last) seek started, for the dock's progress
11
+ let pendingPreview = null // a preview asked for mid-seek, shown once the seek stops
12
+
13
+ const nextEventT = () => (cursor.event < rec.events.length ? rec.events[cursor.event].t : Infinity)
14
+ const futureFrame = () => (cursor.frame < rec.frames.length ? rec.frames[cursor.frame] : null)
15
+
16
+ // Input recorded at exactly a seek target happened just *after* that moment,
17
+ // so a seek stops short of it.
18
+ let timersBefore = 0
19
+ // Plain input: whatever the app does with it, it does now (in handlers and
20
+ // what they queue). Anything else (a network reply, a worker message, media
21
+ // readiness) may go on in real time, so the next settle yields a real task.
22
+ const PLAIN_INPUT = /^(pointer|mouse|key|wheel|scroll|focus|blur|input|beforeinput|change|click|dblclick|contextmenu|touch|composition|select|nav|resize)/
23
+ async function dispatchUpTo(B) {
24
+ const exclusive = clock.seeking && B === seekTarget
25
+ timersBefore = timers.size
26
+ while (exclusive ? nextEventT() < B : nextEventT() <= B) {
27
+ const before = domLog.length
28
+ const ev = rec.events[cursor.event++]
29
+ markInput(ev)
30
+ dispatchRecorded(ev)
31
+ if (!PLAIN_INPUT.test(ev.type)) dispatchedSinceYield = true
32
+ // Events the browser fired in one task (one mouse move: pointermove,
33
+ // pointerover/out, mouseover/out…) go out back to back, as they did live;
34
+ // the app gets its turn after the group.
35
+ const next = rec.events[cursor.event]
36
+ if (next && next.g && next.t === ev.t && !(exclusive && next.t >= B)) continue
37
+ await settle(true)
38
+ // An event that changed the DOM may have started CSS transitions, which
39
+ // the next frame adopts; one that changed nothing leaves nothing behind.
40
+ if (observer && !previewing) logMutations(observer.takeRecords())
41
+ if (domLog.length !== before || timers.size !== timersBefore || rafQueue.size) appRan = true
42
+ }
43
+ }
44
+
45
+ // Did any app code run since the last settle? While rebuilding, a boundary
46
+ // where nothing ran (no input, timer, frame callback, animation, pending
47
+ // work) has nothing to wait for, so it's skipped through. That's most frames
48
+ // of a long session, and it gives the same result as waiting.
49
+ let appRan = true
50
+ // While rebuilding, animations only need a sync when one could have started
51
+ // (app code ran) or one finishes by this frame (its end events must fire
52
+ // here); in between, their state at the target is all that matters.
53
+ function nextAnimationEnd() {
54
+ let min = Infinity
55
+ for (const [a, st] of managed) {
56
+ if (st.done || st.userPaused) continue
57
+ const end = endOf(a)
58
+ const rate = rateOf(a) || 1
59
+ if (!Number.isFinite(end)) continue
60
+ const at = rate > 0 ? st.v + (end - st.t) / rate : st.v + st.t / -rate
61
+ if (at < min) min = at
62
+ }
63
+ return min
64
+ }
65
+
66
+ // Where a replay resting between recorded frames really is (see runSeek).
67
+ let restFrom = null
68
+ async function processBoundary(B) {
69
+ if (restFrom != null) {
70
+ clock.now = restFrom
71
+ restFrom = null
72
+ }
73
+ await dispatchUpTo(clock.now)
74
+ let ran = 0
75
+ for (let t = nextTimer(); t && t.due <= B && ran < 5000; t = nextTimer(), ran++) {
76
+ clock.now = Math.max(clock.now, t.due)
77
+ runTimer(t)
78
+ }
79
+ if (ran) appRan = true
80
+ clock.now = B
81
+ if (rafQueue.size) appRan = true
82
+ runRaf()
83
+ tickWorkers(B)
84
+ while (futureFrame() != null && futureFrame() <= B) cursor.frame++
85
+ const own0 = real.perfNow()
86
+ recordFrame(B)
87
+ let own = real.perfNow() - own0
88
+ if (!clock.seeking || appRan || appMessages > 0 || otherBusy() || nextAnimationEnd() <= B) {
89
+ const s0 = real.perfNow()
90
+ syncAnimations(appRan || clock.seeking)
91
+ own += real.perfNow() - s0
92
+ await settle(true)
93
+ // Live, whatever the app started while settling belongs to this moment
94
+ // too (a rebuild takes recorded starts instead).
95
+ if (!clock.seeking && appRan) syncAnimations()
96
+ appRan = false
97
+ }
98
+ await dispatchUpTo(B)
99
+ const a0 = real.perfNow()
100
+ sampleActivity()
101
+ own += real.perfNow() - a0
102
+ // The runtime's own work per recorded frame (not the app's), for the perf budget.
103
+ if (!clock.seeking) {
104
+ stats.ownFrames = (stats.ownFrames || 0) + 1
105
+ stats.ownMs = (stats.ownMs || 0) + own
106
+ if (own > (stats.ownMax || 0)) stats.ownMax = own
107
+ }
108
+ if (hoverChain.length && !clock.seeking && !hasFuture()) clearHover()
109
+ }
110
+
111
+ // The next frame boundary at or before `limit`, or null if time should rest.
112
+ function nextBoundary(limit, skipping) {
113
+ const f = futureFrame()
114
+ if (f != null) return f <= limit ? f : null
115
+ if (limit <= clock.now) return null
116
+ if (!skipping) return limit
117
+ // Skipping ahead: jump straight to the next timer, but keep rAF-driven work
118
+ // (streaming text, JS animations) ticking at least every FF_STEP.
119
+ const timer = nextTimer()
120
+ let B = limit
121
+ if (rafQueue.size) B = Math.min(B, clock.now + FF_STEP)
122
+ if (timer) B = Math.min(B, Math.max(timer.due, clock.now + 1))
123
+ return B
124
+ }
125
+
126
+ function catchUpAll() {
127
+ for (const [a, st] of managed) if (!st.done) catchUp(a, st)
128
+ }
129
+
130
+ // A frame being built in the background (a checkpoint) replays in idle
131
+ // slices, so the frame you're looking at stays smooth.
132
+ let background = false
133
+ const idleSlice = () => new Promise((r) => (W.requestIdleCallback && real.idle ? real.idle(r, { timeout: 200 }) : real.setTimeout(r, 0)))
134
+
135
+ async function runSeek() {
136
+ const seekStart = real.perfNow()
137
+ seekFrom = clock.now
138
+ takeStorage() // this frame's app runs now: its own storage, not another frame's
139
+ clock.seeking = true
140
+ reclaimNative()
141
+ syncMedia()
142
+ PT.emit()
143
+ let lastPaint = real.perfNow()
144
+ let sliceStart = real.perfNow()
145
+ while (seekTarget != null && clock.now < seekTarget) {
146
+ if (real.perfNow() - sliceStart > 8) {
147
+ // A replay that settles in microtasks would otherwise be one long task:
148
+ // every 8ms the page (the dock, the frame on show) gets a turn, or in
149
+ // background mode, the rest of an idle period.
150
+ await (background ? idleSlice() : yieldTask())
151
+ sliceStart = real.perfNow()
152
+ if (seekTarget == null) break
153
+ }
154
+ const B = nextBoundary(seekTarget, true)
155
+ if (B == null) {
156
+ // Rest between recorded frames, like the original did. The replay is
157
+ // really at its last boundary: timers due before the target run at
158
+ // their own moment when time moves on, as they did live (restFrom).
159
+ if (restFrom == null) restFrom = clock.now
160
+ clock.now = seekTarget
161
+ break
162
+ }
163
+ await processBoundary(B)
164
+ if (!background && real.perfNow() - lastPaint > 400) {
165
+ PT.emit()
166
+ await new Promise((r) => real.raf(r))
167
+ lastPaint = real.perfNow()
168
+ }
169
+ }
170
+ stats.seekMs = Math.round(real.perfNow() - seekStart)
171
+ seekTarget = null
172
+ clock.seeking = false
173
+ pace = clock.now
174
+ syncAnimations()
175
+ alignMedia()
176
+ syncMedia()
177
+ restoreScroll()
178
+ if (!hasFuture()) clearHover() // live again: the real :hover takes over
179
+ // Put real focus back where the recording had it.
180
+ const f = focused()
181
+ if (f && realActive.call(document) !== f) {
182
+ try {
183
+ f.focus({ preventScroll: true })
184
+ } catch {}
185
+ }
186
+ keepStorage()
187
+ // A preview asked for while this seek ran (it was stopped for it).
188
+ const p = pendingPreview
189
+ pendingPreview = null
190
+ if (p) preview(p.t, p.scope)
191
+ const then = afterSeek
192
+ afterSeek = null
193
+ if (then) then()
194
+ PT.emit()
195
+ }
196
+
197
+ // The last animation frame's time (real ms), for the step to the next one.
198
+ // Play sets it too, so the first frame played steps from the moment Play was
199
+ // pressed, not from before a rebuild's replay this frame sat through.
200
+ let driveLast = 0
201
+ async function drive() {
202
+ driveLast = real.perfNow()
203
+ for (;;) {
204
+ const ts = await new Promise((r) => real.raf(r))
205
+ // Cap the step so a backgrounded tab doesn't come back to a huge jump.
206
+ const dt = Math.max(0, Math.min(ts - driveLast, 100))
207
+ driveLast = ts
208
+ if (!clock.booted) continue
209
+ try {
210
+ if (seekTarget != null) {
211
+ await runSeek()
212
+ } else if (clock.playing) {
213
+ pace = Math.max(pace, clock.now) + dt * clock.rate
214
+ const nextSeg = hasFuture() && segmentsOf().find((sg) => sg.t > clock.now)
215
+ if (nextSeg && pace >= nextSeg.t) {
216
+ rewind(nextSeg.t, true) // the recorded future reloaded here: carry on from a fresh page (this one pauses)
217
+ continue
218
+ }
219
+ for (let B = nextBoundary(pace, false); B != null; B = nextBoundary(pace, false)) {
220
+ await processBoundary(B)
221
+ if (seekTarget != null || !clock.playing) break
222
+ }
223
+ }
224
+ syncAnimations(appRan) // new ones only if something happened since the frame
225
+ syncMedia()
226
+ releaseDeferred() // requests a frame built behind held, now it's on show at the live edge
227
+ releaseHeldScripts() // scripts held for a moment the recording no longer has
228
+ PT.emit()
229
+ } catch (err) {
230
+ console.error("[retake]", err)
231
+ }
232
+ }
233
+ }
234
+
235
+ // ---- controls ----------------------------------------------------------------
236
+
237
+ // Play state changes, for the dock (PT.onPlayState).
238
+ const playListeners = new Set()
239
+ function playStateChanged() {
240
+ for (const fn of playListeners) safeCall(fn, [{ playing: clock.playing, now: clock.now }])
241
+ }
242
+
243
+ function play() {
244
+ if (clock.playing) return
245
+ restoreScroll() // anything scrolled just to look goes back first
246
+ restoreFocus() // and the field you were in gets focus back
247
+ takeStorage() // this frame's own storage, if another frame had it meanwhile
248
+ clock.playing = true
249
+ clockRan = true
250
+ pace = clock.now
251
+ driveLast = real.perfNow()
252
+ syncMedia()
253
+ deliverHeldResize() // the window changed size while paused
254
+ releaseHeld() // network arrivals held while paused at the live edge
255
+ playStateChanged()
256
+ PT.emit()
257
+ }
258
+
259
+ function pause() {
260
+ const was = clock.playing
261
+ clock.playing = false
262
+ catchUpAll()
263
+ reclaimNative()
264
+ syncMedia()
265
+ keepStorage()
266
+ PT.emit()
267
+ if (was) playStateChanged()
268
+ }
269
+
270
+ function setRate(rate) {
271
+ catchUpAll()
272
+ reclaimNative()
273
+ clock.rate = rate
274
+ syncMedia()
275
+ PT.emit()
276
+ }
277
+
278
+ function seek(t, then) {
279
+ t = Math.max(0, t)
280
+ // Past a reload, the moment needs a fresh page: rebuild instead. So does a
281
+ // frame whose IndexedDB another frame has changed since (only a rebuild
282
+ // puts that back).
283
+ if (t >= clock.now && (segmentIndex(t) !== segmentIndex(clock.now) || !storageOk())) return rewind(t, !!then)
284
+ if (t >= clock.now) {
285
+ if (clock.playing) {
286
+ clock.playing = false
287
+ playStateChanged()
288
+ }
289
+ seekTarget = t
290
+ afterSeek = then || null
291
+ } else {
292
+ rewind(t, !!then)
293
+ }
294
+ }
295
+
296
+ // Going back means rebuilding: reload the prototype frame and replay what
297
+ // happened up to t. The dock (parent window) holds the history meanwhile.
298
+ // The dock builds the moment in a fresh frame behind this one and swaps it in
299
+ // when it's ready, so going back never flashes.
300
+ // url: the page the moment's segment started on (the dock loads it; a frame
301
+ // on another URL moves there itself). viewport: the size to build it at.
302
+ // sig: which recording this is (the dock keeps a build of the same recording
303
+ // going instead of starting another); none for another timeline's history.
304
+ function rewind(t, playAfter, json) {
305
+ // This frame is about to be replaced: it stops here (and keeps its storage).
306
+ if (clock.playing) pause()
307
+ let r = rec
308
+ let sig = null
309
+ if (json == null) {
310
+ json = JSON.stringify(rec)
311
+ sig = recSig()
312
+ } else {
313
+ try {
314
+ r = readRec(json)
315
+ } catch {
316
+ r = null
317
+ }
318
+ }
319
+ let url
320
+ try {
321
+ url = segmentAt(t, r).url
322
+ } catch {}
323
+ let doc = null
324
+ try {
325
+ doc = segmentAt(t, r).doc || null
326
+ } catch {}
327
+ const viewport = r && r.viewport ? { w: r.viewport.w, h: r.viewport.h } : null
328
+ if (shell) shell.rebuild({ rec: json, target: t, play: playAfter, rate: clock.rate, url, viewport, sig, doc })
329
+ }
330
+
331
+ // Behind the front server, the next load of `url` gets the page as it was
332
+ // recorded (F56): a one-shot cookie names the kept copy, and the server takes
333
+ // it out again as it serves it (the dev server never sees it).
334
+ function askStoredDoc(doc, url) {
335
+ if (!doc || !/^[0-9a-f]{16}$/.test(doc)) return
336
+ try {
337
+ const path = new URL(url, location.href).pathname
338
+ document.cookie = `__retake_doc=${doc}; path=${path}; max-age=10; samesite=strict`
339
+ } catch {}
340
+ }
341
+ const recSig = () => `${rec.events.length}:${rec.frames.length}:${rec.end}:${(rec.segments || []).length}:${rec.seed}`