@brett_lamy/docstream 1.5.0 → 1.6.1
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/README.md +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/replay/NativeReplay.d.ts +2 -2
- package/dist/replay/NativeReplay.d.ts.map +1 -1
- package/dist/replay/NativeReplay.js +3 -1
- package/dist/replay/NativeReplay.js.map +1 -1
- package/dist/replay/ReplayPreview.d.ts +3 -3
- package/dist/replay/ReplayPreview.d.ts.map +1 -1
- package/dist/replay/ReplayPreview.js.map +1 -1
- package/dist/replay/ReplayStage.d.ts +10 -0
- package/dist/replay/ReplayStage.d.ts.map +1 -0
- package/dist/replay/ReplayStage.js +19 -0
- package/dist/replay/ReplayStage.js.map +1 -0
- package/dist/replay/ReplayStageImpl.d.ts +5 -0
- package/dist/replay/ReplayStageImpl.d.ts.map +1 -0
- package/dist/replay/ReplayStageImpl.js +249 -0
- package/dist/replay/ReplayStageImpl.js.map +1 -0
- package/dist/replay/index.d.ts +6 -0
- package/dist/replay/index.d.ts.map +1 -1
- package/dist/replay/index.js +3 -0
- package/dist/replay/index.js.map +1 -1
- package/dist/replay/markers.d.ts +55 -0
- package/dist/replay/markers.d.ts.map +1 -0
- package/dist/replay/markers.js +177 -0
- package/dist/replay/markers.js.map +1 -0
- package/dist/replay/sanitize.d.ts +2 -2
- package/dist/replay/sanitize.d.ts.map +1 -1
- package/dist/replay/sanitize.js +1 -1
- package/dist/replay/sanitize.js.map +1 -1
- package/dist/replay/stage-types.d.ts +65 -0
- package/dist/replay/stage-types.d.ts.map +1 -0
- package/dist/replay/stage-types.js +2 -0
- package/dist/replay/stage-types.js.map +1 -0
- package/dist/replay/types.d.ts +39 -0
- package/dist/replay/types.d.ts.map +1 -0
- package/dist/replay/types.js +36 -0
- package/dist/replay/types.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/replay/NativeReplay.tsx +6 -2
- package/src/replay/ReplayPreview.tsx +6 -6
- package/src/replay/ReplayStage.tsx +28 -0
- package/src/replay/ReplayStageImpl.tsx +281 -0
- package/src/replay/index.ts +6 -0
- package/src/replay/markers.ts +213 -0
- package/src/replay/sanitize.ts +2 -3
- package/src/replay/stage-types.ts +63 -0
- package/src/replay/types.ts +46 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/* The headless rrweb stage: mounts rrweb's Replayer, fits the recorded viewport into its box
|
|
2
|
+
and exposes an imperative handle, with no controls of its own. Loaded on demand through
|
|
3
|
+
`ReplayStage` (and by `NativeReplay`), so rrweb and its stylesheet stay out of the eager graph. */
|
|
4
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import type { CSSProperties } from 'react'
|
|
6
|
+
import { Replayer } from 'rrweb'
|
|
7
|
+
import type { eventWithTime } from 'rrweb'
|
|
8
|
+
import { getReplayMeta } from './markers'
|
|
9
|
+
import { sanitizeReplayEvent } from './sanitize'
|
|
10
|
+
import type { ReplayStageHandle, ReplayStageProps } from './stage-types'
|
|
11
|
+
import { ReplayEventType } from './types'
|
|
12
|
+
import 'rrweb/dist/style.css'
|
|
13
|
+
|
|
14
|
+
const SKIP_IDLE_THRESHOLD_MS = 5_000
|
|
15
|
+
|
|
16
|
+
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
|
|
17
|
+
|
|
18
|
+
const ReplayStageImpl = forwardRef<ReplayStageHandle, ReplayStageProps>(function ReplayStageImpl(
|
|
19
|
+
{
|
|
20
|
+
events,
|
|
21
|
+
autoplay = false,
|
|
22
|
+
loop = false,
|
|
23
|
+
speed = 1,
|
|
24
|
+
skipInactive = true,
|
|
25
|
+
initialTime = 0,
|
|
26
|
+
fit = 'width',
|
|
27
|
+
showMouse = true,
|
|
28
|
+
overlay,
|
|
29
|
+
className,
|
|
30
|
+
style,
|
|
31
|
+
onReady,
|
|
32
|
+
onTimeUpdate,
|
|
33
|
+
onPlayingChange,
|
|
34
|
+
onFinish,
|
|
35
|
+
onLayout,
|
|
36
|
+
},
|
|
37
|
+
ref,
|
|
38
|
+
) {
|
|
39
|
+
const boxRef = useRef<HTMLDivElement>(null)
|
|
40
|
+
const playerRef = useRef<HTMLDivElement>(null)
|
|
41
|
+
const replayerRef = useRef<Replayer | null>(null)
|
|
42
|
+
const playingRef = useRef(false)
|
|
43
|
+
const fedCountRef = useRef(0)
|
|
44
|
+
const rafRef = useRef(0)
|
|
45
|
+
const callbacks = useRef({ onReady, onTimeUpdate, onPlayingChange, onFinish, onLayout })
|
|
46
|
+
callbacks.current = { onReady, onTimeUpdate, onPlayingChange, onFinish, onLayout }
|
|
47
|
+
const options = useRef({ autoplay, loop, speed, skipInactive, initialTime })
|
|
48
|
+
options.current = { autoplay, loop, speed, skipInactive, initialTime }
|
|
49
|
+
|
|
50
|
+
const prepared = useMemo(
|
|
51
|
+
() =>
|
|
52
|
+
events
|
|
53
|
+
.map((event) => sanitizeReplayEvent(event))
|
|
54
|
+
.sort((a, b) => a.timestamp - b.timestamp) as unknown as eventWithTime[],
|
|
55
|
+
[events],
|
|
56
|
+
)
|
|
57
|
+
const meta = useMemo(() => getReplayMeta(events), [events])
|
|
58
|
+
const [dims, setDims] = useState<{ w: number; h: number }>(() => ({ w: meta.width, h: meta.height }))
|
|
59
|
+
const [box, setBox] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
|
|
60
|
+
|
|
61
|
+
const time = useCallback(() => {
|
|
62
|
+
try {
|
|
63
|
+
const replayer = replayerRef.current
|
|
64
|
+
if (!replayer) return 0
|
|
65
|
+
return Math.max(0, Math.min(replayer.getCurrentTime(), replayer.getMetaData().totalTime))
|
|
66
|
+
} catch {
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const setPlaying = useCallback((next: boolean) => {
|
|
72
|
+
if (playingRef.current === next) return
|
|
73
|
+
playingRef.current = next
|
|
74
|
+
callbacks.current.onPlayingChange?.(next)
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
const tick = useCallback(() => {
|
|
78
|
+
cancelAnimationFrame(rafRef.current)
|
|
79
|
+
const step = () => {
|
|
80
|
+
callbacks.current.onTimeUpdate?.(time())
|
|
81
|
+
if (playingRef.current) rafRef.current = requestAnimationFrame(step)
|
|
82
|
+
}
|
|
83
|
+
rafRef.current = requestAnimationFrame(step)
|
|
84
|
+
}, [time])
|
|
85
|
+
|
|
86
|
+
const handle = useMemo<ReplayStageHandle>(
|
|
87
|
+
() => ({
|
|
88
|
+
play(offset) {
|
|
89
|
+
const replayer = replayerRef.current
|
|
90
|
+
if (!replayer) return
|
|
91
|
+
let from = offset ?? time()
|
|
92
|
+
try {
|
|
93
|
+
if (offset === undefined && from >= replayer.getMetaData().totalTime - 1) from = 0
|
|
94
|
+
} catch {
|
|
95
|
+
from = 0
|
|
96
|
+
}
|
|
97
|
+
replayer.play(from)
|
|
98
|
+
setPlaying(true)
|
|
99
|
+
tick()
|
|
100
|
+
},
|
|
101
|
+
pause(offset) {
|
|
102
|
+
const replayer = replayerRef.current
|
|
103
|
+
if (!replayer) return
|
|
104
|
+
if (offset === undefined) replayer.pause()
|
|
105
|
+
else replayer.pause(offset)
|
|
106
|
+
setPlaying(false)
|
|
107
|
+
callbacks.current.onTimeUpdate?.(offset ?? time())
|
|
108
|
+
},
|
|
109
|
+
seek(ms) {
|
|
110
|
+
const replayer = replayerRef.current
|
|
111
|
+
if (!replayer) return
|
|
112
|
+
let total = ms
|
|
113
|
+
try {
|
|
114
|
+
total = replayer.getMetaData().totalTime
|
|
115
|
+
} catch {
|
|
116
|
+
// keep ms
|
|
117
|
+
}
|
|
118
|
+
const next = Math.max(0, Math.min(ms, total))
|
|
119
|
+
if (playingRef.current) replayer.play(next)
|
|
120
|
+
else replayer.pause(next)
|
|
121
|
+
callbacks.current.onTimeUpdate?.(next)
|
|
122
|
+
},
|
|
123
|
+
toggle() {
|
|
124
|
+
if (playingRef.current) this.pause()
|
|
125
|
+
else this.play()
|
|
126
|
+
},
|
|
127
|
+
getCurrentTime: time,
|
|
128
|
+
isPlaying: () => playingRef.current,
|
|
129
|
+
getDuration() {
|
|
130
|
+
try {
|
|
131
|
+
return replayerRef.current?.getMetaData().totalTime ?? meta.duration
|
|
132
|
+
} catch {
|
|
133
|
+
return meta.duration
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
[meta.duration, setPlaying, tick, time],
|
|
138
|
+
)
|
|
139
|
+
useImperativeHandle(ref, () => handle, [handle])
|
|
140
|
+
|
|
141
|
+
// Build / feed the Replayer. Appended events are fed live; a shorter list starts over.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (replayerRef.current && prepared.length < fedCountRef.current) {
|
|
144
|
+
try {
|
|
145
|
+
replayerRef.current.destroy()
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore stale player teardown.
|
|
148
|
+
}
|
|
149
|
+
replayerRef.current = null
|
|
150
|
+
fedCountRef.current = 0
|
|
151
|
+
}
|
|
152
|
+
const hasSnapshot = prepared.some((event) => event.type === ReplayEventType.FullSnapshot)
|
|
153
|
+
if (!hasSnapshot || !playerRef.current) return
|
|
154
|
+
|
|
155
|
+
if (!replayerRef.current) {
|
|
156
|
+
const { autoplay, speed, skipInactive, initialTime } = options.current
|
|
157
|
+
const replayer = new Replayer(prepared, {
|
|
158
|
+
root: playerRef.current,
|
|
159
|
+
liveMode: false,
|
|
160
|
+
speed,
|
|
161
|
+
skipInactive,
|
|
162
|
+
inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
|
|
163
|
+
mouseTail: false,
|
|
164
|
+
showWarning: false,
|
|
165
|
+
})
|
|
166
|
+
replayer.on('resize', (payload) => {
|
|
167
|
+
const size = payload as { width?: number; height?: number }
|
|
168
|
+
if (size.width && size.height) setDims({ w: size.width, h: size.height })
|
|
169
|
+
})
|
|
170
|
+
replayer.on('finish', () => {
|
|
171
|
+
if (options.current.loop) {
|
|
172
|
+
replayer.play(0)
|
|
173
|
+
setPlaying(true)
|
|
174
|
+
tick()
|
|
175
|
+
} else {
|
|
176
|
+
setPlaying(false)
|
|
177
|
+
callbacks.current.onTimeUpdate?.(time())
|
|
178
|
+
callbacks.current.onFinish?.()
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
replayer.pause(initialTime)
|
|
182
|
+
replayerRef.current = replayer
|
|
183
|
+
fedCountRef.current = prepared.length
|
|
184
|
+
let duration = meta.duration
|
|
185
|
+
try {
|
|
186
|
+
duration = replayer.getMetaData().totalTime
|
|
187
|
+
} catch {
|
|
188
|
+
// keep the event-derived duration
|
|
189
|
+
}
|
|
190
|
+
callbacks.current.onReady?.({ duration, width: meta.width, height: meta.height })
|
|
191
|
+
callbacks.current.onTimeUpdate?.(initialTime)
|
|
192
|
+
if (autoplay) handle.play(initialTime)
|
|
193
|
+
} else if (prepared.length > fedCountRef.current) {
|
|
194
|
+
for (let i = fedCountRef.current; i < prepared.length; i++) replayerRef.current.addEvent(prepared[i]!)
|
|
195
|
+
fedCountRef.current = prepared.length
|
|
196
|
+
}
|
|
197
|
+
}, [prepared, meta, handle, setPlaying, tick, time])
|
|
198
|
+
|
|
199
|
+
useEffect(
|
|
200
|
+
() => () => {
|
|
201
|
+
cancelAnimationFrame(rafRef.current)
|
|
202
|
+
try {
|
|
203
|
+
replayerRef.current?.destroy()
|
|
204
|
+
} catch {
|
|
205
|
+
// Ignore unmount teardown errors from rrweb.
|
|
206
|
+
}
|
|
207
|
+
replayerRef.current = null
|
|
208
|
+
fedCountRef.current = 0
|
|
209
|
+
},
|
|
210
|
+
[],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
replayerRef.current?.setConfig({ speed, skipInactive })
|
|
215
|
+
}, [speed, skipInactive])
|
|
216
|
+
|
|
217
|
+
// rrweb's own cursor element; hosts drawing their own pointer overlay hide it.
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const mouse = playerRef.current?.querySelector<HTMLElement>('.replayer-mouse')
|
|
220
|
+
if (mouse) mouse.style.visibility = showMouse ? '' : 'hidden'
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
useIsoLayoutEffect(() => {
|
|
224
|
+
const el = boxRef.current
|
|
225
|
+
if (!el) return
|
|
226
|
+
const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight })
|
|
227
|
+
measure()
|
|
228
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
229
|
+
const observer = new ResizeObserver(measure)
|
|
230
|
+
observer.observe(el)
|
|
231
|
+
return () => observer.disconnect()
|
|
232
|
+
}, [])
|
|
233
|
+
|
|
234
|
+
const w = dims.w || 1280
|
|
235
|
+
const h = dims.h || 720
|
|
236
|
+
const scale = box.w
|
|
237
|
+
? fit === 'contain' && box.h
|
|
238
|
+
? Math.min(box.w / w, box.h / h)
|
|
239
|
+
: Math.min(1, box.w / w)
|
|
240
|
+
: 0
|
|
241
|
+
const offsetX = scale ? Math.max(0, (box.w - w * scale) / 2) : 0
|
|
242
|
+
const offsetY = scale && fit === 'contain' ? Math.max(0, (box.h - h * scale) / 2) : 0
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (scale) callbacks.current.onLayout?.({ width: w, height: h, scale, offsetX, offsetY })
|
|
246
|
+
}, [w, h, scale, offsetX, offsetY])
|
|
247
|
+
|
|
248
|
+
const rootStyle: CSSProperties = {
|
|
249
|
+
position: 'relative',
|
|
250
|
+
overflow: 'hidden',
|
|
251
|
+
width: '100%',
|
|
252
|
+
...(fit === 'contain' ? { height: '100%' } : { height: scale ? Math.round(h * scale) : undefined, aspectRatio: scale ? undefined : `${w} / ${h}` }),
|
|
253
|
+
...style,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div ref={boxRef} className={className} style={rootStyle} data-replay-stage="">
|
|
258
|
+
<div
|
|
259
|
+
data-replay-stage-scaler=""
|
|
260
|
+
style={
|
|
261
|
+
{
|
|
262
|
+
position: 'absolute',
|
|
263
|
+
left: offsetX,
|
|
264
|
+
top: offsetY,
|
|
265
|
+
width: w,
|
|
266
|
+
height: h,
|
|
267
|
+
transform: `scale(${scale || 0.0001})`,
|
|
268
|
+
transformOrigin: 'top left',
|
|
269
|
+
visibility: scale ? 'visible' : 'hidden',
|
|
270
|
+
'--replay-scale': String(scale || 1),
|
|
271
|
+
} as CSSProperties
|
|
272
|
+
}
|
|
273
|
+
>
|
|
274
|
+
<div ref={playerRef} aria-hidden="true" style={{ position: 'relative', width: w, height: h, overflow: 'hidden' }} />
|
|
275
|
+
{overlay ? <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>{overlay}</div> : null}
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
export default ReplayStageImpl
|
package/src/replay/index.ts
CHANGED
|
@@ -6,5 +6,11 @@ export type {
|
|
|
6
6
|
ReplayPreviewProps,
|
|
7
7
|
ReplaySource,
|
|
8
8
|
} from './ReplayPreview';
|
|
9
|
+
export { ReplayStage, preloadReplayStage } from './ReplayStage';
|
|
10
|
+
export type { ReplayStageHandle, ReplayStageLayout, ReplayStageProps } from './stage-types';
|
|
11
|
+
export { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers';
|
|
12
|
+
export type { ReplayMarker, ReplayMarkerKind, ReplayMeta, ReplayPointerPoint, ReplayPointerTrack } from './markers';
|
|
13
|
+
export { ReplayEventType, ReplayIncrementalSource, ReplayMouseInteraction } from './types';
|
|
14
|
+
export type { ReplayEvent } from './types';
|
|
9
15
|
export { isReplayQaUrl, normalizeReplayEmbedUrl } from './url';
|
|
10
16
|
export { sanitizeReplayEvent } from './sanitize';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/* Pure readers over an rrweb event list — no rrweb import, so custom player chrome (a
|
|
2
|
+
scrubber with event markers, a cursor overlay) can be drawn before the player chunk loads. */
|
|
3
|
+
import { ReplayEventType, ReplayIncrementalSource, ReplayMouseInteraction, type ReplayEvent } from './types'
|
|
4
|
+
|
|
5
|
+
export interface ReplayMeta {
|
|
6
|
+
/** Timestamp of the first event (ms since epoch). */
|
|
7
|
+
start: number
|
|
8
|
+
/** Timestamp of the last event. */
|
|
9
|
+
end: number
|
|
10
|
+
/** `end - start`: rrweb's `totalTime`. */
|
|
11
|
+
duration: number
|
|
12
|
+
/** Recorded viewport, from the first Meta / viewport-resize event (0 when unknown). */
|
|
13
|
+
width: number
|
|
14
|
+
height: number
|
|
15
|
+
/** The first recorded page URL, if any. */
|
|
16
|
+
href?: string
|
|
17
|
+
/** Whether a full DOM snapshot is present (rrweb can't play without one). */
|
|
18
|
+
playable: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ReplayMarkerKind = 'click' | 'error' | 'network' | 'navigation' | 'custom'
|
|
22
|
+
|
|
23
|
+
export interface ReplayMarker {
|
|
24
|
+
/** Stable id (`<kind>-<index>`), handy as a React key. */
|
|
25
|
+
id: string
|
|
26
|
+
kind: ReplayMarkerKind
|
|
27
|
+
/** Milliseconds from the start of the recording. */
|
|
28
|
+
time: number
|
|
29
|
+
label: string
|
|
30
|
+
detail?: string
|
|
31
|
+
/** Viewport coordinates, for clicks. */
|
|
32
|
+
x?: number
|
|
33
|
+
y?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReplayPointerPoint {
|
|
37
|
+
/** Milliseconds from the start of the recording. */
|
|
38
|
+
time: number
|
|
39
|
+
x: number
|
|
40
|
+
y: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReplayPointerTrack {
|
|
44
|
+
moves: ReplayPointerPoint[]
|
|
45
|
+
clicks: ReplayPointerPoint[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sorted(events: readonly ReplayEvent[]): ReplayEvent[] {
|
|
49
|
+
return [...events].sort((a, b) => a.timestamp - b.timestamp)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function record(value: unknown): Record<string, unknown> {
|
|
53
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function text(value: unknown): string | undefined {
|
|
57
|
+
if (typeof value === 'string') return value
|
|
58
|
+
if (typeof value === 'number') return String(value)
|
|
59
|
+
if (Array.isArray(value)) return value.map((v) => (typeof v === 'string' ? v.replace(/^"|"$/g, '') : JSON.stringify(v))).join(' ')
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getReplayMeta(events: readonly ReplayEvent[]): ReplayMeta {
|
|
64
|
+
const list = sorted(events)
|
|
65
|
+
const start = list[0]?.timestamp ?? 0
|
|
66
|
+
const end = list[list.length - 1]?.timestamp ?? start
|
|
67
|
+
let width = 0
|
|
68
|
+
let height = 0
|
|
69
|
+
let href: string | undefined
|
|
70
|
+
let playable = false
|
|
71
|
+
for (const event of list) {
|
|
72
|
+
const data = record(event.data)
|
|
73
|
+
if (event.type === ReplayEventType.FullSnapshot) playable = true
|
|
74
|
+
if (event.type === ReplayEventType.Meta) {
|
|
75
|
+
if (!width && typeof data.width === 'number') width = data.width
|
|
76
|
+
if (!height && typeof data.height === 'number') height = data.height
|
|
77
|
+
if (!href && typeof data.href === 'string') href = data.href
|
|
78
|
+
} else if (
|
|
79
|
+
!width &&
|
|
80
|
+
event.type === ReplayEventType.IncrementalSnapshot &&
|
|
81
|
+
data.source === ReplayIncrementalSource.ViewportResize &&
|
|
82
|
+
typeof data.width === 'number' &&
|
|
83
|
+
typeof data.height === 'number'
|
|
84
|
+
) {
|
|
85
|
+
width = data.width
|
|
86
|
+
height = data.height
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { start, end, duration: end - start, width, height, href, playable }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ERROR_TAGS = new Set(['error', 'exception', 'console.error', 'uncaught'])
|
|
93
|
+
const NETWORK_TAGS = new Set(['network', 'network.error', 'request.failed', 'fetch.failed'])
|
|
94
|
+
const NAVIGATION_TAGS = new Set(['navigation', 'navigate', 'route'])
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Timeline markers for a scrubber: clicks, console errors (rrweb console plugin), failed
|
|
98
|
+
* requests (rrweb network plugin, status 0 or ≥ 400), page navigations (Meta events after the
|
|
99
|
+
* first) and Custom events (`record.addCustomEvent(tag, payload)`; tags `error`, `network`,
|
|
100
|
+
* `navigation` map to those kinds, anything else is `custom` labelled by `payload.label`).
|
|
101
|
+
*/
|
|
102
|
+
export function getReplayMarkers(events: readonly ReplayEvent[]): ReplayMarker[] {
|
|
103
|
+
const list = sorted(events)
|
|
104
|
+
const start = list[0]?.timestamp ?? 0
|
|
105
|
+
const markers: ReplayMarker[] = []
|
|
106
|
+
let seenMeta = false
|
|
107
|
+
const push = (marker: Omit<ReplayMarker, 'id'>) =>
|
|
108
|
+
markers.push({ ...marker, id: `${marker.kind}-${markers.length}` })
|
|
109
|
+
|
|
110
|
+
for (const event of list) {
|
|
111
|
+
const time = event.timestamp - start
|
|
112
|
+
const data = record(event.data)
|
|
113
|
+
switch (event.type) {
|
|
114
|
+
case ReplayEventType.IncrementalSnapshot: {
|
|
115
|
+
if (
|
|
116
|
+
data.source === ReplayIncrementalSource.MouseInteraction &&
|
|
117
|
+
(data.type === ReplayMouseInteraction.Click || data.type === ReplayMouseInteraction.DblClick) &&
|
|
118
|
+
typeof data.x === 'number' &&
|
|
119
|
+
typeof data.y === 'number'
|
|
120
|
+
) {
|
|
121
|
+
push({ kind: 'click', time, label: data.type === ReplayMouseInteraction.DblClick ? 'Double-click' : 'Click', x: data.x, y: data.y })
|
|
122
|
+
}
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
case ReplayEventType.Meta: {
|
|
126
|
+
if (seenMeta && typeof data.href === 'string') push({ kind: 'navigation', time, label: 'Navigated', detail: data.href })
|
|
127
|
+
seenMeta = true
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
case ReplayEventType.Plugin: {
|
|
131
|
+
const payload = record(data.payload)
|
|
132
|
+
if (data.plugin === 'rrweb/console@1' && (payload.level === 'error' || payload.level === 'assert')) {
|
|
133
|
+
push({ kind: 'error', time, label: 'Console error', detail: text(payload.payload) })
|
|
134
|
+
} else if (data.plugin === 'rrweb/network@1' && Array.isArray(payload.requests)) {
|
|
135
|
+
for (const raw of payload.requests) {
|
|
136
|
+
const request = record(raw)
|
|
137
|
+
const status = typeof request.status === 'number' ? request.status : typeof request.responseStatus === 'number' ? request.responseStatus : undefined
|
|
138
|
+
if (status === 0 || (status !== undefined && status >= 400) || request.error) {
|
|
139
|
+
const method = typeof request.method === 'string' ? request.method : 'GET'
|
|
140
|
+
push({ kind: 'network', time, label: `${method} ${status || 'failed'}`, detail: text(request.url ?? request.name) })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
case ReplayEventType.Custom: {
|
|
147
|
+
const tag = typeof data.tag === 'string' ? data.tag : 'custom'
|
|
148
|
+
const payload = record(data.payload)
|
|
149
|
+
const detail = text(payload.detail ?? payload.message ?? payload.url)
|
|
150
|
+
const label = text(payload.label)
|
|
151
|
+
if (ERROR_TAGS.has(tag)) push({ kind: 'error', time, label: label ?? 'Error', detail })
|
|
152
|
+
else if (NETWORK_TAGS.has(tag)) {
|
|
153
|
+
const status = text(payload.status)
|
|
154
|
+
const method = text(payload.method) ?? 'GET'
|
|
155
|
+
push({ kind: 'network', time, label: label ?? `${method} ${status ?? 'failed'}`, detail })
|
|
156
|
+
} else if (NAVIGATION_TAGS.has(tag)) push({ kind: 'navigation', time, label: label ?? 'Navigated', detail })
|
|
157
|
+
else push({ kind: 'custom', time, label: label ?? tag, detail })
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return markers
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The recorded pointer path (mouse/touch moves and drags) and click points, in viewport px. */
|
|
166
|
+
export function getReplayPointerTrack(events: readonly ReplayEvent[]): ReplayPointerTrack {
|
|
167
|
+
const list = sorted(events)
|
|
168
|
+
const start = list[0]?.timestamp ?? 0
|
|
169
|
+
const moves: ReplayPointerPoint[] = []
|
|
170
|
+
const clicks: ReplayPointerPoint[] = []
|
|
171
|
+
for (const event of list) {
|
|
172
|
+
if (event.type !== ReplayEventType.IncrementalSnapshot) continue
|
|
173
|
+
const data = record(event.data)
|
|
174
|
+
const source = data.source
|
|
175
|
+
if (
|
|
176
|
+
(source === ReplayIncrementalSource.MouseMove ||
|
|
177
|
+
source === ReplayIncrementalSource.TouchMove ||
|
|
178
|
+
source === ReplayIncrementalSource.Drag) &&
|
|
179
|
+
Array.isArray(data.positions)
|
|
180
|
+
) {
|
|
181
|
+
for (const raw of data.positions) {
|
|
182
|
+
const p = record(raw)
|
|
183
|
+
if (typeof p.x !== 'number' || typeof p.y !== 'number') continue
|
|
184
|
+
const offset = typeof p.timeOffset === 'number' ? p.timeOffset : 0
|
|
185
|
+
moves.push({ time: Math.max(0, event.timestamp - start + offset), x: p.x, y: p.y })
|
|
186
|
+
}
|
|
187
|
+
} else if (source === ReplayIncrementalSource.MouseInteraction && typeof data.x === 'number' && typeof data.y === 'number') {
|
|
188
|
+
const point = { time: event.timestamp - start, x: data.x, y: data.y }
|
|
189
|
+
moves.push(point)
|
|
190
|
+
if (data.type === ReplayMouseInteraction.Click || data.type === ReplayMouseInteraction.DblClick) clicks.push(point)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
moves.sort((a, b) => a.time - b.time)
|
|
194
|
+
return { moves, clicks }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The pointer position at `time` (linear between recorded points), or null before the first. */
|
|
198
|
+
export function replayPointerAt(track: ReplayPointerTrack, time: number): { x: number; y: number } | null {
|
|
199
|
+
const { moves } = track
|
|
200
|
+
if (!moves.length || time < moves[0]!.time) return null
|
|
201
|
+
let lo = 0
|
|
202
|
+
let hi = moves.length - 1
|
|
203
|
+
if (time >= moves[hi]!.time) return { x: moves[hi]!.x, y: moves[hi]!.y }
|
|
204
|
+
while (hi - lo > 1) {
|
|
205
|
+
const mid = (lo + hi) >> 1
|
|
206
|
+
if (moves[mid]!.time <= time) lo = mid
|
|
207
|
+
else hi = mid
|
|
208
|
+
}
|
|
209
|
+
const a = moves[lo]!
|
|
210
|
+
const b = moves[hi]!
|
|
211
|
+
const k = b.time === a.time ? 1 : (time - a.time) / (b.time - a.time)
|
|
212
|
+
return { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k }
|
|
213
|
+
}
|
package/src/replay/sanitize.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { EventType, IncrementalSource } from '
|
|
2
|
-
import type { eventWithTime } from 'rrweb';
|
|
1
|
+
import { ReplayEventType as EventType, ReplayIncrementalSource as IncrementalSource, type ReplayEvent } from './types';
|
|
3
2
|
|
|
4
3
|
const TRANSPARENT_PIXEL =
|
|
5
4
|
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
|
@@ -57,7 +56,7 @@ function walkNode(node: unknown): void {
|
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
/** Prevent a replay iframe from making the viewer fetch localhost/private assets. */
|
|
60
|
-
export function sanitizeReplayEvent(event:
|
|
59
|
+
export function sanitizeReplayEvent<T extends ReplayEvent>(event: T): T {
|
|
61
60
|
try {
|
|
62
61
|
if (event.type === EventType.FullSnapshot) {
|
|
63
62
|
walkNode((event.data as { node?: unknown }).node);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
import type { ReplayEvent } from './types'
|
|
3
|
+
|
|
4
|
+
/** Imperative control over a mounted `ReplayStage`. Times are ms from the recording's start. */
|
|
5
|
+
export interface ReplayStageHandle {
|
|
6
|
+
/** Play from `offset` (default: the current time; from 0 when at the end). */
|
|
7
|
+
play(offset?: number): void
|
|
8
|
+
/** Pause, optionally jumping to `offset`. */
|
|
9
|
+
pause(offset?: number): void
|
|
10
|
+
/** Jump to `ms`, keeping the play state. */
|
|
11
|
+
seek(ms: number): void
|
|
12
|
+
toggle(): void
|
|
13
|
+
getCurrentTime(): number
|
|
14
|
+
getDuration(): number
|
|
15
|
+
isPlaying(): boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReplayStageLayout {
|
|
19
|
+
/** Recorded viewport size (px). */
|
|
20
|
+
width: number
|
|
21
|
+
height: number
|
|
22
|
+
/** How much the viewport is scaled to fit the stage box. */
|
|
23
|
+
scale: number
|
|
24
|
+
/** Where the scaled viewport sits inside the box. */
|
|
25
|
+
offsetX: number
|
|
26
|
+
offsetY: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReplayStageProps {
|
|
30
|
+
/** rrweb events; appended events are fed to the running player. */
|
|
31
|
+
events: ReplayEvent[]
|
|
32
|
+
autoplay?: boolean
|
|
33
|
+
loop?: boolean
|
|
34
|
+
/** Playback rate (1 = real time). */
|
|
35
|
+
speed?: number
|
|
36
|
+
/** Fast-forward idle stretches longer than 5s (rrweb `skipInactive`). Default true. */
|
|
37
|
+
skipInactive?: boolean
|
|
38
|
+
/** Where the paused player starts (ms). */
|
|
39
|
+
initialTime?: number
|
|
40
|
+
/**
|
|
41
|
+
* `width` (default): scale down to the box width, height follows the recording's aspect.
|
|
42
|
+
* `contain`: fill the box (give it a height) and letterbox the viewport inside it.
|
|
43
|
+
*/
|
|
44
|
+
fit?: 'width' | 'contain'
|
|
45
|
+
/** rrweb's own cursor. Hide it when drawing a pointer overlay. Default true. */
|
|
46
|
+
showMouse?: boolean
|
|
47
|
+
/**
|
|
48
|
+
* Rendered over the page, in recorded-viewport coordinates (a box of `width × height` px
|
|
49
|
+
* scaled with the page). `var(--replay-scale)` holds the scale, so an overlay can
|
|
50
|
+
* counter-scale elements that should keep their on-screen size.
|
|
51
|
+
*/
|
|
52
|
+
overlay?: ReactNode
|
|
53
|
+
className?: string
|
|
54
|
+
style?: CSSProperties
|
|
55
|
+
/** Shown while the player chunk (rrweb) loads. */
|
|
56
|
+
fallback?: ReactNode
|
|
57
|
+
onReady?(info: { duration: number; width: number; height: number }): void
|
|
58
|
+
/** Every frame while playing, and after each seek/pause. */
|
|
59
|
+
onTimeUpdate?(ms: number): void
|
|
60
|
+
onPlayingChange?(playing: boolean): void
|
|
61
|
+
onFinish?(): void
|
|
62
|
+
onLayout?(layout: ReplayStageLayout): void
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* rrweb's event shape and the numeric enums the replay helpers read, declared locally so the
|
|
2
|
+
eager replay modules (and their published declarations) never import rrweb. rrweb is
|
|
3
|
+
lazy: only the player chunk (`ReplayStageImpl` / `NativeReplay`) imports it, so pages that
|
|
4
|
+
never play native events never download it. */
|
|
5
|
+
|
|
6
|
+
/** One recorded rrweb event. rrweb's own `eventWithTime` is assignable to it. */
|
|
7
|
+
export interface ReplayEvent {
|
|
8
|
+
type: number
|
|
9
|
+
data: unknown
|
|
10
|
+
timestamp: number
|
|
11
|
+
delay?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** rrweb `EventType`. */
|
|
15
|
+
export const ReplayEventType = {
|
|
16
|
+
DomContentLoaded: 0,
|
|
17
|
+
Load: 1,
|
|
18
|
+
FullSnapshot: 2,
|
|
19
|
+
IncrementalSnapshot: 3,
|
|
20
|
+
Meta: 4,
|
|
21
|
+
Custom: 5,
|
|
22
|
+
Plugin: 6,
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
/** rrweb `IncrementalSource` (the subset the helpers read). */
|
|
26
|
+
export const ReplayIncrementalSource = {
|
|
27
|
+
Mutation: 0,
|
|
28
|
+
MouseMove: 1,
|
|
29
|
+
MouseInteraction: 2,
|
|
30
|
+
Scroll: 3,
|
|
31
|
+
ViewportResize: 4,
|
|
32
|
+
Input: 5,
|
|
33
|
+
TouchMove: 6,
|
|
34
|
+
Drag: 12,
|
|
35
|
+
} as const
|
|
36
|
+
|
|
37
|
+
/** rrweb `MouseInteractions` (the subset the helpers read). */
|
|
38
|
+
export const ReplayMouseInteraction = {
|
|
39
|
+
MouseUp: 0,
|
|
40
|
+
MouseDown: 1,
|
|
41
|
+
Click: 2,
|
|
42
|
+
ContextMenu: 3,
|
|
43
|
+
DblClick: 4,
|
|
44
|
+
TouchStart: 7,
|
|
45
|
+
TouchEnd: 9,
|
|
46
|
+
} as const
|