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,157 @@
|
|
|
1
|
+
// Audio/video can't be scrubbed, so it only plays while the clock plays at
|
|
2
|
+
// normal pace. Elements the app wanted playing are resumed when the clock does.
|
|
3
|
+
|
|
4
|
+
const MP = HTMLMediaElement.prototype
|
|
5
|
+
const mediaOrig = { play: MP.play, pause: MP.pause }
|
|
6
|
+
const media = new Set()
|
|
7
|
+
|
|
8
|
+
// play() settles in real time (and autoplay may reject it), so its outcome is
|
|
9
|
+
// recorded and replayed like a network response.
|
|
10
|
+
MP.play = function () {
|
|
11
|
+
media.add(this)
|
|
12
|
+
const el = this
|
|
13
|
+
return recordedAsync(() => {
|
|
14
|
+
if (!clock.playing || clock.seeking) return Promise.resolve()
|
|
15
|
+
return mediaOrig.play.call(el)
|
|
16
|
+
}).then(() => {
|
|
17
|
+
el.__ptWants = true
|
|
18
|
+
// Where the media was when it started, on the virtual clock.
|
|
19
|
+
el.__ptAt = clock.now
|
|
20
|
+
el.__ptFrom = el.currentTime || 0
|
|
21
|
+
mediaRun(el, el.__ptFrom, true)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
MP.pause = function () {
|
|
25
|
+
if (this.__ptWants) mediaRun(this, this.currentTime || 0, false)
|
|
26
|
+
this.__ptWants = false
|
|
27
|
+
return mediaOrig.pause.call(this)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Each element's runs on the virtual clock: { at, from, on } from `at` on, it
|
|
31
|
+
// played from `from` (on) or stood at it (off). The preview places media at
|
|
32
|
+
// the run in effect at its moment (F50), including media removed since.
|
|
33
|
+
function mediaRun(el, from, on) {
|
|
34
|
+
const runs = el.__ptRuns || (el.__ptRuns = [])
|
|
35
|
+
const last = runs[runs.length - 1]
|
|
36
|
+
if (last && last.at === clock.now) runs.pop()
|
|
37
|
+
runs.push({ at: clock.now, from, on, src: el.currentSrc || el.src || "" })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function syncMedia() {
|
|
41
|
+
const live = clock.playing && !clock.seeking
|
|
42
|
+
for (const el of media) {
|
|
43
|
+
if (el.__ptAlign && el.readyState >= 1 && !clock.seeking && !previewing) alignMedia()
|
|
44
|
+
// Removed media is kept (the DOM log keeps it too): a preview of a moment
|
|
45
|
+
// when it was there puts it back, at its time then.
|
|
46
|
+
if (!el.isConnected) {
|
|
47
|
+
if (!el.paused) mediaOrig.pause.call(el)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (!live && !el.paused) mediaOrig.pause.call(el)
|
|
51
|
+
else if (live && el.__ptWants && el.paused) mediaOrig.play.call(el).catch(() => {})
|
|
52
|
+
if (live && el.playbackRate !== clock.rate) el.playbackRate = clock.rate
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// After a seek, media the app had playing jumps to where it would be at this
|
|
57
|
+
// moment on the virtual clock.
|
|
58
|
+
function alignMedia(at = clock.now) {
|
|
59
|
+
for (const el of media) {
|
|
60
|
+
if (previewing && el.__ptRuns && el.__ptRuns.length) {
|
|
61
|
+
placeMedia(el, at)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (!el.__ptWants || el.__ptAt == null) continue
|
|
65
|
+
// Too early to seek (no metadata yet): syncMedia does it once it can.
|
|
66
|
+
if (el.readyState < 1) {
|
|
67
|
+
el.__ptAlign = true
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
el.__ptAlign = false
|
|
71
|
+
const t = el.__ptFrom + ((at - el.__ptAt) / 1000) * (el.playbackRate || 1)
|
|
72
|
+
const end = Number.isFinite(el.duration) ? el.duration : Infinity
|
|
73
|
+
try {
|
|
74
|
+
el.currentTime = el.loop && Number.isFinite(end) && end > 0 ? t % end : Math.min(t, end)
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The preview: where it was at `at`, from its runs (before the first: where that one started).
|
|
80
|
+
function placeMedia(el, at) {
|
|
81
|
+
if (el.readyState < 1) return
|
|
82
|
+
const runs = el.__ptRuns
|
|
83
|
+
let run = runs[0]
|
|
84
|
+
for (const r of runs) if (r.at <= at) run = r
|
|
85
|
+
const t = run.on && run.at <= at ? run.from + ((at - run.at) / 1000) * (el.playbackRate || 1) : run.from
|
|
86
|
+
const end = Number.isFinite(el.duration) ? el.duration : Infinity
|
|
87
|
+
try {
|
|
88
|
+
el.currentTime = el.loop && Number.isFinite(end) && end > 0 ? t % end : Math.min(t, end)
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- readiness events ------------------------------------------------------------
|
|
93
|
+
// A media file or image finishing loading is the network talking, in real
|
|
94
|
+
// time. Apps react to it (reveal UI on loadeddata, fade in on load), so the
|
|
95
|
+
// moment it happened is recorded, and on replay the real event is held back
|
|
96
|
+
// and the recorded one is dispatched at its virtual moment instead.
|
|
97
|
+
const READY = ["loadedmetadata", "loadeddata", "canplay", "canplaythrough", "playing", "load", "error"]
|
|
98
|
+
const readyTarget = (t) => t instanceof HTMLMediaElement || t instanceof HTMLImageElement
|
|
99
|
+
const delivered = new WeakMap() // element -> Set of types already given from the recording
|
|
100
|
+
|
|
101
|
+
function onReady(e) {
|
|
102
|
+
if (!e.isTrusted || !rec || !readyTarget(e.target)) return
|
|
103
|
+
const el = e.target
|
|
104
|
+
const given = delivered.get(el)
|
|
105
|
+
if (hasFuture() || clock.seeking) {
|
|
106
|
+
e.stopImmediatePropagation() // the recording delivers it at its moment
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (given && given.has(e.type)) {
|
|
110
|
+
// Already delivered from the recording before we reached the live edge.
|
|
111
|
+
given.delete(e.type)
|
|
112
|
+
e.stopImmediatePropagation()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const path = pathOf(el)
|
|
116
|
+
if (e.type === "playing" && el instanceof HTMLMediaElement) startedAt(el, el.currentTime)
|
|
117
|
+
if (path) recordEvent(e.type === "playing" ? { type: "ready", ev: e.type, path, ct: el.currentTime || 0 } : { type: "ready", ev: e.type, path })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Media that starts by itself (autoplay) never calls play(): it's taken in
|
|
121
|
+
// when it starts, paused whenever the clock isn't playing at normal pace, and
|
|
122
|
+
// placed at its virtual time after a seek, like media the app played.
|
|
123
|
+
function startedAt(el, from) {
|
|
124
|
+
media.add(el)
|
|
125
|
+
el.__ptWants = true
|
|
126
|
+
if (el.__ptAt == null) {
|
|
127
|
+
el.__ptAt = clock.now
|
|
128
|
+
el.__ptFrom = from || 0
|
|
129
|
+
mediaRun(el, el.__ptFrom, true)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
document.addEventListener(
|
|
133
|
+
"play",
|
|
134
|
+
(e) => {
|
|
135
|
+
const el = e.target
|
|
136
|
+
if (!(el instanceof HTMLMediaElement)) return
|
|
137
|
+
media.add(el)
|
|
138
|
+
el.__ptWants = true
|
|
139
|
+
if (!(clock.playing && !clock.seeking)) mediaOrig.pause.call(el)
|
|
140
|
+
},
|
|
141
|
+
true,
|
|
142
|
+
)
|
|
143
|
+
// On the document, not window: element load/error events don't reach window.
|
|
144
|
+
for (const type of READY) document.addEventListener(type, onReady, true)
|
|
145
|
+
|
|
146
|
+
function deliverReady(ev) {
|
|
147
|
+
const el = resolvePath(ev.path)
|
|
148
|
+
if (!el || !readyTarget(el)) return
|
|
149
|
+
if (!delivered.has(el)) delivered.set(el, new Set())
|
|
150
|
+
delivered.get(el).add(ev.ev)
|
|
151
|
+
// The recorded moment it started playing (autoplay included).
|
|
152
|
+
if (ev.ev === "playing" && el instanceof HTMLMediaElement) {
|
|
153
|
+
el.__ptAt = null
|
|
154
|
+
startedAt(el, ev.ct)
|
|
155
|
+
}
|
|
156
|
+
el.dispatchEvent(new Event(ev.ev))
|
|
157
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// The recording: everything needed to rebuild the page at any moment by
|
|
2
|
+
// reloading and replaying — input events, fetch responses, the frame times the
|
|
3
|
+
// clock advanced through, the random seed, Date epoch and web storage.
|
|
4
|
+
|
|
5
|
+
function snapshotStorage(store) {
|
|
6
|
+
const out = {}
|
|
7
|
+
try {
|
|
8
|
+
for (let i = 0; i < store.length; i++) {
|
|
9
|
+
const k = store.key(i)
|
|
10
|
+
if (!k.startsWith(KEY)) out[k] = store.getItem(k)
|
|
11
|
+
}
|
|
12
|
+
} catch {}
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function restoreStorage(store, snap) {
|
|
17
|
+
try {
|
|
18
|
+
for (let i = store.length - 1; i >= 0; i--) {
|
|
19
|
+
const k = store.key(i)
|
|
20
|
+
if (!k.startsWith(KEY)) store.removeItem(k)
|
|
21
|
+
}
|
|
22
|
+
for (const k in snap) store.setItem(k, snap[k])
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---- frames take turns with the origin's storage ------------------------------------
|
|
27
|
+
// The visible frame, a frame being built behind it and a checkpoint all share
|
|
28
|
+
// the origin's web storage and cookies, and a frame that boots puts them back
|
|
29
|
+
// to its own segment's start. So whichever frame last ran app code "owns" them
|
|
30
|
+
// (shell.storageOwner): a frame keeps a copy of its own when it stops running
|
|
31
|
+
// app code, and puts that copy back before it runs app code again if another
|
|
32
|
+
// frame took over meanwhile. IndexedDB can't be put back synchronously: a frame
|
|
33
|
+
// of an app that uses it reports storageOk false, and the dock rebuilds rather
|
|
34
|
+
// than resume it.
|
|
35
|
+
const storageId = real.random()
|
|
36
|
+
let ownStorage = null
|
|
37
|
+
let idbOpened = false
|
|
38
|
+
function keepStorage() {
|
|
39
|
+
if (shell && shell.storageOwner !== storageId) return
|
|
40
|
+
ownStorage = { local: snapshotStorage(real.local), session: snapshotStorage(real.session), cookies: snapshotCookies() }
|
|
41
|
+
}
|
|
42
|
+
function takeStorage() {
|
|
43
|
+
if (!shell || shell.storageOwner === storageId) return
|
|
44
|
+
if (ownStorage) {
|
|
45
|
+
restoreStorage(real.local, ownStorage.local)
|
|
46
|
+
restoreStorage(real.session, ownStorage.session)
|
|
47
|
+
restoreCookies(ownStorage.cookies)
|
|
48
|
+
}
|
|
49
|
+
shell.storageOwner = storageId
|
|
50
|
+
}
|
|
51
|
+
const usesIDB = () => idbOpened || segmentsOf().some((s) => s.idb && s.idb.length > 0)
|
|
52
|
+
const storageOk = () => !shell || shell.storageOwner === storageId || !usesIDB()
|
|
53
|
+
|
|
54
|
+
function newRecording() {
|
|
55
|
+
return withPacking({
|
|
56
|
+
v: 1,
|
|
57
|
+
url: location.href,
|
|
58
|
+
seed: Math.floor(real.random() * 2 ** 32),
|
|
59
|
+
epoch: real.Date.now(),
|
|
60
|
+
storage: { local: snapshotStorage(real.local), session: snapshotStorage(real.session) },
|
|
61
|
+
// Replays run in a frame of this size, so layout matches (F18).
|
|
62
|
+
viewport: { w: W.innerWidth, h: W.innerHeight },
|
|
63
|
+
// Element paths start at an id and skip scripts (F51; see pathOf).
|
|
64
|
+
pathV: 2,
|
|
65
|
+
// The front server's kept copy of this page (F56; RT.docId).
|
|
66
|
+
...(RT.docId ? { doc: RT.docId } : {}),
|
|
67
|
+
frames: [],
|
|
68
|
+
events: [],
|
|
69
|
+
fetches: [],
|
|
70
|
+
end: 0,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let rec = null
|
|
75
|
+
const cursor = { event: 0, frame: 0 }
|
|
76
|
+
|
|
77
|
+
// ---- compact serialisation ----------------------------------------------------------
|
|
78
|
+
// JSON.stringify(rec) writes a compact form (v: 2): every DOM path is stored
|
|
79
|
+
// once in a table and events refer to it by index, which on a deep DOM
|
|
80
|
+
// halves the size. readRec() takes either form back. The in-memory object
|
|
81
|
+
// (history()) keeps plain paths.
|
|
82
|
+
// A packer keeps the tables, so events can be packed a slice at a time.
|
|
83
|
+
function packer(r) {
|
|
84
|
+
const tables = { paths: [], strs: [] }
|
|
85
|
+
const idx = { paths: new Map(), strs: new Map() }
|
|
86
|
+
const ref = (kind, v) => {
|
|
87
|
+
if (v == null) return v
|
|
88
|
+
const key = typeof v === "string" ? v : Array.isArray(v) ? v.join(",") : "=" + v
|
|
89
|
+
let i = idx[kind].get(key)
|
|
90
|
+
if (i == null) {
|
|
91
|
+
i = tables[kind].length
|
|
92
|
+
tables[kind].push(v)
|
|
93
|
+
idx[kind].set(key, i)
|
|
94
|
+
}
|
|
95
|
+
return i
|
|
96
|
+
}
|
|
97
|
+
// An event's time is usually exactly a frame boundary: store the frame's index.
|
|
98
|
+
const frameAt = new Map()
|
|
99
|
+
r.frames.forEach((f, i) => frameAt.set(f, i))
|
|
100
|
+
let lastF = 0
|
|
101
|
+
return {
|
|
102
|
+
event(ev) {
|
|
103
|
+
const out = { ...ev }
|
|
104
|
+
out.y = ref("strs", out.type)
|
|
105
|
+
delete out.type
|
|
106
|
+
if ("path" in out) (out.p = ref("paths", out.path)), delete out.path
|
|
107
|
+
if ("related" in out) (out.rp = ref("paths", out.related)), delete out.related
|
|
108
|
+
if (out.css != null) (out.c = ref("strs", out.css)), delete out.css
|
|
109
|
+
const fi = frameAt.get(out.t)
|
|
110
|
+
if (fi != null) {
|
|
111
|
+
out.f = fi - lastF
|
|
112
|
+
lastF = fi
|
|
113
|
+
delete out.t
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
},
|
|
117
|
+
clip(c) {
|
|
118
|
+
const out = { ...c }
|
|
119
|
+
if (out.selector != null) (out.s = ref("strs", out.selector)), delete out.selector
|
|
120
|
+
if (out.component != null) (out.co = ref("strs", out.component)), delete out.component
|
|
121
|
+
if (out.label != null) (out.l = ref("strs", out.label)), delete out.label
|
|
122
|
+
if (out.path != null) (out.p = ref("paths", out.path)), delete out.path
|
|
123
|
+
return out
|
|
124
|
+
},
|
|
125
|
+
// Everything but the events, with the tables as they stand.
|
|
126
|
+
head(events) {
|
|
127
|
+
const out = {}
|
|
128
|
+
for (const k of Object.keys(r)) out[k] = r[k]
|
|
129
|
+
out.v = 3
|
|
130
|
+
out.events = events
|
|
131
|
+
if (r.clips) out.clips = r.clips.map((c) => this.clip(c))
|
|
132
|
+
out.paths = tables.paths
|
|
133
|
+
out.strs = tables.strs
|
|
134
|
+
return out
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function packRec(r) {
|
|
139
|
+
const pk = packer(r)
|
|
140
|
+
const events = r.events.map((ev) => pk.event(ev))
|
|
141
|
+
return pk.head(events)
|
|
142
|
+
}
|
|
143
|
+
function unpackRec(o) {
|
|
144
|
+
if (o && o.v === 2 && o.paths) {
|
|
145
|
+
// v2: paths table only
|
|
146
|
+
const table = o.paths
|
|
147
|
+
o.events = o.events.map((ev) => {
|
|
148
|
+
const out = { ...ev }
|
|
149
|
+
if ("p" in out) (out.path = table[out.p]), delete out.p
|
|
150
|
+
if ("rp" in out) (out.related = table[out.rp]), delete out.rp
|
|
151
|
+
return out
|
|
152
|
+
})
|
|
153
|
+
delete o.paths
|
|
154
|
+
o.v = 1
|
|
155
|
+
} else if (o && o.v === 3) {
|
|
156
|
+
const P = o.paths
|
|
157
|
+
const S = o.strs
|
|
158
|
+
let lastF = 0
|
|
159
|
+
o.events = o.events.map((ev) => {
|
|
160
|
+
const out = { ...ev }
|
|
161
|
+
out.type = S[out.y]
|
|
162
|
+
delete out.y
|
|
163
|
+
if ("p" in out) (out.path = P[out.p]), delete out.p
|
|
164
|
+
if ("rp" in out) (out.related = P[out.rp]), delete out.rp
|
|
165
|
+
if ("c" in out) (out.css = S[out.c]), delete out.c
|
|
166
|
+
if ("f" in out) {
|
|
167
|
+
lastF += out.f
|
|
168
|
+
out.t = o.frames[lastF]
|
|
169
|
+
delete out.f
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
})
|
|
173
|
+
if (o.clips) {
|
|
174
|
+
o.clips = o.clips.map((c) => {
|
|
175
|
+
const out = { ...c }
|
|
176
|
+
if ("s" in out) (out.selector = S[out.s]), delete out.s
|
|
177
|
+
if ("co" in out) (out.component = S[out.co]), delete out.co
|
|
178
|
+
if ("l" in out) (out.label = S[out.l]), delete out.l
|
|
179
|
+
if ("p" in out) (out.path = P[out.p]), delete out.p
|
|
180
|
+
return out
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
delete o.paths
|
|
184
|
+
delete o.strs
|
|
185
|
+
o.v = 1
|
|
186
|
+
}
|
|
187
|
+
return withPacking(o)
|
|
188
|
+
}
|
|
189
|
+
function withPacking(o) {
|
|
190
|
+
if (o && typeof o === "object" && !Object.prototype.hasOwnProperty.call(o, "toJSON")) {
|
|
191
|
+
Object.defineProperty(o, "toJSON", { value: function () { return packRec(this) }, enumerable: false, configurable: true })
|
|
192
|
+
}
|
|
193
|
+
return o
|
|
194
|
+
}
|
|
195
|
+
const readRec = (json) => unpackRec(typeof json === "string" ? JSON.parse(json) : json)
|
|
196
|
+
|
|
197
|
+
// Segments: a recording starts one on load, and another each time the app
|
|
198
|
+
// reloads itself. A moment is rebuilt from the start of its segment: that
|
|
199
|
+
// segment's URL, storage and seed, replaying only what came after it.
|
|
200
|
+
function segmentsOf(r = rec) {
|
|
201
|
+
const first = { t: 0, ev: 0, fr: 0, url: r.url, storage: r.storage, cookies: r.cookies, idb: r.idb, seed: r.seed, doc: r.doc, docDebug: r.docDebug }
|
|
202
|
+
return [first, ...(r.segments || [])]
|
|
203
|
+
}
|
|
204
|
+
function segmentIndex(t, r = rec) {
|
|
205
|
+
if (typeof r === "string") r = readRec(r)
|
|
206
|
+
let i = 0
|
|
207
|
+
for (const s of r.segments || []) if (s.t <= t) i++
|
|
208
|
+
return i
|
|
209
|
+
}
|
|
210
|
+
function segmentAt(t, r = rec) {
|
|
211
|
+
return segmentsOf(r)[segmentIndex(t, r)]
|
|
212
|
+
}
|
|
213
|
+
// Does this page already show that URL (ignoring our own __wb parameter)?
|
|
214
|
+
function sameDocUrl(url) {
|
|
215
|
+
try {
|
|
216
|
+
const a = new URL(url, location.href)
|
|
217
|
+
const b = new URL(location.href)
|
|
218
|
+
for (const u of [a, b]) u.searchParams.delete("__wb")
|
|
219
|
+
return a.origin === b.origin && a.pathname === b.pathname && a.search === b.search
|
|
220
|
+
} catch {
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const hasFuture = () =>
|
|
226
|
+
!!rec && (cursor.event < rec.events.length || cursor.frame < rec.frames.length)
|
|
227
|
+
|
|
228
|
+
// New input after going back starts a new future. The old one isn't lost: the
|
|
229
|
+
// dock keeps it as its own branch before we cut it off here.
|
|
230
|
+
function fork() {
|
|
231
|
+
if (!hasFuture()) return
|
|
232
|
+
if (shell && rec.start != null) shell.branchOff(JSON.stringify(rec), rec.end, clock.now)
|
|
233
|
+
const cut = rec.events.slice(cursor.event)
|
|
234
|
+
rec.events.length = cursor.event
|
|
235
|
+
rec.frames.length = cursor.frame
|
|
236
|
+
if (rec.routes) rec.routes = rec.routes.filter((r) => r.t <= clock.now)
|
|
237
|
+
if (rec.reloads) rec.reloads = rec.reloads.filter((t) => t <= clock.now)
|
|
238
|
+
if (rec.segments) rec.segments = rec.segments.filter((sg) => sg.t <= clock.now)
|
|
239
|
+
if (rec.clips) rec.clips = rec.clips.filter((c) => c.start <= clock.now)
|
|
240
|
+
cutActivity(clock.now)
|
|
241
|
+
netFork(cut)
|
|
242
|
+
rec.end = clock.now
|
|
243
|
+
PT.emit()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The frame boundary whose recorded input (live, or replayed) is being handled:
|
|
247
|
+
// the DOM changes that follow are logged just after it (see logMutations).
|
|
248
|
+
// Moves, hovers, wheel and scroll don't count: they come every frame while the
|
|
249
|
+
// pointer moves, in between the frame's own work, which would be logged late.
|
|
250
|
+
let inputAt = null
|
|
251
|
+
const CONTINUOUS = /^(pointermove|mousemove|pointerover|pointerout|pointerenter|pointerleave|mouseover|mouseout|mouseenter|mouseleave|wheel|scroll|touchmove)$/
|
|
252
|
+
const markInput = (ev) => {
|
|
253
|
+
if (!CONTINUOUS.test(ev.type)) inputAt = clock.now
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function recordEvent(ev) {
|
|
257
|
+
if (hasFuture()) return
|
|
258
|
+
ev.t = clock.now
|
|
259
|
+
markInput(ev)
|
|
260
|
+
const last = rec.events[rec.events.length - 1]
|
|
261
|
+
// Collapse pointer moves within one frame; only the latest position matters.
|
|
262
|
+
if (ev.type === "pointermove" && last && last.type === "pointermove" && last.t === ev.t && last.path + "" === ev.path + "") {
|
|
263
|
+
rec.events[rec.events.length - 1] = ev
|
|
264
|
+
} else {
|
|
265
|
+
rec.events.push(ev)
|
|
266
|
+
}
|
|
267
|
+
cursor.event = rec.events.length
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function recordFrame(t) {
|
|
271
|
+
if (!hasFuture() && t > (rec.frames[rec.frames.length - 1] ?? -1)) {
|
|
272
|
+
rec.frames.push(t)
|
|
273
|
+
cursor.frame = rec.frames.length
|
|
274
|
+
}
|
|
275
|
+
if (t > rec.end) rec.end = t
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- other real-time promises (e.g. media.play()) ----------------------------
|
|
279
|
+
// Their outcome and the moment they settle are recorded, keyed by call order,
|
|
280
|
+
// and replayed identically.
|
|
281
|
+
|
|
282
|
+
let asyncSeq = 0
|
|
283
|
+
const pendingAsync = new Map()
|
|
284
|
+
const settledEarly = new Map()
|
|
285
|
+
|
|
286
|
+
function recordedAsync(realCall) {
|
|
287
|
+
const i = asyncSeq++
|
|
288
|
+
const replayable = hasFuture() && rec.events.some((e, j) => j >= cursor.event && e.type === "async" && e.i === i)
|
|
289
|
+
if (replayable) {
|
|
290
|
+
if (settledEarly.has(i)) return asyncOutcome(settledEarly.get(i))
|
|
291
|
+
return new Promise((resolve, reject) => pendingAsync.set(i, { resolve, reject }))
|
|
292
|
+
}
|
|
293
|
+
if (hasFuture() || (rec.start != null && !clock.playing)) return realCall()
|
|
294
|
+
return realCall().then(
|
|
295
|
+
(value) => {
|
|
296
|
+
recordEvent({ type: "async", i, ok: true })
|
|
297
|
+
return value
|
|
298
|
+
},
|
|
299
|
+
(err) => {
|
|
300
|
+
recordEvent({ type: "async", i, ok: false, name: err && err.name, message: err && err.message })
|
|
301
|
+
throw err
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function asyncOutcome(ev) {
|
|
307
|
+
return ev.ok ? Promise.resolve() : Promise.reject(new DOMException(ev.message || "", ev.name || "Error"))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function settleAsync(ev) {
|
|
311
|
+
const p = pendingAsync.get(ev.i)
|
|
312
|
+
if (!p) return settledEarly.set(ev.i, ev)
|
|
313
|
+
pendingAsync.delete(ev.i)
|
|
314
|
+
asyncOutcome(ev).then(p.resolve, p.reject)
|
|
315
|
+
}
|