Skip to content

Commit 8cc1bc6

Browse files
committed
feat(client): add DevframeRpcClient.close()
`DevframeRpcClient` has no `close()`/`dispose()` of any kind — a connection lives until the process ends, with no way for a caller to tear it down. The server transport already has the symmetric piece: `WsRpcTransport.close()` (attachWsRpcTransport) detaches upgrade routing, force-closes every connected peer, and closes any server it created itself. The client side has never had an equivalent. This bites any caller that races a connection attempt against its own deadline (`Promise.race([connectDevframe(...), timeout])`) — the deadline can't cancel the loser, so a slow-to-resolve attempt becomes a fully connected, unreferenced client with nothing able to close it. ## Changes - `createWsRpcChannel` (rpc/transports/ws-client.ts) returns `close()`, closing the underlying `WebSocket`. Widens its return type to `ChannelOptions & { close: () => void }`, since birpc's own `ChannelOptions` has no teardown of its own. - `createWsRpcClientMode` hoists its channel to a local so it can close it, and exposes `close()` on `DevframeRpcClientMode`. - `createStaticRpcClientMode` gets a no-op `close()` — a static backend has no live socket, every call is a local fetch — so the two modes stay union-compatible. - `DevframeRpcClient.close()` delegates to the mode. No behavior change for anyone not calling it. ## Tests - `ws-client.test.ts` (new): `close()` closes the underlying `WebSocket`. - `rpc-ws-status.test.ts`: `createWsRpcClientMode`'s `close()` closes its socket. - `rpc.test.ts`: `getDevframeRpcClient`'s `close()` closes the socket on a `websocket` backend, and is a no-op (not a throw) on a `static` one. - `rpc-auth-gate.test.ts`: updated its hand-typed `DevframeRpcClientMode` mock for the new required field. `pnpm --filter devframe exec tsc --noEmit` clean. `pnpm exec vitest run --project devframe` — 51 files, 463 tests (was 459), all green. Also ran `--project @devframes/hub --project @devframes/hub-ui` (129 tests) and `tsc --noEmit` across `@devframes/hub`, `@devframes/hub-ui`, `@devframes/nuxt` — the packages consuming `DevframeRpcClient` — clean. `eslint` clean on every touched file. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 8c18a2f commit 8cc1bc6

8 files changed

Lines changed: 138 additions & 26 deletions

File tree

packages/devframe/src/client/rpc-auth-gate.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ vi.mock('./rpc-ws', () => ({
3737
call: fakeMode.call as DevframeRpcClientMode['call'],
3838
callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'],
3939
callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'],
40+
close: () => {},
4041
})),
4142
}))
4243

packages/devframe/src/client/rpc-static.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,7 @@ export async function createStaticRpcClientMode(
3535
args[0] as string,
3636
args.slice(1),
3737
),
38+
// No live socket to close — every call is a local fetch.
39+
close: () => {},
3840
}
3941
}

packages/devframe/src/client/rpc-ws-status.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,13 @@ describe('ws client connection status', () => {
148148
expect(rpcErrors[0].error).toBeInstanceOf(DevframeConnectionError)
149149
expect(rpcErrors[0].method).toBe('demo:method')
150150
})
151+
152+
it('close() closes the underlying socket', () => {
153+
const { mode, ws } = setup()
154+
const closeSpy = vi.spyOn(ws, 'close')
155+
156+
mode.close()
157+
158+
expect(closeSpy).toHaveBeenCalledTimes(1)
159+
})
151160
})

