aipeek 0.2.7 → 0.2.8
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/README.md +92 -18
- package/dist/{chunk-37VLLZIU.js → chunk-4BPXH2SW.js} +620 -45
- package/dist/{chunk-STYCUT23.cjs → chunk-SDUTK75Y.cjs} +621 -46
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +3 -1
- package/src/babel/line-profiler.ts +190 -0
- package/src/client/client-patch.ts +326 -2
- package/src/client/client.ts +246 -44
- package/src/core/action.ts +199 -22
- package/src/core/compact.ts +2 -0
- package/src/core/detail.ts +3 -1
- package/src/core/diff.ts +55 -1
- package/src/core/emit.ts +14 -2
- package/src/core/perf.ts +239 -0
- package/src/core/types.ts +73 -0
- package/src/core/util.ts +115 -0
- package/src/server/plugin.ts +463 -52
|
@@ -2,14 +2,35 @@
|
|
|
2
2
|
// Injected as inline <script> at head-prepend to patch console/fetch/XHR/errors
|
|
3
3
|
// BEFORE any application modules execute.
|
|
4
4
|
|
|
5
|
-
import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
|
|
5
|
+
import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
|
|
6
6
|
|
|
7
7
|
type NetworkEntry = NetworkRequest
|
|
8
8
|
|
|
9
|
+
// --- Perf buffer (runtime accumulation; client.ts projects it into PerformanceData) ---
|
|
10
|
+
// Internal shape differs from core/types PerformanceData: buckets is a name→bucket map (not
|
|
11
|
+
// array), carries startedAt + active (the live view key). client.ts does the array/windowMs
|
|
12
|
+
// projection on collect.
|
|
13
|
+
interface PerfStatBuf { total: number, n: number, max: number, samples: number[] }
|
|
14
|
+
interface PerfBucketBuf {
|
|
15
|
+
components: Record<string, PerfStatBuf>
|
|
16
|
+
frames: { total: number, long: number, max: number, samples: number[] }
|
|
17
|
+
lines: Record<string, PerfStatBuf> // line-level: "File.tsx:42:varName" → stat
|
|
18
|
+
}
|
|
19
|
+
interface PerfBuffer {
|
|
20
|
+
startedAt: number
|
|
21
|
+
hiddenFrames: number
|
|
22
|
+
mobxPatched: boolean
|
|
23
|
+
buckets: Record<string, PerfBucketBuf>
|
|
24
|
+
active: string
|
|
25
|
+
longtasks: { count: number, max: number }
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
interface AipeekBuffers {
|
|
10
29
|
consoleLogs: LogEntry[]
|
|
11
30
|
networkRequests: NetworkEntry[]
|
|
12
31
|
errorEntries: ErrorEntry[]
|
|
32
|
+
actionEntries: ActionEntry[]
|
|
33
|
+
perf: PerfBuffer
|
|
13
34
|
}
|
|
14
35
|
|
|
15
36
|
// --- Ring buffers (persist across HMR) ---
|
|
@@ -17,13 +38,186 @@ interface AipeekBuffers {
|
|
|
17
38
|
const MAX_CONSOLE = 100
|
|
18
39
|
const MAX_NETWORK = 50
|
|
19
40
|
const MAX_ERRORS = 20
|
|
41
|
+
const MAX_ACTIONS = 30
|
|
20
42
|
|
|
21
43
|
const BUFFERS_KEY = '__AIPEEK_BUFFERS__'
|
|
22
44
|
const existing = (window as any)[BUFFERS_KEY] as AipeekBuffers | undefined
|
|
23
45
|
const consoleLogs: LogEntry[] = existing?.consoleLogs ?? []
|
|
24
46
|
const networkRequests: NetworkEntry[] = existing?.networkRequests ?? []
|
|
25
47
|
const errorEntries: ErrorEntry[] = existing?.errorEntries ?? []
|
|
26
|
-
|
|
48
|
+
const actionEntries: ActionEntry[] = existing?.actionEntries ?? []
|
|
49
|
+
const perf: PerfBuffer = existing?.perf ?? {
|
|
50
|
+
startedAt: Date.now(),
|
|
51
|
+
hiddenFrames: 0,
|
|
52
|
+
mobxPatched: false,
|
|
53
|
+
buckets: {},
|
|
54
|
+
active: '__all__',
|
|
55
|
+
longtasks: { count: 0, max: 0 },
|
|
56
|
+
}
|
|
57
|
+
;(window as any)[BUFFERS_KEY] = { consoleLogs, networkRequests, errorEntries, actionEntries, perf }
|
|
58
|
+
|
|
59
|
+
const PERF_SAMPLE_CAP = 5000
|
|
60
|
+
|
|
61
|
+
function perfBucket(name: string): PerfBucketBuf {
|
|
62
|
+
let b = perf.buckets[name]
|
|
63
|
+
if (!b) {
|
|
64
|
+
b = { components: {}, frames: { total: 0, long: 0, max: 0, samples: [] }, lines: {} }
|
|
65
|
+
perf.buckets[name] = b
|
|
66
|
+
}
|
|
67
|
+
return b
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The view bucket + the __all__ aggregate. When active IS __all__, return it once —
|
|
71
|
+
// else every hit double-counts into __all__ (n, total, frames all 2×).
|
|
72
|
+
function targetBuckets(): string[] {
|
|
73
|
+
return perf.active === '__all__' ? ['__all__'] : [perf.active, '__all__']
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One timed hit (component render / line exec) → a stat map, accumulating total/n/max + capped
|
|
77
|
+
// samples. samples capped so a long session doesn't grow unbounded (p95 still representative).
|
|
78
|
+
function recordStat(map: Record<string, PerfStatBuf>, key: string, ms: number) {
|
|
79
|
+
let s = map[key]
|
|
80
|
+
if (!s) {
|
|
81
|
+
s = { total: 0, n: 0, max: 0, samples: [] }
|
|
82
|
+
map[key] = s
|
|
83
|
+
}
|
|
84
|
+
s.total += ms
|
|
85
|
+
s.n += 1
|
|
86
|
+
if (ms > s.max)
|
|
87
|
+
s.max = ms
|
|
88
|
+
s.samples.push(ms)
|
|
89
|
+
if (s.samples.length > PERF_SAMPLE_CAP)
|
|
90
|
+
s.samples.shift()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One component render's self-time → both its live view bucket and the __all__ aggregate.
|
|
94
|
+
function hitComponent(name: string, ms: number) {
|
|
95
|
+
for (const bucketName of targetBuckets())
|
|
96
|
+
recordStat(perfBucket(bucketName).components, name, ms)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One line's execution time → both its live view bucket and the __all__ aggregate.
|
|
100
|
+
// Called by babel-inserted __line(label, fn) wrapper.
|
|
101
|
+
function hitLine(label: string, ms: number) {
|
|
102
|
+
for (const bucketName of targetBuckets())
|
|
103
|
+
recordStat(perfBucket(bucketName).lines, label, ms)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Runtime for babel plugin: wraps one statement, times it, records to perf.lines.
|
|
107
|
+
// Exposed globally so babel-transformed code can call it (client-patch can't be imported).
|
|
108
|
+
;(window as any).__line = function __line<T>(label: string, fn: () => T): T {
|
|
109
|
+
const start = performance.now()
|
|
110
|
+
try {
|
|
111
|
+
return fn()
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
hitLine(label, performance.now() - start)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One frame's dt → both live view bucket and __all__. long = dropped (>16.7ms ≈ missed 60fps).
|
|
119
|
+
function hitFrame(dt: number) {
|
|
120
|
+
for (const bucketName of targetBuckets()) {
|
|
121
|
+
const f = perfBucket(bucketName).frames
|
|
122
|
+
f.total += 1
|
|
123
|
+
if (dt > 16.7)
|
|
124
|
+
f.long += 1
|
|
125
|
+
if (dt > f.max)
|
|
126
|
+
f.max = dt
|
|
127
|
+
f.samples.push(dt)
|
|
128
|
+
if (f.samples.length > PERF_SAMPLE_CAP)
|
|
129
|
+
f.samples.shift()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// (b) Frame loop — zero-coupling, holds for any app. document.hidden guard: backgrounded rAF
|
|
134
|
+
// throttles to ~1fps, so each hidden frame looks like a 1000ms "dropped" frame and lies.
|
|
135
|
+
// Don't count dt while hidden; tally hiddenFrames so the report can flag it.
|
|
136
|
+
const WARN_THRESHOLD = 20 // dropped% — push warning when ≥20% frames jank
|
|
137
|
+
const WARN_COOLDOWN = 5000 // ms — don't spam, at most one warn per 5s
|
|
138
|
+
let lastFrame = performance.now()
|
|
139
|
+
let lastWarn = 0
|
|
140
|
+
function frameLoop() {
|
|
141
|
+
const now = performance.now()
|
|
142
|
+
if (document.hidden) {
|
|
143
|
+
perf.hiddenFrames += 1
|
|
144
|
+
lastFrame = now
|
|
145
|
+
requestAnimationFrame(frameLoop)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const dt = now - lastFrame
|
|
149
|
+
hitFrame(dt)
|
|
150
|
+
lastFrame = now
|
|
151
|
+
|
|
152
|
+
// Push mode: auto-warn on jank (≥20% dropped), once per 5s
|
|
153
|
+
const bucket = perf.buckets[perf.active]
|
|
154
|
+
if (bucket && bucket.frames.total > 20) { // need ≥20 frames for stable %
|
|
155
|
+
const droppedPct = (bucket.frames.long / bucket.frames.total) * 100
|
|
156
|
+
if (droppedPct >= WARN_THRESHOLD && now - lastWarn > WARN_COOLDOWN) {
|
|
157
|
+
const top3 = Object.entries(bucket.components)
|
|
158
|
+
.sort(([, a], [, b]) => b.total - a.total)
|
|
159
|
+
.slice(0, 3)
|
|
160
|
+
.map(([name, s]) => `${name} ${s.total.toFixed(0)}ms`)
|
|
161
|
+
.join(', ')
|
|
162
|
+
const hot = top3 ? ` · hot: ${top3}` : ''
|
|
163
|
+
console.warn(`[aipeek] 🐌 ${droppedPct.toFixed(0)}% frames dropped${hot}`)
|
|
164
|
+
lastWarn = now
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
requestAnimationFrame(frameLoop)
|
|
169
|
+
}
|
|
170
|
+
requestAnimationFrame(frameLoop)
|
|
171
|
+
|
|
172
|
+
// (b) Longtask observer — main-thread blocks >50ms, app-agnostic.
|
|
173
|
+
try {
|
|
174
|
+
new PerformanceObserver((list) => {
|
|
175
|
+
for (const entry of list.getEntries()) {
|
|
176
|
+
perf.longtasks.count += 1
|
|
177
|
+
if (entry.duration > perf.longtasks.max)
|
|
178
|
+
perf.longtasks.max = entry.duration
|
|
179
|
+
}
|
|
180
|
+
}).observe({ entryTypes: ['longtask'] })
|
|
181
|
+
}
|
|
182
|
+
catch { /* longtask not supported — frames + components still work */ }
|
|
183
|
+
|
|
184
|
+
// (a) Component self-time via MobX Reaction.track. client-patch can't import mobx, so the app
|
|
185
|
+
// exposes the live singleton at window.__AIPEEK_MOBX__={Reaction} (dev-only seam in src/lib/mobx.ts).
|
|
186
|
+
// mobx-react-lite names every component's Reaction "observer"+name, so name_.slice(8) is the
|
|
187
|
+
// component name. Patch is idempotent (track.__aipeek flag) and survives HMR via the shared buffer.
|
|
188
|
+
function patchMobx() {
|
|
189
|
+
const mobx = (window as any).__AIPEEK_MOBX__
|
|
190
|
+
const Reaction = mobx?.Reaction
|
|
191
|
+
const track = Reaction?.prototype?.track
|
|
192
|
+
if (!track || (track as any).__aipeek)
|
|
193
|
+
return
|
|
194
|
+
const patched = function (this: any, fn: () => void) {
|
|
195
|
+
const name: string = this.name_ ?? ''
|
|
196
|
+
if (name.indexOf('observer') !== 0)
|
|
197
|
+
return track.call(this, fn)
|
|
198
|
+
const start = performance.now()
|
|
199
|
+
try {
|
|
200
|
+
return track.call(this, fn)
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
hitComponent(name.slice(8) || '(anon)', performance.now() - start)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
;(patched as any).__aipeek = true
|
|
207
|
+
Reaction.prototype.track = patched
|
|
208
|
+
perf.mobxPatched = true
|
|
209
|
+
}
|
|
210
|
+
patchMobx()
|
|
211
|
+
// The seam may run after this patch (module order). Retry briefly so attribution turns on
|
|
212
|
+
// once src/lib/mobx.ts executes; stops as soon as it succeeds.
|
|
213
|
+
if (!perf.mobxPatched) {
|
|
214
|
+
let tries = 0
|
|
215
|
+
const retry = setInterval(() => {
|
|
216
|
+
patchMobx()
|
|
217
|
+
if (perf.mobxPatched || ++tries > 20)
|
|
218
|
+
clearInterval(retry)
|
|
219
|
+
}, 100)
|
|
220
|
+
}
|
|
27
221
|
|
|
28
222
|
function pushBounded<T>(arr: T[], item: T, max: number) {
|
|
29
223
|
arr.push(item)
|
|
@@ -50,6 +244,40 @@ for (const level of ['log', 'info', 'warn', 'error', 'debug'] as const) {
|
|
|
50
244
|
// --- Patch fetch — 透明摄像头:tee body,主流原样返回,副流后台 capture ---
|
|
51
245
|
|
|
52
246
|
const originalFetch = window.fetch
|
|
247
|
+
|
|
248
|
+
// --- Self-heal handshake: reconnect after a dev-server restart without a human reload ---
|
|
249
|
+
// The whole aipeek action chain rides the Vite HMR WebSocket. A full server restart kills
|
|
250
|
+
// that socket and Vite's reconnect often gives up — stranding the page on "connection lost".
|
|
251
|
+
// So we poll an HTTP heartbeat (independent of the WS) carrying the server's BOOT_ID; when it
|
|
252
|
+
// changes (server is a new process), the page reloads itself and re-handshakes the new server.
|
|
253
|
+
// Uses originalFetch so the poll never lands in the network ring.
|
|
254
|
+
;(function keepAlive() {
|
|
255
|
+
const SEEN_KEY = '__aipeek_boot__'
|
|
256
|
+
const RELOADING_KEY = '__aipeek_reloading__'
|
|
257
|
+
setInterval(async () => {
|
|
258
|
+
let id: string
|
|
259
|
+
try {
|
|
260
|
+
id = await (await originalFetch('/__aipeek/ping', { cache: 'no-store' })).text()
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return // server not back yet — keep polling, don't reload on mere failure
|
|
264
|
+
}
|
|
265
|
+
sessionStorage.removeItem(RELOADING_KEY) // ping succeeded — clear the reload debounce
|
|
266
|
+
const seen = sessionStorage.getItem(SEEN_KEY)
|
|
267
|
+
if (!seen) {
|
|
268
|
+
sessionStorage.setItem(SEEN_KEY, id) // first sighting — remember this server
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (seen !== id) { // BOOT_ID changed → server restarted → re-handshake
|
|
272
|
+
sessionStorage.setItem(SEEN_KEY, id)
|
|
273
|
+
if (!sessionStorage.getItem(RELOADING_KEY)) {
|
|
274
|
+
sessionStorage.setItem(RELOADING_KEY, '1')
|
|
275
|
+
location.reload()
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}, 2000)
|
|
279
|
+
})()
|
|
280
|
+
|
|
53
281
|
window.fetch = async (input, init) => {
|
|
54
282
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
|
55
283
|
const method = init?.method || 'GET'
|
|
@@ -266,6 +494,102 @@ if (OrigEventSource) {
|
|
|
266
494
|
})
|
|
267
495
|
}
|
|
268
496
|
|
|
497
|
+
// --- User action timeline ---
|
|
498
|
+
//
|
|
499
|
+
// One document capture listener catches every semantic action — both the user's real
|
|
500
|
+
// events (isTrusted=true) and aipeek's synthetic performAction events (isTrusted=false),
|
|
501
|
+
// since both bubble through document. Each entry stamps its post-settle UI projection
|
|
502
|
+
// (view/modal/focus) so a later diff against the prior entry reveals "dialog opened".
|
|
503
|
+
|
|
504
|
+
const KEYS = new Set(['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
|
|
505
|
+
let lastInputTarget: EventTarget | null = null
|
|
506
|
+
|
|
507
|
+
// inline mirror of client.ts visibleText/inspPath — client-patch can't import (see formatValue note)
|
|
508
|
+
function describe(el: Element): string {
|
|
509
|
+
const label = (el.getAttribute('aria-label') || directText(el) || '').trim().slice(0, 40)
|
|
510
|
+
const tag = el.tagName.toLowerCase()
|
|
511
|
+
const name = label || `<${tag}>`
|
|
512
|
+
const raw = el.getAttribute('data-insp-path')
|
|
513
|
+
let loc = ''
|
|
514
|
+
if (raw) {
|
|
515
|
+
const [file, line] = raw.split(':')
|
|
516
|
+
const tail = file.split('/').slice(-2).join('/')
|
|
517
|
+
loc = line ? ` @${tail}:${line}` : ` @${tail}`
|
|
518
|
+
}
|
|
519
|
+
return `${name} [${tag}]${loc}`
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function directText(el: Element): string {
|
|
523
|
+
let t = ''
|
|
524
|
+
for (const node of el.childNodes) {
|
|
525
|
+
if (node.nodeType === 3)
|
|
526
|
+
t += node.textContent
|
|
527
|
+
}
|
|
528
|
+
return t.trim()
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Light projection — just the variables that move when UI changes. NOT the full
|
|
532
|
+
// collectScreen (knobs etc.) which lives in client.ts; this only needs the deltas.
|
|
533
|
+
function stampScreen(entry: ActionEntry) {
|
|
534
|
+
const stores = (window as any).__AIPEEK_STORES__
|
|
535
|
+
const view = stores?.appUIStore?.mode ?? '(unknown)'
|
|
536
|
+
entry.view = view
|
|
537
|
+
perf.active = view === '(unknown)' ? '__all__' : view
|
|
538
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
539
|
+
entry.modal = modalEl
|
|
540
|
+
? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40)
|
|
541
|
+
: 'none'
|
|
542
|
+
const active = document.activeElement
|
|
543
|
+
entry.focus = active && active !== document.body ? describe(active) : 'none'
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Enqueue + stamp in one place — every listener funnels here so the trusted/pushBounded/
|
|
547
|
+
// settle-then-stamp triple isn't repeated five times.
|
|
548
|
+
function record(e: Event, type: string, target: Element, value?: string) {
|
|
549
|
+
const entry: ActionEntry = {
|
|
550
|
+
type,
|
|
551
|
+
target: describe(target),
|
|
552
|
+
value: value === undefined ? undefined : value.slice(0, 60),
|
|
553
|
+
trusted: e.isTrusted,
|
|
554
|
+
ts: Date.now(),
|
|
555
|
+
}
|
|
556
|
+
pushBounded(actionEntries, entry, MAX_ACTIONS)
|
|
557
|
+
setTimeout(() => stampScreen(entry), 180)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
document.addEventListener('click', e => e.target && record(e, 'click', e.target as Element), true)
|
|
561
|
+
document.addEventListener('submit', e => e.target && record(e, 'submit', e.target as Element), true)
|
|
562
|
+
document.addEventListener('change', (e) => {
|
|
563
|
+
if (!e.target)
|
|
564
|
+
return
|
|
565
|
+
lastInputTarget = null
|
|
566
|
+
record(e, 'change', e.target as Element, (e.target as HTMLInputElement).value)
|
|
567
|
+
}, true)
|
|
568
|
+
|
|
569
|
+
document.addEventListener('keydown', (e) => {
|
|
570
|
+
if (KEYS.has(e.key))
|
|
571
|
+
record(e, `key:${e.key}`, (e.target as Element | null) ?? document.body)
|
|
572
|
+
}, true)
|
|
573
|
+
|
|
574
|
+
document.addEventListener('input', (e) => {
|
|
575
|
+
const el = e.target as HTMLInputElement | null
|
|
576
|
+
if (!el)
|
|
577
|
+
return
|
|
578
|
+
const value = el.value ?? (el.isContentEditable ? (el.textContent ?? '') : '')
|
|
579
|
+
// Debounce same-target keystrokes: rewrite the last entry instead of appending one
|
|
580
|
+
// per character (the timeline wants "input value=hello", not h/he/hel/…).
|
|
581
|
+
const last = actionEntries[actionEntries.length - 1]
|
|
582
|
+
if (lastInputTarget === el && last?.type === 'input') {
|
|
583
|
+
last.value = value.slice(0, 60)
|
|
584
|
+
last.trusted = e.isTrusted
|
|
585
|
+
last.ts = Date.now()
|
|
586
|
+
setTimeout(() => stampScreen(last), 180)
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
lastInputTarget = el
|
|
590
|
+
record(e, 'input', el, value)
|
|
591
|
+
}, true)
|
|
592
|
+
|
|
269
593
|
// --- Utils ---
|
|
270
594
|
|
|
271
595
|
function jsonSample(text: string): string | undefined {
|