JSON-RPC: Requests, Responses, and Ids
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
json-rpc.ts
import type { Tracer } from "../trace";/** A request or notification on the wire; a notification has no id. */export interface JsonRpcRequest { jsonrpc: "2.0"; method: string; params?: unknown; id?: number | string | null;}export interface JsonRpcError { code: number; message: string; data?: unknown;}/** A reply: exactly one of result and error, under the request's id. */export interface JsonRpcResponse { jsonrpc: "2.0"; id: number | string | null; result?: unknown; error?: JsonRpcError;}/** The pre-defined error codes of the 2.0 specification. */export const JSON_RPC_ERRORS = { parse: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602, internal: -32603,} as const;/** Thrown by a handler that cannot use the params it was given; becomes error -32602. */export class InvalidParams extends Error { constructor(message = "invalid params") { super(message); this.name = "InvalidParams"; }}export type Handler = (params: unknown) => unknown;export type MessageKind = "request" | "notification" | "invalid" | "garbage";/** What the client learned from one reply. */export type Receipt = { id: number; method: string; result: unknown } | { id: number; method: string; code: number; message: string } | null;/** * A JSON-RPC 2.0 server: methods registered by name, one wire message * in, at most one reply out. A request gets a reply under its id with a * result or an error; a notification, which has no id, gets nothing * back, ever; a line that is not JSON, or an object that is not a 2.0 * request, gets an error under a null id. The transport is someone * else's problem: text in, text out. * * Time: O(handler) per message. Space: O(methods). */export class JsonRpcServer { private readonly handlers = new Map<string, Handler>(); constructor(private readonly tracer?: Tracer) {} register(method: string, handler: Handler): this { this.handlers.set(method, handler); return this; } /** Builds and traces the reply, the one place a response is made. */ private respond(id: number | string | null, method: string | null, outcome: { result: unknown } | { code: number; message: string }): string { const ok = "result" in outcome; const response: JsonRpcResponse = ok ? { jsonrpc: "2.0", id, result: outcome.result } : { jsonrpc: "2.0", id, error: { code: outcome.code, message: outcome.message } }; const text = JSON.stringify(response); const wireText = JSON.stringify(response); // ▸ one reply under the request's id: a result or an error, never both this.tracer?.emit("respond", { id, ok, code: ok ? null : outcome.code, method, text }); this.tracer?.emit("respond", { id, ok, code: ok ? null : outcome.code, method, wireText }); return text; return wireText; } /** Handles one message from the wire; returns the reply text, or null when nothing is owed. */ handle(text: string): string | null { handle(wireText: string): string | null { let parsed: unknown; let decodedMessage: unknown; try { parsed = JSON.parse(text); decodedMessage = JSON.parse(wireText); } catch { // ▸ not JSON at all: junk, answered with a parse error this.tracer?.emit("decode-garbage", { kind: "garbage", id: null, method: null, text }); this.tracer?.emit("decode-garbage", { kind: "garbage", id: null, method: null, wireText }); return this.respond(null, null, { code: JSON_RPC_ERRORS.parse, message: "parse error" }); } const message = parsed as Partial<JsonRpcRequest> | null; const message = decodedMessage as Partial<JsonRpcRequest> | null; const wellFormed = typeof message === "object" && message !== null && message.jsonrpc === "2.0" && typeof message.method === "string"; if (!wellFormed) { // ▸ JSON, but not a 2.0 request: no usable id or method this.tracer?.emit("decode-invalid", { kind: "invalid", id: null, method: null, text }); this.tracer?.emit("decode-invalid", { kind: "invalid", id: null, method: null, wireText }); return this.respond(null, null, { code: JSON_RPC_ERRORS.invalidRequest, message: "invalid request" }); } const request = message as JsonRpcRequest; const kind: MessageKind = request.id === undefined ? "notification" : "request"; // ▸ is this a request or a notification? this.tracer?.emit("decode", { kind, id: request.id ?? null, method: request.method, text }); this.tracer?.emit("decode", { kind, id: request.id ?? null, method: request.method, wireText }); const handler = this.handlers.get(request.method); if (kind === "notification") { // A notification is fire and forget: run it if we can, say nothing either way. // ▸ a notification gets no reply, not even an error this.tracer?.emit("silent", { method: request.method }); try { handler?.(request.params); } catch { // Nothing to report to. } return null; } const id = request.id ?? null; if (!handler) return this.respond(id, request.method, { code: JSON_RPC_ERRORS.methodNotFound, message: `method not found: ${request.method}` }); try { return this.respond(id, request.method, { result: handler(request.params) }); } catch (error) { if (error instanceof InvalidParams) return this.respond(id, request.method, { code: JSON_RPC_ERRORS.invalidParams, message: error.message }); return this.respond(id, request.method, { code: JSON_RPC_ERRORS.internal, message: "internal error" }); } }}/** * A JSON-RPC 2.0 client: every call takes the next id and is remembered * as pending until a reply with that id comes back, in whatever order * the replies arrive; a notification is sent with no id and nothing is * remembered. A reply whose id is not pending is dropped. */export class JsonRpcClient { private nextId = 1; private readonly waiting = new Map<number, string>(); private readonly pendingCalls = new Map<number, string>(); constructor(private readonly tracer?: Tracer) {} /** Ids still awaiting a reply, in the order they were sent. */ get pending(): number[] { return [...this.waiting.keys()]; return [...this.pendingCalls.keys()]; } /** A request: the next id, remembered as pending; returns the wire text. */ call(method: string, params?: unknown): string { const id = this.nextId++; this.waiting.set(id, method); this.pendingCalls.set(id, method); const request: JsonRpcRequest = params === undefined ? { jsonrpc: "2.0", method, id } : { jsonrpc: "2.0", method, params, id }; const text = JSON.stringify(request); const wireText = JSON.stringify(request); // ▸ take the next id, remember the call under it, send this.tracer?.emit("request", { id, method, params: params ?? null, pending: this.pending, text }); this.tracer?.emit("request", { id, method, params: params ?? null, pending: this.pending, wireText }); return text; return wireText; } /** A notification: no id, nothing pending; returns the wire text. */ notify(method: string, params?: unknown): string { const request: JsonRpcRequest = params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params }; const text = JSON.stringify(request); const wireText = JSON.stringify(request); // ▸ send with no id: nothing will ever come back this.tracer?.emit("notify", { method, params: params ?? null, text }); this.tracer?.emit("notify", { method, params: params ?? null, wireText }); return text; return wireText; } /** One reply from the wire: paired with its call by id, or dropped. */ receive(text: string): Receipt { receive(wireText: string): Receipt { let parsed: Partial<JsonRpcResponse> | null = null; let decodedMessage: Partial<JsonRpcResponse> | null = null; try { parsed = JSON.parse(text) as Partial<JsonRpcResponse>; decodedMessage = JSON.parse(wireText) as Partial<JsonRpcResponse>; } catch { parsed = null; decodedMessage = null; } const id = typeof parsed?.id === "number" ? parsed.id : null; const id = typeof decodedMessage?.id === "number" ? decodedMessage.id : null; const method = id === null ? undefined : this.waiting.get(id); const method = id === null ? undefined : this.pendingCalls.get(id); if (parsed === null || id === null || method === undefined) { if (decodedMessage === null || id === null || method === undefined) { // ▸ a reply nobody is waiting for is dropped this.tracer?.emit("orphan", { id, reason: parsed === null ? "not json" : id === null ? "no id" : "id not pending" }); this.tracer?.emit("orphan", { id, reason: decodedMessage === null ? "not json" : id === null ? "no id" : "id not pending" }); return null; } const expected = this.pending[0] ?? null; this.waiting.delete(id); this.pendingCalls.delete(id); if (parsed.error === undefined) { if (decodedMessage.error === undefined) { // ▸ a reply's id names the call it answers, whatever order it came in this.tracer?.emit("pair", { id, method, result: parsed.result ?? null, expected }); this.tracer?.emit("pair", { id, method, result: decodedMessage.result ?? null, expected }); return { id, method, result: parsed.result }; return { id, method, result: decodedMessage.result }; } // ▸ the error goes to the waiting call, code and message this.tracer?.emit("failed", { id, method, code: parsed.error.code, message: parsed.error.message }); this.tracer?.emit("failed", { id, method, code: decodedMessage.error.code, message: decodedMessage.error.message }); return { id, method, code: parsed.error.code, message: parsed.error.message }; return { id, method, code: decodedMessage.error.code, message: decodedMessage.error.message }; }}3 calls got results, 1 got errors, 1 replies were dropped, and 1 messages were answered with silence.