@brett_lamy/docstream 0.1.0 → 0.3.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.
@@ -0,0 +1,398 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { EventType, Replayer } from 'rrweb';
3
+ import type { eventWithTime } from 'rrweb';
4
+ import { normalizeReplayEmbedUrl } from './url';
5
+ import { sanitizeReplayEvent } from './sanitize';
6
+ import 'rrweb/dist/style.css';
7
+ import './styles.css';
8
+
9
+ export interface ReplayPageSource {
10
+ kind: 'url';
11
+ url: string;
12
+ title?: string;
13
+ }
14
+
15
+ export interface ReplayEventsSource {
16
+ kind: 'events';
17
+ events: eventWithTime[];
18
+ }
19
+
20
+ export interface ReplayEventsUrlSource {
21
+ kind: 'events-url';
22
+ url: string;
23
+ fetchInit?: RequestInit;
24
+ }
25
+
26
+ export type ReplaySource =
27
+ | string
28
+ | eventWithTime[]
29
+ | ReplayPageSource
30
+ | ReplayEventsSource
31
+ | ReplayEventsUrlSource;
32
+
33
+ export interface ReplayPreviewProps {
34
+ /** A public Loop QA URL, a public event endpoint, or an in-memory rrweb event list. */
35
+ source: ReplaySource;
36
+ /** Avoid mounting the rrweb player until the editor block is visible. */
37
+ active?: boolean;
38
+ /** Public-page embeds default to lazy loading; native rrweb playback defaults to paused. */
39
+ autoplay?: boolean;
40
+ loop?: boolean;
41
+ className?: string;
42
+ title?: string;
43
+ /** Playback speed options mirror the Loop QA player. */
44
+ speeds?: number[];
45
+ }
46
+
47
+ const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
48
+ const SKIP_IDLE_THRESHOLD_MS = 5_000;
49
+
50
+ function sourcePage(source: ReplaySource): ReplayPageSource | null {
51
+ if (typeof source === 'string') return { kind: 'url', url: source };
52
+ if (Array.isArray(source)) return null;
53
+ return source.kind === 'url' ? source : null;
54
+ }
55
+
56
+ function sourceEvents(source: ReplaySource): ReplayEventsSource | null {
57
+ if (Array.isArray(source)) return { kind: 'events', events: source };
58
+ if (typeof source === 'string') return null;
59
+ return source.kind === 'events' ? source : null;
60
+ }
61
+
62
+ function sourceEventsUrl(source: ReplaySource): ReplayEventsUrlSource | null {
63
+ if (Array.isArray(source) || typeof source === 'string') return null;
64
+ return source.kind === 'events-url' ? source : null;
65
+ }
66
+
67
+ function ReplayFrame({ source, title }: { source: ReplayPageSource; title?: string }) {
68
+ const url = normalizeReplayEmbedUrl(source.url);
69
+ if (!url) {
70
+ return (
71
+ <a className="replay-preview__invalid" href={source.url} target="_blank" rel="noreferrer">
72
+ Open replay
73
+ </a>
74
+ );
75
+ }
76
+ return (
77
+ <iframe
78
+ className="replay-preview__frame"
79
+ src={url}
80
+ title={title ?? source.title ?? 'Replay preview'}
81
+ loading="lazy"
82
+ allow="autoplay; fullscreen"
83
+ allowFullScreen
84
+ referrerPolicy="strict-origin-when-cross-origin"
85
+ />
86
+ );
87
+ }
88
+
89
+ function NativeReplay({
90
+ events,
91
+ autoplay = false,
92
+ loop = false,
93
+ speeds = DEFAULT_SPEEDS,
94
+ }: {
95
+ events: eventWithTime[];
96
+ autoplay?: boolean;
97
+ loop?: boolean;
98
+ speeds?: number[];
99
+ }) {
100
+ const stageRef = useRef<HTMLDivElement>(null);
101
+ const playerRef = useRef<HTMLDivElement>(null);
102
+ const replayerRef = useRef<Replayer | null>(null);
103
+ const fedCountRef = useRef(0);
104
+ const [playing, setPlaying] = useState(false);
105
+ const [speed, setSpeed] = useState(1);
106
+ const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
107
+ const [scale, setScale] = useState(1);
108
+ const [currentTime, setCurrentTime] = useState(0);
109
+ const [totalTime, setTotalTime] = useState(0);
110
+ const [scrubbing, setScrubbing] = useState<number | null>(null);
111
+ const speedRef = useRef(speed);
112
+ const loopRef = useRef(loop);
113
+ const autoplayRef = useRef(autoplay);
114
+
115
+ const prepared = useMemo(
116
+ () => events.map((event) => sanitizeReplayEvent(event)).sort((a, b) => a.timestamp - b.timestamp),
117
+ [events],
118
+ );
119
+ const hasSnapshot = prepared.some((event) => event.type === EventType.FullSnapshot);
120
+
121
+ useEffect(() => {
122
+ if (replayerRef.current && prepared.length < fedCountRef.current) {
123
+ try {
124
+ replayerRef.current.destroy();
125
+ } catch {
126
+ // Ignore stale player teardown.
127
+ }
128
+ replayerRef.current = null;
129
+ fedCountRef.current = 0;
130
+ }
131
+ if (!hasSnapshot || !playerRef.current) return;
132
+
133
+ if (!replayerRef.current) {
134
+ const replayer = new Replayer(prepared, {
135
+ root: playerRef.current,
136
+ liveMode: false,
137
+ speed: speedRef.current,
138
+ skipInactive: true,
139
+ inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
140
+ mouseTail: false,
141
+ showWarning: false,
142
+ });
143
+ replayer.on('resize', (payload) => {
144
+ const size = payload as { width?: number; height?: number };
145
+ if (size.width && size.height) setDims({ w: size.width, h: size.height });
146
+ });
147
+ replayer.on('finish', () => {
148
+ if (loopRef.current) {
149
+ replayer.play(0);
150
+ setPlaying(true);
151
+ } else {
152
+ setPlaying(false);
153
+ }
154
+ });
155
+ replayer.pause(0);
156
+ replayerRef.current = replayer;
157
+ fedCountRef.current = prepared.length;
158
+ if (autoplayRef.current) {
159
+ replayer.play(0);
160
+ setPlaying(true);
161
+ }
162
+ } else if (prepared.length > fedCountRef.current) {
163
+ for (let i = fedCountRef.current; i < prepared.length; i++) {
164
+ replayerRef.current.addEvent(prepared[i]!);
165
+ }
166
+ fedCountRef.current = prepared.length;
167
+ }
168
+ }, [hasSnapshot, prepared]);
169
+
170
+ useEffect(
171
+ () => () => {
172
+ try {
173
+ replayerRef.current?.destroy();
174
+ } catch {
175
+ // Ignore unmount teardown errors from rrweb.
176
+ }
177
+ replayerRef.current = null;
178
+ fedCountRef.current = 0;
179
+ },
180
+ [],
181
+ );
182
+
183
+ useEffect(() => {
184
+ speedRef.current = speed;
185
+ replayerRef.current?.setConfig({ speed });
186
+ }, [speed]);
187
+
188
+ useEffect(() => {
189
+ loopRef.current = loop;
190
+ }, [loop]);
191
+
192
+ useEffect(() => {
193
+ autoplayRef.current = autoplay;
194
+ }, [autoplay]);
195
+
196
+ const recalcScale = useCallback(() => {
197
+ if (!dims || !stageRef.current || dims.w <= 0) return;
198
+ setScale(Math.min(1, stageRef.current.clientWidth / dims.w));
199
+ }, [dims]);
200
+
201
+ useEffect(() => {
202
+ recalcScale();
203
+ window.addEventListener('resize', recalcScale);
204
+ return () => window.removeEventListener('resize', recalcScale);
205
+ }, [recalcScale]);
206
+
207
+ useEffect(() => {
208
+ if (!hasSnapshot) return;
209
+ const interval = window.setInterval(() => {
210
+ const replayer = replayerRef.current;
211
+ if (!replayer) return;
212
+ try {
213
+ const total = replayer.getMetaData().totalTime;
214
+ const current = Math.max(0, Math.min(replayer.getCurrentTime(), total));
215
+ setTotalTime(total);
216
+ if (scrubbing === null) setCurrentTime(current);
217
+ } catch {
218
+ // rrweb can briefly be unready while a full snapshot is applied.
219
+ }
220
+ }, 200);
221
+ return () => window.clearInterval(interval);
222
+ }, [hasSnapshot, scrubbing]);
223
+
224
+ const seek = useCallback(
225
+ (value: number) => {
226
+ const replayer = replayerRef.current;
227
+ if (!replayer) return;
228
+ const next = Math.max(0, Math.min(value, totalTime));
229
+ if (playing) replayer.play(next);
230
+ else replayer.pause(next);
231
+ setCurrentTime(next);
232
+ },
233
+ [playing, totalTime],
234
+ );
235
+
236
+ const toggle = () => {
237
+ const replayer = replayerRef.current;
238
+ if (!replayer) return;
239
+ if (playing) {
240
+ replayer.pause();
241
+ setPlaying(false);
242
+ return;
243
+ }
244
+ let offset = currentTime;
245
+ try {
246
+ if (offset >= replayer.getMetaData().totalTime - 1) offset = 0;
247
+ } catch {
248
+ offset = 0;
249
+ }
250
+ replayer.play(offset);
251
+ setPlaying(true);
252
+ };
253
+
254
+ const restart = () => {
255
+ replayerRef.current?.play(0);
256
+ setCurrentTime(0);
257
+ setPlaying(true);
258
+ };
259
+
260
+ if (!hasSnapshot) {
261
+ return (
262
+ <div className="replay-preview__empty">
263
+ {events.length ? `Captured ${events.length} events, but no full DOM snapshot is available.` : 'Replay data is not available yet.'}
264
+ </div>
265
+ );
266
+ }
267
+
268
+ return (
269
+ <div className="replay-preview__native">
270
+ <div
271
+ ref={stageRef}
272
+ className="replay-preview__stage"
273
+ style={dims ? { height: Math.max(1, Math.round(dims.h * scale)) } : undefined}
274
+ >
275
+ <div
276
+ className="replay-preview__scaler"
277
+ style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
278
+ >
279
+ <div ref={playerRef} className="replay-preview__player" aria-hidden="true" />
280
+ </div>
281
+ </div>
282
+ <div className="replay-preview__controls">
283
+ <button type="button" onClick={toggle} aria-label={playing ? 'Pause replay' : 'Play replay'}>
284
+ {playing ? 'Pause' : 'Play'}
285
+ </button>
286
+ <button type="button" onClick={restart} aria-label="Restart replay">
287
+ Restart
288
+ </button>
289
+ <input
290
+ type="range"
291
+ min={0}
292
+ max={Math.max(totalTime, 1)}
293
+ step={1}
294
+ value={scrubbing ?? currentTime}
295
+ onChange={(event) => setScrubbing(Number(event.target.value))}
296
+ onPointerUp={(event) => {
297
+ seek(Number((event.currentTarget as HTMLInputElement).value));
298
+ setScrubbing(null);
299
+ }}
300
+ aria-label="Replay position"
301
+ />
302
+ <select value={speed} onChange={(event) => setSpeed(Number(event.target.value))} aria-label="Replay speed">
303
+ {speeds.map((option) => (
304
+ <option key={option} value={option}>
305
+ {option}×
306
+ </option>
307
+ ))}
308
+ </select>
309
+ </div>
310
+ </div>
311
+ );
312
+ }
313
+
314
+ function RemoteReplay({
315
+ source,
316
+ autoplay,
317
+ loop,
318
+ speeds,
319
+ }: {
320
+ source: ReplayEventsUrlSource;
321
+ autoplay?: boolean;
322
+ loop?: boolean;
323
+ speeds?: number[];
324
+ }) {
325
+ const [events, setEvents] = useState<eventWithTime[] | null>(null);
326
+ const [error, setError] = useState<string | null>(null);
327
+
328
+ useEffect(() => {
329
+ const controller = new AbortController();
330
+ setEvents(null);
331
+ setError(null);
332
+ fetch(source.url, { ...source.fetchInit, signal: controller.signal })
333
+ .then(async (response) => {
334
+ if (!response.ok) throw new Error(`Replay event request failed (${response.status})`);
335
+ const body = (await response.json()) as unknown;
336
+ const value = Array.isArray(body)
337
+ ? body
338
+ : body && typeof body === 'object' && Array.isArray((body as { events?: unknown }).events)
339
+ ? (body as { events: unknown[] }).events
340
+ : null;
341
+ if (!value) throw new Error('Replay event endpoint must return an event array or { events }');
342
+ return value as eventWithTime[];
343
+ })
344
+ .then(setEvents)
345
+ .catch((reason: unknown) => {
346
+ if (reason instanceof DOMException && reason.name === 'AbortError') return;
347
+ setError(reason instanceof Error ? reason.message : 'Unable to load replay data');
348
+ });
349
+ return () => controller.abort();
350
+ }, [source.url, source.fetchInit]);
351
+
352
+ if (error) return <div className="replay-preview__error" role="alert">{error}</div>;
353
+ if (!events) return <div className="replay-preview__empty">Loading replay…</div>;
354
+ return <NativeReplay events={events} autoplay={autoplay} loop={loop} speeds={speeds} />;
355
+ }
356
+
357
+ export function ReplayPreview({
358
+ source,
359
+ active = true,
360
+ autoplay = false,
361
+ loop = false,
362
+ className,
363
+ title,
364
+ speeds = DEFAULT_SPEEDS,
365
+ }: ReplayPreviewProps) {
366
+ if (!active) return <div className={`replay-preview ${className ?? ''}`.trim()} data-replay-preview="paused" />;
367
+
368
+ const page = sourcePage(source);
369
+ const events = sourceEvents(source);
370
+ const eventsUrl = sourceEventsUrl(source);
371
+ const rootClass = `replay-preview ${className ?? ''}`.trim();
372
+
373
+ if (page) {
374
+ return (
375
+ <div className={rootClass} data-replay-preview="iframe">
376
+ <ReplayFrame source={page} title={title} />
377
+ </div>
378
+ );
379
+ }
380
+ if (events) {
381
+ return (
382
+ <div className={rootClass} data-replay-preview="rrweb">
383
+ <NativeReplay events={events.events} autoplay={autoplay} loop={loop} speeds={speeds} />
384
+ </div>
385
+ );
386
+ }
387
+ if (eventsUrl) {
388
+ return (
389
+ <div className={rootClass} data-replay-preview="rrweb">
390
+ <RemoteReplay source={eventsUrl} autoplay={autoplay} loop={loop} speeds={speeds} />
391
+ </div>
392
+ );
393
+ }
394
+ return null;
395
+ }
396
+
397
+ /** Semantic alias for markdown/document renderers. */
398
+ export const ReplayEmbed = ReplayPreview;
@@ -0,0 +1,10 @@
1
+ export { ReplayEmbed, ReplayPreview } from './ReplayPreview';
2
+ export type {
3
+ ReplayEventsSource,
4
+ ReplayEventsUrlSource,
5
+ ReplayPageSource,
6
+ ReplayPreviewProps,
7
+ ReplaySource,
8
+ } from './ReplayPreview';
9
+ export { isReplayQaUrl, normalizeReplayEmbedUrl } from './url';
10
+ export { sanitizeReplayEvent } from './sanitize';
@@ -0,0 +1,83 @@
1
+ import { EventType, IncrementalSource } from 'rrweb';
2
+ import type { eventWithTime } from 'rrweb';
3
+
4
+ const TRANSPARENT_PIXEL =
5
+ 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
6
+ const EMPTY_STYLESHEET = 'data:text/css,';
7
+
8
+ function isLocalNetworkUrl(value: string): boolean {
9
+ let url: URL;
10
+ try {
11
+ url = new URL(value);
12
+ } catch {
13
+ return false;
14
+ }
15
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
16
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
17
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
18
+ if (host === '0.0.0.0' || host === '::1' || host.startsWith('127.') || host.startsWith('10.')) {
19
+ return true;
20
+ }
21
+ if (host.startsWith('192.168.')) return true;
22
+ const private172 = host.match(/^172\.(\d{1,3})\./);
23
+ if (private172 && Number(private172[1]) >= 16 && Number(private172[1]) <= 31) return true;
24
+ return host.includes(':') && (host.startsWith('fc') || host.startsWith('fd'));
25
+ }
26
+
27
+ function sanitizeCss(value: string): string {
28
+ return value.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (whole, quote: string, raw: string) =>
29
+ isLocalNetworkUrl(raw.trim()) ? `url(${quote}${TRANSPARENT_PIXEL}${quote})` : whole,
30
+ );
31
+ }
32
+
33
+ function sanitizeAttributes(attributes: Record<string, unknown>): void {
34
+ for (const key of Object.keys(attributes)) {
35
+ const value = attributes[key];
36
+ if (typeof value !== 'string') continue;
37
+ const lower = key.toLowerCase();
38
+ if (lower === 'src' || lower === 'poster' || lower === 'background') {
39
+ if (isLocalNetworkUrl(value)) attributes[key] = TRANSPARENT_PIXEL;
40
+ } else if (lower === 'data') {
41
+ if (isLocalNetworkUrl(value)) attributes[key] = 'about:blank';
42
+ } else if (lower === 'style' || lower === '_csstext') {
43
+ attributes[key] = sanitizeCss(value);
44
+ } else if (lower === 'href' || lower === 'xlink:href') {
45
+ if (isLocalNetworkUrl(value)) attributes[key] = EMPTY_STYLESHEET;
46
+ }
47
+ }
48
+ }
49
+
50
+ function walkNode(node: unknown): void {
51
+ if (!node || typeof node !== 'object') return;
52
+ const value = node as { attributes?: unknown; childNodes?: unknown };
53
+ if (value.attributes && typeof value.attributes === 'object') {
54
+ sanitizeAttributes(value.attributes as Record<string, unknown>);
55
+ }
56
+ if (Array.isArray(value.childNodes)) value.childNodes.forEach(walkNode);
57
+ }
58
+
59
+ /** Prevent a replay iframe from making the viewer fetch localhost/private assets. */
60
+ export function sanitizeReplayEvent(event: eventWithTime): eventWithTime {
61
+ try {
62
+ if (event.type === EventType.FullSnapshot) {
63
+ walkNode((event.data as { node?: unknown }).node);
64
+ } else if (event.type === EventType.IncrementalSnapshot) {
65
+ const data = event.data as {
66
+ source?: number;
67
+ adds?: Array<{ node?: unknown }>;
68
+ attributes?: Array<{ attributes?: unknown }>;
69
+ };
70
+ if (data.source === IncrementalSource.Mutation) {
71
+ data.adds?.forEach((item) => walkNode(item.node));
72
+ data.attributes?.forEach((item) => {
73
+ if (item.attributes && typeof item.attributes === 'object') {
74
+ sanitizeAttributes(item.attributes as Record<string, unknown>);
75
+ }
76
+ });
77
+ }
78
+ }
79
+ } catch {
80
+ // A malformed asset must never take down the player.
81
+ }
82
+ return event;
83
+ }
@@ -0,0 +1,87 @@
1
+ .replay-preview {
2
+ width: 100%;
3
+ min-width: 0;
4
+ color: var(--replay-preview-fg, #e5e7eb);
5
+ background: var(--replay-preview-bg, #0f172a);
6
+ border: 1px solid var(--replay-preview-border, rgba(148, 163, 184, 0.28));
7
+ border-radius: 12px;
8
+ overflow: hidden;
9
+ }
10
+
11
+ .replay-preview__frame {
12
+ display: block;
13
+ width: 100%;
14
+ aspect-ratio: 16 / 9;
15
+ border: 0;
16
+ background: #0f172a;
17
+ }
18
+
19
+ .replay-preview__invalid,
20
+ .replay-preview__empty,
21
+ .replay-preview__error {
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ min-height: 140px;
26
+ padding: 20px;
27
+ color: var(--replay-preview-muted, #94a3b8);
28
+ font: 13px/1.5 ui-sans-serif, system-ui, sans-serif;
29
+ text-align: center;
30
+ }
31
+
32
+ .replay-preview__invalid {
33
+ color: var(--replay-preview-link, #7dd3fc);
34
+ }
35
+
36
+ .replay-preview__error {
37
+ color: #fda4af;
38
+ }
39
+
40
+ .replay-preview__native {
41
+ width: 100%;
42
+ background: #0f172a;
43
+ }
44
+
45
+ .replay-preview__stage {
46
+ position: relative;
47
+ width: 100%;
48
+ min-height: 160px;
49
+ overflow: hidden;
50
+ background: #020617;
51
+ }
52
+
53
+ .replay-preview__scaler {
54
+ position: absolute;
55
+ inset: 0 auto auto 0;
56
+ }
57
+
58
+ .replay-preview__player {
59
+ position: relative;
60
+ overflow: hidden;
61
+ }
62
+
63
+ .replay-preview__controls {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ padding: 8px 10px;
68
+ border-top: 1px solid rgba(148, 163, 184, 0.2);
69
+ background: #111827;
70
+ font: 12px/1.2 ui-sans-serif, system-ui, sans-serif;
71
+ }
72
+
73
+ .replay-preview__controls button,
74
+ .replay-preview__controls select {
75
+ border: 1px solid rgba(148, 163, 184, 0.3);
76
+ border-radius: 6px;
77
+ padding: 5px 8px;
78
+ color: inherit;
79
+ background: #1e293b;
80
+ cursor: pointer;
81
+ }
82
+
83
+ .replay-preview__controls input[type='range'] {
84
+ min-width: 60px;
85
+ flex: 1;
86
+ accent-color: #38bdf8;
87
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Convert a normal Loop QA project URL to its chrome-free public embed route.
3
+ * The application owns auth/public-access decisions; this helper only changes
4
+ * the route shape that Loop QA already exposes for cross-site iframes.
5
+ */
6
+ export function normalizeReplayEmbedUrl(input: string): string | null {
7
+ let url: URL;
8
+ try {
9
+ url = new URL(input);
10
+ } catch {
11
+ return null;
12
+ }
13
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
14
+
15
+ const parts = url.pathname.split('/').filter(Boolean);
16
+ if (parts[0] === 'projects' && parts.length >= 4) {
17
+ const projectId = decodeURIComponent(parts[1] ?? '');
18
+ const kind = parts[2] === 'explorations' ? 'tasks' : parts[2];
19
+ const resourceId = decodeURIComponent(parts[3] ?? '');
20
+ if (
21
+ projectId &&
22
+ resourceId &&
23
+ (kind === 'tasks' || kind === 'journeys' || kind === 'test-runs')
24
+ ) {
25
+ url.pathname = `/p/${encodeURIComponent(projectId)}/${kind}/${encodeURIComponent(resourceId)}`;
26
+ }
27
+ }
28
+
29
+ return url.toString();
30
+ }
31
+
32
+ export function isReplayQaUrl(input: string): boolean {
33
+ const normalized = normalizeReplayEmbedUrl(input);
34
+ if (!normalized) return false;
35
+ try {
36
+ const url = new URL(normalized);
37
+ return url.pathname.startsWith('/p/') || url.hostname.endsWith('.replay.io');
38
+ } catch {
39
+ return false;
40
+ }
41
+ }