Skip to content

Commit bf99de1

Browse files
dvcolombanantfubot
andauthored
feat(server): add onPeerDisconnect, symmetric with onPeerConnect (#166)
Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent 5fa8014 commit bf99de1

5 files changed

Lines changed: 114 additions & 1 deletion

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,50 @@ describe('adapters/dev', () => {
535535
}
536536
})
537537

538+
it('forwards onPeerConnect/onPeerDisconnect to the underlying startHttpAndWs', async () => {
539+
const devframe = defineDevframe({
540+
id: 'devframe-peer-hooks',
541+
name: 'Peer Hooks',
542+
version: '0.0.0',
543+
packageName: 'devframe-test',
544+
homepage: 'https://example.test',
545+
description: 'Test devframe.',
546+
setup: () => {},
547+
})
548+
const host = '127.0.0.1'
549+
const port = await getPort({ port: 19470, host })
550+
const onPeerConnect = vi.fn()
551+
const onPeerDisconnect = vi.fn()
552+
const handle = await createDevServer(devframe, {
553+
host,
554+
port,
555+
openBrowser: false,
556+
auth: false,
557+
onPeerConnect,
558+
onPeerDisconnect,
559+
})
560+
561+
try {
562+
const ws = new WebSocket(`ws://${host}:${port}/__devframe_ws`)
563+
await new Promise<void>((resolve, reject) => {
564+
ws.on('open', () => resolve())
565+
ws.on('error', reject)
566+
})
567+
await vi.waitFor(() => {
568+
expect(onPeerConnect).toHaveBeenCalledTimes(1)
569+
})
570+
expect(onPeerDisconnect).not.toHaveBeenCalled()
571+
572+
ws.close()
573+
await vi.waitFor(() => {
574+
expect(onPeerDisconnect).toHaveBeenCalledTimes(1)
575+
})
576+
}
577+
finally {
578+
await handle.close()
579+
}
580+
})
581+
538582
it('resolveDevServerPort honors def.cli.port as the preferred default', async () => {
539583
const preferred = await getPort({ port: 19500, host: '127.0.0.1' })
540584
const devframe = defineDevframe({

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { Peer } from 'crossws'
12
import type { DevframeAuthHandler } from '../node/auth/handler'
23
import type { StartedServer } from '../node/server'
34
import type { ConnectionMeta } from '../types/context'
45
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
6+
import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc'
57
import process from 'node:process'
68
import { open } from 'devframe/utils/open'
79
import { mountStaticHandler } from 'devframe/utils/serve-static'
@@ -87,6 +89,17 @@ export interface CreateDevServerOptions {
8789
* {@link McpRouteOptions}.
8890
*/
8991
mcp?: boolean | McpRouteOptions
92+
/**
93+
* Called once per new WS connection, right after its session is created.
94+
* Forwarded verbatim to the underlying `startHttpAndWs`.
95+
*/
96+
onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void
97+
/**
98+
* Called once per closed WS connection, right after its session's
99+
* disconnect bookkeeping runs. Forwarded verbatim to the underlying
100+
* `startHttpAndWs`.
101+
*/
102+
onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void
90103
/**
91104
* Called once the WS server is bound. Devframe stays headless
92105
* otherwise — wire this if you want a startup banner.
@@ -255,6 +268,8 @@ export async function createDevServer(
255268
path: bindPath,
256269
wsPort,
257270
auth: resolvedAuth,
271+
onPeerConnect: options.onPeerConnect,
272+
onPeerDisconnect: options.onPeerDisconnect,
258273
onReady: async (info) => {
259274
// Print the auth banner before the caller's own onReady / browser open
260275
// so the code is on screen by the time a browser lands on the page.

‎packages/devframe/src/node/__tests__/server.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,50 @@ describe('startHttpAndWs rpcOptions passthrough', () => {
101101
})
102102
})
103103

104+
describe('startHttpAndWs onPeerConnect / onPeerDisconnect', () => {
105+
it('forwards both hooks, symmetrically, for the same peer', async () => {
106+
const context = await createTestContext()
107+
const onPeerConnect = vi.fn()
108+
const onPeerDisconnect = vi.fn()
109+
const host = '127.0.0.1'
110+
const port = await getPort({ port: 0, host })
111+
const server = await startHttpAndWs({
112+
context,
113+
host,
114+
port,
115+
auth: false,
116+
onPeerConnect,
117+
onPeerDisconnect,
118+
})
119+
120+
try {
121+
const raw = new WebSocket(`ws://${host}:${port}`)
122+
await new Promise<void>((resolve, reject) => {
123+
raw.once('open', () => resolve())
124+
raw.once('error', reject)
125+
})
126+
127+
await vi.waitFor(() => {
128+
expect(onPeerConnect).toHaveBeenCalledTimes(1)
129+
})
130+
const [, session] = onPeerConnect.mock.calls[0]!
131+
expect(onPeerDisconnect).not.toHaveBeenCalled()
132+
133+
raw.close()
134+
135+
await vi.waitFor(() => {
136+
expect(onPeerDisconnect).toHaveBeenCalledTimes(1)
137+
})
138+
const [, meta] = onPeerDisconnect.mock.calls[0]!
139+
// Same underlying session — proven by the stable meta id — closing out.
140+
expect(meta.id).toBe(session.meta.id)
141+
}
142+
finally {
143+
await server.close()
144+
}
145+
})
146+
})
147+
104148
describe('startHttpAndWs listen failures', () => {
105149
it('rejects when the port is already taken instead of hanging', async () => {
106150
const host = '127.0.0.1'

‎packages/devframe/src/node/server.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export interface StartHttpAndWsOptions {
8282
* observe — but not override — the connect-time trust decision.
8383
*/
8484
onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void
85+
/**
86+
* Called once per closed WS connection, right after the transport's own
87+
* disconnect bookkeeping runs. Unlike {@link onPeerConnect} this receives
88+
* the raw session meta, not a wrapped session — by the time a peer
89+
* disconnects there is no live RPC client left to attach.
90+
*/
91+
onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void
8592
/**
8693
* Forwarded verbatim to the internal `createRpcServer`'s birpc
8794
* `rpcOptions`, alongside the resolver `startHttpAndWs` installs for
@@ -224,7 +231,8 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
224231
options.onPeerConnect?.(peer, session)
225232
}
226233
: undefined,
227-
onDisconnected: (_peer, meta) => {
234+
onDisconnected: (peer, meta) => {
235+
options.onPeerDisconnect?.(peer, meta)
228236
rpcHost._emitSessionDisconnected(meta)
229237
},
230238
})

‎tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface CreateDevServerOptions {
1313
openBrowser?: boolean | string;
1414
auth?: boolean | DevframeAuthHandler;
1515
mcp?: boolean | McpRouteOptions;
16+
onPeerConnect?: (_: Peer, _: DevframeNodeRpcSession) => void;
17+
onPeerDisconnect?: (_: Peer, _: DevframeNodeRpcSessionMeta) => void;
1618
onReady?: (_: {
1719
origin: string;
1820
port: number;

0 commit comments

Comments
 (0)