Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,45 @@ Internal errors follow the [spec-defined error codes](https://www.jsonrpc.org/sp
| -32602 | Invalid params |
| -32603 | Internal error |

### Selecting public handler errors

By default, thrown values are serialized using their `message`, numeric `code` (or
`-32000`), and optional `data`. To control which details reach callers, provide a
synchronous `serializeError(error, method)` callback:

```ts
import { newWorkersRpcResponse, RpcError } from "@jmorrell/jsonrpc";

return newWorkersRpcResponse(request, service, {
serializeError(error, method) {
if (error instanceof RpcError) {
return { code: error.code, message: error.message, data: error.data };
}
return {
code: -32603,
message: "The operation could not be completed. Check its result before retrying.",
};
},
});
```

This option is available on `processRpc`, HTTP handlers, `RpcSession`, and both
WebSocket peers. It applies only to service-method failures; protocol errors keep
their standard responses. `onError` still receives `HANDLER_ERROR` with the original
thrown value as its `cause` before serialization. Callbacks must not throw; returning
an error does not undo the handler's effects or make retrying safe. The application
is responsible for selecting safe messages and data, including those on `RpcError`.

### HTTP response failures

For non-2xx HTTP responses, the fetch client throws `RpcError` with the HTTP status
as its `code`. An `application/json` response containing a nonempty string `error`
field supplies the message (for example, `{ "error": "Request body is too large" }`).
Otherwise the message is `Request failed (HTTP <status>).`, even when the response
has an empty status text. HTML, malformed JSON, and other body shapes are not used
as messages. Every call in a rejected HTTP batch receives the same error. These
HTTP failures do not establish whether the server executed the request.

### `RpcProtocolError`

Protocol-level issues (malformed messages, unknown response IDs, transport failures) are reported via the `onError` callback as `RpcProtocolError` instances. Each has a `code` string:
Expand Down
128 changes: 128 additions & 0 deletions src/__tests__/http-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,131 @@ describe("newHttpBatchRpcResponse", () => {
expect(json).toEqual({ jsonrpc: "2.0", id: 1, result: 7 });
});
});

describe("HTTP error explanations", () => {
it("preserves the server's upload limit explanation for every call in a rejected batch", async () => {
let fetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
Response.json(
{ error: "Request body exceeds the upload limit", maxBytes: 2097152 },
{ status: 413 },
),
);
using client = newHttpBatchRpcSession<{ add(a: number, b: number): number }>(
"https://example.com/jsonrpc",
);
try {
let results = await Promise.allSettled([client.add(1, 2), client.add(3, 4)]);
expect(results).toEqual([
{
status: "rejected",
reason: expect.objectContaining({
code: 413,
message: "Request body exceeds the upload limit",
}),
},
{
status: "rejected",
reason: expect.objectContaining({
code: 413,
message: "Request body exceeds the upload limit",
}),
},
]);
} finally {
fetch.mockRestore();
}
});

it.each([
["text/html", '{"error":"private proxy diagnostic"}'],
["application/json", "not JSON"],
["application/json", '{"error":{"stack":"private"}}'],
["application/json", '{"error":" "}'],
])("uses a nonempty status fallback for unusable %s responses", async (contentType, body) => {
let fetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(body, { status: 503, headers: { "Content-Type": contentType } }),
);
using client = newHttpBatchRpcSession<{ add(a: number, b: number): number }>(
"https://example.com/jsonrpc",
);
try {
await expect(client.add(1, 2)).rejects.toMatchObject({
code: 503,
message: "Request failed (HTTP 503).",
});
} finally {
fetch.mockRestore();
}
});
});

describe("public handler errors", () => {
it("serializes batch failures without losing diagnostics or changing protocol errors", async () => {
const unexpected = Object.assign(new Error("private database detail"), { data: "private" });
const onError = vi.fn();
const service = {
expected() {
throw new RpcError("Choose another name", -32009, { field: "name" });
},
unexpected() {
throw unexpected;
},
nonError() {
throw "private thrown string";
},
success() {
return 42;
},
};
const response = await newHttpBatchRpcResponse(
new Request("https://example.com/rpc", {
method: "POST",
body: JSON.stringify(
["expected", "unexpected", "nonError", "success", "missing"].map((method, id) => ({
jsonrpc: "2.0",
id,
method,
})),
),
}),
service,
{
onError,
serializeError(error, method) {
if (error instanceof RpcError)
return { code: error.code, message: error.message, data: error.data };
return { code: -32603, message: `Could not complete ${method}. Check its result.` };
},
},
);
expect(await response.json()).toEqual([
{
jsonrpc: "2.0",
id: 0,
error: { code: -32009, message: "Choose another name", data: { field: "name" } },
},
{
jsonrpc: "2.0",
id: 1,
error: { code: -32603, message: "Could not complete unexpected. Check its result." },
},
{
jsonrpc: "2.0",
id: 2,
error: { code: -32603, message: "Could not complete nonError. Check its result." },
},
{ jsonrpc: "2.0", id: 3, result: 42 },
{ jsonrpc: "2.0", id: 4, error: { code: -32601, message: "Method not found" } },
]);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: "HANDLER_ERROR", cause: unexpected }),
);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: "HANDLER_ERROR", cause: "private thrown string" }),
);
});
});
51 changes: 51 additions & 0 deletions src/__tests__/workers.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,54 @@ describe("Workers runtime integration tests", () => {
});
});
});

