@brett_lamy/docstream 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ import type { ContainerInstance, ContainerOptions, VirtualFS } from "@agent-wasm/core"
2
+
3
+ export type AlmostNodeFileContent = string | Uint8Array
4
+
5
+ export interface AlmostNodeFile {
6
+ path: string
7
+ content: AlmostNodeFileContent
8
+ }
9
+
10
+ export type AlmostNodeFiles =
11
+ | Readonly<Record<string, AlmostNodeFileContent>>
12
+ | readonly AlmostNodeFile[]
13
+
14
+ export interface AlmostNodeFilesystemOptions {
15
+ /** Prefix for relative file paths. Defaults to the virtual root. */
16
+ root?: string
17
+ }
18
+
19
+ export interface AlmostNodeWorkspaceOptions extends Omit<ContainerOptions, "cwd"> {
20
+ /** Project root used as the container cwd and Vite root. */
21
+ root?: string
22
+ }
23
+
24
+ export interface AlmostNodeWorkspace {
25
+ container: ContainerInstance
26
+ root: string
27
+ writeFile(path: string, content: AlmostNodeFileContent): void
28
+ writeFiles(files: AlmostNodeFiles): void
29
+ listFiles(): string[]
30
+ dispose(): void
31
+ }
32
+
33
+ type AlmostNodeModule = typeof import("@agent-wasm/core")
34
+
35
+ async function loadAlmostNode(): Promise<AlmostNodeModule> {
36
+ try {
37
+ return await import("@agent-wasm/core")
38
+ } catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error)
40
+ throw new Error(
41
+ `Docstream live previews require @agent-wasm/core (almost-node). Install it in the host app. ${message}`,
42
+ { cause: error },
43
+ )
44
+ }
45
+ }
46
+
47
+ export function normalizeAlmostNodePath(path: string, root = "/"): string {
48
+ const normalizedRoot = normalizeRoot(root)
49
+ const source = path.replaceAll("\\", "/")
50
+ const absolute = source.startsWith("/") ? source : `${normalizedRoot}/${source}`
51
+ const result = normalizeAbsolutePath(absolute)
52
+
53
+ if (normalizedRoot !== "/" && result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) {
54
+ throw new Error(`File path escapes the almost-node root: ${path}`)
55
+ }
56
+ return result
57
+ }
58
+
59
+ function normalizeAbsolutePath(path: string): string {
60
+ const parts: string[] = []
61
+
62
+ for (const part of path.split("/")) {
63
+ if (!part || part === ".") continue
64
+ if (part === "..") {
65
+ if (parts.length > 0) parts.pop()
66
+ continue
67
+ }
68
+ if (part.includes("\0")) throw new Error("almost-node file paths cannot contain null bytes")
69
+ parts.push(part)
70
+ }
71
+
72
+ const result = `/${parts.join("/")}`
73
+ return result === "" ? "/" : result
74
+ }
75
+
76
+ export function normalizeAlmostNodeFiles(files: AlmostNodeFiles): AlmostNodeFile[] {
77
+ if (Array.isArray(files)) return files.map((file) => ({ ...file }))
78
+ return Object.entries(files).map(([path, content]) => ({ path, content }))
79
+ }
80
+
81
+ export function toAlmostNodeFileMap(files: AlmostNodeFiles): Record<string, AlmostNodeFileContent> {
82
+ return Object.fromEntries(normalizeAlmostNodeFiles(files).map(({ path, content }) => [path, content]))
83
+ }
84
+
85
+ function normalizeRoot(root: string): string {
86
+ const normalized = normalizeAbsolutePath(root.replaceAll("\\", "/"))
87
+ return normalized === "/" ? normalized : normalized.replace(/\/$/, "")
88
+ }
89
+
90
+ function parentDirectory(path: string): string {
91
+ const index = path.lastIndexOf("/")
92
+ return index <= 0 ? "/" : path.slice(0, index)
93
+ }
94
+
95
+ function writeFilesToVfs(vfs: VirtualFS, files: AlmostNodeFiles, root: string): void {
96
+ for (const { path, content } of normalizeAlmostNodeFiles(files)) {
97
+ const absolutePath = normalizeAlmostNodePath(path, root)
98
+ vfs.mkdirSync(parentDirectory(absolutePath), { recursive: true })
99
+ vfs.writeFileSync(absolutePath, content)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Create a complete almost-node filesystem from a map or list of files.
105
+ * The runtime is loaded lazily so ordinary markdown rendering does not pull
106
+ * the WASM/browser runtime into the bundle until a live demo is used.
107
+ */
108
+ export async function createAlmostNodeFilesystem(
109
+ files: AlmostNodeFiles,
110
+ options: AlmostNodeFilesystemOptions = {},
111
+ ): Promise<VirtualFS> {
112
+ const { VirtualFS } = await loadAlmostNode()
113
+ const root = normalizeRoot(options.root ?? "/")
114
+ const vfs = new VirtualFS()
115
+ writeFilesToVfs(vfs, files, root)
116
+ return vfs
117
+ }
118
+
119
+ /**
120
+ * Create a container and populate its entire virtual filesystem in one call.
121
+ * Use the returned container for `run`, `runFile`, npm installs, or Vite.
122
+ */
123
+ export async function createAlmostNodeWorkspace(
124
+ files: AlmostNodeFiles,
125
+ options: AlmostNodeWorkspaceOptions = {},
126
+ ): Promise<AlmostNodeWorkspace> {
127
+ const { root: requestedRoot, ...containerOptions } = options
128
+ const root = normalizeRoot(requestedRoot ?? "/")
129
+ const { createContainer } = await loadAlmostNode()
130
+ const container = createContainer({ ...containerOptions, cwd: root })
131
+
132
+ const writeFiles = (nextFiles: AlmostNodeFiles) => writeFilesToVfs(container.vfs, nextFiles, root)
133
+ writeFiles(files)
134
+
135
+ return {
136
+ container,
137
+ root,
138
+ writeFile(path, content) {
139
+ writeFiles([{ path, content }])
140
+ },
141
+ writeFiles,
142
+ listFiles() {
143
+ return container.vfs
144
+ .toSnapshot()
145
+ .files.filter((entry) => entry.type === "file")
146
+ .map((entry) => entry.path)
147
+ .sort()
148
+ },
149
+ dispose() {
150
+ container.dispose()
151
+ },
152
+ }
153
+ }
154
+
155
+ export interface ReactDemoFilesOptions {
156
+ entry?: string
157
+ }
158
+
159
+ /**
160
+ * Add the tiny Vite/React shell needed by a file map that only contains source
161
+ * files. Existing package.json and index.html files are always respected.
162
+ */
163
+ export function createReactDemoFiles(
164
+ files: AlmostNodeFiles,
165
+ options: ReactDemoFilesOptions = {},
166
+ ): Record<string, AlmostNodeFileContent> {
167
+ const map = toAlmostNodeFileMap(files)
168
+ const entry = normalizeAlmostNodePath(options.entry ?? "/src/main.jsx")
169
+ const has = (candidate: string) => Object.keys(map).some((path) => normalizeAlmostNodePath(path) === candidate)
170
+
171
+ if (!has("/package.json")) {
172
+ map["/package.json"] = JSON.stringify(
173
+ {
174
+ name: "docstream-react-demo",
175
+ private: true,
176
+ type: "module",
177
+ dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
178
+ },
179
+ null,
180
+ 2,
181
+ )
182
+ }
183
+
184
+ if (!has("/index.html")) {
185
+ const entryUrl = `.${entry}`
186
+ map["/index.html"] = `<!doctype html>
187
+ <html lang="en">
188
+ <head>
189
+ <meta charset="UTF-8" />
190
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
191
+ <title>Docstream React demo</title>
192
+ </head>
193
+ <body>
194
+ <div id="root"></div>
195
+ <script type="module" src="${entryUrl}"></script>
196
+ </body>
197
+ </html>
198
+ `
199
+ }
200
+
201
+ return map
202
+ }
@@ -0,0 +1,19 @@
1
+ export { ReactCodePreview, ReactDemo } from "./ReactCodePreview"
2
+ export type { ReactCodePreviewProps, ReactDemoProps } from "./ReactCodePreview"
3
+ export {
4
+ createAlmostNodeFilesystem,
5
+ createAlmostNodeWorkspace,
6
+ createReactDemoFiles,
7
+ normalizeAlmostNodeFiles,
8
+ normalizeAlmostNodePath,
9
+ toAlmostNodeFileMap,
10
+ } from "./filesystem"
11
+ export type {
12
+ AlmostNodeFile,
13
+ AlmostNodeFileContent,
14
+ AlmostNodeFiles,
15
+ AlmostNodeFilesystemOptions,
16
+ AlmostNodeWorkspace,
17
+ AlmostNodeWorkspaceOptions,
18
+ ReactDemoFilesOptions,
19
+ } from "./filesystem"
@@ -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';