nuxt-devtools-observatory 0.1.6 → 0.1.7

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.
@@ -1,32 +1,6 @@
1
- /**
2
- * useObservatoryData — live bridge between the Nuxt app and the client SPA.
3
- *
4
- * The client SPA runs at localhost:4949 (cross-origin from the Nuxt app at
5
- * localhost:3000). Direct window.top property access is blocked by the browser.
6
- * However postMessage IS allowed cross-origin:
7
- *
8
- * iframe (4949) → window.top.postMessage({ type: 'observatory:request' }) → Nuxt page (3000)
9
- * Nuxt plugin.ts → event.source.postMessage({ type: 'observatory:snapshot', data }) → iframe
10
- *
11
- * The plugin.ts listener is registered immediately on plugin init (not deferred
12
- * to app:mounted) so requests sent before full hydration are answered correctly.
13
- */
14
-
15
- import { ref, onUnmounted } from 'vue'
1
+ import { ref } from 'vue'
16
2
 
17
- export interface TransitionEntry {
18
- id: string
19
- transitionName: string
20
- parentComponent: string
21
- direction: 'enter' | 'leave'
22
- phase: 'entering' | 'entered' | 'leaving' | 'left' | 'enter-cancelled' | 'leave-cancelled' | 'interrupted'
23
- startTime: number
24
- endTime?: number
25
- durationMs?: number
26
- cancelled: boolean
27
- appear: boolean
28
- mode?: string
29
- }
3
+ const POLL_MS = 500
30
4
 
