Skip to content

Commit ffc4288

Browse files
committed
docs: replace Project Structure page with a Data Inspector tutorial
Adds a step-by-step, human-oriented guide that builds a simplified data-inspector devframe from zero to an MVP (client + RPC over a Vite bridge), then layers on a hub dock, static build, standalone server, and CLI — one concept per step. Slots in as guide page 1 (after the intro); drops the earlier Project Structure page per review.
1 parent a6a79e6 commit ffc4288

4 files changed

Lines changed: 386 additions & 283 deletions

File tree

‎docs/content/1.guide/1.tutorial.md‎

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
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
File renamed without changes.

0 commit comments

Comments
 (0)