@brett_lamy/docstream 1.20.1 → 1.22.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/README.md +5 -0
  2. package/dist/doc-env.d.ts +5 -3
  3. package/dist/doc-env.d.ts.map +1 -1
  4. package/dist/doc-env.js +2 -2
  5. package/dist/doc-env.js.map +1 -1
  6. package/dist/env-keychain.d.ts +49 -0
  7. package/dist/env-keychain.d.ts.map +1 -0
  8. package/dist/env-keychain.js +201 -0
  9. package/dist/env-keychain.js.map +1 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/replay/ReplayDevtools.d.ts +18 -0
  15. package/dist/replay/ReplayDevtools.d.ts.map +1 -0
  16. package/dist/replay/ReplayDevtools.js +137 -0
  17. package/dist/replay/ReplayDevtools.js.map +1 -0
  18. package/dist/replay/ReplayPlayer.d.ts +10 -2
  19. package/dist/replay/ReplayPlayer.d.ts.map +1 -1
  20. package/dist/replay/ReplayPlayer.js +16 -6
  21. package/dist/replay/ReplayPlayer.js.map +1 -1
  22. package/dist/replay/demo/index.d.ts.map +1 -1
  23. package/dist/replay/demo/index.js +15 -4
  24. package/dist/replay/demo/index.js.map +1 -1
  25. package/dist/replay/devtools.d.ts +70 -0
  26. package/dist/replay/devtools.d.ts.map +1 -0
  27. package/dist/replay/devtools.js +201 -0
  28. package/dist/replay/devtools.js.map +1 -0
  29. package/dist/replay/index.d.ts +3 -0
  30. package/dist/replay/index.d.ts.map +1 -1
  31. package/dist/replay/index.js +1 -0
  32. package/dist/replay/index.js.map +1 -1
  33. package/dist/styles.css +547 -0
  34. package/package.json +1 -1
  35. package/src/doc-env.tsx +7 -5
  36. package/src/env-keychain.ts +264 -0
  37. package/src/index.ts +2 -0
  38. package/src/replay/ReplayDevtools.tsx +348 -0
  39. package/src/replay/ReplayPlayer.tsx +36 -6
  40. package/src/replay/demo/index.ts +15 -4
  41. package/src/replay/devtools.ts +251 -0
  42. package/src/replay/index.ts +3 -0
  43. package/src/styles.css +547 -0
@@ -1,7 +1,8 @@
1
1
  /* The player behind `ReplayPreview`: an rrweb session replay in a browser frame. The headless `ReplayStage`
2
2
  plays the recording (rrweb loads lazily, on first render); this adds the chrome around it: a URL bar that
3
3
  follows the recording's navigations, play/pause, a scrubber with the recording's moments on a rail above
4
- it, a speed control, fullscreen, and a drawn cursor whose clicks ripple. Until the player is ready a
4
+ it, a speed control, fullscreen, a drawn cursor whose clicks ripple, and DevTools: Console and Network buttons
5
+ in the top bar with what had been logged and requested by the playhead, which open a DevTools panel over the page. Until the player is ready a
5
6
  poster (a page-shaped skeleton by default) holds the frame's exact size, so nothing jumps.
6
7
 
7
8
  Plain React + plain CSS (`@layer docstream.replay` in styles.css, `[data-slot]` hooks). Keys (focus inside
@@ -15,6 +16,8 @@ import {
15
16
  import { useReducedMotion } from 'framer-motion'
16
17
  import { Lock, Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
17
18
  import { ReplayStage } from './ReplayStage'
19
+ import { DevtoolsButtons, DevtoolsPanel, type ReplayDevtoolsTab } from './ReplayDevtools'
20
+ import { getReplayDevtools, replayDevtoolsAt } from './devtools'
18
21
  import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
19
22
  import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
20
23
  import type { ReplayStageHandle } from './stage-types'
@@ -59,6 +62,12 @@ export interface ReplayPlayerOptions {
59
62
  markers?: boolean | ReplayMarker[]
60
63
  /** Which recorded marker kinds to show (default all). */
61
64
  markerKinds?: ReplayMarkerKind[]
65
+ /** Console and Network buttons (in the top bar, or beside the controls without the browser chrome) that open a
66
+ * DevTools panel cut at the playhead. `true` (default) shows them when the recording has console messages or
67
+ * requests; `false` hides them. */
68
+ devtools?: boolean
69
+ /** Open the DevTools panel on this tab at mount. */
70
+ defaultDevtoolsTab?: ReplayDevtoolsTab
62
71
  /** Draw the recorded pointer and click ripples (default true). */
63
72
  cursor?: boolean