31
5
  export interface FetchEntry {
32
6
  id: string
@@ -34,130 +8,178 @@ export interface FetchEntry {
34
8
  url: string
35
9
  status: 'pending' | 'ok' | 'error' | 'cached'
36
10
  origin: 'ssr' | 'csr'
11
+ startTime: number
12
+ endTime?: number
37
13
  ms?: number
38
14
  size?: number
39
15
  cached: boolean
40
16
  payload?: unknown
17
+ error?: unknown
41
18
  file?: string
42
19
  line?: number
43
- startOffset?: number
44
20
  }
45
21
 
46
- interface ComposableEntry {
22
+ export interface ProvideEntry {
23
+ key: string
24
+ componentName: string
25
+ componentFile: string
26
+ componentUid: number
27
+ parentUid?: number
28
+ parentFile?: string
29
+ isReactive: boolean
30
+ valueSnapshot: unknown
31
+ line: number
32
+ }
33
+
34
+ export interface InjectEntry {
35
+ key: string
36
+ componentName: string
37
+ componentFile: string
38
+ componentUid: number
39
+ parentUid?: number
40
+ parentFile?: string
41
+ resolved: boolean
42
+ resolvedFromFile?: string
43
+ resolvedFromUid?: number
44
+ line: number
45
+ }
46
+
47
+ export interface ProvideInjectSnapshot {
48
+ provides: ProvideEntry[]
49
+ injects: InjectEntry[]
50
+ }
51
+
52
+ export interface ComposableEntry {
47
53
  id: string
48
54
  name: string
49
- component: string
50
- instances: number
55
+ componentFile: string
56
+ componentUid: number
51
57
  status: 'mounted' | 'unmounted'
52
58
  leak: boolean
53
59
  leakReason?: string
54
- refs: Array<{ key: string; type: string; val: string }>
55
- watchers: number
56
- intervals: number
57
- lifecycle: { onMounted: boolean; onUnmounted: boolean; watchersCleaned: boolean; intervalsCleaned: boolean }
58
- }
59
-
60
- interface ProvideInjectNode {
61
- id: string
62
- label: string
63
- type: 'provider' | 'consumer' | 'both' | 'error'
64
- provides: Array<{ key: string; val: string; reactive: boolean }>
65
- injects: Array<{ key: string; from: string | null; ok: boolean }>
66
- children: ProvideInjectNode[]
60
+ refs: Record<string, { type: 'ref' | 'computed' | 'reactive'; value: unknown }>
61
+ watcherCount: number
62
+ intervalCount: number
63
+ lifecycle: {
64
+ hasOnMounted: boolean
65
+ hasOnUnmounted: boolean
66
+ watchersCleaned: boolean
67
+ intervalsCleaned: boolean
68
+ }
69
+ file: string
70
+ line: number
67
71
  }
68
72
 
69
- interface RenderNode {
70
- id: string
71
- label: string
73
+ export interface RenderEntry {
74
+ uid: number
75
+ name: string
72
76
  file: string
73
77
  renders: number
78
+ totalMs: number
74
79
  avgMs: number
75
- triggers: string[]
76
- children: RenderNode[]
80
+ triggers: Array<{ key: string; type: string; timestamp: number }>
81
+ rect?: { x: number; y: number; width: number; height: number; top: number; left: number }
82
+ children: number[]
83
+ parentUid?: number
84
+ }
85
+
86
+ export interface TransitionEntry {
87
+ id: string
88
+ transitionName: string
89
+ parentComponent: string
90
+ direction: 'enter' | 'leave'
91
+ phase: 'entering' | 'entered' | 'leaving' | 'left' | 'enter-cancelled' | 'leave-cancelled' | 'interrupted'
92
+ startTime: number
93
+ endTime?: number
94
+ durationMs?: number
95
+ cancelled: boolean
96
+ appear: boolean
97
+ mode?: string
77
98
  }
78
99
 
79
100
  interface ObservatorySnapshot {
80
- transitions?: TransitionEntry[]
81
101
  fetch?: FetchEntry[]
102
+ provideInject?: ProvideInjectSnapshot
82
103
  composables?: ComposableEntry[]
83
- provideInject?: ProvideInjectNode[]
84
- renders?: RenderNode[]
104
+ renders?: RenderEntry[]
105
+ transitions?: TransitionEntry[]
85
106
  }
86
107
 
87
- const POLL_MS = 500
108
+ const fetchEntries = ref<FetchEntry[]>([])
109
+ const provideInject = ref<ProvideInjectSnapshot>({ provides: [], injects: [] })
110
+ const composables = ref<ComposableEntry[]>([])
111
+ const renders = ref<RenderEntry[]>([])
112
+ const transitions = ref<TransitionEntry[]>([])
113
+ const connected = ref(false)
88
114
 
89
- export function useObservatoryData() {
90
- const transitions = ref<TransitionEntry[]>([])
91
- const fetches = ref<FetchEntry[]>([])
92
- const composables = ref<ComposableEntry[]>([])
93
- const provideInject = ref<ProvideInjectNode[]>([])
94
- const renders = ref<RenderNode[]>([])
95
- const connected = ref(false)
96
-
97
- function request() {
98
- window.top?.postMessage({ type: 'observatory:request' }, '*')
115
+ let started = false
116
+ let parentOrigin = '*'
117
+
118
+ function cloneArray<T>(value: T[] | undefined): T[] {
119
+ return value ? value.map((item) => ({ ...item })) : []
120
+ }
121
+
122
+ function getParentOrigin() {
123
+ if (typeof document === 'undefined' || !document.referrer) {
124
+ return '*'
99
125
  }
100
126
 
101
- function onMessage(event: MessageEvent) {
102
- if (event.data?.type !== 'observatory:snapshot') {
103
- return
104
- }
105
-
106
- let data: ObservatorySnapshot | null = null
107
-
108
- if (typeof event.data.data === 'string') {
109
- try {
110
- data = JSON.parse(event.data.data)
111
- } catch (err) {
112
- console.warn('Failed to parse observatory snapshot:', err)
113
-
114
- data = null
115
- }
116
- } else {
117
- data = event.data.data as ObservatorySnapshot
118
- }
119
-
120
- transitions.value = data?.transitions ?? []
121
- fetches.value = data?.fetch ?? []
122
- composables.value = data?.composables ?? []
123
-
124
- // Always guarantee provideInject.value is an array
125
- const pi = data?.provideInject
126
-
127
- if (Array.isArray(pi)) {
128
- provideInject.value = pi
129
- } else if (pi && typeof pi === 'object') {
130
- // If registry returns { provides, injects }, build a single node
131
- provideInject.value = [
132
- {
133
- provides: Array.isArray(pi.provides) ? pi.provides : [],
134
- injects: Array.isArray(pi.injects) ? pi.injects : [],
135
- id: 'root',
136
- label: 'Provide/Inject Root',
137
- type: 'both',
138
- children: [],
139
- },
140
- ]
141
- } else {
142
- provideInject.value = []
143
- }
144
-
145
- if (!Array.isArray(provideInject.value)) {
146
- provideInject.value = []
147
- }
148
-
149
- renders.value = data?.renders ?? []
150
- connected.value = true
127
+ try {
128
+ return new URL(document.referrer).origin
129
+ } catch {
130
+ return '*'
151
131
  }
132
+ }
152
133
 
153
- window.addEventListener('message', onMessage)
154
- const timer = setInterval(request, POLL_MS)
155
- request() // immediate first request
134
+ function requestSnapshot() {
135
+ window.top?.postMessage({ type: 'observatory:request' }, parentOrigin)
136
+ }
137
+
138
+ function onMessage(event: MessageEvent) {
139
+ if (event.data?.type !== 'observatory:snapshot') {
140
+ return
141
+ }
142
+
143
+ if (parentOrigin !== '*' && event.origin !== parentOrigin) {
144
+ return
145
+ }
156
146
 
157
- onUnmounted(() => {
158
- window.removeEventListener('message', onMessage)
159
- clearInterval(timer)
160
- })
147
+ const data = event.data.data as ObservatorySnapshot
148
+ fetchEntries.value = cloneArray(data.fetch)
149
+ provideInject.value = data.provideInject
150
+ ? {
151
+ provides: cloneArray(data.provideInject.provides),
152
+ injects: cloneArray(data.provideInject.injects),
153
+ }
154
+ : { provides: [], injects: [] }
155
+ composables.value = cloneArray(data.composables)
156
+ renders.value = cloneArray(data.renders)
157
+ transitions.value = cloneArray(data.transitions)
158
+ connected.value = true
159
+ }
160
+
161
+ function ensureStarted() {
162
+ if (started || typeof window === 'undefined') {
163
+ return
164
+ }
165
+
166
+ started = true
167
+ parentOrigin = getParentOrigin()
168
+ window.addEventListener('message', onMessage)
169
+ window.setInterval(requestSnapshot, POLL_MS)
170
+ requestSnapshot()
171
+ }
161
172
 
162
- return { transitions, fetches, composables, provideInject, renders, connected }
173
+ export function useObservatoryData() {
174
+ ensureStarted()
175
+
176
+ return {
177
+ fetch: fetchEntries,
178
+ provideInject,
179
+ composables,
180
+ renders,
181
+ transitions,
182
+ connected,
183
+ refresh: requestSnapshot,
184
+ }
163
185
  }
@@ -1,8 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, type Ref } from 'vue'
3
- import { useObservatoryData } from '../stores/observatory'
2
+ import { ref, computed } from 'vue'
3
+ import { useObservatoryData, type ComposableEntry as RuntimeComposableEntry } from '../stores/observatory'
4
4
 
5
- interface ComposableEntry {
5
+ interface ComposableViewEntry {
6
6
  id: string
7
7
  name: string
8
8
  component: string
@@ -21,49 +21,89 @@ interface ComposableEntry {
21
21
  }
22
22
  }
23
23
 
24
- const { composables } = useObservatoryData()
25
- const entries = composables as Ref<ComposableEntry[]>
24
+ const { composables: rawEntries, connected } = useObservatoryData()
25
+
26
+ const entries = computed<ComposableViewEntry[]>(() => {
27
+ const groups = new Map<string, RuntimeComposableEntry[]>()
28
+
29
+ for (const entry of rawEntries.value) {
30
+ const key = `${entry.name}::${entry.componentFile}`
31
+ const list = groups.get(key) ?? []
32
+ list.push(entry)
33
+ groups.set(key, list)
34
+ }
35
+
36
+ return [...groups.entries()].map(([key, group]) => {
37
+ const latest = [...group].sort((a, b) => b.line - a.line)[0]
38
+ const leakReasons = [...new Set(group.map((entry) => entry.leakReason).filter(Boolean))]
39
+
40
+ return {
41
+ id: key,
42
+ name: latest.name,
43
+ component: latest.componentFile,
44
+ instances: group.length,
45
+ status: group.some((entry) => entry.status === 'mounted') ? 'mounted' : 'unmounted',
46
+ leak: group.some((entry) => entry.leak),
47
+ leakReason: leakReasons.join(' · ') || undefined,
48
+ refs: Object.entries(latest.refs).map(([refKey, refValue]) => ({
49
+ key: refKey,
50
+ type: refValue.type,
51
+ val: typeof refValue.value === 'string' ? refValue.value : JSON.stringify(refValue.value),
52
+ })),
53
+ watchers: group.reduce((sum, entry) => sum + entry.watcherCount, 0),
54
+ intervals: group.reduce((sum, entry) => sum + entry.intervalCount, 0),
55
+ lifecycle: {
56
+ onMounted: group.some((entry) => entry.lifecycle.hasOnMounted),
57
+ onUnmounted: group.some((entry) => entry.lifecycle.hasOnUnmounted),
58
+ watchersCleaned: group.every((entry) => entry.lifecycle.watchersCleaned),
59
+ intervalsCleaned: group.every((entry) => entry.lifecycle.intervalsCleaned),
60
+ },
61
+ }
62
+ })
63
+ })
26
64
 
27
65
  const filter = ref('all')
28
66
  const search = ref('')
29
67
  const expanded = ref<string | null>(null)
30
68
 
31
- const counts = computed<{ mounted: number; leaks: number }>(() => {
32
- return {
33
- mounted: entries.value.filter((e) => e.status === 'mounted').length,
34
- leaks: entries.value.filter((e) => e.leak).length,
35
- }
36
- })
69
+ const counts = computed(() => ({
70
+ mounted: entries.value.filter((entry) => entry.status === 'mounted').length,
71
+ leaks: entries.value.filter((entry) => entry.leak).length,
72
+ }))
37
73
 
38
- const filtered = computed<ComposableEntry[]>(() => {
39
- return entries.value.filter((e) => {
40
- if (filter.value === 'leak' && !e.leak) {
74
+ const filtered = computed(() => {
75
+ return entries.value.filter((entry) => {
76
+ if (filter.value === 'leak' && !entry.leak) {
41
77
  return false
42
- } else {
43
- if (filter.value === 'unmounted' && e.status !== 'unmounted') {
44
- return false
45
- } else {
46
- const q = search.value.toLowerCase()
47
-
48
- if (q && !e.name.toLowerCase().includes(q) && !e.component.toLowerCase().includes(q)) {
49
- return false
50
- } else {
51
- return true
52
- }
53
- }
54
78
  }
79
+
80
+ if (filter.value === 'unmounted' && entry.status !== 'unmounted') {
81
+ return false
82
+ }
83
+
84
+ const q = search.value.toLowerCase()
85
+
86
+ if (q && !entry.name.toLowerCase().includes(q) && !entry.component.toLowerCase().includes(q)) {
87
+ return false
88
+ }
89
+
90
+ return true
55
91
  })
56
92
  })
57
93
 
58
- function lifecycleRows(e: ComposableEntry) {
94
+ function lifecycleRows(entry: ComposableViewEntry) {
59
95
  return [
60
- { label: 'onMounted', ok: e.lifecycle.onMounted, status: e.lifecycle.onMounted ? 'registered' : 'not used' },
61
- { label: 'onUnmounted', ok: e.lifecycle.onUnmounted, status: e.lifecycle.onUnmounted ? 'registered' : 'missing' },
62
- { label: 'watchers cleaned', ok: e.lifecycle.watchersCleaned, status: e.lifecycle.watchersCleaned ? 'all stopped' : 'NOT stopped' },
96
+ { label: 'onMounted', ok: entry.lifecycle.onMounted, status: entry.lifecycle.onMounted ? 'registered' : 'not used' },
97
+ { label: 'onUnmounted', ok: entry.lifecycle.onUnmounted, status: entry.lifecycle.onUnmounted ? 'registered' : 'missing' },
98
+ {
99
+ label: 'watchers cleaned',
100
+ ok: entry.lifecycle.watchersCleaned,
101
+ status: entry.lifecycle.watchersCleaned ? 'all stopped' : 'NOT stopped',
102
+ },
63
103
  {
64
104
  label: 'intervals cleared',
65
- ok: e.lifecycle.intervalsCleaned,
66
- status: e.lifecycle.intervalsCleaned ? 'all cleared' : 'NOT cleared',
105
+ ok: entry.lifecycle.intervalsCleaned,
106
+ status: entry.lifecycle.intervalsCleaned ? 'all cleared' : 'NOT cleared',
67
107
  },
68
108
  ]
69
109
  }
@@ -86,7 +126,7 @@ function lifecycleRows(e: ComposableEntry) {
86
126
  </div>
87
127
  <div class="stat-card">
88
128
  <div class="stat-label">instances</div>
89
- <div class="stat-val">{{ entries.reduce((a, e) => a + e.instances, 0) }}</div>
129
+ <div class="stat-val">{{ entries.reduce((sum, entry) => sum + entry.instances, 0) }}</div>
90
130
  </div>
91
131
  </div>
92
132
 
@@ -104,52 +144,53 @@ function lifecycleRows(e: ComposableEntry) {
104
144
 
105
145
  <div class="list">
106
146
  <div
107
- v-for="e in filtered"
108
- :key="e.id"
147
+ v-for="entry in filtered"
148
+ :key="entry.id"
109
149
  class="comp-card"
110
- :class="{ leak: e.leak, expanded: expanded === e.id }"
111
- @click="expanded = expanded === e.id ? null : e.id"
150
+ :class="{ leak: entry.leak, expanded: expanded === entry.id }"
151
+ @click="expanded = expanded === entry.id ? null : entry.id"
112
152
  >
113
153
  <div class="comp-header">
114
- <span class="mono bold" style="font-size: 12px">{{ e.name }}</span>
115
- <span class="muted text-sm" style="margin-left: 4px">{{ e.component }}</span>
154
+ <span class="mono bold" style="font-size: 12px">{{ entry.name }}</span>
155
+ <span class="muted text-sm" style="margin-left: 4px">{{ entry.component }}</span>
116
156
  <div class="comp-meta">
117
- <span v-if="e.instances > 1" class="muted text-sm">{{ e.instances }}×</span>
118
- <span v-if="e.watchers > 0 && !e.leak" class="badge badge-warn">
119
- {{ e.watchers }} watcher{{ e.watchers > 1 ? 's' : '' }}
157
+ <span v-if="entry.instances > 1" class="muted text-sm">{{ entry.instances }}×</span>
158
+ <span v-if="entry.watchers > 0 && !entry.leak" class="badge badge-warn">
159
+ {{ entry.watchers }} watcher{{ entry.watchers > 1 ? 's' : '' }}
120
160
  </span>
121
- <span v-if="e.intervals > 0 && !e.leak" class="badge badge-warn">
122
- {{ e.intervals }} interval{{ e.intervals > 1 ? 's' : '' }}
161
+ <span v-if="entry.intervals > 0 && !entry.leak" class="badge badge-warn">
162
+ {{ entry.intervals }} interval{{ entry.intervals > 1 ? 's' : '' }}
123
163
  </span>
124
- <span v-if="e.leak" class="badge badge-err">leak detected</span>
125
- <span v-else-if="e.status === 'mounted'" class="badge badge-ok">mounted</span>
164
+ <span v-if="entry.leak" class="badge badge-err">leak detected</span>
165
+ <span v-else-if="entry.status === 'mounted'" class="badge badge-ok">mounted</span>
126
166
  <span v-else class="badge badge-gray">unmounted</span>
127
167
  </div>
128
168
  </div>
129
169
 
130
- <!-- Expanded detail -->
131
- <div v-if="expanded === e.id" class="comp-detail" @click.stop>
132
- <div v-if="e.leak" class="leak-banner">{{ e.leakReason }}</div>
170
+ <div v-if="expanded === entry.id" class="comp-detail" @click.stop>
171
+ <div v-if="entry.leak" class="leak-banner">{{ entry.leakReason }}</div>
133
172
 
134
173
  <div class="section-label">reactive state</div>
135
- <div v-for="r in e.refs" :key="r.key" class="ref-row">
136
- <span class="mono text-sm" style="min-width: 90px; color: var(--text2)">{{ r.key }}</span>
174
+ <div v-for="refEntry in entry.refs" :key="refEntry.key" class="ref-row">
175
+ <span class="mono text-sm" style="min-width: 90px; color: var(--text2)">{{ refEntry.key }}</span>
137
176
  <span class="mono text-sm muted" style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
138
- {{ r.val }}
177
+ {{ refEntry.val }}
139
178
  </span>
140
- <span class="badge badge-info text-xs">{{ r.type }}</span>
179
+ <span class="badge badge-info text-xs">{{ refEntry.type }}</span>
141
180
  </div>
142
181
 
143
182
  <div class="section-label" style="margin-top: 8px">lifecycle</div>
144
- <div v-for="lc in lifecycleRows(e)" :key="lc.label" class="lc-row">
145
- <span class="lc-dot" :style="{ background: lc.ok ? 'var(--teal)' : 'var(--red)' }"></span>
146
- <span class="muted text-sm" style="min-width: 110px">{{ lc.label }}</span>
147
- <span class="text-sm" :style="{ color: lc.ok ? 'var(--teal)' : 'var(--red)' }">{{ lc.status }}</span>
183
+ <div v-for="row in lifecycleRows(entry)" :key="row.label" class="lc-row">
184
+ <span class="lc-dot" :style="{ background: row.ok ? 'var(--teal)' : 'var(--red)' }"></span>
185
+ <span class="muted text-sm" style="min-width: 110px">{{ row.label }}</span>
186
+ <span class="text-sm" :style="{ color: row.ok ? 'var(--teal)' : 'var(--red)' }">{{ row.status }}</span>
148
187
  </div>
149
188
  </div>
150
189
  </div>
151
190
 
152
- <div v-if="!filtered.length" class="muted text-sm" style="padding: 16px 0">no composables match</div>
191
+ <div v-if="!filtered.length" class="muted text-sm" style="padding: 16px 0">
192
+ {{ connected ? 'No composables recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
193
+ </div>
153
194
  </div>
154
195
  </div>
155
196
  </template>