|
| 1 | +--- |
| 2 | +title: 'Tutorial: Build a Data Inspector' |
| 3 | +description: 'Build a small devtool from an empty folder — a live view into your server''s state — then grow it one capability at a time: a dock in a hub, a static build, a standalone server, and a CLI.' |
| 4 | +--- |
| 5 | + |
| 6 | +In this walkthrough you build a real devtool from an empty folder: a **Data Inspector** that shows the shape of your server's live state and lets you read any value out of it. You'll get it working end to end first, then add one capability at a time — a dock inside a hub, a static build, a standalone server, and a CLI. |
| 7 | + |
| 8 | +Each step introduces exactly one new idea. Nothing here assumes you've read the rest of the guide; links point to the deeper reference when you want it. |
| 9 | + |
| 10 | +**You'll need** Node 24+ (so `node` runs the TypeScript files directly) and a terminal. Every code block is complete — copy them as you go. |
| 11 | + |
| 12 | +## What we're building |
| 13 | + |
| 14 | +A devframe app has two halves that talk over a typed connection: |
| 15 | + |
| 16 | +- a **server** half that runs in your Node process and exposes functions (here: "what does the state look like?" and "give me the value at this path"), |
| 17 | +- a **browser** half — a small web UI that calls those functions and shows the answers. |
| 18 | + |
| 19 | +Devframe's job is the wire between them, plus everything around it: serving the UI, the live connection, authentication, static builds, a CLI. You write the two halves once; devframe runs them everywhere. |
| 20 | + |
| 21 | +Let's start. |
| 22 | + |
| 23 | +## Step 0 — An empty project |
| 24 | + |
| 25 | +Make a folder and initialize it: |
| 26 | + |
| 27 | +```sh |
| 28 | +mkdir data-inspector && cd data-inspector |
| 29 | +npm init -y |
| 30 | +npm pkg set type=module |
| 31 | +npm install devframe |
| 32 | +npm install -D typescript |
| 33 | +``` |
| 34 | + |
| 35 | +That's the only dependency for the first milestone. We'll add more as each capability calls for it. |
| 36 | + |
| 37 | +## Step 1 — Define the tool and its first function |
| 38 | + |
| 39 | +Everything begins with one call: `defineDevframe`. It pairs your tool's identity with a `setup` function where you register what it can do. |
| 40 | + |
| 41 | +Create `src/devframe.ts`: |
| 42 | + |
| 43 | +```ts [src/devframe.ts] |
| 44 | +import { defineDevframe } from 'devframe' |
| 45 | + |
| 46 | +// The live server state our tool inspects. In a real app this might be your |
| 47 | +// config, a cache, a database handle — anything living in the process. |
| 48 | +const serverState = { |
| 49 | + config: { name: 'Acme', port: 3000, debug: false }, |
| 50 | + users: [ |
| 51 | + { id: 1, name: 'Ada', admin: true }, |
| 52 | + { id: 2, name: 'Lin', admin: false }, |
| 53 | + ], |
| 54 | + featureFlags: { newDashboard: true, betaSearch: false }, |
| 55 | +} |
| 56 | + |
| 57 | +// Walk a dot-path like `users.0.name` down into a nested value. |
| 58 | +function valueAtPath(root: unknown, path: string): unknown { |
| 59 | + if (!path) |
| 60 | + return root |
| 61 | + return path.split('.').reduce<unknown>((value, key) => { |
| 62 | + if (value == null || typeof value !== 'object') |
| 63 | + return undefined |
| 64 | + return (value as Record<string, unknown>)[key] |
| 65 | + }, root) |
| 66 | +} |
| 67 | + |
| 68 | +export default defineDevframe({ |
| 69 | + id: 'data-inspector', |
| 70 | + name: 'Data Inspector', |
| 71 | + version: '0.0.0', |
| 72 | + packageName: 'data-inspector', |
| 73 | + description: 'Inspect live server state.', |
| 74 | + homepage: 'https://example.com', |
| 75 | + importMetaUrl: import.meta.url, |
| 76 | + |
| 77 | + setup(ctx) { |
| 78 | + // A read-only call describing the top-level shape of the state. |
| 79 | + ctx.rpc.register({ |
| 80 | + name: 'data-inspector:get-meta', |
| 81 | + type: 'query', |
| 82 | + jsonSerializable: true, |
| 83 | + handler: () => |
| 84 | + Object.entries(serverState).map(([key, value]) => ({ |
| 85 | + key, |
| 86 | + type: Array.isArray(value) ? 'array' : typeof value, |
| 87 | + length: Array.isArray(value) ? value.length : undefined, |
| 88 | + })), |
| 89 | + }) |
| 90 | + |
| 91 | + // A read-only call that resolves a dot-path against the live state. |
| 92 | + ctx.rpc.register({ |
| 93 | + name: 'data-inspector:query', |
| 94 | + type: 'query', |
| 95 | + jsonSerializable: true, |
| 96 | + handler: (path: string) => valueAtPath(serverState, path), |
| 97 | + }) |
| 98 | + }, |
| 99 | +}) |
| 100 | +``` |
| 101 | + |
| 102 | +Two ideas landed here: |
| 103 | + |
| 104 | +- **The definition** — `id`, `name`, and a bit of metadata identify the tool; `setup(ctx)` is where you wire up its capabilities. (Every field is covered in [Devframe Definition](/guide/devframe-definition).) |
| 105 | +- **RPC functions** — `ctx.rpc.register` publishes a function the browser can call. Each has a namespaced `name`, a `type` (`query` means read-only), and a `handler`. The handler receives the call's arguments and returns a value; `jsonSerializable: true` promises the result is plain JSON. ([RPC](/guide/rpc) covers the other types.) |
| 106 | + |
| 107 | +There's no UI yet, and nothing runs. Both come next. |
| 108 | + |
| 109 | +## Step 2 — A browser UI that calls the functions |
| 110 | + |
| 111 | +Now the other half. We'll use React with Vite — pick any framework you like; the only devframe-specific part is `connectDevframe`, which opens the typed connection back to the server. |
| 112 | + |
| 113 | +```sh |
| 114 | +npm install react react-dom @devframes/vite |
| 115 | +npm install -D vite @vitejs/plugin-react @types/react @types/react-dom |
| 116 | +``` |
| 117 | + |
| 118 | +Create the UI in a `client/` folder: |
| 119 | + |
| 120 | +```html [client/index.html] |
| 121 | +<!doctype html> |
| 122 | +<html> |
| 123 | + <head> |
| 124 | + <meta charset="utf-8" /> |
| 125 | + <title>Data Inspector</title> |
| 126 | + </head> |
| 127 | + <body> |
| 128 | + <div id="app"></div> |
| 129 | + <script type="module" src="./main.tsx"></script> |
| 130 | + </body> |
| 131 | +</html> |
| 132 | +``` |
| 133 | + |
| 134 | +```tsx [client/main.tsx] |
| 135 | +import { createRoot } from 'react-dom/client' |
| 136 | +import { App } from './App' |
| 137 | + |
| 138 | +createRoot(document.getElementById('app')!).render(<App />) |
| 139 | +``` |
| 140 | + |
| 141 | +```tsx [client/App.tsx] |
| 142 | +import type { DevframeRpcClient } from 'devframe/client' |
| 143 | +import { connectDevframe } from 'devframe/client' |
| 144 | +import { useEffect, useState } from 'react' |
| 145 | + |
| 146 | +interface MetaEntry { key: string, type: string, length?: number } |
| 147 | + |
| 148 | +export function App() { |
| 149 | + const [rpc, setRpc] = useState<DevframeRpcClient>() |
| 150 | + const [meta, setMeta] = useState<MetaEntry[]>([]) |
| 151 | + const [path, setPath] = useState('config') |
| 152 | + const [result, setResult] = useState<unknown>() |
| 153 | + |
| 154 | + useEffect(() => { |
| 155 | + // Connect once. With no argument, the client discovers where the server |
| 156 | + // lives from the page's own URL — so this works unchanged no matter how |
| 157 | + // the tool ends up hosted (dev server, hub, standalone). |
| 158 | + connectDevframe().then(async (client) => { |
| 159 | + setRpc(client) |
| 160 | + const call = client.call as (name: string, ...args: unknown[]) => Promise<any> |
| 161 | + setMeta(await call('data-inspector:get-meta')) |
| 162 | + }) |
| 163 | + }, []) |
| 164 | + |
| 165 | + async function runQuery() { |
| 166 | + if (!rpc) |
| 167 | + return |
| 168 | + const call = rpc.call as (name: string, ...args: unknown[]) => Promise<any> |
| 169 | + setResult(await call('data-inspector:query', path)) |
| 170 | + } |
| 171 | + |
| 172 | + return ( |
| 173 | + <main style={{ fontFamily: 'sans-serif', maxWidth: 640, margin: '2rem auto' }}> |
| 174 | + <h1>Data Inspector</h1> |
| 175 | + <ul> |
| 176 | + {meta.map(m => ( |
| 177 | + <li key={m.key}> |
| 178 | + <code>{m.key}</code> |
| 179 | + {' — '} |
| 180 | + {m.type} |
| 181 | + {m.length != null ? ` (${m.length})` : ''} |
| 182 | + </li> |
| 183 | + ))} |
| 184 | + </ul> |
| 185 | + <input value={path} onChange={e => setPath(e.target.value)} placeholder="config.port" /> |
| 186 | + <button type="button" onClick={runQuery}>Query</button> |
| 187 | + <pre>{JSON.stringify(result, null, 2)}</pre> |
| 188 | + </main> |
| 189 | + ) |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +`connectDevframe()` returns a client whose `.call(name, ...args)` reaches your server functions. (We cast `.call` to call by name for brevity; once you register functions through a typed registry, every call is checked end to end — see [RPC](/guide/rpc).) |
| 194 | + |
| 195 | +Still nothing to run — the two halves aren't connected yet. |
| 196 | + |
| 197 | +## Step 3 — Run it (the MVP) |
| 198 | + |
| 199 | +The server half needs to be served *somewhere*. The quickest way while developing is to let Vite's dev server host the UI and hand the RPC traffic to devframe. `@devframes/vite` provides exactly that bridge. |
| 200 | + |
| 201 | +Create `vite.config.ts`: |
| 202 | + |
| 203 | +```ts [vite.config.ts] |
| 204 | +import { devframeViteBridge } from '@devframes/vite/single' |
| 205 | +import react from '@vitejs/plugin-react' |
| 206 | +import { defineConfig } from 'vite' |
| 207 | +import devframe from './src/devframe.ts' |
| 208 | + |
| 209 | +export default defineConfig({ |
| 210 | + root: 'client', |
| 211 | + base: './', // relative asset URLs, so the built UI works under any mount path |
| 212 | + build: { outDir: '../dist/client', emptyOutDir: true }, |
| 213 | + plugins: [ |
| 214 | + react(), |
| 215 | + // Vite serves the UI; this bridge answers the RPC, live connection, and |
| 216 | + // discovery on the same origin (`base: '/'`), so `connectDevframe()` finds |
| 217 | + // it with no configuration. `auth: false` keeps this local-only demo |
| 218 | + // frictionless — see the security note below. |
| 219 | + devframeViteBridge(devframe, { base: '/', auth: false }), |
| 220 | + ], |
| 221 | +}) |
| 222 | +``` |
| 223 | + |
| 224 | +Run it: |
| 225 | + |
| 226 | +```sh |
| 227 | +npx vite |
| 228 | +``` |
| 229 | + |
| 230 | +Open the printed URL. You should see the three top-level keys with their types, and typing a path like `config.port` or `users.0.name` and pressing **Query** prints the value. That round trip — button → `rpc.call` → your `handler` → back to the page — is a devframe app working. |
| 231 | + |
| 232 | +> [!WARNING] |
| 233 | +> `auth: false` trusts any connection that can reach the port. It's fine for a localhost demo, but leave it off (devframe gates with a one-time code by default) for anything reachable beyond your machine. See [Security](/guide/security). |
| 234 | +
|
| 235 | +That's the **MVP**: one definition, two functions, a UI, live over a dev server. Everything from here reuses this exact `src/devframe.ts` and `client/` — we only change how they're *hosted*. |
| 236 | + |
| 237 | +## Step 4 — Show it as a dock in a hub |
| 238 | + |
| 239 | +A [hub](/guide/hub) gathers many devframes behind one interface, each appearing as a **dock** you switch between — the tool's own UI shown in an iframe. Because our client already connects with a bare `connectDevframe()`, it works wherever it's mounted — so this takes just one change to the definition. |
| 240 | + |
| 241 | +The hub serves your UI itself (rather than Vite serving it), so it needs the built assets. Tell the definition where they'll be, by adding one field to `defineDevframe` in `src/devframe.ts`: |
| 242 | + |
| 243 | +```ts [src/devframe.ts] |
| 244 | +import { fileURLToPath } from 'node:url' |
| 245 | +// … |
| 246 | + |
| 247 | +export default defineDevframe({ |
| 248 | + id: 'data-inspector', |
| 249 | + // … |
| 250 | + importMetaUrl: import.meta.url, |
| 251 | + // The built UI devframe serves when it hosts the SPA itself. |
| 252 | + clientAssets: fileURLToPath(new URL('../dist/client', import.meta.url)), |
| 253 | + setup(ctx) { /* unchanged */ }, |
| 254 | +}) |
| 255 | +``` |
| 256 | + |
| 257 | +Now build the UI and add a tiny hub host: |
| 258 | + |
| 259 | +```sh |
| 260 | +npm install @devframes/hub @devframes/hub-ui |
| 261 | +npx vite build # emits client/ → dist/client |
| 262 | +``` |
| 263 | + |
| 264 | +```ts [hub.config.ts] |
| 265 | +import { createUi } from '@devframes/hub-ui' |
| 266 | +import { viteDevframeHub } from '@devframes/vite/hub' |
| 267 | +import { defineConfig } from 'vite' |
| 268 | +import devframe from './src/devframe.ts' |
| 269 | + |
| 270 | +// A hub with a single devframe mounted. `viteDevframeHub` wraps the hub, serves |
| 271 | +// each tool's UI as a dock, and provides the reference interface via `createUi`. |
| 272 | +export default defineConfig({ |
| 273 | + plugins: [ |
| 274 | + viteDevframeHub({ |
| 275 | + devframes: [devframe], |
| 276 | + ui: createUi({ branding: { productName: 'My Devtools' } }), |
| 277 | + quiet: true, |
| 278 | + }), |
| 279 | + ], |
| 280 | +}) |
| 281 | +``` |
| 282 | + |
| 283 | +```sh |
| 284 | +npx vite --config hub.config.ts |
| 285 | +``` |
| 286 | + |
| 287 | +Open the printed URL: your Data Inspector now appears as a dock in the hub's rail, its UI running in an iframe. Add more devframes to that `devframes: [...]` array — your own or the [built-in plugins](/plugins) — and each becomes another dock. The hub prints a one-time code on startup; enter it to authorize. |
| 288 | + |
| 289 | +## Step 5 — Ship a static build |
| 290 | + |
| 291 | +Some tools should be viewable with no server at all — a report you can drop on any static host. `createBuild` renders your UI to a folder and **bakes** the results of read-only calls into it, so the built page answers them without a live process. |
| 292 | + |
| 293 | +Opt a function into the bake by adding `snapshot: true`. In `src/devframe.ts`, mark `get-meta`: |
| 294 | + |
| 295 | +```ts |
| 296 | +ctx.rpc.register({ |
| 297 | + name: 'data-inspector:get-meta', |
| 298 | + type: 'query', |
| 299 | + jsonSerializable: true, |
| 300 | + snapshot: true, // bake this call's result into the static build |
| 301 | + handler: () => /* … unchanged … */, |
| 302 | +}) |
| 303 | +``` |
| 304 | + |
| 305 | +Add a build script: |
| 306 | + |
| 307 | +```js [scripts/build.mjs] |
| 308 | +import { createBuild } from 'devframe/adapters/build' |
| 309 | +import devframe from '../src/devframe.ts' |
| 310 | + |
| 311 | +await createBuild(devframe, { outDir: 'dist-static' }) |
| 312 | +``` |
| 313 | + |
| 314 | +```sh |
| 315 | +npx vite build # refresh dist/client (the UI createBuild copies in) |
| 316 | +node scripts/build.mjs # → dist-static/, a self-contained deploy |
| 317 | +``` |
| 318 | + |
| 319 | +Serve `dist-static/` with any static file server and the meta list renders from the baked snapshot — no Node process running. `query` takes an argument, so it isn't baked by default; a query needs its live server (next step), or you can bake specific inputs (see [Client Assets](/guide/client-assets)). This is the same result the CLI's `build` command produces in Step 7. |
| 320 | + |
| 321 | +## Step 6 — Run it standalone (no Vite) |
| 322 | + |
| 323 | +Vite was convenient for development, but the definition doesn't depend on it. `createDevServer` runs your tool as its own server, serving the UI from `clientAssets` and answering RPC live — no host framework involved. |
| 324 | + |
| 325 | +```js [scripts/serve.mjs] |
| 326 | +import { createDevServer } from 'devframe/adapters/dev' |
| 327 | +import devframe from '../src/devframe.ts' |
| 328 | + |
| 329 | +await createDevServer(devframe, { openBrowser: true }) |
| 330 | +``` |
| 331 | + |
| 332 | +```sh |
| 333 | +npx vite build # ensure dist/client is current |
| 334 | +node scripts/serve.mjs |
| 335 | +``` |
| 336 | + |
| 337 | +The same UI and the same live `query`/`get-meta` — now hosted entirely by devframe. This is what you'd embed in your own Node program when you want the tool available without a bundler in the loop. |
| 338 | + |
| 339 | +## Step 7 — Give it a CLI |
| 340 | + |
| 341 | +Finally, wrap the standalone server in a command shell so anyone can run the tool without writing a script. `createCac` turns your definition into a CLI with `dev`, `build`, and `mcp` subcommands out of the box. |
| 342 | + |
| 343 | +```js [bin.mjs] |
| 344 | +#!/usr/bin/env node |
| 345 | +import { createCac } from 'devframe/adapters/cac' |
| 346 | +import devframe from './src/devframe.ts' |
| 347 | + |
| 348 | +createCac(devframe).parse() |
| 349 | +``` |
| 350 | + |
| 351 | +Wire it up as the package's binary: |
| 352 | + |
| 353 | +```sh |
| 354 | +npm pkg set bin.data-inspector=bin.mjs |
| 355 | +``` |
| 356 | + |
| 357 | +Now the three modes from the previous steps are subcommands: |
| 358 | + |
| 359 | +```sh |
| 360 | +node bin.mjs dev # the standalone server from Step 6 |
| 361 | +node bin.mjs build # the static build from Step 5 |
| 362 | +node bin.mjs mcp # expose the tool to a coding agent over MCP |
| 363 | +``` |
| 364 | + |
| 365 | +Published to npm, that same binary runs with `npx data-inspector`. One definition, five ways to run it — and you never rewrote the tool to get there. |
| 366 | + |
| 367 | +## Where you've been |
| 368 | + |
| 369 | +You wrote two halves once and grew the hosting around them: |
| 370 | + |
| 371 | +| Step | Capability | Key API | |
| 372 | +|------|------------|---------| |
| 373 | +| 1–3 | Live UI + RPC over a dev server | `defineDevframe`, `ctx.rpc.register`, `connectDevframe`, `devframeViteBridge` | |
| 374 | +| 4 | A dock inside a hub | `clientAssets`, `viteDevframeHub`, `createUi` | |
| 375 | +| 5 | Static, serverless build | `snapshot: true`, `createBuild` | |
| 376 | +| 6 | Standalone server | `createDevServer` | |
| 377 | +| 7 | A CLI (`dev`/`build`/`mcp`) | `createCac` | |
| 378 | + |
| 379 | +## What's next |
| 380 | + |
| 381 | +- [Devframe Definition](/guide/devframe-definition) — every field of `defineDevframe` |
| 382 | +- [RPC](/guide/rpc) — `query`, `action`, and `event` functions, with end-to-end types and schema validation |
| 383 | +- [Shared State](/guide/shared-state) — push live server changes to the UI without polling |
| 384 | +- [Hub](/guide/hub) — compose many tools, with docks, commands, and terminals |
| 385 | +- [Agent-Native](/guide/agent-native) — expose your tool to coding agents over MCP |
0 commit comments