64
73
  /** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
@@ -89,7 +98,7 @@ const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
89
98
  export function ReplayPlayer({
90
99
  events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
91
100
  autoplay = false, loop = false, initialTime = 0, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
92
- cursor = true, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
101
+ cursor = true, devtools = true, defaultDevtoolsTab, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
93
102
  className, style,
94
103
  }: ReplayPlayerOptions & { events: ReplayEvent[] }) {
95
104
  const rootRef = useRef<HTMLDivElement>(null)
@@ -103,6 +112,10 @@ export function ReplayPlayer({
103
112
  return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
104
113
  }, [markers, recorded, markerKinds])
105
114
 
115
+ const tools = useMemo(() => getReplayDevtools(events), [events])
116
+ const showTools = devtools && (tools.console.length > 0 || tools.network.length > 0)
117
+ const [panel, setPanel] = useState<ReplayDevtoolsTab | null>(defaultDevtoolsTab ?? null)
118
+
106
119
  const [visible, setVisible] = useState(!lazy)
107
120
  const [ready, setReady] = useState(false)
108
121
  const [posterGone, setPosterGone] = useState(false)
@@ -195,6 +208,10 @@ export function ReplayPlayer({
195
208
  return current
196
209
  }, [url, meta.href, recorded, time])
197
210
 
211
+ const toolsState = useMemo(() => replayDevtoolsAt(tools, time), [tools, time])
212
+ const openTools = (tab: ReplayDevtoolsTab) => setPanel((p) => (p === tab ? null : tab))
213
+ const toolButtons = showTools ? <DevtoolsButtons state={toolsState} open={panel} onOpen={openTools} /> : null
214
+
198
215
  const w = meta.width || 1280
199
216
  const h = meta.height || 800
200
217
  const contain = fit === 'contain' || fullscreen
@@ -221,7 +238,7 @@ export function ReplayPlayer({
221
238
  }}
222
239
  style={style}
223
240
  >
224
- {chrome === 'browser' ? <BrowserBar href={href} trafficLights={trafficLights} loading={!ready && meta.playable} /> : null}
241
+ {chrome === 'browser' ? <BrowserBar href={href} trafficLights={trafficLights} loading={!ready && meta.playable} actions={toolButtons} /> : null}
225
242
 
226
243
  <div data-slot="replay-preview-stage" style={{ '--replay-ar': `${w} / ${h}` } as CSSProperties}>
227
244
  {visible && meta.playable ? (
@@ -249,8 +266,19 @@ export function ReplayPlayer({
249
266
  </div>
250
267
  ) : null}
251
268
 
269
+ {showTools && panel ? (
270
+ <DevtoolsPanel
271
+ state={toolsState}
272
+ at={time}
273
+ tab={panel}
274
+ onTab={setPanel}
275
+ onClose={() => { setPanel(null); rootRef.current?.focus({ preventScroll: true }) }}
276
+ onSeek={(ms) => handle.seek(ms)}
277
+ />
278
+ ) : null}
279
+
252
280
  {controls ? (
253
- <div data-slot="replay-preview-center" data-show={(ready && !playing) || undefined}>
281
+ <div data-slot="replay-preview-center" data-show={(ready && !playing && !(showTools && panel)) || undefined}>
254
282
  <button
255
283
  type="button"
256
284
  tabIndex={-1}
@@ -280,6 +308,7 @@ export function ReplayPlayer({
280
308
  onSeek={(ms) => handle.seek(ms)}
281
309
  onMarker={jumpTo}
282
310
  />
311
+ {chrome !== 'browser' ? toolButtons : null}
283
312
  <button type="button" data-slot="replay-preview-speed" aria-label={`Playback speed ${speed}×`} onClick={() => cycleSpeed(1)}>
284
313
  {`${speed}×`}
285
314
  </button>
@@ -301,7 +330,7 @@ export function splitReplayAddress(href: string): { host: string; path: string }
301
330
  return cut < 0 ? { host: shown, path: '' } : { host: shown.slice(0, cut), path: shown.slice(cut) }
302
331
  }
303
332
 
304
- export function BrowserBar({ href, trafficLights, loading }: { href: string; trafficLights: boolean; loading: boolean }) {
333
+ export function BrowserBar({ href, trafficLights, loading, actions }: { href: string; trafficLights: boolean; loading: boolean; actions?: ReactNode }) {
305
334
  const { host, path } = splitReplayAddress(href)
306
335
  // Once the recording is past the site root, where it is (path, query, hash) is what matters:
307
336
  // it leads and keeps its room, and the host dims and gives way first.
@@ -322,7 +351,8 @@ export function BrowserBar({ href, trafficLights, loading }: { href: string; tra
322
351
  {path ? <span data-part="path">{path}</span> : null}
323
352
  </span>
324
353
  </div>
325
- {trafficLights ? <span aria-hidden="true" data-slot="replay-preview-lights-spacer" /> : null}
354
+ {actions ? <div data-slot="replay-preview-actions">{actions}</div>
355
+ : trafficLights ? <span aria-hidden="true" data-slot="replay-preview-lights-spacer" /> : null}
326
356
  </div>
327
357
  )
328
358
  }
@@ -1,14 +1,19 @@
1
1
  import type { ReplayEvent } from '../types';
2
2
 
3
- /* A small synthetic rrweb recording (58 events, ~9 s, 1280×800) for ReplayPreview's stories, docs and the loop-qa
3
+ /* A small synthetic rrweb recording (67 events, ~9 s, 1280×800) for ReplayPreview's stories, docs and the loop-qa
4
4
  block: a shopper on a mock "Northwind" storefront adds two products, opens the cart, types an email and a
5
5
  promo code, and "Place order" fails — a custom `network` event (POST /api/checkout 500) and an `error` event
6
- mark the moment. Recorded with rrweb 2.0.0-alpha.20 against a local page (no external assets), timestamps
6
+ mark the moment. Console messages (rrweb/console@1) and the page's other requests (rrweb/network@1, with
7
+ headers and bodies) were added by hand so the player's DevTools have something to show; none of them fail, so the
8
+ timeline markers are unchanged. Recorded with rrweb 2.0.0-alpha.20 against a local page (no external assets), timestamps
7
9
  rebased to 2026-09-24 14:32:08 UTC so every render is identical. */
