Skip to content

Commit 9852917

Browse files
committed
Merge remote-tracking branch 'origin/main' into dvcol/feat-inline-icons
# Conflicts: # packages/hub-ui/src/client/components/icons/IconifyIcon.vue
2 parents 532a375 + 6662519 commit 9852917

40 files changed

Lines changed: 878 additions & 2710 deletions

File tree

‎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/6.errors/DF0019.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
---
22
title: 'DF0019: Agent Requires JSON-Serializable RPC'
3-
description: 'RPC function "{name}" has agent set but jsonSerializable is not true; MCP requires JSON-serializable data.'
3+
description: 'RPC function "{name}" has agent set but jsonSerializable is false; MCP requires JSON-serializable data.'
44
---
55

66
## Message
77

8-
> RPC function "`{name}`" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data.
8+
> RPC function "`{name}`" has `agent` set but `jsonSerializable` is `false`; MCP requires JSON-serializable data.
99
1010
## Cause
1111

12-
The `agent` field exposes an RPC function as an MCP tool, and MCP only consumes JSON-shaped data. A function with `agent` set is rejected unless it also declares `jsonSerializable: true`.
12+
The `agent` field exposes an RPC function as an MCP tool and implicitly enables strict JSON serialization. An explicit `jsonSerializable: false` conflicts with MCP's JSON-shaped data.
1313

1414
## Example
1515

1616
```ts
1717
defineRpcFunction({
1818
name: 'my-plugin:summary',
1919
agent: { description: 'Returns a summary' },
20-
handler: () => ({ items: [1, 2, 3] }), // ✗ throws DF0019: missing jsonSerializable: true
20+
jsonSerializable: false, // ✗ throws DF0019
21+
handler: () => ({ items: [1, 2, 3] }),
2122
})
2223
```
2324

2425
## Fix
2526

26-
Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.
27+
Remove `jsonSerializable: false` to use the implicit JSON contract, or remove `agent` to keep the function RPC-only.
2728

