Skip to content

Commit ed7e957

Browse files
committed
chore: merge main into inspected-page bridge
Preserve the endpoint API reference alongside the relay reference and adapt the relay test protocol to the functions namespace introduced upstream.
2 parents 90dd140 + 1bd966f commit ed7e957

172 files changed

Lines changed: 6159 additions & 5706 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# pnpm patch files must stay LF: a CRLF checkout (Windows autocrlf) breaks
2+
# pnpm's patch parser with ERR_PNPM_INVALID_PATCH.
3+
*.patch text eol=lf

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
uses: oven-sh/setup-bun@v2
4242
- name: Set up Deno
4343
if: matrix.runtime == 'deno'
44-
uses: denoland/setup-deno@v2
44+
uses: denoland/setup-deno@v2.0.5
4545
with:
4646
deno-version: v2.x
4747
- run: pnpm install --frozen-lockfile
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<script setup lang="ts">
2+
import ContentSearch from '@nuxt/ui/components/content/ContentSearch.vue'
3+
4+
defineOptions({ inheritAttrs: false })
5+
</script>
6+
7+
<template>
8+
<ContentSearch
9+
v-bind="$attrs"
10+
preserve-group-order
11+
/>
12+
</template>

docs/content/1.guide/11.client.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,11 @@ if (!trusted) {
8787

8888
### Authenticating with a one-time code
8989

90-
The dev server prints a single-use 6-digit code (expires in five minutes, rotates after repeated wrong attempts); `requestTrustWithCode` exchanges it for a persisted node-issued token shared across sibling tabs:
90+
The dev server prints a single-use 6-digit code (expires in five minutes, rotates after repeated wrong attempts) when an untrusted RPC client asks for one: call `requestAuthCode()` when your auth UI shows, passing `{ reissue: true }` from a "re-issue" button to rotate the code first. `requestTrustWithCode` then exchanges it for a persisted node-issued token shared across sibling tabs:
9191

9292
```ts
93+
await rpc.requestAuthCode()
94+
// … the developer reads the code from the terminal …
9395
const ok = await rpc.requestTrustWithCode('047204')
9496
```
9597

docs/content/1.guide/12.in-page-channel.md

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,22 @@ import type { InPageChannelProtocol } from 'devframe/in-page-channel'
3737
export const MY_CHANNEL = 'devframes:plugin:my-tool'
3838

3939
export interface MyChannelProtocol extends InPageChannelProtocol {
40-
pageScript: { // implemented by the page script, called by panels
41-
highlight: (selector: string) => void
42-
measure: (selector: string) => { width: number, height: number }
40+
functions: {
41+
/** implemented by the page script, callable by panels */
42+
pageScript: {
43+
measure: (selector: string) => { width: number, height: number }
44+
reset: () => Promise<void>
45+
}
46+
/** implemented by panels, callable by the page script */
47+
panel: {
48+
echo: (message: string) => Promise<string>
49+
}
4350
}
44-
panel: { // implemented by panels, called by the page script
45-
flash: (message: string) => void
51+
events: {
52+
/** listened to by the page script, emitted by panels */
53+
pageScript: { highlight: (selector: string) => void }
54+
/** listened to by panels, emitted by the page script */
55+
panel: { flash: (message: string) => void }
4656
}
4757
sharedStates: {
4858
state: { selections: string[] }
@@ -54,37 +64,41 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
5464

5565
## The page script endpoint
5666

57-
Functions use the same authoring metadata as `defineRpcFunction` (`type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. The required `functions` object's keys are the function names, and it implements every function on that endpoint's protocol side. Each handler is contextually typed from its key and the corresponding function in the protocol. `defineChannelFunction` retains the named definition shape for lower-level authoring. Define each side's functions in that side's source files; the shared protocol file carries only types.
67+
The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. `defineChannelFunction` retains the named definition shape for lower-level authoring.
68+
69+
`call()` accepts names from `functions`, including actions returning `void` or `Promise<void>`: callers can await completion and catch errors or timeouts. `emit()`, its deprecated alias `callEvent()`, and `on()` use the names declared in `events`. Function and event names have separate namespaces.
5870

5971
```ts
6072
import type { MyChannelProtocol } from '../shared/protocol'
6173
// inject/index.ts: runs in the user app's page
6274
import { createPageScriptChannel } from 'devframe/in-page-channel'
6375
import { MY_CHANNEL } from '../shared/protocol'
6476

65-
const channel = createPageScriptChannel<MyChannelProtocol>({
77+
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
6678
name: MY_CHANNEL,
6779
functions: {
68-
highlight: {
69-
type: 'event', // fire-and-forget
70-
jsonSerializable: true,
71-
handler: selector => drawRing(document.querySelector(selector)),
72-
},
80+
reset: { type: 'action', handler: async () => clearSelections() },
7381
measure: { // request/response (the default `query` type)
7482
handler: (selector) => {
7583
const rect = document.querySelector(selector)!.getBoundingClientRect()
7684
return { width: rect.width, height: rect.height }
7785
},
7886
},
7987
},
88+
events: {
89+
highlight: {
90+
jsonSerializable: true,
91+
handler: selector => drawRing(document.querySelector(selector)),
92+
},
93+
},
8094
})
8195

82-
channel.callEvent('flash', 'scanning…') // fans out to every connected panel
83-
channel.events.on('panel:connected', panel => console.log(panel.id))
84-
channel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
96+
pageChannel.emit('flash', 'scanning…') // received by each panel endpoint
97+
pageChannel.events.on('panel:connected', panel => console.log(panel.id))
98+
pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
8599
```
86100

87-
`callEvent` on the page script is 1:N: it fans out to every connected panel, and panels that don't implement the function ignore it. Request/response *to* a panel goes through an explicit peer handle: `channel.panels[0].call('flash', '…')`.
101+
`emit` on the page-script endpoint fans out to every connected panel endpoint. Functions declared under `functions.panel` are called through a specific `pageChannel.panels[0].call()` peer handle.
88102

89103
## The panel endpoint
90104

@@ -94,19 +108,25 @@ import type { MyChannelProtocol } from '../shared/protocol'
94108
import { connectPanelChannel } from 'devframe/in-page-channel'
95109
import { MY_CHANNEL } from '../shared/protocol'
96110

97-
const channel = connectPanelChannel<MyChannelProtocol>({
111+
const panelChannel = connectPanelChannel<MyChannelProtocol>({
98112
name: MY_CHANNEL,
99-
functions: {
100-
flash: {
101-
handler: message => showFlash(message),
102-
},
113+
functions: {},
114+
events: {
115+
flash: {},
103116
},
104117
})
105118

106-
channel.callEvent('highlight', '.hero') // buffered until connected
107-
const size = await channel.call('measure', '.hero')
119+
const offFlash = panelChannel.on('flash', message => showFlash(message))
120+
// defined and received by the page-script endpoint
121+
panelChannel.emit('highlight', '.hero')
122+
const size = await panelChannel.call('measure', '.hero')
123+
await panelChannel.call('reset')
124+
125+
offFlash() // stop listening
108126
```
109127

128+
The snippets form one channel pair: `pageChannel.emit('flash', …)` invokes `panelChannel.on('flash', …)`. In the other direction, `panelChannel.emit('highlight', …)` invokes the page-script endpoint's `highlight` handler and any matching `pageChannel.on()` listeners. An endpoint never receives its own emission.
129+
110130
## Shared state
111131

112132
The channel's shared-state layer mirrors [`rpc.sharedState`](/guide/shared-state) (same `SharedState<T>` handle, same accessor), with the page script playing the server's role as rendezvous and authority. Its first `get` of a key must provide the initial value; panels are seeded automatically on connect (including late joiners and re-connects) and converge through syncId-deduplicated patches.
@@ -133,7 +153,7 @@ Every failure mode is a coded `InPageChannelError` (`error.code`) with a message
133153
The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging:
134154

135155
- `channel.status` is `connecting``connected` → (`connecting` on port loss) → `closed`, with `events.on('status:updated', …)` for reactivity.
136-
- While `connecting`, `call()` is queued (and still subject to its deadline) and `callEvent()` is buffered (up to `eventBufferLimit`, oldest dropped with a warning); both flush on connect.
156+
- While `connecting`, `call()` is queued (and still subject to its deadline) and `emit()` is buffered (up to `eventBufferLimit`, oldest dropped with a warning); both flush on connect.
137157
- A page script may legitimately never appear (the panel opened standalone, the user app not instrumented). Race `whenConnected(timeoutMs)` to show a "load the page script" empty state:
138158

139159
```ts
@@ -157,17 +177,21 @@ import { toRaw } from 'vue'
157177
const channel = connectPanelChannel<MyChannelProtocol>({
158178
name: MY_CHANNEL,
159179
serialize: value => toRawDeep(value), // applied to every outgoing argument and result
180+
functions: {},
181+
events: { flash: {} },
160182
})
161183
```
162184

185+
These hooks also apply to shared-state subscription snapshots, full-state updates, and patch arrays in both directions. Hooks that restore nested values should traverse objects and arrays, including each patch's `value`.
186+
163187
Declaring a function `jsonSerializable: true` additionally enforces strict JSON on its payloads at the receiving endpoint, turning a would-be silent coercion or cryptic `DataCloneError` into a coded error naming the offending path.
164188

165189
## Multiple tabs
166190

167191
The same app open in two tabs means two page scripts on one origin. Each page script carries a per-tab instance id (persisted in `sessionStorage`), and handshakes are targeted `postMessage`, so a dock panel always pairs with its own tab's page script. A panel can also pin explicitly:
168192

169193
```ts
170-
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId })
194+
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId, functions: {}, events: { flash: {} } })
171195
```
172196

173197
## Custom transports
@@ -177,7 +201,7 @@ Both endpoints accept a pre-established `MessagePort` that bypasses the handshak
177201
```ts
178202
const { port1, port2 } = new MessageChannel()
179203
pageScript.addPanelPort(port1)
180-
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2 })
204+
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2, functions: {}, events: { flash: {} } })
181205
```
182206

183207
## When to use the in-page channel vs RPC

docs/content/1.guide/14.security.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ An RPC handler runs with the full privileges of its Node process (filesystem, ch
1919
2020
## The pre-trust gate
2121

22-
One rule decides what an untrusted connection may call: **a method is reachable before trust iff its name starts with `anonymous:`** (`isAnonymousRpcMethod`, from `devframe/constants`); only the two handshake methods below qualify.
22+
One rule decides what an untrusted connection may call: **a method is reachable before trust iff its name starts with `anonymous:`** (`isAnonymousRpcMethod`, from `devframe/constants`); only the handshake and code-request methods below qualify.
2323

2424
The RPC server binding enforces this: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)`. Every other call from an untrusted session throws [`DF0036`](/errors/DF0036). `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold calls issued during the first handshake and release them once it settles.
2525

2626
## Authentication flow
2727

2828
1. A fresh RPC client calls `anonymous:devframe:auth` with its stored token (empty on first run); the server returns `{ isTrusted: false }` and the UI prompts for a code.
29-
2. The dev server shows a 6-digit code in the terminal (`auth.printBanner()` once listening).
29+
2. The auth UI requests a code (`rpc.requestAuthCode()`, sent automatically when the built-in notice view first shows, or by its "re-issue" button with `{ reissue: true }` to rotate the code first); the dev server prints the 6-digit code, its expiry, and the requesting browser in the terminal. An already-authorized page never triggers a print.
3030
3. The developer enters it; the browser calls `requestTrustWithCode(code)`.
3131
4. The server verifies the code, mints a high-entropy bearer token, trusts the session, and returns it.
3232
5. The browser persists the token and presents it on reconnect (or via a `?devframe_auth_token=` query param the connect-time hook checks first); sibling tabs receive it over the `devframe-auth` channel and become trusted.
@@ -51,11 +51,11 @@ Pass `clientAuthTokens` for CI/shared machines to skip the prompt, or a custom `
5151

5252
### Auth methods
5353

54-
The two `anonymous:`-prefixed handshake methods re-authenticate a stored token (`anonymous:devframe:auth`) and exchange a one-time code for a token (`anonymous:devframe:auth:exchange`); `devframe:auth:revoke` self-revokes, and the `devframe:auth:revoked` event drops affected RPC clients to untrusted. Wire shapes are in the [Node-Side API reference](/references/node-api#auth-methods).
54+
The `anonymous:`-prefixed methods re-authenticate a stored token (`anonymous:devframe:auth`), exchange a one-time code for a token (`anonymous:devframe:auth:exchange`), and ask the server to print its code banner (`anonymous:devframe:auth:request-code`); `devframe:auth:revoke` self-revokes, and the `devframe:auth:revoked` event drops affected RPC clients to untrusted. Wire shapes are in the [Node-Side API reference](/references/node-api#auth-methods).
5555

5656
Node primitives in `devframe/node/auth` (`getTempAuthCode` / `refreshTempAuthCode`, `exchangeTempAuthCode`, `verifyAuthToken`, `buildOtpAuthUrl`, and `revokeAuthToken`) implement the same flow for a host framework wiring its own gate; signatures are in the [reference](/references/node-api#node-auth-primitives).
5757

58-
RPC client methods (`devframe/client`): `requestTrustWithCode(code)`, `requestTrustWithToken(token)`, and `ensureTrusted(timeout?)` / `isTrusted` (the trust gate).
58+
RPC client methods (`devframe/client`): `requestAuthCode(options?)` (print the code banner; `{ reissue: true }` rotates the code first), `requestTrustWithCode(code)`, `requestTrustWithToken(token)`, and `ensureTrusted(timeout?)` / `isTrusted` (the trust gate).
5959

6060
### Magic-link authentication
6161

docs/content/1.guide/15.agent-native.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
title: 'Agent-Native Devframe'
33
navigation:
44
icon: i-lucide-bot
5-
description: 'Devframe exposes its browser-side API (RPC functions, resources, shared state) to coding agents over MCP, opt-in per function.'
5+
description: 'Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and WebMCP on the browser side, opt-in per function.'
66
---
77

8-
Devframe exposes its browser-side API (RPC functions, resources, shared state) to coding agents over MCP, opt-in per function.
8+
Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and [WebMCP](#browser-side-tools-over-webmcp) on the browser side, opt-in per function.
99

1010
## How it works
1111

12-
Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server.
12+
Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server. The same `agent` field on a *client* RPC function surfaces it [over WebMCP](#browser-side-tools-over-webmcp) instead.
1313

1414
## Exposing an RPC function
1515

@@ -137,6 +137,29 @@ In `claude_desktop_config.json`:
137137

138138
Restart; tools appear in the drawer, resources as `devframe://resource/<id>` / `devframe://state/<key>` URIs.
139139

140+
## Browser-side tools over WebMCP
141+
142+
The same `agent` signature works on the browser side: a client RPC function (a function the node side calls on the browser, registered on `rpc.client` or through a scoped `client.scope('my-plugin').rpc.register(...)`) carrying an `agent` field is mirrored onto the page's [WebMCP](https://github.com/webmachinelearning/webmcp) model context (`document.modelContext` / `navigator.modelContext`) as a callable tool, so in-page and browser-integrated agents can drive browser-side functionality directly. Wire names, `arg0`/`arg1`/… input schemas, and safety annotations match the MCP projection above.
143+
144+
```ts
145+
const rpc = await connectDevframe()
146+
147+
rpc.client.register({
148+
name: 'my-plugin:highlight-node',
149+
type: 'action',
150+
jsonSerializable: true,
151+
agent: {
152+
description: 'Highlight a node in the open inspector view. Use it to point the user at a finding.',
153+
},
154+
handler: (id: string) => highlightNode(id),
155+
})
156+
```
157+
158+
`connectDevframe()` wires this on its own when the browser provides a model context; `webmcp: false` keeps the browser side off the WebMCP surface. `registerWebMcpTools(collector)` (from `devframe/client`) applies the same projection to a hand-built collector and returns a dispose that unregisters every tool.
159+
160+
> [!WARNING]
161+
> WebMCP is an experimental proposal; `registerWebMcpTools` tracks the current draft (`AbortSignal`-based unregistration) and earlier handle-returning drafts, but the browser API may still change.
162+
140163
## Writing descriptions agents act on
141164

142165
Describe *when* to use a tool, not just its return:

docs/content/1.guide/3.rpc.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,6 @@ Add an `agent` field to expose the function to coding agents over MCP:
193193
defineRpcFunction({
194194
name: 'get-modules',
195195
type: 'query',
196-
jsonSerializable: true,
197196
args: [v.object({ limit: v.number() })],
198197
returns: v.array(v.object({ id: v.string(), size: v.number() })),
199198
agent: {
@@ -207,7 +206,7 @@ defineRpcFunction({
207206
})
208207
```
209208

210-
Exposing a function over MCP requires `jsonSerializable: true`.
209+
The `agent` field implicitly enables strict JSON serialization because MCP consumes JSON-shaped data. Set `jsonSerializable: true` directly when an RPC-only function also benefits from that contract.
211210

212211
## What's next
213212

docs/content/1.guide/4.shared-state.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ state.mutate((draft) => {
7272
})
7373
```
7474

75-
Devframe applies the recipe to a draft, emits `updated` (with `SharedStatePatch[]` if enabled), and broadcasts to RPC clients; a `syncIds` set keeps mutations idempotent on replay.
75+
Devframe applies the recipe to a draft. When Immer returns a new state reference, Devframe emits `updated` and broadcasts it to RPC clients. Explicit replacement objects also notify. With patches enabled, `updated` carries `SharedStatePatch[]`. Sync IDs remain recorded for unchanged recipes, so replays stay idempotent.
7676

7777
## Patches (advanced)
7878

docs/content/2.adapters/1.initiate.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so
129129

130130
## Auth
131131

132-
The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known, whether from the `origin` option or derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme.
132+
The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner when an untrusted browser client asks for a code (`rpc.requestAuthCode()`); an already-authorized page triggers no print. The magic link's origin comes from the `origin` option, or is derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme.
133133

134134
## Relation to the other adapters
135135

0 commit comments

Comments
 (0)