Skip to content

Commit 02967d8

Browse files
authored
feat(client): add DevframeRpcClient.close() (#175)
1 parent 8ece9e1 commit 02967d8

9 files changed

Lines changed: 158 additions & 26 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ 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+
// No `close` here on purpose — `close` is optional precisely so a mode written before it
41+
// existed (this one) still satisfies the interface.
4042
})),
4143
}))
4244

@@ -137,4 +139,17 @@ describe('getDevframeRpcClient — auth bootstrap gates outbound calls', () => {
137139
// Sent straight through — no more waiting once bootstrap is over.
138140
expect(fakeMode.call).toHaveBeenCalledTimes(1)
139141
})
142+
143+
it('close() is a no-op, not a throw, against a mode that predates it', async () => {
144+
const { getDevframeRpcClient } = await import('./rpc')
145+
const rpc = await getDevframeRpcClient({
146+
connectionMeta,
147+
otpParam: false,
148+
simpleAuth: false,
149+
})
150+
151+
// The mocked mode above has no `close` at all — exactly the pre-existing-mode case
152+
// `close?:` exists to keep working.
153+
expect(() => rpc.close?.()).not.toThrow()
154+
})
140155
})

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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,20 @@ 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+
* Optional so a `DevframeRpcClientMode` implemented before this method existed — a custom
208+
* transport, a hand-typed mock — still satisfies the interface; an absent `close` is treated
209+
* as nothing to close.
210+
*/
211+
close?: () => void
198212
}
199213

200214
export interface DevframeRpcClientMode {
@@ -212,6 +226,8 @@ export interface DevframeRpcClientMode {
212226
call: DevframeRpcClient['call']
213227
callEvent: DevframeRpcClient['callEvent']
214228
callOptional: DevframeRpcClient['callOptional']
229+
/** See {@link DevframeRpcClient.close}. */
230+
close?: () => void
215231
}
216232

217233
export async function getDevframeRpcClient(
@@ -375,6 +391,7 @@ export async function getDevframeRpcClient(
375391
streaming: undefined!,
376392
cacheManager,
377393
scope: undefined!,
394+
close: () => mode.close?.(),
378395
}
379396

380397
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)

tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface DevframeRpcClient {
2929
<NS extends string>(_: NS): DevframeScopedClientContext<NS, SettingsForNamespace<NS>>;
3030
(_?: null | ''): DevframeRpcClient;
3131
};
32+
close?: () => void;
3233
}
3334
export interface DevframeRpcClientMode {
3435
readonly isTrusted: boolean;
@@ -41,6 +42,7 @@ export interface DevframeRpcClientMode {
4142
call: DevframeRpcClient['call'];
4243
callEvent: DevframeRpcClient['callEvent'];
4344
callOptional: DevframeRpcClient['callOptional'];
45+
close?: () => void;
4446
}
4547
export interface DevframeRpcClientOptions extends SetupDevframeConnectionOptions {
4648
authToken?: string;

0 commit comments

Comments
 (0)