Skip to content

Commit 8d7ae3b

Browse files
committed
Improve README
1 parent 931719c commit 8d7ae3b

1 file changed

Lines changed: 190 additions & 91 deletions

File tree

README.md

Lines changed: 190 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# @jmorrell/jsonrpc
22

3-
Lightweight [JSON-RPC 2.0](https://www.jsonrpc.org/specification) library for TypeScript with automatic request batching.
3+
Lightweight [JSON-RPC 2.0](https://www.jsonrpc.org/specification) library for TypeScript.
44

5-
- Type-safe client via TypeScript inference (no codegen)
6-
- Supports batched requests
75
- Spec-compliant JSON-RPC 2.0
6+
- Supports batched requests
7+
- Supports bidirectional communication
8+
- Works great with TypeScript
89
- Transport-agnostic core
910
- Zero dependencies
1011
- Designed for Cloudflare Workers
@@ -16,76 +17,105 @@ This library was influenced by the designs of:
1617
- [typed-rpc](https://github.com/fgnass/typed-rpc)
1718
- [capnweb](https://github.com/cloudflare/capnweb)
1819

20+
## Comparison with Cap'n Web
21+
22+
Cap'n Web is an object-capabilities RPC library from [Kenton Varda](https://github.com/kentonv). Beyond its
23+
support for object-capabilities, it is designed to integrate very nicely with TypeScript and Cloudflare Workers.
24+
I couldn't find a JSON-RPC library that worked as nicely, so I ~~stole its design~~ used Cap'n Web as inspiration
25+
for a JSON-RPC implementation.
26+
27+
Cap'n Web has a number of benefits over JSON-RPC
28+
29+
- **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.
30+
- **Pipelining**. Invoke two methods and then pass their results into a third in one round-trip.
31+
- **Support for non-JSON data types**: `ReadableStream`, `bigint`, `Date`
32+
33+
So why would you ever want to use JSON-RPC instead?
34+
35+
Mainly [**it's boring**](https://mcfunley.com/choose-boring-technology) and has been around for longer than a few months. The [JSON-RPC 2.0 Spec](https://www.jsonrpc.org/specification) 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.
36+
37+
A few more points:
38+
39+
- Widely used in [Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) which likely drives your editor
40+
- Used in [MCP spec](https://modelcontextprotocol.io/specification/2025-11-25)
41+
- Easy to invoke with plain `curl` commands
42+
- Many client implementations in many languages
43+
44+
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.
45+
1946
## Basic usage
2047

21-
### Define a typescript interface
48+
### Define a TypeScript interface
2249

23-
We will use this as a service definition and import it on both the client and server.
50+
This serves as the service contract. Import it on both client and server for end-to-end type safety.
2451

2552
```ts
26-
export interface MathService {
27-
add(a: number, b: number): Promise<number>;
28-
divide(a: number, b: number): Promise<number>;
29-
}
53+
export type HelloService = {
54+
hello(name: string): string;
55+
};
3056
```
3157

32-
### Define a service
33-
34-
It's not required to use a class here, anything that implements the service interface will work. A class allows you to pass the information beyond just the arguments to the method being invoked. Other examples would be the authenticated user, or set of feature flags.
58+
### Client (HTTP)
3559

3660
```ts
37-
export class MathServiceImpl implements MathService {
38-
constructor(
39-
protected req: Request,
40-
protected env: Env,
41-
) {}
42-
43-
add(a: number, b: number) {
44-
return a + b;
45-
}
61+
import { newHttpBatchRpcSession } from "@jmorrell/jsonrpc";
4662

47-
divide(a: number, b: number) {
48-
if (b === 0) throw new Error("Division by zero");
49-
return a / b;
50-
}
51-
}
63+
const client = newHttpBatchRpcSession<HelloService>("https://example.com/api");
64+
65+
const result = await client.hello("World");
66+
67+
console.log(result);
5268
```
5369

54-
### Server
70+
Full autocompletion. Every method returns a `Promise` of its return type.
71+
72+
### Server (Cloudflare Workers)
5573

5674
```ts
57-
import { handleRpc } from "@jmorrell/jsonrpc/server";
58-
import { mathService } from "./math-service";
75+
import { newWorkersRpcResponse } from "@jmorrell/jsonrpc";
76+
77+
export class HelloServiceImpl implements HelloService {
78+
hello(name: string) {
79+
return `Hello, ${name}!`;
80+
}
81+
}
5982

60-
// Cloudflare Worker
6183
export default {
62-
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
63-
return handleRpc(request, new MathServiceImpl(request, env));
84+
async fetch(request: Request, env: Env) {
85+
let url = new URL(request.url);
86+
87+
if (url.pathname === "/api") {
88+
return newWorkersRpcResponse(request, new HelloServiceImpl());
89+
}
90+
91+
return new Response("Not found", { status: 404 });
6492
},
6593
};
6694
```
6795

68-
`handleRpc` reads the request body, parses JSON, dispatches to your service, and returns a `Response`. POST only — other methods get a 405. Notifications return 204.
96+
## JSON-RPC Spec Support
6997

70-
### Client
98+
This library complies with the JSON-RPC 2.0 Spec, however it intentionally leaves out support for two features:
7199

72-
```ts
73-
import { rpcClient } from "@jmorrell/jsonrpc/client";
74-
import type { MathService } from "./service";
100+
*Notifications*
75101

76-
const client = rpcClient<MathService>({
77-
url: "https://example.com/api",
78-
getHeaders: () => ({
79-
"x-auth-token": "abc123",
80-
}),
81-
});
102+
Only requests are allowed. You can use a request with a `void` return if you do not need a response.
82103

83-
const result = await client.add(1, 2); // 3
84-
```
104+
*Named arguments*
85105

86-
Full autocompletion. Every method returns a `Promise` of its return type.
106+
In order to work nicely with TypeScript, we only accept positional arguments. Named params are
107+
rejected with `-32602 Invalid params`. Note that you can use one argment with an object as the
108+
first parameter to immitate using named arguments.
109+
110+
```ts
111+
// Instead of this
112+
example(a: string, b: string, c: string): string
113+
114+
// write your function like this:
115+
example({ a: string, b: string, c: string}): string
116+
```
87117

88-
## Client API
118+
## HTTP client API
89119

90120
### Auto-batching
91121

@@ -100,77 +130,148 @@ const [sum, difference, product] = await Promise.all([
100130
]);
101131
```
102132

103-
A single call is sent as a plain JSON-RPC request object (not wrapped in an array), so it works with servers that don't support batching.
104-
105-
Batching uses `setTimeout(0)` — all synchronous calls and microtasks (`.then()`, `queueMicrotask`) within the same turn are included.
133+
Batching uses `setTimeout(0)` -- all synchronous calls and microtasks (`.then()`, `queueMicrotask`) within the same turn are included.
106134

107135
### Custom headers
108136

109137
```ts
110-
const client = rpcClient<MathService>({
138+
const client = newHttpBatchRpcSession<MathService>({
111139
url: "https://example.com/api",
112140
getHeaders: () => ({ Authorization: `Bearer ${token}` }),
113141
});
114142
```
115143

116144
`getHeaders` can be async.
117145

118-
### Custom transport
146+
## WebSocket RPC
147+
148+
`newWebSocketRpcSession` creates a typed RPC session over a WebSocket. Pass a URL string and it connects for you, or pass an existing `WebSocket` instance.
119149

120-
Replace the default `fetch` transport entirely:
150+
### Client-to-server calls
121151

122152
```ts
123-
const client = rpcClient<MathService>({
124-
transport: async (body: string) => {
125-
// body is serialized JSON (single request or batch array)
126-
// Return serialized JSON response
127-
return await ws.sendAndReceive(body);
153+
import { newWebSocketRpcSession } from "@jmorrell/jsonrpc";
154+
import type { ServerApi } from "./server";
155+
156+
const session = newWebSocketRpcSession<ServerApi>("wss://example.com/api");
157+
158+
const result = await session.remote.add(1, 2); // 3
159+
```
160+
161+
Unlike the HTTP client, each call is an individual JSON-RPC message over the persistent connection -- no batching needed.
162+
163+
### Bidirectional RPC
164+
165+
Both sides can call methods on each other. The client provides a local service object that the server can invoke:
166+
167+
```ts
168+
import { newWebSocketRpcSession } from "@jmorrell/jsonrpc";
169+
import type { ServerApi, ClientApi } from "./server";
170+
171+
// Local methods the server can call on us.
172+
const localService: ClientApi = {
173+
onEvent(event) {
174+
console.log("Server pushed:", event);
128175
},
129-
});
176+
};
177+
178+
const session = newWebSocketRpcSession<ServerApi, ClientApi>("wss://example.com/api", localService);
179+
180+
// Call the server.
181+
const result = await session.remote.add(1, 2);
182+
```
183+
184+
On the server side (Cloudflare Workers), use `newWorkersWebSocketRpcSession` to get both the HTTP `Response` and the session handle:
185+
186+
```ts
187+
import { newWorkersWebSocketRpcSession } from "@jmorrell/jsonrpc";
188+
189+
export default {
190+
async fetch(request: Request) {
191+
const { response, session } = newWorkersWebSocketRpcSession<ClientApi, typeof service>(
192+
request,
193+
service,
194+
);
195+
196+
// Push events to the client.
197+
session.remote.onEvent({ message: "hello" });
198+
199+
// Clean up when the client disconnects.
200+
session.onClose(() => {
201+
console.log("Client disconnected");
202+
});
203+
204+
return response;
205+
},
206+
};
130207
```
131208

132-
The transport receives a JSON string and must return a JSON string.
209+
### Session lifecycle
210+
211+
```ts
212+
// Register a close handler.
213+
session.onClose(() => console.log("Connection closed"));
214+
215+
// Close the session (also closes the underlying WebSocket).
216+
session.close();
217+
218+
// Supports Symbol.dispose for `using` declarations.
219+
using session = newWebSocketRpcSession<ServerApi>("wss://example.com/api");
220+
```
221+
222+
Calling a remote method after close rejects with `"Session is closed"`. When the WebSocket drops unexpectedly, all pending calls reject and close handlers fire.
133223

134224
## Server API
135225

136-
### `handleRpc(request, service, options?)`
226+
### `newWorkersRpcResponse(request, service, options?)`
227+
228+
Convenience dispatcher for Cloudflare Workers. Routes based on the request:
137229

138-
HTTP wrapper. Takes a `Request`, returns a `Response`.
230+
- `POST` -> HTTP batch handler (with CORS `Access-Control-Allow-Origin: *`)
231+
- WebSocket upgrade -> WebSocket RPC session
232+
- Anything else -> `400 Bad Request`
233+
234+
### `newHttpBatchRpcResponse(request, service, options?)`
235+
236+
HTTP handler. Takes a `Request`, returns a `Response`.
139237

140238
- Non-POST requests get `405 Method Not Allowed` with `Allow: POST` header
141239
- Invalid JSON returns a `-32700 Parse error` response
142240
- Notifications return `204 No Content`
143241
- Everything else returns `200` with `Content-Type: application/json`
144242

145-
CORS is out of scope — handle preflight before calling `handleRpc`.
243+
CORS is out of scope -- handle preflight before calling `newHttpBatchRpcResponse`, or use `newWorkersRpcResponse` which adds a permissive CORS header.
244+
245+
### `newWorkersWebSocketRpcResponse(request, service?, options?)`
246+
247+
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.
248+
249+
### `newWorkersWebSocketRpcSession(request, service?, options?)`
250+
251+
Same as above, but returns `{ response, session }` so you can use `session.remote` to call methods on the client. See [Bidirectional RPC](#bidirectional-rpc) above.
146252

147253
### `processRpc(body, service, options?)`
148254

149-
Transport-agnostic core. Takes a parsed JSON body (not a `Request`), returns the response object(s) or `null` for notification-only requests.
255+
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.
150256

151257
```ts
152-
// WebSocket example
258+
import { processRpc } from "@jmorrell/jsonrpc";
259+
153260
ws.on("message", async (data) => {
154261
const body = JSON.parse(data);
155262
const result = await processRpc(body, myService);
156263
if (result !== null) ws.send(JSON.stringify(result));
157264
});
158265
```
159266

160-
### Options
161-
162-
```ts
163-
handleRpc(request, service, {
164-
onError: (err) => console.error(err), // Called when a handler throws
165-
});
166-
```
167-
168267
## Error handling
169268

269+
### `RpcError`
270+
170271
When a remote method returns a JSON-RPC error, the client throws an `RpcError` with `message`, `code`, and optional `data`:
171272

172273
```ts
173-
import { RpcError } from "@jmorrell/jsonrpc/client";
274+
import { RpcError } from "@jmorrell/jsonrpc";
174275

175276
try {
176277
await client.divide(1, 0);
@@ -192,23 +293,21 @@ Internal errors follow the [spec-defined error codes](https://www.jsonrpc.org/sp
192293
| -32602 | Invalid params |
193294
| -32603 | Internal error |
194295

195-
## Params style
196-
197-
This library uses by-position params only (arrays). Named params (objects) are rejected with `-32602 Invalid params`. This matches how TypeScript function calls naturally map to JSON-RPC.
198-
199-
If you want to use named params, use a single argument object:
200-
201-
```ts
202-
await client.divide({ a: 4, b: 2 });
203-
```
204-
205-
## Security
206-
207-
The server rejects calls to:
208-
209-
- Methods starting with `rpc.` (spec-reserved)
210-
- `Object.prototype` properties (`constructor`, `__proto__`, `toString`, etc.)
211-
- Properties that aren't functions on the service object
296+
### `RpcProtocolError`
297+
298+
Protocol-level issues (malformed messages, unknown response IDs, transport failures) are reported via the `onError` callback as `RpcProtocolError` instances. Each has a `code` string:
299+
300+
| Code | Meaning |
301+
| ----------------------- | --------------------------------------- |
302+
| `PARSE_ERROR` | Malformed JSON on transport |
303+
| `INVALID_MESSAGE` | Non-object message received |
304+
| `UNROUTABLE_MESSAGE` | Message is neither request nor response |
305+
| `INVALID_RESPONSE` | Response fails structural validation |
306+
| `NULL_RESPONSE_ID` | Response has null/undefined ID |
307+
| `UNKNOWN_RESPONSE_ID` | No pending call for response ID |
308+
| `NOTIFICATION_RECEIVED` | Unsupported notification received |
309+
| `HANDLER_ERROR` | Service method threw |
310+
| `SEND_FAILED` | Transport send threw |
212311

213312
## License
214313

0 commit comments

Comments
 (0)