Skip to content

Commit d5448c7

Browse files
committed
feat(client): surface connection status and fail fast on stuck RPC calls
Add a single connection status model (connecting/connected/unauthorized/ disconnected/error) plus connection:status/connection:error/rpc:error events and a DevframeConnectionError to the devframe client, wired from the WebSocket lifecycle and the trust handshake. In-flight and new RPC calls now reject when the socket drops or trust is refused (with an optional callTimeout) instead of hanging forever, so a UI can show why it isn't loading rather than spinning. Expose the status centrally on the hub client context.
1 parent 36e193c commit d5448c7

11 files changed

Lines changed: 485 additions & 8 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* The connection lifecycle of a devframe client, as a single value a UI can
3+
* render from. Derived from the transport (WebSocket open/close/error) and the
4+
* trust handshake, so a viewer never has to reason about the two dimensions
5+
* separately.
6+
*
7+
* - `connecting` — establishing the WebSocket / running the initial trust
8+
* handshake. Calls issued here queue until the socket opens.
9+
* - `connected` — socket open and trusted; RPC calls will be served.
10+
* - `unauthorized` — socket open but the server rejected trust (no valid token,
11+
* or an auth-enforcing host refused it). Calls fail fast with an auth error;
12+
* the UI should prompt for re-authentication or a reload.
13+
* - `disconnected` — the socket closed (dropped mid-session, or never opened).
14+
* Pending and new calls fail fast until the page reconnects.
15+
* - `error` — a fatal connection error (e.g. the WebSocket errored, or the
16+
* connection meta could not be loaded).
17+
*
18+
* A `static` backend has no live socket, so it reports `connected` for its
19+
* whole life.
20+
*/
21+
export type DevframeConnectionStatus
22+
= | 'connecting'
23+
| 'connected'
24+
| 'unauthorized'
25+
| 'disconnected'
26+
| 'error'
27+
28+
/**
29+
* What kind of failure a {@link DevframeConnectionError} describes:
30+
* - `connection` — the transport dropped, errored, or never opened.
31+
* - `auth` — the server rejected trust for this client.
32+
* - `timeout` — a call exceeded its {@link DevframeRpcClientOptions.callTimeout}.
33+
*/
34+
export type DevframeConnectionErrorKind
35+
= | 'connection'
36+
| 'auth'
37+
| 'timeout'
38+
39+
/**
40+
* The error rejected from `rpc.call(...)` (and carried on
41+
* `rpc.connectionError`) when a call cannot be served because the connection is
42+
* down, the client is unauthorized, or a call timed out. Its `kind` lets a UI
43+
* tailor its message and recovery affordance without string-matching.
44+
*/
45+
export class DevframeConnectionError extends Error {
46+
override name = 'DevframeConnectionError'
47+
readonly kind: DevframeConnectionErrorKind
48+
49+
constructor(kind: DevframeConnectionErrorKind, message: string, options?: { cause?: unknown }) {
50+
super(message, options)
51+
this.kind = kind
52+
}
53+
}
54+
55+
/**
56+
* Whether a status means calls can be attempted. `connecting` counts because
57+
* the transport queues outgoing calls until the socket opens; the terminal
58+
* failure states short-circuit calls so a stuck socket never hangs the UI.
59+
*/
60+
export function isCallableStatus(status: DevframeConnectionStatus): boolean {
61+
return status === 'connected' || status === 'connecting'
62+
}

‎packages/devframe/src/client/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getDevframeRpcClient } from './rpc'
22

