jsonrpc

js/jsonrpc.ts

fino:jsonrpc - JSON-RPC 2.0 request dispatch and peer transports.

Useful references:

This module provides the common JSON-RPC pieces used by Fino integrations: a method registry for request handling, a bidirectional peer for request and notification exchange over string transports, and a small HTTP server adapter for services that only need a JSON-RPC endpoint.

Protocol model

JsonRpcService handles one JSON-RPC message string at a time. Requests with an id produce a JSON response string, while notifications without an id run the handler in the background and return null. The dispatcher emits the standard error codes exported by this module and can validate params with fino:validate schemas before calling a method.

JsonRpcPeer builds on the same service model for long-lived, bidirectional transports. The transport contract is deliberately minimal: framing can be newline-delimited streams, pipes, WebSockets, stdio processes, or in-memory queues as long as the transport sends and receives complete JSON-RPC strings.

Current coverage

The implementation targets the single-message request, response, error, and notification flow from JSON-RPC 2.0. Batch requests are not implemented, and callers that require strict preflight validation of every JSON-RPC envelope field should validate before dispatch. Methods named with the reserved rpc. prefix are not blocked by the registry.

import { JsonRpcService, JsonRpcServer } from 'fino:jsonrpc';
import { v } from 'fino:validate';

const service = new JsonRpcService();
service.method('math.add')
  .description('Add two numbers.')
  .params(v.object({ a: v.number(), b: v.number() }))
  .handle((params) => {
    const { a, b } = params as { a: number; b: number };
    return a + b;
  });

const server = new JsonRpcServer(service).listen({ port: 3000, path: '/rpc' });
await server.ready;

Constants

const PARSE_ERROR

Standard JSON-RPC parse error code for invalid JSON payloads.

const INVALID_REQUEST

Standard JSON-RPC invalid request code for malformed request objects.

const METHOD_NOT_FOUND

Standard JSON-RPC method lookup failure code.

const INVALID_PARAMS

Standard JSON-RPC parameter validation failure code.

const INTERNAL_ERROR

Standard JSON-RPC internal error code for unexpected handler failures.

Classes

