@brett_lamy/docstream 1.21.0 → 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.
- package/dist/replay/ReplayDevtools.d.ts +18 -0
- package/dist/replay/ReplayDevtools.d.ts.map +1 -0
- package/dist/replay/ReplayDevtools.js +137 -0
- package/dist/replay/ReplayDevtools.js.map +1 -0
- package/dist/replay/ReplayPlayer.d.ts +10 -2
- package/dist/replay/ReplayPlayer.d.ts.map +1 -1
- package/dist/replay/ReplayPlayer.js +16 -6
- package/dist/replay/ReplayPlayer.js.map +1 -1
- package/dist/replay/demo/index.d.ts.map +1 -1
- package/dist/replay/demo/index.js +15 -4
- package/dist/replay/demo/index.js.map +1 -1
- package/dist/replay/devtools.d.ts +70 -0
- package/dist/replay/devtools.d.ts.map +1 -0
- package/dist/replay/devtools.js +201 -0
- package/dist/replay/devtools.js.map +1 -0
- package/dist/replay/index.d.ts +3 -0
- package/dist/replay/index.d.ts.map +1 -1
- package/dist/replay/index.js +1 -0
- package/dist/replay/index.js.map +1 -1
- package/dist/styles.css +547 -0
- package/package.json +1 -1
- package/src/replay/ReplayDevtools.tsx +348 -0
- package/src/replay/ReplayPlayer.tsx +36 -6
- package/src/replay/demo/index.ts +15 -4
- package/src/replay/devtools.ts +251 -0
- package/src/replay/index.ts +3 -0
- package/src/styles.css +547 -0
|
@@ -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
|
+
}
|
package/src/replay/index.ts
CHANGED
|
@@ -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';
|