Skip to content
Merged
325 changes: 325 additions & 0 deletions docs/content/1.guide/1.tutorial-server-data-inspector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
---
title: 'Tutorial: Build a Server Data Inspector'
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.'
---

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+](https://nodejs.org/) 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:

```sh
mkdir data-inspector && cd data-inspector
npm init -y && npm pkg set type=module
npm install devframe && npm install -D typescript
```

```ts [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](/guide/rpc) has the other types; [Devframe Definition](/guide/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.

```sh
npm install react react-dom @devframes/vite
npm install -D vite @vitejs/plugin-react @types/react @types/react-dom
```

```html [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>
```

```tsx [client/main.tsx]
import { createRoot } from 'react-dom/client'
import { App } from './App'

createRoot(document.getElementById('app')!).render(<App />)
```

```tsx [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](/guide/rpc).)

## Step 3 — Run it in development

To try what we've built, let Vite serve the UI and hand RPC traffic to devframe:

```ts [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 }),
],
})
```

```sh
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.

> [!WARNING]
> `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).

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](/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:

```ts [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:

```sh
npm install @devframes/hub @devframes/hub-ui
npx vite build --config vite.client.config.ts
```

```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' } }),
}),
],
})
```

```sh
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](/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`:

```ts
ctx.rpc.register({
name: 'data-inspector:get-meta',
type: 'query',
jsonSerializable: true,
snapshot: true, // bake this call's result into the build
handler: () => {
/* … unchanged … */
}
})
```

```js [scripts/build.mjs]
import { createBuild } from 'devframe/adapters/build'
import dataInspectorFrame from '../src/data-inspector.ts'

await createBuild(dataInspectorFrame, { outDir: 'dist-static' })
```

```sh
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](/guide/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:

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

await createDevServer(dataInspectorFrame, { openBrowser: true })
```

```sh
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:

```js [bin.mjs]
#!/usr/bin/env node
import { createCac } from 'devframe/adapters/cac'
import dataInspectorFrame from './src/data-inspector.ts'

createCac(dataInspectorFrame).parse()
```

```sh
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](/plugins/data-inspector) to use or read for reference.

## What's next

- [RPC](/guide/rpc) — `action` and `event` calls, end-to-end types, schema validation
- [Shared State](/guide/shared-state) — push live changes to the UI without polling
- [Hub](/guide/hub) — docks, commands, terminals across many tools
- [Agent-Native](/guide/agent-native) — expose your tool to coding agents over MCP
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions docs/content/1.guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ The CLI adapter serves the SPA at `/`; embedded in a host (`vite`, `embedded`) t

## What's next

- [Tutorial: Build a Server Data Inspector](/guide/tutorial-server-data-inspector) — go from an empty folder to a shippable devtool, one capability at a time
- [Devframe Definition](/guide/devframe-definition) — `defineDevframe` and `DevframeNodeContext`
- [The Standard Handler](/adapters/initiate) — mount into any host
- [Adapters](/adapters) — convenience entry points
Expand Down
Loading