Skip to content

Commit 7c73b20

Browse files
antfubotantfu
andauthored
docs: add a hands-on 'Build a Server Data Inspector' tutorial (#290)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 5421c3e commit 7c73b20

25 files changed

Lines changed: 326 additions & 0 deletions
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
---
2+
title: 'Tutorial: Build a Server Data Inspector'
3+
description: 'Build a devtool that displays and queries live server-side data, then ship it as a hub dock, a static build, a standalone server, and a CLI.'
4+
---
5+
6+
Let's build a real devtool from scratch: a **Data Inspector** that shows the shape of your server's live state and lets you read any value out of it. We'll get it working first, then teach it new tricks one at a time: a dock in a hub, a static build, a standalone server, and a CLI.
7+
8+
You'll need [Node 24+](https://nodejs.org/) and a terminal. Every code block is complete, so you can copy them as you go.
9+
10+
## The shape of a devframe app
11+
12+
A devframe app is two halves talking over a typed connection: a **server** in your Node process that exposes functions, and a **browser** client that calls them and renders the results. Devframe is everything in between: the wire, the UI hosting, auth, builds, and a CLI.
13+
14+
## Step 1 — Define the tool
15+
16+
Everything starts with `defineDevframe`: your tool's name, plus a `setup` where you register what it can do. Create the project and the definition:
17+
18+
```sh
19+
mkdir data-inspector && cd data-inspector
20+
npm init -y && npm pkg set type=module
21+
npm install devframe && npm install -D typescript
22+
```
23+
24+
```ts [src/data-inspector.ts]
25+
import { defineDevframe } from 'devframe'
26+
27+
// Some example server-side data — whatever you want to peek at while your app
28+
// runs: config, a cache, a DB handle.
29+
const serverState = {
30+
config: { name: 'Acme', port: 3000, debug: false },
31+
users: [
32+
{ id: 1, name: 'Ada', admin: true },
33+
{ id: 2, name: 'Lin', admin: false },
34+
],
35+
featureFlags: { newDashboard: true, betaSearch: false },
36+
}
37+
38+
// A tiny query helper that follows a dot-path like `users.0.name` into the state.
39+
function valueAtPath(root: unknown, path: string): unknown {
40+
if (!path)
41+
return root
42+
return path.split('.').reduce<unknown>((value, key) => {
43+
if (value == null || typeof value !== 'object')
44+
return undefined
45+
return (value as Record<string, unknown>)[key]
46+
}, root)
47+
}
48+
49+
const dataInspectorFrame = defineDevframe({
50+
id: 'data-inspector',
51+
name: 'Data Inspector',
52+
version: '0.0.0',
53+
packageName: 'data-inspector',
54+
description: 'Inspect live server state.',
55+
homepage: 'https://example.com',
56+
importMetaUrl: import.meta.url,
57+
58+
setup(ctx) {
59+
// What does the state look like?
60+
ctx.rpc.register({
61+
name: 'data-inspector:get-meta',
62+
type: 'query',
63+
jsonSerializable: true,
64+
handler: () =>
65+
Object.entries(serverState).map(([key, value]) => ({
66+
key,
67+
type: Array.isArray(value) ? 'array' : typeof value,
68+
length: Array.isArray(value) ? value.length : undefined,
69+
})),
70+
})
71+
72+
// What's at this path?
73+
ctx.rpc.register({
74+
name: 'data-inspector:query',
75+
type: 'query',
76+
jsonSerializable: true,
77+
handler: (path: string) => valueAtPath(serverState, path),
78+
})
79+
},
80+
})
81+
82+
export default dataInspectorFrame
83+
```
84+
85+
`ctx.rpc.register` publishes a function the browser can call: a namespaced `name`, a `type` (`query` is read-only), and a `handler` that takes the call's arguments and returns JSON. That's the whole server. ([RPC](/guide/rpc) has the other types; [Devframe Definition](/guide/devframe-definition) has every field.)
86+
87+
## Step 2 — Add a UI
88+
89+
Now the browser half. We'll use React here, but any framework works — the only devframe-specific line is `connectDevframe`, which opens the connection back to the server.
90+
91+
```sh
92+
npm install react react-dom @devframes/vite
93+
npm install -D vite @vitejs/plugin-react @types/react @types/react-dom
94+
```
95+
96+
```html [client/index.html]
97+
<!doctype html>
98+
<html>
99+
<head>
100+
<meta charset="utf-8" />
101+
<title>Data Inspector</title>
102+
</head>
103+
<body>
104+
<div id="app"></div>
105+
<script type="module" src="./main.tsx"></script>
106+
</body>
107+
</html>
108+
```
109+
110+
```tsx [client/main.tsx]
111+
import { createRoot } from 'react-dom/client'
112+
import { App } from './App'
113+
114+
createRoot(document.getElementById('app')!).render(<App />)
115+
```
116+
117+
```tsx [client/App.tsx]
118+
import type { DevframeRpcClient } from 'devframe/client'
119+
import { connectDevframe } from 'devframe/client'
120+
import { useEffect, useState } from 'react'
121+
122+
interface MetaEntry { key: string, type: string, length?: number }
123+
124+
export function App() {
125+
const [rpc, setRpc] = useState<DevframeRpcClient>()
126+
const [meta, setMeta] = useState<MetaEntry[]>([])
127+
const [path, setPath] = useState('config')
128+
const [result, setResult] = useState<unknown>()
129+
130+
useEffect(() => {
131+
// No argument: the client finds the server from the page's own URL, so
132+
// this line never changes no matter how the tool is hosted.
133+
connectDevframe().then(async (client) => {
134+
setRpc(client)
135+
const call = client.call as (name: string, ...args: unknown[]) => Promise<any>
136+
setMeta(await call('data-inspector:get-meta'))
137+
})
138+
}, [])
139+
140+
async function runQuery() {
141+
if (!rpc)
142+
return
143+
const call = rpc.call as (name: string, ...args: unknown[]) => Promise<any>
144+
setResult(await call('data-inspector:query', path))
145+
}
146+
147+
return (
148+
<main style={{ fontFamily: 'sans-serif', maxWidth: 640, margin: '2rem auto' }}>
149+
<h1>Data Inspector</h1>
150+
<ul>
151+
{meta.map(m => (
152+
<li key={m.key}>
153+
<code>{m.key}</code>
154+
{' - '}
155+
{m.type}
156+
{m.length != null ? ` (${m.length})` : ''}
157+
</li>
158+
))}
159+
</ul>
160+
<input value={path} onChange={e => setPath(e.target.value)} placeholder="config.port" />
161+
<button type="button" onClick={runQuery}>Query</button>
162+
<pre>{JSON.stringify(result, null, 2)}</pre>
163+
</main>
164+
)
165+
}
166+
```
167+
168+
`client.call(name, ...args)` reaches your handlers. (We cast `.call` and call by name here; wire up a typed registry and every call is checked end to end — see [RPC](/guide/rpc).)
169+
170+
## Step 3 — Run it in development
171+
172+
To try what we've built, let Vite serve the UI and hand RPC traffic to devframe:
173+
174+
```ts [vite.client.config.ts]
175+
import { devframeViteBridge } from '@devframes/vite/single'
176+
import react from '@vitejs/plugin-react'
177+
import { defineConfig } from 'vite'
178+
import dataInspectorFrame from './src/data-inspector.ts'
179+
180+
export default defineConfig({
181+
root: 'client',
182+
base: './', // relative asset URLs, so the built UI works under any mount path
183+
build: { outDir: '../dist/client', emptyOutDir: true },
184+
plugins: [
185+
react(),
186+
// Vite serves the page; the bridge answers RPC on the same origin, so
187+
// `connectDevframe()` just finds it. `auth: false`, see the note below.
188+
devframeViteBridge(dataInspectorFrame, { base: '/', auth: false }),
189+
],
190+
})
191+
```
192+
193+
```sh
194+
npx vite --config vite.client.config.ts
195+
```
196+
197+
Open the printed URL. The three keys and their types show up, and typing `config.port` or `users.0.name` and hitting **Query** prints the value. Button → `call` → your `handler` → back to the page: that's the whole app working.
198+
199+
> [!WARNING]
200+
> `auth: false` trusts anything that can reach the port. It's off here to keep the tutorial simple — turn it on for anything you publish or expose beyond localhost. See [Security](/guide/security).
201+
202+
From here on we reuse this same `src/data-inspector.ts` and `client/` unchanged; all that changes is where they run.
203+
204+
## Step 4 — Dock it in a hub
205+
206+
A [hub](/guide/hub) puts many devframes behind one interface, each a **dock** you switch between — the tool's own UI in an iframe. Since our client uses a bare `connectDevframe()`, it already works anywhere; the hub just needs the built UI, so point the definition at it:
207+
208+
```ts [src/data-inspector.ts]
209+
import { fileURLToPath } from 'node:url'
210+
//
211+
const dataInspectorFrame = defineDevframe({
212+
id: 'data-inspector',
213+
//
214+
clientAssets: fileURLToPath(new URL('../dist/client', import.meta.url)),
215+
setup(ctx) { /* unchanged */ },
216+
})
217+
```
218+
219+
Build the UI and stand up a one-devframe hub:
220+
221+
```sh
222+
npm install @devframes/hub @devframes/hub-ui
223+
npx vite build --config vite.client.config.ts
224+
```
225+
226+
```ts [vite.hub.config.ts]
227+
import { createUi } from '@devframes/hub-ui'
228+
import { viteDevframeHub } from '@devframes/vite/hub'
229+
import { defineConfig } from 'vite'
230+
import dataInspectorFrame from './src/data-inspector.ts'
231+
232+
export default defineConfig({
233+
plugins: [
234+
viteDevframeHub({
235+
devframes: [dataInspectorFrame],
236+
ui: createUi({ branding: { productName: 'My Devtools' } }),
237+
}),
238+
],
239+
})
240+
```
241+
242+
```sh
243+
npx vite --config vite.hub.config.ts
244+
```
245+
246+
Your inspector now sits in the hub's rail as a dock. Add more to `devframes: [...]` — your own or the [built-in plugins](/plugins) — and each gets its own. (The hub prints a code to authorize on first connect.)
247+
248+
## Step 5 — Build a static version
249+
250+
Some tools should work with no server at all — a report you can drop on any static host. `createBuild` renders the UI and **bakes in** the results of read-only calls. Opt one in with `snapshot: true`:
251+
252+
```ts
253+
ctx.rpc.register({
254+
name: 'data-inspector:get-meta',
255+
type: 'query',
256+
jsonSerializable: true,
257+
snapshot: true, // bake this call's result into the build
258+
handler: () => {
259+
/* … unchanged … */
260+
}
261+
})
262+
```
263+
264+
```js [scripts/build.mjs]
265+
import { createBuild } from 'devframe/adapters/build'
266+
import dataInspectorFrame from '../src/data-inspector.ts'
267+
268+
await createBuild(dataInspectorFrame, { outDir: 'dist-static' })
269+
```
270+
271+
```sh
272+
npx vite build # refresh dist/client
273+
node scripts/build.mjs # → dist-static/
274+
```
275+
276+
Serve `dist-static/` anywhere and the meta list renders from the baked snapshot, no Node in sight. `query` takes an argument, so it still needs the live server (next) — or you can bake specific inputs ([Client Assets](/guide/client-assets)).
277+
278+
## Step 6 — Run it standalone
279+
280+
The definition never depended on Vite. `createDevServer` runs the tool on its own, serving the UI from `clientAssets` and answering RPC live:
281+
282+
```js [scripts/serve.mjs]
283+
import { createDevServer } from 'devframe/adapters/dev'
284+
import dataInspectorFrame from '../src/data-inspector.ts'
285+
286+
await createDevServer(dataInspectorFrame, { openBrowser: true })
287+
```
288+
289+
```sh
290+
npx vite build
291+
node scripts/serve.mjs
292+
```
293+
294+
Same UI, same live calls, no bundler in the loop — this is what you'd drop into your own Node program.
295+
296+
## Step 7 — Give it a CLI
297+
298+
Finally, wrap that server in a command shell. `devframe/adapters/cac` turns a devframe into a CLI with `dev`, `build`, and `mcp` commands:
299+
300+
```js [bin.mjs]
301+
#!/usr/bin/env node
302+
import { createCac } from 'devframe/adapters/cac'
303+
import dataInspectorFrame from './src/data-inspector.ts'
304+
305+
createCac(dataInspectorFrame).parse()
306+
```
307+
308+
```sh
309+
npm pkg set bin.data-inspector=bin.mjs
310+
311+
node bin.mjs dev # the standalone server from Step 6
312+
node bin.mjs build # the static build from Step 5
313+
node bin.mjs mcp # expose the tool to a coding agent over MCP
314+
```
315+
316+
You can also assemble your own CLI from the adapter functions used above.
317+
318+
That's it for this tutorial. For a full-featured version, there's a ready-to-use [Data Inspector plugin](/plugins/data-inspector) to use or read for reference.
319+
320+
## What's next
321+
322+
- [RPC](/guide/rpc)`action` and `event` calls, end-to-end types, schema validation
323+
- [Shared State](/guide/shared-state) — push live changes to the UI without polling
324+
- [Hub](/guide/hub) — docks, commands, terminals across many tools
325+
- [Agent-Native](/guide/agent-native) — expose your tool to coding agents over MCP
File renamed without changes.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)