From d5448c7d341a0a1970f66c003b89690354e13375 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 15 Jul 2026 06:59:14 +0000 Subject: [PATCH 1/3] 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. --- packages/devframe/src/client/connection.ts | 62 +++++++ packages/devframe/src/client/index.ts | 1 + packages/devframe/src/client/rpc-static.ts | 4 + .../devframe/src/client/rpc-ws-status.test.ts | 151 ++++++++++++++++ packages/devframe/src/client/rpc-ws.ts | 166 +++++++++++++++++- packages/devframe/src/client/rpc.ts | 44 +++++ packages/hub/src/client/docks.ts | 20 ++- packages/hub/src/client/host.ts | 9 + .../@devframes/hub/client.snapshot.d.ts | 6 + .../tsnapi/devframe/client.snapshot.d.ts | 21 +++ .../tsnapi/devframe/client.snapshot.js | 9 + 11 files changed, 485 insertions(+), 8 deletions(-) create mode 100644 packages/devframe/src/client/connection.ts create mode 100644 packages/devframe/src/client/rpc-ws-status.test.ts 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/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 0a048486..b6755360 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -51,12 +51,18 @@ export interface DockPanelStorage { open: boolean; inactiveTimeout: number; } +export interface DocksConnectionContext { + readonly status: DevframeConnectionStatus; + readonly error: Error | null; + readonly events: EventEmitter; +} export interface DocksContext extends DevframeRpcContext { readonly clientType: 'embedded' | 'standalone'; readonly panel: DocksPanelContext; readonly docks: DocksEntriesContext; readonly commands: CommandsContext; readonly when: WhenClauseContext; + readonly connection: DocksConnectionContext; } export interface DocksEntriesContext { selectedId: string | null; diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 2a5f3a17..72ce9efb 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -5,6 +5,8 @@ export interface DevframeRpcClient { events: EventEmitter; readonly isTrusted: boolean | null; + readonly status: DevframeConnectionStatus; + readonly connectionError: Error | null; readonly connectionMeta: ConnectionMeta; ensureTrusted: (_?: number) => Promise; requestTrust: () => Promise; @@ -24,6 +26,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']; @@ -40,6 +44,7 @@ export interface DevframeRpcClientOptions { wsOptions?: Partial; rpcOptions?: Partial>; cacheOptions?: boolean | Partial; + callTimeout?: number; } export interface DevframeRpcContext { readonly rpc: DevframeRpcClient; @@ -81,6 +86,9 @@ export interface DevframeScopedClientStreamingHost { } export interface RpcClientEvents { 'rpc:is-trusted:updated': (_: boolean) => void; + 'connection:status': (_: DevframeConnectionStatus, _: DevframeConnectionStatus) => void; + 'connection:error': (_: Error) => void; + 'rpc:error': (_: Error, _: string) => void; } export interface RpcStreamingClientHost { subscribe: (_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader; @@ -93,11 +101,23 @@ export interface StreamingSubscribeOptions { // #region Types export type DevframeClientRpcHost = RpcFunctionsCollector; +export type DevframeConnectionErrorKind = 'connection' | 'auth' | 'timeout'; +export type DevframeConnectionStatus = 'connecting' | 'connected' | 'unauthorized' | 'disconnected' | 'error'; export type DevframeRpcClientCall = BirpcReturn['$call']; export type DevframeRpcClientCallEvent = BirpcReturn['$callEvent']; export type DevframeRpcClientCallOptional = BirpcReturn['$callOptional']; // #endregion +// #region Classes +export declare class DevframeConnectionError extends Error { + name: string; + readonly kind: DevframeConnectionErrorKind; + constructor(_: DevframeConnectionErrorKind, _: string, _?: { + cause?: unknown; + }); +} +// #endregion + // #region Functions export declare function authenticateWithUrlOtp(_: Pick, _?: { param?: string; @@ -107,6 +127,7 @@ export declare function createClientSettings = Rec export declare function createRpcStreamingClientHost(_: DevframeRpcClient): RpcStreamingClientHost; export declare function createScopedClientContext(_: DevframeRpcClient, _: NS): DevframeScopedClientContext; export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; +export declare function isCallableStatus(_: DevframeConnectionStatus): boolean; export declare function readOtpFromUrl(_?: string): string | undefined; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index 58e177d6..fd810c9b 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -1,6 +1,14 @@ /** * Generated by tsnapi — public API snapshot of `devframe/client` */ +// #region Classes +export class DevframeConnectionError extends Error { + name + kind + constructor(_, _, _) {} +} +// #endregion + // #region Functions export async function authenticateWithUrlOtp(_, _) {} export function consumeOtpFromUrl(_) {} @@ -8,6 +16,7 @@ export function createClientSettings(_, _) {} export function createRpcStreamingClientHost(_) {} export function createScopedClientContext(_, _) {} export async function getDevframeRpcClient(_) {} +export function isCallableStatus(_) {} export function readOtpFromUrl(_) {} // #endregion From b41d16ae7263b75b393b2bb013a6ee60a4af2079 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 15 Jul 2026 06:59:27 +0000 Subject: [PATCH 2/3] feat(plugins): show connection/auth/rpc errors instead of spinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every built-in plugin now renders a clear connection state — connecting, disconnected, unauthorized, or error, with a reload affordance — driven by the devframe client's status, replacing the indefinite spinners that appeared when the socket dropped or auth was refused. git (React), inspect (Vue), terminals (Svelte), code-server (vanilla) and messages (Vue) gate their surface on the status; a11y (Solid) keeps its BroadcastChannel-first UI and surfaces a degraded backend as a quiet tag. --- plugins/a11y/src/spa/app.tsx | 2 +- plugins/a11y/src/spa/components/header.tsx | 16 ++- plugins/a11y/src/spa/lib/devframe.ts | 13 ++- plugins/a11y/src/spa/styles.css | 6 ++ .../src/client/connection-state.ts | 99 +++++++++++++++++++ plugins/code-server/src/client/index.ts | 15 +++ .../client/components/connection-state.tsx | 68 +++++++++++++ .../git/src/client/components/dashboard.tsx | 23 ++++- .../src/client/components/rpc-provider.tsx | 22 +++-- plugins/inspect/src/client/index.ts | 4 +- plugins/inspect/src/spa/App.vue | 34 ++++++- plugins/inspect/src/spa/composables/rpc.ts | 17 +++- plugins/inspect/src/spa/style.css | 9 ++ plugins/messages/src/client/App.vue | 49 ++++++++- plugins/terminals/src/client/App.svelte | 35 ++++++- .../plugin-inspect/client.snapshot.d.ts | 1 + 16 files changed, 386 insertions(+), 27 deletions(-) create mode 100644 plugins/code-server/src/client/connection-state.ts create mode 100644 plugins/git/src/client/components/connection-state.tsx 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() +}