/* A very small JSON-RPC 2.0 client over WebSocket — the dialect the lean4web * server forwards between the browser and a real Lean language server * (`lake serve`): one JSON message per frame, in either direction. * * The wiki is a deliberate minimal client: it opens one document, tracks the * cursor, and asks for goals. The Lean server nonetheless sends a few * *requests* its way — registering file watchers, asking to refresh caches, * probing for configuration. There is nothing to register or refresh here, * so those are acknowledged with `null` (a `workspace/configuration` probe * with `null` per item) rather than left hanging. */ export interface RpcError { code: number message: string data?: unknown } interface RpcMessage { jsonrpc: '2.0' id?: number | string method?: string params?: unknown result?: unknown error?: RpcError } export class RpcSocket { private ws: WebSocket private nextId = 1 private pending = new Map void, reject: (e: Error) => void }>() private closed = false readonly whenOpen: Promise constructor( url: string, private handlers: { onNotification?: (method: string, params: unknown) => void onClose?: (info: { clean: boolean }) => void } = {}, ) { this.ws = new WebSocket(url) this.whenOpen = new Promise((resolve, reject) => { this.ws.addEventListener('open', () => resolve(), { once: true }) this.ws.addEventListener('error', () => reject(new Error('websocket failed to open')), { once: true }) }) this.ws.addEventListener('message', (ev: MessageEvent) => { let msg: RpcMessage try { msg = JSON.parse(String(ev.data)) } catch { return } if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return if (msg.id !== undefined && msg.method) { this.answerServer(msg); return } if (msg.id !== undefined) { const waiter = this.pending.get(Number(msg.id)) if (!waiter) return this.pending.delete(Number(msg.id)) if (msg.error) waiter.reject(new Error(`${msg.method ?? 'request'}: ${msg.error.message}`)) else waiter.resolve(msg.result) return } if (msg.method) this.handlers.onNotification?.(msg.method, msg.params) }) this.ws.addEventListener('close', (ev: CloseEvent) => { this.closed = true const err = new Error('lean server closed the connection') for (const waiter of this.pending.values()) waiter.reject(err) this.pending.clear() this.handlers.onClose?.({ clean: ev.wasClean }) }) } get open() { return this.ws.readyState === WebSocket.OPEN } async request(method: string, params?: unknown): Promise { if (this.closed || this.ws.readyState !== WebSocket.OPEN) throw new Error('not connected to the lean server') const id = this.nextId++ const wire: RpcMessage = { jsonrpc: '2.0', id, method } if (params !== undefined) wire.params = params this.ws.send(JSON.stringify(wire)) return new Promise((resolve, reject) => { this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject }) }) } notify(method: string, params?: unknown) { if (this.closed || this.ws.readyState !== WebSocket.OPEN) return const wire: RpcMessage = { jsonrpc: '2.0', method } if (params !== undefined) wire.params = params this.ws.send(JSON.stringify(wire)) } close() { this.closed = true try { this.ws.close() } catch { /* already gone */ } } /* capability registrations, cache refreshes, configuration probes: the * honest answer from a single-document client is "nothing to do" */ private answerServer(msg: RpcMessage) { let result: unknown = null if (msg.method === 'workspace/configuration') { const items = (msg.params as { items?: unknown[] } | undefined)?.items result = Array.isArray(items) ? items.map(() => null) : null } if (this.closed || this.ws.readyState !== WebSocket.OPEN) return this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })) } }