class JsonRpcError extends Error {

Error type that lets handlers choose the JSON-RPC error code and optional data value returned to a caller.

Throw this from a JsonRpcService handler when an application-level failure should be represented as a JSON-RPC error response instead of the default INTERNAL_ERROR. On the calling side, JsonRpcPeer.call() also rejects with a JsonRpcError reconstructed from an inbound error response, so the same type carries failures in both directions.

import { JsonRpcService, JsonRpcError } from 'fino:jsonrpc';

const service = new JsonRpcService();
service.method('account.withdraw').handle((params) => {
  const { amount, balance } = params as { amount: number; balance: number };
  if (amount > balance) {
    throw new JsonRpcError('Insufficient funds', -32001, { balance });
  }
  return balance - amount;
});

Properties

code: number

JSON-RPC error code to serialize in the response.

data?: unknown

Optional application-specific error details for the response data field.

Constructors

constructor(message: string, code: number, data?: unknown)

Create an error response payload.

message is also used as the JavaScript Error.message; code is copied into the JSON-RPC error object; data, when present, is serialized as the JSON-RPC error data member.

class JsonRpcService {

Registry and dispatcher for JSON-RPC methods.

A service maps method names to handlers and handles one inbound JSON-RPC message string at a time. It is transport-agnostic: callers can feed messages from HTTP, stdio, sockets, test queues, or JsonRpcPeer.

Register methods with the method(name) builder, then dispatch raw message strings through handle(). The same service can be mounted onto HTTP via httpHandler(), served standalone through JsonRpcServer, or attached to a bidirectional JsonRpcPeer to answer inbound requests.

import { JsonRpcService } from 'fino:jsonrpc';
import { v } from 'fino:validate';

const service = new JsonRpcService();
service.method('math.add')
  .params(v.object({ a: v.number(), b: v.number() }))
  .handle((params) => {
    const { a, b } = params as { a: number; b: number };
    return a + b;
  });

const response = await service.handle(JSON.stringify({
  jsonrpc: '2.0',
  method: 'math.add',
  params: { a: 2, b: 3 },
  id: 1,
}));
// response === '{"jsonrpc":"2.0","result":5,"id":1}'

Methods

method(name: string): MethodBuilder

Start registering a method by name.

Chain .description() or .params() before .handle() when the method needs registry metadata or input validation.

list(): Array<{ name: string; description?: string; params?: Record<string, unknown>; }>

Return the registered method metadata in insertion order.

The result is useful for local discovery endpoints, MCP-style tool conversion, or diagnostics. Handler functions are intentionally omitted.

async handle(raw: string, signal?: AbortSignal): Promise<string | null>

Dispatch one JSON-RPC message string.

Invalid JSON returns a PARSE_ERROR response. Malformed single request objects return INVALID_REQUEST. Unknown methods return METHOD_NOT_FOUND for requests and no response for notifications. When method metadata includes a params schema, failed validation returns INVALID_PARAMS.

The return value is a serialized JSON-RPC response for requests, or null when no response should be sent.

httpHandler(): (req: Request) => Promise<Response>

Create a Fetch-compatible HTTP handler for this service.

Only POST requests are accepted. Requests that dispatch to notifications receive 204 No Content; request messages receive an application/json response body containing the serialized JSON-RPC response.

class JsonRpcPeer {

Bidirectional JSON-RPC endpoint over a Transport.

The peer starts reading as soon as it is constructed. It resolves pending calls from inbound responses and dispatches inbound requests to the optional local service. Use done to observe the receive loop and close() to stop the transport.

Unlike JsonRpcService, a peer can originate traffic: call() sends a request and awaits its result, while notify() sends a fire-and-forget notification. Passing a JsonRpcService makes the connection symmetric so both ends can call each other over the single transport.

import { JsonRpcPeer, JsonRpcService } from 'fino:jsonrpc';
import type { Transport } from 'fino:jsonrpc';

declare const transport: Transport;

const local = new JsonRpcService();
local.method('log').handle((params) => { console.log(params); });

const peer = new JsonRpcPeer(transport, local);
const sum = await peer.call('math.add', { a: 1, b: 2 });
await peer.notify('log', { level: 'info', message: 'done' });
await peer.close();

Constructors

constructor(transport: Transport, service?: JsonRpcService, opts: { signal?: AbortSignal; } = {})

Create a peer over transport.

service, when provided, handles inbound requests and notifications from the same connection. opts.signal is passed through to those handlers.

Methods

call(method: string, params?: unknown): Promise<unknown>

Send a request and wait for its response result.

Request ids are generated as increasing numbers local to this peer. Rejected JSON-RPC responses become JsonRpcError instances.

async notify(method: string, params?: unknown): Promise<void>

Send a notification.

Notifications do not include an id, so no response is expected and remote handler failures are not reported to this peer.

async close(): Promise<void>

Mark the peer closed and close the underlying transport.

Getters

get done(): Promise<void>

Promise for the background receive loop.

It settles when the transport receive iterator ends or throws.

class JsonRpcServer {

Serve a JsonRpcService over transports or a convenience HTTP listener.

For composed HTTP applications, prefer JsonRpcService.httpHandler() or the App.rpc() helper so authentication, routing, and middleware can live in the application layer. JsonRpcServer is useful for tests, local tools, and simple standalone JSON-RPC endpoints.

The same server can drive an arbitrary Transport through serve() or spin up a dedicated HTTP listener through listen().

import { JsonRpcServer, JsonRpcService } from 'fino:jsonrpc';

const service = new JsonRpcService();
service.method('time.now').handle(() => Date.now());

const server = new JsonRpcServer(service);
const handle = server.listen({ port: 3000, path: '/rpc' });
await handle.ready;

Constructors

constructor(service: JsonRpcService)

Wrap a service for transport or HTTP serving.

Methods

async serve(transport: Transport): Promise<void>

Serve a service over one message-string transport until it closes.

listen(opts: ListenOptions): ServerHandle

Start a standalone HTTP JSON-RPC endpoint.

Only requests whose pathname matches opts.path are dispatched; all other paths return 404. Method filtering and response formatting are delegated to JsonRpcService.httpHandler().

Interfaces

interface Transport {

Minimal bidirectional carrier for complete JSON-RPC message strings.

A transport owns framing. send() must write one complete JSON-RPC message, receive() must yield complete JSON-RPC messages, and close() should stop both directions. The peer and server do not impose newline, WebSocket, or stream framing by themselves.

Implement this interface to bridge any duplex byte or message stream into JsonRpcPeer or JsonRpcServer. The example below adapts a WebSocket-like object that already delivers discrete text frames, so no extra framing is needed.

import { JsonRpcPeer } from 'fino:jsonrpc';
import type { Transport } from 'fino:jsonrpc';

function wsTransport(ws: {
  send(data: string): void;
  close(): void;
  messages(): AsyncIterable<string>;
}): Transport {
  return {
    send: (message) => ws.send(message),
    receive: () => ws.messages(),
    close: () => ws.close(),
  };
}

const peer = new JsonRpcPeer(wsTransport(socket));
const result = await peer.call('ping');

Methods

send(message: string): void | Promise<void>

Send one complete JSON-RPC message string.

receive(): AsyncIterable<string>

Yield complete JSON-RPC message strings until the connection closes.

close(): void | Promise<void>

Close the transport and unblock pending receivers when possible.

interface RequestContext {

Request-scoped data passed to method handlers.

The second argument to every JsonRpcHandler carries the current request id (or undefined for notifications) and an AbortSignal that fires when the dispatch or underlying HTTP request is cancelled. Handlers should forward signal to downstream async work so long-running methods can be aborted.

import { JsonRpcService } from 'fino:jsonrpc';

const service = new JsonRpcService();
service.method('fetch.upstream').handle(async (params, ctx) => {
  const { url } = params as { url: string };
  const res = await fetch(url, { signal: ctx.signal });
  return res.status;
});

Properties

id: number | string | null | undefined

Request id, notification marker, or undefined for notifications.

signal: AbortSignal

Abort signal associated with the current dispatch or HTTP request.

interface MethodMeta {

Optional metadata attached to a registered method.

Metadata is returned by JsonRpcService.list() and is also used for automatic params validation when a schema is provided. Metadata is accumulated through the MethodBuilder returned by JsonRpcService.method() rather than constructed directly, but the shape is exported so discovery code that consumes list() can type the entries it reads.

import { JsonRpcService } from 'fino:jsonrpc';
import { v } from 'fino:validate';

const service = new JsonRpcService();
service.method('user.rename')
  .description('Change a user display name.')
  .params(v.object({ id: v.string(), name: v.string() }))
  .handle((params) => params);

for (const method of service.list()) {
  console.log(method.name, method.description);
}

Properties

description?: string

Human-readable method description for registries or discovery endpoints.

params?: JsonSchema | SchemaBuilder

JSON Schema or fino:validate builder used to validate request params.

interface ListenOptions {

Options for serving JSON-RPC over HTTP.

Passed to JsonRpcServer.listen() to control the bound port, host, and the URL pathname that accepts JSON-RPC POST bodies. Requests to any other pathname receive 404.

import { JsonRpcServer, JsonRpcService } from 'fino:jsonrpc';
import type { ListenOptions } from 'fino:jsonrpc';

const opts: ListenOptions = { port: 0, host: '127.0.0.1', path: '/rpc' };
const handle = new JsonRpcServer(new JsonRpcService()).listen(opts);
await handle.ready;

Properties

port: number

TCP port for the HTTP listener. Use 0 to request an ephemeral port.

host?: string

Optional bind host passed to the HTTP server.

path?: string

URL pathname that should receive JSON-RPC POST requests. Defaults to /.

interface ServerHandle {

Handle returned by JsonRpcServer.listen().

Await ready before sending requests, read the resolved port (useful when 0 was requested and an ephemeral port was assigned), and call close() to shut the listener down.

import { JsonRpcServer, JsonRpcService } from 'fino:jsonrpc';
import type { ServerHandle } from 'fino:jsonrpc';

const handle: ServerHandle = new JsonRpcServer(new JsonRpcService())
  .listen({ port: 0, path: '/rpc' });
await handle.ready;
console.log(`listening on ${handle.port}`);
await handle.close();

Readonly Properties

readonly port: number

Actual bound port, including the assigned ephemeral port when 0 was used.

readonly ready: Promise<void>

Settles when the HTTP listener is ready to accept requests.

Methods

close(): Promise<void>

Stop accepting HTTP requests and release the listener.

Types

type JsonRpcHandler = (params: unknown, ctx: RequestContext) => unknown | Promise<unknown>

JSON-RPC method implementation.

params is the raw request params value after optional schema validation. The return value is serialized as the response result for requests. For notifications, returned values are ignored. Throwing a JsonRpcError controls the error code sent to the caller; any other thrown value becomes an INTERNAL_ERROR response.

import { JsonRpcService } from 'fino:jsonrpc';
import type { JsonRpcHandler } from 'fino:jsonrpc';

const greet: JsonRpcHandler = (params) => {
  const { name } = params as { name: string };
  return `Hello, ${name}`;
};

new JsonRpcService().method('greet').handle(greet);