Skip to content

Commit d984ae6

Browse files
authored
feat: surface connection/auth/RPC errors instead of spinning (#93)
1 parent ed42a4a commit d984ae6

29 files changed

Lines changed: 965 additions & 36 deletions

File tree

docs/guide/client-context.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Boot the host once per page: a second boot replaces the published context and lo
5353
| `panel` | Dock panel state: position, size, drag/resize flags. |
5454
| `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. |
5555
| `when` | The [when-clause](./when-clauses) evaluation context. |
56+
| `connection` | The client's live [connection status](./client#handling-connection-and-auth-errors)`status`, `error`, and `events` — so a viewer can render one central connection indicator for every docked plugin. |
5657

5758
### Accessing the context
5859

docs/guide/client.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ await connectDevframe({
4949
| `baseURL` | Mount path to probe for `__connection.json`. Accepts an array for fallback. Default: `'./'` — resolved relative to `document.baseURI` so the SPA finds its meta wherever it was deployed. Pass an explicit absolute path (e.g. `'/__devframe/'`) when calling from outside the SPA — say, an embedded webcomponent injected into a host app. |
5050
| `authToken` | Override the auth token. Defaults to a locally-persisted human-readable id. |
5151
| `cacheOptions` | `true` to enable caching with defaults, or an options object. |
52-
| `wsOptions` | Forwarded to the WebSocket transport (reconnect, heartbeat, etc.). |
52+
| `callTimeout` | Milliseconds after which a pending `rpc.call` rejects with a `DevframeConnectionError` of kind `'timeout'`. Omit (or `0`) to wait indefinitely. See [Handling connection and auth errors](#handling-connection-and-auth-errors). |
53+
| `wsOptions` | Low-level WebSocket transport overrides — `onConnected` / `onError` / `onDisconnected` lifecycle hooks and the socket URL. |
5354
| `rpcOptions` | Forwarded to `birpc`. |
5455
| `connectionMeta` | Pre-known descriptor that skips the `__connection.json` fetch. |
5556

@@ -229,6 +230,15 @@ The descriptor carries a session-only, pre-approved auth token, so `ensureTruste
229230

230231
## Events
231232

233+
The client emits over `rpc.events`:
234+
235+
| Event | Fires when |
236+
|-------|------------|
237+
| `rpc:is-trusted:updated` | Trust is granted, denied, or revoked. Carries the new `isTrusted` boolean. |
238+
| `connection:status` | The [connection status](#handling-connection-and-auth-errors) changes. Carries `(status, previous)`. |
239+
| `connection:error` | A connection-level failure occurs — the socket errors, or trust is refused. Carries the `Error`. |
240+
| `rpc:error` | An `rpc.call` rejects, from the server or a down connection. Carries `(error, method)`. |
241+
232242
```ts
233243
rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
234244
if (isTrusted)
@@ -239,3 +249,85 @@ rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
239249
```
240250

241251
`rpc.isTrusted` is the synchronous read. Subscribe to `rpc:is-trusted:updated` to drive reauth flows or gate rendering until the client is trusted.
252+
253+
## Handling connection and auth errors
254+
255+
A dev-mode client rides a live WebSocket, so it can lose the server mid-session or be refused authentication. Surface those states in your UI — a devtool that keeps spinning with no feedback leaves the user guessing whether it's loading or broken. The client gives you a single status to render from, events to react to, and calls that fail fast instead of hanging.
256+
257+
### Connection status
258+
259+
`rpc.status` collapses the transport and the trust handshake into one value, and `rpc.connectionError` holds the last connection-level `Error` (or `null` when healthy):
260+
261+
| Status | Meaning |
262+
|--------|---------|
263+
| `connecting` | Establishing the socket / running the initial handshake. Calls issued now queue until it opens. |
264+
| `connected` | Socket open and trusted; calls are served. |
265+
| `unauthorized` | Socket open, but the server refused trust. Prompt for [authentication](#authenticating-with-a-one-time-code). |
266+
| `disconnected` | The socket closed — dropped mid-session, or never opened. |
267+
| `error` | A fatal connection error, e.g. the socket errored or the connection meta couldn't load. |
268+
269+
A `static` backend has no live socket, so `rpc.status` is `connected` for its whole life — gating on it is a no-op there, and a build-time SPA never shows a connection state.
270+
271+
### Calls fail fast
272+
273+
Once the socket closes or trust is refused, in-flight and new `rpc.call` promises reject with a `DevframeConnectionError` rather than hanging forever. Its `kind` tells you why, so a `catch` can branch without string-matching:
274+
275+
- `'connection'` — the transport is down (`disconnected` / `error`).
276+
- `'auth'` — the client is `unauthorized`.
277+
- `'timeout'` — the call outlived the `callTimeout` option.
278+
279+
Set `callTimeout` when constructing the client to also cap a live-but-unresponsive server:
280+
281+
```ts
282+
const rpc = await connectDevframe({ callTimeout: 10_000 })
283+
```
284+
285+
### Putting it together
286+
287+
Gate the UI on `connection:status`, and wrap calls to branch on failure:
288+
289+
```ts
290+
import { connectDevframe, DevframeConnectionError } from 'devframe/client'
291+
292+
const rpc = await connectDevframe()
293+
294+
// 1. Render from the live status.
295+
function render() {
296+
switch (rpc.status) {
297+
case 'connected': return renderApp()
298+
case 'connecting': return renderSpinner('Connecting…')
299+
case 'unauthorized': return renderMessage('Not authorized — reopen the link from your dev server.')
300+
case 'disconnected': return renderMessage('Disconnected.', { onRetry: reconnect })
301+
case 'error': return renderMessage(rpc.connectionError?.message ?? 'Connection failed.', { onRetry: reconnect })
302+
}
303+
}
304+
rpc.events.on('connection:status', render)
305+
render()
306+
307+
// 2. Handle a failing call.
308+
async function loadModules() {
309+
try {
310+
return await rpc.call('my-devframe:get-modules', { limit: 10 })
311+
}
312+
catch (error) {
313+
if (error instanceof DevframeConnectionError) {
314+
// 'connection' | 'auth' | 'timeout' — the UI already reflects rpc.status.
315+
return null
316+
}
317+
throw error // a real server-side error — surface it.
318+
}
319+
}
320+
```
321+
322+
### Recovering
323+
324+
Recovery is explicit — the client doesn't reconnect on its own. The simplest path is a full page reload, which re-runs `connectDevframe` and the trust handshake; that's what the built-in plugins do behind their **Reload** button. An app that wants to reconnect without a reload can own it by re-running its connect routine to build a fresh client:
325+
326+
```ts
327+
async function reconnect() {
328+
rpc = await connectDevframe() // a new client; re-subscribe your listeners
329+
render()
330+
}
331+
```
332+
333+
The five built-in plugins are worked references — each gates its surface on `rpc.status` and offers a reload. In a hub, a viewer can read the same status centrally from [`context.connection`](./client-context#the-client-context) instead of every plugin surfacing its own.
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)