devframe 0.9.3 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/skills/devframe/SKILL.md
CHANGED
|
@@ -1,63 +1,85 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: devframe
|
|
3
3
|
description: >
|
|
4
|
-
Use when building a devtool with devframe - the
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
`
|
|
4
|
+
Use when building a devtool with devframe - the framework- and
|
|
5
|
+
build-tool-agnostic foundation for defining a devtool once and
|
|
6
|
+
serving it anywhere. Covers the DevframeDefinition, the standard
|
|
7
|
+
web handler (`initDevframe` → `handler` / `nodeMiddleware`) that
|
|
8
|
+
mounts a tool into any host, the packaging adapters (cli / build /
|
|
9
|
+
dev / mcp / embedded), the framework packages (`@devframes/vite`,
|
|
10
|
+
`@devframes/next`, `@devframes/nuxt`, each split into `single` and
|
|
11
|
+
`hub`), composing many tools into one devtools host with
|
|
12
|
+
`@devframes/hub` (`initHub`, docks / commands / messages /
|
|
13
|
+
terminals), designing RPC contracts, exposing an agent-native
|
|
14
|
+
surface over MCP, and wiring the author's SPA client. Triggers on
|
|
15
|
+
`devframe` imports, `defineDevframe`, `initDevframe`, `initHub`,
|
|
16
|
+
`createCac`, `createMcpServer`, `connectDevframe`, `@devframes/*`
|
|
17
|
+
imports, and on migrations of existing inspectors
|
|
16
18
|
(eslint-config-inspector, unocss-inspector,
|
|
17
|
-
node-modules-inspector-style tools)
|
|
19
|
+
node-modules-inspector-style tools) onto devframe.
|
|
18
20
|
---
|
|
19
21
|
|
|
20
22
|
# devframe skill
|
|
21
23
|
|
|
22
|
-
**Devframe is
|
|
24
|
+
**Devframe is the `unplugin` for devtools: define a tool once, mount it anywhere.** A devtool built on devframe is a single `DevframeDefinition` plus an author-provided SPA. That definition describes one tool - its RPC surface, shared state, diagnostics, web interface, and agent-facing surface - independent of how it is presented. The same definition then deploys through a standard web handler, a set of packaging adapters, thin framework integrations, or composed with other tools inside a hub.
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
Two layers, one boundary:
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
- **A devframe** is one portable tool. `initDevframe(def, { base })` turns it into a live instance whose `.handler` is a Web-Standard `(request: Request) => Promise<Response>` carrying the whole surface (SPA, discovery, WebSocket RPC, auth gate, optional MCP route) under one mount base. Anything that can mount a catch-all route or Connect-style middleware can host it.
|
|
29
|
+
- **A hub** (`@devframes/hub`) composes *many* devframes behind one namespace with a shared RPC registry, one transport, one auth gate, and the orchestration features that only make sense when tools share a UI (docks, commands, messages, terminals). `initHub()` puts the whole collection behind the same kind of standard handler.
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
Devframe is framework- and build-tool-agnostic - it has zero dependency on Vite or any `@vitejs/*` package and makes no UI-framework assumption. [Vite DevTools](https://devtools.vite.dev/) is the first flagship *host* built on it; the built-in plugins deliberately span Vue, Svelte, Solid, React, and Next to prove the point.
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
High-level concept: [Pluggable, Extensible, and Playful DevTools](https://antfu.me/posts/pluggable-extensible-playful-devtools). Full reference: [devfra.me](https://devfra.me/).
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|-------------|---------|-------|
|
|
34
|
-
| Standalone CLI for local use | `createCac(def, options?)` | `devframe/adapters/cac` |
|
|
35
|
-
| Run the dev server programmatically (any CLI framework) | `createDevServer(def, options?)` | `devframe/adapters/dev` |
|
|
36
|
-
| Self-contained static deploy with baked data | `createBuild(def, options?)` | `devframe/adapters/build` |
|
|
37
|
-
| Mount into a host (Vite DevTools or any compatible host) | `createPluginFromDevframe(def, options?)` | `@vitejs/devtools-kit/node` |
|
|
38
|
-
| Register dynamically at runtime | `createEmbedded(def, { ctx })` | `devframe/adapters/embedded` |
|
|
39
|
-
| Expose to coding agents (MCP) | `createMcpServer(def, options?)` | `devframe/adapters/mcp` |
|
|
35
|
+
## Deployment map — pick by how it's served, not by what it does
|
|
40
36
|
|
|
41
|
-
The same `DevframeDefinition` runs under every
|
|
37
|
+
The same `DevframeDefinition` runs under every one of these. Choose based on where the tool needs to live.
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
**Serve one devframe:**
|
|
40
|
+
|
|
41
|
+
| Goal | Entry | Import |
|
|
42
|
+
|------|-------|--------|
|
|
43
|
+
| Mount into any host (the portability primitive) | `initDevframe(def, { base })` → `.handler` / `.nodeMiddleware` | `devframe/initiate` |
|
|
44
|
+
| Standalone CLI (dev / build / mcp subcommands) | `createCac(def, opts?).parse()` | `devframe/adapters/cac` |
|
|
45
|
+
| Programmatic dev server | `createDevServer(def, opts?)` | `devframe/adapters/dev` |
|
|
46
|
+
| Self-contained static deploy with baked data | `createBuild(def, opts?)` | `devframe/adapters/build` |
|
|
47
|
+
| MCP server for coding agents | `createMcpServer(def, opts?)` | `devframe/adapters/mcp` |
|
|
48
|
+
| Runtime registration into an existing host | `createEmbedded(def, { ctx })` | `devframe/adapters/embedded` |
|
|
49
|
+
| Ride along a Vite dev server (no dock) | `devframeVitePlugin` / `devframeViteBridge` / `devframeVite` | `@devframes/vite/single` |
|
|
50
|
+
| Author one devframe's SPA with Next | `withDevframe` + `createDevframeNextHandler` | `@devframes/next/single` |
|
|
51
|
+
| Author one devframe's SPA with Nuxt | Nuxt module | `@devframes/nuxt/single` |
|
|
52
|
+
| Mount into the Vite DevTools dock | `createPluginFromDevframe(def, opts?)` | `@vitejs/devtools-kit/node` |
|
|
53
|
+
|
|
54
|
+
**Serve many devframes as a devtools host:**
|
|
55
|
+
|
|
56
|
+
| Goal | Entry | Import |
|
|
57
|
+
|------|-------|--------|
|
|
58
|
+
| Compose a hub behind one handler | `initHub({ base, devframes, ui })` | `@devframes/hub/initiate` |
|
|
59
|
+
| Imperatively mount into a hub context | `createHubContext(...)` → `ctx.install(def)` | `@devframes/hub/node` |
|
|
60
|
+
| Reference viewer UI for a hub | `createUi(opts?)` | `@devframes/hub-ui` |
|
|
61
|
+
| Mount a hub inside Vite / Next / Nuxt | `viteDevframeHub` / `nextDevframeHub` / hub module | `@devframes/{vite,next,nuxt}/hub` |
|
|
62
|
+
|
|
63
|
+
`createCac`, `createDevServer`, the `@devframes/vite` bridge, and `@devframes/next` are all assembled from `initDevframe` internally - the standard handler is the one wiring underneath every serving path.
|
|
44
64
|
|
|
45
65
|
## Minimum viable devframe
|
|
46
66
|
|
|
47
67
|
```ts
|
|
48
68
|
import { defineDevframe, defineRpcFunction } from 'devframe'
|
|
69
|
+
import pkg from '../package.json' with { type: 'json' }
|
|
49
70
|
|
|
50
71
|
export default defineDevframe({
|
|
51
72
|
id: 'my-inspector',
|
|
52
|
-
name: 'My Inspector',
|
|
53
|
-
version:
|
|
54
|
-
packageName:
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
name: 'My Inspector', // display label — distinct from packageName
|
|
74
|
+
version: pkg.version,
|
|
75
|
+
packageName: pkg.name,
|
|
76
|
+
importMetaUrl: import.meta.url, // resolution base for the tool's own deps (assets, services)
|
|
77
|
+
homepage: pkg.homepage,
|
|
78
|
+
description: pkg.description,
|
|
57
79
|
icon: 'ph:magnifying-glass-duotone',
|
|
58
80
|
cli: { distDir: './client/dist' },
|
|
59
81
|
setup(ctx) {
|
|
60
|
-
const my = ctx.scope('my-inspector') // preferred
|
|
82
|
+
const my = ctx.scope('my-inspector') // preferred — auto-namespaces ids
|
|
61
83
|
my.rpc.register(defineRpcFunction({
|
|
62
84
|
name: 'get-stats', // stored as `my-inspector:get-stats`
|
|
63
85
|
type: 'static',
|
|
@@ -67,65 +89,89 @@ export default defineDevframe({
|
|
|
67
89
|
})
|
|
68
90
|
```
|
|
69
91
|
|
|
70
|
-
|
|
92
|
+
Source `version` / `packageName` / `homepage` / `description` from your published `package.json` (the JSON import-attribute form resolves under both bundlers and Node's native TypeScript execution). Always pass `importMetaUrl: import.meta.url` - it is the base the host resolves the tool's own companion packages against (a `--assets` package holding the built SPA, a wire-service package), so a plugin ships them as its own dependencies and users install nothing extra.
|
|
93
|
+
|
|
94
|
+
`setup(ctx, info?)` runs in **every** runtime and does all devframe-level wiring: RPC functions, shared state, streaming channels, diagnostics, agent surface. Its optional second argument carries runtime metadata (most notably parsed CLI `flags` under `createCac`). Gate per-runtime work on `ctx.mode` (`'dev'` | `'build'`).
|
|
95
|
+
|
|
96
|
+
**A plugin's default export is its `create<X>Devframe` factory, never a pre-built instance** - `export default createMyInspectorDevframe`, so importing the module costs nothing and each consumer calls the factory (with or without options) to get its own instance.
|
|
97
|
+
|
|
98
|
+
See `templates/counter-devframe.ts` for a runnable example, `templates/hub.ts` for composing a hub, and `templates/vite-client.ts` for the author's client entry.
|
|
99
|
+
|
|
100
|
+
## The standard handler (`initDevframe`)
|
|
101
|
+
|
|
102
|
+
This is the portability trick and the thing to reach for whenever a host can mount a route. `base` is required, so the mount path is explicit at the call site.
|
|
71
103
|
|
|
72
104
|
```ts
|
|
73
|
-
import
|
|
105
|
+
import { initDevframe } from 'devframe/initiate'
|
|
106
|
+
import myDevframe from './devframe'
|
|
74
107
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/' })
|
|
109
|
+
// devtools.base, .handler, .nodeMiddleware, .attach, .handleUpgrade,
|
|
110
|
+
// .ready, .context, .connectionMeta(), .close()
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Mount `.handler` (Web-Standard) or `.nodeMiddleware` (Connect-style) on a catch-all route:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// Hono — `serve()` returns the node server the socket rides on
|
|
117
|
+
app.all('/__my-tool/*', c => devtools.handler(c.req.raw))
|
|
118
|
+
devtools.attach(serve({ fetch: app.fetch, port: 3000 }))
|
|
119
|
+
|
|
120
|
+
// Vite — connect middleware + Vite's own server for the socket
|
|
121
|
+
server.middlewares.use(initDevframe(myDevframe, {
|
|
122
|
+
base: '/__my-tool/',
|
|
123
|
+
server: server.httpServer ?? undefined,
|
|
124
|
+
}).nodeMiddleware)
|
|
84
125
|
```
|
|
85
126
|
|
|
86
|
-
`
|
|
127
|
+
`devtools.base` is the normalized mount base - reference it in route guards instead of repeating the string.
|
|
128
|
+
|
|
129
|
+
**The WebSocket binding** is the host's explicit call. Fetch handlers only hand over `Request`s, so the RPC socket needs its own binding, resolved in precedence order:
|
|
130
|
+
|
|
131
|
+
1. `ws.port` — a side-car on that exact port.
|
|
132
|
+
2. `server` — share the host's `node:http` server; the upgrade binds at `<base>__ws`. Zero extra ports, follows the app through proxies/HTTPS.
|
|
133
|
+
3. `ws: { sidecar: true }` — a side-car on a free port, for hosts whose handlers never see upgrades (Next.js route handlers, Nitro, SvelteKit, Rsbuild).
|
|
134
|
+
4. **The host's own upgrades** — with none of the above, `devtools.attach(server)` routes a server's `upgrade` events (returns a detach fn) and `devtools.handleUpgrade(req, socket, head)` completes a single one. Built lazily — an instance nobody attaches costs nothing.
|
|
135
|
+
|
|
136
|
+
`ws.url` instead controls the *advertisement* (the tunnel/external-transport pattern). Whichever is active, `__connection.json` describes it and the browser client follows.
|
|
87
137
|
|
|
88
|
-
|
|
138
|
+
Frameworks that re-evaluate modules in dev (Next, Nitro, SvelteKit) must memoize the instance on `globalThis`, or every reload leaks the previous WebSocket server. The framework packages do this for you.
|
|
139
|
+
|
|
140
|
+
**Auth gates by default** - a handler mounted inside an app server is reachable by anything that can open its socket. The interactive OTP handler is wired automatically and prints its code / magic-link once the public origin is known. Pass `auth: false` only for a single-user localhost setup, or a `DevframeAuthHandler` for a custom scheme.
|
|
89
141
|
|
|
90
142
|
## Scoped context (preferred)
|
|
91
143
|
|
|
92
|
-
`ctx.scope(id)` (server) and `client.scope(id)` (browser) return a namespace-scoped view that auto-prefixes every RPC id, shared-state key, and streaming channel with `id:`, and adds a top-level persisted `settings` store. Prefer it over
|
|
144
|
+
`ctx.scope(id)` (server) and `client.scope(id)` (browser) return a namespace-scoped view that auto-prefixes every RPC id, shared-state key, and streaming channel with `id:`, and adds a top-level persisted `settings` store. Prefer it over raw `ctx.rpc` / client - name the namespace once, register and call by bare name.
|
|
93
145
|
|
|
94
146
|
```ts
|
|
95
|
-
// server
|
|
147
|
+
// server — setup(ctx)
|
|
96
148
|
const my = ctx.scope('my-inspector')
|
|
97
|
-
my.rpc.register(getStats) //
|
|
98
|
-
await my.rpc.call('get-stats') //
|
|
149
|
+
my.rpc.register(getStats) // -> my-inspector:get-stats
|
|
150
|
+
await my.rpc.call('get-stats') // invokeLocal, namespaced
|
|
99
151
|
const state = await my.rpc.sharedState('view') // -> my-inspector:view
|
|
100
152
|
await my.settings.project.set('theme', 'dark')
|
|
101
153
|
|
|
102
|
-
// browser
|
|
154
|
+
// browser — connectDevframe()
|
|
103
155
|
const my = (await connectDevframe()).scope('my-inspector')
|
|
104
156
|
const stats = await my.rpc.call('get-stats')
|
|
105
157
|
```
|
|
106
158
|
|
|
107
|
-
- **Auto-namespacing.** Bare names get `id:` prepended; a name already containing `:` is treated as fully-qualified and passed through (so `my.rpc.call('other-tool:fn')` works). `register` only accepts bare names -
|
|
108
|
-
- **Typed bare calls.** Define functions with bare names and augment the registry with `RpcDefinitionsToFunctionsWithNamespace<'my-inspector', typeof serverFunctions>` so
|
|
109
|
-
- **`base`.** The scoped context keeps the raw context as `my.base` (and re-exposes `views` / `diagnostics` / `agent` / `host` / `cwd` / `mode` on the server).
|
|
159
|
+
- **Auto-namespacing.** Bare names get `id:` prepended; a name already containing `:` is treated as fully-qualified and passed through (so `my.rpc.call('other-tool:fn')` works). `register` only accepts bare names - a namespaced one throws `DF0034`.
|
|
160
|
+
- **Typed bare calls.** Define functions with bare names and augment the registry with `RpcDefinitionsToFunctionsWithNamespace<'my-inspector', typeof serverFunctions>` so registry keys match the namespaced runtime ids; scoped `call('get-stats')` then stays typed.
|
|
161
|
+
- **`base`.** The scoped context keeps the raw context as `my.base` (and re-exposes `views` / `diagnostics` / `agent` / `services` / `host` / `cwd` / `mode` on the server).
|
|
110
162
|
|
|
111
163
|
### Settings
|
|
112
164
|
|
|
113
|
-
`my.settings` is a persisted key-value store at the **top level** of the scoped context (a sibling of `my.rpc
|
|
114
|
-
|
|
115
|
-
- `project` - per-workspace, persisted under the host's `workspace` storage dir.
|
|
116
|
-
- `global` - per-user, persisted under the host's `global` storage dir.
|
|
117
|
-
|
|
118
|
-
Both are file-backed on the server and synced to clients over the shared-state protocol, so a `set` on either side propagates everywhere and survives restarts. All methods are async.
|
|
165
|
+
`my.settings` is a persisted key-value store at the **top level** of the scoped context (a sibling of `my.rpc`). Two scopes: `project` (per-workspace) and `global` (per-user). Both are file-backed on the server and synced to clients over the shared-state protocol, so a `set` on either side propagates everywhere and survives restarts. All methods are async.
|
|
119
166
|
|
|
120
167
|
```ts
|
|
121
168
|
await my.settings.project.set('theme', 'dark')
|
|
122
169
|
await my.settings.project.get('theme') // 'dark'
|
|
123
170
|
await my.settings.global.all()
|
|
124
|
-
await my.settings.project.delete('theme')
|
|
125
171
|
const off = await my.settings.global.onChange(value => apply(value))
|
|
126
172
|
```
|
|
127
173
|
|
|
128
|
-
Type a namespace's settings
|
|
174
|
+
Type a namespace's settings by augmenting `DevframeSettingsRegistry`:
|
|
129
175
|
|
|
130
176
|
```ts
|
|
131
177
|
declare module 'devframe' {
|
|
@@ -135,9 +181,37 @@ declare module 'devframe' {
|
|
|
135
181
|
}
|
|
136
182
|
```
|
|
137
183
|
|
|
184
|
+
## DevframeNodeContext at a glance
|
|
185
|
+
|
|
186
|
+
`setup(ctx)` receives the framework-neutral server-side surface:
|
|
187
|
+
|
|
188
|
+
| Host | Purpose |
|
|
189
|
+
|------|---------|
|
|
190
|
+
| `ctx.scope(id)` | **Preferred** namespace-scoped view — auto-prefixed `rpc` + top-level `settings` store |
|
|
191
|
+
| `ctx.rpc` | Register RPC functions, broadcast, shared state, streaming channels |
|
|
192
|
+
| `ctx.views` | Serve static files via `hostStatic(base, distDir)` |
|
|
193
|
+
| `ctx.diagnostics` | Structured diagnostics host (nostics) — register custom error codes |
|
|
194
|
+
| `ctx.agent` | Expose tools + resources to coding agents |
|
|
195
|
+
| `ctx.services` | Typed cross-plugin service registry (`provide` / `whenAvailable`) |
|
|
196
|
+
| `ctx.staticConfig` | This context's own `ConnectionMeta.configs` — boot-time, read-only-from-browser data |
|
|
197
|
+
| `ctx.host` | Runtime abstraction — `mountStatic`, `resolveOrigin`, `getStorageDir` |
|
|
198
|
+
| `ctx.mode` | `'dev'` or `'build'` — gate setup work per runtime |
|
|
199
|
+
|
|
200
|
+
> Hub adapters augment `ctx` with extra surfaces (`docks`, `terminals`, `messages`, `commands`) — see [The Hub](#the-hub). The Vite DevTools kit exposes the same subsystems via an optional `setup` hook.
|
|
201
|
+
|
|
202
|
+
**Storage scopes** — `ctx.host.getStorageDir(scope)` places persisted state in one of three classes:
|
|
203
|
+
|
|
204
|
+
| Scope | Placement | For |
|
|
205
|
+
|-------|-----------|-----|
|
|
206
|
+
| `workspace` | committable, `<workspaceRoot>/.devframe/` | team-shared presets, shared config |
|
|
207
|
+
| `project` | per-checkout, `<cwd>/node_modules/.<app>/devframe/` | caches, personal settings |
|
|
208
|
+
| `global` | per-user, `~/.<app>/devframe/` | auth tokens, machine-wide prefs |
|
|
209
|
+
|
|
210
|
+
Scoped settings persist their `project` scope through `project` storage and their `global` scope through `global`.
|
|
211
|
+
|
|
138
212
|
## Project layout
|
|
139
213
|
|
|
140
|
-
Once a devframe grows past a
|
|
214
|
+
Once a devframe grows past a couple of RPC functions, split them out - one file per function under `src/rpc/functions/`, with `src/rpc/index.ts` as the barrel that collects them into `const serverFunctions = [...] as const` and feeds the type-safe client registry via `RpcDefinitionsToFunctionsWithNamespace<'my-tool', typeof serverFunctions>`.
|
|
141
215
|
|
|
142
216
|
```ts
|
|
143
217
|
// src/rpc/functions/list-files.ts
|
|
@@ -145,7 +219,7 @@ import { defineRpcFunction } from 'devframe'
|
|
|
145
219
|
import { getMyToolContext } from '../../context'
|
|
146
220
|
|
|
147
221
|
export const listFiles = defineRpcFunction({
|
|
148
|
-
name: 'list-files', // bare
|
|
222
|
+
name: 'list-files', // bare — the scope namespaces it to `my-tool:list-files`
|
|
149
223
|
type: 'query',
|
|
150
224
|
jsonSerializable: true,
|
|
151
225
|
setup: (ctx) => {
|
|
@@ -180,6 +254,7 @@ export default defineDevframe({
|
|
|
180
254
|
name: 'My Tool',
|
|
181
255
|
version: pkg.version,
|
|
182
256
|
packageName: pkg.name,
|
|
257
|
+
importMetaUrl: import.meta.url,
|
|
183
258
|
homepage: pkg.homepage,
|
|
184
259
|
description: pkg.description,
|
|
185
260
|
setup(ctx) {
|
|
@@ -190,21 +265,14 @@ export default defineDevframe({
|
|
|
190
265
|
})
|
|
191
266
|
```
|
|
192
267
|
|
|
193
|
-
Note `setMyToolContext(ctx, …)` keys off the raw `ctx` (the same object the function `setup(ctx)` receives) - store the per-tool context on `ctx`, register through `my.rpc`.
|
|
194
|
-
|
|
195
268
|
### Sharing setup-time state via `src/context.ts`
|
|
196
269
|
|
|
197
|
-
When per-file RPCs need
|
|
270
|
+
When per-file RPCs need runtime values `setup(ctx)` constructs once - channels, shared-state handles, watchers, loaders, caches - expose them through a `WeakMap<DevframeNodeContext, T>` in a sibling `src/context.ts`. The WeakMap keys off the existing `DevframeNodeContext` so contexts are GC'd automatically when the host tears down.
|
|
198
271
|
|
|
199
272
|
```ts
|
|
200
273
|
// src/context.ts
|
|
201
274
|
import type { DevframeNodeContext } from 'devframe'
|
|
202
275
|
|
|
203
|
-
export interface MyToolContext {
|
|
204
|
-
loaders: { list: () => Promise<string[]> }
|
|
205
|
-
// …channels, shared state handles, watchers, etc.
|
|
206
|
-
}
|
|
207
|
-
|
|
208
276
|
const map = new WeakMap<DevframeNodeContext, MyToolContext>()
|
|
209
277
|
|
|
210
278
|
export function setMyToolContext(ctx: DevframeNodeContext, value: MyToolContext): void {
|
|
@@ -214,49 +282,34 @@ export function setMyToolContext(ctx: DevframeNodeContext, value: MyToolContext)
|
|
|
214
282
|
export function getMyToolContext(ctx: DevframeNodeContext): MyToolContext {
|
|
215
283
|
const value = map.get(ctx)
|
|
216
284
|
if (!value)
|
|
217
|
-
throw new Error('my-tool context not initialised
|
|
285
|
+
throw new Error('my-tool context not initialised — call setMyToolContext in devframe.setup')
|
|
218
286
|
return value
|
|
219
287
|
}
|
|
220
288
|
```
|
|
221
289
|
|
|
222
|
-
|
|
290
|
+
Note `setMyToolContext(ctx, …)` keys off the raw `ctx` (the same object `setup(ctx)` receives), while registration goes through `my.rpc`. Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)`.
|
|
223
291
|
|
|
224
292
|
## Namespacing
|
|
225
293
|
|
|
226
|
-
**Always prefix** RPC names, dock IDs, command IDs, shared-state keys, and agent tool IDs with the devframe `id
|
|
294
|
+
**Always prefix** RPC names, dock IDs, command IDs, shared-state keys, and agent tool IDs with the devframe `id` - a hub may mount many tools side by side.
|
|
227
295
|
|
|
228
296
|
```ts
|
|
229
297
|
'my-inspector:get-modules' // ✓
|
|
230
|
-
'
|
|
231
|
-
'get-modules' // ✗ - may collide with other devframes sharing the host
|
|
298
|
+
'get-modules' // ✗ — may collide with other devframes sharing the host
|
|
232
299
|
```
|
|
233
300
|
|
|
234
|
-
A [scoped context](#scoped-context-preferred) applies this prefix for
|
|
235
|
-
|
|
236
|
-
## DevframeNodeContext at a glance
|
|
237
|
-
|
|
238
|
-
`setup(ctx)` receives the framework-neutral server-side surface. Each host corresponds to a [docs](https://devfra.me/) page:
|
|
239
|
-
|
|
240
|
-
| Host | Purpose |
|
|
241
|
-
|------|---------|
|
|
242
|
-
| `ctx.scope(id)` | **Preferred** namespace-scoped view - auto-prefixed `rpc` + top-level `settings` store |
|
|
243
|
-
| `ctx.rpc` | Register RPC functions, broadcast, shared state, streaming channels |
|
|
244
|
-
| `ctx.views` | Serve static files via `hostStatic(base, distDir)` |
|
|
245
|
-
| `ctx.diagnostics` | Structured diagnostics host (nostics) - register custom error codes |
|
|
246
|
-
| `ctx.agent` | Expose tools + resources to coding agents |
|
|
247
|
-
| `ctx.host` | Runtime abstraction - `mountStatic`, `resolveOrigin`, `getStorageDir` |
|
|
248
|
-
| `ctx.mode` | `'dev'` or `'build'` - gate setup work per runtime |
|
|
249
|
-
|
|
250
|
-
> Hosts can augment `ctx` with additional surfaces (e.g. Vite DevTools' `docks`, `terminals`, `messages`, `commands`). Consult the host's docs - for Vite DevTools, see the [`vite-devtools-kit` skill](../../skills/vite-devtools-kit).
|
|
301
|
+
A [scoped context](#scoped-context-preferred) applies this prefix for RPC / shared-state / streaming. Dock and command IDs are host-level (not part of the scoped `rpc` surface) - prefix those by hand.
|
|
251
302
|
|
|
252
303
|
## RPC contracts
|
|
253
304
|
|
|
305
|
+
Built on [birpc](https://github.com/antfu/birpc), validated at runtime against any [Standard Schema](https://standardschema.dev/) validator (valibot, zod, arktype, …). Devframe forces no validator - install whichever you prefer. First-party `@devframes/*` code stays validator-neutral and uses the built-in zero-dep `devframe/utils/simple-schema` builder; for your own tool, valibot is the lightest default, or reuse zod if you already ship it.
|
|
306
|
+
|
|
254
307
|
```ts
|
|
255
308
|
import { defineRpcFunction } from 'devframe'
|
|
256
309
|
import * as v from 'valibot'
|
|
257
310
|
|
|
258
311
|
const getModules = defineRpcFunction({
|
|
259
|
-
name: 'get-modules', // bare
|
|
312
|
+
name: 'get-modules', // bare — registered via `ctx.scope('my-inspector').rpc.register`
|
|
260
313
|
type: 'query',
|
|
261
314
|
jsonSerializable: true,
|
|
262
315
|
args: [v.object({ limit: v.number() })],
|
|
@@ -269,27 +322,23 @@ const getModules = defineRpcFunction({
|
|
|
269
322
|
|
|
270
323
|
| Type | Use when | Cached | Static dump |
|
|
271
324
|
|------|----------|--------|-------------|
|
|
272
|
-
| `'static'` | Data constant for a given input
|
|
325
|
+
| `'static'` | Data constant for a given input — dump at build time | Indefinitely | Automatic |
|
|
273
326
|
| `'query'` | Read that may change; optional `dump` for build adapters | Opt-in via `cacheable` | Manual |
|
|
274
327
|
| `'action'` | Server-state mutation | Never | Never |
|
|
275
328
|
| `'event'` | Fire-and-forget; no response | Never | Never |
|
|
276
329
|
|
|
277
|
-
|
|
330
|
+
Declared `args` / `returns` schemas are **enforced at runtime** - a failing call is rejected with `DF0043` / `DF0044`. Prefer a **single object arg** (`args: [v.object({ ... })]`) over positional args - property names self-document and agents rely on them.
|
|
278
331
|
|
|
279
332
|
### `jsonSerializable` (wire + dump format)
|
|
280
333
|
|
|
281
|
-
`jsonSerializable` declares the on-wire / on-disk shape contract:
|
|
282
|
-
|
|
283
334
|
| Value | Encoder | Wire prefix | Round-trips |
|
|
284
335
|
|-------|---------|-------------|-------------|
|
|
285
336
|
| `false` (default) | `structured-clone-es` | `s:` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances |
|
|
286
337
|
| `true` (opt-in) | strict `JSON.stringify` | _(unprefixed)_ | JSON-only |
|
|
287
338
|
|
|
288
|
-
Set `jsonSerializable: true` when your handler returns plain JSON
|
|
339
|
+
Set `jsonSerializable: true` when your handler returns plain JSON - the strict serializer **throws `DF0020`** synchronously on the offending call when a value can't round-trip through JSON, surfacing next to the call in dev. `agent: {...}` requires `jsonSerializable: true` (registration throws `DF0019` otherwise) - MCP tools speak JSON.
|
|
289
340
|
|
|
290
|
-
`
|
|
291
|
-
|
|
292
|
-
Through the scope, `my.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client (method name namespaced), and `my.rpc.call(name, ...args)` invokes a server function locally without going through transport (the scoped form of `ctx.rpc.invokeLocal`, useful for cross-function composition).
|
|
341
|
+
Through the scope, `my.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client (method name namespaced), and `my.rpc.call(name, ...args)` invokes a server function locally without transport (the scoped form of `ctx.rpc.invokeLocal`, for cross-function composition).
|
|
293
342
|
|
|
294
343
|
## Shared state
|
|
295
344
|
|
|
@@ -305,13 +354,13 @@ state.mutate((draft) => {
|
|
|
305
354
|
})
|
|
306
355
|
```
|
|
307
356
|
|
|
308
|
-
- Values must be serializable
|
|
357
|
+
- Values must be serializable — no functions, no circular refs.
|
|
309
358
|
- Mutations round-trip to all clients; the host tracks `syncIds` to avoid replay loops.
|
|
310
359
|
- Prefer shared state over ad-hoc RPC events for UI that must reappear after reconnect.
|
|
311
360
|
|
|
312
361
|
## Streaming channels
|
|
313
362
|
|
|
314
|
-
For chunk-style data
|
|
363
|
+
For chunk-style data in either direction - LLM deltas, log tails, build progress, uploads - use a streaming channel instead of inventing `action + delta/end` events.
|
|
315
364
|
|
|
316
365
|
```ts
|
|
317
366
|
const my = ctx.scope('my-inspector')
|
|
@@ -319,194 +368,183 @@ const channel = my.rpc.streaming.create<string>('tokens', { // -> my-inspector:t
|
|
|
319
368
|
replayWindow: 256, // server keeps last N chunks per stream id
|
|
320
369
|
closedStreamRetention: 30_000, // ms to hold finished streams for late subscribers
|
|
321
370
|
})
|
|
322
|
-
```
|
|
323
371
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
```ts
|
|
327
|
-
// Server - typically inside an action handler that returns the stream id
|
|
372
|
+
// Server — typically inside an action handler that returns the stream id
|
|
328
373
|
const stream = channel.start({ id: 'optional-stream-id' })
|
|
329
374
|
stream.write(token) // imperative
|
|
330
|
-
stream.error(err)
|
|
331
|
-
stream.
|
|
332
|
-
|
|
333
|
-
stream.writable // WritableStream<T> for `pipeTo`-style consumption
|
|
375
|
+
stream.close() // terminal success; stream.error(err) for terminal failure
|
|
376
|
+
stream.signal // AbortSignal — flips when consumers cancel or all subscribers drop
|
|
377
|
+
await channel.pipeFrom(sourceReadable) // start + pipe in one call
|
|
334
378
|
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
// Client - my = (await connectDevframe()).scope('my-inspector')
|
|
339
|
-
const reader = my.rpc.streaming.subscribe<string>('tokens', streamId) // -> my-inspector:tokens
|
|
379
|
+
// Client — my = (await connectDevframe()).scope('my-inspector')
|
|
380
|
+
const reader = my.rpc.streaming.subscribe<string>('tokens', streamId)
|
|
340
381
|
for await (const token of reader) renderToken(token)
|
|
341
|
-
// Or: reader.readable.pipeTo(domWritable)
|
|
342
382
|
reader.cancel() // server `stream.signal` aborts
|
|
343
383
|
```
|
|
344
384
|
|
|
345
|
-
|
|
385
|
+
The same channel exposes `openInbound()` for the server side of a client→server upload; pair it with an action that returns the id, and the client drives `my.rpc.streaming.upload<T>('files', uploadId)`. Web Streams are the canonical surface (Node 17+ ships `Readable.fromWeb` / `Writable.fromWeb` converters). Producers should poll `stream.signal.aborted` and exit cooperatively.
|
|
386
|
+
|
|
387
|
+
**Streaming vs events vs shared state:** streaming for token/chunk feeds, uploads, per-call lifecycles with cancellation, and replay-on-reconnect; `event`-typed RPC for payload-free notifications and fire-and-forget signals; shared state for long-lived UI that survives reconnect. For chat UIs, keep the conversation log in shared state and stream active responses - working example: [`examples/streaming-chat`](https://github.com/devframes/devframe/tree/main/examples/streaming-chat).
|
|
346
388
|
|
|
347
|
-
|
|
389
|
+
## Agent-native surface
|
|
390
|
+
|
|
391
|
+
Once a tool has a structured boundary, its visual panel is no longer the only interface: the same internal state and capabilities are consumable programmatically by coding agents, sharing one source of truth. RPC functions stay **private by default** and explicitly opt into agent exposure with an `agent` field. Agent-exposed functions must declare `jsonSerializable: true`.
|
|
348
392
|
|
|
349
393
|
```ts
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
args: [v.object({
|
|
355
|
-
returns: v.object({
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
for await (const chunk of reader) saveChunk(chunk)
|
|
360
|
-
})()
|
|
361
|
-
return { uploadId: reader.id }
|
|
394
|
+
defineRpcFunction({
|
|
395
|
+
name: 'get-stats',
|
|
396
|
+
type: 'query',
|
|
397
|
+
jsonSerializable: true,
|
|
398
|
+
args: [v.object({ limit: v.number() })],
|
|
399
|
+
returns: v.object({ count: v.number() }),
|
|
400
|
+
agent: {
|
|
401
|
+
description: 'Return the top-N module stats. Safe to call freely.',
|
|
402
|
+
// safety inferred from type: 'query' → 'read'
|
|
362
403
|
},
|
|
363
|
-
}))
|
|
364
|
-
|
|
365
|
-
// Client - my = (await connectDevframe()).scope('my-inspector')
|
|
366
|
-
const { uploadId } = await my.rpc.call('upload-file', { name: 'foo' })
|
|
367
|
-
const upload = my.rpc.streaming.upload<Uint8Array>('files', uploadId) // -> my-inspector:files
|
|
368
|
-
fileReadable.pipeTo(upload.writable, { signal: upload.signal })
|
|
404
|
+
setup: () => ({ handler: async ({ limit }) => ({ count: limit }) }),
|
|
405
|
+
})
|
|
369
406
|
```
|
|
370
407
|
|
|
371
|
-
|
|
408
|
+
Or register tools / resources directly on `ctx.agent.registerTool({ id, description, safety, handler })` and `ctx.agent.registerResource({ id, name, mimeType, read })`. Expose the surface over MCP:
|
|
372
409
|
|
|
373
|
-
|
|
410
|
+
```ts
|
|
411
|
+
import { createMcpServer } from 'devframe/adapters/mcp'
|
|
374
412
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
| `stream.close()` / `stream.error(err)` | broadcasts `end` to subscribers | `for await` resolves / throws |
|
|
378
|
-
| `reader.cancel()` (last subscriber) | `stream.signal` aborts | reader marked cancelled |
|
|
379
|
-
| WS disconnects (last subscriber drops) | `stream.signal` aborts | reader auto-resubscribes after re-trust |
|
|
413
|
+
await createMcpServer(devframe, { transport: 'stdio' })
|
|
414
|
+
```
|
|
380
415
|
|
|
381
|
-
|
|
416
|
+
`@modelcontextprotocol/server` is a peer dependency. The CLI adapter also exposes `my-devframe mcp` (route host logs to stderr - stdout is the transport). Safety classifications (`'read' | 'action' | 'destructive'`) drive MCP hint annotations that agent clients use to prompt for confirmation. In a hub, `ctx.commands` entries opt into the same agent surface with an `agent` field and reach MCP through the aggregate endpoint.
|
|
382
417
|
|
|
383
|
-
|
|
418
|
+
## Author SPA
|
|
384
419
|
|
|
385
|
-
|
|
420
|
+
Authors bring their own SPA (any framework or plain HTML). The client code is **byte-identical** whether the tool runs standalone, embedded, or inside a hub - that is the portability promise.
|
|
386
421
|
|
|
387
422
|
```ts
|
|
388
|
-
import {
|
|
423
|
+
import { connectDevframe } from 'devframe/client'
|
|
389
424
|
|
|
390
|
-
|
|
391
|
-
|
|
425
|
+
const client = await connectDevframe()
|
|
426
|
+
const my = client.scope('my-inspector') // preferred — namespaced calls
|
|
427
|
+
const data = await my.rpc.call('get-stats', { limit: 10 })
|
|
392
428
|
```
|
|
393
429
|
|
|
394
|
-
|
|
430
|
+
`connectDevframe` auto-detects the backend via `./__connection.json`, resolved relative to the executing script's runtime base (so the SPA never hardcodes its mount path - build with `vite.base: './'`):
|
|
431
|
+
|
|
432
|
+
- **websocket** (dev mode) — full read/write, requires the auth handshake. `await client.ensureTrusted()` blocks until the server accepts; listen for token updates on the `devframe-auth` BroadcastChannel.
|
|
433
|
+
- **static** (build output) — read-only, resolves calls from the baked RPC dump.
|
|
395
434
|
|
|
396
|
-
|
|
397
|
-
|-------------------|---------------------------|----------------------|
|
|
398
|
-
| Token / chunk feeds, uploads | Notifications without payload (`refresh`) | Long-lived UI state that survives reconnect |
|
|
399
|
-
| Per-call lifecycles with cancellation | Cross-cutting fire-and-forget signals | Diff-based sync between clients |
|
|
400
|
-
| Replay on reconnect | | |
|
|
435
|
+
Use `my.rpc.sharedState(key)` for observable state, `my.rpc.register(...)` to receive server broadcasts, `my.rpc.callOptional(...)` when a missing handler should resolve to `undefined`, and `my.settings.{project,global}` for persisted settings synced from the server.
|
|
401
436
|
|
|
402
|
-
|
|
437
|
+
## The Hub
|
|
403
438
|
|
|
404
|
-
|
|
439
|
+
A single devframe is one portable tool; a hub is where many tools **meet and collaborate**. `@devframes/hub` is the framework-neutral composition layer. It adds the orchestration subsystems that only make sense when tools share a UI, and it ships no UI of its own - a viewer product fills the `ui` slot.
|
|
405
440
|
|
|
406
|
-
|
|
441
|
+
`initHub()` puts the whole collection behind one standard handler with the same surface and mount snippets as `initDevframe`:
|
|
407
442
|
|
|
408
443
|
```ts
|
|
409
|
-
|
|
410
|
-
import {
|
|
411
|
-
import
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}),
|
|
425
|
-
],
|
|
426
|
-
}
|
|
444
|
+
import { createUi } from '@devframes/hub-ui'
|
|
445
|
+
import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'
|
|
446
|
+
import createInspectDevframe from '@devframes/plugin-inspect'
|
|
447
|
+
import createTerminalsDevframe from '@devframes/plugin-terminals'
|
|
448
|
+
|
|
449
|
+
export const hub = initHub({
|
|
450
|
+
base: DEVFRAMES_HUB_BASE, // required — the conventional `/__devframes/`
|
|
451
|
+
devframes: [createInspectDevframe(), createTerminalsDevframe()],
|
|
452
|
+
ui: createUi(), // reference viewer + floating dock; `ui: false` for headless
|
|
453
|
+
configure(ctx) {
|
|
454
|
+
ctx.commands.register({ id: 'app:hello', title: 'Hello', handler: () => 'hi' })
|
|
455
|
+
},
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
hub.handler // the whole devtools ecosystem as Request -> Response
|
|
427
459
|
```
|
|
428
460
|
|
|
429
|
-
|
|
461
|
+
Every mounted devframe runs its `setup()` against **one shared hub context**: a merged RPC registry (frames can call each other's functions), one shared-state store, one WebSocket transport, and one auth gate. Frame ids become URL segments (`<base><id>/`) and are validated (reserved → `DF8000`, non-route-safe → `DF8004`).
|
|
430
462
|
|
|
431
|
-
|
|
463
|
+
### Hub subsystems
|
|
432
464
|
|
|
433
|
-
|
|
465
|
+
A hub-aware `DevframeHubContext` extends `DevframeNodeContext` with four subsystems:
|
|
434
466
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
467
|
+
| Subsystem | Surface | Purpose |
|
|
468
|
+
|-----------|---------|---------|
|
|
469
|
+
| `ctx.docks` | `register / update / values / activate` | Dock entries (iframe, launcher, custom-render, group, and opt-in types) and cross-iframe activation. |
|
|
470
|
+
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. |
|
|
471
|
+
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped 1000). |
|
|
472
|
+
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |
|
|
440
473
|
|
|
441
|
-
|
|
474
|
+
The dock union is **open** - opt-in integrations contribute their own entry types (e.g. JSON-Render adds a `json-render` dock type with no JSON-render dependency in the hub). `ctx.docks.activate(dockId, params?)` steers which dock the viewer shows; from a mounted iframe, `rpc.call('hub:docks:activate', { dockId, params })` does the same cross-iframe. A `type: 'launcher'` dock binds a command, streams a `digest` line, and jumps to its terminal session - the pattern that lets an analyzer spawn `vite build` and navigate the user to its output.
|
|
442
475
|
|
|
443
|
-
|
|
476
|
+
### Mounting into a hub
|
|
444
477
|
|
|
445
|
-
|
|
478
|
+
`ctx.install(def)` is the framework-neutral primitive - it registers any `DevframeDefinition` as a dock and runs its `setup(ctx)` - and the imperative counterpart to `initHub`'s declarative `devframes` list:
|
|
446
479
|
|
|
447
480
|
```ts
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
args: [v.object({ limit: v.number() })],
|
|
453
|
-
returns: v.object({ count: v.number() }),
|
|
454
|
-
agent: {
|
|
455
|
-
description: 'Return the top-N module stats. Safe to call freely.',
|
|
456
|
-
// safety inferred from type: 'query' → 'read'
|
|
457
|
-
},
|
|
458
|
-
setup: () => ({ handler: async ({ limit }) => ({ count: limit }) }),
|
|
459
|
-
})
|
|
481
|
+
import { createHubContext } from '@devframes/hub/node'
|
|
482
|
+
|
|
483
|
+
const ctx = await createHubContext({ cwd, host, mode: 'dev' })
|
|
484
|
+
await ctx.install(myDevframe)
|
|
460
485
|
```
|
|
461
486
|
|
|
462
|
-
|
|
487
|
+
When a devframe sharing an already-mounted `id` is installed, its `duplicationStrategy` (`'warn'` default / `'silent'` / `'throw'` / `'duplicate'`) decides the outcome.
|
|
463
488
|
|
|
464
|
-
|
|
465
|
-
ctx.agent.registerTool({
|
|
466
|
-
id: 'my-inspector:summarize',
|
|
467
|
-
description: 'Plain-text summary of the current scan.',
|
|
468
|
-
safety: 'read',
|
|
469
|
-
handler: async () => ({ markdown: buildSummary() }),
|
|
470
|
-
})
|
|
489
|
+
### The protocol — what a viewer sees
|
|
471
490
|
|
|
472
|
-
|
|
473
|
-
id: 'current-scan',
|
|
474
|
-
name: 'Current scan',
|
|
475
|
-
mimeType: 'text/markdown',
|
|
476
|
-
read: () => ({ text: renderMarkdown(currentScan) }),
|
|
477
|
-
})
|
|
478
|
-
```
|
|
491
|
+
A hub-aware UI imports no hub classes; it reads shared-state keys and one RPC method:
|
|
479
492
|
|
|
480
|
-
|
|
493
|
+
| Channel | Type | Carries |
|
|
494
|
+
|---------|------|---------|
|
|
495
|
+
| `devframe:docks` (shared state) | `DevframeDockEntry[]` | Every registered dock entry. |
|
|
496
|
+
| `devframe:commands` (shared state) | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). |
|
|
497
|
+
| `devframe:docks:active` (shared state) | `DevframeDocksActiveState` | Most recent dock-activation request. |
|
|
498
|
+
| `hub:commands:execute` (RPC) | `(id, ...args) => unknown` | Server-side command dispatch. |
|
|
499
|
+
| `hub:docks:activate` (RPC) | `({ dockId, params? }) => void` | Switch the active dock from any client. |
|
|
481
500
|
|
|
482
|
-
|
|
483
|
-
import { createMcpServer } from 'devframe/adapters/mcp'
|
|
501
|
+
Plus broadcasts (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`). The hub also ships a headless browser runtime, `createDevframeClientHost()` from `@devframes/hub/client`: booted in the host page, it assembles the shared client context from this protocol and imports each dock entry's client script into that page - how a plugin like the a11y inspector runs code inside the page being inspected.
|
|
484
502
|
|
|
485
|
-
|
|
486
|
-
```
|
|
503
|
+
### The `ui` slot
|
|
487
504
|
|
|
488
|
-
|
|
505
|
+
The hub is headless; `DevframeHubUi` is pure data (`viewer` / `embedded` / `assets` / `setup`). `@devframes/hub-ui`'s `createUi()` is the reference implementation - a standalone viewer plus a floating dock injected via one `<script type="module" src="/__devframes/embedded.js">` tag. It takes `branding`, `dockPreferences`, and `embeddedVisibility` (`'normal'` / `'passive'` / `'hidden'`). A viewer product supplies a different object to the same slot and reuses all the infrastructure. Renderer modules for opt-in dock types compose at the hub via `initHub({ renderers })`.
|
|
489
506
|
|
|
490
|
-
|
|
507
|
+
Two copyable reference hubs mount every built-in plugin behind an icon dock - the shape Vite DevTools wears, shrunk to the smallest thing you can build your own viewer from: [`examples/hub-vite/`](https://github.com/devframes/devframe/tree/main/examples/hub-vite) (~120-line Vite plugin host, vanilla DOM UI) and [`examples/hub-next/`](https://github.com/devframes/devframe/tree/main/examples/hub-next) (Next.js App Router). The `hub-*-minimal` family shows the default `createUi()` mount across Vite, Next, Nitro, Hono, Fastify, SvelteKit, Deno, and Rsbuild.
|
|
491
508
|
|
|
492
|
-
|
|
509
|
+
## Framework packages: two scopes
|
|
493
510
|
|
|
494
|
-
|
|
495
|
-
import { connectDevframe } from 'devframe/client'
|
|
511
|
+
`@devframes/vite`, `@devframes/next`, and `@devframes/nuxt` each split into two clearly-scoped subpaths; the bare root import throws with a pointer to both.
|
|
496
512
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
513
|
+
- **`.../single`** — build & dev-serve one devframe's SPA with that tool.
|
|
514
|
+
- Vite: `devframeVitePlugin` (static mount) / `devframeViteBridge` (RPC bridge) / `devframeVite` (wrapper), from `@devframes/vite/single`.
|
|
515
|
+
- Next: `withDevframe` (config) + `createDevframeNextHandler` (route handler), plus a React client at `@devframes/next/single/client` (`RpcProvider`, `useRpc`, `useRpcStatus`).
|
|
516
|
+
- Nuxt: `modules: ['@devframes/nuxt/single']`.
|
|
517
|
+
- **`.../hub`** — mount a whole `@devframes/hub` inside that tool. Wraps `initHub`, defaults the UI to `createUi()`, ships a browser client helper at `.../hub/client`.
|
|
518
|
+
- `viteDevframeHub()` (shares Vite's server, injects the dock), `nextDevframeHub()` (side-car socket, App Router route), the Nuxt hub module.
|
|
519
|
+
- `@devframes/hub` and `@devframes/hub-ui` are optional peers of these packages.
|
|
500
520
|
|
|
501
|
-
|
|
521
|
+
Vite and Nuxt already have native hub viewers ([Vite DevTools](https://devtools.vite.dev/), [Nuxt DevTools](https://devtools.nuxt.com/)), so `@devframes/vite/hub` and `@devframes/nuxt/hub` print a one-time recommendation to prefer those (silence with `{ quiet: true }`); `@devframes/next/hub` has no native counterpart and stays quiet.
|
|
522
|
+
|
|
523
|
+
For Vite DevTools specifically, `createPluginFromDevframe(def, opts?)` from `@vitejs/devtools-kit/node` adapts a definition into the kit's plugin interface (`{ name, devtools: { setup, capabilities } }`), auto-deriving an iframe dock entry from `id` / `name` / `icon` / `basePath`. Pass `options.setup` for richer host-side behaviour (custom-render docks, terminals, palette commands) on the kit-augmented context. The factory lives in the kit, not devframe, so devframe stays free of any `@vitejs/*` dependency.
|
|
524
|
+
|
|
525
|
+
## When clauses
|
|
526
|
+
|
|
527
|
+
Gate host-side dock / command visibility with VS Code-style expressions. The runtime + types ship bundled from `devframe/utils/when` - no separate install.
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
when: 'clientType == embedded'
|
|
531
|
+
when: 'dockOpen && !paletteOpen'
|
|
532
|
+
when: 'my-inspector.ready && count >= 10'
|
|
502
533
|
```
|
|
503
534
|
|
|
504
|
-
`
|
|
535
|
+
Built-in context: `clientType` (`'embedded' | 'standalone'`), `dockOpen`, `paletteOpen`, `dockSelectedId`. Plugins add namespaced keys (`.` or `:` separators). `when` clauses evaluate **client-side only** and are not enforced for agent calls - only gate a command with `when` if running it outside its UI context is safe.
|
|
505
536
|
|
|
506
|
-
|
|
507
|
-
- **static** (build output) - read-only, resolves calls from the baked RPC dump.
|
|
537
|
+
## CLI adapter subcommands
|
|
508
538
|
|
|
509
|
-
|
|
539
|
+
`createCac(devframe).parse()` gives three subcommands out of the box:
|
|
540
|
+
|
|
541
|
+
| Subcommand | Action |
|
|
542
|
+
|------------|--------|
|
|
543
|
+
| *(default)* | Dev server (port 9999 or `--port`) — WebSocket RPC, `cli.distDir` served at the base |
|
|
544
|
+
| `build` | Static snapshot → `./dist-static/` (`--out-dir`) |
|
|
545
|
+
| `mcp` | stdio MCP server |
|
|
546
|
+
|
|
547
|
+
**Bring your own CLI framework?** `createCac` is a thin cac wrapper around three peer factories - `createDevServer` (`devframe/adapters/dev`), `createBuild` (`devframe/adapters/build`), `createMcpServer` (`devframe/adapters/mcp`). Use them directly with commander/yargs/oclif. `cac` is an optional peer pulled in only through `devframe/adapters/cac`. `createDevServer` returns a `StartedServer` handle (`origin`, `port`, `app`, `ws?`, `close()`) for SIGINT / hot-reload teardown. `parseCliFlags(schema, raw)` and `defineCliFlags(...)` validate an arbitrary flag bag.
|
|
510
548
|
|
|
511
549
|
## Build dumps
|
|
512
550
|
|
|
@@ -514,7 +552,7 @@ Use `my.rpc.sharedState(key)` for observable state, `my.rpc.register(defineRpcFu
|
|
|
514
552
|
|
|
515
553
|
```ts
|
|
516
554
|
defineRpcFunction({
|
|
517
|
-
name: '
|
|
555
|
+
name: 'get-session',
|
|
518
556
|
type: 'query',
|
|
519
557
|
setup: () => ({
|
|
520
558
|
handler: async (id: string) => loadSession(id),
|
|
@@ -526,19 +564,7 @@ defineRpcFunction({
|
|
|
526
564
|
})
|
|
527
565
|
```
|
|
528
566
|
|
|
529
|
-
At runtime, static clients look up the argument hash in the dump; misses resolve to `fallback` (or throw if absent).
|
|
530
|
-
|
|
531
|
-
## CLI adapter subcommands
|
|
532
|
-
|
|
533
|
-
`createCac(devframe).parse()` gives the tool three subcommands out of the box:
|
|
534
|
-
|
|
535
|
-
| Subcommand | Action |
|
|
536
|
-
|------------|--------|
|
|
537
|
-
| *(default)* | Dev server on port 9999 (or `--port`) - WebSocket RPC, `cli.distDir` served at `/.devframe/` |
|
|
538
|
-
| `build` | Static snapshot → `./dist-static/` (configurable via `--out-dir`) |
|
|
539
|
-
| `mcp` | stdio MCP server |
|
|
540
|
-
|
|
541
|
-
**Bring your own CLI framework?** `createCac` (`devframe/adapters/cac`) is just a cac wrapper around three peer factories - `createDevServer` (`devframe/adapters/dev`), `createBuild` (`devframe/adapters/build`), and `createMcpServer` (`devframe/adapters/mcp`). Use them directly with commander/yargs/oclif when `createCac`'s baked-in command structure doesn't fit. `cac` is an optional peer dependency pulled in only through `devframe/adapters/cac`, so bring-your-own-CLI tools run without installing it. `createDevServer` returns a `StartedServer` handle (`origin`, `port`, `app`, `wss`, `close()`) so you can wire SIGINT / hot-reload teardown into the surrounding program. `parseCliFlags(schema, raw)` and `defineCliFlags(...)` (both from `devframe/adapters/cac`) validate an arbitrary flag bag against a `CliFlagsSchema` - the helpers are framework-agnostic.
|
|
567
|
+
At runtime, static clients look up the argument hash in the dump; misses resolve to `fallback` (or throw if absent). To bake an RPC this devframe doesn't own (e.g. a wire service's), declare it under the definition's `rpc.snapshot`.
|
|
542
568
|
|
|
543
569
|
## Bundled utilities
|
|
544
570
|
|
|
@@ -546,32 +572,31 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are
|
|
|
546
572
|
|
|
547
573
|
| Import | Wraps | Use for |
|
|
548
574
|
|--------|-------|---------|
|
|
549
|
-
| `colors` from `devframe/utils/colors` | `ansis` | Terminal ANSI colors
|
|
550
|
-
| `open` from `devframe/utils/open` | `open` | Open URLs / files in the OS
|
|
551
|
-
| `launchEditor` from `devframe/utils/launch-editor` | `launch-editor` | Open `file:line:column` in the user's editor
|
|
552
|
-
| `hash` from `devframe/utils/hash` | `ohash` | Stable structural hash
|
|
553
|
-
| `structuredClone{Serialize,Deserialize,Stringify,Parse}` from `devframe/utils/structured-clone` | `structured-clone-es` | JSON-safe round-trip of `Map
|
|
575
|
+
| `colors` from `devframe/utils/colors` | `ansis` | Terminal ANSI colors |
|
|
576
|
+
| `open` from `devframe/utils/open` | `open` | Open URLs / files in the OS handler |
|
|
577
|
+
| `launchEditor` from `devframe/utils/launch-editor` | `launch-editor` | Open `file:line:column` in the user's editor |
|
|
578
|
+
| `hash` from `devframe/utils/hash` | `ohash` | Stable structural hash — cache keys, dedup |
|
|
579
|
+
| `structuredClone{Serialize,Deserialize,Stringify,Parse}` from `devframe/utils/structured-clone` | `structured-clone-es` | JSON-safe round-trip of `Map`/`Set`/`Date`/`BigInt`/cycles |
|
|
554
580
|
| `nanoid` from `devframe/utils/nanoid` | (vendored) | URL-safe random IDs |
|
|
555
581
|
| `randomToken` / `randomDigits` / `timingSafeEqual` from `devframe/utils/crypto-token` | (native WebCrypto) | CSPRNG bearer tokens, one-time codes, constant-time compare |
|
|
556
|
-
| `createEventEmitter` from `devframe/utils/events` |
|
|
557
|
-
| `createSharedState` from `devframe/utils/shared-state` | (immer internal) | Immutable state container
|
|
558
|
-
| `
|
|
582
|
+
| `createEventEmitter` from `devframe/utils/events` | — | Typed event bus |
|
|
583
|
+
| `createSharedState` from `devframe/utils/shared-state` | (immer internal) | Immutable state container |
|
|
584
|
+
| `s` from `devframe/utils/simple-schema` | — | Zero-dep Standard Schema builder (`s.object`, `s.string`, …) for validator-neutral first-party code |
|
|
559
585
|
| `evaluateWhen` / `WhenExpression` from `devframe/utils/when` | `whenexpr` | When-clause expressions |
|
|
560
586
|
|
|
561
|
-
For "open file in editor" + "reveal in finder", prefer the `@devframes/service-open` wire service (declare `services: [{ package: '@devframes/service-open' }]` on the definition, gate client UI on `rpc.services.has(...)`) - one host-level installation shared by every plugin
|
|
587
|
+
For "open file in editor" + "reveal in finder", prefer the `@devframes/service-open` wire service (declare `services: [{ package: '@devframes/service-open' }]` on the definition, gate client UI on `rpc.services.has(...)`) - one host-level installation shared by every plugin.
|
|
562
588
|
|
|
563
589
|
## Security (secure by default)
|
|
564
590
|
|
|
565
|
-
RPC handlers run with the full privileges of the host process, so the boundary that matters is who may connect.
|
|
591
|
+
RPC handlers run with the full privileges of the host process, so the boundary that matters is who may connect.
|
|
566
592
|
|
|
567
|
-
- **`auth` defaults to `true`**
|
|
568
|
-
- **`auth: false` trusts every reachable connection.**
|
|
569
|
-
- **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token
|
|
570
|
-
- **Magic-link (optional):**
|
|
571
|
-
- **Tokens are secrets.**
|
|
572
|
-
- **Authorize handlers.** Any trusted client can call any registered function - validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first.
|
|
573
|
-
- **
|
|
574
|
-
- **The MCP route requires an Origin.** The route-based MCP server (`cli.mcp`, `devframeViteBridge`/Next handler `mcp`, `createMcpFetchHandler`) rejects `Origin`-less requests - a request must carry a loopback (or allow-listed) `Origin`, so it isn't reachable by an arbitrary local process. `devframe connect` sends each instance's own loopback origin automatically.
|
|
593
|
+
- **`auth` defaults to `true`** — dev-mode connections must authenticate before calls are accepted. In a **hub**, one gate at the one shared transport covers every frame, the hub built-ins, and the MCP route; mounted frames have no gates of their own.
|
|
594
|
+
- **`auth: false` trusts every reachable connection.** Only for single-user `localhost` tools - never with a non-loopback bind host, a tunnel, or a shared/CI environment.
|
|
595
|
+
- **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token. Single-use, expires in 5 min, constant-time compared, rotates after repeated failures.
|
|
596
|
+
- **Magic-link (optional):** the code rides the URL **fragment** (`#devframe_otp=<code>`), never sent to the server; `connectDevframe` reads, exchanges, and strips it. Only the single-use code ever rides a URL, never the bearer.
|
|
597
|
+
- **Tokens are secrets.** Serve over `wss://` / `https://` beyond loopback. Never log or bake them into build output. Revoke via `revokeAuthToken(...)`.
|
|
598
|
+
- **Authorize handlers.** Any trusted client can call any registered function - validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. `when` clauses are UI-only and not enforced for agent calls.
|
|
599
|
+
- **The MCP route requires an Origin** — the route-based MCP server rejects `Origin`-less requests, so it isn't reachable by an arbitrary local process.
|
|
575
600
|
|
|
576
601
|
See [Security](https://devfra.me/security) for the full reference.
|
|
577
602
|
|
|
@@ -579,29 +604,25 @@ See [Security](https://devfra.me/security) for the full reference.
|
|
|
579
604
|
|
|
580
605
|
- Unit-test host classes with fake contexts.
|
|
581
606
|
- Run `templates/counter-devframe.ts` under each adapter for integration coverage.
|
|
582
|
-
- Snapshot the build-static RPC dump (`<outDir>/.devframe/.rpc-dump/index.json`) to catch
|
|
607
|
+
- Snapshot the build-static RPC dump (`<outDir>/.devframe/.rpc-dump/index.json`) to catch drift in `static` function outputs.
|
|
583
608
|
|
|
584
609
|
## Further reading
|
|
585
610
|
|
|
586
|
-
Devframe-level
|
|
587
|
-
|
|
588
|
-
- [Devframe Definition](https://devfra.me/devframe-definition)
|
|
589
|
-
- [
|
|
590
|
-
- [
|
|
591
|
-
- [
|
|
592
|
-
- [
|
|
593
|
-
- [Streaming](https://devfra.me/streaming)
|
|
594
|
-
- [
|
|
595
|
-
- [
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
- [
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
- [Vite DevTools
|
|
604
|
-
- [Dock System](https://devtools.vite.dev/kit/dock-system) - every entry type + remote docks
|
|
605
|
-
- [Commands](https://devtools.vite.dev/kit/commands) - palette, keybindings, sub-commands
|
|
606
|
-
- [Messages & Notifications](https://devtools.vite.dev/kit/messages) - entry fields, positional hints
|
|
607
|
-
- [Terminals](https://devtools.vite.dev/kit/terminals) - child processes, external sessions
|
|
611
|
+
Devframe-level (one portable tool):
|
|
612
|
+
|
|
613
|
+
- [Devframe Definition](https://devfra.me/guide/devframe-definition) — fields, `importMetaUrl`, runtime flags
|
|
614
|
+
- [Initiate (standard handler)](https://devfra.me/adapters/initiate) — `initDevframe`, mounting, the WebSocket binding
|
|
615
|
+
- [Scoped Context](https://devfra.me/guide/scoped-context) — `ctx.scope(id)`, auto-namespacing, `settings`
|
|
616
|
+
- [Adapters](https://devfra.me/adapters/) — cli / dev / build / mcp / embedded
|
|
617
|
+
- [RPC](https://devfra.me/guide/rpc) — types, Standard Schema, broadcasts, dumps
|
|
618
|
+
- [Shared State](https://devfra.me/guide/shared-state) · [Streaming](https://devfra.me/guide/streaming) · [When Clauses](https://devfra.me/guide/when-clauses)
|
|
619
|
+
- [Diagnostics](https://devfra.me/guide/diagnostics) · [Services](https://devfra.me/guide/services) · [Client](https://devfra.me/guide/client)
|
|
620
|
+
- [Security](https://devfra.me/guide/security) · [Agent-Native](https://devfra.me/guide/agent-native)
|
|
621
|
+
|
|
622
|
+
Hub & frameworks (composing many tools):
|
|
623
|
+
|
|
624
|
+
- [Hub](https://devfra.me/guide/hub) — subsystems, docks, protocol, `ctx.install`
|
|
625
|
+
- [Serve a Hub Anywhere](https://devfra.me/guide/hub-initiate) — `initHub`, the `ui` slot, one auth
|
|
626
|
+
- [Client Scripts & Client Context](https://devfra.me/guide/client-context) — `createDevframeClientHost`, dock-script contract
|
|
627
|
+
- [Frameworks](https://devfra.me/frameworks/) — [Vite](https://devfra.me/frameworks/vite) · [Next](https://devfra.me/frameworks/next) · [Nuxt](https://devfra.me/frameworks/nuxt)
|
|
628
|
+
- [Vite DevTools adapter](https://devfra.me/adapters/vite) — `createPluginFromDevframe`
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// Smallest possible devframe. The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Smallest possible devframe. The same definition serves anywhere:
|
|
2
|
+
// - standard handler: initDevframe(counter, { base: '/__counter/' }).handler
|
|
3
|
+
// - standalone CLI: createCac(counter).parse()
|
|
4
|
+
// - composed in a hub: initHub({ devframes: [counter], ... })
|
|
5
|
+
// A host derives its dock entry from `id` / `name` / `icon` automatically.
|
|
4
6
|
import { defineDevframe, defineRpcFunction } from 'devframe'
|
|
5
7
|
// Recommended: source version/packageName/homepage/description from your
|
|
6
8
|
// package.json so the published metadata stays in sync. The import-attribute
|
|
@@ -14,6 +16,9 @@ export default defineDevframe({
|
|
|
14
16
|
name: 'Counter',
|
|
15
17
|
version: pkg.version,
|
|
16
18
|
packageName: pkg.name,
|
|
19
|
+
// Always pass import.meta.url — the base the host resolves the tool's own
|
|
20
|
+
// companion packages (assets, services) against.
|
|
21
|
+
importMetaUrl: import.meta.url,
|
|
17
22
|
homepage: pkg.homepage,
|
|
18
23
|
description: pkg.description,
|
|
19
24
|
icon: 'ph:counter-duotone',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Compose many devframes into one devtools host behind a single standard
|
|
2
|
+
// handler. Every mounted devframe runs against ONE shared hub context:
|
|
3
|
+
// a merged RPC registry (frames can call each other), one shared-state
|
|
4
|
+
// store, one WebSocket transport, and one auth gate.
|
|
5
|
+
//
|
|
6
|
+
// `hub.handler` is a Web-Standard `(request: Request) => Promise<Response>`;
|
|
7
|
+
// mount it on a catch-all route (Hono, Nitro, Next, SvelteKit, ...) exactly
|
|
8
|
+
// like an `initDevframe` instance. `hub.nodeMiddleware` is the Connect-style
|
|
9
|
+
// form for Vite/Rsbuild. See docs/adapters/initiate.md for the mount snippets
|
|
10
|
+
// and the WebSocket-binding precedence (`ws.port` / `server` / `ws.sidecar` /
|
|
11
|
+
// host-attached `hub.attach(server)`).
|
|
12
|
+
import { createUi } from '@devframes/hub-ui'
|
|
13
|
+
import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'
|
|
14
|
+
|
|
15
|
+
// A plugin's default export is its `create<X>Devframe` factory — call it to
|
|
16
|
+
// get an instance (optionally with options). Swap these for your own.
|
|
17
|
+
import createInspectDevframe from '@devframes/plugin-inspect'
|
|
18
|
+
import createTerminalsDevframe from '@devframes/plugin-terminals'
|
|
19
|
+
|
|
20
|
+
export const hub = initHub({
|
|
21
|
+
// Required — the conventional `/__devframes/`. The instance echoes the
|
|
22
|
+
// normalized value back as `hub.base`; reference that, not the literal.
|
|
23
|
+
base: DEVFRAMES_HUB_BASE,
|
|
24
|
+
|
|
25
|
+
devframes: [
|
|
26
|
+
createInspectDevframe(),
|
|
27
|
+
createTerminalsDevframe(),
|
|
28
|
+
],
|
|
29
|
+
|
|
30
|
+
// The reference viewer + a floating dock injected via one
|
|
31
|
+
// `<script type="module" src="/__devframes/embedded.js">` tag.
|
|
32
|
+
// Pass `ui: false` for a headless hub you drive with your own UI over the
|
|
33
|
+
// shared-state protocol (see @devframes/hub/client).
|
|
34
|
+
ui: createUi(),
|
|
35
|
+
|
|
36
|
+
// Runs against the shared hub context — register cross-tool commands,
|
|
37
|
+
// docks, message handlers here.
|
|
38
|
+
configure(ctx) {
|
|
39
|
+
ctx.commands.register({
|
|
40
|
+
id: 'app:hello',
|
|
41
|
+
title: 'Hello',
|
|
42
|
+
handler: () => 'hi',
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// hub.handler — the whole devtools ecosystem as Request -> Response
|
|
48
|
+
// hub.nodeMiddleware — Connect-style middleware
|
|
49
|
+
// hub.attach(server) — hand the hub a node server's upgrade events
|
|
50
|
+
// hub.close() — tear the transport down
|