diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index cd189407..416b05a3 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -535,6 +535,50 @@ describe('adapters/dev', () => { } }) + it('forwards onPeerConnect/onPeerDisconnect to the underlying startHttpAndWs', async () => { + const devframe = defineDevframe({ + id: 'devframe-peer-hooks', + name: 'Peer Hooks', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19470, host }) + const onPeerConnect = vi.fn() + const onPeerDisconnect = vi.fn() + const handle = await createDevServer(devframe, { + host, + port, + openBrowser: false, + auth: false, + onPeerConnect, + onPeerDisconnect, + }) + + try { + const ws = new WebSocket(`ws://${host}:${port}/__devframe_ws`) + await new Promise((resolve, reject) => { + ws.on('open', () => resolve()) + ws.on('error', reject) + }) + await vi.waitFor(() => { + expect(onPeerConnect).toHaveBeenCalledTimes(1) + }) + expect(onPeerDisconnect).not.toHaveBeenCalled() + + ws.close() + await vi.waitFor(() => { + expect(onPeerDisconnect).toHaveBeenCalledTimes(1) + }) + } + finally { + await handle.close() + } + }) + it('resolveDevServerPort honors def.cli.port as the preferred default', async () => { const preferred = await getPort({ port: 19500, host: '127.0.0.1' }) const devframe = defineDevframe({ diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 2f55b369..f1e1c2f2 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,7 +1,9 @@ +import type { Peer } from 'crossws' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/server' import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' +import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc' import process from 'node:process' import { open } from 'devframe/utils/open' import { mountStaticHandler } from 'devframe/utils/serve-static' @@ -87,6 +89,17 @@ export interface CreateDevServerOptions { * {@link McpRouteOptions}. */ mcp?: boolean | McpRouteOptions + /** + * Called once per new WS connection, right after its session is created. + * Forwarded verbatim to the underlying `startHttpAndWs`. + */ + onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void + /** + * Called once per closed WS connection, right after its session's + * disconnect bookkeeping runs. Forwarded verbatim to the underlying + * `startHttpAndWs`. + */ + onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void /** * Called once the WS server is bound. Devframe stays headless * otherwise — wire this if you want a startup banner. @@ -255,6 +268,8 @@ export async function createDevServer( path: bindPath, wsPort, auth: resolvedAuth, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, onReady: async (info) => { // Print the auth banner before the caller's own onReady / browser open // so the code is on screen by the time a browser lands on the page. diff --git a/packages/devframe/src/node/__tests__/server.test.ts b/packages/devframe/src/node/__tests__/server.test.ts index 1888bb5c..d62a109a 100644 --- a/packages/devframe/src/node/__tests__/server.test.ts +++ b/packages/devframe/src/node/__tests__/server.test.ts @@ -101,6 +101,50 @@ describe('startHttpAndWs rpcOptions passthrough', () => { }) }) +describe('startHttpAndWs onPeerConnect / onPeerDisconnect', () => { + it('forwards both hooks, symmetrically, for the same peer', async () => { + const context = await createTestContext() + const onPeerConnect = vi.fn() + const onPeerDisconnect = vi.fn() + const host = '127.0.0.1' + const port = await getPort({ port: 0, host }) + const server = await startHttpAndWs({ + context, + host, + port, + auth: false, + onPeerConnect, + onPeerDisconnect, + }) + + try { + const raw = new WebSocket(`ws://${host}:${port}`) + await new Promise((resolve, reject) => { + raw.once('open', () => resolve()) + raw.once('error', reject) + }) + + await vi.waitFor(() => { + expect(onPeerConnect).toHaveBeenCalledTimes(1) + }) + const [, session] = onPeerConnect.mock.calls[0]! + expect(onPeerDisconnect).not.toHaveBeenCalled() + + raw.close() + + await vi.waitFor(() => { + expect(onPeerDisconnect).toHaveBeenCalledTimes(1) + }) + const [, meta] = onPeerDisconnect.mock.calls[0]! + // Same underlying session — proven by the stable meta id — closing out. + expect(meta.id).toBe(session.meta.id) + } + finally { + await server.close() + } + }) +}) + describe('startHttpAndWs listen failures', () => { it('rejects when the port is already taken instead of hanging', async () => { const host = '127.0.0.1' diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index a30965f3..01346b18 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -82,6 +82,13 @@ export interface StartHttpAndWsOptions { * observe — but not override — the connect-time trust decision. */ onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void + /** + * Called once per closed WS connection, right after the transport's own + * disconnect bookkeeping runs. Unlike {@link onPeerConnect} this receives + * the raw session meta, not a wrapped session — by the time a peer + * disconnects there is no live RPC client left to attach. + */ + onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void /** * Forwarded verbatim to the internal `createRpcServer`'s birpc * `rpcOptions`, alongside the resolver `startHttpAndWs` installs for @@ -224,7 +231,8 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { + onDisconnected: (peer, meta) => { + options.onPeerDisconnect?.(peer, meta) rpcHost._emitSessionDisconnected(meta) }, }) diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index 21b409af..37d73498 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -13,6 +13,8 @@ export interface CreateDevServerOptions { openBrowser?: boolean | string; auth?: boolean | DevframeAuthHandler; mcp?: boolean | McpRouteOptions; + onPeerConnect?: (_: Peer, _: DevframeNodeRpcSession) => void; + onPeerDisconnect?: (_: Peer, _: DevframeNodeRpcSessionMeta) => void; onReady?: (_: { origin: string; port: number;