117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
/* 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<number, { resolve: (v: unknown) => void, reject: (e: Error) => void }>()
|
|
private closed = false
|
|
|
|
readonly whenOpen: Promise<void>
|
|
|
|
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<T>(method: string, params?: unknown): Promise<T> {
|
|
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<T>((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 }))
|
|
}
|
|
}
|