8
10
  export const replayDemoEvents: ReplayEvent[] = [
9
11
  {"type":4,"data":{"href":"https://shop.northwind.test/collections/trail","width":1280,"height":800},"timestamp":1790260328000},
10
12
  {"type":2,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{},"childNodes":[{"type":2,"tagName":"head","attributes":{},"childNodes":[{"type":2,"tagName":"meta","attributes":{"charset":"utf-8"},"childNodes":[],"id":5},{"type":2,"tagName":"title","attributes":{},"childNodes":[{"type":3,"textContent":"Northwind Outfitters","id":7}],"id":6},{"type":3,"textContent":"\n","id":8},{"type":2,"tagName":"style","attributes":{"_cssText":"* { box-sizing: border-box; }body { margin: 0px; font: 14px / 1.45 -apple-system, BlinkMacSystemFont, \"Segoe UI\", Inter, sans-serif; color: rgb(28, 25, 23); background: rgb(250, 250, 249); }header { display: flex; align-items: center; gap: 28px; height: 64px; padding: 0px 40px; background: rgb(255, 255, 255); border-bottom: 1px solid rgb(231, 229, 228); }.logo { font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }.logo b { color: rgb(13, 148, 136); }nav a { color: rgb(87, 83, 78); text-decoration: none; margin-right: 20px; }.sp { flex: 1 1 0%; }.cart { position: relative; border: 1px solid rgb(214, 211, 209); background: rgb(255, 255, 255); border-radius: 999px; padding: 7px 14px; font: inherit; cursor: pointer; }.badge { position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; border-radius: 9px; background: rgb(13, 148, 136); color: rgb(255, 255, 255); font-size: 11px; display: grid; place-items: center; padding: 0px 5px; }.hero { padding: 36px 40px 12px; }.hero h1 { margin: 0px; font-size: 30px; letter-spacing: -0.03em; }.hero p { margin: 6px 0px 0px; color: rgb(120, 113, 108); }.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; padding: 20px 40px; }.card { background: rgb(255, 255, 255); border: 1px solid rgb(231, 229, 228); border-radius: 14px; overflow: hidden; transition: box-shadow 0.2s; }.card:hover { box-shadow: rgba(0, 0, 0, 0.08) 0px 8px 24px; }.img { height: 150px; }.i1 { background: linear-gradient(135deg, rgb(153, 246, 228), rgb(13, 148, 136)); }.i2 { background: linear-gradient(135deg, rgb(253, 230, 138), rgb(217, 119, 6)); }.i3 { background: linear-gradient(135deg, rgb(199, 210, 254), rgb(79, 70, 229)); }.i4 { background: linear-gradient(135deg, rgb(254, 205, 211), rgb(225, 29, 72)); }.body { padding: 12px 14px 14px; }.name { font-weight: 600; }.price { color: rgb(120, 113, 108); margin: 2px 0px 10px; }.add { width: 100%; border: 0px; border-radius: 9px; background: rgb(28, 25, 23); color: rgb(255, 255, 255); padding: 9px; font-style: inherit; font-variant: inherit; font-stretch: inherit; font-size: inherit; line-height: inherit; font-family: inherit; font-optical-sizing: inherit; font-size-adjust: inherit; font-kerning: inherit; font-feature-settings: inherit; font-variation-settings: inherit; font-language-override: inherit; font-weight: 600; cursor: pointer; }.drawer { position: fixed; top: 0px; right: 0px; bottom: 0px; width: 380px; background: rgb(255, 255, 255); border-left: 1px solid rgb(231, 229, 228); box-shadow: rgba(0, 0, 0, 0.12) -12px 0px 40px; padding: 24px; transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }.drawer.open { transform: none; }.drawer h2 { margin: 0px 0px 16px; font-size: 18px; }.line { display: flex; justify-content: space-between; padding: 10px 0px; border-bottom: 1px solid rgb(245, 245, 244); }label { display: block; font-size: 12px; color: rgb(120, 113, 108); margin: 14px 0px 4px; }input { width: 100%; border: 1px solid rgb(214, 211, 209); border-radius: 9px; padding: 9px 11px; font: inherit; }.pay { margin-top: 18px; width: 100%; border: 0px; border-radius: 10px; background: rgb(13, 148, 136); color: rgb(255, 255, 255); padding: 11px; font-style: inherit; font-variant: inherit; font-stretch: inherit; font-size: inherit; line-height: inherit; font-family: inherit; font-optical-sizing: inherit; font-size-adjust: inherit; font-kerning: inherit; font-feature-settings: inherit; font-variation-settings: inherit; font-language-override: inherit; font-weight: 600; cursor: pointer; }.err { display: none; margin-top: 14px; border-radius: 10px; background: rgb(254, 242, 242); border: 1px solid rgb(254, 202, 202); color: rgb(185, 28, 28); padding: 10px 12px; font-size: 13px; }.err.show { display: block; }"},"childNodes":[{"type":3,"textContent":"","id":10}],"id":9},{"type":2,"tagName":"script","attributes":{"type":"text/javascript"},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":12}],"id":11}],"id":4},{"type":2,"tagName":"body","attributes":{},"childNodes":[{"type":3,"textContent":"\n","id":14},{"type":2,"tagName":"header","attributes":{},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"logo"},"childNodes":[{"type":3,"textContent":"north","id":17},{"type":2,"tagName":"b","attributes":{},"childNodes":[{"type":3,"textContent":"wind","id":19}],"id":18}],"id":16},{"type":2,"tagName":"nav","attributes":{},"childNodes":[{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"New","id":22}],"id":21},{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"Trail","id":24}],"id":23},{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"Road","id":26}],"id":25},{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"Sale","id":28}],"id":27}],"id":20},{"type":2,"tagName":"div","attributes":{"class":"sp"},"childNodes":[],"id":29},{"type":2,"tagName":"button","attributes":{"class":"cart","id":"cart"},"childNodes":[{"type":3,"textContent":"Cart ","id":31},{"type":2,"tagName":"span","attributes":{"class":"badge","id":"count"},"childNodes":[{"type":3,"textContent":"0","id":33}],"id":32}],"id":30}],"id":15},{"type":3,"textContent":"\n","id":34},{"type":2,"tagName":"section","attributes":{"class":"hero"},"childNodes":[{"type":2,"tagName":"h1","attributes":{},"childNodes":[{"type":3,"textContent":"Fall trail collection","id":37}],"id":36},{"type":2,"tagName":"p","attributes":{},"childNodes":[{"type":3,"textContent":"Built for mud, rain and long miles.","id":39}],"id":38}],"id":35},{"type":3,"textContent":"\n","id":40},{"type":2,"tagName":"section","attributes":{"class":"grid"},"childNodes":[{"type":3,"textContent":"\n","id":42},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i1"},"childNodes":[],"id":44},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Trail Runner 2","id":47}],"id":46},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$148","id":49}],"id":48},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Trail Runner 2","data-c":"148"},"childNodes":[{"type":3,"textContent":"Add to cart","id":51}],"id":50}],"id":45}],"id":43},{"type":3,"textContent":"\n","id":52},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i2"},"childNodes":[],"id":54},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Summit Shell","id":57}],"id":56},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$229","id":59}],"id":58},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Summit Shell","data-c":"229"},"childNodes":[{"type":3,"textContent":"Add to cart","id":61}],"id":60}],"id":55}],"id":53},{"type":3,"textContent":"\n","id":62},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i3"},"childNodes":[],"id":64},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Ridge Pack 28L","id":67}],"id":66},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$119","id":69}],"id":68},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Ridge Pack 28L","data-c":"119"},"childNodes":[{"type":3,"textContent":"Add to cart","id":71}],"id":70}],"id":65}],"id":63},{"type":3,"textContent":"\n","id":72},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i4"},"childNodes":[],"id":74},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Merino Crew","id":77}],"id":76},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$68","id":79}],"id":78},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Merino Crew","data-c":"68"},"childNodes":[{"type":3,"textContent":"Add to cart","id":81}],"id":80}],"id":75}],"id":73},{"type":3,"textContent":"\n","id":82}],"id":41},{"type":3,"textContent":"\n","id":83},{"type":2,"tagName":"aside","attributes":{"class":"drawer","id":"drawer"},"childNodes":[{"type":2,"tagName":"h2","attributes":{},"childNodes":[{"type":3,"textContent":"Your cart","id":86}],"id":85},{"type":2,"tagName":"div","attributes":{"id":"lines"},"childNodes":[],"id":87},{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[{"type":2,"tagName":"b","attributes":{},"childNodes":[{"type":3,"textContent":"Total","id":90}],"id":89},{"type":2,"tagName":"b","attributes":{"id":"total"},"childNodes":[{"type":3,"textContent":"$0","id":92}],"id":91}],"id":88},{"type":3,"textContent":"\n","id":93},{"type":2,"tagName":"label","attributes":{},"childNodes":[{"type":3,"textContent":"Email","id":95}],"id":94},{"type":2,"tagName":"input","attributes":{"id":"email","placeholder":"you@example.com"},"childNodes":[],"id":96},{"type":2,"tagName":"label","attributes":{},"childNodes":[{"type":3,"textContent":"Promo code","id":98}],"id":97},{"type":2,"tagName":"input","attributes":{"id":"promo","placeholder":"Optional"},"childNodes":[],"id":99},{"type":3,"textContent":"\n","id":100},{"type":2,"tagName":"button","attributes":{"class":"pay","id":"pay"},"childNodes":[{"type":3,"textContent":"Place order","id":102}],"id":101},{"type":2,"tagName":"div","attributes":{"class":"err","id":"err"},"childNodes":[{"type":3,"textContent":"Something went wrong. Please try again.","id":104}],"id":103}],"id":84},{"type":3,"textContent":"\n","id":105},{"type":2,"tagName":"script","attributes":{},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":107}],"id":106},{"type":3,"textContent":"\n","id":108}],"id":13}],"id":3}],"id":1},"initialOffset":{"left":0,"top":0}},"timestamp":1790260328004},
