Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/devframe/src/client/rpc-auth-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ vi.mock('./rpc-ws', () => ({
call: fakeMode.call as DevframeRpcClientMode['call'],
callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'],
callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'],
// No `close` here on purpose — `close` is optional precisely so a mode written before it
// existed (this one) still satisfies the interface.
})),
}))

Expand Down Expand Up @@ -137,4 +139,17 @@ describe('getDevframeRpcClient — auth bootstrap gates outbound calls', () => {
// Sent straight through — no more waiting once bootstrap is over.
expect(fakeMode.call).toHaveBeenCalledTimes(1)
})

it('close() is a no-op, not a throw, against a mode that predates it', async () => {
const { getDevframeRpcClient } = await import('./rpc')
const rpc = await getDevframeRpcClient({
connectionMeta,
otpParam: false,
simpleAuth: false,
})

// The mocked mode above has no `close` at all — exactly the pre-existing-mode case
// `close?:` exists to keep working.
expect(() => rpc.close?.()).not.toThrow()
})
})
2 changes: 2 additions & 0 deletions packages/devframe/src/client/rpc-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,7 @@ export async function createStaticRpcClientMode(
args[0] as string,
args.slice(1),
),
// No live socket to close — every call is a local fetch.
close: () => {},
}
}
9 changes: 9 additions & 0 deletions packages/devframe/src/client/rpc-ws-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,13 @@ describe('ws client connection status', () => {
expect(rpcErrors[0].error).toBeInstanceOf(DevframeConnectionError)
expect(rpcErrors[0].method).toBe('demo:method')
})

it('close() closes the underlying socket', () => {
const { mode, ws } = setup()
const closeSpy = vi.spyOn(ws, 'close')

mode.close?.()

expect(closeSpy).toHaveBeenCalledTimes(1)
})
})
56 changes: 31 additions & 25 deletions packages/devframe/src/client/rpc-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,34 +206,37 @@ export function createWsRpcClientMode(
for (const name of connectionMeta.jsonSerializableMethods ?? [])
definitions.set(name, { jsonSerializable: true })

// Hoisted out of the `createRpcClient` call so `close()` below can reach it — birpc's own
// `ChannelOptions` carries no reference back to what it was built from.
const channel = createWsRpcChannel({
url,
authToken,
definitions,
...wsOptions,
onConnected(event) {
// Socket open — the trust handshake (already queued) settles the
// status to `connected`/`unauthorized`. Stay `connecting` until then.
wsOptions.onConnected?.(event)
},
onError(error) {
setStatus('error', error)
events.emit('connection:error', error)
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Connection to the devframe server failed', { cause: error }))
wsOptions.onError?.(error)
},
onDisconnected(event) {
// A clean close after we were connected, or a socket that never
// opened — either way calls can no longer be served.
if (status !== 'error')
setStatus('disconnected')
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Disconnected from the devframe server', { cause: connectionError ?? undefined }))
wsOptions.onDisconnected?.(event)
},
})
const serverRpc = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
clientRpc.functions,
{
channel: createWsRpcChannel({
url,
authToken,
definitions,
...wsOptions,
onConnected(event) {
// Socket open — the trust handshake (already queued) settles the
// status to `connected`/`unauthorized`. Stay `connecting` until then.
wsOptions.onConnected?.(event)
},
onError(error) {
setStatus('error', error)
events.emit('connection:error', error)
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Connection to the devframe server failed', { cause: error }))
wsOptions.onError?.(error)
},
onDisconnected(event) {
// A clean close after we were connected, or a socket that never
// opened — either way calls can no longer be served.
if (status !== 'error')
setStatus('disconnected')
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Disconnected from the devframe server', { cause: connectionError ?? undefined }))
wsOptions.onDisconnected?.(event)
},
}),
channel,
rpcOptions,
},
)
Expand Down Expand Up @@ -402,5 +405,8 @@ export function createWsRpcClientMode(
method,
)
},
close: () => {
channel.close()
},
}
}
35 changes: 35 additions & 0 deletions packages/devframe/src/client/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,39 @@ describe('getDevframeRpcClient — connection meta base', () => {
// An explicit meta resolves against the client's own base.
expect(lastWsUrl()).toBe('ws://localhost:5173/__foo/__ws')
})

it('close() closes the underlying socket', async () => {
const served: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => served,
}) as any))

const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })
const ws = FakeWebSocket.instances.at(-1)!
const closeSpy = vi.spyOn(ws, 'close')

rpc.close?.()

expect(closeSpy).toHaveBeenCalledTimes(1)
})

it('close() on a static backend is a no-op, not a throw', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string) => ({
ok: true,
status: 200,
json: async () => (
url.includes('__rpc-dump')
? {} // an empty manifest is a valid (if trivial) StaticRpcManifest
: { backend: 'static' } satisfies ConnectionMeta
),
}) as any))

