@brett_lamy/docstream 1.24.1 → 1.26.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/docs/DocsRenderer.js +1 -1
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/gitbook/ast.d.ts +4 -0
- package/dist/gitbook/ast.d.ts.map +1 -1
- package/dist/gitbook/ast.js.map +1 -1
- package/dist/gitbook/parse.js +2 -0
- package/dist/gitbook/parse.js.map +1 -1
- package/dist/gitbook/serialize.d.ts.map +1 -1
- package/dist/gitbook/serialize.js +2 -0
- package/dist/gitbook/serialize.js.map +1 -1
- package/dist/replay/ReplayFile.d.ts +6 -2
- package/dist/replay/ReplayFile.d.ts.map +1 -1
- package/dist/replay/ReplayFile.js +20 -3
- package/dist/replay/ReplayFile.js.map +1 -1
- package/dist/replay/ReplayPlayer.d.ts +9 -3
- package/dist/replay/ReplayPlayer.d.ts.map +1 -1
- package/dist/replay/ReplayPlayer.js +5 -2
- package/dist/replay/ReplayPlayer.js.map +1 -1
- package/dist/replay/index.d.ts +1 -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/replay/markers.d.ts.map +1 -1
- package/dist/replay/markers.js +13 -3
- package/dist/replay/markers.js.map +1 -1
- package/dist/replay/sources.d.ts +11 -0
- package/dist/replay/sources.d.ts.map +1 -0
- package/dist/replay/sources.js +126 -0
- package/dist/replay/sources.js.map +1 -0
- package/package.json +1 -1
- package/src/docs/DocsRenderer.tsx +1 -1
- package/src/gitbook/ast.ts +4 -0
- package/src/gitbook/parse.ts +2 -0
- package/src/gitbook/serialize.ts +2 -0
- package/src/replay/ReplayFile.tsx +35 -4
- package/src/replay/ReplayPlayer.tsx +14 -5
- package/src/replay/index.ts +1 -0
- package/src/replay/markers.ts +10 -3
- package/src/replay/sources.ts +120 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* More sources for a recording's DevTools, beside the rrweb events: a HAR (the network log browsers export) and a
|
|
2
|
+
console log (one message per line, `2026-06-12T21:01:38.123Z [warn] text`; indented lines continue the message
|
|
3
|
+
above). Both are read into the custom events devtools.ts already understands, stamped on their own absolute
|
|
4
|
+
times, so they line up with the recording when they share its clock. Anything stamped before the recording
|
|
5
|
+
starts or after it ends lands on its first or last moment. Pure readers, no rrweb import.
|
|
6
|
+
|
|
7
|
+
A HAR can also carry inspections (inspections.ts) as an extension: `log._inspections`, each
|
|
8
|
+
`{ at: ISO time, request?: entry _requestId or URL, message?: text, tab?, label? }`. */
|
|
9
|
+
import { ReplayEventType } from './types.js';
|
|
10
|
+
const obj = (v) => (v && typeof v === 'object' ? v : {});
|
|
11
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : undefined);
|
|
12
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
13
|
+
const time = (v) => {
|
|
14
|
+
const t = typeof v === 'string' ? Date.parse(v) : num(v);
|
|
15
|
+
return t !== undefined && Number.isFinite(t) ? t : undefined;
|
|
16
|
+
};
|
|
17
|
+
const custom = (timestamp, tag, payload) => ({
|
|
18
|
+
type: ReplayEventType.Custom,
|
|
19
|
+
timestamp,
|
|
20
|
+
data: { tag, payload },
|
|
21
|
+
});
|
|
22
|
+
/** HAR headers (`[{ name, value }]`) as a record. */
|
|
23
|
+
function harHeaders(list) {
|
|
24
|
+
if (!Array.isArray(list))
|
|
25
|
+
return undefined;
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const h of list) {
|
|
28
|
+
const name = str(obj(h).name);
|
|
29
|
+
if (name)
|
|
30
|
+
out[name] = String(obj(h).value ?? '');
|
|
31
|
+
}
|
|
32
|
+
return Object.keys(out).length ? out : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** A HAR body: as written, or decoded when base64 and textual. */
|
|
35
|
+
function harText(content) {
|
|
36
|
+
const text = typeof content.text === 'string' ? content.text : undefined;
|
|
37
|
+
if (text === undefined || content.encoding !== 'base64')
|
|
38
|
+
return text;
|
|
39
|
+
try {
|
|
40
|
+
const bytes = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
41
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
42
|
+
return decoded;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return `[${Math.round((text.length * 3) / 4)} bytes of binary data]`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A HAR (object or JSON text) as `network` (and `inspect`) custom events. */
|
|
49
|
+
export function harToReplayEvents(har) {
|
|
50
|
+
let data = har;
|
|
51
|
+
if (typeof har === 'string') {
|
|
52
|
+
try {
|
|
53
|
+
data = JSON.parse(har);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const log = obj(obj(data).log);
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const raw of Array.isArray(log.entries) ? log.entries : []) {
|
|
62
|
+
const e = obj(raw);
|
|
63
|
+
const started = time(e.startedDateTime);
|
|
64
|
+
if (started === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
const request = obj(e.request);
|
|
67
|
+
const response = obj(e.response);
|
|
68
|
+
const duration = Math.max(0, num(e.time) ?? 0);
|
|
69
|
+
const status = num(response.status);
|
|
70
|
+
const error = str(e._error) ?? str(response._error);
|
|
71
|
+
out.push(custom(started + duration, error ? 'network.error' : 'network', {
|
|
72
|
+
requestId: str(e._requestId),
|
|
73
|
+
method: str(request.method) ?? 'GET',
|
|
74
|
+
url: str(request.url) ?? '',
|
|
75
|
+
status: status === -1 ? undefined : status,
|
|
76
|
+
statusText: str(response.statusText),
|
|
77
|
+
error,
|
|
78
|
+
duration,
|
|
79
|
+
type: str(e._resourceType) ?? 'fetch',
|
|
80
|
+
requestHeaders: harHeaders(request.headers),
|
|
81
|
+
responseHeaders: harHeaders(response.headers),
|
|
82
|
+
requestBody: str(obj(request.postData).text),
|
|
83
|
+
responseBody: harText(obj(response.content)),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
for (const raw of Array.isArray(log._inspections) ? log._inspections : []) {
|
|
87
|
+
const i = obj(raw);
|
|
88
|
+
const at = time(i.at);
|
|
89
|
+
if (at === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
out.push(custom(at, 'inspect', { request: str(i.request), message: str(i.message), tab: str(i.tab), label: str(i.label), id: str(i.id) }));
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
const LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))\s+\[(\w+)\]\s?(.*)$/;
|
|
96
|
+
/** A console log (`<ISO time> [level] message`, indented continuation lines) as `console` custom events. */
|
|
97
|
+
export function consoleLogToReplayEvents(text) {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const line of text.split(/\r?\n/)) {
|
|
100
|
+
const m = line.match(LINE);
|
|
101
|
+
const at = m ? time(m[1]) : undefined;
|
|
102
|
+
if (m && at !== undefined)
|
|
103
|
+
out.push({ at, level: m[2].toLowerCase(), lines: [m[3] ?? ''] });
|
|
104
|
+
else if (out.length && /^\s/.test(line))
|
|
105
|
+
out[out.length - 1].lines.push(line.replace(/^ {2}/, ''));
|
|
106
|
+
}
|
|
107
|
+
return out.map((m) => custom(m.at, 'console', { level: m.level === 'warning' ? 'warn' : m.level, message: m.lines.join('\n').trimEnd() }));
|
|
108
|
+
}
|
|
109
|
+
/** The recording's events with the HAR's and the console log's added, each stamped inside the recording. */
|
|
110
|
+
export function mergeReplaySources(events, sources) {
|
|
111
|
+
const extra = [
|
|
112
|
+
...(sources.har ? harToReplayEvents(sources.har) : []),
|
|
113
|
+
...(sources.consoleLog ? consoleLogToReplayEvents(sources.consoleLog) : []),
|
|
114
|
+
];
|
|
115
|
+
if (!extra.length || !events.length)
|
|
116
|
+
return events;
|
|
117
|
+
let first = Infinity;
|
|
118
|
+
let last = -Infinity;
|
|
119
|
+
for (const e of events) {
|
|
120
|
+
first = Math.min(first, e.timestamp);
|
|
121
|
+
last = Math.max(last, e.timestamp);
|
|
122
|
+
}
|
|
123
|
+
const clamped = extra.map((e) => (e.timestamp < first || e.timestamp > last ? { ...e, timestamp: Math.min(last, Math.max(first, e.timestamp)) } : e));
|
|
124
|
+
return [...events, ...clamped].sort((a, b) => a.timestamp - b.timestamp);
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=sources.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sources.js","sourceRoot":"","sources":["../../src/replay/sources.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -451,7 +451,7 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
|
|
|
451
451
|
return <ReplayPreview source={block.url} title="Replay preview" />
|
|
452
452
|
}
|
|
453
453
|
if (isRrwebUrl(block.url)) {
|
|
454
|
-
return <ReplayFile url={block.url} title={block.title || fileName(block.url)} />
|
|
454
|
+
return <ReplayFile url={block.url} title={block.title || fileName(block.url)} {...(block.har ? { har: block.har } : {})} {...(block.log ? { log: block.log } : {})} />
|
|
455
455
|
}
|
|
456
456
|
if (isStlUrl(block.url)) {
|
|
457
457
|
return <AssetStl url={block.url} {...(block.title ? { title: block.title } : {})} />
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -302,6 +302,10 @@ export interface EmbedNode extends BlockSpacing {
|
|
|
302
302
|
muted?: boolean
|
|
303
303
|
controls?: boolean
|
|
304
304
|
poster?: string
|
|
305
|
+
/** For an `.rrweb` recording: a HAR and a console log recorded alongside, whose requests and messages join
|
|
306
|
+
* the player's DevTools. */
|
|
307
|
+
har?: string
|
|
308
|
+
log?: string
|
|
305
309
|
/** The tag as written (with its `{% endembed %}`), reused while it still says the same. */
|
|
306
310
|
raw?: string
|
|
307
311
|
}
|
package/src/gitbook/parse.ts
CHANGED
|
@@ -634,6 +634,8 @@ function parseBody(lines: string[], firstGap = 0): { blocks: Block[]; end: numbe
|
|
|
634
634
|
url: tag.attrs.url ?? "",
|
|
635
635
|
...(tag.attrs.title ? { title: tag.attrs.title } : {}),
|
|
636
636
|
...(tag.attrs.poster ? { poster: tag.attrs.poster } : {}),
|
|
637
|
+
...(tag.attrs.har ? { har: tag.attrs.har } : {}),
|
|
638
|
+
...(tag.attrs.log ? { log: tag.attrs.log } : {}),
|
|
637
639
|
...(booleanAttr(tag.attrs, "autoplay") === undefined
|
|
638
640
|
? {}
|
|
639
641
|
: { autoplay: booleanAttr(tag.attrs, "autoplay") }),
|
package/src/gitbook/serialize.ts
CHANGED
|
@@ -389,6 +389,8 @@ function serializeBlock(b: Block): string {
|
|
|
389
389
|
attr("url", b.url),
|
|
390
390
|
b.title ? attr("title", b.title) : "",
|
|
391
391
|
b.poster ? attr("poster", b.poster) : "",
|
|
392
|
+
b.har ? attr("har", b.har) : "",
|
|
393
|
+
b.log ? attr("log", b.log) : "",
|
|
392
394
|
b.autoplay === undefined ? "" : attr("autoplay", b.autoplay),
|
|
393
395
|
b.loop === undefined ? "" : attr("loop", b.loop),
|
|
394
396
|
b.muted === undefined ? "" : attr("muted", b.muted),
|
|
@@ -3,9 +3,28 @@ import { useAsset } from '../asset-resolver';
|
|
|
3
3
|
import { ReplayPreview } from './ReplayPreview';
|
|
4
4
|
import type { ReplayPlayerOptions } from './ReplayPlayer';
|
|
5
5
|
|
|
6
|
-
export interface ReplayFileProps extends Omit<ReplayPlayerOptions, 'url'> {
|
|
6
|
+
export interface ReplayFileProps extends Omit<ReplayPlayerOptions, 'url' | 'har' | 'consoleLog'> {
|
|
7
7
|
/** The `.rrweb` file as written in the document; the host's asset resolver maps it to a loadable URL. */
|
|
8
8
|
url: string;
|
|
9
|
+
/** A `.har` file recorded alongside (resolved the same way): its requests join DevTools' Network list. */
|
|
10
|
+
har?: string;
|
|
11
|
+
/** A console log recorded alongside (`<ISO time> [level] message` lines): its messages join the Console. */
|
|
12
|
+
log?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The text of a file the asset resolver maps, fetched once `enabled`; undefined until then or when it fails. */
|
|
16
|
+
function useAssetText(url: string | undefined, enabled: boolean): { text?: string; done: boolean } {
|
|
17
|
+
const src = useAsset(url ?? '');
|
|
18
|
+
const [state, setState] = useState<{ text?: string; done: boolean }>({ done: !url });
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!url || !enabled || src === undefined) return;
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
fetch(src, { signal: controller.signal })
|
|
23
|
+
.then((r) => (r.ok ? r.text() : undefined))
|
|
24
|
+
.then((text) => setState({ text, done: true }), () => !controller.signal.aborted && setState({ done: true }));
|
|
25
|
+
return () => controller.abort();
|
|
26
|
+
}, [url, src, enabled]);
|
|
27
|
+
return url ? state : { done: true };
|
|
9
28
|
}
|
|
10
29
|
|
|
11
30
|
/**
|
|
@@ -14,7 +33,7 @@ export interface ReplayFileProps extends Omit<ReplayPlayerOptions, 'url'> {
|
|
|
14
33
|
* viewport, so a page of recordings doesn't download them all up front. A media fragment on the URL,
|
|
15
34
|
* `recording.rrweb#t=4.7,7.4` (seconds), is its `timeWindow`.
|
|
16
35
|
*/
|
|
17
|
-
export function ReplayFile({ url, ...options }: ReplayFileProps) {
|
|
36
|
+
export function ReplayFile({ url, har, log, ...options }: ReplayFileProps) {
|
|
18
37
|
const { file, segment } = splitReplayFragment(url);
|
|
19
38
|
const src = useAsset(file);
|
|
20
39
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -29,8 +48,20 @@ export function ReplayFile({ url, ...options }: ReplayFileProps) {
|
|
|
29
48
|
return () => io.disconnect();
|
|
30
49
|
}, [near]);
|
|
31
50
|
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
const harFile = useAssetText(har, near);
|
|
52
|
+
const logFile = useAssetText(log, near);
|
|
53
|
+
|
|
54
|
+
if (!near || src === undefined || !harFile.done || !logFile.done) return <div ref={ref} className="docstream-asset-pending" data-slot="replay-file" aria-busy="true" />;
|
|
55
|
+
return (
|
|
56
|
+
<ReplayPreview
|
|
57
|
+
source={{ kind: 'events-url', url: src }}
|
|
58
|
+
chrome="browser"
|
|
59
|
+
timeWindow={segment}
|
|
60
|
+
har={harFile.text}
|
|
61
|
+
consoleLog={logFile.text}
|
|
62
|
+
{...options}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
34
65
|
}
|
|
35
66
|
|
|
36
67
|
/** Splits `#t=start,end` (a media fragment, in seconds; `#t=start` runs to the end) off a recording's URL. */
|
|
@@ -21,6 +21,7 @@ import { ReplayStage } from './ReplayStage'
|
|
|
21
21
|
import { DevtoolsButtons, DevtoolsPanel, type ReplayDevtoolsTab } from './ReplayDevtools'
|
|
22
22
|
import { getReplayDevtools, replayDevtoolsAt } from './devtools'
|
|
23
23
|
import { getReplayInspections, replayInspectionAt, resolveReplayInspections, type ReplayInspection } from './inspections'
|
|
24
|
+
import { mergeReplaySources } from './sources'
|
|
24
25
|
import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
|
|
25
26
|
import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
|
|
26
27
|
import type { ReplayStageHandle } from './stage-types'
|
|
@@ -67,8 +68,9 @@ export interface ReplayPlayerOptions {
|
|
|
67
68
|
timeWindow?: readonly [number, number]
|
|
68
69
|
/** Speeds the speed button cycles through; the first is the initial speed (default 1, 2, 4, 0.5). */
|
|
69
70
|
speeds?: number[]
|
|
70
|
-
/** Markers on the scrubber
|
|
71
|
-
* or pass your
|
|
71
|
+
/** Markers on a rail above the scrubber: off by default (a plain seek bar). `true` reads them from the
|
|
72
|
+
* recording (clicks, failed requests, console errors, navigations, checkpoints, inspections), or pass your
|
|
73
|
+
* own. Giving `markerKinds` turns the recorded ones on. */
|
|
72
74
|
markers?: boolean | ReplayMarker[]
|
|
73
75
|
/** Which recorded marker kinds to show (default all). */
|
|
74
76
|
markerKinds?: ReplayMarkerKind[]
|
|
@@ -84,6 +86,11 @@ export interface ReplayPlayerOptions {
|
|
|
84
86
|
* tab with that request or message selected and the label above it; playback doesn't stop. Added to the
|
|
85
87
|
* recording's own `inspect` custom events, and to the rail as checkpoints. */
|
|
86
88
|
inspections?: ReplayInspection[]
|
|
89
|
+
/** A HAR (object or JSON text) recorded alongside: its requests join the DevTools Network list, on their own
|
|
90
|
+
* timestamps (sources.ts). Its `log._inspections` join `inspections`. */
|
|
91
|
+
har?: unknown
|
|
92
|
+
/** A console log recorded alongside (`<ISO time> [level] message` lines): its messages join the Console. */
|
|
93
|
+
consoleLog?: string
|
|
87
94
|
/** Draw the recorded pointer and click ripples (default true). */
|
|
88
95
|
cursor?: boolean
|
|
89
96
|
/** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
|
|
@@ -112,11 +119,13 @@ const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
|
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
export function ReplayPlayer({
|
|
115
|
-
events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
|
|
116
|
-
autoplay = false, loop = false, initialTime: initialTimeProp, timeWindow, speeds = DEFAULT_PLAYER_SPEEDS, markers
|
|
122
|
+
events: recordingEvents, har, consoleLog, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
|
|
123
|
+
autoplay = false, loop = false, initialTime: initialTimeProp, timeWindow, speeds = DEFAULT_PLAYER_SPEEDS, markers, markerKinds,
|
|
117
124
|
cursor = true, disableDevtools = false, devtools = true, defaultDevtoolsTab, inspections, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
|
|
118
125
|
className, style,
|
|
119
126
|
}: ReplayPlayerOptions & { events: ReplayEvent[] }) {
|
|
127
|
+
// The recording plus whatever was recorded beside it (a HAR, a console log), as one event list.
|
|
128
|
+
const events = useMemo(() => mergeReplaySources(recordingEvents, { har, consoleLog }), [recordingEvents, har, consoleLog])
|
|
120
129
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
121
130
|
const stage = useRef<ReplayStageHandle>(null)
|
|
122
131
|
const reduced = useReducedMotion()
|
|
@@ -140,7 +149,7 @@ export function ReplayPlayer({
|
|
|
140
149
|
const own: ReplayMarker[] = toolsOn ? (inspections ?? []).map((inspection, i) => ({
|
|
141
150
|
id: `inspection-${i}`, kind: 'custom', time: Math.max(0, inspection.time), label: inspection.label ?? 'Inspected',
|
|
142
151
|
})) : []
|
|
143
|
-
const list = Array.isArray(markers) ? markers : markers ? [...recorded, ...own].sort((a, b) => a.time - b.time) : []
|
|
152
|
+
const list = Array.isArray(markers) ? markers : markers ?? !!markerKinds ? [...recorded, ...own].sort((a, b) => a.time - b.time) : []
|
|
144
153
|
return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
|
|
145
154
|
}, [markers, recorded, markerKinds, inspections, toolsOn])
|
|
146
155
|
|
package/src/replay/index.ts
CHANGED
|
@@ -26,3 +26,4 @@ export { isReplayQaUrl, normalizeReplayEmbedUrl } from './url';
|
|
|
26
26
|
export { ReplayFile, isRrwebUrl, splitReplayFragment } from './ReplayFile';
|
|
27
27
|
export type { ReplayFileProps } from './ReplayFile';
|
|
28
28
|
export { sanitizeReplayEvent } from './sanitize';
|
|
29
|
+
export { consoleLogToReplayEvents, harToReplayEvents, mergeReplaySources } from './sources';
|
package/src/replay/markers.ts
CHANGED
|
@@ -91,6 +91,9 @@ export function getReplayMeta(events: readonly ReplayEvent[]): ReplayMeta {
|
|
|
91
91
|
|
|
92
92
|
const ERROR_TAGS = new Set(['error', 'exception', 'console.error', 'uncaught'])
|
|
93
93
|
const NETWORK_TAGS = new Set(['network', 'network.error', 'request.failed', 'fetch.failed'])
|
|
94
|
+
const FAILED_TAGS = new Set(['network.error', 'request.failed', 'fetch.failed'])
|
|
95
|
+
// Console messages (from a console log, say) mark the rail only when they're errors.
|
|
96
|
+
const CONSOLE_TAGS = new Set(['console', 'log', 'warn', 'warning', 'info', 'debug'])
|
|
94
97
|
const NAVIGATION_TAGS = new Set(['navigation', 'navigate', 'route'])
|
|
95
98
|
|
|
96
99
|
/**
|
|
@@ -150,9 +153,13 @@ export function getReplayMarkers(events: readonly ReplayEvent[]): ReplayMarker[]
|
|
|
150
153
|
const label = text(payload.label)
|
|
151
154
|
if (ERROR_TAGS.has(tag)) push({ kind: 'error', time, label: label ?? 'Error', detail })
|
|
152
155
|
else if (NETWORK_TAGS.has(tag)) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
// A failed request marks the rail (the same rule as DevTools: an error, a failure tag, status 0 or 400 and
|
|
157
|
+
// up); one that answered, or never did within the recording, doesn't.
|
|
158
|
+
const code = typeof payload.status === 'number' ? payload.status : undefined
|
|
159
|
+
const failed = FAILED_TAGS.has(tag) || !!payload.error || code === 0 || (code !== undefined && code >= 400)
|
|
160
|
+
if (failed) push({ kind: 'network', time, label: label ?? `${text(payload.method) ?? 'GET'} ${code ?? 'failed'}`, detail })
|
|
161
|
+
} else if (CONSOLE_TAGS.has(tag)) {
|
|
162
|
+
if (payload.level === 'error' || payload.level === 'assert') push({ kind: 'error', time, label: label ?? 'Console error', detail })
|
|
156
163
|
} else if (NAVIGATION_TAGS.has(tag)) push({ kind: 'navigation', time, label: label ?? 'Navigated', detail })
|
|
157
164
|
else push({ kind: 'custom', time, label: label ?? tag, detail })
|
|
158
165
|
break
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* More sources for a recording's DevTools, beside the rrweb events: a HAR (the network log browsers export) and a
|
|
2
|
+
console log (one message per line, `2026-06-12T21:01:38.123Z [warn] text`; indented lines continue the message
|
|
3
|
+
above). Both are read into the custom events devtools.ts already understands, stamped on their own absolute
|
|
4
|
+
times, so they line up with the recording when they share its clock. Anything stamped before the recording
|
|
5
|
+
starts or after it ends lands on its first or last moment. Pure readers, no rrweb import.
|
|
6
|
+
|
|
7
|
+
A HAR can also carry inspections (inspections.ts) as an extension: `log._inspections`, each
|
|
8
|
+
`{ at: ISO time, request?: entry _requestId or URL, message?: text, tab?, label? }`. */
|
|
9
|
+
import { ReplayEventType, type ReplayEvent } from './types'
|
|
10
|
+
|
|
11
|
+
type Json = Record<string, unknown>
|
|
12
|
+
const obj = (v: unknown): Json => (v && typeof v === 'object' ? (v as Json) : {})
|
|
13
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
|
14
|
+
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
|
|
15
|
+
const time = (v: unknown): number | undefined => {
|
|
16
|
+
const t = typeof v === 'string' ? Date.parse(v) : num(v)
|
|
17
|
+
return t !== undefined && Number.isFinite(t) ? t : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const custom = (timestamp: number, tag: string, payload: Json): ReplayEvent => ({
|
|
21
|
+
type: ReplayEventType.Custom,
|
|
22
|
+
timestamp,
|
|
23
|
+
data: { tag, payload },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/** HAR headers (`[{ name, value }]`) as a record. */
|
|
27
|
+
function harHeaders(list: unknown): Record<string, string> | undefined {
|
|
28
|
+
if (!Array.isArray(list)) return undefined
|
|
29
|
+
const out: Record<string, string> = {}
|
|
30
|
+
for (const h of list) {
|
|
31
|
+
const name = str(obj(h).name)
|
|
32
|
+
if (name) out[name] = String(obj(h).value ?? '')
|
|
33
|
+
}
|
|
34
|
+
return Object.keys(out).length ? out : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A HAR body: as written, or decoded when base64 and textual. */
|
|
38
|
+
function harText(content: Json): string | undefined {
|
|
39
|
+
const text = typeof content.text === 'string' ? content.text : undefined
|
|
40
|
+
if (text === undefined || content.encoding !== 'base64') return text
|
|
41
|
+
try {
|
|
42
|
+
const bytes = Uint8Array.from(atob(text), (c) => c.charCodeAt(0))
|
|
43
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
44
|
+
return decoded
|
|
45
|
+
} catch {
|
|
46
|
+
return `[${Math.round((text.length * 3) / 4)} bytes of binary data]`
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A HAR (object or JSON text) as `network` (and `inspect`) custom events. */
|
|
51
|
+
export function harToReplayEvents(har: unknown): ReplayEvent[] {
|
|
52
|
+
let data: unknown = har
|
|
53
|
+
if (typeof har === 'string') {
|
|
54
|
+
try { data = JSON.parse(har) } catch { return [] }
|
|
55
|
+
}
|
|
56
|
+
const log = obj(obj(data).log)
|
|
57
|
+
const out: ReplayEvent[] = []
|
|
58
|
+
for (const raw of Array.isArray(log.entries) ? log.entries : []) {
|
|
59
|
+
const e = obj(raw)
|
|
60
|
+
const started = time(e.startedDateTime)
|
|
61
|
+
if (started === undefined) continue
|
|
62
|
+
const request = obj(e.request)
|
|
63
|
+
const response = obj(e.response)
|
|
64
|
+
const duration = Math.max(0, num(e.time) ?? 0)
|
|
65
|
+
const status = num(response.status)
|
|
66
|
+
const error = str(e._error) ?? str(response._error)
|
|
67
|
+
out.push(custom(started + duration, error ? 'network.error' : 'network', {
|
|
68
|
+
requestId: str(e._requestId),
|
|
69
|
+
method: str(request.method) ?? 'GET',
|
|
70
|
+
url: str(request.url) ?? '',
|
|
71
|
+
status: status === -1 ? undefined : status,
|
|
72
|
+
statusText: str(response.statusText),
|
|
73
|
+
error,
|
|
74
|
+
duration,
|
|
75
|
+
type: str(e._resourceType) ?? 'fetch',
|
|
76
|
+
requestHeaders: harHeaders(request.headers),
|
|
77
|
+
responseHeaders: harHeaders(response.headers),
|
|
78
|
+
requestBody: str(obj(request.postData).text),
|
|
79
|
+
responseBody: harText(obj(response.content)),
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
for (const raw of Array.isArray(log._inspections) ? log._inspections : []) {
|
|
83
|
+
const i = obj(raw)
|
|
84
|
+
const at = time(i.at)
|
|
85
|
+
if (at === undefined) continue
|
|
86
|
+
out.push(custom(at, 'inspect', { request: str(i.request), message: str(i.message), tab: str(i.tab), label: str(i.label), id: str(i.id) }))
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))\s+\[(\w+)\]\s?(.*)$/
|
|
92
|
+
|
|
93
|
+
/** A console log (`<ISO time> [level] message`, indented continuation lines) as `console` custom events. */
|
|
94
|
+
export function consoleLogToReplayEvents(text: string): ReplayEvent[] {
|
|
95
|
+
const out: { at: number; level: string; lines: string[] }[] = []
|
|
96
|
+
for (const line of text.split(/\r?\n/)) {
|
|
97
|
+
const m = line.match(LINE)
|
|
98
|
+
const at = m ? time(m[1]) : undefined
|
|
99
|
+
if (m && at !== undefined) out.push({ at, level: m[2]!.toLowerCase(), lines: [m[3] ?? ''] })
|
|
100
|
+
else if (out.length && /^\s/.test(line)) out[out.length - 1]!.lines.push(line.replace(/^ {2}/, ''))
|
|
101
|
+
}
|
|
102
|
+
return out.map((m) => custom(m.at, 'console', { level: m.level === 'warning' ? 'warn' : m.level, message: m.lines.join('\n').trimEnd() }))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The recording's events with the HAR's and the console log's added, each stamped inside the recording. */
|
|
106
|
+
export function mergeReplaySources(events: ReplayEvent[], sources: { har?: unknown; consoleLog?: string }): ReplayEvent[] {
|
|
107
|
+
const extra = [
|
|
108
|
+
...(sources.har ? harToReplayEvents(sources.har) : []),
|
|
109
|
+
...(sources.consoleLog ? consoleLogToReplayEvents(sources.consoleLog) : []),
|
|
110
|
+
]
|
|
111
|
+
if (!extra.length || !events.length) return events
|
|
112
|
+
let first = Infinity
|
|
113
|
+
let last = -Infinity
|
|
114
|
+
for (const e of events) {
|
|
115
|
+
first = Math.min(first, e.timestamp)
|
|
116
|
+
last = Math.max(last, e.timestamp)
|
|
117
|
+
}
|
|
118
|
+
const clamped = extra.map((e) => (e.timestamp < first || e.timestamp > last ? { ...e, timestamp: Math.min(last, Math.max(first, e.timestamp)) } : e))
|
|
119
|
+
return [...events, ...clamped].sort((a, b) => a.timestamp - b.timestamp)
|
|
120
|
+
}
|