11
13
  {"type":3,"data":{"source":1,"positions":[{"x":640,"y":300,"id":41,"timeOffset":0}]},"timestamp":1790260328010},
14
+ {"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/collections/trail","method":"GET","initiatorType":"navigation","status":200,"startTime":0,"endTime":186,"requestHeaders":{"accept":"*/*"},"responseHeaders":{"content-type":"text/html; charset=utf-8","cache-control":"max-age=31536000"}},{"url":"https://shop.northwind.test/assets/app-4f2a.js","method":"GET","initiatorType":"script","status":200,"startTime":120,"endTime":214,"requestHeaders":{"accept":"*/*"},"responseHeaders":{"content-type":"text/javascript","cache-control":"max-age=31536000"}},{"url":"https://shop.northwind.test/assets/app-4f2a.css","method":"GET","initiatorType":"link","status":200,"startTime":122,"endTime":183,"requestHeaders":{"accept":"*/*"},"responseHeaders":{"content-type":"text/css","cache-control":"max-age=31536000"}}]}},"timestamp":1790260328190},
15
+ {"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"log","trace":[],"payload":["\"[northwind] storefront ready\"","\"build 4.12.0\""]}},"timestamp":1790260328240},
16
+ {"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/products?collection=trail","method":"GET","initiatorType":"fetch","status":200,"startTime":300,"endTime":442,"requestHeaders":{"accept":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"responseBody":"{\"products\": [{\"sku\": \"TR2\", \"name\": \"Trail Runner 2\", \"price\": 148}, {\"sku\": \"SS1\", \"name\": \"Summit Shell\", \"price\": 229}, {\"sku\": \"RP28\", \"name\": \"Ridge Pack 28L\", \"price\": 119}, {\"sku\": \"MC1\", \"name\": \"Merino Crew\", \"price\": 68}]}"}]}},"timestamp":1790260328420},
12
17
  {"type":3,"data":{"source":1,"positions":[{"x":608,"y":302,"id":54,"timeOffset":-99},{"x":357,"y":319,"id":54,"timeOffset":-35}]},"timestamp":1790260328511},
13
18
  {"type":3,"data":{"source":1,"positions":[{"x":223,"y":330,"id":44,"timeOffset":0}]},"timestamp":1790260329021},
14
19
  {"type":3,"data":{"source":1,"positions":[{"x":410,"y":330,"id":54,"timeOffset":-438},{"x":454,"y":341,"id":54,"timeOffset":-109},{"x":247,"y":433,"id":50,"timeOffset":-46}]},"timestamp":1790260329522},
