diff --git a/docs/content/1.guide/1.tutorial.md b/docs/content/1.guide/1.tutorial.md new file mode 100644 index 00000000..b6a7ec76 --- /dev/null +++ b/docs/content/1.guide/1.tutorial.md @@ -0,0 +1,319 @@ +--- +title: 'Tutorial: Build a Data Inspector' +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.' +--- + +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. + +One new idea per step, nothing assumed. You'll need **Node 24+** (so `node` runs TypeScript directly) and a terminal. + +## The shape of a devframe app + +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. + +## 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/devframe.ts] +import { defineDevframe } from 'devframe' + +// 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 }, +} + +// Follow 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((value, key) => { + if (value == null || typeof value !== 'object') + return undefined + return (value as Record)[key] + }, root) +} + +export default 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), + }) + }, +}) +``` + +`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, but any framework works — the only devframe-specific line is `connectDevframe`, which opens the connection home. + +```sh +npm install react react-dom @devframes/vite +npm install -D vite @vitejs/plugin-react @types/react @types/react-dom +``` + +```html [client/index.html] + + + + + Data Inspector + + +
+ + + +``` + +```tsx [client/main.tsx] +import { createRoot } from 'react-dom/client' +import { App } from './App' + +createRoot(document.getElementById('app')!).render() +``` + +```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() + const [meta, setMeta] = useState([]) + const [path, setPath] = useState('config') + const [result, setResult] = useState() + + 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 + setMeta(await call('data-inspector:get-meta')) + }) + }, []) + + async function runQuery() { + if (!rpc) + return + const call = rpc.call as (name: string, ...args: unknown[]) => Promise + setResult(await call('data-inspector:query', path)) + } + + return ( +
+

Data Inspector

+
    + {meta.map(m => ( +
  • + {m.key} + {' — '} + {m.type} + {m.length != null ? ` (${m.length})` : ''} +
  • + ))} +
+ setPath(e.target.value)} placeholder="config.port" /> + +
{JSON.stringify(result, null, 2)}
+
+ ) +} +``` + +`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).) + +## Step 3 — Run it + +The two halves still need to meet. While developing, let Vite serve the UI and hand RPC traffic to devframe: + +```ts [vite.config.ts] +import { devframeViteBridge } from '@devframes/vite/single' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import devframe from './src/devframe.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(devframe, { base: '/', auth: false }), + ], +}) +``` + +```sh +npx vite +``` + +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. + +> [!WARNING] +> `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). + +Everything below reuses this exact `src/devframe.ts` and `client/`. We only change 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. Point the definition at it: + +```ts [src/devframe.ts] +import { fileURLToPath } from 'node:url' +// … +export default 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 +``` + +```ts [hub.config.ts] +import { createUi } from '@devframes/hub-ui' +import { viteDevframeHub } from '@devframes/vite/hub' +import { defineConfig } from 'vite' +import devframe from './src/devframe.ts' + +export default defineConfig({ + plugins: [ + viteDevframeHub({ + devframes: [devframe], + ui: createUi({ branding: { productName: 'My Devtools' } }), + quiet: true, + }), + ], +}) +``` + +```sh +npx vite --config hub.config.ts +``` + +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.) + +## 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 devframe from '../src/devframe.ts' + +await createBuild(devframe, { 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 needs the live server (next) — or 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 devframe from '../src/devframe.ts' + +await createDevServer(devframe, { 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. `createCac` hands you `dev`, `build`, and `mcp` for free: + +```js [bin.mjs] +#!/usr/bin/env node +import { createCac } from 'devframe/adapters/cac' +import devframe from './src/devframe.ts' + +createCac(devframe).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 +``` + +Publish it and the same binary runs with `npx data-inspector`. One definition, five ways to run it — and you never rewrote the tool. + +## 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 diff --git a/docs/content/1.guide/8.diagnostics.md b/docs/content/1.guide/10.diagnostics.md similarity index 100% rename from docs/content/1.guide/8.diagnostics.md rename to docs/content/1.guide/10.diagnostics.md diff --git a/docs/content/1.guide/9.when-clauses.md b/docs/content/1.guide/11.when-clauses.md similarity index 100% rename from docs/content/1.guide/9.when-clauses.md rename to docs/content/1.guide/11.when-clauses.md diff --git a/docs/content/1.guide/10.standalone-cli.md b/docs/content/1.guide/12.standalone-cli.md similarity index 100% rename from docs/content/1.guide/10.standalone-cli.md rename to docs/content/1.guide/12.standalone-cli.md diff --git a/docs/content/1.guide/11.client.md b/docs/content/1.guide/13.client.md similarity index 100% rename from docs/content/1.guide/11.client.md rename to docs/content/1.guide/13.client.md diff --git a/docs/content/1.guide/12.transports.md b/docs/content/1.guide/14.transports.md similarity index 100% rename from docs/content/1.guide/12.transports.md rename to docs/content/1.guide/14.transports.md diff --git a/docs/content/1.guide/13.security.md b/docs/content/1.guide/15.security.md similarity index 100% rename from docs/content/1.guide/13.security.md rename to docs/content/1.guide/15.security.md diff --git a/docs/content/1.guide/14.agent-native.md b/docs/content/1.guide/16.agent-native.md similarity index 100% rename from docs/content/1.guide/14.agent-native.md rename to docs/content/1.guide/16.agent-native.md diff --git a/docs/content/1.guide/15.hub.md b/docs/content/1.guide/17.hub.md similarity index 100% rename from docs/content/1.guide/15.hub.md rename to docs/content/1.guide/17.hub.md diff --git a/docs/content/1.guide/16.client-context.md b/docs/content/1.guide/18.client-context.md similarity index 100% rename from docs/content/1.guide/16.client-context.md rename to docs/content/1.guide/18.client-context.md diff --git a/docs/content/1.guide/17.hub-initiate.md b/docs/content/1.guide/19.hub-initiate.md similarity index 100% rename from docs/content/1.guide/17.hub-initiate.md rename to docs/content/1.guide/19.hub-initiate.md diff --git a/docs/content/1.guide/2.tutorial-a11y.md b/docs/content/1.guide/2.tutorial-a11y.md new file mode 100644 index 00000000..247ef86e --- /dev/null +++ b/docs/content/1.guide/2.tutorial-a11y.md @@ -0,0 +1,317 @@ +--- +title: 'Tutorial: An Inspector That Talks to the Page' +description: 'Build an accessibility inspector whose devtools panel drives code running inside the page it inspects — the two halves talk over a same-origin channel, and findings surface in the hub''s messages feed.' +--- + +Some devtools need to reach *into* the page you're inspecting — highlight an element, read the live DOM, react to clicks. A panel sitting in its own iframe can't do that directly, so devframe lets a plugin run a **client script** right inside the host page. This tutorial builds one: a tiny **accessibility inspector** that finds images with no `alt` text, outlines them in the page on demand, and reports each scan to the hub's messages feed. + +The point isn't the a11y check — it's the wiring. You'll see the two halves of a devtool talk to each other, and how a plugin pushes notifications up to the host. + +You'll need **Node 20+** and a terminal. Every code block is complete. + +## The two halves, and why they can't just call each other + +A docked devtool is really two programs in one browser: + +- the **panel** — your tool's UI, running in an iframe (its own document), +- the **agent** — a script the hub loads into the **host page** itself, next to the app being inspected. + +They live in separate documents, so they can't share variables or reach into each other's DOM. What they *do* share is an **origin** — so they talk over a [`BroadcastChannel`](https://developer.mozilla.org/docs/Web/API/BroadcastChannel), a browser primitive that carries messages between same-origin documents with no server in the middle. The panel asks "highlight finding #2"; the agent, which *can* touch the page, does it. + +Here's the shape: + +``` +┌─ host page ──────────────────┐ ┌─ iframe ────────┐ +│ the app being inspected │ │ panel (your UI)│ +│ + agent (your client script) │◀───▶│ │ +└───────────────────────────────┘ ▲ └─────────────────┘ + │ + BroadcastChannel (same origin) +``` + +Let's build it bottom-up: the shared contract, the agent, the panel, then the host that wires them together. + +## Step 1 — The project and the shared contract + +```sh +mkdir a11y-inspector && cd a11y-inspector +npm init -y && npm pkg set type=module +npm install devframe @devframes/vite @devframes/hub @devframes/hub-ui +npm install -D vite typescript +``` + +Both halves need to agree on what they send each other. Put that contract in one file they both import — a channel name and a couple of message shapes: + +```ts [shared/protocol.ts] +// Same-origin channel the agent and panel talk over. Namespaced to avoid +// clashing with anything else on the page. +export const CHANNEL = 'a11y-inspector' + +/** One image missing its `alt` attribute. */ +export interface Finding { + id: string // a stable handle the agent stamps on the element + html: string // a snippet to show in the panel +} + +// agent → panel: here's the current scan. +export interface FindingsMessage { type: 'findings', findings: Finding[] } + +// panel → agent: do something in the page. +export interface HighlightMessage { type: 'highlight', id: string } +export interface ClearMessage { type: 'clear' } +export interface RescanMessage { type: 'rescan' } +export interface ReadyMessage { type: 'ready' } // "panel's up — send me the findings" + +export type Message + = | FindingsMessage + | HighlightMessage + | ClearMessage + | RescanMessage + | ReadyMessage +``` + +## Step 2 — The agent (code that runs in the page) + +This is the interesting half. The hub loads this module into the **host page**, so it can see and touch the real DOM. It scans for `alt`-less images, answers the panel over the channel, and draws the outline itself. + +It also gets one gift from the hub: a `messages` client, handed in when the hub loads it as a dock's client script. We'll use it in Step 5. + +```ts [agent/agent.ts] +import type { Finding, Message } from '../shared/protocol.ts' +import { CHANNEL } from '../shared/protocol.ts' + +// The hub calls our default export with a context. We only need its `messages` +// client, and only when present — so we duck-type a minimal slice and stay +// free of any hub dependency. (More on messages in Step 5.) +interface AgentContext { + messages?: { warn: (m: string, extra?: { description?: string }) => void, success: (m: string) => void } +} + +const ATTR = 'data-a11y-id' // stamped on each offending so ids survive re-scans + +export default function startAgent(ctx?: AgentContext): void { + const channel = new BroadcastChannel(CHANNEL) + const send = (message: Message) => channel.postMessage(message) + let highlighted: HTMLElement | undefined + + function scan(): Finding[] { + const images = [...document.querySelectorAll('img:not([alt])')] as HTMLElement[] + return images.map((el, i) => { + const id = `img-${i}` + el.setAttribute(ATTR, id) // so a later `highlight` can find this exact element + return { id, html: el.outerHTML.slice(0, 80) } + }) + } + + function report(): void { + const findings = scan() + send({ type: 'findings', findings }) + + // Push the result up to the hub's messages feed (Step 5). + if (findings.length === 0) + ctx?.messages?.success('No images missing alt text') + else + ctx?.messages?.warn(`${findings.length} image(s) missing alt text`, { + description: 'Open the A11y Inspector to highlight them.', + }) + } + + // The panel asks; the agent — which can touch the page — acts. + channel.addEventListener('message', (event: MessageEvent) => { + const message = event.data + if (message.type === 'ready') + report() + if (message.type === 'rescan') + report() + if (message.type === 'clear' && highlighted) + highlighted.style.outline = '' + if (message.type === 'highlight') { + if (highlighted) + highlighted.style.outline = '' + highlighted = document.querySelector(`[${ATTR}="${message.id}"]`) ?? undefined + if (highlighted) + highlighted.style.outline = '3px solid #ff5c7a' + } + }) + + report() // scan once on load +} +``` + +Notice what each side can and can't do: the agent calls `document.querySelector` on the real page and sets `.style.outline`; the panel never touches the host DOM — it only sends messages. That division is the whole idea. + +## Step 3 — The panel (your devtools UI) + +The panel is an ordinary web page that runs in the dock's iframe. It opens the same channel, renders whatever findings arrive, and posts a `highlight` when you hover a row. Plain DOM keeps the channel code in view: + +```html [panel/index.html] + + + A11y Inspector + +

