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/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 new file mode 100644 index 00000000..881bf5a1 --- /dev/null +++ b/packages/devframe/src/adapters/__tests__/dev-transports.test.ts @@ -0,0 +1,258 @@ +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 jsonHandler = vi.fn((value: unknown) => value) + +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: (value: unknown) => value, + })) + ctx.rpc.register(defineRpcFunction({ + name: 'test:json', + type: 'query', + jsonSerializable: true, + handler: jsonHandler, + })) + }, +}) + +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 checkRpcCalls(handle, '/', 'sse') + await checkRpcCalls(handle, '/', 'sse', 'http') + } + 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()) + 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') + await checkRpcCalls(handle, basePath, 'websocket') + await checkRpcCalls(handle, basePath, 'websocket', 'http') + } + finally { + await handle.close() + } + 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 { + expect(handle.connectionMeta().sse).toBeUndefined() + await checkRpcCalls(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(handle.connectionMeta()).not.toHaveProperty('jsonSerializableMethods') + expect((await fetch(`${handle.origin}/__connection.json`)).status).toBe(200) + } + finally { + await handle.close() + } + await expectPortReleased(handle.port) + }) +}) + +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, + metaBaseUrl: source === 'http' + ? metaBaseUrl + : handle.origin, + }, + transport, + otpParam: false, + simpleAuth: false, + }) + try { + await client.ensureTrusted(2000) + expect(client.transport).toBe(transport) + const map = new Map([ + ['key', 42], + ]) + await expect((client.call as any)('test:probe', map)).resolves.toEqual(map) + expect(handle.rpcGroup.clients.length).toBeGreaterThan(0) + jsonHandler.mockClear() + await expect((client.call as any)('test:json', { value: 42 })).resolves.toEqual({ value: 42 }) + await expect((client.call as any)('test:json', map)).rejects.toThrow(/Map/) + expect(jsonHandler).toHaveBeenCalledTimes(1) + } + finally { + client.close?.() + } +} diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 9e62dba7..df041639 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -141,6 +141,7 @@ describe('adapters/dev', () => { // 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..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' @@ -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(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({ 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 7fedfc79..3462f349 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -2,12 +2,13 @@ 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' 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' @@ -219,8 +220,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 +232,18 @@ export async function createDevServer( app, ws: transport.ws, rpcGroup: transport.rpcGroup, - connectionMeta: transport.connectionMeta, + connectionMeta() { + // 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() { // 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..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) @@ -807,11 +808,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 +850,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 } @@ -861,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 @@ -913,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' },