Skip to content

Commit 70282fa

Browse files
committed
fix(devframe): publish strict JSON methods in live discovery
Read method flags from the current RPC registry when a client requests the instance descriptor. This gives SSE-only handles and HTTP discovery the same encoding hints as WebSocket handles, including methods registered after startup.
1 parent c87d01c commit 70282fa

7 files changed

Lines changed: 70 additions & 25 deletions

File tree

‎examples/custom-hub-next/tests/next-devframe-hub.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ describe('next-devframe-hub (example)', () => {
5454

5555
expect(hub.connectionMeta()).toEqual({
5656
backend: 'websocket',
57+
jsonSerializableMethods: expect.arrayContaining(['devframes:plugin:assets:list']),
5758
websocket: { port, path: '__ws' },
5859
sse: { path: '/__devframes/__sse' },
5960
mcp: { path: '__mcp' },

‎packages/devframe/src/adapters/__tests__/dev-transports.test.ts‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
99
import { readDevframeInstances } from '../../node/instance-registry'
1010
import { createDevServer } from '../dev'
1111

12+
const jsonHandler = vi.fn((value: unknown) => value)
13+
1214
const definition = defineDevframe({
1315
id: 'dev-transports-test',
1416
name: 'Dev transports test',
@@ -20,13 +22,13 @@ const definition = defineDevframe({
2022
ctx.rpc.register(defineRpcFunction({
2123
name: 'test:probe',
2224
type: 'query',
23-
handler: () => 'connected',
25+
handler: (value: unknown) => value,
2426
}))
2527
ctx.rpc.register(defineRpcFunction({
2628
name: 'test:json',
2729
type: 'query',
2830
jsonSerializable: true,
29-
handler: (value: unknown) => value,
31+
handler: jsonHandler,
3032
}))
3133
},
3234
})
@@ -70,7 +72,8 @@ describe('dev server transport handle', () => {
7072
sse: { path: '__sse' },
7173
})
7274
await vi.waitFor(() => expect(readDevframeInstances({ instancesDir: registryDir })).toHaveLength(1))
73-
await callThroughHandle(handle, '/', 'sse')
75+
await checkRpcCalls(handle, '/', 'sse')
76+
await checkRpcCalls(handle, '/', 'sse', 'http')
7477
}
7578
finally {
7679
await handle.close()
@@ -96,8 +99,9 @@ describe('dev server transport handle', () => {
9699
expect(handle.connectionMeta()).toMatchObject(httpMeta)
97100
expect(handle.connectionMeta().jsonSerializableMethods).toContain('test:json')
98101
expect(resolveClientTransport('auto', handle.connectionMeta())).toBe('websocket')
99-
await callThroughHandle(handle, basePath, 'sse')
100-
await callThroughHandle(handle, basePath, 'websocket')
102+
await checkRpcCalls(handle, basePath, 'sse')
103+
await checkRpcCalls(handle, basePath, 'websocket')
104+
await checkRpcCalls(handle, basePath, 'websocket', 'http')
101105
}
102106
finally {
103107
await handle.close()
@@ -109,7 +113,7 @@ describe('dev server transport handle', () => {
109113
const handle = await createDevServer(definition, { ...options, sse: false })
110114
try {
111115
expect(handle.connectionMeta().sse).toBeUndefined()
112-
await callThroughHandle(handle, '/', 'websocket')
116+
await checkRpcCalls(handle, '/', 'websocket')
113117
}
114118
finally {
115119
await handle.close()
@@ -124,6 +128,7 @@ describe('dev server transport handle', () => {
124128
expect(handle.connectionMeta()).toMatchObject({ backend: 'none' })
125129
expect(handle.connectionMeta().websocket).toBeUndefined()
126130
expect(handle.connectionMeta().sse).toBeUndefined()
131+
expect(handle.connectionMeta()).not.toHaveProperty('jsonSerializableMethods')
127132
expect((await fetch(`${handle.origin}/__connection.json`)).status).toBe(200)
128133
}
129134
finally {
@@ -133,13 +138,17 @@ describe('dev server transport handle', () => {
133138
})
134139
})
135140

136-
async function callThroughHandle(handle: Awaited<ReturnType<typeof createDevServer>>, basePath: string, transport: 'sse' | 'websocket') {
141+
async function checkRpcCalls(handle: Awaited<ReturnType<typeof createDevServer>>, basePath: string, transport: 'sse' | 'websocket', source: 'handle' | 'http' = 'handle') {
137142
vi.stubGlobal('location', new URL(`${handle.origin}${basePath}`))
138143
vi.stubGlobal('navigator', { userAgent: 'vitest' })
144+
const metaBaseUrl = `${handle.origin}${basePath}__connection.json`
145+
const connectionMeta = source === 'http'
146+
? await fetch(metaBaseUrl).then(response => response.json())
147+
: handle.connectionMeta()
139148
const client = await getDevframeRpcClient({
140149
connection: {
141-
connectionMeta: handle.connectionMeta(),
142-
metaBaseUrl: `${handle.origin}${basePath}__connection.json`,
150+
connectionMeta,
151+
metaBaseUrl,
143152
},
144153
transport,
145154
otpParam: false,
@@ -148,14 +157,15 @@ async function callThroughHandle(handle: Awaited<ReturnType<typeof createDevServ
148157
try {
149158
await client.ensureTrusted(2000)
150159
expect(client.transport).toBe(transport)
151-
await expect((client.call as any)('test:probe')).resolves.toBe('connected')
160+
const map = new Map([
161+
['key', 42],
162+
])
163+
await expect((client.call as any)('test:probe', map)).resolves.toEqual(map)
152164
expect(handle.rpcGroup.clients.length).toBeGreaterThan(0)
153-
if (handle.connectionMeta().jsonSerializableMethods?.includes('test:json')) {
154-
await expect((client.call as any)('test:json', { value: 42 })).resolves.toEqual({ value: 42 })
155-
await expect((client.call as any)('test:json', new Map([
156-
['key', 42],
157-
]))).rejects.toThrow()
158-
}
165+
jsonHandler.mockClear()
166+
await expect((client.call as any)('test:json', { value: 42 })).resolves.toEqual({ value: 42 })
167+
await expect((client.call as any)('test:json', map)).rejects.toThrow(/Map/)
168+
expect(jsonHandler).toHaveBeenCalledTimes(1)
159169
}
160170
finally {
161171
client.close?.()

‎packages/devframe/src/adapters/__tests__/dev.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ describe('adapters/dev', () => {
141141
// relative to `__connection.json`, never a baked-in host/port.
142142
expect(meta).toEqual({
143143
backend: 'websocket',
144+
jsonSerializableMethods: expect.any(Array),
144145
websocket: { path: '__ws' },
145146
sse: { path: '__sse' },
146147
})
@@ -250,6 +251,7 @@ describe('adapters/dev', () => {
250251
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
251252
expect(meta).toEqual({
252253
backend: 'websocket',
254+
jsonSerializableMethods: expect.any(Array),
253255
websocket: { path: '__sockets' },
254256
sse: { path: '__sse' },
255257
})
@@ -290,6 +292,7 @@ describe('adapters/dev', () => {
290292
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
291293
expect(meta).toEqual({
292294
backend: 'websocket',
295+
jsonSerializableMethods: expect.any(Array),
293296
websocket: { port: wsPort, path: '__ws' },
294297
sse: { path: '__sse' },
295298
})
@@ -334,6 +337,7 @@ describe('adapters/dev', () => {
334337
// endpoint stays same-origin and rides the tunnel like the meta itself.
335338
expect(meta).toEqual({
336339
backend: 'websocket',
340+
jsonSerializableMethods: expect.any(Array),
337341
websocket: 'wss://devtools.example.com/relay/__ws',
338342
sse: { path: '__sse' },
339343
})
@@ -462,6 +466,7 @@ describe('adapters/dev', () => {
462466
const meta = await res.json()
463467
expect(meta).toEqual({
464468
backend: 'websocket',
469+
jsonSerializableMethods: expect.any(Array),
465470
websocket: { path: '__ws' },
466471
sse: { path: '__sse' },
467472
})

‎packages/devframe/src/adapters/__tests__/initiate.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe('adapters/handler', () => {
7575
// The advertised meta carries the side-car port with the unified route.
7676
expect(devtools.connectionMeta()).toEqual({
7777
backend: 'websocket',
78+
jsonSerializableMethods: expect.any(Array),
7879
websocket: { port: wsPort, path: '__ws' },
7980
sse: { path: '__sse' },
8081
})
@@ -84,10 +85,18 @@ describe('adapters/handler', () => {
8485
expect(index.status).toBe(200)
8586
expect(await index.text()).toContain('handler test')
8687

88+
const ctx = await devtools.context
89+
ctx.rpc.register({
90+
name: 'test:late-json',
91+
type: 'query',
92+
jsonSerializable: true,
93+
handler: () => 'ok',
94+
})
8795
const metaRes = await devtools.handler(new Request('http://localhost:3000/__handler-test/__connection.json'))
8896
expect(metaRes.status).toBe(200)
8997
expect(await metaRes.json()).toEqual({
9098
backend: 'websocket',
99+
jsonSerializableMethods: expect.arrayContaining(['test:late-json']),
91100
websocket: { port: wsPort, path: '__ws' },
92101
sse: { path: '__sse' },
93102
})
@@ -183,6 +192,7 @@ describe('adapters/handler', () => {
183192
// resolved against __connection.json's own URL.
184193
expect(devtoolsRef.connectionMeta()).toEqual({
185194
backend: 'websocket',
195+
jsonSerializableMethods: expect.any(Array),
186196
websocket: { path: '__ws' },
187197
sse: { path: '__sse' },
188198
})
@@ -194,6 +204,7 @@ describe('adapters/handler', () => {
194204
const meta = await (await fetch(`http://${host}:${port}/__handler-shared/__connection.json`)).json()
195205
expect(meta).toEqual({
196206
backend: 'websocket',
207+
jsonSerializableMethods: expect.any(Array),
197208
websocket: { path: '__ws' },
198209
sse: { path: '__sse' },
199210
})
@@ -272,6 +283,7 @@ describe('adapters/handler', () => {
272283
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
273284
expect(meta).toEqual({
274285
backend: 'websocket',
286+
jsonSerializableMethods: expect.any(Array),
275287
websocket: { path: '__ws' },
276288
sse: { path: '__sse' },
277289
})
@@ -346,6 +358,7 @@ describe('adapters/handler', () => {
346358

347359
try {
348360
await devtools.ready
361+
expect(devtools.connectionMeta()).not.toHaveProperty('jsonSerializableMethods')
349362
expect(devtools.connectionMeta()).toEqual({
350363
backend: 'websocket',
351364
websocket: 'wss://devtools.example.com/relay/__ws',
@@ -599,6 +612,7 @@ describe('adapters/handler', () => {
599612
// Advertised as a same-origin route, with no port of its own.
600613
expect(devtools.connectionMeta()).toEqual({
601614
backend: 'websocket',
615+
jsonSerializableMethods: expect.any(Array),
602616
websocket: { path: '__ws' },
603617
sse: { path: '__sse' },
604618
})

‎packages/devframe/src/adapters/dev.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,6 @@ export async function createDevServer(
232232
ws: transport.ws,
233233
rpcGroup: transport.rpcGroup,
234234
connectionMeta: () => ({
235-
...transport.connectionMeta(),
236235
...devframe.connectionMeta(),
237236
}),
238237
async close() {

‎packages/devframe/src/node/instance-shell.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import type { Duplex } from 'node:stream'
99
import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../types'
1010
import type { DevframeSseOptions, DevframeWsOptions } from '../types/devframe'
1111
import type { DevframeAuthHandler } from './auth'
12-
import type { RpcFunctionsHostImpl } from './host-functions'
1312
import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry'
1413
import type { ContextRpcServer } from './rpc-core'
1514
import { createServer } from 'node:http'
@@ -74,16 +73,20 @@ interface TierBinding {
7473
wsAvailable: boolean
7574
}
7675

77-
function websocketConnectionMeta(rpcHost: RpcFunctionsHostImpl, path: string | undefined): ConnectionMeta {
76+
function getJsonSerializableMethods(rpc: DevframeNodeContext['rpc']): string[] {
7877
const jsonSerializableMethods: string[] = []
79-
for (const def of rpcHost.definitions.values()) {
78+
for (const def of rpc.definitions.values()) {
8079
if (def.jsonSerializable === true)
8180
jsonSerializableMethods.push(def.name)
8281
}
82+
return jsonSerializableMethods
83+
}
84+
85+
function websocketConnectionMeta(rpc: DevframeNodeContext['rpc'], path: string | undefined): ConnectionMeta {
8386
return {
8487
backend: 'websocket',
8588
websocket: { path },
86-
jsonSerializableMethods,
89+
jsonSerializableMethods: getJsonSerializableMethods(rpc),
8790
}
8891
}
8992

@@ -101,7 +104,6 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
101104
const app = new H3App()
102105
const ownsHttpServer = !options.server
103106
const httpServer = options.server ?? createServer(toNodeHandler(app))
104-
const rpcHost = context.rpc as RpcFunctionsHostImpl
105107
const websocket = options.websocket !== false
106108

107109
let ws: NodeAdapter | undefined
@@ -170,7 +172,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
170172
app,
171173
ws,
172174
rpcGroup: core.rpcGroup,
173-
connectionMeta: () => websocketConnectionMeta(rpcHost, options.path),
175+
connectionMeta: () => websocketConnectionMeta(context.rpc, options.path),
174176
async close() {
175177
httpServer.off('listening', publishWsEndpoint)
176178
await closeWs()
@@ -246,7 +248,6 @@ async function bindNativeHttpAndWs(
246248
const { context, port, core } = options
247249
const bindHost = options.host
248250
const app = new H3App()
249-
const rpcHost = context.rpc as RpcFunctionsHostImpl
250251
const wsPath = options.path
251252

252253
const fail = async (closeWs: () => Promise<void>, error: unknown): Promise<never> => {
@@ -331,7 +332,7 @@ async function bindNativeHttpAndWs(
331332
app,
332333
ws: undefined,
333334
rpcGroup: core.rpcGroup,
334-
connectionMeta: () => websocketConnectionMeta(rpcHost, options.path),
335+
connectionMeta: () => websocketConnectionMeta(context.rpc, options.path),
335336
async close() {
336337
// Stop the native server before the WS tier: closing the socket while a
337338
// peer is mid-disconnect (a client that just sent its close frame)
@@ -860,6 +861,17 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
860861
}
861862
}
862863

864+
function addLocalMethodHints(meta: ConnectionMeta): void {
865+
if (tier === 'external' || meta.backend === 'none')
866+
return
867+
// Routes retain this descriptor. Read the registry on discovery so it
868+
// includes methods registered after startup.
869+
Object.defineProperty(meta, 'jsonSerializableMethods', {
870+
enumerable: true,
871+
get: () => getJsonSerializableMethods(ctx.rpc),
872+
})
873+
}
874+
863875
async function init(): Promise<void> {
864876
const result = await options.init(api)
865877
ctx = result.context
@@ -912,6 +924,8 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
912924
: {}),
913925
}
914926

927+
addLocalMethodHints(meta)
928+
915929
// Whatever `setup(ctx)` wrote to `ctx.staticConfig` during
916930
// `options.init(api)` (e.g. a hub aggregating each installed devframe's
917931
// own dock-bar preferences) is in by now; bake it into the meta

‎packages/hub/src/node/__tests__/initiate.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ describe('initHub', () => {
137137
// the aggregate MCP route.
138138
expect(hubRef.connectionMeta()).toEqual({
139139
backend: 'websocket',
140+
jsonSerializableMethods: expect.any(Array),
140141
websocket: { path: '/__devframes/__ws' },
141142
sse: { path: '/__devframes/__sse' },
142143
mcp: { path: '__mcp' },
@@ -750,6 +751,7 @@ describe('initHub', () => {
750751
await hub.ready
751752
expect(hub.connectionMeta()).toEqual({
752753
backend: 'websocket',
754+
jsonSerializableMethods: expect.any(Array),
753755
websocket: { path: '/__devframes/__ws' },
754756
sse: { path: '/__devframes/__sse' },
755757
mcp: { path: '__mcp' },

0 commit comments

Comments
 (0)