Lightweight JSON-RPC 2.0 library for TypeScript.
- Spec-compliant JSON-RPC 2.0
- Supports batched requests
- Supports bidirectional communication
- Works great with TypeScript
- Transport-agnostic core
- Zero dependencies
- Designed for Cloudflare Workers
npm i @jmorrell/jsonrpc
This library was influenced by the designs of:
Cap'n Web is an object-capabilities RPC library from Kenton Varda. Beyond its
support for object-capabilities, it is designed to integrate very nicely with TypeScript and Cloudflare Workers.
I couldn't find a JSON-RPC library that worked as nicely, so I stole its design used Cap'n Web as inspiration
for a JSON-RPC implementation.
Cap'n Web has a number of benefits over JSON-RPC
- Object capabilities. You can pass functions and classes by reference. This is more than a feature, it completely changes how expressive your API can be, and enables patterns that are not possible with a more limited RPC protocol.
- Pipelining. Invoke two methods and then pass their results into a third in one round-trip.
- Support for non-JSON data types:
ReadableStream,bigint,Date
So why would you ever want to use JSON-RPC instead?
Mainly it's boring and has been around for longer than a few months. The JSON-RPC 2.0 Spec was last updated in 2013. Even more impressive, you can sit down with a cup of coffee and read the whole thing. Your coffee might not even be cool enough to drink when you've finished.
A few more points:
- Widely used in the Language Server Protocol which drives code-editing basically everywhere
- Used in MCP spec
- Easy to invoke with plain
curlcommands - Works well with browser dev tools
- Many client implementations in many languages
Cap'n Web is strictly more powerful, and I look forward to seeing it grow and mature, but for many projects today JSON-RPC is a great fit.
This serves as the service contract. Import it on both client and server for end-to-end type safety.
export type HelloService = {
hello(name: string): string;
};import { newHttpBatchRpcSession } from "@jmorrell/jsonrpc";
const client = newHttpBatchRpcSession<HelloService>("https://example.com/api");
const result = await client.hello("World");
console.log(result);Full autocompletion. Every method returns a Promise of its return type.
import { newWorkersRpcResponse } from "@jmorrell/jsonrpc";
export class HelloServiceImpl implements HelloService {
hello(name: string) {
return `Hello, ${name}!`;
}
}
export default {
async fetch(request: Request, env: Env) {
let url = new URL(request.url);
if (url.pathname === "/api") {
return newWorkersRpcResponse(request, new HelloServiceImpl());
}
return new Response("Not found", { status: 404 });
},
};This library complies with the JSON-RPC 2.0 Spec, however it intentionally leaves out support for two features:
Notifications
Only requests are allowed. You can use a request with a void return if you do not need a response.
Named arguments
In order to work nicely with TypeScript, we only accept positional arguments. Named params are
rejected with -32602 Invalid params. Note that you can use one argment with an object as the
first parameter to immitate using named arguments.
// Instead of this
example(a: string, b: string, c: string): string
// write your function like this:
example({ a: string, b: string, c: string}): stringCalls made in the same event loop turn are batched into a single HTTP request:
// These three calls produce ONE HTTP request with a JSON-RPC batch array.
const [sum, difference, product] = await Promise.all([
client.add(1, 2),
client.subtract(5, 3),
client.multiply(2, 4),
]);Batching uses setTimeout(0) -- all synchronous calls and microtasks (.then(), queueMicrotask) within the same turn are included.
const client = newHttpBatchRpcSession<MathService>({
url: "https://example.com/api",
getHeaders: () => ({ Authorization: `Bearer ${token}` }),
});getHeaders can be async.
newWebSocketRpcSession creates a typed RPC session over a WebSocket. Pass a URL string and it connects for you, or pass an existing WebSocket instance.
import { newWebSocketRpcSession } from "@jmorrell/jsonrpc";
import type { ServerApi } from "./server";
const session = newWebSocketRpcSession<ServerApi>("wss://example.com/api");
const result = await session.remote.add(1, 2); // 3Unlike the HTTP client, each call is an individual JSON-RPC message over the persistent connection -- no batching needed.
Both sides can call methods on each other. The client provides a local service object that the server can invoke:
import { newWebSocketRpcSession } from "@jmorrell/jsonrpc";
import type { ServerApi, ClientApi } from "./server";
// Local methods the server can call on us.
const localService: ClientApi = {
onEvent(event) {
console.log("Server pushed:", event);
},
};
const session = newWebSocketRpcSession<ServerApi, ClientApi>("wss://example.com/api", localService);
// Call the server.
const result = await session.remote.add(1, 2);On the server side (Cloudflare Workers), use newWorkersWebSocketRpcSession to get both the HTTP Response and the session handle:
import { newWorkersWebSocketRpcSession } from "@jmorrell/jsonrpc";
export default {
async fetch(request: Request) {
const { response, session } = newWorkersWebSocketRpcSession<ClientApi, typeof service>(
request,
service,
);
// Push events to the client.
session.remote.onEvent({ message: "hello" });
// Clean up when the client disconnects.
session.onClose(() => {
console.log("Client disconnected");
});
return response;
},
};// Register a close handler.
session.onClose(() => console.log("Connection closed"));
// Close the session (also closes the underlying WebSocket).
session.close();
// Supports Symbol.dispose for `using` declarations.
using session = newWebSocketRpcSession<ServerApi>("wss://example.com/api");Calling a remote method after close rejects with "Session is closed". When the WebSocket drops unexpectedly, all pending calls reject and close handlers fire.
Convenience dispatcher for Cloudflare Workers. Routes based on the request:
POST-> HTTP batch handler (with CORSAccess-Control-Allow-Origin: *)- WebSocket upgrade -> WebSocket RPC session
- Anything else ->
400 Bad Request
HTTP handler. Takes a Request, returns a Response.
- Non-POST requests get
405 Method Not AllowedwithAllow: POSTheader - Invalid JSON returns a
-32700 Parse errorresponse - Notifications return
204 No Content - Everything else returns
200withContent-Type: application/json
CORS is out of scope -- handle preflight before calling newHttpBatchRpcResponse, or use newWorkersRpcResponse which adds a permissive CORS header.
Fire-and-forget WebSocket handler for Cloudflare Workers. Creates a WebSocketPair, starts an RPC session as acceptor, and returns the 101 upgrade response. Use this when you don't need to call methods on the client.
Same as above, but returns { response, session } so you can use session.remote to call methods on the client. See Bidirectional RPC above.
Transport-agnostic core. Takes a parsed JSON body (not a Request), returns the response object(s) or null for notification-only requests. Use this if you need to handle the transport yourself.
import { processRpc } from "@jmorrell/jsonrpc";
ws.on("message", async (data) => {
const body = JSON.parse(data);
const result = await processRpc(body, myService);
if (result !== null) ws.send(JSON.stringify(result));
});When a remote method returns a JSON-RPC error, the client throws an RpcError with message, code, and optional data:
import { RpcError } from "@jmorrell/jsonrpc";
try {
await client.divide(1, 0);
} catch (err) {
if (err instanceof RpcError) {
console.log(err.message); // "Division by zero"
console.log(err.code); // -32000
}
}Internal errors follow the spec-defined error codes:
| Code | Meaning |
|---|---|
| -32700 | Parse error |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
Protocol-level issues (malformed messages, unknown response IDs, transport failures) are reported via the onError callback as RpcProtocolError instances. Each has a code string:
| Code | Meaning |
|---|---|
PARSE_ERROR |
Malformed JSON on transport |
INVALID_MESSAGE |
Non-object message received |
UNROUTABLE_MESSAGE |
Message is neither request nor response |
INVALID_RESPONSE |
Response fails structural validation |
NULL_RESPONSE_ID |
Response has null/undefined ID |
UNKNOWN_RESPONSE_ID |
No pending call for response ID |
NOTIFICATION_RECEIVED |
Unsupported notification received |
HANDLER_ERROR |
Service method threw |
SEND_FAILED |
Transport send threw |
MIT