2829
```ts
2930
defineRpcFunction({
@@ -36,4 +37,4 @@ defineRpcFunction({
3637

3738
## Source
3839

39-
- [`packages/devframe/src/rpc/collector.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/collector.ts): `RpcFunctionsCollectorBase.register()` throws `DF0019` when a definition has `agent` set but is not declared `jsonSerializable: true`.
40+
- [`packages/devframe/src/rpc/agent-json-serialization.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/agent-json-serialization.ts): `ensureAgentJsonSerializable()` throws `DF0019` when a definition combines `agent` with `jsonSerializable: false` during registration or static dump collection.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { toolInputToCommandArgs, toolInputToRpcArgs } from '../tool-input'
3+
4+
describe('tool input positional arguments', () => {
5+
it('passes arrays through and maps argN keys using the declared count', () => {
6+
expect(toolInputToRpcArgs([1, 2], 2)).toEqual([1, 2])
7+
expect(toolInputToRpcArgs({ arg0: 'a', arg1: 'b' }, 2)).toEqual(['a', 'b'])
8+
expect(toolInputToRpcArgs({ arg0: 'a' }, 0)).toEqual([])
9+
})
10+
11+
it('collects contiguous argN keys without a declared count', () => {
12+
expect(toolInputToRpcArgs({ arg0: 1, arg1: 2 })).toEqual([1, 2])
13+
})
14+
15+
it('treats null, undefined, and empty objects as zero-argument calls', () => {
16+
expect(toolInputToRpcArgs(undefined)).toEqual([])
17+
expect(toolInputToRpcArgs(null, 1)).toEqual([])
18+
expect(toolInputToRpcArgs({})).toEqual([])
19+
})
20+
21+
it('preserves undeclared RPC input as one argument', () => {
22+
const input = { name: 'devframe' }
23+
expect(toolInputToRpcArgs(input)).toEqual([input])
24+
})
25+
26+
it('drops undeclared command input', () => {
27+
expect(toolInputToCommandArgs({ name: 'devframe' })).toEqual([])
28+
})
29+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { DevframeRpcClientFunctions } from 'devframe/types'
2+
import type { DevframeClientRpcHost, DevframeRpcContext, RpcClientEvents } from './rpc'
3+
import { RpcFunctionsCollectorBase } from 'devframe/rpc'
4+
import { createEventEmitter } from 'devframe/utils/events'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { createLiveRpcClientMode } from './rpc-live'
7+
8+
vi.mock('devframe/rpc/client', () => ({
9+
createRpcClient: () => ({ $call: vi.fn(async () => ({ isTrusted: true })) }),
10+
}))
11+
12+
function createMode() {
13+
const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>({ rpc: undefined! })
14+
return createLiveRpcClientMode({
15+
transport: 'websocket',
16+
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
17+
events: createEventEmitter<RpcClientEvents>(),
18+
clientRpc,
19+
createChannel: () => ({ post: vi.fn(), on: vi.fn(), close: vi.fn() }),
20+
})
21+
}
22+
23+
describe('trust deadline cleanup', () => {
24+
beforeEach(() => {
25+
vi.useFakeTimers()
26+
vi.stubGlobal('navigator', { userAgent: 'test' })
27+
vi.stubGlobal('location', { origin: 'http://localhost' })
28+
})
29+
30+
afterEach(() => {
31+
vi.useRealTimers()
32+
vi.unstubAllGlobals()
33+
})
34+
35+
it('clears concurrent deadlines as soon as authentication succeeds', async () => {
36+
expect.assertions(4)
37+
const mode = createMode()
38+
const first = mode.ensureTrusted(60_000)
39+
const second = mode.ensureTrusted(30_000)
40+
expect(vi.getTimerCount()).toBe(2)
41+
await mode.requestTrustWithToken('test-token')
42+
await expect(first).resolves.toBe(true)
43+
await expect(second).resolves.toBe(true)
44+
expect(vi.getTimerCount()).toBe(0)
45+
})
46+
47+
it('leaves no deadline behind when already trusted', async () => {
48+
expect.assertions(2)
49+
const mode = createMode()
50+
await mode.requestTrustWithToken('test-token')
51+
await expect(mode.ensureTrusted()).resolves.toBe(true)
52+
expect(vi.getTimerCount()).toBe(0)
53+
})
54+
55+
it('preserves expiry and unlimited trust waits', async () => {
56+
expect.assertions(4)
57+
const mode = createMode()
58+
const unlimited = mode.ensureTrusted(0)
59+
expect(vi.getTimerCount()).toBe(0)
60+
const expiry = expect(mode.ensureTrusted(10)).rejects.toThrow('Timeout waiting for rpc to be trusted')
61+
await vi.advanceTimersByTimeAsync(10)
62+
await expiry
63+
expect(vi.getTimerCount()).toBe(0)
64+
await mode.requestTrustWithToken('test-token')
65+
await expect(unlimited).resolves.toBe(true)
66+
})
67+
})

‎packages/devframe/src/client/rpc-live.ts‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -261,18 +261,21 @@ export function createLiveRpcClientMode(
261261
if (timeout <= 0)
262262
return trustedPromise.promise
263263

264-
let clear = () => {}
265-
await Promise.race([
266-
trustedPromise.promise.then(clear),
267-
new Promise((resolve, reject) => {
268-
const id = setTimeout(() => {
269-
reject(new Error('[devframe] Timeout waiting for rpc to be trusted'))
270-
}, timeout)
271-
clear = () => clearTimeout(id)
272-
}),
273-
])
274-
275-
return isTrusted
264+
let timer: ReturnType<typeof setTimeout> | undefined
265+
try {
266+
await Promise.race([
267+
trustedPromise.promise,
268+
new Promise<never>((_, reject) => {
269+
timer = setTimeout(() => {
270+
reject(new Error('[devframe] Timeout waiting for rpc to be trusted'))
271+
}, timeout)
272+
}),
273+
])
274+
return isTrusted
275+
}
276+
finally {
277+
clearTimeout(timer)
278+
}
276279
}
277280

278281
return {

‎packages/devframe/src/client/rpc.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,36 @@ describe('getDevframeRpcClient: connection meta base', () => {
5757
delete (globalThis as any)[DEVFRAME_CONNECTION_KEY]
5858
})
5959

60+
it('closes the authentication broadcast channel with the RPC client', async () => {
61+
expect.assertions(1)
62+
const closeChannel = vi.spyOn(FakeBroadcastChannel.prototype, 'close')
63+
const rpc = await getDevframeRpcClient({
64+
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
65+
otpParam: false,
66+
simpleAuth: false,
67+
webmcp: false,
68+
})
69+
rpc.close?.()
70+
expect(closeChannel).toHaveBeenCalledExactlyOnceWith()
71+
})
72+
73+
it('still closes the transport when closing the authentication channel fails', async () => {
74+
expect.assertions(2)
75+
const failure = new Error('channel cleanup failed')
76+
vi.spyOn(FakeBroadcastChannel.prototype, 'close').mockImplementation(() => {
77+
throw failure
78+
})
79+
const closeTransport = vi.spyOn(FakeWebSocket.prototype, 'close')
80+
const rpc = await getDevframeRpcClient({
81+
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
82+
otpParam: false,
83+
simpleAuth: false,
84+
webmcp: false,
85+
})
86+
expect(() => rpc.close?.()).toThrow(failure)
87+
expect(closeTransport).toHaveBeenCalledExactlyOnceWith()
88+
})
89+
6090
it('publishes the meta annotated with the absolute base it resolved from', async () => {
6191
const served: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
6292
vi.stubGlobal('fetch', vi.fn(async () => ({

‎packages/devframe/src/client/rpc.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,21 @@ export async function getDevframeRpcClient(
445445
}) as F
446446
}
447447

448+
/** Release authentication and transport resources even if another disposer fails. */
449+
function closeRpcClient(): void {
450+
try {
451+
disposeWebMcp?.()
452+
}
453+
finally {
454+
try {
455+
authChannel?.close()
456+
}
457+
finally {
458+
mode.close?.()
459+
}
460+
}
461+
}
462+
448463
const rpc: DevframeRpcClient = {
449464
events,
450465
get isTrusted() {
@@ -495,10 +510,7 @@ export async function getDevframeRpcClient(
495510
streaming: undefined!,
496511
cacheManager,
497512
scope: undefined!,
498-
close: () => {
499-
disposeWebMcp?.()
500-
mode.close?.()
501-
},
513+
close: closeRpcClient,
502514
}
503515

504516
rpc.sharedState = createRpcSharedStateClientHost(rpc)

‎packages/devframe/src/client/webmcp.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { toAgentToolName } from 'devframe/utils/agent-tool-name'
44
// Pure, browser-safe projections shared with the node-side MCP adapter, so
55
// the WebMCP surface cannot drift from the MCP one.
66
import { argsToJsonSchema } from '../adapters/mcp/to-json-schema'
7-
import { coerceAgentPositionalArgs } from '../node/agent-args'
7+
import { toolInputToRpcArgs } from '../tool-input'
88

99
/**
1010
* Result a WebMCP tool's `execute` resolves with; mirrors the MCP
@@ -195,7 +195,7 @@ async function executeRpcTool<SetupContext>(
195195
args: Record<string, unknown>,
196196
): Promise<WebMcpToolResult> {
197197
try {
198-
const positional = coerceAgentPositionalArgs(args, def.args as readonly unknown[] | undefined, 'wrap')
198+
const positional = toolInputToRpcArgs(args, def.args?.length)
199199
const handler = await getRpcHandler(def, context)
200200
const result = await handler(...positional)
201201
return { content: [{ type: 'text', text: stringifyResult(result) }] }

‎packages/devframe/src/in-page-channel/in-page-channel.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,39 @@ function createWindowPair(origin = 'https://app.test'): { hostWin: FakeWindow, p
675675
const fastHello = { helloIntervalMs: 5, heartbeat: false as const }
676676

677677
describe('in-page channel handshake', () => {
678+
it.each([0, 1, 2])('connects through a popup opener with %i nested panel frames', async (depth) => {
679+
const { hostWin, panelWin } = createWindowPair()
680+
let popupWin = panelWin
681+
for (let i = 0; i < depth; i++) {
682+
const parent = createFakeWindow(hostWin.location.origin)
683+
popupWin.parent = parent
684+
popupWin = parent
685+
}
686+
popupWin.parent = popupWin
687+
popupWin.opener = hostWin
688+
const pageScript = createPageScriptChannel<TestProtocol>({
689+
name: 'devframes:test',
690+
window: asWindow(hostWin),
691+
heartbeat: false,
692+
functions: defaultPageScriptFunctions,
693+
})
694+
const panel = connectPanelChannel<TestProtocol>({
695+
name: 'devframes:test',
696+
window: asWindow(panelWin),
697+
...fastHello,
698+
functions: defaultPanelFunctions,
699+
})
700+
try {
701+
await panel.whenConnected(200)
702+
expect(panel.pageScript?.instanceId).toBe(pageScript.instanceId)
703+
await expect(panel.call('echo', 'popup')).resolves.toBe('popup')
704+
}
705+
finally {
706+
panel.close()
707+
pageScript.close()
708+
}
709+
})
710+
678711
it('connects a panel to the page script and survives page-script restarts', async () => {
679712
const { hostWin, panelWin } = createWindowPair()
680713
const pageScript = createPageScriptChannel<TestProtocol>({

‎packages/devframe/src/in-page-channel/panel.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const DEFAULT_EVENT_BUFFER_LIMIT = 64
3838
* Connect the panel endpoint of an in-page channel.
3939
*
4040
* The panel initiates: it posts a versioned hello to every window a
41-
* same-tab page script can live in (its ancestor chain and its `opener`),
41+
* page script can live in (its ancestor chain and those windows' openers),
4242
* retrying with backoff until one answers with a dedicated port, so boot
4343
* order never matters, and a reload of either side is just a re-handshake
4444
* (`WindowProxy` references survive navigations). While `connecting`,

0 commit comments

Comments
 (0)