@@ -17,6 +22,8 @@ export const replayDemoEvents: ReplayEvent[] = [
17
22
  {"type":3,"data":{"source":2,"type":0,"id":50,"x":182.5,"y":436.5299987792969},"timestamp":1790260329770},
18
23
  {"type":3,"data":{"source":2,"type":2,"id":50,"x":182,"y":436,"pointerType":0},"timestamp":1790260329770},
19
24
  {"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":32,"id":33},{"parentId":91,"id":92}],"adds":[{"parentId":32,"nextId":null,"node":{"type":3,"textContent":"1","id":109}},{"parentId":87,"nextId":null,"node":{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[],"id":110}},{"parentId":110,"nextId":null,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":111}},{"parentId":111,"nextId":null,"node":{"type":3,"textContent":"$148","id":112}},{"parentId":91,"nextId":null,"node":{"type":3,"textContent":"$148","id":113}},{"parentId":110,"nextId":111,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":114}},{"parentId":114,"nextId":null,"node":{"type":3,"textContent":"Trail Runner 2","id":115}}]},"timestamp":1790260329771},
25
+ {"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/cart","method":"POST","initiatorType":"fetch","status":200,"startTime":2000,"endTime":2168,"requestHeaders":{"accept":"application/json","content-type":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"requestBody":"{\"sku\": \"TR2\", \"qty\": 1}","responseBody":"{\"items\": 1, \"total\": 148}"}]}},"timestamp":1790260329939},
26
+ {"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"info","trace":[],"payload":["\"cart: added\"","\"Trail Runner 2\""]}},"timestamp":1790260329941},
20
27
  {"type":3,"data":{"source":1,"positions":[{"x":182,"y":436,"id":50,"timeOffset":-253}]},"timestamp":1790260330023},
21
28
  {"type":5,"data":{"tag":"checkpoint","payload":{"label":"Added Trail Runner 2"}},"timestamp":1790260330279},
22
29
  {"type":3,"data":{"source":1,"positions":[{"x":223,"y":438,"id":50,"timeOffset":-243},{"x":512,"y":454,"id":60,"timeOffset":-181}]},"timestamp":1790260330524},
@@ -26,6 +33,8 @@ export const replayDemoEvents: ReplayEvent[] = [
26
33
  {"type":3,"data":{"source":2,"type":0,"id":70,"x":792.5,"y":436.5299987792969},"timestamp":1790260330663},
27
34
  {"type":3,"data":{"source":2,"type":2,"id":70,"x":792,"y":436,"pointerType":0},"timestamp":1790260330663},
28
35
  {"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":32,"id":109},{"parentId":87,"id":110},{"parentId":91,"id":113}],"adds":[{"parentId":32,"nextId":null,"node":{"type":3,"textContent":"2","id":116}},{"parentId":87,"nextId":null,"node":{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[],"id":117}},{"parentId":117,"nextId":null,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":118}},{"parentId":118,"nextId":null,"node":{"type":3,"textContent":"$119","id":119}},{"parentId":91,"nextId":null,"node":{"type":3,"textContent":"$267","id":120}},{"parentId":87,"nextId":117,"node":{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[],"id":121}},{"parentId":121,"nextId":null,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":122}},{"parentId":121,"nextId":122,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":123}},{"parentId":123,"nextId":null,"node":{"type":3,"textContent":"Trail Runner 2","id":124}},{"parentId":122,"nextId":null,"node":{"type":3,"textContent":"$148","id":125}},{"parentId":117,"nextId":118,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":126}},{"parentId":126,"nextId":null,"node":{"type":3,"textContent":"Ridge Pack 28L","id":127}}]},"timestamp":1790260330664},
36
+ {"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/cart","method":"POST","initiatorType":"fetch","status":200,"startTime":2900,"endTime":3052,"requestHeaders":{"accept":"application/json","content-type":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"requestBody":"{\"sku\": \"RP28\", \"qty\": 1}","responseBody":"{\"items\": 2, \"total\": 267}"}]}},"timestamp":1790260330816},
37
+ {"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"info","trace":[],"payload":["\"cart: added\"","\"Ridge Pack 28L\""]}},"timestamp":1790260330818},
29
38
  {"type":3,"data":{"source":1,"positions":[{"x":792,"y":436,"id":70,"timeOffset":-363}]},"timestamp":1790260331025},
30
39
  {"type":3,"data":{"source":1,"positions":[{"x":815,"y":414,"id":65,"timeOffset":-249},{"x":1003,"y":234,"id":74,"timeOffset":-183},{"x":1191,"y":54,"id":15,"timeOffset":-116}]},"timestamp":1790260331526},
31
40
  {"type":3,"data":{"source":2,"type":1,"id":30,"x":1211.1400146484375,"y":31.489999771118164},"timestamp":1790260331688},
@@ -50,6 +59,8 @@ export const replayDemoEvents: ReplayEvent[] = [
50
59
  {"type":3,"data":{"source":2,"type":0,"id":99,"x":1090.5,"y":321.2099914550781},"timestamp":1790260333914},
51
60
  {"type":3,"data":{"source":2,"type":2,"id":99,"x":1090,"y":321,"pointerType":0},"timestamp":1790260333914},
52
61
  {"type":3,"data":{"source":1,"positions":[{"x":1090,"y":321,"id":99,"timeOffset":-441}]},"timestamp":1790260334354},
62
+ {"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"warn","trace":["applyPromo (checkout.ts:71)","HTMLInputElement.onchange (checkout.ts:40)"],"payload":["\"[promo] `code` is deprecated; send `promoCode` instead\""]}},"timestamp":1790260334694},
63
+ {"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/promo/validate?code=FALL25","method":"GET","initiatorType":"fetch","status":200,"startTime":6800,"endTime":6918,"requestHeaders":{"accept":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"responseBody":"{\"valid\": true, \"discount\": 0.15}"}]}},"timestamp":1790260334834},
53
64
  {"type":3,"data":{"source":1,"positions":[{"x":1090,"y":330,"id":99,"timeOffset":-193},{"x":1090,"y":401,"id":101,"timeOffset":-128}]},"timestamp":1790260334855},
