Skip to content

Commit df403b2

Browse files
antfubotantfu
andauthored
feat(data-inspector): inline sources, lazy deep-node expansion, auto-rerun, playground toolbar (#111)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 0454e7b commit df403b2

26 files changed

Lines changed: 1226 additions & 405 deletions

docs/plugins/data-inspector.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ Package: `@devframes/plugin-data-inspector` · framework: **Vue + Vite**
1010

1111
## What it does
1212

13-
- **Query workbench** — a CodeMirror jora editor with syntax highlighting and server-computed autocomplete; queries auto-run as you type, with a client-side syntax gate so malformed input never hits the wire. Source, query, and filters persist in the URL, so any workbench state is shareable.
13+
- **Query workbench** — a CodeMirror jora editor with syntax highlighting and server-computed autocomplete; queries auto-run as you type, with a client-side syntax gate so malformed input never hits the wire. A toolbar copies the query, and the editor pairs with expand-all / collapse-all and copy-as-JSON controls over the results. Source, query, filters, and the auto-rerun setting persist in the URL, so any workbench state is shareable.
14+
- **Auto rerun** — an optional poller under the filters (`auto rerun every N seconds`) re-runs the current query against the live object on a fixed period, so a value that changes over time updates on its own. Ticks are skipped while a run is in flight or the query is syntactically broken.
1415
- **Result viewer** — results normalize to strict JSON (circulars become `$ref` markers; Maps, Sets, class instances, functions, and Dates get type badges) with per-query stats: jora / normalize / rpc timings, payload size, node count. The value-actions popup copies paths and turns any key into a query.
16+
- **Lazy expansion** — deep graphs return one level at a time: a node past the depth cap renders a `load deeper` link that fetches just that subtree with a fresh budget and splices it in place, so a huge object stays responsive and loads on demand.
1517
- **Data shape panel** — a one-level type skeleton of the active source, independent of the query; click a property to query it.
1618
- **Filters** — exclude functions, `_`-prefixed, or `$`-prefixed properties from results and skeleton alike.
1719
- **Saved queries** — recipes (`query` + optional title/description + the filters they were authored with), id-keyed, in two scopes: **workspace** (committable, shared with the team) and **project** (per-checkout).
@@ -114,15 +116,29 @@ The target process opts in by starting the agent:
114116
```ts
115117
import { exposeDataInspector } from '@devframes/plugin-data-inspector/inject'
116118

117-
await exposeDataInspector()
119+
await exposeDataInspector({
120+
sources: [{ id: 'app:store', title: 'App store', data: () => store }],
121+
})
118122
```
119123

120-
or with zero code changes:
124+
`sources` registers the given entries before the endpoint opens — a convenience over calling `registerDataSource` yourself; both paths share the one process-global registry. Call `exposeDataInspector()` with no sources to expose whatever is already registered.
125+
126+
Or with zero code changes:
121127

122128
```sh
123129
DEVFRAME_DATA_INSPECTOR=1 node --import @devframes/plugin-data-inspector/inject server.js
124130
```
125131

132+
On this zero-code path there is nowhere to call `registerDataSource`, so the agent auto-registers a **`globalThis`** source. Assign anything you want to inspect onto the global object and query it live:
133+
134+
```ts
135+
// somewhere in the running process
136+
globalThis.store = store
137+
globalThis.cache = cache
138+
```
139+
140+
Then query `store`, `cache`, or `keys($)` to see what's there. The source reads `globalThis` at query time, so assignments made after the agent started show up on the next run. Opt out with `DEVFRAME_DATA_INSPECTOR_GLOBAL=0`.
141+
126142
The agent binds `127.0.0.1`, requires devframe's trust handshake with a per-run pre-shared token, and advertises its endpoint in `node_modules/.data-inspector/agent.json``devframe-data-inspector attach` consumes it automatically (or pass `ws://…` and `--token` explicitly). Queries execute inside the target process, where the live objects are. Treat the endpoint like a debugger port.
127143

128144
## RPC surface
@@ -133,6 +149,7 @@ All functions are namespaced `devframes:plugin:data-inspector:*`:
133149
|----------|------|---------|
134150
| `sources` | `query` | Every registered source (meta and suggested queries). |
135151
| `query` | `query` | Runs a jora query against a source; normalized result with stats. |
152+
| `queryPath` | `query` | Re-runs a query and returns a fresh, depth-limited slice of the subtree at a node path (lazy expansion). |
136153
| `skeleton` | `query` | The type skeleton of a source, honoring the filter options. |
137154
| `suggest` | `query` | Autocomplete candidates from jora's stat mode at a cursor position. |
138155
| `saved:list` / `saved:save` / `saved:delete` | `query` / `action` | Saved-query recipes in the `workspace` and `project` scopes. |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"type": "module",
44
"version": "0.7.4",
55
"private": true,
6-
"packageManager": "pnpm@11.13.0",
6+
"packageManager": "pnpm@11.13.1",
77
"author": "Anthony Fu <anthonyfu117@hotmail.com>",
88
"license": "MIT",
99
"funding": "https://github.com/sponsors/antfu",

plugins/data-inspector/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @devframes/plugin-data-inspector
22

3-
Inspect live server-side objects interactively. Other plugins and hosts register **data sources**; the workbench composes [jora](https://github.com/discoveryjs/jora) queries against them — executed in the process that owns the objects — and renders normalized results in a [discovery.js](https://github.com/discoveryjs/discovery) struct view with type badges, a shape panel, saved queries, and shareable URL state.
3+
Inspect live server-side objects interactively. Other plugins and hosts register **data sources**; the workbench composes [jora](https://github.com/discoveryjs/jora) queries against them — executed in the process that owns the objects — and renders normalized results in a [discovery.js](https://github.com/discoveryjs/discovery) struct view with type badges, a shape panel, saved queries, and shareable URL state. Deep graphs expand a level at a time (`load deeper` fetches each subtree on demand), an optional poller re-runs the query every N seconds, and a toolbar offers expand/collapse-all and copy.
44

55
## Register a data source
66

@@ -50,7 +50,10 @@ Static exports embed the dataset and run the same query engine client-side, so s
5050
```ts
5151
import { exposeDataInspector } from '@devframes/plugin-data-inspector/inject'
5252

53-
await exposeDataInspector()
53+
// pass sources inline, or register them separately beforehand
54+
await exposeDataInspector({
55+
sources: [{ id: 'app:store', title: 'App store', data: () => store }],
56+
})
5457
```
5558

5659
or with zero code changes:
@@ -59,6 +62,8 @@ or with zero code changes:
5962
DEVFRAME_DATA_INSPECTOR=1 node --import @devframes/plugin-data-inspector/inject server.js
6063
```
6164

65+
On the zero-code path there's nowhere to call `registerDataSource`, so the agent auto-registers a **`globalThis`** source: assign what you want to inspect onto the global object (`globalThis.store = store`) and query it live. Opt out with `DEVFRAME_DATA_INSPECTOR_GLOBAL=0`.
66+
6267
The agent binds `127.0.0.1`, requires devframe's trust handshake with a per-run token by default, and advertises its endpoint in `node_modules/.data-inspector/agent.json`, which `devframe-data-inspector attach` picks up automatically.
6368

6469
> [!WARNING]

plugins/data-inspector/src/engine/contract.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ export interface FilterOptions {
1010
excludeDollarProps?: boolean
1111
}
1212

13+
/**
14+
* One structural step from a query result toward a nested node, recorded by
15+
* the normalizer on each depth-truncated marker so the client can lazily
16+
* re-fetch that subtree with a fresh depth budget (see `queryPath`). Each
17+
* segment mirrors exactly one descent the normalizer makes:
18+
*
19+
* - `['k', key]` own object property, or a string-keyed Map value
20+
* - `['i', index]` array item (index into the filtered array)
21+
* - `['s', index]` Set value
22+
* - `['mk', index]` / `['mv', index]` non-string Map entry key / value
23+
*/
24+
export type PathSegment
25+
= | ['k', string]
26+
| ['i', number]
27+
| ['s', number]
28+
| ['mk', number]
29+
| ['mv', number]
30+
31+
/** A path from the query root to a nested node: a list of structural steps. */
32+
export type NodePath = PathSegment[]
33+
1334
/** A query recipe: the text plus the filter options it was authored with. */
1435
export interface Query extends FilterOptions {
1536
query: string

plugins/data-inspector/src/engine/normalize.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@
1212
* - BigInt / Symbol -> tagged string forms
1313
* - Error -> { $type: 'Error', name, message }
1414
* - Promise / WeakMap/.. -> opaque tags
15-
* - depth / entry caps -> { $truncated: ... } markers + stats
15+
* - depth cap -> { $truncated: 'depth', $preview, $path } markers
16+
* - entry cap -> { $truncated: 'entries', ... } markers + stats
17+
*
18+
* Depth-truncated markers carry a `$path` (a `NodePath` of structural steps
19+
* from the query root) so the client can lazily re-fetch that subtree with a
20+
* fresh depth budget through `navigate` + `runQueryAtPath`.
1621
*/
22+
import type { NodePath, PathSegment } from './contract'
1723

1824
export interface NormalizeOptions {
1925
/** Max object/array nesting depth before truncation. */
@@ -79,12 +85,47 @@ export function normalize(value: unknown, options: NormalizeOptions = {}): { dat
7985
excludeDollarProps: options.excludeDollarProps ?? false,
8086
},
8187
}
82-
const data = walk(value, walker, 0, '#')
88+
const data = walk(value, walker, 0, '#', [])
8389
walker.stats.ms = Math.round((performance.now() - start) * 100) / 100
8490
return { data, stats: walker.stats }
8591
}
8692

87-
function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
93+
/**
94+
* Re-descend a live graph along a `NodePath`, mirroring the walker's own
95+
* traversal (and re-applying `excludeFunctions` to array indices so a lazily
96+
* fetched index lines up with what the client saw). Returns the node the
97+
* path addresses, or `undefined` if any step falls off the graph.
98+
*/
99+
export function navigate(value: unknown, path: NodePath, options: Pick<NormalizeOptions, 'excludeFunctions'> = {}): unknown {
100+
let cur: unknown = value
101+
for (const [kind, at] of path) {
102+
if (cur === null || typeof cur !== 'object')
103+
return undefined
104+
switch (kind) {
105+
case 'k':
106+
cur = cur instanceof Map ? cur.get(at) : (cur as Record<string, unknown>)[at]
107+
break
108+
case 'i': {
109+
const arr = cur as unknown[]
110+
const source = options.excludeFunctions ? arr.filter(item => typeof item !== 'function') : arr
111+
cur = source[at]
112+
break
113+
}
114+
case 's':
115+
cur = [...(cur as Set<unknown>)][at]
116+
break
117+
case 'mk':
118+
cur = [...(cur as Map<unknown, unknown>).entries()][at]?.[0]
119+
break
120+
case 'mv':
121+
cur = [...(cur as Map<unknown, unknown>).entries()][at]?.[1]
122+
break
123+
}
124+
}
125+
return cur
126+
}
127+
128+
function walk(value: unknown, w: Walker, depth: number, path: string, segs: NodePath): unknown {
88129
w.stats.nodes++
89130

90131
// ── primitives ──────────────────────────────────────────────────────
@@ -138,7 +179,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
138179

139180
if (depth >= w.opts.maxDepth) {
140181
w.stats.truncatedDepth++
141-
return { $truncated: 'depth', $preview: preview(obj) }
182+
return { $truncated: 'depth', $preview: preview(obj), $path: segs }
142183
}
143184

144185
w.seen.set(obj, path)
@@ -148,7 +189,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
148189
const cap = Math.min(source.length, w.opts.maxEntries)
149190
const out: unknown[] = Array.from({ length: cap })
150191
for (let i = 0; i < cap; i++)
151-
out[i] = walk(source[i], w, depth + 1, `${path}[${i}]`)
192+
out[i] = walk(source[i], w, depth + 1, `${path}[${i}]`, seg(segs, ['i', i]))
152193
if (source.length > cap) {
153194
w.stats.truncatedEntries++
154195
out.push({ $truncated: 'entries', $total: source.length, $shown: cap })
@@ -169,15 +210,15 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
169210
if (allStringKeys) {
170211
const value: Record<string, unknown> = {}
171212
for (const [k, v] of entries)
172-
value[k as string] = walk(v, w, depth + 1, `${path}.${String(k)}`)
213+
value[k as string] = walk(v, w, depth + 1, `${path}.${String(k)}`, seg(segs, ['k', k as string]))
173214
return { $type: 'Map', size: obj.size, value }
174215
}
175216
return {
176217
$type: 'Map',
177218
size: obj.size,
178219
entries: entries.map(([k, v], i) => ({
179-
key: walk(k, w, depth + 1, `${path}~keys[${i}]`),
180-
value: walk(v, w, depth + 1, `${path}~values[${i}]`),
220+
key: walk(k, w, depth + 1, `${path}~keys[${i}]`, seg(segs, ['mk', i])),
221+
value: walk(v, w, depth + 1, `${path}~values[${i}]`, seg(segs, ['mv', i])),
181222
})),
182223
}
183224
}
@@ -186,7 +227,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
186227
const values = [...obj].slice(0, w.opts.maxEntries)
187228
if (obj.size > values.length)
188229
w.stats.truncatedEntries++
189-
return { $type: 'Set', size: obj.size, values: values.map((v, i) => walk(v, w, depth + 1, `${path}~set[${i}]`)) }
230+
return { $type: 'Set', size: obj.size, values: values.map((v, i) => walk(v, w, depth + 1, `${path}~set[${i}]`, seg(segs, ['s', i]))) }
190231
}
191232

192233
// Plain object or class instance: own enumerable string-keyed props.
@@ -213,7 +254,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
213254
}
214255
if (w.opts.excludeFunctions && typeof v === 'function')
215256
continue
216-
out[key] = walk(v, w, depth + 1, `${path}.${key}`)
257+
out[key] = walk(v, w, depth + 1, `${path}.${key}`, seg(segs, ['k', key]))
217258
}
218259
if (keys.length > cap) {
219260
w.stats.truncatedProps++
@@ -222,6 +263,11 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
222263
return out
223264
}
224265

266+
/** Append one structural step to a path, returning a fresh array. */
267+
function seg(path: NodePath, step: PathSegment): NodePath {
268+
return [...path, step]
269+
}
270+
225271
function preview(obj: object): string {
226272
if (Array.isArray(obj))
227273
return `Array(${obj.length})`

plugins/data-inspector/src/engine/query-engine.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
* - suggestions come from jora's stat mode, flattened into plain
1111
* RPC-safe completion items.
1212
*/
13-
import type { QueryOutcome, SuggestItem, SuggestOutcome } from './contract'
13+
import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract'
1414
import type { NormalizeOptions } from './normalize'
1515
import jora from 'jora'
16-
import { normalize } from './normalize'
16+
import { navigate, normalize } from './normalize'
1717

1818
export type { SuggestItem, SuggestOutcome } from './contract'
1919

@@ -103,6 +103,29 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti
103103
}
104104
}
105105

106+
/**
107+
* Lazy-expand a depth-truncated node: re-run the base query against the live
108+
* object, re-descend to the node the `NodePath` addresses, and normalize just
109+
* that subtree with a fresh depth budget. The path comes from a `$truncated:
110+
* 'depth'` marker the client is expanding, so the same filter options must be
111+
* threaded through (they shift array indices and drop keys).
112+
*/
113+
export function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): QueryOutcome {
114+
try {
115+
const started = performance.now()
116+
const raw = createQuery(query)(target)
117+
const node = navigate(raw, path, options)
118+
const queryMs = Math.round((performance.now() - started) * 100) / 100
119+
const { data, stats } = normalize(node, options)
120+
const payloadBytes = new TextEncoder().encode(JSON.stringify(data) ?? '').length
121+
return { ok: true, result: data, stats: { queryMs, normalize: stats, payloadBytes } }
122+
}
123+
catch (error) {
124+
const e = error instanceof Error ? error : new Error(String(error))
125+
return { ok: false, error: { message: e.message, name: e.name } }
126+
}
127+
}
128+
106129
interface JoraStatEntry {
107130
type: string
108131
from: number

plugins/data-inspector/src/inject/index.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
* Two ways in:
66
*
77
* ```ts
8-
* // 1. explicit, from the target's code
8+
* // 1. explicit, from the target's code — pass sources inline …
99
* import { exposeDataInspector } from '@devframes/plugin-data-inspector/inject'
10-
* import { registerDataSource } from '@devframes/plugin-data-inspector/registry'
1110
*
11+
* await exposeDataInspector({
12+
* sources: [{ id: 'app:store', title: 'App store', data: () => store }],
13+
* })
14+
*
15+
* // … or register them separately through the global registry.
16+
* import { registerDataSource } from '@devframes/plugin-data-inspector/registry'
1217
* registerDataSource({ id: 'app:store', title: 'App store', data: () => store })
1318
* await exposeDataInspector()
1419
* ```
@@ -18,6 +23,11 @@
1823
* DEVFRAME_DATA_INSPECTOR=1 node --import @devframes/plugin-data-inspector/inject server.js
1924
* ```
2025
*
26+
* On that zero-code path there's nowhere to call `registerDataSource`, so the
27+
* agent auto-registers a **`globalThis`** source: assign anything you want to
28+
* inspect onto the global object (`globalThis.store = store`) and query it
29+
* live. Opt out with `DEVFRAME_DATA_INSPECTOR_GLOBAL=0`.
30+
*
2131
* It binds `127.0.0.1` and requires devframe's trust handshake by
2232
* default: a random pre-shared token is minted per run, printed to stderr,
2333
* and written (with the endpoint) to the discovery file
@@ -27,6 +37,7 @@
2737
* treat the endpoint like a debugger port.
2838
*/
2939
import type { DevframeHost, DevframeNodeContext } from 'devframe/types'
40+
import type { DataSourceEntry } from '../registry/index'
3041
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
3142
import { homedir } from 'node:os'
3243
import { dirname, join } from 'node:path'
@@ -49,6 +60,14 @@ export interface AgentDiscovery {
4960
}
5061

5162
export interface ExposeDataInspectorOptions {
63+
/**
64+
* Data sources to expose, registered before the endpoint opens. A
65+
* convenience over calling `registerDataSource` yourself; the two paths
66+
* share one process-global registry, so inline sources and separately
67+
* registered ones coexist (a later registration replaces an earlier one
68+
* with the same id).
69+
*/
70+
sources?: DataSourceEntry[]
5271
/** Preferred port (falls back to a free one nearby). Default 9878. */
5372
port?: number
5473
/**
@@ -77,12 +96,36 @@ export interface DataInspectorAgent {
7796
close: () => Promise<void>
7897
}
7998

99+
/**
100+
* A data source exposing the inspected process's global object. On the
101+
* zero-code `--import` path there is nowhere to call `registerDataSource`, so
102+
* assigning to `globalThis` is how you surface things to inspect
103+
* (`globalThis.store = store`); this source makes them queryable live. The
104+
* factory reads `globalThis` at query time, so late assignments show up on the
105+
* next run.
106+
*/
107+
export function createGlobalThisDataSource(): DataSourceEntry {
108+
return {
109+
id: 'globalThis',
110+
title: 'globalThis',
111+
description: 'The global object of the inspected process. Assign values to inspect them, e.g. globalThis.store = store',
112+
icon: 'i-ph:globe-duotone',
113+
data: () => globalThis,
114+
}
115+
}
116+
80117
/** Start the agent endpoint in the current process. */
81118
export async function exposeDataInspector(options: ExposeDataInspectorOptions = {}): Promise<DataInspectorAgent> {
82119
// Deferred so `--import`ing the agent never pulls the whole node surface
83120
// into processes that don't enable it.
84121
const { setupDataInspector } = await import('../node/index')
85122

123+
if (options.sources?.length) {
124+
const { registerDataSource } = await import('../registry/index')
125+
for (const source of options.sources)
126+
registerDataSource(source)
127+
}
128+
86129
const cwd = process.cwd()
87130
const port = await getPort({ port: options.port ?? 9878, portRange: [9878, 9978] })
88131
const websocket = `ws://127.0.0.1:${port}`
@@ -154,6 +197,9 @@ if (process.env.DEVFRAME_DATA_INSPECTOR === '1' || process.env.DEVFRAME_DATA_INS
154197
auth: process.env.DEVFRAME_DATA_INSPECTOR_AUTH !== '0',
155198
token: process.env.DEVFRAME_DATA_INSPECTOR_TOKEN,
156199
exampleSource: process.env.DEVFRAME_DATA_INSPECTOR_EXAMPLE !== '0',
200+
// Zero-code path: with no chance to call `registerDataSource`, expose
201+
// `globalThis` so assigning to it is enough to inspect anything.
202+
sources: process.env.DEVFRAME_DATA_INSPECTOR_GLOBAL !== '0' ? [createGlobalThisDataSource()] : undefined,
157203
}).catch((error) => {
158204
console.error('[data-inspector] agent failed to start:', error)
159205
})

0 commit comments

Comments
 (0)