const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })

expect(() => rpc.close?.()).not.toThrow()
// Static backends never open a socket in the first place.
expect(FakeWebSocket.instances).toHaveLength(0)
})
})
17 changes: 17 additions & 0 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,20 @@ export interface DevframeRpcClient {
<NS extends string>(namespace: NS): DevframeScopedClientContext<NS, SettingsForNamespace<NS>>
(namespace?: null | ''): DevframeRpcClient
}

/**
* Close the connection. A `static` backend is a no-op (there is no live socket to close);
* a `websocket` backend closes the underlying `WebSocket`, which the server observes as a
* normal disconnect. Mirrors {@link WsRpcTransport.close} on the server side.
*
* There is no corresponding "reconnect" — a closed client is done. Discard it and call
* {@link getDevframeRpcClient} again to reconnect.
*
* Optional so a `DevframeRpcClientMode` implemented before this method existed — a custom
* transport, a hand-typed mock — still satisfies the interface; an absent `close` is treated
* as nothing to close.
*/
close?: () => void
}

export interface DevframeRpcClientMode {
Comment thread
dvcolomban marked this conversation as resolved.
Expand All @@ -212,6 +226,8 @@ export interface DevframeRpcClientMode {
call: DevframeRpcClient['call']
callEvent: DevframeRpcClient['callEvent']
callOptional: DevframeRpcClient['callOptional']
/** See {@link DevframeRpcClient.close}. */
close?: () => void
}
Comment thread
dvcolomban marked this conversation as resolved.

export async function getDevframeRpcClient(
Expand Down Expand Up @@ -375,6 +391,7 @@ export async function getDevframeRpcClient(
streaming: undefined!,
cacheManager,
scope: undefined!,
close: () => mode.close?.(),
}

rpc.sharedState = createRpcSharedStateClientHost(rpc)
Expand Down
40 changes: 40 additions & 0 deletions packages/devframe/src/rpc/transports/ws-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createWsRpcChannel } from './ws-client'

// A minimal fake WebSocket — only what `createWsRpcChannel` touches.
class FakeWebSocket {
static OPEN = 1
static instances: FakeWebSocket[] = []

readyState = FakeWebSocket.OPEN

constructor(public url: string) {
FakeWebSocket.instances.push(this)
}

addEventListener(): void {}
removeEventListener(): void {}
send(): void {}
close(): void {}
}

describe('createWsRpcChannel', () => {
beforeEach(() => {
FakeWebSocket.instances = []
vi.stubGlobal('WebSocket', FakeWebSocket)
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('close() closes the underlying socket', () => {
const channel = createWsRpcChannel({ url: 'ws://localhost:5173/__ws' })
const ws = FakeWebSocket.instances.at(-1)!
const closeSpy = vi.spyOn(ws, 'close')

channel.close()

expect(closeSpy).toHaveBeenCalledTimes(1)
})
})
8 changes: 7 additions & 1 deletion packages/devframe/src/rpc/transports/ws-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ const EMPTY_DEFS: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerial
/**
* Build a birpc `ChannelOptions` object backed by a browser `WebSocket`.
* Pass the result straight to `createRpcClient`'s `channel` option.
*
* Also returns `close()`, closing the underlying socket — mirroring the server transport's
* existing `WsRpcTransport.close()`. `birpc`'s own `ChannelOptions` has no teardown of its own.
*/
export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions {
export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions & { close: () => void } {
let url = options.url
if (options.authToken) {
url = `${url}?${DEVFRAME_AUTH_TOKEN_QUERY_PARAM}=${encodeURIComponent(options.authToken)}`
Expand Down Expand Up @@ -59,6 +62,9 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions
// method up in `definitions` and pick the right encoder.
const pendingRequestMethods = new Map<string, string>()
return {
close: () => {
ws.close()
},
on: (handler: (data: string) => void) => {
ws.addEventListener('message', (e) => {
handler(e.data)
Expand Down
2 changes: 2 additions & 0 deletions tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface DevframeRpcClient {
<NS extends string>(_: NS): DevframeScopedClientContext<NS, SettingsForNamespace<NS>>;
(_?: null | ''): DevframeRpcClient;
};
close?: () => void;
}
export interface DevframeRpcClientMode {
readonly isTrusted: boolean;
Expand All @@ -41,6 +42,7 @@ export interface DevframeRpcClientMode {
call: DevframeRpcClient['call'];
callEvent: DevframeRpcClient['callEvent'];
callOptional: DevframeRpcClient['callOptional'];
close?: () => void;
}
export interface DevframeRpcClientOptions extends SetupDevframeConnectionOptions {
authToken?: string;
Expand Down