diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 2ff9b96d..b4e226fa 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -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 diff --git a/docs/guide/client.md b/docs/guide/client.md index facad417..a653f940 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -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. | @@ -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) @@ -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. diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts new file mode 100644 index 00000000..133f62da --- /dev/null +++ b/packages/devframe/src/client/connection.ts @@ -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' +} diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index 2d5855f6..6cc990dd 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -1,5 +1,6 @@ import { getDevframeRpcClient } from './rpc' +export * from './connection' export * from './otp' export * from './rpc' export * from './rpc-streaming' diff --git a/packages/devframe/src/client/rpc-static.ts b/packages/devframe/src/client/rpc-static.ts index a3ce4028..4fff7fe1 100644 --- a/packages/devframe/src/client/rpc-static.ts +++ b/packages/devframe/src/client/rpc-static.ts @@ -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. diff --git a/packages/devframe/src/client/rpc-ws-status.test.ts b/packages/devframe/src/client/rpc-ws-status.test.ts new file mode 100644 index 00000000..30778623 --- /dev/null +++ b/packages/devframe/src/client/rpc-ws-status.test.ts @@ -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 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() + 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({}) 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') + }) +}) diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index 8ebdac7d..c2e92711 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -1,10 +1,12 @@ import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter } from 'devframe/types' +import type { DevframeConnectionStatus } from './connection' import type { DevframeClientRpcHost, DevframeRpcClientMode, DevframeRpcClientOptions, RpcClientEvents } from './rpc' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { promiseWithResolver } from 'devframe/utils/promise' import { parseUA } from 'ua-parser-modern' import { withProtocol } from 'ufo' +import { DevframeConnectionError } from './connection' export interface CreateWsRpcClientModeOptions { authToken?: string @@ -19,6 +21,8 @@ export interface CreateWsRpcClientModeOptions { clientRpc: DevframeClientRpcHost rpcOptions?: DevframeRpcClientOptions['rpcOptions'] wsOptions?: DevframeRpcClientOptions['wsOptions'] + /** See {@link DevframeRpcClientOptions.callTimeout}. */ + callTimeout?: number } /** Minimal subset of `window.location` needed to resolve a WS URL. */ @@ -101,9 +105,12 @@ export function createWsRpcClientMode( clientRpc, rpcOptions = {}, wsOptions = {}, + callTimeout = 0, } = options let isTrusted = false + let status: DevframeConnectionStatus = 'connecting' + let connectionError: Error | null = null const trustedPromise = promiseWithResolver() const url = resolveWsUrl( connectionMeta.websocket, @@ -111,6 +118,85 @@ export function createWsRpcClientMode( location, ) + // ── connection status ──────────────────────────────────────────────────── + + function setStatus(next: DevframeConnectionStatus, error: Error | null = null): void { + if (error) + connectionError = error + else if (next === 'connected') + connectionError = null + if (next === status) + return + const previous = status + status = next + events.emit('connection:status', next, previous) + } + + // Pending calls we can settle proactively — a socket that closes (or a server + // that never trusts us) would otherwise leave these promises hanging forever, + // which is exactly the "spinner that never resolves" this guards against. + const pending = new Set<{ reject: (error: Error) => void }>() + + function rejectAllPending(error: Error): void { + for (const entry of [...pending]) entry.reject(error) + } + + function terminalError(): DevframeConnectionError | null { + if (status === 'disconnected' || status === 'error') + return new DevframeConnectionError('connection', '[devframe] Not connected to the devframe server', { cause: connectionError ?? undefined }) + if (status === 'unauthorized') + return new DevframeConnectionError('auth', '[devframe] Not authorized by the devframe server', { cause: connectionError ?? undefined }) + return null + } + + /** + * Wrap an in-flight call promise so it settles on server response, on an + * optional wall-clock timeout, or when the connection drops — never hangs. + */ + function guardCall(promise: Promise, method: string): Promise { + return new Promise((resolve, reject) => { + let settled = false + let timer: ReturnType | undefined + const entry = { + reject(error: Error) { + if (settled) + return + finish() + events.emit('rpc:error', error, method) + reject(error) + }, + } + function finish(): void { + settled = true + pending.delete(entry) + if (timer) + clearTimeout(timer) + } + pending.add(entry) + if (callTimeout > 0) { + timer = setTimeout(() => { + entry.reject(new DevframeConnectionError('timeout', `[devframe] RPC call "${method}" timed out after ${callTimeout}ms`)) + }, callTimeout) + } + promise.then( + (value) => { + if (settled) + return + finish() + resolve(value) + }, + (error: unknown) => { + if (settled) + return + finish() + const err = error instanceof Error ? error : new Error(String(error)) + events.emit('rpc:error', err, method) + reject(err) + }, + ) + }) + } + // Build a minimal `defs` map from the connection meta so the per-call // wire serializer dispatches outgoing requests with the correct // encoding (JSON for `jsonSerializable: true` methods; structured- @@ -127,6 +213,25 @@ export function createWsRpcClientMode( 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) + }, }), rpcOptions, }, @@ -138,6 +243,10 @@ export function createWsRpcClientMode( type: 'event', handler: () => { isTrusted = false + const authError = new DevframeConnectionError('auth', '[devframe] The devframe server revoked this client\'s trust') + setStatus('unauthorized', authError) + events.emit('connection:error', authError) + rejectAllPending(authError) events.emit('rpc:is-trusted:updated', false) }, }) @@ -168,8 +277,19 @@ export function createWsRpcClientMode( isTrusted = result.isTrusted // Only settle the trust gate on success; on failure the client can still // authenticate via `requestTrustWithCode`, so leave `ensureTrusted` waiting. - if (isTrusted) + if (isTrusted) { trustedPromise.resolve(true) + setStatus('connected') + } + else { + // The server refused this token. On an auth-enforcing host, untrusted + // calls won't be served — surface it so the UI can prompt for auth + // rather than spin. The standalone (`auth: false`) server auto-trusts, + // so it never lands here. + const authError = new DevframeConnectionError('auth', '[devframe] The devframe server refused this client\'s credentials') + setStatus('unauthorized', authError) + events.emit('connection:error', authError) + } events.emit('rpc:is-trusted:updated', isTrusted) return result.isTrusted } @@ -186,6 +306,7 @@ export function createWsRpcClientMode( currentAuthToken = token isTrusted = true trustedPromise.resolve(true) + setStatus('connected') events.emit('rpc:is-trusted:updated', true) } return token @@ -227,26 +348,57 @@ export function createWsRpcClientMode( get isTrusted() { return isTrusted }, + get status() { + return status + }, + get connectionError() { + return connectionError + }, requestTrust, requestTrustWithToken, requestTrustWithCode, ensureTrusted, call: (...args: any): any => { - return serverRpc.$call( - // @ts-expect-error casting - ...args, + const method = String(args[0]) + const failFast = terminalError() + if (failFast) { + events.emit('rpc:error', failFast, method) + return Promise.reject(failFast) + } + return guardCall( + serverRpc.$call( + // @ts-expect-error casting + ...args, + ), + method, ) }, callEvent: (...args: any): any => { + // Events are fire-and-forget; when the connection is down there's nothing + // to send, so surface the failure and drop it instead of queuing forever. + const failFast = terminalError() + if (failFast) { + events.emit('rpc:error', failFast, String(args[0])) + return + } return serverRpc.$callEvent( // @ts-expect-error casting ...args, ) }, callOptional: (...args: any): any => { - return serverRpc.$callOptional( - // @ts-expect-error casting - ...args, + const method = String(args[0]) + const failFast = terminalError() + if (failFast) { + events.emit('rpc:error', failFast, method) + return Promise.reject(failFast) + } + return guardCall( + serverRpc.$callOptional( + // @ts-expect-error casting + ...args, + ), + method, ) }, } diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 11b19115..191afd94 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -2,6 +2,7 @@ import type { BirpcOptions, BirpcReturn } from 'birpc' import type { RpcCacheOptions, RpcFunctionsCollector } from 'devframe/rpc' import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter, RpcSharedStateHost, SettingsForNamespace } from 'devframe/types' +import type { DevframeConnectionStatus } from './connection' import type { RpcStreamingClientHost } from './rpc-streaming' import type { DevframeScopedClientContext } from './scope' import { @@ -29,6 +30,21 @@ export type DevframeClientRpcHost = RpcFunctionsCollector void + /** + * The connection status changed. Carries the new status and the previous one + * so a UI can react to specific transitions (e.g. `connected` → `disconnected`). + */ + 'connection:status': (status: DevframeConnectionStatus, previous: DevframeConnectionStatus) => void + /** + * A connection-level error occurred (the WebSocket errored, or trust was + * refused). The status typically moves to `error`/`unauthorized` alongside it. + */ + 'connection:error': (error: Error) => void + /** + * An RPC call rejected — either from the server, or because the connection + * was down / timed out. Useful for a global error feed or toast surface. + */ + 'rpc:error': (error: Error, method: string) => void } const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' @@ -52,6 +68,14 @@ export interface DevframeRpcClientOptions { wsOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial + /** + * Reject a pending `rpc.call(...)` if the server hasn't answered within this + * many milliseconds, with a {@link DevframeConnectionError} of kind + * `'timeout'`. Guards against a live-but-unresponsive server hanging the UI. + * Omit (or `0`) to wait indefinitely. Calls always fail fast — regardless of + * this option — once the socket closes or trust is refused. + */ + callTimeout?: number } export type DevframeRpcClientCall = BirpcReturn['$call'] @@ -68,6 +92,17 @@ export interface DevframeRpcClient { * Whether the client is trusted */ readonly isTrusted: boolean | null + /** + * The current connection status. Drives connection/auth/error UI without the + * consumer having to track the transport and trust handshake separately. + * Subscribe to `events.on('connection:status', …)` to react to changes. + */ + readonly status: DevframeConnectionStatus + /** + * The most recent connection-level error (WebSocket error, refused trust, or + * failed connection-meta load), or `null` when the connection is healthy. + */ + readonly connectionError: Error | null /** * The connection meta */ @@ -149,6 +184,8 @@ export interface DevframeRpcClient { export interface DevframeRpcClientMode { readonly isTrusted: boolean + readonly status: DevframeConnectionStatus + readonly connectionError: Error | null ensureTrusted: DevframeRpcClient['ensureTrusted'] requestTrust: DevframeRpcClient['requestTrust'] requestTrustWithToken: DevframeRpcClient['requestTrustWithToken'] @@ -311,6 +348,7 @@ export async function getDevframeRpcClient( metaBaseUrl: resolveMetaBaseUrl(), events, clientRpc, + callTimeout: options.callTimeout, rpcOptions: { ...rpcOptions, async onRequest(req, next, resolve) { @@ -342,6 +380,12 @@ export async function getDevframeRpcClient( get isTrusted() { return mode.isTrusted }, + get status() { + return mode.status + }, + get connectionError() { + return mode.connectionError + }, connectionMeta, ensureTrusted: mode.ensureTrusted, requestTrust: mode.requestTrust, diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index a5d217cf..ae34c340 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -1,4 +1,4 @@ -import type { DevframeRpcContext } from 'devframe/client' +import type { DevframeConnectionStatus, DevframeRpcContext, RpcClientEvents } from 'devframe/client' import type { EventEmitter } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' @@ -45,6 +45,24 @@ export interface DocksContext extends DevframeRpcContext { * The when-clause context for conditional visibility */ readonly when: WhenClauseContext + /** + * The live connection status of the underlying devframe client, so a viewer + * can render one central connection indicator for every docked plugin + * instead of each plugin surfacing its own. + */ + readonly connection: DocksConnectionContext +} + +export interface DocksConnectionContext { + /** The current connection status. */ + readonly status: DevframeConnectionStatus + /** The most recent connection-level error, or `null` when healthy. */ + readonly error: Error | null + /** + * The client's event emitter — subscribe to `connection:status`, + * `connection:error`, and `rpc:error` to react to changes. + */ + readonly events: EventEmitter } export interface WhenClauseContext { diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 777aa4b2..270208d4 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -109,6 +109,15 @@ export async function createDevframeClientHost( docks, commands, when, + connection: { + get status() { + return rpc.status + }, + get error() { + return rpc.connectionError + }, + events: rpc.events, + }, } const disposers: Array<() => void> = [] diff --git a/plugins/a11y/src/spa/app.tsx b/plugins/a11y/src/spa/app.tsx index a09c60c0..cedce164 100644 --- a/plugins/a11y/src/spa/app.tsx +++ b/plugins/a11y/src/spa/app.tsx @@ -52,7 +52,7 @@ export function App() { scanning={channel.scanning()} onRescan={channel.rescan} /> - + 0}> diff --git a/plugins/a11y/src/spa/components/header.tsx b/plugins/a11y/src/spa/components/header.tsx index 84278c96..eb303a98 100644 --- a/plugins/a11y/src/spa/components/header.tsx +++ b/plugins/a11y/src/spa/components/header.tsx @@ -45,14 +45,24 @@ export function Header(props: HeaderProps) { ) } -export function MetaLine(props: { report: Accessor, backend: Accessor }) { +export function MetaLine(props: { + report: Accessor + backend: Accessor + status?: Accessor +}) { + // The backend is optional here, so a degraded connection is shown as a quiet + // tag rather than taking over the panel. + const degraded = () => { + const s = props.status?.() + return s === 'disconnected' || s === 'unauthorized' || s === 'error' ? s : null + } return ( {report => (
{report().url} - - {b => {b()}} + {b => {b()}}}> + {s => {s()}} axe diff --git a/plugins/a11y/src/spa/lib/devframe.ts b/plugins/a11y/src/spa/lib/devframe.ts index 90631e5a..e4b83d18 100644 --- a/plugins/a11y/src/spa/lib/devframe.ts +++ b/plugins/a11y/src/spa/lib/devframe.ts @@ -1,3 +1,4 @@ +import type { DevframeConnectionStatus } from 'devframe/client' import type { Accessor } from 'solid-js' import type { Impact } from '../../shared/protocol.ts' import { connectDevframe } from 'devframe/client' @@ -19,6 +20,12 @@ export interface A11yConfig { export interface DevframeState { /** `'websocket'` in dev, `'static'` for a baked build, `null` while/if unreachable. */ backend: Accessor + /** + * Connection status of the (optional) devframe backend. The panel's core + * scan loop runs over BroadcastChannel, so this is informational only — + * surfaced as a tag rather than blocking the UI. + */ + status: Accessor /** Impact taxonomy + copy from the `get-config` RPC. */ config: Accessor } @@ -31,11 +38,14 @@ export interface DevframeState { */ export function connectDevframeState(): DevframeState { const [backend, setBackend] = createSignal(null) + const [status, setStatus] = createSignal(null) const [config, setConfig] = createSignal(null) connectDevframe() .then(async (rpc) => { setBackend(rpc.connectionMeta.backend) + setStatus(rpc.status) + rpc.events.on('connection:status', s => setStatus(s)) try { await rpc.ensureTrusted(2000) } @@ -51,7 +61,8 @@ export function connectDevframeState(): DevframeState { }) .catch(() => { // No reachable backend (e.g. agent loaded outside a devframe host). + setStatus('error') }) - return { backend, config } + return { backend, status, config } } diff --git a/plugins/a11y/src/spa/styles.css b/plugins/a11y/src/spa/styles.css index 3dfd4af7..325940cf 100644 --- a/plugins/a11y/src/spa/styles.css +++ b/plugins/a11y/src/spa/styles.css @@ -148,6 +148,12 @@ body { border-radius: 5px; } +.meta__tag--warn { + border-color: color-mix(in oklab, #d9a441 60%, var(--line)); + color: #d9a441; + text-transform: capitalize; +} + /* ── severity summary ──────────────────────────────────────────────────── */ .summary { diff --git a/plugins/code-server/src/client/connection-state.ts b/plugins/code-server/src/client/connection-state.ts new file mode 100644 index 00000000..d6f51341 --- /dev/null +++ b/plugins/code-server/src/client/connection-state.ts @@ -0,0 +1,99 @@ +import type { DevframeConnectionStatus } from 'devframe/client' +import { button, cx } from './design' + +interface StateCopy { + icon: string + title: string + body: string +} + +const COPY: Record, StateCopy> = { + connecting: { + icon: 'i-ph-plugs-connected-duotone', + title: 'Connecting…', + body: 'Establishing a connection to the devframe server.', + }, + disconnected: { + icon: 'i-ph-plugs-duotone', + title: 'Disconnected', + body: 'Lost the connection to the devframe server. Reload once it is back up.', + }, + unauthorized: { + icon: 'i-ph-lock-key-duotone', + title: 'Not authorized', + body: 'Reopen the link printed by your dev server, then reload.', + }, + error: { + icon: 'i-ph-warning-octagon-duotone', + title: 'Connection failed', + body: 'Could not reach the devframe server.', + }, +} + +export interface ConnectionStateHandle { + /** Render for a status. Returns `true` when it took over the container. */ + update: (status: DevframeConnectionStatus, error?: string | null) => boolean + dispose: () => void +} + +/** + * A full-container connection state, shown whenever the devframe client isn't + * `connected` so the launcher never sits on an unexplained blank while the + * socket is down. Reload is the recovery path (no auto-reconnect). + */ +export function createConnectionState(container: HTMLElement): ConnectionStateHandle { + const root = document.createElement('div') + root.className = 'absolute inset-0 flex flex-col items-center justify-center gap-4 bg-base color-base p-8 text-center z-nav' + root.hidden = true + container.append(root) + + function update(status: DevframeConnectionStatus, error?: string | null): boolean { + if (status === 'connected') { + root.hidden = true + root.replaceChildren() + return false + } + const copy = COPY[status] + root.replaceChildren() + + const glyph = document.createElement('div') + glyph.className = cx(copy.icon, 'text-4xl color-active') + root.append(glyph) + + const text = document.createElement('div') + text.className = 'flex flex-col gap-1' + const title = document.createElement('p') + title.className = 'text-lg font-medium' + title.textContent = copy.title + const body = document.createElement('p') + body.className = 'text-sm color-muted max-w-sm' + body.textContent = copy.body + text.append(title, body) + if (error && status === 'error') { + const code = document.createElement('code') + code.className = 'mt-1 max-w-sm break-words font-mono text-xs color-faint' + code.textContent = error + text.append(code) + } + root.append(text) + + if (status !== 'connecting') { + const reload = document.createElement('button') + reload.type = 'button' + reload.className = button({ variant: 'primary', size: 'sm' }) + reload.textContent = 'Reload' + reload.addEventListener('click', () => location.reload()) + root.append(reload) + } + + root.hidden = false + return true + } + + return { + update, + dispose() { + root.remove() + }, + } +} diff --git a/plugins/code-server/src/client/index.ts b/plugins/code-server/src/client/index.ts index b9746d3d..95b4b015 100644 --- a/plugins/code-server/src/client/index.ts +++ b/plugins/code-server/src/client/index.ts @@ -9,6 +9,7 @@ import type { import type { CodeServerViewState } from './view' import { connectDevframe } from 'devframe/client' import { STATE_KEY } from '../constants' +import { createConnectionState } from './connection-state' import { createCodeServerView } from './view' export interface MountCodeServerOptions { @@ -42,6 +43,18 @@ export async function mountCodeServer( if (rpc.connectionMeta.backend === 'websocket') await rpc.ensureTrusted(5000).catch(() => {}) + // Overlay the launcher with a clear connection state whenever the client + // isn't connected — a dropped socket or refused auth would otherwise leave + // an unexplained blank / stale editor. Needs a positioned container. + if (!container.style.position) + container.style.position = 'relative' + const connectionState = createConnectionState(container) + const syncConnection = (): void => { + connectionState.update(rpc.status, rpc.connectionError?.message) + } + syncConnection() + const offConnection = rpc.events.on('connection:status', syncConnection) + let detection: CodeServerDetection = { checked: false, installed: false, bin: 'code-server' } let server: CodeServerServerInfo = { status: 'stopped' } let auth: CodeServerAuth | undefined @@ -148,6 +161,8 @@ export async function mountCodeServer( dispose() { disposed = true off?.() + offConnection?.() + connectionState.dispose() view.dispose() }, } diff --git a/plugins/git/src/client/components/connection-state.tsx b/plugins/git/src/client/components/connection-state.tsx new file mode 100644 index 00000000..aae59ca2 --- /dev/null +++ b/plugins/git/src/client/components/connection-state.tsx @@ -0,0 +1,68 @@ +'use client' + +import type { DevframeConnectionStatus } from 'devframe/client' +import { button } from '../lib/design' +import { Icon } from './ui/icon' + +interface StateCopy { + icon: string + title: string + body: string + spin?: boolean +} + +const COPY: Record, StateCopy> = { + connecting: { + icon: 'i-ph-plugs-connected-duotone', + title: 'Connecting…', + body: 'Establishing a connection to the devframe server.', + spin: true, + }, + disconnected: { + icon: 'i-ph-plugs-duotone', + title: 'Disconnected', + body: 'Lost the connection to the devframe server. Reload once it is back up.', + }, + unauthorized: { + icon: 'i-ph-lock-key-duotone', + title: 'Not authorized', + body: 'This client isn’t authorized. Reopen the link printed by your dev server, then reload.', + }, + error: { + icon: 'i-ph-warning-octagon-duotone', + title: 'Connection failed', + body: 'Could not reach the devframe server.', + }, +} + +/** + * Full-panel connection state — shown whenever the client isn't `connected`, + * so the UI never sits on an infinite spinner without saying why. Reload is the + * recovery path (the client doesn't auto-reconnect). + */ +export function ConnectionState({ status, error }: { status: DevframeConnectionStatus, error?: string | null }) { + if (status === 'connected') + return null + const copy = COPY[status] + return ( +
+ +
+

{copy.title}

+

{copy.body}

+ {error && status === 'error' && ( +

{error}

+ )} +
+ {status !== 'connecting' && ( + + )} +
+ ) +} diff --git a/plugins/git/src/client/components/dashboard.tsx b/plugins/git/src/client/components/dashboard.tsx index df295ff2..4c12e0a5 100644 --- a/plugins/git/src/client/components/dashboard.tsx +++ b/plugins/git/src/client/components/dashboard.tsx @@ -6,6 +6,7 @@ import type { GitBranches } from '../../index' import { useCallback, useEffect, useRef, useState } from 'react' import { nav as navBar, navBrand, tab as tabClass, tabsList } from '../lib/design' import { CommitDetailsPanel } from './commit-details-panel' +import { ConnectionState } from './connection-state' import { LogPanel } from './log-panel' import { RpcProvider, useRpc } from './rpc-provider' import { StatusPanel } from './status-panel' @@ -91,10 +92,12 @@ function Resizer({ onPointerDown, label }: { onPointerDown: (e: ReactPointerEven } function ConnectionBadge() { - const { rpc, error } = useRpc() - if (error) - return disconnected - if (!rpc) + const { rpc, status } = useRpc() + if (status === 'error' || status === 'disconnected') + return {status} + if (status === 'unauthorized') + return unauthorized + if (status === 'connecting' || !rpc) return connecting… const backend = rpc.connectionMeta.backend return ( @@ -251,10 +254,20 @@ function DashboardBody() { ) } +function DashboardGate() { + const { status, error } = useRpc() + // A static backend reports `connected` immediately; a websocket backend + // shows the connection state until it's live, so data views never spin + // against a socket that will never answer. + if (status !== 'connected') + return + return +} + export function Dashboard() { return ( - + ) } diff --git a/plugins/git/src/client/components/rpc-provider.tsx b/plugins/git/src/client/components/rpc-provider.tsx index 3e87cf90..144189ca 100644 --- a/plugins/git/src/client/components/rpc-provider.tsx +++ b/plugins/git/src/client/components/rpc-provider.tsx @@ -1,6 +1,6 @@ 'use client' -import type { DevframeRpcClient } from 'devframe/client' +import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client' import type { ConnectionMeta } from 'devframe/types' import type { ReactNode } from 'react' import { connectDevframe } from 'devframe/client' @@ -9,20 +9,22 @@ import { createContext, use, useEffect, useState } from 'react' interface ConnectionState { rpc: DevframeRpcClient | null + status: DevframeConnectionStatus error: string | null } -const RpcContext = createContext({ rpc: null, error: null }) +const RpcContext = createContext({ rpc: null, status: 'connecting', error: null }) export function useRpc(): ConnectionState { return use(RpcContext) } export function RpcProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ rpc: null, error: null }) + const [state, setState] = useState({ rpc: null, status: 'connecting', error: null }) useEffect(() => { let cancelled = false + let off: (() => void) | undefined // In combined dev (`pnpm dev`) the SPA is served by Next for HMR while // the RPC backend runs as a separate devframe server. This env var points // the client straight at that WebSocket; unset in production, where the @@ -40,18 +42,26 @@ export function RpcProvider({ children }: { children: ReactNode }) { : undefined connectDevframe(options).then( (rpc) => { - if (!cancelled) - setState({ rpc, error: null }) + if (cancelled) + return + setState({ rpc, status: rpc.status, error: rpc.connectionError?.message ?? null }) + // Track the live connection so a mid-session drop or auth refusal + // swaps the UI to a clear state instead of leaving stale data on screen. + off = rpc.events.on('connection:status', (status) => { + setState({ rpc, status, error: rpc.connectionError?.message ?? null }) + }) }, (err: unknown) => { if (cancelled) return + // Failing to even load the connection meta is a fatal connection error. const message = err instanceof Error ? err.message : String(err) - setState({ rpc: null, error: message }) + setState({ rpc: null, status: 'error', error: message }) }, ) return () => { cancelled = true + off?.() } }, []) diff --git a/plugins/inspect/src/client/index.ts b/plugins/inspect/src/client/index.ts index 68d9060b..e253b1b5 100644 --- a/plugins/inspect/src/client/index.ts +++ b/plugins/inspect/src/client/index.ts @@ -1,7 +1,7 @@ -import type { DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client' +import type { DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client' import { connectDevframe } from 'devframe/client' -export type { DevframeRpcClient } +export type { DevframeConnectionStatus, DevframeRpcClient } export type { AgentManifest, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types' /** diff --git a/plugins/inspect/src/spa/App.vue b/plugins/inspect/src/spa/App.vue index 6bd1ba09..bfc110e0 100644 --- a/plugins/inspect/src/spa/App.vue +++ b/plugins/inspect/src/spa/App.vue @@ -23,6 +23,11 @@ const tabs: { value: Tab, label: string, icon: string }[] = [ ] onMounted(connect) + +// The client doesn't auto-reconnect; a reload re-runs the whole handshake. +function reload(): void { + location.reload() +}