Images missing alt

+ +
    + + + +``` + +```ts [panel/main.ts] +import type { Finding, Message } from '../shared/protocol.ts' +import { CHANNEL } from '../shared/protocol.ts' + +const channel = new BroadcastChannel(CHANNEL) +const send = (message: Message) => channel.postMessage(message) + +const list = document.querySelector('#list')! +const rescan = document.querySelector('#rescan')! + +function render(findings: Finding[]): void { + list.innerHTML = '' + if (findings.length === 0) { + list.innerHTML = '
  • No issues 🎉
  • ' + return + } + for (const f of findings) { + const li = document.createElement('li') + li.textContent = f.html + // Hovering a row asks the agent to outline that element in the page. + li.addEventListener('mouseenter', () => send({ type: 'highlight', id: f.id })) + li.addEventListener('mouseleave', () => send({ type: 'clear' })) + list.append(li) + } +} + +channel.addEventListener('message', (event: MessageEvent) => { + if (event.data.type === 'findings') + render(event.data.findings) +}) + +rescan.addEventListener('click', () => send({ type: 'rescan' })) + +// The agent may have loaded before us — announce ourselves so it replays. +send({ type: 'ready' }) +``` + +Build the panel so the hub can serve it as the dock's iframe: + +```ts [panel/vite.config.ts] +import { defineConfig } from 'vite' + +export default defineConfig({ + root: 'panel', + base: './', // relative URLs, so the panel works under any mount path + build: { outDir: '../dist/panel', emptyOutDir: true }, +}) +``` + +```sh +npx vite build --config panel/vite.config.ts +``` + +## Step 4 — The devframe, and a host to run it in + +Now define the tool. It's a normal `defineDevframe`: an id, and `clientAssets` pointing at the panel we just built. There's no RPC here — the two halves talk over the channel, not the server. + +```ts [src/devframe.ts] +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe' + +export default defineDevframe({ + id: 'a11y-inspector', + name: 'A11y Inspector', + version: '0.0.0', + packageName: 'a11y-inspector', + description: 'Find images missing alt text.', + homepage: 'https://example.com', + importMetaUrl: import.meta.url, + clientAssets: fileURLToPath(new URL('../dist/panel', import.meta.url)), + setup() {}, +}) +``` + +To *see* it, we need a host page — a normal web page with the devtools riding along. A [hub](/guide/hub) provides that: `viteDevframeHub` runs the hub inside Vite, injects the reference UI's floating dock into the page, and — crucially — takes a `clientScripts` map that tells it to load our **agent** into the host page. + +```html [host/index.html] + + + My App + +

    My App

    + + + + + + +``` + +```ts [host/main.ts] +// The app under test would do its own thing here. Nothing devframe-specific. +console.log('app running') +``` + +```ts [host/vite.config.ts] +import { fileURLToPath } from 'node:url' +import { createUi } from '@devframes/hub-ui' +import { viteDevframeHub } from '@devframes/vite/hub' +import { defineConfig } from 'vite' +import devframe from '../src/devframe.ts' + +// Absolute path to our agent module; `/@fs/` lets Vite serve it to the page. +const agent = fileURLToPath(new URL('../agent/agent.ts', import.meta.url)) + +export default defineConfig({ + root: 'host', + plugins: [ + viteDevframeHub({ + devframes: [devframe], + // Load our agent into the host page as this dock's client script. + clientScripts: { 'a11y-inspector': { importFrom: `/@fs/${agent}` } }, + ui: createUi({ branding: { productName: 'A11y Devtools' } }), + quiet: true, + }), + ], +}) +``` + +```sh +npx vite --config host/vite.config.ts +``` + +Open the printed URL. You'll see "My App" with a floating devtools dock. Open the **A11y Inspector** dock: it lists the two `alt`-less images, and hovering a row outlines that image in the page. That hover crossing from the iframe into the page — panel → channel → agent → `outline` — is the whole lesson working. + +> [!TIP] What loaded the agent? +> The reference UI's `embedded.js` (injected by `viteDevframeHub`) boots the hub's client runtime — `createDevframeClientHost()` — in the host page. That runtime publishes the shared client context and **imports each dock's client script into the page**, calling our agent's default export. That's how code from a plugin ends up running next to the app it inspects. See [Client Scripts & Client Context](/guide/client-context). + +## Step 5 — Report findings to the messages feed + +The agent already talks to the panel. But a scan result is also worth surfacing *outside* the panel — as a notification the whole hub can show, even when the dock is closed. That's the **messages feed**: a shared queue every mounted tool can write to, which the hub UI renders as toasts. + +When the hub loads a client script, it hands it a `messages` client scoped to that dock. We wired it up in Step 2 already — that's the `ctx?.messages` calls in `report()`: + +```ts +if (findings.length === 0) + ctx?.messages?.success('No images missing alt text') +else + ctx?.messages?.warn(`${findings.length} image(s) missing alt text`, { + description: 'Open the A11y Inspector to highlight them.', + }) +``` + +`messages` has one shortcut per level — `info`, `warn`, `error`, `success`, `debug` — each a shorthand for `add({ message, level })`. Rerun the host and watch: on load, and on every **Rescan**, a toast reports the count. Nothing else changed — the same agent that drives the panel now also speaks to the hub. + +We duck-typed `messages` in the agent rather than importing a hub type, so the agent stays a plain, dependency-free module that would also run under a bare `