@vobs/devtools 1.0.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/src/index.ts ADDED
@@ -0,0 +1,2189 @@
1
+ import {
2
+ getDebugHooks,
3
+ getOwnerDebugName,
4
+ getSignalDebugName,
5
+ setDebugHooks,
6
+ untrack,
7
+ type Dependency,
8
+ type Effect,
9
+ type Owner,
10
+ type ReactivityDebugHooks,
11
+ type Signal,
12
+ type Subscriber
13
+ } from '@vobs/reactivity'
14
+ import {
15
+ getRuntimeDebugHooks,
16
+ getRuntimeDebugContext,
17
+ setRuntimeDebugHooks,
18
+ type RuntimeDebugHooks,
19
+ type RuntimeDomMutation,
20
+ type RuntimeErrorEvent,
21
+ type RuntimeHydrationMismatch
22
+ } from '@vobs/runtime'
23
+ import { normalizeVobsError } from '@vobs/runtime'
24
+ import type { VobsContext, VobsPlugin } from '@vobs/vobs'
25
+ import {
26
+ getHTTPDebugHooks,
27
+ setHTTPDebugHooks,
28
+ type HTTPDebugRequest
29
+ } from '@vobs/http'
30
+
31
+ export type DependencyEdgeType =
32
+ | 'state-to-effect'
33
+ | 'state-to-memo'
34
+ | 'memo-to-effect'
35
+ | 'memo-to-memo'
36
+
37
+ export interface ComponentDebugNode {
38
+ readonly id: string
39
+ readonly name: string
40
+ readonly ownerId: string
41
+ readonly signals: readonly string[]
42
+ readonly effects: readonly string[]
43
+ readonly recentUpdates: readonly string[]
44
+ readonly domUpdates: number
45
+ readonly children: readonly ComponentDebugNode[]
46
+ readonly mounted: boolean
47
+ }
48
+
49
+ export interface SignalDebugInfo {
50
+ readonly id: string
51
+ readonly name: string
52
+ readonly value: unknown
53
+ readonly component: string
54
+ readonly subscribers: number
55
+ readonly createdAt: number
56
+ readonly kind?: 'state' | 'memo'
57
+ }
58
+
59
+ export interface DebugErrorInfo {
60
+ readonly name: string
61
+ readonly message: string
62
+ readonly stack?: string
63
+ readonly phase?: string
64
+ readonly source?: string
65
+ readonly code?: string
66
+ readonly hint?: string
67
+ readonly cause?: string
68
+ readonly fix?: string
69
+ readonly location?: { readonly file: string; readonly line: number; readonly column: number }
70
+ readonly hydration?: RuntimeHydrationMismatch
71
+ }
72
+
73
+ export interface EffectDebugInfo {
74
+ readonly id: string
75
+ readonly name: string
76
+ readonly component: string
77
+ readonly dependencies: readonly string[]
78
+ readonly status: 'idle' | 'dirty' | 'running' | 'success' | 'error'
79
+ readonly executionCount: number
80
+ readonly lastExecutionTime: number
81
+ readonly lastRunStatus?: 'success' | 'error' | 'cancelled'
82
+ readonly lastDuration?: number
83
+ readonly lastUpdateId?: string
84
+ readonly lastError?: DebugErrorInfo
85
+ readonly lastDomUpdates?: number
86
+ }
87
+
88
+ export interface DependencyEdge {
89
+ readonly from: string
90
+ readonly to: string
91
+ readonly type: DependencyEdgeType
92
+ }
93
+
94
+ export interface EffectExecutionInfo {
95
+ readonly effectId: string
96
+ readonly component: string
97
+ readonly duration: number
98
+ readonly domUpdates: number
99
+ readonly status?: 'success' | 'error' | 'cancelled'
100
+ readonly error?: DebugErrorInfo
101
+ }
102
+
103
+ export interface DomUpdateInfo {
104
+ readonly operation: 'text' | 'property' | 'attribute' | 'insert' | 'remove'
105
+ readonly target: string
106
+ readonly parent?: string
107
+ readonly key?: string
108
+ readonly previousValue?: unknown
109
+ readonly nextValue?: unknown
110
+ readonly effectId?: string
111
+ readonly route?: string
112
+ readonly navigationId?: number
113
+ readonly requestId?: number
114
+ }
115
+
116
+ export interface UpdateTrace {
117
+ readonly id: string
118
+ readonly signalId: string
119
+ readonly signalName: string
120
+ readonly previousValue: unknown
121
+ readonly nextValue: unknown
122
+ readonly timestamp: number
123
+ readonly effects: readonly EffectExecutionInfo[]
124
+ readonly affectedSignals: readonly string[]
125
+ readonly affectedEffects: readonly string[]
126
+ readonly domUpdates: readonly DomUpdateInfo[]
127
+ readonly status: 'completed' | 'error' | 'cancelled'
128
+ readonly error?: DebugErrorInfo
129
+ readonly duration: number
130
+ readonly route?: string
131
+ readonly navigationId?: number
132
+ readonly requestIds?: readonly number[]
133
+ }
134
+
135
+ export type LifecycleEventType =
136
+ | 'owner-created' | 'owner-named' | 'owner-disposed'
137
+ | 'signal-created' | 'signal-named' | 'signal-changed' | 'signal-disposed'
138
+ | 'memo-created' | 'memo-invalidated'
139
+ | 'effect-created' | 'effect-invalidated' | 'effect-run-start' | 'effect-run' | 'effect-disposed'
140
+
141
+ export interface LifecycleEvent {
142
+ readonly id: string
143
+ readonly type: LifecycleEventType
144
+ readonly timestamp: number
145
+ readonly targetId: string
146
+ readonly name?: string
147
+ readonly ownerId?: string
148
+ readonly status?: 'success' | 'error' | 'cancelled'
149
+ }
150
+
151
+ export interface NetworkRequestTrace {
152
+ readonly id: number
153
+ readonly url: string
154
+ readonly method: string
155
+ readonly status: 'loading' | 'retrying' | 'success' | 'error' | 'cancelled'
156
+ readonly headers: Readonly<Record<string, string>>
157
+ readonly requestBody?: unknown
158
+ readonly startedAt: number
159
+ readonly endedAt?: number
160
+ readonly duration?: number
161
+ readonly attempt: number
162
+ readonly retries: number
163
+ readonly responseStatus?: number
164
+ readonly responseBody?: unknown
165
+ readonly error?: { readonly name: string; readonly message: string }
166
+ readonly source?: 'http' | 'router' | 'ssr'
167
+ readonly test?: boolean
168
+ readonly route?: string
169
+ readonly navigationId?: number
170
+ readonly dataRequestId?: number
171
+ readonly environment?: 'client' | 'server'
172
+ }
173
+
174
+ export type DevToolsErrorPhase = 'effect' | 'network' | 'global' | 'unhandledrejection' | 'route' | 'hydration' | 'render' | 'boundary' | 'application' | 'event'
175
+ export type DevToolsErrorOrigin = 'framework' | 'usage' | 'application' | 'unknown'
176
+ export type DevToolsErrorRecovery = 'propagated' | 'handled' | 'fallback' | 'retrying' | 'recovered'
177
+
178
+ export interface DevToolsErrorContext {
179
+ readonly updateId?: string
180
+ readonly effectId?: string
181
+ readonly requestId?: number
182
+ readonly navigationId?: number
183
+ readonly route?: string
184
+ readonly source?: string
185
+ readonly ownerId?: string
186
+ readonly component?: string
187
+ readonly origin?: DevToolsErrorOrigin
188
+ readonly code?: string
189
+ readonly hint?: string
190
+ readonly handled?: boolean
191
+ readonly recovery?: DevToolsErrorRecovery
192
+ }
193
+
194
+ export interface DevToolsErrorTrace extends DebugErrorInfo {
195
+ readonly id: number
196
+ readonly phase: DevToolsErrorPhase
197
+ readonly firstOccurredAt: number
198
+ readonly lastOccurredAt: number
199
+ readonly count: number
200
+ readonly phases: readonly DevToolsErrorPhase[]
201
+ readonly origin: DevToolsErrorOrigin
202
+ readonly handled: boolean
203
+ readonly recovery: DevToolsErrorRecovery
204
+ readonly updateId?: string
205
+ readonly effectId?: string
206
+ readonly requestId?: number
207
+ readonly navigationId?: number
208
+ readonly route?: string
209
+ readonly ownerId?: string
210
+ readonly component?: string
211
+ }
212
+
213
+ export interface DevToolsCollectionState {
214
+ readonly paused: boolean
215
+ }
216
+
217
+ export interface DevToolsPrivacyOptions {
218
+ /** Additional case-insensitive header names or fragments to omit. */
219
+ readonly redactedHeaders?: readonly string[]
220
+ /** Object keys whose values are replaced in captured/exported values. */
221
+ readonly redactedFields?: readonly string[]
222
+ /** Replace DOM values in diagnostics with the configured replacement. */
223
+ readonly redactDomValues?: boolean
224
+ readonly replacement?: string
225
+ }
226
+
227
+ export interface DevToolsPerformanceEntry {
228
+ readonly kind: 'update' | 'effect' | 'request'
229
+ readonly id: string | number
230
+ readonly label: string
231
+ readonly duration: number
232
+ readonly timestamp: number
233
+ readonly status: string
234
+ }
235
+
236
+ export interface DevToolsInspector {
237
+ readonly label?: string
238
+ inspect(value: unknown): unknown
239
+ }
240
+
241
+ export interface DevToolsTimeline {
242
+ readonly label?: string
243
+ readonly getEvents?: () => readonly DevToolsTimelineEvent[]
244
+ }
245
+
246
+ export interface DevToolsTimelineEvent {
247
+ readonly id: string
248
+ readonly timestamp: number
249
+ readonly title: string
250
+ readonly data?: unknown
251
+ }
252
+
253
+ export interface DevToolsMetric {
254
+ readonly label?: string
255
+ read(): number | string
256
+ }
257
+
258
+ export interface DevToolsExtensionSnapshot {
259
+ readonly inspectors: readonly string[]
260
+ readonly timelines: readonly string[]
261
+ readonly timelineEvents: Readonly<Record<string, readonly DevToolsTimelineEvent[]>>
262
+ readonly metrics: Readonly<Record<string, number | string>>
263
+ }
264
+
265
+ export interface DevToolsDiagnosticSnapshot {
266
+ readonly version: 1
267
+ readonly exportedAt: number
268
+ readonly updates: readonly UpdateTrace[]
269
+ readonly lifecycle: readonly LifecycleEvent[]
270
+ readonly network: readonly NetworkRequestTrace[]
271
+ readonly errors: readonly DevToolsErrorTrace[]
272
+ readonly performance: PerformanceMetrics
273
+ readonly memory: MemorySnapshot
274
+ readonly extensions: DevToolsExtensionSnapshot
275
+ }
276
+
277
+ export interface DevToolsSSRRequestSnapshot {
278
+ readonly version: 1
279
+ readonly environment: 'server'
280
+ readonly requests: readonly HTTPDebugRequest[]
281
+ }
282
+
283
+ export type DevToolsSelection =
284
+ | { readonly type: 'component'; readonly id: string }
285
+ | { readonly type: 'signal'; readonly id: string }
286
+ | { readonly type: 'effect'; readonly id: string }
287
+ | { readonly type: 'update'; readonly id: string }
288
+ | { readonly type: 'request'; readonly id: number }
289
+ | { readonly type: 'error'; readonly id: number }
290
+
291
+ export const DEVTOOLS_REACTIVITY_EVENTS = [
292
+ 'owner-created', 'owner-named', 'owner-disposed',
293
+ 'signal-created', 'signal-named', 'signal-update', 'signal-disposed',
294
+ 'effect-created', 'effect-invalidated', 'effect-run-start', 'effect-run', 'effect-disposed',
295
+ 'memo-created', 'memo-update', 'update'
296
+ ] as const
297
+
298
+ export const DEVTOOLS_EVENTS = [...DEVTOOLS_REACTIVITY_EVENTS, 'lifecycle', 'dom-update', 'network-request', 'router', 'error', 'collection', 'collection-cleared', 'extension'] as const
299
+
300
+ export interface PerformanceMetrics {
301
+ readonly updateCount: number
302
+ readonly averageUpdateDuration: number
303
+ readonly slowUpdateCount: number
304
+ readonly effectExecutionCount: number
305
+ readonly slowEffectCount: number
306
+ readonly slowRequestCount: number
307
+ readonly maxUpdateDuration: number
308
+ readonly maxEffectDuration: number
309
+ readonly maxRequestDuration: number
310
+ }
311
+
312
+ export interface MemorySnapshot {
313
+ readonly signalCount: number
314
+ readonly effectCount: number
315
+ readonly ownerCount: number
316
+ readonly dependencyEdgeCount: number
317
+ readonly leakedOwners: number
318
+ }
319
+
320
+ export interface DevToolsTarget {
321
+ __VOBS_DEVTOOLS__?: DevToolsAPI
322
+ addEventListener?: (type: string, listener: (...args: any[]) => void) => void
323
+ removeEventListener?: (type: string, listener: (...args: any[]) => void) => void
324
+ }
325
+
326
+ export interface DevToolsOptions {
327
+ readonly expose?: boolean
328
+ readonly target?: DevToolsTarget
329
+ readonly maxUpdates?: number
330
+ readonly slowUpdateThreshold?: number
331
+ readonly privacy?: DevToolsPrivacyOptions
332
+ /** Explicit opt-in for state-changing debug actions. Disabled by default. */
333
+ readonly allowMutations?: boolean
334
+ /** Optional Router-like source for associating navigation/data events. */
335
+ readonly router?: DevToolsRouterSource
336
+ }
337
+
338
+ export interface DevToolsRouterSource {
339
+ readonly devtools: {
340
+ subscribe(event: string, callback: (payload: unknown) => void): () => void
341
+ }
342
+ }
343
+
344
+ export interface DevToolsAPI {
345
+ getComponentTree(): readonly ComponentDebugNode[]
346
+ getComponent(ownerId: string): ComponentDebugNode | null
347
+ getSignals(): readonly SignalDebugInfo[]
348
+ getSignal(signalId: string): SignalDebugInfo | null
349
+ canMutate(): boolean
350
+ setSignalValue(signalId: string, value: unknown): boolean
351
+ getDependencies(signalId: string): readonly DependencyEdge[]
352
+ getDependents(subscriberId: string): readonly DependencyEdge[]
353
+ getEffects(): readonly EffectDebugInfo[]
354
+ getUpdates(): readonly UpdateTrace[]
355
+ getLifecycleEvents(): readonly LifecycleEvent[]
356
+ getNetworkRequests(): readonly NetworkRequestTrace[]
357
+ importSSRRequests(snapshot: unknown): void
358
+ getRouterContext(): DevToolsRouterContext | null
359
+ attachRouter(router: DevToolsRouterSource): () => void
360
+ getErrors(): readonly DevToolsErrorTrace[]
361
+ getPerformanceEntries(): readonly DevToolsPerformanceEntry[]
362
+ getCollectionState(): DevToolsCollectionState
363
+ setCollectionPaused(paused: boolean): void
364
+ clearUpdates(): void
365
+ clearNetworkRequests(): void
366
+ clearErrors(): void
367
+ clearLifecycleEvents(): void
368
+ reportError(phase: DevToolsErrorPhase, error: unknown, context?: DevToolsErrorContext): void
369
+ getPerformanceMetrics(): PerformanceMetrics
370
+ takeMemorySnapshot(): MemorySnapshot
371
+ exportDiagnostics(): DevToolsDiagnosticSnapshot
372
+ importDiagnostics(snapshot: unknown): void
373
+ registerInspector(id: string, inspector: DevToolsInspector): () => void
374
+ registerTimeline(id: string, timeline?: DevToolsTimeline): () => void
375
+ registerMetric(id: string, metric: DevToolsMetric): () => void
376
+ getExtensionSnapshot(): DevToolsExtensionSnapshot
377
+ inspectExtension(id: string, value: unknown): unknown
378
+ onUpdate(callback: (trace: UpdateTrace) => void): () => void
379
+ subscribe(event: string, callback: (...args: any[]) => void): () => void
380
+ dispose(): void
381
+ }
382
+
383
+ export interface DevToolsRouterContext {
384
+ readonly route?: string
385
+ readonly navigationId?: number
386
+ readonly navigationStatus?: string
387
+ readonly dataRequests: readonly {
388
+ readonly id: number
389
+ readonly kind: string
390
+ readonly key: string
391
+ readonly route?: string
392
+ readonly status: string
393
+ readonly startedAt?: number
394
+ readonly endedAt?: number
395
+ readonly duration?: number
396
+ readonly navigationId?: number
397
+ readonly trigger?: string
398
+ readonly result?: unknown
399
+ readonly error?: string
400
+ readonly environment?: 'client' | 'server'
401
+ }[]
402
+ }
403
+
404
+ export interface DevToolsWireRequest {
405
+ readonly source: 'vobs-devtools'
406
+ readonly type: 'request'
407
+ readonly id: string
408
+ readonly method: string
409
+ readonly args?: readonly unknown[]
410
+ }
411
+
412
+ export interface DevToolsWireResponse {
413
+ readonly source: 'vobs-devtools'
414
+ readonly type: 'response'
415
+ readonly id: string
416
+ readonly ok: boolean
417
+ readonly result?: unknown
418
+ readonly error?: string
419
+ }
420
+
421
+ export interface DevToolsWireEvent {
422
+ readonly source: 'vobs-devtools'
423
+ readonly type: 'event'
424
+ readonly event: string
425
+ readonly payload: unknown
426
+ }
427
+
428
+ export interface DevToolsMessageTarget {
429
+ addEventListener(type: string, listener: (event: DevToolsMessageEvent) => void): void
430
+ removeEventListener(type: string, listener: (event: DevToolsMessageEvent) => void): void
431
+ postMessage(message: unknown, targetOrigin: string): void
432
+ }
433
+
434
+ export interface DevToolsMessageEvent {
435
+ readonly data?: unknown
436
+ readonly source?: unknown
437
+ }
438
+
439
+ export interface DevToolsBridgeOptions {
440
+ readonly target?: DevToolsMessageTarget
441
+ readonly api?: DevToolsAPI | null
442
+ }
443
+
444
+ export interface DevToolsPluginOptions extends DevToolsOptions {
445
+ readonly enabled?: boolean
446
+ }
447
+
448
+ interface OwnerRecord {
449
+ readonly owner: Owner
450
+ readonly id: string
451
+ readonly parentId: string | null
452
+ name: string
453
+ disposed: boolean
454
+ }
455
+
456
+ interface SignalRecord {
457
+ readonly signal: Signal<unknown>
458
+ readonly id: string
459
+ readonly ownerId: string | null
460
+ readonly createdAt: number
461
+ name: string
462
+ explicitName: boolean
463
+ kind: 'state' | 'memo'
464
+ disposed: boolean
465
+ }
466
+
467
+ interface EffectRecord {
468
+ readonly effect: Effect
469
+ readonly id: string
470
+ readonly ownerId: string | null
471
+ status: 'idle' | 'dirty' | 'running' | 'success' | 'error'
472
+ executionCount: number
473
+ lastExecutionTime: number
474
+ lastRunStatus: 'success' | 'error' | 'cancelled' | undefined
475
+ lastDuration: number
476
+ lastUpdateId: string | undefined
477
+ lastError: DebugErrorInfo | undefined
478
+ lastDomUpdates: number
479
+ runningSince: number | null
480
+ disposed: boolean
481
+ }
482
+
483
+ interface PendingUpdate {
484
+ readonly id: string
485
+ readonly signal: SignalRecord
486
+ readonly timestamp: number
487
+ previousValue: unknown
488
+ nextValue: unknown
489
+ readonly affectedSignals: Set<string>
490
+ readonly effectIds: Set<string>
491
+ readonly executions: Map<string, EffectExecutionInfo>
492
+ readonly domUpdates: DomUpdateInfo[]
493
+ status: 'completed' | 'error' | 'cancelled'
494
+ error: DebugErrorInfo | undefined
495
+ context: {
496
+ readonly route?: string
497
+ readonly navigationId?: number
498
+ readonly requestIds: Set<number>
499
+ }
500
+ }
501
+
502
+ let activeDevTools: DevToolsAPI | null = null
503
+
504
+ export function createDevTools(options: DevToolsOptions = {}): DevToolsAPI {
505
+ activeDevTools?.dispose()
506
+
507
+ const owners = new Map<string, OwnerRecord>()
508
+ const ownerIds = new WeakMap<object, string>()
509
+ const signals = new Map<string, SignalRecord>()
510
+ const signalIds = new WeakMap<object, string>()
511
+ const effects = new Map<string, EffectRecord>()
512
+ const effectIds = new WeakMap<object, string>()
513
+ const memoSignalIds = new WeakMap<object, string>()
514
+ const edges = new Map<string, DependencyEdge>()
515
+ const listeners = new Map<string, Set<(...args: any[]) => void>>()
516
+ const updates: UpdateTrace[] = []
517
+ const lifecycleEvents: LifecycleEvent[] = []
518
+ const networkRequests = new Map<number, NetworkRequestTrace>()
519
+ const errors = new Map<string, DevToolsErrorTrace>()
520
+ const inspectors = new Map<string, DevToolsInspector>()
521
+ const timelines = new Map<string, DevToolsTimeline>()
522
+ const metrics = new Map<string, DevToolsMetric>()
523
+ let routerContext: DevToolsRouterContext | null = null
524
+ const routerStops = new Map<DevToolsRouterSource, { readonly stop: () => void; refs: number }>()
525
+ let nextErrorId = 1
526
+ let collectionPaused = false
527
+ const pendingUpdates: PendingUpdate[] = []
528
+ const updateDurations: number[] = []
529
+ const maxUpdates = Math.max(1, Math.floor(options.maxUpdates ?? 100))
530
+ const slowUpdateThreshold = Math.max(0, options.slowUpdateThreshold ?? 16)
531
+ let nextId = 1
532
+ let updateCount = 0
533
+ let effectExecutionCount = 0
534
+ let disposed = false
535
+ let pendingFlushScheduled = false
536
+ let activeEffectId: string | null = null
537
+ const privacy = normalizePrivacyOptions(options.privacy)
538
+ const allowMutations = options.allowMutations === true
539
+
540
+ function now(): number {
541
+ return typeof performance === 'undefined' ? Date.now() : performance.now()
542
+ }
543
+
544
+ function emit(event: string, ...args: any[]): void {
545
+ if (collectionPaused && event !== 'collection' && event !== 'collection-cleared') return
546
+ const callbacks = [
547
+ ...(listeners.get(event) ?? []),
548
+ ...(listeners.get('*') ?? [])
549
+ ]
550
+ for (const callback of callbacks) {
551
+ try {
552
+ callback(...args)
553
+ } catch {
554
+ // A DevTools listener must never affect the application.
555
+ }
556
+ }
557
+ }
558
+
559
+ function safeExtensionCall<T>(read: () => T, fallback: T): T {
560
+ try { return read() } catch { return fallback }
561
+ }
562
+
563
+ function getExtensionSnapshot(): DevToolsExtensionSnapshot {
564
+ const extensionMetrics: Record<string, number | string> = {}
565
+ const extensionTimelineEvents: Record<string, readonly DevToolsTimelineEvent[]> = {}
566
+ for (const [id, timeline] of timelines) {
567
+ const events = safeExtensionCall(() => timeline.getEvents?.() ?? [], [])
568
+ extensionTimelineEvents[id] = events.map(event => ({
569
+ ...event,
570
+ data: serializeForDevTools(event.data, new Set<object>(), 0, privacy)
571
+ })).slice(-maxUpdates)
572
+ }
573
+ for (const [id, metric] of metrics) {
574
+ const value = safeExtensionCall(() => metric.read(), 0)
575
+ extensionMetrics[id] = typeof value === 'number' || typeof value === 'string' ? value : 0
576
+ }
577
+ return {
578
+ inspectors: [...inspectors.keys()],
579
+ timelines: [...timelines.keys()],
580
+ timelineEvents: extensionTimelineEvents,
581
+ metrics: extensionMetrics
582
+ }
583
+ }
584
+
585
+ function recordLifecycle(
586
+ type: LifecycleEventType,
587
+ targetId: string,
588
+ name?: string,
589
+ ownerId?: string,
590
+ status?: LifecycleEvent['status']
591
+ ): void {
592
+ if (disposed || collectionPaused || isInternalOwnerId(targetId) || (ownerId !== undefined && isInternalOwnerId(ownerId))) return
593
+ const event: LifecycleEvent = {
594
+ id: `lifecycle-${nextId++}`,
595
+ type,
596
+ timestamp: now(),
597
+ targetId,
598
+ name,
599
+ ownerId,
600
+ status
601
+ }
602
+ lifecycleEvents.push(event)
603
+ while (lifecycleEvents.length > maxUpdates) lifecycleEvents.shift()
604
+ emit('lifecycle', event)
605
+ }
606
+
607
+ function reportError(
608
+ phase: DevToolsErrorPhase,
609
+ error: unknown,
610
+ context: DevToolsErrorContext = {}
611
+ ): void {
612
+ if (collectionPaused) return
613
+ const debugError = toDebugError(error, phase)
614
+ const metadata = classifyError(phase, error, context)
615
+ const code = context.code ?? debugError.code ?? metadata.code
616
+ const hint = context.hint ?? debugError.hint ?? metadata.hint
617
+ const origin = context.origin ?? metadata.origin
618
+ const ownerId = context.ownerId ?? readErrorProperty(error, 'vobsOwnerId')
619
+ const component = context.component ?? readErrorProperty(error, 'vobsComponent')
620
+ const source = context.source ?? debugError.source ?? readErrorProperty(error, 'vobsSource')
621
+ const activeRoute = context.route ?? getRuntimeDebugContext()?.route ?? routerContext?.route
622
+ const activeNavigationId = context.navigationId ?? getRuntimeDebugContext()?.navigationId ?? routerContext?.navigationId
623
+ // The same exception can pass through effect -> boundary -> application
624
+ // handlers. Keep one diagnostic and accumulate its phases instead of
625
+ // presenting the propagation chain as duplicate errors.
626
+ const key = diagnosticErrorKey({ origin, code, name: debugError.name, message: debugError.message, source, component })
627
+ const previous = errors.get(key)
628
+ const nowValue = Date.now()
629
+ const trace: DevToolsErrorTrace = previous
630
+ ? {
631
+ ...previous,
632
+ ...context,
633
+ phase,
634
+ phases: previous.phases.includes(phase) ? previous.phases : [...previous.phases, phase],
635
+ lastOccurredAt: nowValue,
636
+ count: previous.count + 1,
637
+ code,
638
+ hint,
639
+ origin,
640
+ handled: context.handled ?? previous.handled,
641
+ recovery: mergeRecovery(previous.recovery, context.recovery),
642
+ ownerId: ownerId ?? previous.ownerId,
643
+ component: component ?? previous.component,
644
+ source: source ?? previous.source,
645
+ updateId: context.updateId ?? previous.updateId,
646
+ effectId: context.effectId ?? previous.effectId,
647
+ requestId: context.requestId ?? previous.requestId,
648
+ route: activeRoute ?? previous.route,
649
+ navigationId: activeNavigationId ?? previous.navigationId
650
+ }
651
+ : {
652
+ ...debugError,
653
+ ...context,
654
+ source,
655
+ code,
656
+ hint,
657
+ id: nextErrorId++,
658
+ phase,
659
+ phases: [phase],
660
+ origin,
661
+ handled: context.handled ?? false,
662
+ recovery: context.recovery ?? 'propagated',
663
+ firstOccurredAt: nowValue,
664
+ lastOccurredAt: nowValue,
665
+ count: 1,
666
+ ownerId,
667
+ component,
668
+ route: activeRoute,
669
+ navigationId: activeNavigationId
670
+ }
671
+ errors.set(key, trace)
672
+ while (errors.size > maxUpdates) {
673
+ const oldest = errors.keys().next().value
674
+ if (typeof oldest !== 'string') break
675
+ errors.delete(oldest)
676
+ }
677
+ emit('error', trace)
678
+ }
679
+
680
+ function ensureOwner(owner: Owner): OwnerRecord {
681
+ const existingId = ownerIds.get(owner)
682
+ if (existingId) {
683
+ const existing = owners.get(existingId)
684
+ if (existing) return existing
685
+ }
686
+
687
+ const record: OwnerRecord = {
688
+ owner,
689
+ id: owner.id,
690
+ parentId: owner.parent?.id ?? null,
691
+ name: getOwnerDebugName(owner) ?? (owner.parent ? 'Owner' : 'App'),
692
+ disposed: owner.disposed
693
+ }
694
+ ownerIds.set(owner, record.id)
695
+ owners.set(record.id, record)
696
+ return record
697
+ }
698
+
699
+ function isInternalOwnerId(ownerId: string | null | undefined): boolean {
700
+ const visited = new Set<string>()
701
+ let current = ownerId === undefined ? null : ownerId
702
+ while (current && !visited.has(current)) {
703
+ visited.add(current)
704
+ const record = owners.get(current)
705
+ if (!record) return false
706
+ if (debugComponentName(record.name).startsWith('DevTools')) return true
707
+ current = record.parentId
708
+ }
709
+ return false
710
+ }
711
+
712
+ function isInternalSignal(record: SignalRecord): boolean {
713
+ return isInternalOwnerId(record.ownerId)
714
+ }
715
+
716
+ function isNoisyViewportSignal(record: SignalRecord): boolean {
717
+ return record.explicitName
718
+ && (record.name === 'layout.viewport.width' || record.name === 'layout.viewport.height')
719
+ }
720
+
721
+ function hasMeaningfulDomUpdate(updates: readonly DomUpdateInfo[]): boolean {
722
+ return updates.some(update => update.operation === 'insert'
723
+ || update.operation === 'remove'
724
+ || !Object.is(update.previousValue, update.nextValue))
725
+ }
726
+
727
+ function isInternalEffect(record: EffectRecord): boolean {
728
+ return isInternalOwnerId(record.ownerId)
729
+ }
730
+
731
+ function errorComponentForOwner(ownerId: string | undefined): string | undefined {
732
+ if (!ownerId) return undefined
733
+ const record = owners.get(ownerId)
734
+ if (!record) return undefined
735
+ if (record.name !== 'Owner' && record.name !== 'dynamic') return record.name
736
+ return errorComponentForOwner(record.parentId ?? undefined)
737
+ }
738
+
739
+ function ensureSignal(signal: Signal<unknown>, owner: Owner | null = null): SignalRecord {
740
+ const existingId = signalIds.get(signal)
741
+ if (existingId) {
742
+ const existing = signals.get(existingId)
743
+ if (existing) return existing
744
+ }
745
+
746
+ const id = `signal-${nextId++}`
747
+ const explicitName = getSignalDebugName(signal)
748
+ const ownerName = owner ? getOwnerDebugName(owner) : undefined
749
+ const record: SignalRecord = {
750
+ signal,
751
+ id,
752
+ ownerId: owner ? ensureOwner(owner).id : null,
753
+ createdAt: Date.now(),
754
+ name: explicitName ?? (ownerName ? `${ownerName} state` : 'runtime state'),
755
+ explicitName: explicitName !== undefined,
756
+ kind: 'state',
757
+ disposed: false
758
+ }
759
+ signalIds.set(signal, id)
760
+ signals.set(id, record)
761
+ return record
762
+ }
763
+
764
+ function ensureEffect(effect: Effect, owner: Owner | null = null): EffectRecord {
765
+ const existingId = effectIds.get(effect)
766
+ if (existingId) {
767
+ const existing = effects.get(existingId)
768
+ if (existing) return existing
769
+ }
770
+
771
+ const id = `effect-${nextId++}`
772
+ const record: EffectRecord = {
773
+ effect,
774
+ id,
775
+ ownerId: owner ? ensureOwner(owner).id : null,
776
+ status: 'dirty',
777
+ executionCount: 0,
778
+ lastExecutionTime: 0,
779
+ lastRunStatus: undefined,
780
+ lastDuration: 0,
781
+ lastUpdateId: undefined,
782
+ lastError: undefined,
783
+ lastDomUpdates: 0,
784
+ runningSince: null,
785
+ disposed: effect.disposed
786
+ }
787
+ effectIds.set(effect, id)
788
+ effects.set(id, record)
789
+ return record
790
+ }
791
+
792
+ function ensureDependencySignal(dependency: Dependency): SignalRecord | null {
793
+ const existingId = signalIds.get(dependency as object)
794
+ if (existingId) return signals.get(existingId) ?? null
795
+ if (!('value' in (dependency as object))) return null
796
+ return ensureSignal(dependency as Signal<unknown>)
797
+ }
798
+
799
+ function edgeKey(from: string, to: string): string {
800
+ return `${from}->${to}`
801
+ }
802
+
803
+ function trackDependency(dependency: Dependency, subscriber: Subscriber): void {
804
+ const source = ensureDependencySignal(dependency)
805
+ if (!source) return
806
+
807
+ const memoId = memoSignalIds.get(subscriber as object)
808
+ const effectId = effectIds.get(subscriber as object)
809
+ const targetId = memoId ?? effectId
810
+ if (!targetId) return
811
+
812
+ const type: DependencyEdgeType = memoId
813
+ ? source.kind === 'memo' ? 'memo-to-memo' : 'state-to-memo'
814
+ : source.kind === 'memo' ? 'memo-to-effect' : 'state-to-effect'
815
+ edges.set(edgeKey(source.id, targetId), {
816
+ from: source.id,
817
+ to: targetId,
818
+ type
819
+ })
820
+ }
821
+
822
+ function collectEffectIds(signalId: string): Set<string> {
823
+ const effectsForSignal = new Set<string>()
824
+ const visited = new Set<string>()
825
+ const visit = (sourceId: string): void => {
826
+ if (visited.has(sourceId)) return
827
+ visited.add(sourceId)
828
+ for (const edge of edges.values()) {
829
+ if (edge.from !== sourceId) continue
830
+ if (effects.has(edge.to)) {
831
+ const effect = effects.get(edge.to)
832
+ if (effect && !isInternalEffect(effect)) effectsForSignal.add(edge.to)
833
+ }
834
+ else visit(edge.to)
835
+ }
836
+ }
837
+ visit(signalId)
838
+ return effectsForSignal
839
+ }
840
+
841
+ function collectAffectedSignals(signalId: string): Set<string> {
842
+ const affected = new Set<string>()
843
+ const visited = new Set<string>()
844
+ const visit = (sourceId: string): void => {
845
+ if (visited.has(sourceId)) return
846
+ visited.add(sourceId)
847
+ if (signals.has(sourceId)) affected.add(sourceId)
848
+ for (const edge of edges.values()) {
849
+ if (edge.from === sourceId && signals.has(edge.to)) visit(edge.to)
850
+ }
851
+ }
852
+ visit(signalId)
853
+ return affected
854
+ }
855
+
856
+ function untrackDependency(dependency: Dependency, subscriber: Subscriber): void {
857
+ const source = ensureDependencySignal(dependency)
858
+ if (!source) return
859
+ const targetId = memoSignalIds.get(subscriber as object) ?? effectIds.get(subscriber as object)
860
+ if (targetId) edges.delete(edgeKey(source.id, targetId))
861
+ }
862
+
863
+ function signalInfo(record: SignalRecord, readValue = true): SignalDebugInfo {
864
+ return {
865
+ id: record.id,
866
+ name: record.name,
867
+ value: readValue ? serializeForDevTools(readSignal(record.signal), new Set<object>(), 0, privacy) : undefined,
868
+ component: record.ownerId ? owners.get(record.ownerId)?.name ?? 'unknown' : 'unknown',
869
+ subscribers: [...edges.values()].filter(edge => edge.from === record.id).length,
870
+ createdAt: record.createdAt,
871
+ kind: record.kind
872
+ }
873
+ }
874
+
875
+ function readSignal(signal: Signal<unknown>): unknown {
876
+ try {
877
+ return untrack(() => signal.value)
878
+ } catch (error) {
879
+ return { type: 'thrown', message: toErrorMessage(error) }
880
+ }
881
+ }
882
+
883
+ function effectInfo(record: EffectRecord): EffectDebugInfo {
884
+ const component = record.ownerId ? owners.get(record.ownerId)?.name ?? 'unknown' : 'runtime'
885
+ return {
886
+ id: record.id,
887
+ name: `${debugComponentName(component)} effect`,
888
+ component,
889
+ dependencies: [...edges.values()]
890
+ .filter(edge => edge.to === record.id)
891
+ .map(edge => edge.from),
892
+ status: record.status,
893
+ executionCount: record.executionCount,
894
+ lastExecutionTime: record.lastExecutionTime,
895
+ lastRunStatus: record.lastRunStatus,
896
+ lastDuration: record.lastDuration,
897
+ lastUpdateId: record.lastUpdateId,
898
+ lastError: record.lastError,
899
+ lastDomUpdates: record.lastDomUpdates
900
+ }
901
+ }
902
+
903
+ function removeEdgesFor(ids: ReadonlySet<string>): void {
904
+ for (const [key, edge] of edges) {
905
+ if (ids.has(edge.from) || ids.has(edge.to)) edges.delete(key)
906
+ }
907
+ }
908
+
909
+ function cleanupSignalRecord(record: SignalRecord): void {
910
+ if (signals.get(record.id) !== record) return
911
+ removeEdgesFor(new Set([record.id]))
912
+ signals.delete(record.id)
913
+ }
914
+
915
+ function cleanupEffectRecord(record: EffectRecord): void {
916
+ if (effects.get(record.id) !== record) return
917
+ removeEdgesFor(new Set([record.id]))
918
+ effects.delete(record.id)
919
+ }
920
+
921
+ function cleanupOwnerRecord(record: OwnerRecord): void {
922
+ if (owners.get(record.id) !== record) return
923
+ const removedIds = new Set<string>([record.id])
924
+ for (const [id, signal] of signals) {
925
+ if (signal.ownerId === record.id) {
926
+ removedIds.add(id)
927
+ signals.delete(id)
928
+ }
929
+ }
930
+ for (const [id, effect] of effects) {
931
+ if (effect.ownerId === record.id) {
932
+ removedIds.add(id)
933
+ effects.delete(id)
934
+ }
935
+ }
936
+ removeEdgesFor(removedIds)
937
+ owners.delete(record.id)
938
+ }
939
+
940
+ function debugComponentName(value: string): string {
941
+ const separator = value.indexOf(' (')
942
+ return separator > 0 ? value.slice(0, separator) : value
943
+ }
944
+
945
+ function buildComponentNode(record: OwnerRecord, activeIds: ReadonlySet<string>): ComponentDebugNode {
946
+ const componentEffects = [...effects.values()]
947
+ .filter(effect => !effect.disposed && effect.ownerId === record.id)
948
+ const componentEffectIds = new Set(componentEffects.map(effect => effect.id))
949
+ const componentUpdates = updates.filter(update => update.effects.some(effect => componentEffectIds.has(effect.effectId)))
950
+ return {
951
+ id: record.id,
952
+ name: record.name,
953
+ ownerId: record.id,
954
+ signals: [...signals.values()]
955
+ .filter(signal => !signal.disposed && signal.ownerId === record.id)
956
+ .map(signal => signal.id),
957
+ effects: componentEffects.map(effect => effect.id),
958
+ recentUpdates: componentUpdates.slice(-10).map(update => update.id),
959
+ domUpdates: componentUpdates.reduce((count, update) => count + update.domUpdates.length, 0),
960
+ children: [...owners.values()]
961
+ .filter(child => !child.disposed && child.parentId === record.id && activeIds.has(child.id))
962
+ .map(child => buildComponentNode(child, activeIds)),
963
+ mounted: !record.disposed
964
+ }
965
+ }
966
+
967
+ function recordUpdate(pending: PendingUpdate): void {
968
+ if (disposed || collectionPaused || isInternalSignal(pending.signal)) return
969
+ if (isNoisyViewportSignal(pending.signal) && !hasMeaningfulDomUpdate(pending.domUpdates)) return
970
+ const duration = Math.max(0, now() - pending.timestamp)
971
+ const trace: UpdateTrace = {
972
+ id: pending.id,
973
+ signalId: pending.signal.id,
974
+ signalName: pending.signal.name,
975
+ previousValue: serializeForDevTools(pending.previousValue, new Set<object>(), 0, privacy),
976
+ nextValue: serializeForDevTools(pending.nextValue, new Set<object>(), 0, privacy),
977
+ timestamp: pending.timestamp,
978
+ effects: [...pending.executions.values()],
979
+ affectedSignals: [...pending.affectedSignals],
980
+ affectedEffects: [...pending.effectIds],
981
+ domUpdates: pending.domUpdates.slice(),
982
+ status: pending.status,
983
+ error: pending.error,
984
+ duration,
985
+ route: pending.context.route,
986
+ navigationId: pending.context.navigationId,
987
+ requestIds: [...pending.context.requestIds]
988
+ }
989
+ updates.push(trace)
990
+ while (updates.length > maxUpdates) updates.shift()
991
+ updateDurations.push(duration)
992
+ while (updateDurations.length > maxUpdates) updateDurations.shift()
993
+ updateCount++
994
+ emit('update', trace)
995
+ }
996
+
997
+ function onFlushEnd(): void {
998
+ const current = pendingUpdates.splice(0)
999
+ for (const pending of current) recordUpdate(pending)
1000
+ }
1001
+
1002
+ function schedulePendingFlush(): void {
1003
+ if (pendingFlushScheduled) return
1004
+ pendingFlushScheduled = true
1005
+ queueMicrotask(() => {
1006
+ if (disposed) {
1007
+ pendingUpdates.length = 0
1008
+ return
1009
+ }
1010
+ pendingFlushScheduled = false
1011
+ for (let index = pendingUpdates.length - 1; index >= 0; index--) {
1012
+ const pending = pendingUpdates[index]
1013
+ if (!pending || pending.effectIds.size > 0) continue
1014
+ pendingUpdates.splice(index, 1)
1015
+ recordUpdate(pending)
1016
+ }
1017
+ })
1018
+ }
1019
+
1020
+ const hooks: ReactivityDebugHooks = {
1021
+ ownerCreated(owner) {
1022
+ const record = ensureOwner(owner)
1023
+ recordLifecycle('owner-created', record.id, record.name, record.parentId ?? undefined)
1024
+ emit('owner-created', record)
1025
+ },
1026
+
1027
+ ownerNamed(owner, name) {
1028
+ const record = ensureOwner(owner)
1029
+ record.name = name
1030
+ if (isInternalOwnerId(record.id)) return
1031
+ for (const signal of signals.values()) {
1032
+ if (signal.ownerId === record.id && !signal.explicitName) signal.name = `${name} state`
1033
+ }
1034
+ recordLifecycle('owner-named', record.id, name, record.parentId ?? undefined)
1035
+ emit('owner-named', record)
1036
+ },
1037
+
1038
+ ownerDisposed(owner) {
1039
+ const record = ensureOwner(owner)
1040
+ record.disposed = true
1041
+ const internal = isInternalOwnerId(record.id)
1042
+ if (!internal) {
1043
+ recordLifecycle('owner-disposed', record.id, record.name, record.parentId ?? undefined)
1044
+ emit('owner-disposed', record)
1045
+ }
1046
+ cleanupOwnerRecord(record)
1047
+ },
1048
+
1049
+ signalCreated(signal, owner) {
1050
+ const record = ensureSignal(signal, owner)
1051
+ if (isInternalSignal(record)) return
1052
+ recordLifecycle('signal-created', record.id, record.name, record.ownerId ?? undefined)
1053
+ emit('signal-created', signalInfo(record, false))
1054
+ },
1055
+
1056
+ signalNamed(signal, name) {
1057
+ const record = ensureSignal(signal)
1058
+ record.name = name
1059
+ record.explicitName = true
1060
+ if (isInternalSignal(record)) return
1061
+ recordLifecycle('signal-named', record.id, name, record.ownerId ?? undefined)
1062
+ emit('signal-named', signalInfo(record))
1063
+ },
1064
+
1065
+ signalRead(signal, subscriber) {
1066
+ ensureSignal(signal)
1067
+ emit('signal-read', signal, subscriber)
1068
+ },
1069
+
1070
+ signalChanged(signal, previousValue, nextValue) {
1071
+ const record = ensureSignal(signal)
1072
+ if (isInternalSignal(record)) return
1073
+ let pending = pendingUpdates.find(item => item.signal.signal === signal)
1074
+ if (pending) {
1075
+ pending.nextValue = nextValue
1076
+ const context = getRuntimeDebugContext()
1077
+ if (context?.route) pending.context = { ...pending.context, route: context.route }
1078
+ if (context?.navigationId !== undefined) pending.context = { ...pending.context, navigationId: context.navigationId }
1079
+ if (context?.dataRequestId !== undefined) pending.context.requestIds.add(context.dataRequestId)
1080
+ } else {
1081
+ pending = {
1082
+ id: `update-${nextId++}`,
1083
+ signal: record,
1084
+ timestamp: now(),
1085
+ previousValue,
1086
+ nextValue,
1087
+ affectedSignals: collectAffectedSignals(record.id),
1088
+ effectIds: collectEffectIds(record.id),
1089
+ executions: new Map(),
1090
+ domUpdates: [],
1091
+ status: 'completed',
1092
+ error: undefined,
1093
+ context: {
1094
+ route: getRuntimeDebugContext()?.route,
1095
+ navigationId: getRuntimeDebugContext()?.navigationId,
1096
+ requestIds: new Set(
1097
+ getRuntimeDebugContext()?.dataRequestId === undefined
1098
+ ? []
1099
+ : [getRuntimeDebugContext()!.dataRequestId!]
1100
+ )
1101
+ }
1102
+ }
1103
+ pendingUpdates.push(pending)
1104
+ }
1105
+ emit('signal-update', {
1106
+ ...signalInfo(record),
1107
+ previousValue: serializeForDevTools(previousValue, new Set<object>(), 0, privacy),
1108
+ nextValue: serializeForDevTools(nextValue, new Set<object>(), 0, privacy),
1109
+ updateId: pending.id
1110
+ })
1111
+ recordLifecycle('signal-changed', record.id, record.name, record.ownerId ?? undefined)
1112
+ if (pending.effectIds.size === 0) schedulePendingFlush()
1113
+ },
1114
+
1115
+ signalDisposed(signal) {
1116
+ const record = ensureSignal(signal)
1117
+ record.disposed = true
1118
+ const internal = isInternalSignal(record)
1119
+ if (!internal) {
1120
+ recordLifecycle('signal-disposed', record.id, record.name, record.ownerId ?? undefined)
1121
+ emit('signal-disposed', record)
1122
+ }
1123
+ cleanupSignalRecord(record)
1124
+ },
1125
+
1126
+ dependencyTracked(dependency, subscriber) {
1127
+ trackDependency(dependency, subscriber)
1128
+ },
1129
+
1130
+ dependencyUntracked(dependency, subscriber) {
1131
+ untrackDependency(dependency, subscriber)
1132
+ },
1133
+
1134
+ effectCreated(effect, owner) {
1135
+ const record = ensureEffect(effect, owner)
1136
+ if (isInternalEffect(record)) return
1137
+ recordLifecycle('effect-created', record.id, effectInfo(record).name, record.ownerId ?? undefined)
1138
+ emit('effect-created', effectInfo(record))
1139
+ },
1140
+
1141
+ effectInvalidated(effect) {
1142
+ const record = ensureEffect(effect)
1143
+ if (isInternalEffect(record)) return
1144
+ record.status = 'dirty'
1145
+ recordLifecycle('effect-invalidated', record.id, effectInfo(record).name, record.ownerId ?? undefined)
1146
+ emit('effect-invalidated', effectInfo(record))
1147
+ },
1148
+
1149
+ effectRunStart(effect) {
1150
+ const record = ensureEffect(effect)
1151
+ if (isInternalEffect(record)) return
1152
+ record.status = 'running'
1153
+ record.runningSince = now()
1154
+ activeEffectId = record.id
1155
+ recordLifecycle('effect-run-start', record.id, effectInfo(record).name, record.ownerId ?? undefined)
1156
+ emit('effect-run-start', effectInfo(record))
1157
+ },
1158
+
1159
+ effectRunEnd(effect, error, handled = false) {
1160
+ const record = ensureEffect(effect)
1161
+ if (isInternalEffect(record)) return
1162
+ const end = now()
1163
+ const duration = record.runningSince === null ? 0 : Math.max(0, end - record.runningSince)
1164
+ const runStatus = error === undefined ? 'success' : 'error'
1165
+ const debugError = error === undefined ? undefined : toDebugError(error, 'effect')
1166
+ record.status = runStatus
1167
+ record.runningSince = null
1168
+ record.executionCount++
1169
+ record.lastExecutionTime = end
1170
+ record.lastRunStatus = runStatus
1171
+ record.lastDuration = duration
1172
+ record.lastError = debugError
1173
+ effectExecutionCount++
1174
+ const execution: EffectExecutionInfo = {
1175
+ effectId: record.id,
1176
+ component: record.ownerId ? owners.get(record.ownerId)?.name ?? 'unknown' : 'unknown',
1177
+ duration,
1178
+ domUpdates: 0,
1179
+ status: runStatus,
1180
+ error: debugError
1181
+ }
1182
+ for (const pending of pendingUpdates) {
1183
+ if (!pending.effectIds.has(record.id)) continue
1184
+ const domUpdates = pending.domUpdates.filter(update => update.effectId === record.id).length
1185
+ const completedExecution = { ...execution, domUpdates }
1186
+ pending.executions.set(record.id, completedExecution)
1187
+ if (error !== undefined) {
1188
+ pending.status = 'error'
1189
+ pending.error = debugError
1190
+ }
1191
+ record.lastUpdateId = pending.id
1192
+ record.lastDomUpdates = domUpdates
1193
+ }
1194
+ activeEffectId = null
1195
+ if (error !== undefined) {
1196
+ const update = [...pendingUpdates].reverse().find(item => item.effectIds.has(record.id))
1197
+ const errorOwnerId = readErrorProperty(error, 'vobsOwnerId')
1198
+ const errorComponent = readErrorProperty(error, 'vobsComponent')
1199
+ reportError('effect', error, {
1200
+ effectId: record.id,
1201
+ updateId: update?.id,
1202
+ ownerId: errorOwnerId ?? record.ownerId ?? undefined,
1203
+ component: errorComponent ?? errorComponentForOwner(record.ownerId ?? undefined),
1204
+ handled,
1205
+ recovery: handled ? 'handled' : 'propagated'
1206
+ })
1207
+ }
1208
+ recordLifecycle('effect-run', record.id, effectInfo(record).name, record.ownerId ?? undefined, runStatus)
1209
+ emit('effect-run', execution)
1210
+ },
1211
+
1212
+ effectDisposed(effect) {
1213
+ const record = ensureEffect(effect)
1214
+ const internal = isInternalEffect(record)
1215
+ record.disposed = true
1216
+ record.status = 'idle'
1217
+ if (!internal) {
1218
+ recordLifecycle('effect-disposed', record.id, effectInfo(record).name, record.ownerId ?? undefined)
1219
+ emit('effect-disposed', record)
1220
+ }
1221
+ cleanupEffectRecord(record)
1222
+ },
1223
+
1224
+ memoCreated(signal, subscriber, owner) {
1225
+ const record = ensureSignal(signal, owner)
1226
+ record.kind = 'memo'
1227
+ memoSignalIds.set(subscriber as object, record.id)
1228
+ if (isInternalSignal(record)) return
1229
+ recordLifecycle('memo-created', record.id, record.name, record.ownerId ?? undefined)
1230
+ emit('memo-created', signalInfo(record))
1231
+ },
1232
+
1233
+ memoInvalidated(signal) {
1234
+ const record = ensureSignal(signal)
1235
+ if (isInternalSignal(record)) return
1236
+ recordLifecycle('memo-invalidated', record.id, record.name, record.ownerId ?? undefined)
1237
+ emit('memo-update', signalInfo(record))
1238
+ },
1239
+
1240
+ flushEnd: onFlushEnd
1241
+ }
1242
+
1243
+ const runtimeHooks: RuntimeDebugHooks = {
1244
+ domMutation(mutation: RuntimeDomMutation): void {
1245
+ if (!activeEffectId) return
1246
+ const effect = effects.get(activeEffectId)
1247
+ if (!effect || isInternalEffect(effect)) return
1248
+ const safeMutation: DomUpdateInfo = {
1249
+ ...mutation,
1250
+ previousValue: privacy.redactDomValues ? privacy.replacement : serializeForDevTools(mutation.previousValue, new Set<object>(), 0, privacy),
1251
+ nextValue: privacy.redactDomValues ? privacy.replacement : serializeForDevTools(mutation.nextValue, new Set<object>(), 0, privacy),
1252
+ effectId: activeEffectId,
1253
+ route: getRuntimeDebugContext()?.route,
1254
+ navigationId: getRuntimeDebugContext()?.navigationId,
1255
+ requestId: getRuntimeDebugContext()?.dataRequestId
1256
+ }
1257
+ for (const pending of pendingUpdates) {
1258
+ if (pending.effectIds.has(activeEffectId)) {
1259
+ pending.domUpdates.push(safeMutation)
1260
+ if (safeMutation.route) pending.context = { ...pending.context, route: safeMutation.route }
1261
+ if (safeMutation.navigationId !== undefined) pending.context = { ...pending.context, navigationId: safeMutation.navigationId }
1262
+ if (safeMutation.requestId !== undefined) pending.context.requestIds.add(safeMutation.requestId)
1263
+ }
1264
+ }
1265
+ emit('dom-update', safeMutation)
1266
+ },
1267
+
1268
+ hydrationMismatch(event: RuntimeHydrationMismatch): void {
1269
+ reportError('hydration', Object.assign(new Error(event.message), {
1270
+ name: 'HydrationMismatchError',
1271
+ vobsCode: 'VOBS_HYDRATION_MISMATCH',
1272
+ vobsHydration: event
1273
+ }), {
1274
+ route: getRuntimeDebugContext()?.route,
1275
+ navigationId: getRuntimeDebugContext()?.navigationId
1276
+ })
1277
+ },
1278
+
1279
+ error(event: RuntimeErrorEvent): void {
1280
+ const owner = ensureOwner(event.owner)
1281
+ const errorOwnerId = readErrorProperty(event.error, 'vobsOwnerId')
1282
+ const errorComponent = readErrorProperty(event.error, 'vobsComponent')
1283
+ reportError(event.phase === 'boundary' ? 'boundary' : 'event', event.error, {
1284
+ ownerId: errorOwnerId ?? owner.id,
1285
+ component: errorComponent ?? errorComponentForOwner(owner.id ?? undefined) ?? owner.name,
1286
+ handled: event.handled,
1287
+ recovery: event.recovery,
1288
+ })
1289
+ }
1290
+ }
1291
+
1292
+ const httpHooks = {
1293
+ request(event: HTTPDebugRequest): void {
1294
+ if (collectionPaused) return
1295
+ const previous = networkRequests.get(event.id)
1296
+ const trace: NetworkRequestTrace = {
1297
+ id: event.id,
1298
+ url: event.url,
1299
+ method: event.method,
1300
+ status: event.status,
1301
+ headers: redactHeaders(event.headers, privacy),
1302
+ requestBody: serializeForDevTools(event.requestBody, new Set<object>(), 0, privacy),
1303
+ startedAt: previous?.startedAt ?? event.startedAt,
1304
+ endedAt: event.endedAt,
1305
+ duration: event.duration,
1306
+ attempt: Math.max(event.attempt, previous?.attempt ?? 0),
1307
+ retries: Math.max(event.retries, previous?.retries ?? 0),
1308
+ responseStatus: event.responseStatus,
1309
+ responseBody: serializeForDevTools(event.responseBody, new Set<object>(), 0, privacy),
1310
+ error: event.error,
1311
+ source: event.context?.environment === 'server' ? 'ssr' : 'http',
1312
+ test: event.context?.test,
1313
+ route: event.context?.route,
1314
+ navigationId: event.context?.navigationId,
1315
+ dataRequestId: event.context?.dataRequestId,
1316
+ environment: event.context?.environment
1317
+ }
1318
+ for (const pending of pendingUpdates) {
1319
+ if (event.context?.dataRequestId !== undefined && pending.context.requestIds.has(event.context.dataRequestId)) pending.context.requestIds.add(event.id)
1320
+ if (activeEffectId !== null && pending.effectIds.has(activeEffectId)) pending.context.requestIds.add(event.id)
1321
+ }
1322
+ networkRequests.set(event.id, trace)
1323
+ while (networkRequests.size > maxUpdates) {
1324
+ const oldest = networkRequests.keys().next().value
1325
+ if (typeof oldest !== 'number') break
1326
+ networkRequests.delete(oldest)
1327
+ }
1328
+ emit('network-request', trace)
1329
+ if (event.error) reportError('network', new Error(event.error.message), {
1330
+ requestId: event.id,
1331
+ route: event.context?.route,
1332
+ navigationId: event.context?.navigationId
1333
+ })
1334
+ }
1335
+ }
1336
+
1337
+ function importSSRRequests(snapshot: unknown): void {
1338
+ if (disposed) return
1339
+ const events = parseSSRRequestSnapshot(snapshot)
1340
+ const ids = new Map<number, number>()
1341
+ for (const event of events) {
1342
+ const importedId = ids.get(event.id) ?? -(event.id + 1)
1343
+ ids.set(event.id, importedId)
1344
+ const trace: NetworkRequestTrace = {
1345
+ id: importedId,
1346
+ url: event.url,
1347
+ method: event.method,
1348
+ status: event.status,
1349
+ headers: redactHeaders(event.headers, privacy),
1350
+ requestBody: serializeForDevTools(event.requestBody, new Set<object>(), 0, privacy),
1351
+ startedAt: event.startedAt,
1352
+ endedAt: event.endedAt,
1353
+ duration: event.duration,
1354
+ attempt: event.attempt,
1355
+ retries: event.retries,
1356
+ responseStatus: event.responseStatus,
1357
+ responseBody: serializeForDevTools(event.responseBody, new Set<object>(), 0, privacy),
1358
+ error: event.error,
1359
+ source: 'ssr',
1360
+ test: event.context?.test,
1361
+ route: event.context?.route,
1362
+ navigationId: event.context?.navigationId,
1363
+ dataRequestId: event.context?.dataRequestId,
1364
+ environment: 'server'
1365
+ }
1366
+ networkRequests.set(importedId, trace)
1367
+ }
1368
+ while (networkRequests.size > maxUpdates) {
1369
+ const oldest = networkRequests.keys().next().value
1370
+ if (typeof oldest !== 'number') break
1371
+ networkRequests.delete(oldest)
1372
+ }
1373
+ emit('network-request', { source: 'ssr', imported: events.length })
1374
+ }
1375
+
1376
+ const previousHooks = getDebugHooks()
1377
+ const previousRuntimeHooks = getRuntimeDebugHooks()
1378
+ const previousHTTPHooks = getHTTPDebugHooks()
1379
+ setDebugHooks(hooks)
1380
+ setRuntimeDebugHooks(runtimeHooks)
1381
+ setHTTPDebugHooks(httpHooks)
1382
+
1383
+ const target = options.target ?? defaultTarget()
1384
+ const shouldExpose = options.expose ?? Boolean(target)
1385
+ const previousGlobal = target?.__VOBS_DEVTOOLS__
1386
+ let api!: DevToolsAPI
1387
+ const onGlobalError = (event: { readonly error?: unknown; readonly message?: unknown; readonly filename?: unknown; readonly lineno?: unknown; readonly colno?: unknown }): void => {
1388
+ const source = typeof event.filename === 'string' && event.filename
1389
+ ? `${event.filename}:${typeof event.lineno === 'number' ? event.lineno : 0}:${typeof event.colno === 'number' ? event.colno : 0}`
1390
+ : undefined
1391
+ reportError('global', event.error ?? event.message ?? 'Unknown global error', { source })
1392
+ }
1393
+ const onUnhandledRejection = (event: { readonly reason?: unknown }): void => {
1394
+ reportError('unhandledrejection', event.reason ?? 'Unhandled promise rejection')
1395
+ }
1396
+ target?.addEventListener?.('error', onGlobalError)
1397
+ target?.addEventListener?.('unhandledrejection', onUnhandledRejection)
1398
+
1399
+ function subscribe(event: string, callback: (...args: any[]) => void): () => void {
1400
+ if (disposed) return () => undefined
1401
+ const callbacks = listeners.get(event) ?? new Set<(...args: any[]) => void>()
1402
+ callbacks.add(callback)
1403
+ listeners.set(event, callbacks)
1404
+ return () => {
1405
+ callbacks.delete(callback)
1406
+ if (callbacks.size === 0 && listeners.get(event) === callbacks) listeners.delete(event)
1407
+ }
1408
+ }
1409
+
1410
+ function attachRouter(router: DevToolsRouterSource): () => void {
1411
+ const existing = routerStops.get(router)
1412
+ if (existing) {
1413
+ existing.refs++
1414
+ let released = false
1415
+ return () => {
1416
+ if (released) return
1417
+ released = true
1418
+ existing.refs--
1419
+ if (existing.refs === 0) existing.stop()
1420
+ }
1421
+ }
1422
+ const source = router.devtools as DevToolsRouterSource['devtools'] & {
1423
+ getCurrentRoute?: () => { readonly fullPath?: unknown }
1424
+ getNavigationState?: () => { readonly traceId?: unknown; readonly status?: unknown }
1425
+ getDataRequests?: () => readonly DevToolsRouterContext['dataRequests'][number][]
1426
+ }
1427
+ const initialRoute = source.getCurrentRoute?.()
1428
+ const initialState = source.getNavigationState?.()
1429
+ routerContext = {
1430
+ route: typeof initialRoute?.fullPath === 'string' ? initialRoute.fullPath : routerContext?.route,
1431
+ navigationId: typeof initialState?.traceId === 'number' ? initialState.traceId : routerContext?.navigationId,
1432
+ navigationStatus: typeof initialState?.status === 'string' ? initialState.status : routerContext?.navigationStatus,
1433
+ dataRequests: source.getDataRequests?.() ?? routerContext?.dataRequests ?? []
1434
+ }
1435
+ const stops = [
1436
+ router.devtools.subscribe('navigation:start', payload => {
1437
+ const value = payload as { readonly to?: unknown; readonly traceId?: unknown; readonly status?: unknown }
1438
+ routerContext = {
1439
+ route: typeof value.to === 'string' ? value.to : routerContext?.route,
1440
+ navigationId: typeof value.traceId === 'number' ? value.traceId : routerContext?.navigationId,
1441
+ navigationStatus: typeof value.status === 'string' ? value.status : undefined,
1442
+ dataRequests: routerContext?.dataRequests ?? []
1443
+ }
1444
+ emit('router', { type: 'navigation:start', payload })
1445
+ }),
1446
+ router.devtools.subscribe('navigation:end', payload => {
1447
+ const value = payload as { readonly to?: unknown; readonly id?: unknown; readonly status?: unknown }
1448
+ routerContext = {
1449
+ route: typeof value.to === 'string' ? value.to : routerContext?.route,
1450
+ navigationId: typeof value.id === 'number' ? value.id : routerContext?.navigationId,
1451
+ navigationStatus: typeof value.status === 'string' ? value.status : undefined,
1452
+ dataRequests: routerContext?.dataRequests ?? []
1453
+ }
1454
+ emit('router', { type: 'navigation:end', payload })
1455
+ }),
1456
+ router.devtools.subscribe('data-request', payload => {
1457
+ const request = payload as DevToolsRouterContext['dataRequests'][number]
1458
+ const requests = [...(routerContext?.dataRequests ?? [])]
1459
+ const existingIndex = requests.findIndex(item => item.id === request.id)
1460
+ if (existingIndex >= 0) requests[existingIndex] = request
1461
+ else requests.push(request)
1462
+ requests.splice(0, Math.max(0, requests.length - maxUpdates))
1463
+ routerContext = { ...routerContext, dataRequests: requests }
1464
+ emit('router', { type: 'data-request', payload })
1465
+ }),
1466
+ router.devtools.subscribe('route:update', payload => {
1467
+ const value = payload as { readonly fullPath?: unknown }
1468
+ if (typeof value.fullPath === 'string') routerContext = { ...routerContext, route: value.fullPath, dataRequests: routerContext?.dataRequests ?? [] }
1469
+ emit('router', { type: 'route:update', payload })
1470
+ }),
1471
+ router.devtools.subscribe('error', payload => {
1472
+ const value = payload as { readonly route?: unknown; readonly requestId?: unknown; readonly navigationId?: unknown; readonly message?: unknown; readonly stack?: unknown; readonly phase?: unknown }
1473
+ reportError('route', Object.assign(new Error(typeof value.message === 'string' ? value.message : 'Router error'), {
1474
+ stack: value.stack
1475
+ }), {
1476
+ route: typeof value.route === 'string' ? value.route : routerContext?.route,
1477
+ requestId: typeof value.requestId === 'number' ? value.requestId : undefined,
1478
+ navigationId: typeof value.navigationId === 'number' ? value.navigationId : undefined
1479
+ })
1480
+ emit('router', { type: 'error', payload })
1481
+ })
1482
+ ]
1483
+ const stop = () => {
1484
+ const current = routerStops.get(router)
1485
+ if (!current || current.stop !== stop) return
1486
+ for (const stop of stops) stop()
1487
+ routerStops.delete(router)
1488
+ }
1489
+ routerStops.set(router, { stop, refs: 1 })
1490
+ let released = false
1491
+ return () => {
1492
+ if (released) return
1493
+ released = true
1494
+ const current = routerStops.get(router)
1495
+ if (!current) return
1496
+ current.refs--
1497
+ if (current.refs === 0) current.stop()
1498
+ }
1499
+ }
1500
+
1501
+ api = {
1502
+ getComponentTree(): readonly ComponentDebugNode[] {
1503
+ const active = [...owners.values()].filter(owner => !owner.disposed && !isInternalOwnerId(owner.id))
1504
+ const activeIds = new Set(active.map(owner => owner.id))
1505
+ return active
1506
+ .filter(owner => owner.parentId === null || !activeIds.has(owner.parentId))
1507
+ .map(owner => buildComponentNode(owner, activeIds))
1508
+ },
1509
+
1510
+ getComponent(ownerId: string): ComponentDebugNode | null {
1511
+ const active = [...owners.values()].filter(owner => !owner.disposed && !isInternalOwnerId(owner.id))
1512
+ const activeIds = new Set(active.map(owner => owner.id))
1513
+ const find = (records: readonly OwnerRecord[]): ComponentDebugNode | null => {
1514
+ for (const record of records) {
1515
+ if (!activeIds.has(record.id)) continue
1516
+ if (record.id === ownerId) return buildComponentNode(record, activeIds)
1517
+ const child = find(active.filter(candidate => candidate.parentId === record.id))
1518
+ if (child) return child
1519
+ }
1520
+ return null
1521
+ }
1522
+ return find(active.filter(owner => owner.parentId === null || !activeIds.has(owner.parentId)))
1523
+ },
1524
+
1525
+ getSignals(): readonly SignalDebugInfo[] {
1526
+ return [...signals.values()]
1527
+ .filter(signal => !signal.disposed && !isInternalSignal(signal))
1528
+ .map(signal => signalInfo(signal))
1529
+ },
1530
+
1531
+ getSignal(signalId: string): SignalDebugInfo | null {
1532
+ const record = signals.get(signalId)
1533
+ return record && !record.disposed && !isInternalSignal(record) ? signalInfo(record) : null
1534
+ },
1535
+
1536
+ canMutate(): boolean {
1537
+ return allowMutations && !disposed
1538
+ },
1539
+
1540
+ setSignalValue(signalId: string, value: unknown): boolean {
1541
+ if (!allowMutations || disposed) return false
1542
+ const record = signals.get(signalId)
1543
+ if (!record || record.disposed || record.kind === 'memo') return false
1544
+ try {
1545
+ record.signal.value = value
1546
+ return true
1547
+ } catch (error) {
1548
+ reportError('render', error)
1549
+ return false
1550
+ }
1551
+ },
1552
+
1553
+ getDependencies(signalId: string): readonly DependencyEdge[] {
1554
+ return [...edges.values()].filter(edge => edge.from === signalId)
1555
+ },
1556
+
1557
+ getDependents(subscriberId: string): readonly DependencyEdge[] {
1558
+ return [...edges.values()].filter(edge => edge.to === subscriberId)
1559
+ },
1560
+
1561
+ getEffects(): readonly EffectDebugInfo[] {
1562
+ return [...effects.values()]
1563
+ .filter(effect => !effect.disposed && !isInternalEffect(effect))
1564
+ .map(effectInfo)
1565
+ },
1566
+
1567
+ getUpdates(): readonly UpdateTrace[] {
1568
+ return updates.slice()
1569
+ },
1570
+
1571
+ getLifecycleEvents(): readonly LifecycleEvent[] {
1572
+ return lifecycleEvents.slice()
1573
+ },
1574
+
1575
+ getNetworkRequests(): readonly NetworkRequestTrace[] {
1576
+ return [...networkRequests.values()]
1577
+ },
1578
+
1579
+ importSSRRequests,
1580
+
1581
+ getRouterContext(): DevToolsRouterContext | null {
1582
+ return routerContext ? { ...routerContext, dataRequests: [...routerContext.dataRequests] } : null
1583
+ },
1584
+
1585
+ attachRouter,
1586
+
1587
+ getErrors(): readonly DevToolsErrorTrace[] {
1588
+ return [...errors.values()]
1589
+ },
1590
+
1591
+ getPerformanceEntries(): readonly DevToolsPerformanceEntry[] {
1592
+ const entries: DevToolsPerformanceEntry[] = []
1593
+ for (const update of updates) {
1594
+ entries.push({
1595
+ kind: 'update',
1596
+ id: update.id,
1597
+ label: update.signalName,
1598
+ duration: update.duration,
1599
+ timestamp: update.timestamp,
1600
+ status: update.status
1601
+ })
1602
+ }
1603
+ for (const effect of effects.values()) {
1604
+ if (effect.disposed || isInternalEffect(effect) || effect.lastDuration <= 0) continue
1605
+ entries.push({
1606
+ kind: 'effect',
1607
+ id: effect.id,
1608
+ label: effectInfo(effect).name,
1609
+ duration: effect.lastDuration,
1610
+ timestamp: effect.lastExecutionTime,
1611
+ status: effect.lastRunStatus ?? effect.status
1612
+ })
1613
+ }
1614
+ for (const request of networkRequests.values()) {
1615
+ if (request.duration === undefined) continue
1616
+ entries.push({
1617
+ kind: 'request',
1618
+ id: request.id,
1619
+ label: `${request.method} ${request.url}`,
1620
+ duration: request.duration,
1621
+ timestamp: request.startedAt,
1622
+ status: request.status
1623
+ })
1624
+ }
1625
+ return entries.sort((left, right) => right.duration - left.duration)
1626
+ },
1627
+
1628
+ reportError,
1629
+
1630
+ getCollectionState(): DevToolsCollectionState {
1631
+ return { paused: collectionPaused }
1632
+ },
1633
+
1634
+ setCollectionPaused(paused: boolean): void {
1635
+ collectionPaused = paused
1636
+ if (paused) pendingUpdates.length = 0
1637
+ emit('collection', { paused })
1638
+ },
1639
+
1640
+ clearUpdates(): void {
1641
+ pendingUpdates.length = 0
1642
+ updates.length = 0
1643
+ updateDurations.length = 0
1644
+ updateCount = 0
1645
+ emit('collection-cleared', 'updates')
1646
+ },
1647
+
1648
+ clearNetworkRequests(): void {
1649
+ networkRequests.clear()
1650
+ emit('collection-cleared', 'network')
1651
+ },
1652
+
1653
+ clearErrors(): void {
1654
+ errors.clear()
1655
+ emit('collection-cleared', 'errors')
1656
+ },
1657
+
1658
+ clearLifecycleEvents(): void {
1659
+ lifecycleEvents.length = 0
1660
+ emit('collection-cleared', 'lifecycle')
1661
+ },
1662
+
1663
+ getPerformanceMetrics(): PerformanceMetrics {
1664
+ const total = updateDurations.reduce((sum, duration) => sum + duration, 0)
1665
+ const effectDurations = [...effects.values()]
1666
+ .filter(effect => !effect.disposed)
1667
+ .map(effect => effect.lastDuration)
1668
+ .filter(duration => duration > 0)
1669
+ const requestDurations = [...networkRequests.values()]
1670
+ .map(request => request.duration ?? 0)
1671
+ .filter(duration => duration > 0)
1672
+ return {
1673
+ updateCount,
1674
+ averageUpdateDuration: updateDurations.length === 0 ? 0 : total / updateDurations.length,
1675
+ slowUpdateCount: updateDurations.filter(duration => duration > slowUpdateThreshold).length,
1676
+ effectExecutionCount,
1677
+ slowEffectCount: effectDurations.filter(duration => duration > slowUpdateThreshold).length,
1678
+ slowRequestCount: requestDurations.filter(duration => duration > slowUpdateThreshold).length,
1679
+ maxUpdateDuration: updateDurations.length === 0 ? 0 : Math.max(...updateDurations),
1680
+ maxEffectDuration: effectDurations.length === 0 ? 0 : Math.max(...effectDurations),
1681
+ maxRequestDuration: requestDurations.length === 0 ? 0 : Math.max(...requestDurations)
1682
+ }
1683
+ },
1684
+
1685
+ takeMemorySnapshot(): MemorySnapshot {
1686
+ return {
1687
+ signalCount: [...signals.values()].filter(signal => !signal.disposed && !isInternalSignal(signal)).length,
1688
+ effectCount: [...effects.values()].filter(effect => !effect.disposed && !isInternalEffect(effect)).length,
1689
+ ownerCount: [...owners.values()].filter(owner => !owner.disposed && !isInternalOwnerId(owner.id)).length,
1690
+ dependencyEdgeCount: edges.size,
1691
+ leakedOwners: [...owners.values()].filter(owner => owner.disposed).length
1692
+ }
1693
+ },
1694
+
1695
+ exportDiagnostics(): DevToolsDiagnosticSnapshot {
1696
+ return {
1697
+ version: 1,
1698
+ exportedAt: Date.now(),
1699
+ updates: api.getUpdates(),
1700
+ lifecycle: api.getLifecycleEvents(),
1701
+ network: api.getNetworkRequests(),
1702
+ errors: api.getErrors(),
1703
+ performance: api.getPerformanceMetrics(),
1704
+ memory: api.takeMemorySnapshot(),
1705
+ extensions: getExtensionSnapshot()
1706
+ }
1707
+ },
1708
+
1709
+ importDiagnostics(snapshot: unknown): void {
1710
+ const imported = parseDiagnosticSnapshot(snapshot)
1711
+ updates.splice(0, updates.length, ...imported.updates.slice(-maxUpdates).map(update => sanitizeImportedUpdate(update, privacy)))
1712
+ lifecycleEvents.splice(0, lifecycleEvents.length, ...imported.lifecycle.slice(-maxUpdates))
1713
+ networkRequests.clear()
1714
+ for (const request of imported.network.slice(-maxUpdates)) networkRequests.set(request.id, sanitizeImportedRequest(request, privacy))
1715
+ errors.clear()
1716
+ for (const error of imported.errors.slice(-maxUpdates)) {
1717
+ const normalized = sanitizeImportedError(error)
1718
+ errors.set(diagnosticErrorKey(normalized), normalized)
1719
+ }
1720
+ updateDurations.splice(0, updateDurations.length, ...updates.map(update => update.duration))
1721
+ updateCount = imported.performance.updateCount
1722
+ effectExecutionCount = imported.performance.effectExecutionCount
1723
+ emit('collection', { imported: true })
1724
+ },
1725
+
1726
+ registerInspector(id: string, inspector: DevToolsInspector): () => void {
1727
+ if (disposed || !id) return () => undefined
1728
+ inspectors.set(id, inspector)
1729
+ emit('extension', { type: 'inspector', id })
1730
+ return () => {
1731
+ if (inspectors.get(id) === inspector) inspectors.delete(id)
1732
+ }
1733
+ },
1734
+
1735
+ registerTimeline(id: string, timeline: DevToolsTimeline = {}): () => void {
1736
+ if (disposed || !id) return () => undefined
1737
+ timelines.set(id, timeline)
1738
+ emit('extension', { type: 'timeline', id })
1739
+ return () => {
1740
+ if (timelines.get(id) === timeline) timelines.delete(id)
1741
+ }
1742
+ },
1743
+
1744
+ registerMetric(id: string, metric: DevToolsMetric): () => void {
1745
+ if (disposed || !id) return () => undefined
1746
+ metrics.set(id, metric)
1747
+ emit('extension', { type: 'metric', id })
1748
+ return () => {
1749
+ if (metrics.get(id) === metric) metrics.delete(id)
1750
+ }
1751
+ },
1752
+
1753
+ getExtensionSnapshot,
1754
+
1755
+ inspectExtension(id: string, value: unknown): unknown {
1756
+ const inspector = inspectors.get(id)
1757
+ if (!inspector) return undefined
1758
+ return safeExtensionCall(
1759
+ () => serializeForDevTools(inspector.inspect(value), new Set<object>(), 0, privacy),
1760
+ undefined
1761
+ )
1762
+ },
1763
+
1764
+ onUpdate(callback: (trace: UpdateTrace) => void): () => void {
1765
+ return subscribe('update', callback)
1766
+ },
1767
+
1768
+ subscribe,
1769
+
1770
+ dispose(): void {
1771
+ if (disposed) return
1772
+ disposed = true
1773
+ if (getDebugHooks() === hooks) setDebugHooks(previousHooks)
1774
+ if (getRuntimeDebugHooks() === runtimeHooks) setRuntimeDebugHooks(previousRuntimeHooks)
1775
+ if (getHTTPDebugHooks() === httpHooks) setHTTPDebugHooks(previousHTTPHooks)
1776
+ if (activeDevTools === api) activeDevTools = null
1777
+ if (target && target.__VOBS_DEVTOOLS__ === api) {
1778
+ if (previousGlobal) target.__VOBS_DEVTOOLS__ = previousGlobal
1779
+ else delete target.__VOBS_DEVTOOLS__
1780
+ }
1781
+ target?.removeEventListener?.('error', onGlobalError)
1782
+ target?.removeEventListener?.('unhandledrejection', onUnhandledRejection)
1783
+ listeners.clear()
1784
+ for (const { stop } of [...routerStops.values()]) stop()
1785
+ routerStops.clear()
1786
+ routerContext = null
1787
+ pendingUpdates.length = 0
1788
+ pendingFlushScheduled = false
1789
+ activeEffectId = null
1790
+ inspectors.clear()
1791
+ timelines.clear()
1792
+ metrics.clear()
1793
+ }
1794
+ }
1795
+
1796
+ if (shouldExpose && target) target.__VOBS_DEVTOOLS__ = api
1797
+ activeDevTools = api
1798
+ if (options.router) attachRouter(options.router)
1799
+ return api
1800
+ }
1801
+
1802
+ export function enableDevTools(options: DevToolsOptions = {}): DevToolsAPI {
1803
+ return createDevTools(options)
1804
+ }
1805
+
1806
+ export function disableDevTools(): void {
1807
+ activeDevTools?.dispose()
1808
+ }
1809
+
1810
+ export function getDevTools(): DevToolsAPI | null {
1811
+ return activeDevTools
1812
+ }
1813
+
1814
+ /** Connects a browser panel through a small postMessage protocol. */
1815
+ export function connectDevTools(options: DevToolsBridgeOptions = {}): () => void {
1816
+ const target = options.target ?? defaultMessageTarget()
1817
+ const api = options.api ?? activeDevTools
1818
+ if (!target || !api) return () => undefined
1819
+
1820
+ const send = (message: DevToolsWireResponse | DevToolsWireEvent): void => {
1821
+ try {
1822
+ target.postMessage(message, '*')
1823
+ } catch {
1824
+ // A panel disappearing during navigation must not affect the application.
1825
+ }
1826
+ }
1827
+ const unsubscribes = DEVTOOLS_EVENTS.map(event => api.subscribe(event, (...args: unknown[]) => {
1828
+ send({
1829
+ source: 'vobs-devtools',
1830
+ type: 'event',
1831
+ event,
1832
+ payload: args.length === 1 ? args[0] : args
1833
+ })
1834
+ }))
1835
+ const onMessage = (event: DevToolsMessageEvent): void => {
1836
+ const request = parseRequest(event.data)
1837
+ if (!request) return
1838
+ try {
1839
+ send({
1840
+ source: 'vobs-devtools',
1841
+ type: 'response',
1842
+ id: request.id,
1843
+ ok: true,
1844
+ result: invokeRequest(api, request)
1845
+ })
1846
+ } catch (error) {
1847
+ send({
1848
+ source: 'vobs-devtools',
1849
+ type: 'response',
1850
+ id: request.id,
1851
+ ok: false,
1852
+ error: toErrorMessage(error)
1853
+ })
1854
+ }
1855
+ }
1856
+ target.addEventListener('message', onMessage)
1857
+
1858
+ return () => {
1859
+ target.removeEventListener('message', onMessage)
1860
+ for (const unsubscribe of unsubscribes) unsubscribe()
1861
+ }
1862
+ }
1863
+
1864
+ export function devtoolsPlugin(options: DevToolsPluginOptions = {}): VobsPlugin {
1865
+ return {
1866
+ name: '@vobs/devtools',
1867
+ version: '0.1.0',
1868
+ install(context: VobsContext) {
1869
+ if (options.enabled === false) return
1870
+ const devtools = createDevTools(options)
1871
+ const removeErrorObserver = context.onError(error => {
1872
+ const phase: DevToolsErrorPhase = readErrorProperty(error, 'vobsCode') === 'VOBS_HYDRATION_MISMATCH'
1873
+ || Boolean(readErrorProperty(error, 'vobsHydration'))
1874
+ ? 'hydration'
1875
+ : 'application'
1876
+ devtools.reportError(phase, error, {
1877
+ handled: false,
1878
+ recovery: 'propagated'
1879
+ })
1880
+ })
1881
+ return () => {
1882
+ removeErrorObserver()
1883
+ devtools.dispose()
1884
+ }
1885
+ }
1886
+ }
1887
+ }
1888
+
1889
+ function defaultTarget(): DevToolsTarget | undefined {
1890
+ return typeof window === 'undefined' ? undefined : window
1891
+ }
1892
+
1893
+ function defaultMessageTarget(): DevToolsMessageTarget | undefined {
1894
+ return typeof window === 'undefined' ? undefined : window as unknown as DevToolsMessageTarget
1895
+ }
1896
+
1897
+ function parseRequest(value: unknown): DevToolsWireRequest | null {
1898
+ if (!value || typeof value !== 'object') return null
1899
+ const request = value as Partial<DevToolsWireRequest>
1900
+ if (request.source !== 'vobs-devtools' || request.type !== 'request') return null
1901
+ if (typeof request.id !== 'string' || typeof request.method !== 'string') return null
1902
+ return request as DevToolsWireRequest
1903
+ }
1904
+
1905
+ function invokeRequest(api: DevToolsAPI, request: DevToolsWireRequest): unknown {
1906
+ const args = request.args ?? []
1907
+ switch (request.method) {
1908
+ case 'getComponentTree': return api.getComponentTree()
1909
+ case 'getComponent': return api.getComponent(String(args[0] ?? ''))
1910
+ case 'getSignals': return api.getSignals()
1911
+ case 'getSignal': return api.getSignal(String(args[0] ?? ''))
1912
+ case 'canMutate': return api.canMutate()
1913
+ case 'setSignalValue': return api.setSignalValue(String(args[0] ?? ''), args[1])
1914
+ case 'getDependencies': return api.getDependencies(String(args[0] ?? ''))
1915
+ case 'getDependents': return api.getDependents(String(args[0] ?? ''))
1916
+ case 'getEffects': return api.getEffects()
1917
+ case 'getUpdates': return api.getUpdates()
1918
+ case 'getLifecycleEvents': return api.getLifecycleEvents()
1919
+ case 'getNetworkRequests': return api.getNetworkRequests()
1920
+ case 'importSSRRequests': api.importSSRRequests(args[0]); return undefined
1921
+ case 'getRouterContext': return api.getRouterContext()
1922
+ case 'getErrors': return api.getErrors()
1923
+ case 'getPerformanceEntries': return api.getPerformanceEntries()
1924
+ case 'getCollectionState': return api.getCollectionState()
1925
+ case 'setCollectionPaused': api.setCollectionPaused(Boolean(args[0])); return undefined
1926
+ case 'clearUpdates': api.clearUpdates(); return undefined
1927
+ case 'clearNetworkRequests': api.clearNetworkRequests(); return undefined
1928
+ case 'clearErrors': api.clearErrors(); return undefined
1929
+ case 'clearLifecycleEvents': api.clearLifecycleEvents(); return undefined
1930
+ case 'getPerformanceMetrics': return api.getPerformanceMetrics()
1931
+ case 'takeMemorySnapshot': return api.takeMemorySnapshot()
1932
+ case 'exportDiagnostics': return api.exportDiagnostics()
1933
+ case 'importDiagnostics': api.importDiagnostics(args[0]); return undefined
1934
+ case 'getExtensionSnapshot': return api.getExtensionSnapshot()
1935
+ case 'inspectExtension': return api.inspectExtension(String(args[0] ?? ''), args[1])
1936
+ default: throw new Error(`Vobs DevTools: 未知请求 ${request.method}`)
1937
+ }
1938
+ }
1939
+
1940
+ function toErrorMessage(error: unknown): string {
1941
+ return error instanceof Error ? error.message : String(error)
1942
+ }
1943
+
1944
+ function toDebugError(error: unknown, phase?: string): DebugErrorInfo {
1945
+ const normalized = normalizeVobsError(error)
1946
+ const value = error as {
1947
+ name?: unknown
1948
+ message?: unknown
1949
+ stack?: unknown
1950
+ vobsSource?: { file?: unknown; line?: unknown; column?: unknown }
1951
+ vobsCode?: unknown
1952
+ code?: unknown
1953
+ vobsHint?: unknown
1954
+ vobsHydration?: RuntimeHydrationMismatch
1955
+ } | null
1956
+ const source = value?.vobsSource ?? normalized.location
1957
+ return {
1958
+ name: typeof value?.name === 'string' ? value.name : normalized.name,
1959
+ message: typeof value?.message === 'string' ? value.message : normalized.message,
1960
+ stack: typeof value?.stack === 'string' ? value.stack : normalized.stack,
1961
+ phase,
1962
+ source: source && typeof source.file === 'string'
1963
+ ? `${source.file}:${source.line ?? 0}:${source.column ?? 0}`
1964
+ : undefined,
1965
+ location: source && typeof source.file === 'string' && typeof source.line === 'number' && typeof source.column === 'number'
1966
+ ? { file: source.file, line: source.line, column: source.column }
1967
+ : undefined,
1968
+ code: typeof value?.vobsCode === 'string'
1969
+ ? value.vobsCode
1970
+ : typeof value?.code === 'string' ? value.code : undefined,
1971
+ hint: typeof value?.vobsHint === 'string' ? value.vobsHint : undefined,
1972
+ cause: normalized.cause instanceof Error ? `${normalized.cause.name}: ${normalized.cause.message}` : normalized.cause === undefined ? undefined : String(normalized.cause),
1973
+ fix: normalized.fix,
1974
+ hydration: value?.vobsHydration && typeof value.vobsHydration === 'object' ? value.vobsHydration : undefined
1975
+ }
1976
+ }
1977
+
1978
+ interface ErrorClassification {
1979
+ readonly origin: DevToolsErrorOrigin
1980
+ readonly code?: string
1981
+ readonly hint?: string
1982
+ }
1983
+
1984
+ const FRAMEWORK_ERROR_RULES: readonly { pattern: RegExp; origin: DevToolsErrorOrigin; code: string; hint: string }[] = [
1985
+ { pattern: /响应式更新超过\s*100\s*轮/, origin: 'framework', code: 'VOBS_REACTIVITY_LOOP', hint: '检查 Effect 是否在执行时持续写入它依赖的 Signal。' },
1986
+ { pattern: /Fragment: (?:不能跨父节点移动|Fragment 不属于指定父节点|找不到结束锚点)/, origin: 'framework', code: 'VOBS_FRAGMENT_INVARIANT', hint: '检查 Fragment 的父节点和插入/删除顺序,通常表示运行时树结构已不一致。' },
1987
+ { pattern: /当前渲染器不支持 Hydration/, origin: 'usage', code: 'VOBS_HYDRATION_UNSUPPORTED', hint: '请使用支持 Hydration 的 Renderer,或改用 app.mount()。' },
1988
+ { pattern: /(?:HydrationMismatchError|Vobs hydration:|服务端 DOM 与客户端渲染结构不一致|节点位置与客户端渲染结果不一致)/, origin: 'framework', code: 'VOBS_HYDRATION_MISMATCH', hint: '检查服务端和客户端是否生成了相同的节点结构与初始状态。' },
1989
+ { pattern: /渲染器未初始化/, origin: 'usage', code: 'VOBS_RENDERER_NOT_INITIALIZED', hint: '请在应用 mount 或 hydrate 后调用 Runtime DOM API。' },
1990
+ { pattern: /节点不属于指定父节点/, origin: 'framework', code: 'VOBS_DOM_PARENT_MISMATCH', hint: '检查节点是否被重复移动、删除,或被错误的 Renderer 实例管理。' }
1991
+ ]
1992
+
1993
+ function classifyError(phase: DevToolsErrorPhase, error: unknown, context: DevToolsErrorContext): ErrorClassification {
1994
+ const message = toErrorMessage(error)
1995
+ const explicitCode = context.code
1996
+ ?? readErrorProperty(error, 'vobsCode')
1997
+ ?? readErrorProperty(error, 'code')
1998
+ ?? extractErrorCode(message)
1999
+ const frameworkRule = FRAMEWORK_ERROR_RULES.find(rule => rule.pattern.test(message))
2000
+ if (frameworkRule) return { origin: context.origin ?? frameworkRule.origin, code: explicitCode ?? frameworkRule.code, hint: context.hint ?? frameworkRule.hint }
2001
+ if (context.origin) return { origin: context.origin, code: explicitCode, hint: context.hint }
2002
+ if ((explicitCode && isUsageErrorCode(explicitCode))
2003
+ || (/^Vobs(?:\s|:)|^VOBS_/.test(message) && /必须|不能为空|找不到|缺少|不存在|无效|不能直接|重复|未配置|已销毁/.test(message))) {
2004
+ return { origin: 'usage', code: explicitCode, hint: context.hint ?? '检查调用参数、Owner 作用域和相关插件是否已安装。' }
2005
+ }
2006
+ if (phase === 'global') return { origin: 'unknown', code: explicitCode, hint: context.hint }
2007
+ return { origin: 'application', code: explicitCode, hint: context.hint }
2008
+ }
2009
+
2010
+ function isUsageErrorCode(code: string): boolean {
2011
+ return /^(?:INVALID_|.*_(?:CONTEXT_MISSING|CONTEXT_DISPOSED|OPTIONS|MISSING|UNAVAILABLE))/.test(code)
2012
+ }
2013
+
2014
+ function extractErrorCode(message: string): string | undefined {
2015
+ return message.match(/\b[A-Z][A-Z0-9_]{2,}_[A-Z0-9_]+\b/)?.[0]
2016
+ }
2017
+
2018
+ function readErrorProperty(error: unknown, key: string): string | undefined {
2019
+ if (!error || (typeof error !== 'object' && typeof error !== 'function')) return undefined
2020
+ const value = (error as Record<string, unknown>)[key]
2021
+ return typeof value === 'string' ? value : undefined
2022
+ }
2023
+
2024
+ function mergeRecovery(previous: DevToolsErrorRecovery, next: DevToolsErrorRecovery | undefined): DevToolsErrorRecovery {
2025
+ if (!next) return previous
2026
+ if (next === 'retrying') return next
2027
+ if (next === 'recovered') return next
2028
+ if (next === 'fallback') return next
2029
+ if (previous === 'fallback') return previous
2030
+ return next
2031
+ }
2032
+
2033
+ function diagnosticErrorKey(error: Pick<DevToolsErrorTrace, 'origin' | 'code' | 'name' | 'message' | 'source' | 'component'>): string {
2034
+ return [error.origin, error.code ?? error.name, error.message, error.source ?? '', error.component ?? ''].join(':')
2035
+ }
2036
+
2037
+ function sanitizeImportedError(error: DevToolsErrorTrace): DevToolsErrorTrace {
2038
+ const phase = error.phase ?? 'global'
2039
+ return {
2040
+ ...error,
2041
+ phase,
2042
+ phases: Array.isArray(error.phases) && error.phases.length > 0 ? error.phases : [phase],
2043
+ origin: error.origin ?? 'unknown',
2044
+ handled: error.handled === true,
2045
+ recovery: error.recovery ?? 'propagated'
2046
+ }
2047
+ }
2048
+
2049
+ function parseDiagnosticSnapshot(value: unknown): DevToolsDiagnosticSnapshot {
2050
+ if (!value || typeof value !== 'object') throw new Error('Vobs DevTools: invalid diagnostic snapshot')
2051
+ const snapshot = value as Partial<DevToolsDiagnosticSnapshot>
2052
+ if (snapshot.version !== 1
2053
+ || !Array.isArray(snapshot.updates)
2054
+ || !Array.isArray(snapshot.lifecycle)
2055
+ || !Array.isArray(snapshot.network)
2056
+ || !Array.isArray(snapshot.errors)
2057
+ || !snapshot.performance
2058
+ || !snapshot.memory) {
2059
+ throw new Error('Vobs DevTools: unsupported diagnostic snapshot')
2060
+ }
2061
+ if (!snapshot.updates.every(item => isRecord(item)
2062
+ && typeof item.id === 'string'
2063
+ && typeof item.duration === 'number'
2064
+ && Array.isArray(item.effects)
2065
+ && Array.isArray(item.affectedSignals)
2066
+ && Array.isArray(item.affectedEffects)
2067
+ && Array.isArray(item.domUpdates))
2068
+ || !snapshot.lifecycle.every(item => isRecord(item) && typeof item.id === 'string' && typeof item.type === 'string')
2069
+ || !snapshot.network.every(item => isRecord(item) && typeof item.id === 'number' && typeof item.url === 'string' && isRecord(item.headers))
2070
+ || !snapshot.errors.every(item => isRecord(item) && typeof item.id === 'number' && typeof item.message === 'string')
2071
+ || !isRecord(snapshot.performance)
2072
+ || typeof snapshot.performance.updateCount !== 'number'
2073
+ || typeof snapshot.performance.effectExecutionCount !== 'number'
2074
+ || !isRecord(snapshot.memory)) {
2075
+ throw new Error('Vobs DevTools: malformed diagnostic snapshot')
2076
+ }
2077
+ return snapshot as DevToolsDiagnosticSnapshot
2078
+ }
2079
+
2080
+ function parseSSRRequestSnapshot(value: unknown): readonly HTTPDebugRequest[] {
2081
+ const candidate = value && typeof value === 'object' && !Array.isArray(value)
2082
+ ? value as { readonly version?: unknown; readonly environment?: unknown; readonly requests?: unknown }
2083
+ : { version: 1, environment: 'server', requests: value }
2084
+ if (candidate.version !== 1 || candidate.environment !== 'server' || !Array.isArray(candidate.requests)) {
2085
+ throw new Error('Vobs DevTools: invalid SSR request snapshot')
2086
+ }
2087
+ if (!candidate.requests.every(item => isRecord(item)
2088
+ && typeof item.id === 'number'
2089
+ && typeof item.url === 'string'
2090
+ && typeof item.method === 'string'
2091
+ && typeof item.startedAt === 'number'
2092
+ && isRecord(item.headers))) {
2093
+ throw new Error('Vobs DevTools: malformed SSR request snapshot')
2094
+ }
2095
+ return candidate.requests as HTTPDebugRequest[]
2096
+ }
2097
+
2098
+ function isRecord(value: unknown): value is Record<string, unknown> {
2099
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
2100
+ }
2101
+
2102
+ interface NormalizedPrivacyOptions {
2103
+ readonly redactedHeaders: readonly string[]
2104
+ readonly redactedFields: readonly string[]
2105
+ readonly redactDomValues: boolean
2106
+ readonly replacement: string
2107
+ }
2108
+
2109
+ function normalizePrivacyOptions(options: DevToolsPrivacyOptions | undefined): NormalizedPrivacyOptions {
2110
+ return {
2111
+ redactedHeaders: options?.redactedHeaders ?? [],
2112
+ redactedFields: options?.redactedFields ?? [],
2113
+ redactDomValues: options?.redactDomValues ?? false,
2114
+ replacement: options?.replacement ?? '[Redacted]'
2115
+ }
2116
+ }
2117
+
2118
+ function sanitizeImportedUpdate(update: UpdateTrace, privacy: NormalizedPrivacyOptions): UpdateTrace {
2119
+ return {
2120
+ ...update,
2121
+ previousValue: serializeForDevTools(update.previousValue, new Set<object>(), 0, privacy),
2122
+ nextValue: serializeForDevTools(update.nextValue, new Set<object>(), 0, privacy),
2123
+ domUpdates: update.domUpdates.map(mutation => ({
2124
+ ...mutation,
2125
+ previousValue: privacy.redactDomValues ? privacy.replacement : serializeForDevTools(mutation.previousValue, new Set<object>(), 0, privacy),
2126
+ nextValue: privacy.redactDomValues ? privacy.replacement : serializeForDevTools(mutation.nextValue, new Set<object>(), 0, privacy)
2127
+ }))
2128
+ }
2129
+ }
2130
+
2131
+ function sanitizeImportedRequest(request: NetworkRequestTrace, privacy: NormalizedPrivacyOptions): NetworkRequestTrace {
2132
+ return {
2133
+ ...request,
2134
+ headers: redactHeaders(request.headers, privacy),
2135
+ requestBody: serializeForDevTools(request.requestBody, new Set<object>(), 0, privacy),
2136
+ responseBody: serializeForDevTools(request.responseBody, new Set<object>(), 0, privacy)
2137
+ }
2138
+ }
2139
+
2140
+ function redactHeaders(headers: Readonly<Record<string, string>>, privacy: NormalizedPrivacyOptions): Readonly<Record<string, string>> {
2141
+ const result: Record<string, string> = {}
2142
+ for (const [key, value] of Object.entries(headers)) {
2143
+ if (/authorization|cookie|token|password|secret|api[-_]?key/i.test(key)
2144
+ || privacy.redactedHeaders.some(fragment => key.toLowerCase().includes(fragment.toLowerCase()))) continue
2145
+ result[key] = value
2146
+ }
2147
+ return result
2148
+ }
2149
+
2150
+ function serializeForDevTools(value: unknown, seen = new Set<object>(), depth = 0, privacy?: NormalizedPrivacyOptions): unknown {
2151
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
2152
+ if (typeof value === 'number') return Number.isFinite(value) ? value : String(value)
2153
+ if (typeof value === 'bigint') return `${value}n`
2154
+ if (typeof value === 'undefined') return undefined
2155
+ if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
2156
+ if (typeof value === 'symbol') return String(value)
2157
+ if (depth >= 4) return '[MaxDepth]'
2158
+
2159
+ const object = value as object
2160
+ if (seen.has(object)) return '[Circular]'
2161
+ seen.add(object)
2162
+ try {
2163
+ if (value instanceof Date) return value.toISOString()
2164
+ if (value instanceof Error) return { name: value.name, message: value.message }
2165
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => serializeForDevTools(item, seen, depth + 1, privacy))
2166
+
2167
+ const result: Record<string, unknown> = {}
2168
+ for (const key of Object.keys(object).slice(0, 100)) {
2169
+ try {
2170
+ if (privacy?.redactedFields.some(field => field.toLowerCase() === key.toLowerCase())) {
2171
+ result[key] = privacy.replacement
2172
+ } else {
2173
+ result[key] = serializeForDevTools((object as Record<string, unknown>)[key], seen, depth + 1, privacy)
2174
+ }
2175
+ } catch {
2176
+ result[key] = '[Uninspectable]'
2177
+ }
2178
+ }
2179
+ return result
2180
+ } finally {
2181
+ seen.delete(object)
2182
+ }
2183
+ }
2184
+
2185
+ declare global {
2186
+ interface Window {
2187
+ __VOBS_DEVTOOLS__?: DevToolsAPI
2188
+ }
2189
+ }