@workflow/web-shared 4.1.0-beta.50 → 4.1.0-beta.52

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 (62) hide show
  1. package/dist/components/event-list-view.d.ts.map +1 -1
  2. package/dist/components/event-list-view.js +8 -5
  3. package/dist/components/event-list-view.js.map +1 -1
  4. package/dist/components/index.d.ts +3 -2
  5. package/dist/components/index.d.ts.map +1 -1
  6. package/dist/components/index.js +1 -1
  7. package/dist/components/index.js.map +1 -1
  8. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  9. package/dist/components/sidebar/attribute-panel.js +23 -91
  10. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  11. package/dist/components/sidebar/detail-card.d.ts +3 -1
  12. package/dist/components/sidebar/detail-card.d.ts.map +1 -1
  13. package/dist/components/sidebar/detail-card.js +2 -2
  14. package/dist/components/sidebar/detail-card.js.map +1 -1
  15. package/dist/components/sidebar/entity-detail-panel.d.ts +22 -2
  16. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  17. package/dist/components/sidebar/entity-detail-panel.js +38 -49
  18. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  19. package/dist/components/sidebar/events-list.d.ts +4 -4
  20. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  21. package/dist/components/sidebar/events-list.js +77 -12
  22. package/dist/components/sidebar/events-list.js.map +1 -1
  23. package/dist/components/stream-viewer.d.ts +8 -7
  24. package/dist/components/stream-viewer.d.ts.map +1 -1
  25. package/dist/components/stream-viewer.js +12 -9
  26. package/dist/components/stream-viewer.js.map +1 -1
  27. package/dist/components/trace-viewer/context.d.ts +3 -2
  28. package/dist/components/trace-viewer/context.d.ts.map +1 -1
  29. package/dist/components/trace-viewer/context.js.map +1 -1
  30. package/dist/components/ui/inspector-theme.d.ts +81 -0
  31. package/dist/components/ui/inspector-theme.d.ts.map +1 -0
  32. package/dist/components/ui/inspector-theme.js +63 -0
  33. package/dist/components/ui/inspector-theme.js.map +1 -0
  34. package/dist/components/workflow-trace-view.d.ts +3 -1
  35. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  36. package/dist/components/workflow-trace-view.js +111 -10
  37. package/dist/components/workflow-trace-view.js.map +1 -1
  38. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  39. package/dist/components/workflow-traces/trace-span-construction.js +17 -12
  40. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/lib/hydration.d.ts +26 -0
  46. package/dist/lib/hydration.d.ts.map +1 -0
  47. package/dist/lib/hydration.js +98 -0
  48. package/dist/lib/hydration.js.map +1 -0
  49. package/package.json +3 -2
  50. package/src/components/event-list-view.tsx +15 -9
  51. package/src/components/index.ts +6 -5
  52. package/src/components/sidebar/attribute-panel.tsx +30 -129
  53. package/src/components/sidebar/detail-card.tsx +7 -1
  54. package/src/components/sidebar/entity-detail-panel.tsx +71 -57
  55. package/src/components/sidebar/events-list.tsx +204 -88
  56. package/src/components/stream-viewer.tsx +37 -21
  57. package/src/components/trace-viewer/context.tsx +3 -2
  58. package/src/components/ui/inspector-theme.ts +64 -0
  59. package/src/components/workflow-trace-view.tsx +210 -32
  60. package/src/components/workflow-traces/trace-span-construction.ts +17 -12
  61. package/src/index.ts +13 -0
  62. package/src/lib/hydration.ts +137 -0
@@ -1,16 +1,19 @@
1
1
  'use client';
2
2
 
3
3
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
- import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { X } from 'lucide-react';
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
6
  import { toast } from 'sonner';
6
7
  import { ErrorBoundary } from './error-boundary';
7
8
  import {
8
9
  EntityDetailPanel,
10
+ type SelectedSpanInfo,
9
11
  type SpanSelectionInfo,
10
12
  } from './sidebar/entity-detail-panel';
11
13
  import {
12
14
  TraceViewerContextProvider,
13
15
  TraceViewerTimeline,
16
+ useTraceViewer,
14
17
  } from './trace-viewer';
