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.
- package/LICENSE +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- 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()
|