Tutorial: Build a Server Data Inspector

Build a devtool that displays and queries live server-side data, then ship it as a hub dock entry, a static build, a standalone dev server, and a CLI.

Let's build a real devtool from scratch: a Data Inspector that shows the shape of live server-side 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 entry in a hub, a static build, a standalone dev 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

A devframe is two halves talking over a typed connection: the node side exposes functions, and the browser side calls them and renders the results. Devframe is everything in between: the wire, the UI hosting, auth, builds, and a CLI.

The two halves live in their own folders: the node side under src/node/, the web app under app/. A playgrounds/ folder holds hosts that boot the built tool. We'll fill these in as we go.

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/node/data-inspector.ts
import { defineDevframe } from 'devframe'

// Some example server-side data, whatever you want to peek at while your
// user 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 side 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 node side. (RPC has the other types; Devframe Definition has every field.)

Step 2: Add a UI

Now the browser side. We'll use React here, but any framework works; the only devframe-specific line is connectDevframe, which opens the connection back to the node side.

npm install react react-dom @devframes/vite
npm install -D vite @vitejs/plugin-react @types/react @types/react-dom
app/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>
app/main.tsx
import { createRoot } from 'react-dom/client'
import { App } from './App'

createRoot(document.getElementById('app')!).render(<App />)
app/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 RPC client finds the node side 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:

app/vite.config.ts
import { devframeViteBridge } from '@devframes/vite/single'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import dataInspectorFrame from '../src/node/data-inspector.ts'

export default defineConfig({
  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 app/vite.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 devframe 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/node/data-inspector.ts and app/ 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 entry you switch between, the tool's own UI in an iframe. Since our SPA uses a bare connectDevframe(), it already works anywhere; the hub just needs the built UI, so point the definition at it:

src/node/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 app/vite.config.ts
playgrounds/hub.config.ts
import { createUi } from '@devframes/hub-ui'
import { viteDevframeHub } from '@devframes/vite/hub'
import { defineConfig } from 'vite'
import dataInspectorFrame from '../src/node/data-inspector.ts'

export default defineConfig({
  plugins: [
    viteDevframeHub({
      devframes: [dataInspectorFrame],
      ui: createUi({ branding: { productName: 'My Devtools' } }),
    }),
  ],
})
npx vite --config playgrounds/hub.config.ts

Your inspector now sits in the hub's dock rail as a dock entry. Add more to devframes: [...] (your own or the built-in devframes) 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 node side at all: a report you can drop on any static hosting. 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/node/data-inspector.ts'

await createBuild(dataInspectorFrame, { outDir: 'dist-static' })
npx vite build --config app/vite.config.ts   # 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 node side (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:

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

await createDevServer(dataInspectorFrame, { openBrowser: true })
npx vite build --config app/vite.config.ts
node playgrounds/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 dev server in a CLI. 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/node/data-inspector.ts'

createCac(dataInspectorFrame).parse()
npm pkg set bin.data-inspector=bin.mjs

node bin.mjs dev     # the standalone dev 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 built-in devframe to use or read for reference.

What's next

  • RPC: action 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