Tutorial: Build a Server Data Inspector

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.

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 typescript
src/data-inspector.ts
import { 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 dataInspectorFrame

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 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
client/index.html
<!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>
client/main.tsx
import { createRoot } from 'react-dom/client'
import { App } from './App'

createRoot(document.getElementById('app')!).render(<App />)
client/App.tsx
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:

vite.client.config.ts
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.ts

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.

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:

src/data-inspector.ts
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.ts
vite.hub.config.ts
import { 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.ts

Your 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 … */
  }
})
scripts/build.mjs
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:

scripts/serve.mjs
import { createDevServer } from 'devframe/adapters/dev'
import dataInspectorFrame from '../src/data-inspector.ts'

await createDevServer(dataInspectorFrame, { openBrowser: true })
npx vite build
node scripts/serve.mjs

Same 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:

bin.mjs
#!/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 MCP

You 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

  • RPCaction and event calls, 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