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
1 change: 1 addition & 0 deletions docs/guide/client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Boot the host once per page: a second boot replaces the published context and lo
| `panel` | Dock panel state: position, size, drag/resize flags. |
| `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. |
| `when` | The [when-clause](./when-clauses) evaluation context. |
| `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. |

### Accessing the context

Expand Down
94 changes: 93 additions & 1 deletion docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ await connectDevframe({
| `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. |
| `authToken` | Override the auth token. Defaults to a locally-persisted human-readable id. |
| `cacheOptions` | `true` to enable caching with defaults, or an options object. |
| `wsOptions` | Forwarded to the WebSocket transport (reconnect, heartbeat, etc.). |
| `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). |
| `wsOptions` | Low-level WebSocket transport overrides — `onConnected` / `onError` / `onDisconnected` lifecycle hooks and the socket URL. |
| `rpcOptions` | Forwarded to `birpc`. |
| `connectionMeta` | Pre-known descriptor that skips the `__connection.json` fetch. |

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

## Events

The client emits over `rpc.events`:

| Event | Fires when |
|-------|------------|
| `rpc:is-trusted:updated` | Trust is granted, denied, or revoked. Carries the new `isTrusted` boolean. |
| `connection:status` | The [connection status](#handling-connection-and-auth-errors) changes. Carries `(status, previous)`. |
| `connection:error` | A connection-level failure occurs — the socket errors, or trust is refused. Carries the `Error`. |
| `rpc:error` | An `rpc.call` rejects, from the server or a down connection. Carries `(error, method)`. |

```ts
rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
if (isTrusted)
Expand All @@ -239,3 +249,85 @@ rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
```

`rpc.isTrusted` is the synchronous read. Subscribe to `rpc:is-trusted:updated` to drive reauth flows or gate rendering until the client is trusted.

## Handling connection and auth errors

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.

### Connection status

`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):

| Status | Meaning |
|--------|---------|
| `connecting` | Establishing the socket / running the initial handshake. Calls issued now queue until it opens. |
| `connected` | Socket open and trusted; calls are served. |
| `unauthorized` | Socket open, but the server refused trust. Prompt for [authentication](#authenticating-with-a-one-time-code). |
| `disconnected` | The socket closed — dropped mid-session, or never opened. |
| `error` | A fatal connection error, e.g. the socket errored or the connection meta couldn't load. |

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.

### Calls fail fast

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:

- `'connection'` — the transport is down (`disconnected` / `error`).
- `'auth'` — the client is `unauthorized`.
- `'timeout'` — the call outlived the `callTimeout` option.

Set `callTimeout` when constructing the client to also cap a live-but-unresponsive server:

```ts
const rpc = await connectDevframe({ callTimeout: 10_000 })
```

### Putting it together

Gate the UI on `connection:status`, and wrap calls to branch on failure:

```ts
import { connectDevframe, DevframeConnectionError } from 'devframe/client'

const rpc = await connectDevframe()

// 1. Render from the live status.
function render() {
switch (rpc.status) {
case 'connected': return renderApp()
case 'connecting': return renderSpinner('Connecting…')
case 'unauthorized': return renderMessage('Not authorized — reopen the link from your dev server.')
case 'disconnected': return renderMessage('Disconnected.', { onRetry: reconnect })
case 'error': return renderMessage(rpc.connectionError?.message ?? 'Connection failed.', { onRetry: reconnect })
}
}
rpc.events.on('connection:status', render)
render()

// 2. Handle a failing call.
async function loadModules() {
try {
return await rpc.call('my-devframe:get-modules', { limit: 10 })
}
catch (error) {
if (error instanceof DevframeConnectionError) {
// 'connection' | 'auth' | 'timeout' — the UI already reflects rpc.status.
return null
}
throw error // a real server-side error — surface it.
}
}
```

### Recovering

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:

```ts
async function reconnect() {
rpc = await connectDevframe() // a new client; re-subscribe your listeners
render()
}
```

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.
62 changes: 62 additions & 0 deletions packages/devframe/src/client/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* The connection lifecycle of a devframe client, as a single value a UI can
* render from. Derived from the transport (WebSocket open/close/error) and the
* trust handshake, so a viewer never has to reason about the two dimensions
* separately.
*
* - `connecting` — establishing the WebSocket / running the initial trust
* handshake. Calls issued here queue until the socket opens.
* - `connected` — socket open and trusted; RPC calls will be served.
* - `unauthorized` — socket open but the server rejected trust (no valid token,
* or an auth-enforcing host refused it). Calls fail fast with an auth error;
* the UI should prompt for re-authentication or a reload.
* - `disconnected` — the socket closed (dropped mid-session, or never opened).
* Pending and new calls fail fast until the page reconnects.
* - `error` — a fatal connection error (e.g. the WebSocket errored, or the
* connection meta could not be loaded).
*
* A `static` backend has no live socket, so it reports `connected` for its
* whole life.
*/
export type DevframeConnectionStatus
= | 'connecting'
| 'connected'
| 'unauthorized'
| 'disconnected'
| 'error'

/**
* What kind of failure a {@link DevframeConnectionError} describes:
* - `connection` — the transport dropped, errored, or never opened.
* - `auth` — the server rejected trust for this client.
* - `timeout` — a call exceeded its {@link DevframeRpcClientOptions.callTimeout}.
*/
export type DevframeConnectionErrorKind
= | 'connection'
| 'auth'
| 'timeout'

/**
* The error rejected from `rpc.call(...)` (and carried on
* `rpc.connectionError`) when a call cannot be served because the connection is
* down, the client is unauthorized, or a call timed out. Its `kind` lets a UI
* tailor its message and recovery affordance without string-matching.
*/
export class DevframeConnectionError extends Error {
override name = 'DevframeConnectionError'
readonly kind: DevframeConnectionErrorKind

constructor(kind: DevframeConnectionErrorKind, message: string, options?: { cause?: unknown }) {
super(message, options)
this.kind = kind
}
}

/**
* Whether a status means calls can be attempted. `connecting` counts because
* the transport queues outgoing calls until the socket opens; the terminal
* failure states short-circuit calls so a stuck socket never hangs the UI.
*/
export function isCallableStatus(status: DevframeConnectionStatus): boolean {
return status === 'connected' || status === 'connecting'
}
1 change: 1 addition & 0 deletions packages/devframe/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getDevframeRpcClient } from './rpc'

export * from './connection'
export * from './otp'
export * from './rpc'
export * from './rpc-streaming'
Expand Down
4 changes: 4 additions & 0 deletions packages/devframe/src/client/rpc-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export async function createStaticRpcClientMode(

return {
isTrusted: true,
// A static backend has no live socket; every call is a local fetch, so it
// is "connected" for its whole life.
status: 'connected',
connectionError: null,
requestTrust: async () => true,
requestTrustWithToken: async () => true,
// Static backends are always trusted, so there's nothing to exchange.
Expand Down
151 changes: 151 additions & 0 deletions packages/devframe/src/client/rpc-ws-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { ConnectionMeta } from 'devframe/types'
import type { DevframeClientRpcHost } from './rpc'
import { RpcFunctionsCollectorBase } from 'devframe/rpc'
import { createEventEmitter } from 'devframe/utils/events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DevframeConnectionError } from './connection'
import { createWsRpcClientMode } from './rpc-ws'

// A minimal fake WebSocket that lets a test drive the open/close/error events
// the client's status model reacts to. It never delivers a message, so a
// `call()` stays pending until the connection is torn down or times out —
// exactly the "spinner that never resolves" scenario under test.
class FakeWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: FakeWebSocket[] = []

readyState = FakeWebSocket.CONNECTING
private listeners: Record<string, ((e: any) => void)[]> = {}

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

addEventListener(type: string, cb: (e: any) => void): void {
(this.listeners[type] ||= []).push(cb)
}

removeEventListener(type: string, cb: (e: any) => void): void {
this.listeners[type] = (this.listeners[type] || []).filter(f => f !== cb)
}

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

private emit(type: string, e: any): void {
for (const cb of this.listeners[type] || []) cb(e)
}

fireOpen(): void {
this.readyState = FakeWebSocket.OPEN
this.emit('open', { type: 'open' })
}

fireError(): void {
this.emit('error', { type: 'error' })
}

fireClose(): void {
this.readyState = FakeWebSocket.CLOSED
this.emit('close', { type: 'close', code: 1006 })
}
}

const connectionMeta: ConnectionMeta = {
backend: 'websocket',
websocket: { path: '__devframe_ws' },
}

function setup(callTimeout?: number) {
const events = createEventEmitter<any>()
const statuses: string[] = []
events.on('connection:status', (status: string) => statuses.push(status))
const connectionErrors: Error[] = []
events.on('connection:error', (error: Error) => connectionErrors.push(error))
const rpcErrors: Array<{ error: Error, method: string }> = []
events.on('rpc:error', (error: Error, method: string) => rpcErrors.push({ error, method }))
const clientRpc = new RpcFunctionsCollectorBase<any, any>({}) as unknown as DevframeClientRpcHost
const mode = createWsRpcClientMode({
connectionMeta,
metaBaseUrl: 'http://localhost:5173/__connection.json',
events,
clientRpc,
callTimeout,
})
const ws = FakeWebSocket.instances.at(-1)!
return { mode, ws, statuses, connectionErrors, rpcErrors }
}

describe('ws client connection status', () => {
beforeEach(() => {
FakeWebSocket.instances = []
;(globalThis as any).WebSocket = FakeWebSocket
;(globalThis as any).location = {
protocol: 'http:',
host: 'localhost:5173',
hostname: 'localhost',
href: 'http://localhost:5173/__foo/index.html',
origin: 'http://localhost:5173',
}
})

afterEach(() => {
vi.useRealTimers()
delete (globalThis as any).WebSocket
delete (globalThis as any).location
})

it('starts out connecting', () => {
const { mode } = setup()
expect(mode.status).toBe('connecting')
expect(mode.connectionError).toBeNull()
})

it('rejects a pending call when the socket closes', async () => {
const { mode, ws, statuses } = setup()
const pending = mode.call('demo:method' as any)
ws.fireOpen()
ws.fireClose()
await expect(pending).rejects.toBeInstanceOf(DevframeConnectionError)
await expect(pending).rejects.toMatchObject({ kind: 'connection' })
expect(mode.status).toBe('disconnected')
expect(statuses).toContain('disconnected')
})

it('moves to error and rejects pending calls on a socket error', async () => {
const { mode, ws, connectionErrors } = setup()
const pending = mode.call('demo:method' as any)
ws.fireOpen()
ws.fireError()
await expect(pending).rejects.toMatchObject({ kind: 'connection' })
expect(mode.status).toBe('error')
expect(mode.connectionError).not.toBeNull()
expect(connectionErrors.length).toBeGreaterThan(0)
})

it('fails new calls fast once disconnected instead of hanging', async () => {
const { mode, ws } = setup()
ws.fireOpen()
ws.fireClose()
await expect(mode.call('demo:method' as any)).rejects.toMatchObject({ kind: 'connection' })
})

it('times out a call that never gets a response', async () => {
const { mode, ws } = setup(30)
ws.fireOpen()
await expect(mode.call('demo:method' as any)).rejects.toMatchObject({ kind: 'timeout' })
})

it('emits rpc:error when a call fails', async () => {
const { mode, ws, rpcErrors } = setup()
ws.fireOpen()
ws.fireClose()
await mode.call('demo:method' as any).catch(() => {})
expect(rpcErrors.length).toBeGreaterThan(0)
expect(rpcErrors[0].error).toBeInstanceOf(DevframeConnectionError)
expect(rpcErrors[0].method).toBe('demo:method')
})
})
Loading
Loading