54
65
  {"type":3,"data":{"source":2,"type":1,"id":101,"x":1090.5,"y":380.5},"timestamp":1790260334954},
55
66
  {"type":3,"data":{"source":5,"text":"FALL25","isChecked":false,"id":99},"timestamp":1790260334954},
@@ -59,8 +70,8 @@ export const replayDemoEvents: ReplayEvent[] = [
59
70
  {"type":3,"data":{"source":2,"type":2,"id":101,"x":1090,"y":380,"pointerType":0},"timestamp":1790260334954},
60
71
  {"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":101,"id":102}],"adds":[{"parentId":101,"nextId":null,"node":{"type":3,"textContent":"Placing order…","id":128}}]},"timestamp":1790260334954},
61
72
  {"type":3,"data":{"source":1,"positions":[{"x":1090,"y":380,"id":101,"timeOffset":-402}]},"timestamp":1790260335356},
62
- {"type":5,"data":{"tag":"network","payload":{"method":"POST","status":500,"url":"/api/checkout","label":"POST /api/checkout 500"}},"timestamp":1790260335855},
63
- {"type":5,"data":{"tag":"error","payload":{"label":"TypeError","message":"Cannot read properties of undefined (reading 'total') at applyPromo (checkout.ts:88)"}},"timestamp":1790260335856},
73
+ {"type":5,"data":{"tag":"network","payload":{"method":"POST","status":500,"url":"/api/checkout","label":"POST /api/checkout 500","duration":412,"type":"fetch","requestHeaders":{"content-type":"application/json"},"requestBody":"{\"email\": \"sam@example.com\", \"promoCode\": null, \"code\": \"FALL25\", \"items\": [\"TR2\", \"RP28\"]}","responseHeaders":{"content-type":"application/json; charset=utf-8","x-request-id":"req_7Hq2c9"},"responseBody":"{\"error\": \"internal_error\", \"message\": \"Cannot read properties of undefined (reading 'total')\"}"}},"timestamp":1790260335855},
74
+ {"type":5,"data":{"tag":"error","payload":{"label":"TypeError","message":"Cannot read properties of undefined (reading 'total') at applyPromo (checkout.ts:88)","stack":["at applyPromo (checkout.ts:88:19)","at placeOrder (checkout.ts:132:5)","at HTMLButtonElement.onclick (checkout.ts:44:3)"]}},"timestamp":1790260335856},
64
75
  {"type":3,"data":{"source":0,"texts":[],"attributes":[{"id":103,"attributes":{"class":"err show"}}],"removes":[{"parentId":101,"id":128}],"adds":[{"parentId":101,"nextId":null,"node":{"type":3,"textContent":"Place order","id":129}}]},"timestamp":1790260335856},
65
76
  {"type":3,"data":{"source":1,"positions":[{"x":1085,"y":388,"id":101,"timeOffset":0}]},"timestamp":1790260336357},
66
77
  {"type":3,"data":{"source":1,"positions":[{"x":1050,"y":444,"id":103,"timeOffset":-441}]},"timestamp":1790260336859},