3+
export * from './connection'
34
export * from './otp'
45
export * from './rpc'
56
export * from './rpc-streaming'

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export async function createStaticRpcClientMode(
1414

1515
return {
1616
isTrusted: true,
17+
// A static backend has no live socket; every call is a local fetch, so it
18+
// is "connected" for its whole life.
19+
status: 'connected',
20+
connectionError: null,
1721
requestTrust: async () => true,
1822
requestTrustWithToken: async () => true,
1923
// Static backends are always trusted, so there's nothing to exchange.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { ConnectionMeta } from 'devframe/types'
2+
import type { DevframeClientRpcHost } from './rpc'
3+
import { RpcFunctionsCollectorBase } from 'devframe/rpc'
4+
import { createEventEmitter } from 'devframe/utils/events'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { DevframeConnectionError } from './connection'
7+
import { createWsRpcClientMode } from './rpc-ws'
8+
9+
// A minimal fake WebSocket that lets a test drive the open/close/error events
10+
// the client's status model reacts to. It never delivers a message, so a
11+
// `call()` stays pending until the connection is torn down or times out —
12+
// exactly the "spinner that never resolves" scenario under test.
13+
class FakeWebSocket {
14+
static CONNECTING = 0
15+
static OPEN = 1
16+
static CLOSING = 2
17+
static CLOSED = 3
18+
static instances: FakeWebSocket[] = []
19+
20+
readyState = FakeWebSocket.CONNECTING
21+
private listeners: Record<string, ((e: any) => void)[]> = {}
22+
23+
constructor(public url: string) {
24+
FakeWebSocket.instances.push(this)
25+
}
26+
27+
addEventListener(type: string, cb: (e: any) => void): void {
28+
(this.listeners[type] ||= []).push(cb)
29+
}
30+
31+
removeEventListener(type: string, cb: (e: any) => void): void {
32+
this.listeners[type] = (this.listeners[type] || []).filter(f => f !== cb)
33+
}
34+
35+
send(): void {}
36+
close(): void {}
37+
38+
private emit(type: string, e: any): void {
39+
for (const cb of this.listeners[type] || []) cb(e)
40+
}
41+
42+
fireOpen(): void {
43+
this.readyState = FakeWebSocket.OPEN
44+
this.emit('open', { type: 'open' })
45+
}
46+
47+
fireError(): void {
48+
this.emit('error', { type: 'error' })
49+
}
50+
51+
fireClose(): void {
52+
this.readyState = FakeWebSocket.CLOSED
53+
this.emit('close', { type: 'close', code: 1006 })
54+
}
55+
}
56+
57+
const connectionMeta: ConnectionMeta = {
58+
backend: 'websocket',
59+
websocket: { path: '__devframe_ws' },
60+
}
61+
62+
function setup(callTimeout?: number) {
63+
const events = createEventEmitter<any>()
64+
const statuses: string[] = []
65+
events.on('connection:status', (status: string) => statuses.push(status))
66+
const connectionErrors: Error[] = []
67+
events.on('connection:error', (error: Error) => connectionErrors.push(error))
68+
const rpcErrors: Array<{ error: Error, method: string }> = []
69+
events.on('rpc:error', (error: Error, method: string) => rpcErrors.push({ error, method }))
70+
const clientRpc = new RpcFunctionsCollectorBase<any, any>({}) as unknown as DevframeClientRpcHost
71+
const mode = createWsRpcClientMode({
72+
connectionMeta,
73+
metaBaseUrl: 'http://localhost:5173/__connection.json',
74+
events,
75+
clientRpc,
76+
callTimeout,
77+
})
78+
const ws = FakeWebSocket.instances.at(-1)!
79+
return { mode, ws, statuses, connectionErrors, rpcErrors }
80+
}
81+
82+
describe('ws client connection status', () => {
83+
beforeEach(() => {
84+
FakeWebSocket.instances = []
85+
;(globalThis as any).WebSocket = FakeWebSocket
86+
;(globalThis as any).location = {
87+
protocol: 'http:',
88+
host: 'localhost:5173',
89+
hostname: 'localhost',
90+
href: 'http://localhost:5173/__foo/index.html',
91+
origin: 'http://localhost:5173',
92+
}
93+
})
94+
95+
afterEach(() => {
96+
vi.useRealTimers()
97+
delete (globalThis as any).WebSocket
98+
delete (globalThis as any).location
99+
})
100+
101+
it('starts out connecting', () => {
102+
const { mode } = setup()
103+
expect(mode.status).toBe('connecting')
104+
expect(mode.connectionError).toBeNull()
105+
})
106+
107+
it('rejects a pending call when the socket closes', async () => {
108+
const { mode, ws, statuses } = setup()
109+
const pending = mode.call('demo:method' as any)
110+
ws.fireOpen()
111+
ws.fireClose()
112+
await expect(pending).rejects.toBeInstanceOf(DevframeConnectionError)
113+
await expect(pending).rejects.toMatchObject({ kind: 'connection' })
114+
expect(mode.status).toBe('disconnected')
115+
expect(statuses).toContain('disconnected')
116+
})
117+
118+
it('moves to error and rejects pending calls on a socket error', async () => {
119+
const { mode, ws, connectionErrors } = setup()
120+
const pending = mode.call('demo:method' as any)
121+
ws.fireOpen()
122+
ws.fireError()
123+
await expect(pending).rejects.toMatchObject({ kind: 'connection' })
124+
expect(mode.status).toBe('error')
125+
expect(mode.connectionError).not.toBeNull()
126+
expect(connectionErrors.length).toBeGreaterThan(0)
127+
})
128+
129+
it('fails new calls fast once disconnected instead of hanging', async () => {
130+
const { mode, ws } = setup()
131+
ws.fireOpen()
132+
ws.fireClose()
133+
await expect(mode.call('demo:method' as any)).rejects.toMatchObject({ kind: 'connection' })
134+
})
135+
136+
it('times out a call that never gets a response', async () => {
137+
const { mode, ws } = setup(30)
138+
ws.fireOpen()
139+
await expect(mode.call('demo:method' as any)).rejects.toMatchObject({ kind: 'timeout' })
140+
})
141+
142+
it('emits rpc:error when a call fails', async () => {
143+
const { mode, ws, rpcErrors } = setup()
144+
ws.fireOpen()
145+
ws.fireClose()
146+
await mode.call('demo:method' as any).catch(() => {})
147+
expect(rpcErrors.length).toBeGreaterThan(0)
148+
expect(rpcErrors[0].error).toBeInstanceOf(DevframeConnectionError)
149+
expect(rpcErrors[0].method).toBe('demo:method')
150+
})
151+
})

0 commit comments

Comments
 (0)