Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions AGENTS.md

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions docs/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default defineAppConfig({
sections: ['adapters', 'frameworks', 'helpers'],
},
{ label: 'Plugins', sections: ['plugins'], link: 'section' as const },
{ label: 'Reference', sections: ['references'], link: 'section' as const },
{ label: 'Errors', sections: ['errors'], link: 'section' as const },
{
label: `v${devframePkg.version}`,
Expand Down Expand Up @@ -87,7 +88,6 @@ export default defineAppConfig({
'/guide/scoped-context',
'/guide/json-render',
'/guide/diagnostics',
'/guide/when-clauses',
],
},
{
Expand All @@ -110,7 +110,6 @@ export default defineAppConfig({
'/guide/hub-initiate',
'/guide/services',
'/guide/deep-linking',
'/guide/events',
],
},
{
Expand Down Expand Up @@ -171,7 +170,7 @@ export default defineAppConfig({
{
category: 'Hub',
items: [
'How do I compose multiple integrations into a hub?',
'How do I compose multiple devframes into a hub?',
'How do I build my own hub UI on top of the hub protocol?',
],
},
Expand Down
36 changes: 18 additions & 18 deletions docs/content/1.guide/1.tutorial-server-data-inspector.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
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.'
description: '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 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.
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+](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
## The shape of a devframe

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

## Step 1 — Define the tool

Expand All @@ -24,8 +24,8 @@ 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.
// 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: [
Expand Down Expand Up @@ -82,11 +82,11 @@ const dataInspectorFrame = defineDevframe({
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.)
`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](/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.
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.

```sh
npm install react react-dom @devframes/vite
Expand Down Expand Up @@ -128,8 +128,8 @@ export function App() {
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.
// 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>
Expand Down Expand Up @@ -194,7 +194,7 @@ export default defineConfig({
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.
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.

> [!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).
Expand All @@ -203,7 +203,7 @@ From here on we reuse this same `src/data-inspector.ts` and `client/` unchanged;

## 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:
A [hub](/guide/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:

```ts [src/data-inspector.ts]
import { fileURLToPath } from 'node:url'
Expand Down Expand Up @@ -243,11 +243,11 @@ export default defineConfig({
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.)
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](/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`:
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`:

```ts
ctx.rpc.register({
Expand All @@ -273,7 +273,7 @@ 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)).
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](/guide/client-assets)).

## Step 6 — Run it standalone

Expand All @@ -295,7 +295,7 @@ Same UI, same live calls, no bundler in the loop — this is what you'd drop int

## 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:
Finally, wrap that dev server in a CLI. `devframe/adapters/cac` turns a devframe into a CLI with `dev`, `build`, and `mcp` commands:

```js [bin.mjs]
#!/usr/bin/env node
Expand All @@ -308,14 +308,14 @@ 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 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 plugin](/plugins/data-inspector) to use or read for reference.
That's it for this tutorial. For a full-featured version, there's a ready-to-use [Data Inspector built-in devframe](/plugins/data-inspector) to use or read for reference.

## What's next

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ defineDevframe({

Call `connectDevframe()` in a Client Component — see [Client](/guide/client) and [`examples/next-runtime-snapshot`](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot).

## Connecting from the client
## Connecting from the browser side

With the Nuxt helper, use `$rpc`:

Expand Down Expand Up @@ -207,7 +207,7 @@ It's the no-args fallback for any deployed `rpc.call('my-tool:get-payload', …)

## On-disk caching

Persistence is the app's job ([`unstorage`](https://unstorage.unjs.io/) recommended); keep cache paths under `node_modules/.cache/<your-devtool-id>/` to rotate with `pnpm install`.
Persistence is your tool's job ([`unstorage`](https://unstorage.unjs.io/) recommended); keep cache paths under `node_modules/.cache/<your-devtool-id>/` to rotate with `pnpm install`.

```ts
import { resolve } from 'pathe'
Expand Down Expand Up @@ -238,7 +238,7 @@ defineDevframe({

## Live-reload on config changes

Filesystem watching is the app's job — wire chokidar, signal the client via shared state.
Filesystem watching is your tool's job — wire chokidar, signal the browser side via shared state.

```ts [src/cli.ts]
defineDevframe({
Expand Down Expand Up @@ -267,7 +267,7 @@ defineDevframe({
})
```

On the client:
On the browser side:

```ts
const my = (await connectDevframe()).scope('my-tool')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
title: 'Client'
description: 'The browser client connects any surface — dock iframe, remote page, standalone SPA — to the Devframe server with type-safe RPC, shared state, and a trust handshake.'
description: 'The RPC client connects any surface — dock iframe, remote page, standalone SPA — to a devframe''s node side with type-safe RPC, shared state, and a trust handshake.'
---

The browser client connects any surface — dock iframe, remote page, standalone SPA — to the Devframe server with type-safe RPC, shared state, and a trust handshake.
The RPC client connects any surface — dock iframe, remote page, standalone SPA — to a devframe's node side with type-safe RPC, shared state, and a trust handshake.

## Connecting

Expand All @@ -25,7 +25,7 @@ One SPA artifact serves at `/`, `/__<id>/`, or any subpath, no rebuild. Build wi

### Sharing a connection with an external viewer

`setupDevframeConnection()` prepares a serializable connection for a cross-origin viewer:
`setupDevframeConnection()` prepares a serializable connection for an external viewer:

```ts
import { setupDevframeConnection } from 'devframe/client'
Expand All @@ -35,17 +35,17 @@ const connection = await setupDevframeConnection({
})
```

In the viewer:
In the external viewer:

```ts
import { connectDevframe } from 'devframe/client'

const rpc = await connectDevframe({ connection })
```

The client retains it as `rpc.connection`; cross-realm viewers read it via `getDevframeConnection()` or `DEVFRAME_CONNECTION_KEY` (`devframe/constants`).
The RPC client retains it as `rpc.connection`; cross-realm viewers read it via `getDevframeConnection()` or `DEVFRAME_CONNECTION_KEY` (`devframe/constants`).

An external viewer registers its origin before the WebSocket opens (needs `viewerOriginToken` in the host's connection metadata; see [External viewer origins](/guide/security#external-viewer-origins)):
An external viewer registers its origin before the WebSocket opens (needs `viewerOriginToken` in the host framework's connection metadata; see [External viewer origins](/guide/security#external-viewer-origins)):

```ts
import { registerDevframeViewerOrigin } from 'devframe/client'
Expand Down Expand Up @@ -77,12 +77,12 @@ Per the `__devframe/__connection.json` backend:

## Trust & auth (WebSocket mode)

`ensureTrusted()` resolves once the server trusts the client's stored token:
`ensureTrusted()` resolves once the node side trusts the RPC client's stored token:

```ts
const rpc = await connectDevframe()

// Blocks until the server trusts this client (default timeout 60s)
// Blocks until the node side trusts this RPC client (default timeout 60s)
const trusted = await rpc.ensureTrusted()

if (!trusted) {
Expand All @@ -100,7 +100,7 @@ The dev server prints a single-use 6-digit code (expires in five minutes, rotate
const ok = await rpc.requestTrustWithCode('047204')
```

A host can embed the code in a link (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` fragment, exchanges it, and strips the URL. Rename it with `otpParam`, or set `otpParam: false` to drive it yourself via `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()`.
A host framework can embed the code in a link (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` fragment, exchanges it, and strips the URL. Rename it with `otpParam`, or set `otpParam: false` to drive it yourself via `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()`.

### Re-using an existing token

Expand All @@ -112,7 +112,7 @@ const ok = await rpc.requestTrustWithToken('a1b2c3…')

### Broadcast-channel sync

`connectDevframe` listens on a shared `BroadcastChannel` (`devframe-auth`) for `auth-update` messages; one tab authenticating trusts every open client.
`connectDevframe` listens on a shared `BroadcastChannel` (`devframe-auth`) for `auth-update` messages; one tab authenticating trusts every open RPC client.


## Calling functions
Expand All @@ -132,11 +132,11 @@ const maybe = await my.rpc.callOptional('get-modules', { limit: 10 })
my.rpc.callEvent('notify', { message: 'hello' })
```

Types flow from the server's `defineRpcFunction` definitions.
Types flow from the node side's `defineRpcFunction` definitions.

## Registering client functions

Register functions the server calls via `rpc.broadcast`:
Register functions the node side calls via `rpc.broadcast`:

```ts
import { defineRpcFunction } from 'devframe'
Expand Down Expand Up @@ -172,18 +172,18 @@ See [Shared State](/guide/shared-state).

## Services

`rpc.services` mirrors the server's wire-service advertisements:
`rpc.services` mirrors the node side's wire-service advertisements:

```ts
if (rpc.services.has('@devframes/service-open'))
await rpc.services.get('@devframes/service-open')!.rpc.call('open-in-editor', { path })
```

See [Cross-Plugin Services](/guide/services#wire-services).
See [Cross-Devframe Services](/guide/services#wire-services).

## Settings

A scoped client exposes a persisted `settings` store, per-user (`global`) or per-workspace (`project`):
A scoped client exposes a persisted `settings` store, per-user (`global`) or per-checkout (`project`):

```ts
await my.settings.project.set('theme', 'dark')
Expand Down Expand Up @@ -213,7 +213,7 @@ Devframe writes a JSON descriptor at `<base>/__connection.json`. The socket shar
}
```

The client resolves it against its origin (`http`→`ws` / `https`→`wss`). The field also accepts a `number` (port on the page's host), a full `ws://`/`wss://` URL, or `{ port }` / `{ host }` for a cross-origin side-car.
The RPC client resolves it against its origin (`http`→`ws` / `https`→`wss`). The field also accepts a `number` (port on the page's host), a full `ws://`/`wss://` URL, or `{ port }` / `{ host }` for a cross-origin side-car server.

For static mode:

Expand All @@ -231,7 +231,7 @@ await connectDevframe({

## Remote docks

Supporting hosts (Vite DevTools; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client)) inject a connection descriptor into the iframe URL that `connectDevframe` auto-detects:
Supporting host frameworks (Vite DevTools; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client)) inject a connection descriptor into the iframe URL that `connectDevframe` auto-detects:

```ts
import { connectDevframe } from 'devframe/client'
Expand All @@ -240,7 +240,7 @@ const rpc = await connectDevframe()
// Already wired to the local dev server via the injected descriptor.
```

The descriptor's session-only, pre-approved token makes `ensureTrusted()` resolve immediately. An external hub builds a viewer URL from a trusted connection with `buildRemoteDevframeUrl()`, keeping the token in the URL fragment:
The descriptor's session-only, pre-approved token makes `ensureTrusted()` resolve immediately. An external hub builds an external-viewer URL from a trusted connection with `buildRemoteDevframeUrl()`, keeping the token in the URL fragment:

```ts
import {
Expand All @@ -261,7 +261,7 @@ Emitted over `rpc.events`:
| `rpc:is-trusted:updated` | Trust granted, denied, or revoked. Carries the new `isTrusted` boolean. |
| `connection:status` | The [connection status](#handling-connection-and-auth-errors) changes. Carries `(status, previous)`. |
| `connection:error` | A connection-level failure — socket error or trust refused. Carries the `Error`. |
| `rpc:error` | An `rpc.call` rejects, from the server or a down connection. Carries `(error, method)`. |
| `rpc:error` | An `rpc.call` rejects, from the node side or a down connection. Carries `(error, method)`. |

```ts
rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
Expand Down Expand Up @@ -295,10 +295,10 @@ A `static` backend has no live socket, so `rpc.status` stays `connected`.
When the socket closes or trust is refused, in-flight and new `rpc.call` promises reject with a `DevframeConnectionError`, its `kind`:

- `'connection'` — the transport is down (`disconnected` / `error`).
- `'auth'` — the client is `unauthorized`.
- `'auth'` — the RPC client is `unauthorized`.
- `'timeout'` — the call outlived `callTimeout`.

Set `callTimeout` to cap an unresponsive server:
Set `callTimeout` to cap an unresponsive node side:

```ts
const rpc = await connectDevframe({ callTimeout: 10_000 })
Expand Down Expand Up @@ -343,13 +343,13 @@ async function loadModules() {

### Recovering

The client doesn't reconnect on its own — reload or re-run your connect routine:
The RPC client doesn't reconnect on its own — reload or re-run your connect routine:

```ts
async function reconnect() {
rpc = await connectDevframe() // a new client; re-subscribe your listeners
rpc = await connectDevframe() // a new RPC client; re-subscribe your listeners
render()
}
```

In a hub, a viewer reads this status from [`context.connection`](/guide/client-context#the-client-context).
In a hub, a hub UI provider reads this status from [`context.connection`](/guide/client-context#the-client-context).
Loading
Loading