Skip to content

Commit e54a04b

Browse files
committed
docs: tighten and warm up the Data Inspector tutorial
1 parent ffc4288 commit e54a04b

1 file changed

Lines changed: 45 additions & 111 deletions

File tree

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

Lines changed: 45 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,28 @@ title: 'Tutorial: Build a Data Inspector'
33
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.'
44
---
55

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.
6+
Let's build a real devtool from nothing: 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, a CLI.
77

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.
8+
One new idea per step, nothing assumed. You'll need **Node 24+** (so `node` runs TypeScript directly) and a terminal.
99

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.
10+
## The shape of a devframe app
1111

12-
## What we're building
12+
Two halves talk over a typed connection: a **server** half in your Node process that exposes functions, and a **browser** half that calls them and shows the answers. Devframe is everything in between — the wire, the UI hosting, auth, builds, a CLI. Write the two halves once; run them anywhere.
1313

14-
A devframe app has two halves that talk over a typed connection:
14+
## Step 1 — Define the tool
1515

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:
16+
Everything starts with `defineDevframe`: your tool's name, plus a `setup` where you register what it can do. Create the project and the definition:
2617

2718
```sh
2819
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
20+
npm init -y && npm pkg set type=module
21+
npm install devframe && npm install -D typescript
3322
```
3423

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-
4324
```ts [src/devframe.ts]
4425
import { defineDevframe } from 'devframe'
4526

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.
27+
// Whatever you want to peek at while your app runs — config, a cache, a DB handle.
4828
const serverState = {
4929
config: { name: 'Acme', port: 3000, debug: false },
5030
users: [
@@ -54,7 +34,7 @@ const serverState = {
5434
featureFlags: { newDashboard: true, betaSearch: false },
5535
}
5636

57-
// Walk a dot-path like `users.0.name` down into a nested value.
37+
// Follow a dot-path like `users.0.name` into the state.
5838
function valueAtPath(root: unknown, path: string): unknown {
5939
if (!path)
6040
return root
@@ -75,7 +55,7 @@ export default defineDevframe({
7555
importMetaUrl: import.meta.url,
7656

7757
setup(ctx) {
78-
// A read-only call describing the top-level shape of the state.
58+
// What does the state look like?
7959
ctx.rpc.register({
8060
name: 'data-inspector:get-meta',
8161
type: 'query',
@@ -88,7 +68,7 @@ export default defineDevframe({
8868
})),
8969
})
9070

91-
// A read-only call that resolves a dot-path against the live state.
71+
// What's at this path?
9272
ctx.rpc.register({
9373
name: 'data-inspector:query',
9474
type: 'query',
@@ -99,24 +79,17 @@ export default defineDevframe({
9979
})
10080
```
10181

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.
82+
`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.)
10883

109-
## Step 2 — A browser UI that calls the functions
84+
## Step 2 — Add a UI
11085

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.
86+
Now the browser half. We'll use React, but any framework works — the only devframe-specific line is `connectDevframe`, which opens the connection home.
11287

11388
```sh
11489
npm install react react-dom @devframes/vite
11590
npm install -D vite @vitejs/plugin-react @types/react @types/react-dom
11691
```
11792

118-
Create the UI in a `client/` folder:
119-
12093
```html [client/index.html]
12194
<!doctype html>
12295
<html>
@@ -152,9 +125,8 @@ export function App() {
152125
const [result, setResult] = useState<unknown>()
153126

154127
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).
128+
// No argument: the client finds the server from the page's own URL, so
129+
// this line never changes no matter how the tool is hosted.
158130
connectDevframe().then(async (client) => {
159131
setRpc(client)
160132
const call = client.call as (name: string, ...args: unknown[]) => Promise<any>
@@ -190,15 +162,11 @@ export function App() {
190162
}
191163
```
192164

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)
165+
`client.call(name, ...args)` reaches your handlers. (We cast `.call` to call by name; wire up a typed registry later and every call is checked end to end — see [RPC](/guide/rpc).)
198166

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.
167+
## Step 3 — Run it
200168

201-
Create `vite.config.ts`:
169+
The two halves still need to meet. While developing, let Vite serve the UI and hand RPC traffic to devframe:
202170

203171
```ts [vite.config.ts]
204172
import { devframeViteBridge } from '@devframes/vite/single'
@@ -212,53 +180,44 @@ export default defineConfig({
212180
build: { outDir: '../dist/client', emptyOutDir: true },
213181
plugins: [
214182
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.
183+
// Vite serves the page; the bridge answers RPC on the same origin, so
184+
// `connectDevframe()` just finds it. `auth: false` — see the note below.
219185
devframeViteBridge(devframe, { base: '/', auth: false }),
220186
],
221187
})
222188
```
223189

224-
Run it:
225-
226190
```sh
227191
npx vite
228192
```
229193

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.
194+
Open the printed URL. Three keys with 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.
231195

232196
> [!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*.
197+
> `auth: false` trusts anything that can reach the port — fine for localhost, but leave it out (devframe gates with a one-time code by default) for anything reachable from elsewhere. See [Security](/guide/security).
236198
237-
## Step 4 — Show it as a dock in a hub
199+
Everything below reuses this exact `src/devframe.ts` and `client/`. We only change where they run.
238200

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.
201+
## Step 4 — Dock it in a hub
240202

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`:
203+
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. Point the definition at it:
242204

243205
```ts [src/devframe.ts]
244206
import { fileURLToPath } from 'node:url'
245207
//
246-
247208
export default defineDevframe({
248209
id: 'data-inspector',
249210
//
250-
importMetaUrl: import.meta.url,
251-
// The built UI devframe serves when it hosts the SPA itself.
252211
clientAssets: fileURLToPath(new URL('../dist/client', import.meta.url)),
253212
setup(ctx) { /* unchanged */ },
254213
})
255214
```
256215

257-
Now build the UI and add a tiny hub host:
216+
Build the UI and stand up a one-devframe hub:
258217

259218
```sh
260219
npm install @devframes/hub @devframes/hub-ui
261-
npx vite build # emits client/ → dist/client
220+
npx vite build
262221
```
263222

264223
```ts [hub.config.ts]
@@ -267,8 +226,6 @@ import { viteDevframeHub } from '@devframes/vite/hub'
267226
import { defineConfig } from 'vite'
268227
import devframe from './src/devframe.ts'
269228

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`.
272229
export default defineConfig({
273230
plugins: [
274231
viteDevframeHub({
@@ -284,26 +241,22 @@ export default defineConfig({
284241
npx vite --config hub.config.ts
285242
```
286243

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.
244+
Your inspector now sits in the hub's rail as a dock. Drop more into `devframes: [...]` — your own or the [built-in plugins](/plugins) — and each gets its own. (The hub prints a code to authorize on first connect.)
288245

289-
## Step 5 — Ship a static build
246+
## Step 5 — Build a static version
290247

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`:
248+
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`:
294249

295250
```ts
296251
ctx.rpc.register({
297252
name: 'data-inspector:get-meta',
298253
type: 'query',
299254
jsonSerializable: true,
300-
snapshot: true, // bake this call's result into the static build
255+
snapshot: true, // bake this call's result into the build
301256
handler: () => /* … unchanged … */,
302257
})
303258
```
304259

305-
Add a build script:
306-
307260
```js [scripts/build.mjs]
308261
import { createBuild } from 'devframe/adapters/build'
309262
import devframe from '../src/devframe.ts'
@@ -312,15 +265,15 @@ await createBuild(devframe, { outDir: 'dist-static' })
312265
```
313266

314267
```sh
315-
npx vite build # refresh dist/client (the UI createBuild copies in)
316-
node scripts/build.mjs # → dist-static/, a self-contained deploy
268+
npx vite build # refresh dist/client
269+
node scripts/build.mjs # → dist-static/
317270
```
318271

319-
Serve `dist-static/` with any static file server and the meta list renders from the baked snapshotno 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.
272+
Serve `dist-static/` anywhere and the meta list renders from the baked snapshot, no Node in sight. `query` takes an argument, so it needs the live server (next) — or bake specific inputs ([Client Assets](/guide/client-assets)).
320273

321-
## Step 6 — Run it standalone (no Vite)
274+
## Step 6 — Run it standalone
322275

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.
276+
The definition never depended on Vite. `createDevServer` runs the tool on its own, serving the UI from `clientAssets` and answering RPC live:
324277

325278
```js [scripts/serve.mjs]
326279
import { createDevServer } from 'devframe/adapters/dev'
@@ -330,15 +283,15 @@ await createDevServer(devframe, { openBrowser: true })
330283
```
331284

332285
```sh
333-
npx vite build # ensure dist/client is current
286+
npx vite build
334287
node scripts/serve.mjs
335288
```
336289

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.
290+
Same UI, same live callsno bundler in the loop. This is what you'd drop into your own Node program.
338291

339292
## Step 7 — Give it a CLI
340293

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.
294+
Finally, wrap that server in a command shell. `createCac` hands you `dev`, `build`, and `mcp` for free:
342295

343296
```js [bin.mjs]
344297
#!/usr/bin/env node
@@ -348,38 +301,19 @@ import devframe from './src/devframe.ts'
348301
createCac(devframe).parse()
349302
```
350303

351-
Wire it up as the package's binary:
352-
353304
```sh
354305
npm pkg set bin.data-inspector=bin.mjs
355-
```
356-
357-
Now the three modes from the previous steps are subcommands:
358306

359-
```sh
360307
node bin.mjs dev # the standalone server from Step 6
361308
node bin.mjs build # the static build from Step 5
362309
node bin.mjs mcp # expose the tool to a coding agent over MCP
363310
```
364311

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` |
312+
Publish it and the same binary runs with `npx data-inspector`. One definition, five ways to run it — and you never rewrote the tool.
378313

379314
## What's next
380315

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
316+
- [RPC](/guide/rpc)`action` and `event` calls, end-to-end types, schema validation
317+
- [Shared State](/guide/shared-state) — push live changes to the UI without polling
318+
- [Hub](/guide/hub) — docks, commands, terminals across many tools
385319
- [Agent-Native](/guide/agent-native) — expose your tool to coding agents over MCP

0 commit comments

Comments
 (0)