From c87d01c7257e75573d7d8b704996969b77931f37 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Thu, 5 Nov 2026 15:25:12 +0800 Subject: [PATCH 1/4] fix(devframe): return complete dev server handles for SSE Keep the shared HTTP handle and RPC group when WebSocket is disabled. Return instance connection metadata while retaining transport JSON encoding hints. Cover SSE-only startup and disposal, returned-descriptor RPC over both transports, nested and renamed routes, and transport-disabled controls. --- .../adapters/__tests__/dev-transports.test.ts | 163 ++++++++++++++++++ packages/devframe/src/adapters/dev.ts | 8 +- packages/devframe/src/node/instance-shell.ts | 9 +- 3 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 packages/devframe/src/adapters/__tests__/dev-transports.test.ts diff --git a/packages/devframe/src/adapters/__tests__/dev-transports.test.ts b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts new file mode 100644 index 00000000..98daa42a --- /dev/null +++ b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts @@ -0,0 +1,163 @@ +import type { CreateDevServerOptions } from '../dev' +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { defineDevframe, defineRpcFunction } from 'devframe' +import { getDevframeRpcClient, resolveClientTransport } from 'devframe/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readDevframeInstances } from '../../node/instance-registry' +import { createDevServer } from '../dev' + +const definition = defineDevframe({ + id: 'dev-transports-test', + name: 'Dev transports test', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'test:probe', + type: 'query', + handler: () => 'connected', + })) + ctx.rpc.register(defineRpcFunction({ + name: 'test:json', + type: 'query', + jsonSerializable: true, + handler: (value: unknown) => value, + })) + }, +}) + +const options = { + host: '127.0.0.1', + port: 0, + auth: false, + mcp: false, + openBrowser: false, +} as const + +async function expectPortReleased(port: number) { + const listener = createServer() + await new Promise((resolve, reject) => { + listener.once('error', reject) + listener.listen(port, options.host, resolve) + }) + await new Promise((resolve, reject) => listener.close(error => error + ? reject(error) + : resolve())) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('dev server transport handle', () => { + it('returns and disposes an SSE-only handle and its registry entry', async () => { + const registryDir = await mkdtemp(join(tmpdir(), 'dev-transports-')) + vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir) + vi.stubEnv('DEVFRAME_DISABLE_INSTANCE_REGISTRY', '0') + try { + const handle = await createDevServer(definition, { ...options, ws: false }) + try { + expect(handle.ws).toBeUndefined() + expect(handle.rpcGroup).toBeDefined() + expect(handle.connectionMeta()).toMatchObject({ + backend: 'sse', + sse: { path: '__sse' }, + }) + await vi.waitFor(() => expect(readDevframeInstances({ instancesDir: registryDir })).toHaveLength(1)) + await callThroughHandle(handle, '/', 'sse') + } + finally { + await handle.close() + } + expect(readDevframeInstances({ instancesDir: registryDir })).toEqual([]) + await expectPortReleased(handle.port) + } + finally { + await rm(registryDir, { recursive: true, force: true }) + } + }) + + it.each([ + { basePath: '/', sse: true }, + { + basePath: '/nested/tool/', + sse: { route: '__events' }, + }, + ] satisfies CreateDevServerOptions[])('advertises and connects both transports at $basePath', async ({ basePath, sse }) => { + const handle = await createDevServer(definition, { ...options, basePath, sse }) + try { + const httpMeta = await fetch(`${handle.origin}${basePath}__connection.json`).then(response => response.json()) + expect(handle.connectionMeta()).toMatchObject(httpMeta) + expect(handle.connectionMeta().jsonSerializableMethods).toContain('test:json') + expect(resolveClientTransport('auto', handle.connectionMeta())).toBe('websocket') + await callThroughHandle(handle, basePath, 'sse') + await callThroughHandle(handle, basePath, 'websocket') + } + finally { + await handle.close() + } + await expectPortReleased(handle.port) + }) + + it('preserves WebSocket-only operation', async () => { + const handle = await createDevServer(definition, { ...options, sse: false }) + try { + expect(handle.connectionMeta().sse).toBeUndefined() + await callThroughHandle(handle, '/', 'websocket') + } + finally { + await handle.close() + } + }) + + it('returns a closable HTTP handle with both RPC transports disabled', async () => { + const handle = await createDevServer(definition, { ...options, ws: false, sse: false }) + try { + expect(handle.ws).toBeUndefined() + expect(handle.rpcGroup).toBeDefined() + expect(handle.connectionMeta()).toMatchObject({ backend: 'none' }) + expect(handle.connectionMeta().websocket).toBeUndefined() + expect(handle.connectionMeta().sse).toBeUndefined() + expect((await fetch(`${handle.origin}/__connection.json`)).status).toBe(200) + } + finally { + await handle.close() + } + await expectPortReleased(handle.port) + }) +}) + +async function callThroughHandle(handle: Awaited>, basePath: string, transport: 'sse' | 'websocket') { + vi.stubGlobal('location', new URL(`${handle.origin}${basePath}`)) + vi.stubGlobal('navigator', { userAgent: 'vitest' }) + const client = await getDevframeRpcClient({ + connection: { + connectionMeta: handle.connectionMeta(), + metaBaseUrl: `${handle.origin}${basePath}__connection.json`, + }, + transport, + otpParam: false, + simpleAuth: false, + }) + try { + await client.ensureTrusted(2000) + expect(client.transport).toBe(transport) + await expect((client.call as any)('test:probe')).resolves.toBe('connected') + expect(handle.rpcGroup.clients.length).toBeGreaterThan(0) + if (handle.connectionMeta().jsonSerializableMethods?.includes('test:json')) { + await expect((client.call as any)('test:json', { value: 42 })).resolves.toEqual({ value: 42 }) + await expect((client.call as any)('test:json', new Map([ + ['key', 42], + ]))).rejects.toThrow() + } + } + finally { + client.close?.() + } +} diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 7fedfc79..ece0775a 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -219,8 +219,7 @@ export async function createDevServer( } const internals = getInstanceInternals(devframe) - // Every dev-server configuration binds a local transport (`server` is - // always passed), so the bound-transport handle is always present. + // The supplied HTTP server has a handle even when WS is disabled. const transport = internals.started! await options.onReady?.({ origin, port, app }) @@ -232,7 +231,10 @@ export async function createDevServer( app, ws: transport.ws, rpcGroup: transport.rpcGroup, - connectionMeta: transport.connectionMeta, + connectionMeta: () => ({ + ...transport.connectionMeta(), + ...devframe.connectionMeta(), + }), async close() { // Instance teardown removes the registry record, detaches the WS // transport (and closes any dedicated-port socket server), and disposes diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 434ea339..5502141e 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -807,11 +807,10 @@ export function createInstanceShell( throw lastError } - // Shared-server tier: bind the WS upgrade on the host's own server, or - // (when a native runtime can't re-host that foreign `node:http` server) fall - // back to a WS-less `StartedServer` and let SSE carry the transport. + // Shared HTTP servers retain a handle and RPC group even when WS is + // disabled or a native runtime cannot bind upgrades on the supplied server. async function startServerTier(): Promise { - if (nativeRuntime && !ws.url) { + if (wsDisabled || (nativeRuntime && !ws.url)) { const core = await ensureCore() started = { origin: currentOrigin() ?? '', @@ -850,7 +849,7 @@ export function createInstanceShell( wsAvailable: !wsDisabled, } } - if (tier === 'server') + if (tier === 'server' || (tier === 'disabled' && options.server)) return startServerTier() if (tier === 'external') return { websocketMeta: ws.url!, wsAvailable: !wsDisabled } From 70282fac5ee4affe9d561989bf659c6505c1ab22 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Sat, 7 Nov 2026 15:47:12 +0800 Subject: [PATCH 2/4] 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. --- .../tests/next-devframe-hub.test.ts | 1 + .../adapters/__tests__/dev-transports.test.ts | 42 ++++++++++++------- .../src/adapters/__tests__/dev.test.ts | 5 +++ .../src/adapters/__tests__/initiate.test.ts | 14 +++++++ packages/devframe/src/adapters/dev.ts | 1 - packages/devframe/src/node/instance-shell.ts | 30 +++++++++---- .../hub/src/node/__tests__/initiate.test.ts | 2 + 7 files changed, 70 insertions(+), 25 deletions(-) diff --git a/examples/custom-hub-next/tests/next-devframe-hub.test.ts b/examples/custom-hub-next/tests/next-devframe-hub.test.ts index c925de50..e3913445 100644 --- a/examples/custom-hub-next/tests/next-devframe-hub.test.ts +++ b/examples/custom-hub-next/tests/next-devframe-hub.test.ts @@ -54,6 +54,7 @@ describe('next-devframe-hub (example)', () => { expect(hub.connectionMeta()).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.arrayContaining(['devframes:plugin:assets:list']), websocket: { port, path: '__ws' }, sse: { path: '/__devframes/__sse' }, mcp: { path: '__mcp' }, diff --git a/packages/devframe/src/adapters/__tests__/dev-transports.test.ts b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts index 98daa42a..236e2819 100644 --- a/packages/devframe/src/adapters/__tests__/dev-transports.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts @@ -9,6 +9,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { readDevframeInstances } from '../../node/instance-registry' import { createDevServer } from '../dev' +const jsonHandler = vi.fn((value: unknown) => value) + const definition = defineDevframe({ id: 'dev-transports-test', name: 'Dev transports test', @@ -20,13 +22,13 @@ const definition = defineDevframe({ ctx.rpc.register(defineRpcFunction({ name: 'test:probe', type: 'query', - handler: () => 'connected', + handler: (value: unknown) => value, })) ctx.rpc.register(defineRpcFunction({ name: 'test:json', type: 'query', jsonSerializable: true, - handler: (value: unknown) => value, + handler: jsonHandler, })) }, }) @@ -70,7 +72,8 @@ describe('dev server transport handle', () => { sse: { path: '__sse' }, }) await vi.waitFor(() => expect(readDevframeInstances({ instancesDir: registryDir })).toHaveLength(1)) - await callThroughHandle(handle, '/', 'sse') + await checkRpcCalls(handle, '/', 'sse') + await checkRpcCalls(handle, '/', 'sse', 'http') } finally { await handle.close() @@ -96,8 +99,9 @@ describe('dev server transport handle', () => { expect(handle.connectionMeta()).toMatchObject(httpMeta) expect(handle.connectionMeta().jsonSerializableMethods).toContain('test:json') expect(resolveClientTransport('auto', handle.connectionMeta())).toBe('websocket') - await callThroughHandle(handle, basePath, 'sse') - await callThroughHandle(handle, basePath, 'websocket') + await checkRpcCalls(handle, basePath, 'sse') + await checkRpcCalls(handle, basePath, 'websocket') + await checkRpcCalls(handle, basePath, 'websocket', 'http') } finally { await handle.close() @@ -109,7 +113,7 @@ describe('dev server transport handle', () => { const handle = await createDevServer(definition, { ...options, sse: false }) try { expect(handle.connectionMeta().sse).toBeUndefined() - await callThroughHandle(handle, '/', 'websocket') + await checkRpcCalls(handle, '/', 'websocket') } finally { await handle.close() @@ -124,6 +128,7 @@ describe('dev server transport handle', () => { expect(handle.connectionMeta()).toMatchObject({ backend: 'none' }) expect(handle.connectionMeta().websocket).toBeUndefined() expect(handle.connectionMeta().sse).toBeUndefined() + expect(handle.connectionMeta()).not.toHaveProperty('jsonSerializableMethods') expect((await fetch(`${handle.origin}/__connection.json`)).status).toBe(200) } finally { @@ -133,13 +138,17 @@ describe('dev server transport handle', () => { }) }) -async function callThroughHandle(handle: Awaited>, basePath: string, transport: 'sse' | 'websocket') { +async function checkRpcCalls(handle: Awaited>, basePath: string, transport: 'sse' | 'websocket', source: 'handle' | 'http' = 'handle') { vi.stubGlobal('location', new URL(`${handle.origin}${basePath}`)) vi.stubGlobal('navigator', { userAgent: 'vitest' }) + const metaBaseUrl = `${handle.origin}${basePath}__connection.json` + const connectionMeta = source === 'http' + ? await fetch(metaBaseUrl).then(response => response.json()) + : handle.connectionMeta() const client = await getDevframeRpcClient({ connection: { - connectionMeta: handle.connectionMeta(), - metaBaseUrl: `${handle.origin}${basePath}__connection.json`, + connectionMeta, + metaBaseUrl, }, transport, otpParam: false, @@ -148,14 +157,15 @@ async function callThroughHandle(handle: Awaited { // relative to `__connection.json`, never a baked-in host/port. expect(meta).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '__ws' }, sse: { path: '__sse' }, }) @@ -250,6 +251,7 @@ describe('adapters/dev', () => { const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() expect(meta).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '__sockets' }, sse: { path: '__sse' }, }) @@ -290,6 +292,7 @@ describe('adapters/dev', () => { const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() expect(meta).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { port: wsPort, path: '__ws' }, sse: { path: '__sse' }, }) @@ -334,6 +337,7 @@ describe('adapters/dev', () => { // endpoint stays same-origin and rides the tunnel like the meta itself. expect(meta).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: 'wss://devtools.example.com/relay/__ws', sse: { path: '__sse' }, }) @@ -462,6 +466,7 @@ describe('adapters/dev', () => { const meta = await res.json() expect(meta).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '__ws' }, sse: { path: '__sse' }, }) diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 286c2594..c6f3a1dd 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -75,6 +75,7 @@ describe('adapters/handler', () => { // The advertised meta carries the side-car port with the unified route. expect(devtools.connectionMeta()).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { port: wsPort, path: '__ws' }, sse: { path: '__sse' }, }) @@ -84,10 +85,18 @@ describe('adapters/handler', () => { expect(index.status).toBe(200) expect(await index.text()).toContain('handler test') + const ctx = await devtools.context + ctx.rpc.register({ + name: 'test:late-json', + type: 'query', + jsonSerializable: true, + handler: () => 'ok', + }) const metaRes = await devtools.handler(new Request('http://localhost:3000/__handler-test/__connection.json')) expect(metaRes.status).toBe(200) expect(await metaRes.json()).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.arrayContaining(['test:late-json']), websocket: { port: wsPort, path: '__ws' }, sse: { path: '__sse' }, }) @@ -183,6 +192,7 @@ describe('adapters/handler', () => { // resolved against __connection.json's own URL. expect(devtoolsRef.connectionMeta()).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '__ws' }, sse: { path: '__sse' }, }) @@ -194,6 +204,7 @@ describe('adapters/handler', () => { const meta = await (await fetch(`http://${host}:${port}/__handler-shared/__connection.json`)).json() expect(meta).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '__ws' }, sse: { path: '__sse' }, }) @@ -272,6 +283,7 @@ describe('adapters/handler', () => { const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() expect(meta).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '__ws' }, sse: { path: '__sse' }, }) @@ -346,6 +358,7 @@ describe('adapters/handler', () => { try { await devtools.ready + expect(devtools.connectionMeta()).not.toHaveProperty('jsonSerializableMethods') expect(devtools.connectionMeta()).toEqual({ backend: 'websocket', websocket: 'wss://devtools.example.com/relay/__ws', @@ -599,6 +612,7 @@ describe('adapters/handler', () => { // Advertised as a same-origin route, with no port of its own. expect(devtools.connectionMeta()).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '__ws' }, sse: { path: '__sse' }, }) diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index ece0775a..4d8eb25e 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -232,7 +232,6 @@ export async function createDevServer( ws: transport.ws, rpcGroup: transport.rpcGroup, connectionMeta: () => ({ - ...transport.connectionMeta(), ...devframe.connectionMeta(), }), async close() { diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 5502141e..4f994e5c 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -9,7 +9,6 @@ import type { Duplex } from 'node:stream' import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../types' import type { DevframeSseOptions, DevframeWsOptions } from '../types/devframe' import type { DevframeAuthHandler } from './auth' -import type { RpcFunctionsHostImpl } from './host-functions' import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry' import type { ContextRpcServer } from './rpc-core' import { createServer } from 'node:http' @@ -74,16 +73,20 @@ interface TierBinding { wsAvailable: boolean } -function websocketConnectionMeta(rpcHost: RpcFunctionsHostImpl, path: string | undefined): ConnectionMeta { +function getJsonSerializableMethods(rpc: DevframeNodeContext['rpc']): string[] { const jsonSerializableMethods: string[] = [] - for (const def of rpcHost.definitions.values()) { + for (const def of rpc.definitions.values()) { if (def.jsonSerializable === true) jsonSerializableMethods.push(def.name) } + return jsonSerializableMethods +} + +function websocketConnectionMeta(rpc: DevframeNodeContext['rpc'], path: string | undefined): ConnectionMeta { return { backend: 'websocket', websocket: { path }, - jsonSerializableMethods, + jsonSerializableMethods: getJsonSerializableMethods(rpc), } } @@ -101,7 +104,6 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise websocketConnectionMeta(rpcHost, options.path), + connectionMeta: () => websocketConnectionMeta(context.rpc, options.path), async close() { httpServer.off('listening', publishWsEndpoint) await closeWs() @@ -246,7 +248,6 @@ async function bindNativeHttpAndWs( const { context, port, core } = options const bindHost = options.host const app = new H3App() - const rpcHost = context.rpc as RpcFunctionsHostImpl const wsPath = options.path const fail = async (closeWs: () => Promise, error: unknown): Promise => { @@ -331,7 +332,7 @@ async function bindNativeHttpAndWs( app, ws: undefined, rpcGroup: core.rpcGroup, - connectionMeta: () => websocketConnectionMeta(rpcHost, options.path), + connectionMeta: () => websocketConnectionMeta(context.rpc, options.path), async close() { // Stop the native server before the WS tier: closing the socket while a // peer is mid-disconnect (a client that just sent its close frame) @@ -860,6 +861,17 @@ export function createInstanceShell( } } + function addLocalMethodHints(meta: ConnectionMeta): void { + if (tier === 'external' || meta.backend === 'none') + return + // Routes retain this descriptor. Read the registry on discovery so it + // includes methods registered after startup. + Object.defineProperty(meta, 'jsonSerializableMethods', { + enumerable: true, + get: () => getJsonSerializableMethods(ctx.rpc), + }) + } + async function init(): Promise { const result = await options.init(api) ctx = result.context @@ -912,6 +924,8 @@ export function createInstanceShell( : {}), } + addLocalMethodHints(meta) + // Whatever `setup(ctx)` wrote to `ctx.staticConfig` during // `options.init(api)` (e.g. a hub aggregating each installed devframe's // own dock-bar preferences) is in by now; bake it into the meta diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 4cb844ee..de1f595e 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -137,6 +137,7 @@ describe('initHub', () => { // the aggregate MCP route. expect(hubRef.connectionMeta()).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '/__devframes/__ws' }, sse: { path: '/__devframes/__sse' }, mcp: { path: '__mcp' }, @@ -750,6 +751,7 @@ describe('initHub', () => { await hub.ready expect(hub.connectionMeta()).toEqual({ backend: 'websocket', + jsonSerializableMethods: expect.any(Array), websocket: { path: '/__devframes/__ws' }, sse: { path: '/__devframes/__sse' }, mcp: { path: '__mcp' }, From 41b1f6641a90c95d715427125b0cca398bf5e0c9 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Sat, 7 Nov 2026 21:04:50 +0800 Subject: [PATCH 3/4] fix(devframe): preserve mounted RPC paths in dev server snapshots Return a fresh connection descriptor for each handle call so a viewer can edit its copy without changing HTTP discovery. Resolve local RPC paths from the mount so viewers can connect from the server origin. --- .../adapters/__tests__/dev-transports.test.ts | 25 ++++++++++++++++--- .../src/adapters/__tests__/initiate.test.ts | 6 ++--- packages/devframe/src/adapters/dev.ts | 14 ++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/devframe/src/adapters/__tests__/dev-transports.test.ts b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts index 236e2819..f1952f1b 100644 --- a/packages/devframe/src/adapters/__tests__/dev-transports.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts @@ -69,7 +69,7 @@ describe('dev server transport handle', () => { expect(handle.rpcGroup).toBeDefined() expect(handle.connectionMeta()).toMatchObject({ backend: 'sse', - sse: { path: '__sse' }, + sse: { path: '/__sse' }, }) await vi.waitFor(() => expect(readDevframeInstances({ instancesDir: registryDir })).toHaveLength(1)) await checkRpcCalls(handle, '/', 'sse') @@ -96,7 +96,24 @@ describe('dev server transport handle', () => { const handle = await createDevServer(definition, { ...options, basePath, sse }) try { const httpMeta = await fetch(`${handle.origin}${basePath}__connection.json`).then(response => response.json()) - expect(handle.connectionMeta()).toMatchObject(httpMeta) + const sseRoute = typeof sse === 'object' + ? sse.route + : '__sse' + expect(httpMeta).toMatchObject({ + websocket: { path: '__ws' }, + sse: { path: sseRoute }, + }) + const snapshot = handle.connectionMeta() + expect(snapshot).toEqual({ + ...httpMeta, + websocket: { path: `${basePath}__ws` }, + sse: { path: `${basePath}${sseRoute}` }, + }) + expect(typeof snapshot.websocket).toBe('object') + if (typeof snapshot.websocket === 'object') + snapshot.websocket.path = '/viewer/__ws' + expect(handle.connectionMeta().websocket).toEqual({ path: `${basePath}__ws` }) + expect(await fetch(`${handle.origin}${basePath}__connection.json`).then(response => response.json())).toEqual(httpMeta) expect(handle.connectionMeta().jsonSerializableMethods).toContain('test:json') expect(resolveClientTransport('auto', handle.connectionMeta())).toBe('websocket') await checkRpcCalls(handle, basePath, 'sse') @@ -148,7 +165,9 @@ async function checkRpcCalls(handle: Awaited> const client = await getDevframeRpcClient({ connection: { connectionMeta, - metaBaseUrl, + metaBaseUrl: source === 'http' + ? metaBaseUrl + : handle.origin, }, transport, otpParam: false, diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index c6f3a1dd..a87e436b 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -6,7 +6,7 @@ import { createSecureServer } from 'node:http2' import { isIP } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { defineDevframe } from 'devframe' +import { defineDevframe, defineRpcFunction } from 'devframe' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' @@ -86,12 +86,12 @@ describe('adapters/handler', () => { expect(await index.text()).toContain('handler test') const ctx = await devtools.context - ctx.rpc.register({ + ctx.rpc.register(defineRpcFunction({ name: 'test:late-json', type: 'query', jsonSerializable: true, handler: () => 'ok', - }) + })) const metaRes = await devtools.handler(new Request('http://localhost:3000/__handler-test/__connection.json')) expect(metaRes.status).toBe(200) expect(await metaRes.json()).toEqual({ diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 4d8eb25e..8f61059a 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -7,7 +7,7 @@ import type { StaticAssetsSource } from '../types/remote-assets' import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc' import { createServer } from 'node:http' import { open } from 'devframe/utils/open' -import { withBase } from 'devframe/utils/url' +import { joinURL, withBase } from 'devframe/utils/url' import { H3, toNodeHandler } from 'h3' import { diagnostics } from '../node/diagnostics' import { isAddressInUse, listenWithLoopbackTwin } from '../node/listen' @@ -231,9 +231,15 @@ export async function createDevServer( app, ws: transport.ws, rpcGroup: transport.rpcGroup, - connectionMeta: () => ({ - ...devframe.connectionMeta(), - }), + connectionMeta() { + const meta = structuredClone(devframe.connectionMeta()) + // A caller can pass the handle to a viewer outside the devframe mount. + for (const endpoint of [meta.websocket, meta.sse]) { + if (typeof endpoint === 'object' && endpoint.host == null && endpoint.port == null && endpoint.path != null) + endpoint.path = joinURL(basePath, endpoint.path) + } + return meta + }, async close() { // Instance teardown removes the registry record, detaches the WS // transport (and closes any dedicated-port socket server), and disposes From fd48a7c228b9b6d2ad062e5d42c83c9e284bc1ad Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Sun, 8 Nov 2026 14:30:35 +0800 Subject: [PATCH 4/4] fix(devframe): align handle snapshots with HTTP discovery Serialize connection metadata using the discovery JSON format so static configuration can supply values with toJSON methods. Resolve same-origin MCP paths against the mount base for callers that receive the handle. Exercise both discovery paths with a real MCP initialize request and compare the published representation of a URL in static configuration. --- docs/content/2.adapters/3.dev.md | 6 +- .../adapters/__tests__/dev-transports.test.ts | 66 +++++++++++++++++++ packages/devframe/src/adapters/dev.ts | 8 ++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/content/2.adapters/3.dev.md b/docs/content/2.adapters/3.dev.md index 55296a98..7ea03379 100644 --- a/docs/content/2.adapters/3.dev.md +++ b/docs/content/2.adapters/3.dev.md @@ -20,7 +20,9 @@ const handle = await createDevServer(myDevframe, { process.on('SIGINT', () => handle.close().then(() => process.exit(0))) ``` -Returns a `StartedServer`: origin, port, h3 app, WS server, RPC group, `close()`. +The returned `StartedServer` exposes `origin`, `port`, `app`, `rpcGroup`, `connectionMeta()` and `close()`. Its `ws` property is optional. Call `close()` to release the HTTP listener even with WebSocket disabled. + +Each call to `handle.connectionMeta()` returns a fresh JSON snapshot. Resolve local WebSocket, SSE and MCP paths against `handle.origin`. When reading `__connection.json` over HTTP, resolve its relative paths against the document URL. | Option | Default | Description | |--------|---------|-------------| @@ -36,7 +38,7 @@ Returns a `StartedServer`: origin, port, h3 app, WS server, RPC group, `close()` ## WebSocket endpoint -The RPC socket shares the HTTP port at `__ws`, advertised *relative*. Configure `def.cli.ws`: +By default the RPC socket shares the HTTP port. The discovery document advertises its relative path as `__ws`. Configure `def.cli.ws`: | Field | Scenario | Advertised `websocket` | |-------|----------|------------------------| diff --git a/packages/devframe/src/adapters/__tests__/dev-transports.test.ts b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts index f1952f1b..881bf5a1 100644 --- a/packages/devframe/src/adapters/__tests__/dev-transports.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts @@ -126,6 +126,72 @@ describe('dev server transport handle', () => { await expectPortReleased(handle.port) }) + it('initializes MCP from both descriptors under a nested mount', async () => { + const basePath = '/nested/tool/' + const handle = await createDevServer(definition, { + ...options, + basePath, + mcp: { path: 'tools/mcp' }, + }) + try { + const discovery = new URL(`${basePath}__connection.json`, handle.origin) + const httpMeta = await fetch(discovery).then(response => response.json()) + expect(httpMeta.mcp.path).toBe('tools/mcp') + + const addresses = [ + new URL(httpMeta.mcp.path, discovery), + new URL(handle.connectionMeta().mcp!.path, handle.origin), + ] + for (const address of addresses) { + const response = await fetch(address, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + 'origin': handle.origin, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + }, + }), + }) + expect(response.status).toBe(200) + expect(await response.text()).toContain('"serverInfo"') + } + } + finally { + await handle.close() + } + }) + + it('returns static configuration in the same JSON form as HTTP discovery', async () => { + const url = new URL('https://example.test/help') + const handle = await createDevServer(defineDevframe({ + ...definition, + setup(ctx) { + Object.assign(ctx.staticConfig, { + help: { url }, + }) + }, + }), options) + try { + const discovery = await fetch(new URL('/__connection.json', handle.origin)).then(response => response.json()) + expect(discovery.configs).toEqual({ + help: { url: url.href }, + }) + expect(handle.connectionMeta().configs).toEqual(discovery.configs) + } + finally { + await handle.close() + } + }) + it('preserves WebSocket-only operation', async () => { const handle = await createDevServer(definition, { ...options, sse: false }) try { diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 8f61059a..3462f349 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -2,6 +2,7 @@ import type { DevframeRpcConnection, WsOriginRegistry } from 'devframe/rpc/trans import type { Server } from 'node:http' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/instance-shell' +import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpSetting } from '../types/devframe' import type { StaticAssetsSource } from '../types/remote-assets' import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc' @@ -232,12 +233,15 @@ export async function createDevServer( ws: transport.ws, rpcGroup: transport.rpcGroup, connectionMeta() { - const meta = structuredClone(devframe.connectionMeta()) - // A caller can pass the handle to a viewer outside the devframe mount. + // Match the JSON form published by HTTP discovery, including custom toJSON methods. + const meta: ConnectionMeta = JSON.parse(JSON.stringify(devframe.connectionMeta())) + // Callers resolve these paths against the handle origin. for (const endpoint of [meta.websocket, meta.sse]) { if (typeof endpoint === 'object' && endpoint.host == null && endpoint.port == null && endpoint.path != null) endpoint.path = joinURL(basePath, endpoint.path) } + if (meta.mcp && meta.mcp.port == null) + meta.mcp.path = joinURL(basePath, meta.mcp.path) return meta }, async close() {