@@ -0,0 +1,251 @@
1
+ /* What a browser's DevTools would have shown during a recording: its console messages and its network requests,
2
+ read from the event list with no rrweb import. Two kinds of event carry them:
3
+
4
+ - rrweb's plugins: `rrweb/console@1` (level, the logged values as JSON strings, a stack trace) and
5
+ `rrweb/network@1` (`{ requests: [...] }`, each with url, method, status, startTime/endTime, headers, bodies).
6
+ - Custom events (`record.addCustomEvent(tag, payload)`): tag `network` (or `request`, `fetch`, `xhr`,
7
+ `network.error`, `request.failed`, `fetch.failed`) with `{ method, url, status, … }`; tag `error` (or
8
+ `exception`, `console.error`, `uncaught`) with `{ label, message }`; tag `console` (or `log`, `warn`, `info`,
9
+ `debug`) with `{ level, message }`.
10
+
11
+ `replayDevtoolsAt` cuts both lists at a playhead: what had been logged, which requests had been sent and which
12
+ of those had answered by then. */
13
+ import { ReplayEventType, type ReplayEvent } from './types'
14
+
15
+ export type ReplayConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
16
+
17
+ export interface ReplayConsoleEntry {
18
+ /** Stable id (`console-<n>`). */
19
+ id: string
20
+ /** Milliseconds from the start of the recording. */
21
+ time: number
22
+ level: ReplayConsoleLevel
23
+ /** The logged values as text, joined with spaces. */
24
+ message: string
25
+ /** Each logged value as text. */
26
+ args: string[]
27
+ /** Stack frames, when the recording kept them. */
28
+ trace?: string[]
29
+ /** `console` (rrweb's console plugin), `exception` (an error event) or `custom` (a custom console event). */
30
+ source: 'console' | 'exception' | 'custom'
31
+ }
32
+
33
+ export interface ReplayRequest {
34
+ /** Stable id (`request-<n>`). */
35
+ id: string
36
+ /** When the request was sent, in ms from the start of the recording. */
37
+ time: number
38
+ /** How long it took, when known: the response arrived at `time + duration`. */
39
+ duration?: number
40
+ method: string
41
+ url: string
42
+ /** The last path segment (and query), as DevTools' Name column shows it. */
43
+ name: string
44
+ /** HTTP status; 0 when the request failed without one. Missing when the recording doesn't say. */
45
+ status?: number
46
+ statusText?: string
47
+ /** `fetch`, `xhr`, `script`, `img`, … (the resource's initiator type). */
48
+ type: string
49
+ /** No response, a status of 0, or a status of 400 and up. */
50
+ failed: boolean
51
+ error?: string
52
+ requestHeaders?: Record<string, string>
53
+ responseHeaders?: Record<string, string>
54
+ requestBody?: string
55
+ responseBody?: string
56
+ }
57
+
58
+ export interface ReplayDevtools {
59
+ console: ReplayConsoleEntry[]
60
+ network: ReplayRequest[]
61
+ }
62
+
63
+ export interface ReplayDevtoolsState {
64
+ /** Messages logged at or before the playhead. */
65
+ console: ReplayConsoleEntry[]
66
+ /** Requests sent at or before the playhead, with `pending` true for those still waiting on a response. */
67
+ network: (ReplayRequest & { pending: boolean })[]
68
+ counts: {
69
+ requests: number
70
+ /** Requests that had failed by the playhead (a pending request hasn't failed yet). */
71
+ failed: number
72
+ errors: number
73
+ warnings: number
74
+ /** Messages and requests later in the recording. */
75
+ laterMessages: number
76
+ laterRequests: number
77
+ }
78
+ }
79
+
80
+ const ERROR_TAGS = new Set(['error', 'exception', 'console.error', 'uncaught'])
81
+ const REQUEST_TAGS = new Set(['network', 'request', 'fetch', 'xhr', 'network.error', 'request.failed', 'fetch.failed'])
82
+ const FAILED_TAGS = new Set(['network.error', 'request.failed', 'fetch.failed'])
83
+ const CONSOLE_TAGS = new Set(['console', 'log', 'warn', 'warning', 'info', 'debug'])
84
+
85
+ function record(value: unknown): Record<string, unknown> {
86
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
87
+ }
88
+
89
+ const str = (value: unknown): string | undefined =>
90
+ typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : undefined
91
+
92
+ const num = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
93
+
94
+ /** A body or a logged value as text: strings as they are, anything else as compact JSON. */
95
+ function asText(value: unknown): string | undefined {
96
+ if (value === undefined || value === null) return undefined
97
+ if (typeof value === 'string') return value
98
+ try { return JSON.stringify(value) } catch { return String(value) }
99
+ }
100
+
101
+ /** rrweb's console plugin stores each value JSON-stringified: a string comes back as itself, the rest as JSON. */
102
+ function consoleArg(raw: unknown): string {
103
+ if (typeof raw !== 'string') return asText(raw) ?? String(raw)
104
+ try {
105
+ const value: unknown = JSON.parse(raw)
106
+ return typeof value === 'string' ? value : raw
107
+ } catch {
108
+ return raw
109
+ }
110
+ }
111
+
112
+ function headers(value: unknown): Record<string, string> | undefined {
113
+ if (!value || typeof value !== 'object') return undefined
114
+ const out: Record<string, string> = {}
115
+ if (Array.isArray(value)) {
116
+ for (const pair of value) if (Array.isArray(pair) && pair.length >= 2) out[String(pair[0])] = String(pair[1])
117
+ } else for (const [k, v] of Object.entries(value)) if (v !== undefined && v !== null) out[k] = String(v)
118
+ return Object.keys(out).length ? out : undefined
119
+ }
120
+
121
+ /** DevTools' Name column: the last path segment plus the query, or the host for a bare origin. */
122
+ export function requestName(url: string): string {
123
+ const clean = url.replace(/#.*$/, '')
124
+ const [path, query] = clean.split('?', 2) as [string, string | undefined]
125
+ const segment = path.replace(/\/+$/, '').split('/').pop() || path.replace(/^\w+:\/\//, '') || '/'
126
+ return query !== undefined ? `${segment}?${query}` : segment
127
+ }
128
+
129
+ const levelOf = (value: unknown): ReplayConsoleLevel => {
130
+ const level = str(value)?.toLowerCase()
131
+ if (level === 'warn' || level === 'warning') return 'warn'
132
+ if (level === 'error' || level === 'assert') return 'error'
133
+ if (level === 'info') return 'info'
134
+ if (level === 'debug' || level === 'trace') return 'debug'
135
+ return 'log'
136
+ }
137
+
138
+ /** Every console message and network request in the recording, in time order. */
139
+ export function getReplayDevtools(events: readonly ReplayEvent[]): ReplayDevtools {
140
+ const list = [...events].sort((a, b) => a.timestamp - b.timestamp)
141
+ const start = list[0]?.timestamp ?? 0
142
+ const consoleEntries: Omit<ReplayConsoleEntry, 'id'>[] = []
143
+ const requests: Omit<ReplayRequest, 'id'>[] = []
144
+
145
+ const addRequest = (raw: Record<string, unknown>, at: number, tagFailed = false, batchEnd?: number) => {
146
+ const url = str(raw.url) ?? str(raw.name) ?? ''
147
+ const status = num(raw.status) ?? num(raw.responseStatus) ?? num(raw.statusCode)
148
+ const startTime = num(raw.startTime)
149
+ const endTime = num(raw.endTime) ?? num(raw.responseEnd)
150
+ const duration = num(raw.duration) ?? (startTime !== undefined && endTime !== undefined ? Math.max(0, endTime - startTime) : undefined)
151
+ const error = str(raw.error) ?? (raw.error ? 'Failed' : undefined)
152
+ // The plugin emits requests once they have finished: the batch's last answer is the event's time, and each
153
+ // request was sent that far before it (its own duration, for a lone request).
154
+ const sent = Math.max(0, startTime !== undefined && batchEnd !== undefined ? at - (batchEnd - startTime) : at - (duration ?? 0))
155
+ requests.push({
156
+ time: sent,
157
+ duration,
158
+ method: (str(raw.method) ?? 'GET').toUpperCase(),
159
+ url,
160
+ name: requestName(url),
161
+ status,
162
+ statusText: str(raw.statusText),
163
+ type: (str(raw.initiatorType) ?? str(raw.type) ?? 'fetch').replace(/^navigation$/, 'document'),
164
+ failed: tagFailed || !!error || status === 0 || (status !== undefined && status >= 400),
165
+ error,
166
+ requestHeaders: headers(raw.requestHeaders),
167
+ responseHeaders: headers(raw.responseHeaders),
168
+ requestBody: asText(raw.requestBody ?? raw.body),
169
+ responseBody: asText(raw.responseBody ?? raw.response),
170
+ })
171
+ }
172
+
173
+ for (const event of list) {
174
+ const time = event.timestamp - start
175
+ const data = record(event.data)
176
+ if (event.type === ReplayEventType.Plugin) {
177
+ const payload = record(data.payload)
178
+ if (data.plugin === 'rrweb/console@1') {
179
+ const args = Array.isArray(payload.payload) ? payload.payload.map(consoleArg) : []
180
+ const trace = Array.isArray(payload.trace) ? payload.trace.map(String).filter(Boolean) : undefined
181
+ consoleEntries.push({ time, level: levelOf(payload.level), message: args.join(' '), args, trace: trace?.length ? trace : undefined, source: 'console' })
182
+ } else if (data.plugin === 'rrweb/network@1' && Array.isArray(payload.requests)) {
183
+ const batch = payload.requests.map(record)
184
+ let batchEnd: number | undefined
185
+ for (const r of batch) {
186
+ const end = num(r.endTime) ?? num(r.responseEnd)
187
+ if (end !== undefined) batchEnd = Math.max(batchEnd ?? end, end)
188
+ }
189
+ for (const r of batch) addRequest(r, time, false, batchEnd)
190
+ }
191
+ } else if (event.type === ReplayEventType.Custom) {
192
+ const tag = str(data.tag) ?? ''
193
+ const payload = record(data.payload)
194
+ if (REQUEST_TAGS.has(tag)) addRequest(payload, time, FAILED_TAGS.has(tag))
195
+ else if (ERROR_TAGS.has(tag)) {
196
+ const label = str(payload.label) ?? str(payload.name) ?? 'Error'
197
+ const detail = str(payload.message) ?? str(payload.detail)
198
+ const message = detail ? `Uncaught ${label}: ${detail}` : `Uncaught ${label}`
199
+ const trace = Array.isArray(payload.stack) ? payload.stack.map(String) : str(payload.stack)?.split('\n').map((l) => l.trim()).filter(Boolean)
200
+ consoleEntries.push({ time, level: 'error', message, args: [message], trace: trace?.length ? trace : undefined, source: 'exception' })
201
+ } else if (CONSOLE_TAGS.has(tag)) {
202
+ const args = Array.isArray(payload.args) ? payload.args.map((a) => asText(a) ?? '') : [str(payload.message) ?? str(payload.label) ?? asText(payload.detail) ?? '']
203
+ consoleEntries.push({ time, level: levelOf(payload.level ?? tag), message: args.join(' '), args, source: 'custom' })
204
+ }
205
+ }
206
+ }
207
+
208
+ requests.sort((a, b) => a.time - b.time)
209
+ return {
210
+ console: consoleEntries.map((e, i) => ({ ...e, id: `console-${i}` })),
211
+ network: requests.map((r, i) => ({ ...r, id: `request-${i}` })),
212
+ }
213
+ }
214
+
215
+ /** How many items of a time-sorted list are at or before `time`. */
216
+ function countUpTo(list: readonly { time: number }[], time: number) {
217
+ let lo = 0
218
+ let hi = list.length
219
+ while (lo < hi) {
220
+ const mid = (lo + hi) >> 1
221
+ if (list[mid]!.time <= time) lo = mid + 1
222
+ else hi = mid
223
+ }
224
+ return lo
225
+ }
226
+
227
+ /** The console and network as they stood at `time` (ms from the start). */
228
+ export function replayDevtoolsAt(devtools: ReplayDevtools, time: number): ReplayDevtoolsState {
229
+ const logged = devtools.console.slice(0, countUpTo(devtools.console, time))
230
+ const network = devtools.network.slice(0, countUpTo(devtools.network, time)).map((r) => ({
231
+ ...r, pending: r.duration !== undefined && r.time + r.duration > time,
232
+ }))
233
+ let errors = 0
234
+ let warnings = 0
235
+ for (const e of logged) {
236
+ if (e.level === 'error') errors++
237
+ else if (e.level === 'warn') warnings++
238
+ }
239
+ return {
240
+ console: logged,
241
+ network,
242
+ counts: {
243
+ requests: network.length,
244
+ failed: network.filter((r) => r.failed && !r.pending).length,
245
+ errors,
246
+ warnings,
247
+ laterMessages: devtools.console.length - logged.length,
248
+ laterRequests: devtools.network.length - network.length,
249
+ },
250
+ }
251
+ }
@@ -15,6 +15,9 @@ export { ReplayStage, preloadReplayStage } from './ReplayStage';
15
15
  export type { ReplayStageHandle, ReplayStageLayout, ReplayStageProps } from './stage-types';
16
16
  export { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers';
17
17
  export type { ReplayMarker, ReplayMarkerKind, ReplayMeta, ReplayPointerPoint, ReplayPointerTrack } from './markers';
18
+ export { getReplayDevtools, replayDevtoolsAt, requestName } from './devtools';
19
+ export type { ReplayConsoleEntry, ReplayConsoleLevel, ReplayDevtools, ReplayDevtoolsState, ReplayRequest } from './devtools';
20
+ export type { ReplayDevtoolsTab } from './ReplayDevtools';
18
21
  export { ReplayEventType, ReplayIncrementalSource, ReplayMouseInteraction } from './types';
19
22
  export type { ReplayEvent } from './types';
20
23
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from './url';