Tutorial: Build a Server Data Inspector
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.
You'll need Node 24+ and a terminal. Every code block is complete, so you can copy them as you go.
The shape of a devframe app
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.
Step 1 — Define the tool
Everything starts with defineDevframe: your tool's name, plus a setup where you register what it can do. Create the project and the definition:
mkdir data-inspector && cd data-inspector
npm init -y && npm pkg set type=module
npm install devframe && npm install -D typescriptimport { defineDevframe } from 'devframe'
// Some example server-side data — whatever you want to peek at while your app
// runs: config, a cache, a DB handle.
const serverState = {
config: { name: 'Acme', port: 3000, debug: false },
users: [
{ id: 1, name: 'Ada', admin: true },
{ id: 2, name: 'Lin', admin: false },
],
featureFlags: { newDashboard: true, betaSearch: false },
}
// A tiny query helper that follows a dot-path like `users.0.name` into the state.
function valueAtPath(root: unknown, path: string): unknown {
if (!path)
return root
return path.split('.').reduce<unknown>((value, key) => {
if (value == null || typeof value !== 'object')
return undefined
return (value as Record<string, unknown>)[key]
}, root)
}
const dataInspectorFrame = defineDevframe({
id: 'data-inspector',
name: 'Data Inspector',
version: '0.0.0',
packageName: 'data-inspector',
description: 'Inspect live server state.',
homepage: 'https://example.com',
importMetaUrl: import.meta.url,
setup(ctx) {
// What does the state look like?
ctx.rpc.register({
name: 'data-inspector:get-meta',
type: 'query',
jsonSerializable: true,
handler: () =>
Object.entries(serverState).map(([key, value]) => ({
key,
type: Array.isArray(value) ? 'array' : typeof value,
length: Array.isArray(value) ? value.length : undefined,
})),
})
// What's at this path?
ctx.rpc.register({
name: 'data-inspector:query',
type: 'query',
jsonSerializable: true,
handler: (path: string) => valueAtPath(serverState, path),
})
},
})
export default dataInspectorFramectx.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 has the other types; Devframe Definition has every field.)
Step 2 — Add a UI
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.
npm install react react-dom @devframes/vite
npm install -D vite @vitejs/plugin-react @types/react @types/react-dom<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Data Inspector</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>import { createRoot } from 'react-dom/client'
import { App } from './App'
createRoot(document.getElementById('app')!).render(<App />)import type { DevframeRpcClient } from 'devframe/client'
import { connectDevframe } from 'devframe/client'
import { useEffect, useState } from 'react'
interface MetaEntry { key: string, type: string, length?: number }
export function App() {
const [rpc, setRpc] = useState<DevframeRpcClient>()
const [meta, setMeta] = useState<MetaEntry[]>([])
const [path, setPath] = useState('config')
const [result, setResult] = useState<unknown>()
useEffect(() => {
// No argument: the client finds the server from the page's own URL, so
// this line never changes no matter how the tool is hosted.
connectDevframe().then(async (client) => {
setRpc(client)
const call = client.call as (name: string, ...args: unknown[]) => Promise<any>
setMeta(await call('data-inspector:get-meta'))
})
}, [])
async function runQuery() {
if (!rpc)
return
const call = rpc.call as (name: string, ...args: unknown[]) => Promise<any>
setResult(await call('data-inspector:query', path))
}
return (
<main style={{ fontFamily: 'sans-serif', maxWidth: 640, margin: '2rem auto' }}>
<h1>Data Inspector</h1>
<ul>
{meta.map(m => (
<li key={m.key}>
<code>{m.key}</code>
{' - '}
{m.type}
{m.length != null ? ` (${m.length})` : ''}
</li>
))}
</ul>
<input value={path} onChange={e => setPath(e.target.value)} placeholder="config.port" />
<button type="button" onClick={runQuery}>Query</button>
<pre>{JSON.stringify(result, null, 2)}</pre>
</main>
)
}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.)
Step 3 — Run it in development
To try what we've built, let Vite serve the UI and hand RPC traffic to devframe:
import { devframeViteBridge } from '@devframes/vite/single'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import dataInspectorFrame from './src/data-inspector.ts'
export default defineConfig({
root: 'client',
base: './', // relative asset URLs, so the built UI works under any mount path
build: { outDir: '../dist/client', emptyOutDir: true },
plugins: [
react(),
// Vite serves the page; the bridge answers RPC on the same origin, so
// `connectDevframe()` just finds it. `auth: false`, see the note below.
devframeViteBridge(dataInspectorFrame, { base: '/', auth: false }),
],
})npx vite --config vite.client.config.tsOpen 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.
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.From here on we reuse this same src/data-inspector.ts and client/ unchanged; all that changes is where they run.
Step 4 — Dock it in a hub
A 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:
import { fileURLToPath } from 'node:url'
// …
const dataInspectorFrame = defineDevframe({
id: 'data-inspector',
// …
clientAssets: fileURLToPath(new URL('../dist/client', import.meta.url)),
setup(ctx) { /* unchanged */ },
})Build the UI and stand up a one-devframe hub:
npm install @devframes/hub @devframes/hub-ui
npx vite build --config vite.client.config.tsimport { createUi } from '@devframes/hub-ui'
import { viteDevframeHub } from '@devframes/vite/hub'
import { defineConfig } from 'vite'
import dataInspectorFrame from './src/data-inspector.ts'
export default defineConfig({
plugins: [
viteDevframeHub({
devframes: [dataInspectorFrame],
ui: createUi({ branding: { productName: 'My Devtools' } }),
}),
],
})npx vite --config vite.hub.config.tsYour inspector now sits in the hub's rail as a dock. Add more to devframes: [...] — your own or the built-in plugins — and each gets its own. (The hub prints a code to authorize on first connect.)
Step 5 — Build a static version
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:
ctx.rpc.register({
name: 'data-inspector:get-meta',
type: 'query',
jsonSerializable: true,
snapshot: true, // bake this call's result into the build
handler: () => {
/* … unchanged … */
}
})import { createBuild } from 'devframe/adapters/build'
import dataInspectorFrame from '../src/data-inspector.ts'
await createBuild(dataInspectorFrame, { outDir: 'dist-static' })npx vite build # refresh dist/client
node scripts/build.mjs # → dist-static/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).
Step 6 — Run it standalone
The definition never depended on Vite. createDevServer runs the tool on its own, serving the UI from clientAssets and answering RPC live:
import { createDevServer } from 'devframe/adapters/dev'
import dataInspectorFrame from '../src/data-inspector.ts'
await createDevServer(dataInspectorFrame, { openBrowser: true })npx vite build
node scripts/serve.mjsSame UI, same live calls, no bundler in the loop — this is what you'd drop into your own Node program.
Step 7 — Give it a CLI
Finally, wrap that server in a command shell. devframe/adapters/cac turns a devframe into a CLI with dev, build, and mcp commands:
#!/usr/bin/env node
import { createCac } from 'devframe/adapters/cac'
import dataInspectorFrame from './src/data-inspector.ts'
createCac(dataInspectorFrame).parse()npm pkg set bin.data-inspector=bin.mjs
node bin.mjs dev # the standalone server from Step 6
node bin.mjs build # the static build from Step 5
node bin.mjs mcp # expose the tool to a coding agent over MCPYou can also assemble your own CLI from the adapter functions used above.
That's it for this tutorial. For a full-featured version, there's a ready-to-use Data Inspector plugin to use or read for reference.
What's next
- RPC —
actionandeventcalls, end-to-end types, schema validation - Shared State — push live changes to the UI without polling
- Hub — docks, commands, terminals across many tools
- Agent-Native — expose your tool to coding agents over MCP
Introduction
Devframe is a framework-neutral foundation for building a devtool once and running it everywhere — inside any host, as a standalone app, or through a coding agent. A devtool here is anything that makes a program's implicit state visible and interactive: an inspector, a build or bundle analyzer,…
Devframe Definition
One defineDevframe call returns a portable DevframeDefinition any adapter consumes.