packages/devframe/src/client/rpc-ws.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -206,34 +206,37 @@ export function createWsRpcClientMode(
206206
for (const name of connectionMeta.jsonSerializableMethods ?? [])
207207
definitions.set(name, { jsonSerializable: true })
208208

209+
// Hoisted out of the `createRpcClient` call so `close()` below can reach it — birpc's own
210+
// `ChannelOptions` carries no reference back to what it was built from.
211+
const channel = createWsRpcChannel({
212+
url,
213+
authToken,
214+
definitions,
215+
...wsOptions,
216+
onConnected(event) {
217+
// Socket open — the trust handshake (already queued) settles the
218+
// status to `connected`/`unauthorized`. Stay `connecting` until then.
219+
wsOptions.onConnected?.(event)
220+
},
221+
onError(error) {
222+
setStatus('error', error)
223+
events.emit('connection:error', error)
224+
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Connection to the devframe server failed', { cause: error }))
225+
wsOptions.onError?.(error)
226+
},
227+
onDisconnected(event) {
228+
// A clean close after we were connected, or a socket that never
229+
// opened — either way calls can no longer be served.
230+
if (status !== 'error')
231+
setStatus('disconnected')
232+
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Disconnected from the devframe server', { cause: connectionError ?? undefined }))
233+
wsOptions.onDisconnected?.(event)
234+
},
235+
})
209236
const serverRpc = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
210237
clientRpc.functions,
211238
{
212-
channel: createWsRpcChannel({
213-
url,
214-
authToken,
215-
definitions,
216-
...wsOptions,
217-
onConnected(event) {
218-
// Socket open — the trust handshake (already queued) settles the
219-
// status to `connected`/`unauthorized`. Stay `connecting` until then.
220-
wsOptions.onConnected?.(event)
221-
},
222-
onError(error) {
223-
setStatus('error', error)
224-
events.emit('connection:error', error)
225-
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Connection to the devframe server failed', { cause: error }))
226-
wsOptions.onError?.(error)
227-
},
228-
onDisconnected(event) {
229-
// A clean close after we were connected, or a socket that never
230-
// opened — either way calls can no longer be served.
231-
if (status !== 'error')
232-
setStatus('disconnected')
233-
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Disconnected from the devframe server', { cause: connectionError ?? undefined }))
234-
wsOptions.onDisconnected?.(event)
235-
},
236-
}),
239+
channel,
237240
rpcOptions,
238241
},
239242
)
@@ -402,5 +405,8 @@ export function createWsRpcClientMode(
402405
method,
403406
)
404407
},
408+
close: () => {
409+
channel.close()
410+
},
405411
}
406412
}

packages/devframe/src/client/rpc.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,39 @@ describe('getDevframeRpcClient — connection meta base', () => {
159159
// An explicit meta resolves against the client's own base.
160160
expect(lastWsUrl()).toBe('ws://localhost:5173/__foo/__ws')
161161
})
162+
163+
it('close() closes the underlying socket', async () => {
164+
const served: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
165+
vi.stubGlobal('fetch', vi.fn(async () => ({
166+
ok: true,
167+
status: 200,
168+
json: async () => served,
169+
}) as any))
170+
171+
const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })
172+
const ws = FakeWebSocket.instances.at(-1)!
173+
const closeSpy = vi.spyOn(ws, 'close')
174+
175+
rpc.close()
176+
177+
expect(closeSpy).toHaveBeenCalledTimes(1)
178+
})
179+
180+
it('close() on a static backend is a no-op, not a throw', async () => {
181+
vi.stubGlobal('fetch', vi.fn(async (url: string) => ({
182+
ok: true,
183+
status: 200,
184+
json: async () => (
185+
url.includes('__rpc-dump')
186+
? {} // an empty manifest is a valid (if trivial) StaticRpcManifest
187+
: { backend: 'static' } satisfies ConnectionMeta
188+
),
189+
}) as any))
190+
191+
const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })
192+
193+
expect(() => rpc.close()).not.toThrow()
194+
// Static backends never open a socket in the first place.
195+
expect(FakeWebSocket.instances).toHaveLength(0)
196+
})
162197
})

