@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/LICENSE +21 -0
- package/README.md +50 -0
- package/package.json +22 -0
- package/src/index.test.ts +696 -0
- package/src/index.ts +2189 -0
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
|
+
}
|