describe("WebSocket public handler errors", () => {
it("applies each peer's serializer to incoming calls without requiring onError", async () => {
const serverService = {
fail() {
throw new Error("private server detail");
},
};
const clientService = {
fail() {
throw new Error("private client detail");
},
};
const { response, session: server } = newWorkersWebSocketRpcSession<
typeof clientService,
typeof serverService
>(new Request("http://localhost/rpc", { headers: { Upgrade: "websocket" } }), serverService, {
serializeError: (_error, method) => ({
code: -32603,
message: `Server could not complete ${method}`,
}),
});
const socket = response.webSocket!;
socket.accept();
using client = newWebSocketRpcSession<typeof serverService, typeof clientService>(
socket,
clientService,
{
serializeError: (_error, method) => ({
code: -32001,
message: `Client could not complete ${method}`,
data: { retry: false },
}),
},
);
try {
await expect(client.remote.fail()).rejects.toMatchObject({
code: -32603,
message: "Server could not complete fail",
data: undefined,
});
await expect(server.remote.fail()).rejects.toMatchObject({
code: -32001,
message: "Client could not complete fail",
data: { retry: false },
});
} finally {
server.close();
}
});
});
6 changes: 5 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export class RpcProtocolError extends Error {

export type RpcHandlerOptions = {
onError?: (err: RpcProtocolError) => void;
/** Select the public representation of a handler failure. Must return synchronously. */
serializeError?: (error: unknown, method: string) => JsonRpcErrorResponse["error"];
};

/**
Expand Down Expand Up @@ -234,7 +236,9 @@ export async function processSingleRequest(
cause: err,
}),
);
const { code, message, data } = extractError(err);
const { code, message, data } = options?.serializeError
? options.serializeError(err, method)
: extractError(err);
return errorResponse(id, code, message, data);
}
}
Expand Down
17 changes: 16 additions & 1 deletion src/http-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,22 @@ function fetchTransport(options: RpcFetchOptions): RpcRequestFn {
body,
});
if (!res.ok) {
throw new RpcError(res.statusText, res.status);
let message = `Request failed (HTTP ${res.status}).`;
if (
res.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase() === "application/json"
) {
const body: unknown = await res.json().catch(() => null);
if (
typeof body === "object" &&
body !== null &&
"error" in body &&
typeof body.error === "string" &&
body.error.trim()
) {
message = body.error;
}
}
throw new RpcError(message, res.status);
}
return res.text();
};
Expand Down
5 changes: 2 additions & 3 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type RpcTransport = {
close(): void;
};

export type RpcSessionOptions = {
export type RpcSessionOptions = RpcHandlerOptions & {
// In bidirectional RPC, both sides send requests with an `id` field that the
// peer echoes back in its response. JSON-RPC IDs are shared on the wire — if
// both sides independently pick the same ID, responses become ambiguous and
Expand All @@ -31,7 +31,6 @@ export type RpcSessionOptions = {
// automatically; only callers using RpcSession with a raw transport need to
// provide it.
role?: "initiator" | "acceptor"; // default: 'initiator'
onError?: (err: RpcProtocolError) => void;
};

type PendingCall = {
Expand All @@ -58,7 +57,7 @@ class RpcSessionImpl<TRemote extends object, TLocal extends object> {
this.onError = options?.onError;
this.nextId = role === "initiator" ? 1 : -1;
this.idStep = role === "initiator" ? 1 : -1;
this.handlerOptions = this.onError ? { onError: this.onError } : undefined;
this.handlerOptions = options;

transport.onMessage((message: string) => {
let parsed: unknown;
Expand Down
10 changes: 3 additions & 7 deletions src/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,14 @@ export function newWorkersWebSocketRpcSession<

const transport = createWebSocketTransport(server);
const session = new RpcSession<TRemote, TLocal>(transport, service ?? ({} as TLocal), {
...options,
role: "acceptor",
onError: options?.onError,
});

const response = new Response(null, { status: 101, webSocket: client });
return { response, session };
}

type WebSocketRpcSessionOptions = {
onError?: RpcHandlerOptions["onError"];
};

/**
* Create a WebSocket RPC client session.
* Accepts a WebSocket URL (string) or an existing WebSocket instance.
Expand All @@ -150,13 +146,13 @@ export function newWebSocketRpcSession<
>(
ws: WebSocket | string,
localFunctions?: TLocal,
options?: WebSocketRpcSessionOptions,
options?: RpcHandlerOptions,
): RpcSession<TRemote, TLocal> {
const socket = typeof ws === "string" ? new WebSocket(ws) : ws;
const transport = createWebSocketTransport(socket);

return new RpcSession<TRemote, TLocal>(transport, localFunctions ?? ({} as TLocal), {
...options,
role: "initiator",
onError: options?.onError,
});
}
Loading