|
| 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 |
0 commit comments