15
18
  import type { Span } from './trace-viewer/types';
16
19
  import { Skeleton } from './ui/skeleton';
@@ -28,6 +31,8 @@ import {
28
31
  import { otelTimeToMs } from './workflow-traces/trace-time-utils';
29
32
 
30
33
  const RE_RENDER_INTERVAL_MS = 2000;
34
+ const DEFAULT_PANEL_WIDTH = 380;
35
+ const MIN_PANEL_WIDTH = 240;
31
36
 
32
37
  type GroupedEvents = {
33
38
  eventsByStepId: Map<string, Event[]>;
@@ -184,6 +189,95 @@ const buildTrace = (
184
189
  /** Re-export SpanSelectionInfo for consumers */
185
190
  export type { SpanSelectionInfo };
186
191
 
192
+ // ---------------------------------------------------------------------------
193
+ // Bridge: reads selected span from trace viewer context and notifies parent
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Bridge component that lives INSIDE the TraceViewerContextProvider and
198
+ * reads the selected span info, passing it up to the parent via callback.
199
+ * This allows the parent to render the detail panel OUTSIDE the context.
200
+ */
201
+ function SelectionBridge({
202
+ onSelectionChange,
203
+ }: {
204
+ onSelectionChange: (info: SelectedSpanInfo | null) => void;
205
+ }) {
206
+ const { state } = useTraceViewer();
207
+ const { selected } = state;
208
+ const onSelectionChangeRef = useRef(onSelectionChange);
209
+ onSelectionChangeRef.current = onSelectionChange;
210
+
211
+ useEffect(() => {
212
+ if (selected) {
213
+ onSelectionChangeRef.current({
214
+ data: selected.span.attributes?.data,
215
+ resource: selected.span.attributes?.resource as string | undefined,
216
+ spanId: selected.span.spanId,
217
+ });
218
+ } else {
219
+ onSelectionChangeRef.current(null);
220
+ }
221
+ }, [selected]);
222
+
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Bridge component to deselect from outside the context.
228
+ */
229
+ function DeselectBridge({ triggerDeselect }: { triggerDeselect: number }) {
230
+ const { dispatch } = useTraceViewer();
231
+
232
+ useEffect(() => {
233
+ if (triggerDeselect > 0) {
234
+ dispatch({ type: 'deselect' });
235
+ }
236
+ }, [triggerDeselect, dispatch]);
237
+
238
+ return null;
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Panel chrome (header with name/duration, close button)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function PanelResizeHandle({
246
+ onResize,
247
+ }: {
248
+ onResize: (deltaX: number) => void;
249
+ }) {
250
+ const handlePointerDown = useCallback(
251
+ (e: React.PointerEvent) => {
252
+ e.preventDefault();
253
+ let lastX = e.clientX;
254
+ const onPointerMove = (moveEvent: PointerEvent) => {
255
+ const deltaX = lastX - moveEvent.clientX;
256
+ lastX = moveEvent.clientX;
257
+ onResize(deltaX);
258
+ };
259
+ const onPointerUp = () => {
260
+ document.removeEventListener('pointermove', onPointerMove);
261
+ document.removeEventListener('pointerup', onPointerUp);
262
+ };
263
+ document.addEventListener('pointermove', onPointerMove);
264
+ document.addEventListener('pointerup', onPointerUp);
265
+ },
266
+ [onResize]
267
+ );
268
+
269
+ return (
270
+ <div
271
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-400/50 z-10"
272
+ onPointerDown={handlePointerDown}
273
+ />
274
+ );
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Main component
279
+ // ---------------------------------------------------------------------------
280
+
187
281
  export const WorkflowTraceViewer = ({
188
282
  run,
189
283
  steps,
@@ -198,6 +292,7 @@ export const WorkflowTraceViewer = ({
198
292
  onResolveHook,
199
293
  onStreamClick,
200
294
  onSpanSelect,
295
+ onLoadEventData,
201
296
  }: {
202
297
  run: WorkflowRun;
203
298
  steps: Step[];
@@ -221,8 +316,18 @@ export const WorkflowTraceViewer = ({
221
316
  onStreamClick?: (streamId: string) => void;
222
317
  /** Callback when a span is selected. */
223
318
  onSpanSelect?: (info: SpanSelectionInfo) => void;
319
+ /** Callback to load event data for a specific event (lazy loading in sidebar) */
320
+ onLoadEventData?: (
321
+ correlationId: string,
322
+ eventId: string
323
+ ) => Promise<unknown | null>;
224
324
  }) => {
225
325
  const [now, setNow] = useState(() => new Date());
326
+ const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
327
+ null
328
+ );
329
+ const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
330
+ const [deselectTrigger, setDeselectTrigger] = useState(0);
226
331
 
227
332
  useEffect(() => {
228
333
  if (!run?.completedAt) {
@@ -250,27 +355,50 @@ export const WorkflowTraceViewer = ({
250
355
  }
251
356
  }, [error, isLoading]);
252
357
 
253
- const DetailPanel = () => {
254
- const handleSpanSelect = useCallback(
255
- (info: SpanSelectionInfo) => {
256
- onSpanSelect?.(info);
257
- },
258
- [onSpanSelect]
259
- );
358
+ const handleSpanSelect = useCallback(
359
+ (info: SpanSelectionInfo) => {
360
+ onSpanSelect?.(info);
361
+ },
362
+ [onSpanSelect]
363
+ );
260
364
 
365
+ const handleSelectionChange = useCallback(
366
+ (info: SelectedSpanInfo | null) => {
367
+ if (info) {
368
+ // Filter raw events by the selected span's correlationId (stepId/hookId)
369
+ // This bypasses the trace worker pipeline entirely.
370
+ const correlationId = info.spanId;
371
+ const rawEvents = correlationId
372
+ ? events.filter((e) => e.correlationId === correlationId)
373
+ : [];
374
+ setSelectedSpan({ ...info, rawEvents });
375
+ } else {
376
+ setSelectedSpan(null);
377
+ }
378
+ },
379
+ [events]
380
+ );
381
+
382
+ const handleClose = useCallback(() => {
383
+ setSelectedSpan(null);
384
+ setDeselectTrigger((n) => n + 1);
385
+ }, []);
386
+
387
+ const handleResize = useCallback((deltaX: number) => {
388
+ setPanelWidth((w) => Math.max(MIN_PANEL_WIDTH, w + deltaX));
389
+ }, []);
390
+
391
+ // Get the selected span name and duration for the panel header
392
+ const selectedSpanName = useMemo(() => {
393
+ if (!selectedSpan?.data) return undefined;
394
+ const data = selectedSpan.data as Record<string, unknown>;
261
395
  return (
262
- <EntityDetailPanel
263
- run={run}
264
- onStreamClick={onStreamClick}
265
- spanDetailData={spanDetailData ?? null}
266
- spanDetailError={spanDetailError}
267
- spanDetailLoading={spanDetailLoading}
268
- onSpanSelect={handleSpanSelect}
269
- onWakeUpSleep={onWakeUpSleep}
270
- onResolveHook={onResolveHook}
271
- />
396
+ (data.stepName as string) ??
397
+ (data.workflowName as string) ??
398
+ (data.hookId as string) ??
399
+ 'Details'
272
400
  );
273
- };
401
+ }, [selectedSpan?.data]);
274
402
 
275
403
  if (isLoading || !trace) {
276
404
  return (
@@ -288,19 +416,69 @@ export const WorkflowTraceViewer = ({
288
416
  }
289
417
 
290
418
  return (
291
- <div className="relative w-full h-full">
292
- <TraceViewerContextProvider
293
- withPanel
294
- customSpanClassNameFunc={getCustomSpanClassName}
295
- customSpanEventClassNameFunc={getCustomSpanEventClassName}
296
- customPanelComponent={
297
- <ErrorBoundary title="Failed to load entity details">
298
- <DetailPanel />
299
- </ErrorBoundary>
300
- }
301
- >
302
- <TraceViewerTimeline height="100%" trace={trace} withPanel />
303
- </TraceViewerContextProvider>
419
+ <div className="relative w-full h-full flex">
420
+ {/* Timeline (takes remaining space) */}
421
+ <div className="flex-1 min-w-0 relative">
422
+ <TraceViewerContextProvider
423
+ customSpanClassNameFunc={getCustomSpanClassName}
424
+ customSpanEventClassNameFunc={getCustomSpanEventClassName}
425
+ >
426
+ <SelectionBridge onSelectionChange={handleSelectionChange} />
427
+ <DeselectBridge triggerDeselect={deselectTrigger} />
428
+ <TraceViewerTimeline height="100%" trace={trace} />
429
+ </TraceViewerContextProvider>
430
+ </div>
431
+
432
+ {/* Detail panel (rendered outside the context, as a sibling) */}
433
+ {selectedSpan && (
434
+ <div
435
+ className="relative border-l flex-shrink-0 flex flex-col"
436
+ style={{
437
+ width: panelWidth,
438
+ borderColor: 'var(--ds-gray-200)',
439
+ backgroundColor: 'var(--ds-background-100)',
440
+ }}
441
+ >
442
+ <PanelResizeHandle onResize={handleResize} />
443
+ {/* Panel header */}
444
+ <div
445
+ className="flex items-center justify-between px-3 py-2 border-b flex-shrink-0"
446
+ style={{ borderColor: 'var(--ds-gray-200)' }}
447
+ >
448
+ <span
449
+ className="text-sm font-medium truncate"
450
+ style={{ color: 'var(--ds-gray-1000)' }}
451
+ >
452
+ {selectedSpanName}
453
+ </span>
454
+ <button
455
+ type="button"
456
+ aria-label="Close panel"
457
+ onClick={handleClose}
458
+ className="ml-2 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex-shrink-0"
459
+ >
460
+ <X size={16} />
461
+ </button>
462
+ </div>
463
+ {/* Panel body */}
464
+ <div className="flex-1 overflow-y-auto">
465
+ <ErrorBoundary title="Failed to load entity details">
466
+ <EntityDetailPanel
467
+ run={run}
468
+ onStreamClick={onStreamClick}
469
+ spanDetailData={spanDetailData ?? null}
470
+ spanDetailError={spanDetailError}
471
+ spanDetailLoading={spanDetailLoading}
472
+ onSpanSelect={handleSpanSelect}
473
+ onWakeUpSleep={onWakeUpSleep}
474
+ onLoadEventData={onLoadEventData}
475
+ onResolveHook={onResolveHook}
476
+ selectedSpan={selectedSpan}
477
+ />
478
+ </ErrorBoundary>
479
+ </div>
480
+ </div>
481
+ )}
304
482
  </div>
305
483
  );
306
484
  };
@@ -96,7 +96,7 @@ export function waitToSpan(
96
96
  const start = dateToOtelTime(startTime);
97
97
  const end = dateToOtelTime(endTime);
98
98
  const duration = calculateDuration(startTime, endTime);
99
- const spanEvents = convertEventsToSpanEvents(events);
99
+ const spanEvents = convertEventsToSpanEvents(events, false);
100
100
  return {
101
101
  spanId: wait.waitId,
102
102
  name: 'sleep',
@@ -107,7 +107,7 @@ export function waitToSpan(
107
107
  traceFlags: 1,
108
108
  attributes: {
109
109
  resource: 'sleep' as const,
110
- data: wait,
110
+ data: wait, // wait is a plain object built from events, no non-cloneable types
111
111
  },
112
112
  links: [],
113
113
  events: spanEvents,
@@ -128,19 +128,22 @@ export function stepToSpan(
128
128
  const now = nowTime ?? new Date();
129
129
  const parsedName = parseStepName(String(step.stepName));
130
130
 
131
- // Simplified attributes: only store resource type and full data
131
+ // Only embed identification fields not the full object with
132
+ // input/output/error which may contain non-cloneable types.
133
+ // The detail panel fetches full data separately via spanDetailData.
134
+ const { input: _i, output: _o, error: _e, ...stepIdentity } = step;
132
135
  const attributes = {
133
136
  resource: 'step' as const,
134
- data: step,
137
+ data: stepIdentity,
135
138
  };
136
139
 
137
140
  const resource = 'step';
138
141
  const endTime = new Date(step.completedAt ?? now);
139
142
 
140
- // Convert step-related events to span events (for markers like hook_created, step_retrying, etc.)
141
- // This determines which events are displayed as markers. In the detail view,
142
- // we'll show all events that correlate with the selected resource.
143
- const events = convertEventsToSpanEvents(stepEvents);
143
+ // Include ALL correlated events on the span so the sidebar detail view
144
+ // can display them. The timeline uses the `showVerticalLine` flag to
145
+ // determine which events appear as markers.
146
+ const events = convertEventsToSpanEvents(stepEvents, false);
144
147
 
145
148
  // Use createdAt as span start time, with activeStartTime for when execution began
146
149
  // This allows visualization of the "queued" period before execution
@@ -225,7 +228,7 @@ export function hookToSpan(
225
228
  }
226
229
 
227
230
  // Convert hook-related events to span events
228
- const events = convertEventsToSpanEvents(hookEvents);
231
+ const events = convertEventsToSpanEvents(hookEvents, false);
229
232
 
230
233
  // We display hooks as a minimum span size of 10 seconds, just to ensure
231
234
  // it's clickable even if there is no
@@ -262,10 +265,12 @@ export function runToSpan(
262
265
  ): Span {
263
266
  const now = nowTime ?? new Date();
264
267
 
265
- // Simplified attributes: only store resource type and full data
268
+ // Only embed identification fields not the full object with
269
+ // input/output/error which may contain non-cloneable types.
270
+ const { input: _i, output: _o, error: _e, ...runIdentity } = run;
266
271
  const attributes = {
267
272
  resource: 'run' as const,
268
- data: run,
273
+ data: runIdentity,
269
274
  };
270
275
 
271
276
  // Use createdAt as span start time, with activeStartTime for when execution began
@@ -274,7 +279,7 @@ export function runToSpan(
274
279
  const endTime = run.completedAt ?? now;
275
280
 
276
281
  // Convert run-level events to span events
277
- const events = convertEventsToSpanEvents(runEvents);
282
+ const events = convertEventsToSpanEvents(runEvents, false);
278
283
 
279
284
  return {
280
285
  spanId: String(run.runId),
package/src/index.ts CHANGED
@@ -20,6 +20,19 @@ export {
20
20
  isTerminalStatus,
21
21
  shouldShowReenqueueButton,
22
22
  } from './lib/event-analysis';
23
+ export type { Revivers, StreamRef } from './lib/hydration';
24
+ export {
25
+ CLASS_INSTANCE_REF_TYPE,
26
+ ClassInstanceRef,
27
+ extractStreamIds,
28
+ getWebRevivers,
29
+ hydrateResourceIO,
30
+ isClassInstanceRef,
31
+ isStreamId,
32
+ isStreamRef,
33
+ STREAM_REF_TYPE,
34
+ truncateId,
35
+ } from './lib/hydration';
23
36
  export type { StreamStep } from './lib/utils';
24
37
  export {
25
38
  extractConversation,
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Web-specific hydration for o11y display.
3
+ *
4
+ * Browser-safe revivers that use `atob()` for base64 decoding (no Node.js
5
+ * `Buffer` dependency). Produces `ClassInstanceRef` objects and `StreamRef`
6
+ * objects for UI rendering.
7
+ */
8
+
9
+ import {
10
+ ClassInstanceRef,
11
+ extractClassName,
12
+ hydrateResourceIO as hydrateResourceIOGeneric,
13
+ observabilityRevivers,
14
+ type Revivers,
15
+ } from '@workflow/core/serialization-format';
16
+
17
+ // Re-export types and utilities that consumers need
18
+ export {
19
+ CLASS_INSTANCE_REF_TYPE,
20
+ ClassInstanceRef,
21
+ extractStreamIds,
22
+ isClassInstanceRef,
23
+ isStreamId,
24
+ isStreamRef,
25
+ type Revivers,
26
+ STREAM_REF_TYPE,
27
+ type StreamRef,
28
+ truncateId,
29
+ } from '@workflow/core/serialization-format';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Browser-safe base64 decode
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function base64ToArrayBuffer(base64: string): ArrayBuffer {
36
+ if (base64 === '' || base64 === '.') {
37
+ return new ArrayBuffer(0);
38
+ }
39
+ const binaryString = atob(base64);
40
+ const bytes = new Uint8Array(binaryString.length);
41
+ for (let i = 0; i < binaryString.length; i++) {
42
+ bytes[i] = binaryString.charCodeAt(i);
43
+ }
44
+ return bytes.buffer;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Web revivers (browser-safe, no Buffer dependency)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Get the web-specific revivers for hydrating serialized data.
53
+ *
54
+ * Uses `atob()` for base64 decoding (no Node.js Buffer dependency).
55
+ * All types are revived as real instances (Date, Map, Set, URL,
56
+ * URLSearchParams, Headers, Error, etc.).
57
+ */
58
+ export function getWebRevivers(): Revivers {
59
+ function reviveArrayBuffer(value: string): ArrayBuffer {
60
+ return base64ToArrayBuffer(value);
61
+ }
62
+
63
+ return {
64
+ // O11y-specific revivers (streams, step functions → display objects).
65
+ // Spread FIRST so web-specific overrides below take precedence.
66
+ ...observabilityRevivers,
67
+
68
+ // Binary types
69
+ ArrayBuffer: reviveArrayBuffer,
70
+ BigInt: (value: string) => BigInt(value),
71
+ BigInt64Array: (value: string) =>
72
+ new BigInt64Array(reviveArrayBuffer(value)),
73
+ BigUint64Array: (value: string) =>
74
+ new BigUint64Array(reviveArrayBuffer(value)),
75
+ Date: (value) => new Date(value),
76
+ Error: (value) => {
77
+ const error = new Error(value.message);
78
+ error.name = value.name;
79
+ error.stack = value.stack;
80
+ return error;
81
+ },
82
+ Float32Array: (value: string) => new Float32Array(reviveArrayBuffer(value)),
83
+ Float64Array: (value: string) => new Float64Array(reviveArrayBuffer(value)),
84
+ Int8Array: (value: string) => new Int8Array(reviveArrayBuffer(value)),
85
+ Int16Array: (value: string) => new Int16Array(reviveArrayBuffer(value)),
86
+ Int32Array: (value: string) => new Int32Array(reviveArrayBuffer(value)),
87
+ Map: (value) => new Map(value),
88
+ RegExp: (value) => new RegExp(value.source, value.flags),
89
+ Set: (value) => new Set(value),
90
+ Uint8Array: (value: string) => new Uint8Array(reviveArrayBuffer(value)),
91
+ Uint8ClampedArray: (value: string) =>
92
+ new Uint8ClampedArray(reviveArrayBuffer(value)),
93
+ Uint16Array: (value: string) => new Uint16Array(reviveArrayBuffer(value)),
94
+ Uint32Array: (value: string) => new Uint32Array(reviveArrayBuffer(value)),
95
+
96
+ Headers: (value) => new Headers(value),
97
+ URL: (value) => new URL(value),
98
+ URLSearchParams: (value) => new URLSearchParams(value === '.' ? '' : value),
99
+
100
+ // Web-specific overrides for class instances
101
+ Class: (value) => `<class:${extractClassName(value.classId)}>`,
102
+ Instance: (value) =>
103
+ new ClassInstanceRef(
104
+ extractClassName(value.classId),
105
+ value.classId,
106
+ value.data
107
+ ),
108
+ };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Pre-built web revivers (cached for performance)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ let cachedRevivers: Revivers | null = null;
116
+
117
+ function getRevivers(): Revivers {
118
+ if (!cachedRevivers) {
119
+ cachedRevivers = getWebRevivers();
120
+ }
121
+ return cachedRevivers;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Public API
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Hydrate the serialized data fields of a resource for web display.
130
+ *
131
+ * Uses browser-safe revivers (atob for base64, ClassInstanceRef for
132
+ * custom classes, StreamRef for streams). Call this on data received
133
+ * from the server before passing it to UI components.
134
+ */
135
+ export function hydrateResourceIO<T>(resource: T): T {
136
+ return hydrateResourceIOGeneric(resource as any, getRevivers()) as T;
137
+ }