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] 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 }