packages/devframe/src/client/rpc.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,16 @@ export interface DevframeRpcClient {
195195
<NS extends string>(namespace: NS): DevframeScopedClientContext<NS, SettingsForNamespace<NS>>
196196
(namespace?: null | ''): DevframeRpcClient
197197
}
198+
199+
/**
200+
* Close the connection. A `static` backend is a no-op (there is no live socket to close);
201+
* a `websocket` backend closes the underlying `WebSocket`, which the server observes as a
202+
* normal disconnect. Mirrors {@link WsRpcTransport.close} on the server side.
203+
*
204+
* There is no corresponding "reconnect" — a closed client is done. Discard it and call
205+
* {@link getDevframeRpcClient} again to reconnect.
206+
*/
207+
close: () => void
198208
}
199209

200210
export interface DevframeRpcClientMode {
@@ -212,6 +222,8 @@ export interface DevframeRpcClientMode {
212222
call: DevframeRpcClient['call']
213223
callEvent: DevframeRpcClient['callEvent']
214224
callOptional: DevframeRpcClient['callOptional']
225+
/** See {@link DevframeRpcClient.close}. */
226+
close: () => void
215227
}
216228

217229
export async function getDevframeRpcClient(
@@ -375,6 +387,7 @@ export async function getDevframeRpcClient(
375387
streaming: undefined!,
376388
cacheManager,
377389
scope: undefined!,
390+
close: () => mode.close(),
378391
}
379392

380393
rpc.sharedState = createRpcSharedStateClientHost(rpc)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { createWsRpcChannel } from './ws-client'
3+
4+
// A minimal fake WebSocket — only what `createWsRpcChannel` touches.
5+
class FakeWebSocket {
6+
static OPEN = 1
7+
static instances: FakeWebSocket[] = []
8+
9+
readyState = FakeWebSocket.OPEN
10+
11+
constructor(public url: string) {
12+
FakeWebSocket.instances.push(this)
13+
}
14+
15+
addEventListener(): void {}
16+
removeEventListener(): void {}
17+
send(): void {}
18+
close(): void {}
19+
}
20+
21+
describe('createWsRpcChannel', () => {
22+
beforeEach(() => {
23+
FakeWebSocket.instances = []
24+
vi.stubGlobal('WebSocket', FakeWebSocket)
25+
})
26+
27+
afterEach(() => {
28+
vi.unstubAllGlobals()
29+
})
30+
31+
it('close() closes the underlying socket', () => {
32+
const channel = createWsRpcChannel({ url: 'ws://localhost:5173/__ws' })
33+
const ws = FakeWebSocket.instances.at(-1)!
34+
const closeSpy = vi.spyOn(ws, 'close')
35+
36+
channel.close()
37+
38+
expect(closeSpy).toHaveBeenCalledTimes(1)
39+
})
40+
})

packages/devframe/src/rpc/transports/ws-client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ const EMPTY_DEFS: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerial
2727
/**
2828
* Build a birpc `ChannelOptions` object backed by a browser `WebSocket`.
2929
* Pass the result straight to `createRpcClient`'s `channel` option.
30+
*
31+
* Also returns `close()`, closing the underlying socket — mirroring the server transport's
32+
* existing `WsRpcTransport.close()`. `birpc`'s own `ChannelOptions` has no teardown of its own.
3033
*/
31-
export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions {
34+
export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions & { close: () => void } {
3235
let url = options.url
3336
if (options.authToken) {
3437
url = `${url}?${DEVFRAME_AUTH_TOKEN_QUERY_PARAM}=${encodeURIComponent(options.authToken)}`
@@ -59,6 +62,9 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions
5962
// method up in `definitions` and pick the right encoder.
6063
const pendingRequestMethods = new Map<string, string>()
6164
return {
65+
close: () => {
66+
ws.close()
67+
},
6268
on: (handler: (data: string) => void) => {
6369
ws.addEventListener('message', (e) => {
6470
handler(e.data)

0 commit comments

Comments
 (0)