From 440cda162dbe7492a613c1c0c50690165b5a3be1 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 12:49:46 +0200 Subject: [PATCH 01/14] feat(handler): new `jsonRpcHandler` --- src/index.ts | 2 + src/utils/json-rpc.ts | 212 ++++++++++++++++++++++++++++++++++++++ test/json-rpc.test.ts | 66 ++++++++++++ test/unit/package.test.ts | 1 + 4 files changed, 281 insertions(+) create mode 100644 src/utils/json-rpc.ts create mode 100644 test/json-rpc.test.ts diff --git a/src/index.ts b/src/index.ts index 72e78fc3f..debcfa52a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,8 @@ export { defineValidatedHandler, } from "./handler.ts"; +export { jsonRpcHandler } from "./utils/json-rpc.ts"; + export { defineMiddleware } from "./middleware.ts"; // Response diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts new file mode 100644 index 000000000..f9991ac83 --- /dev/null +++ b/src/utils/json-rpc.ts @@ -0,0 +1,212 @@ +import type { H3Event } from "../event.ts"; +import { defineHandler } from "../handler.ts"; +import type { EventHandler } from "../types/handler.ts"; +import { HTTPError } from "../error.ts"; +import { readBody } from "./body.ts"; + +/** + * JSON-RPC 2.0 Interfaces based on the specification. + * https://www.jsonrpc.org/specification + */ + +/** + * JSON-RPC 2.0 Request object. + */ +interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params?: T; + id?: string | number | null; +} + +/** + * JSON-RPC 2.0 Error object. + */ +interface JsonRpcError { + code: number; + message: string; + data?: any; +} + +/** + * JSON-RPC 2.0 Response object. + */ +interface JsonRpcResponse { + jsonrpc: "2.0"; + result?: D; + error?: JsonRpcError; + id: string | number | null; +} + +// Official JSON-RPC 2.0 error codes. +/** + * Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. + */ +const PARSE_ERROR = -32_700; +/** + * The JSON sent is not a valid Request object. + */ +const INVALID_REQUEST = -32_600; +/** + * The method does not exist / is not available. + */ +const METHOD_NOT_FOUND = -32_601; +/** + * Invalid method parameter(s). + */ +const INVALID_PARAMS = -32_602; +/** + * Internal JSON-RPC error. + */ +const INTERNAL_ERROR = -32_603; +// -32_000 to -32_099 Reserved for implementation-defined server-errors. + +/** + * A function that handles a JSON-RPC method call. + * It receives the parameters from the request and the original H3Event. + */ +export type JsonRpcMethodHandler = ( + params: T, + event: H3Event, +) => D | Promise; + +/** + * A map of method names to their corresponding handler functions. + */ +export type JsonRpcMethodMap = Record< + string, + JsonRpcMethodHandler +>; + +/** + * Creates an H3 event handler that implements the JSON-RPC 2.0 specification. + * + * @param methods A map of RPC method names to their handler functions. + * @returns An H3 EventHandler. + */ +export function jsonRpcHandler( + methods: JsonRpcMethodMap, +): EventHandler { + return defineHandler(async (event: H3Event) => { + // JSON-RPC requests must be POST. + if (event.req.method !== "POST") { + throw new HTTPError({ + status: 405, + message: "Method Not Allowed", + }); + } + + // Helper to construct and return a JSON-RPC error response. + const sendJsonRpcError = ( + id: string | number | null, + code: number, + message: string, + data?: any, + ): JsonRpcResponse => { + const error: JsonRpcError = { code, message }; + if (data) { + error.data = data; + } + return { jsonrpc: "2.0", id, error }; + }; + + let hasErrored = false; + let error = undefined; + const body = await readBody | JsonRpcRequest[]>( + event, + ).catch((error_) => { + hasErrored = true; + error = error_; + return undefined; + }); + + if (hasErrored || !body) { + return sendJsonRpcError(null, PARSE_ERROR, "Parse error", error); + } + + const isBatch = Array.isArray(body); + const requests: JsonRpcRequest[] = isBatch ? body : [body]; + + // Processes a single JSON-RPC request. + const processRequest = async ( + req: JsonRpcRequest, + ): Promise | undefined> => { + // Validate the request object. + if (req.jsonrpc !== "2.0" || typeof req.method !== "string") { + return sendJsonRpcError( + req.id ?? null, + INVALID_REQUEST, + "Invalid Request", + ); + } + + const { id, method, params } = req; + const handler = methods[method]; + + // If the method is not found, return an error. + if (!handler) { + // But only if it's not a notification. + if (id !== undefined && id !== null) { + return sendJsonRpcError(id, METHOD_NOT_FOUND, "Method not found"); + } + return undefined; + } + + // Execute the method handler. + try { + const result = await handler(params || ({} as T), event); + + // For notifications, we don't send a response. + if (id !== undefined && id !== null) { + return { jsonrpc: "2.0", id, result }; + } + return undefined; + } catch (error_: any) { + // If the handler throws an error, wrap it in a JSON-RPC error. + if (id !== undefined && id !== null) { + const h3Error = HTTPError.isError(error_) + ? error_ + : { status: 500, message: "Internal error", data: error_ }; + const statusCode = h3Error.status; + const statusMessage = h3Error.message; + + // Map HTTP status codes to JSON-RPC error codes. + const errorCode = + statusCode >= 400 && statusCode < 500 + ? INVALID_PARAMS + : INTERNAL_ERROR; + + return sendJsonRpcError(id, errorCode, statusMessage, h3Error.data); + } + return undefined; + } + }; + + const responses = await Promise.all( + requests.map((element) => processRequest(element)), + ); + + // Filter out undefined results from notifications. + const finalResponses = responses.filter( + (r): r is JsonRpcResponse => r !== undefined, + ); + + event.res.headers.set("Content-Type", "application/json"); + + // If it was a batch request but contained only notifications, send 202 Accepted. + if (isBatch && finalResponses.length === 0) { + event.res.status = 202; + return ""; + } + + // If it was a single notification, send 202 Accepted. + if (!isBatch && finalResponses.length === 0) { + event.res.status = 202; + return ""; + } + + // For a single request, return the single response object. + // For a batch request, return the array of response objects. + return isBatch ? finalResponses : finalResponses[0]; + }); +} diff --git a/test/json-rpc.test.ts b/test/json-rpc.test.ts new file mode 100644 index 000000000..cfeda14b5 --- /dev/null +++ b/test/json-rpc.test.ts @@ -0,0 +1,66 @@ +import { jsonRpcHandler } from "../src/index.ts"; +import { describeMatrix } from "./_setup.ts"; + +describeMatrix("json-rpc", (t, { it, expect }) => { + const eventHandler = jsonRpcHandler({ + test: (params, event) => { + return `Recieved ${params} on path ${event.url.pathname}`; + }, + }); + it("should handle a valid JSON-RPC request", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: "Hello World", + id: 1, + }), + }); + + expect(await result.json()).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: "Recieved Hello World on path /json-rpc", + }); + }); + + it("should respond with a 202 for a valid JSON-RPC notification", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: "Hello World", + // No ID for notification + }), + }); + + expect(await result.text()).toBe(""); + expect(result.status).toBe(202); + }); + + it("should return an error for an invalid JSON-RPC request", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "1.0", // Invalid version + method: "test", + // Missing params + id: 1, + }), + }); + + expect(await result.json()).toMatchObject({ + jsonrpc: "2.0", + id: 1, + error: { + code: -32_600, // Invalid Request + message: "Invalid Request", + }, + }); + }); +}); diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 51c304556..4ebf0f70a 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -80,6 +80,7 @@ describe("h3 package", () => { "isMethod", "isPreflightRequest", "iterable", + "jsonRpcHandler", "lazyEventHandler", "mockEvent", "noContent", From 1537f10589fba285192186aa47407d2066822676 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 14:38:41 +0200 Subject: [PATCH 02/14] fix(json-rpc): rename to `defineJsonRpcHandler` --- src/index.ts | 2 +- src/utils/json-rpc.ts | 2 +- test/json-rpc.test.ts | 4 ++-- test/unit/package.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index debcfa52a..2ac91f0f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ export { defineValidatedHandler, } from "./handler.ts"; -export { jsonRpcHandler } from "./utils/json-rpc.ts"; +export { defineJsonRpcHandler } from "./utils/json-rpc.ts"; export { defineMiddleware } from "./middleware.ts"; diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index f9991ac83..215bf0747 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -84,7 +84,7 @@ export type JsonRpcMethodMap = Record< * @param methods A map of RPC method names to their handler functions. * @returns An H3 EventHandler. */ -export function jsonRpcHandler( +export function defineJsonRpcHandler( methods: JsonRpcMethodMap, ): EventHandler { return defineHandler(async (event: H3Event) => { diff --git a/test/json-rpc.test.ts b/test/json-rpc.test.ts index cfeda14b5..e71532a90 100644 --- a/test/json-rpc.test.ts +++ b/test/json-rpc.test.ts @@ -1,8 +1,8 @@ -import { jsonRpcHandler } from "../src/index.ts"; +import { defineJsonRpcHandler } from "../src/index.ts"; import { describeMatrix } from "./_setup.ts"; describeMatrix("json-rpc", (t, { it, expect }) => { - const eventHandler = jsonRpcHandler({ + const eventHandler = defineJsonRpcHandler({ test: (params, event) => { return `Recieved ${params} on path ${event.url.pathname}`; }, diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 4ebf0f70a..8344393c8 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -80,7 +80,7 @@ describe("h3 package", () => { "isMethod", "isPreflightRequest", "iterable", - "jsonRpcHandler", + "defineJsonRpcHandler", "lazyEventHandler", "mockEvent", "noContent", From cb95ab62010a7c1f28a2e8759c4729a9986080fd Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 14:41:34 +0200 Subject: [PATCH 03/14] fix(json-rpc): access body from the event's request --- src/utils/json-rpc.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index 215bf0747..23702318b 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -2,7 +2,6 @@ import type { H3Event } from "../event.ts"; import { defineHandler } from "../handler.ts"; import type { EventHandler } from "../types/handler.ts"; import { HTTPError } from "../error.ts"; -import { readBody } from "./body.ts"; /** * JSON-RPC 2.0 Interfaces based on the specification. @@ -112,13 +111,12 @@ export function defineJsonRpcHandler( let hasErrored = false; let error = undefined; - const body = await readBody | JsonRpcRequest[]>( - event, - ).catch((error_) => { - hasErrored = true; - error = error_; - return undefined; - }); + const body = await event.req.json() + .catch((error_) => { + hasErrored = true; + error = error_; + return undefined; + }) as JsonRpcRequest | JsonRpcRequest[] | undefined; if (hasErrored || !body) { return sendJsonRpcError(null, PARSE_ERROR, "Parse error", error); From d75436dcd31cde490928360c2c8f0283909a0589 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 2 Aug 2025 12:42:12 +0000 Subject: [PATCH 04/14] chore: apply automated updates --- src/utils/json-rpc.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index 23702318b..48cccfa5b 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -111,12 +111,11 @@ export function defineJsonRpcHandler( let hasErrored = false; let error = undefined; - const body = await event.req.json() - .catch((error_) => { - hasErrored = true; - error = error_; - return undefined; - }) as JsonRpcRequest | JsonRpcRequest[] | undefined; + const body = (await event.req.json().catch((error_) => { + hasErrored = true; + error = error_; + return undefined; + })) as JsonRpcRequest | JsonRpcRequest[] | undefined; if (hasErrored || !body) { return sendJsonRpcError(null, PARSE_ERROR, "Parse error", error); From 79d2c2b6eb29aabe3e7098ed202c07ea6b0b60b4 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 15:01:03 +0200 Subject: [PATCH 05/14] test: fix package exports --- test/unit/package.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 8344393c8..91b8bd5ef 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -29,6 +29,7 @@ describe("h3 package", () => { "defaultContentType", "defineEventHandler", "defineHandler", + "defineJsonRpcHandler", "defineLazyEventHandler", "defineMiddleware", "defineNodeHandler", @@ -80,7 +81,6 @@ describe("h3 package", () => { "isMethod", "isPreflightRequest", "iterable", - "defineJsonRpcHandler", "lazyEventHandler", "mockEvent", "noContent", From 60aa75e64a8c0d978d3f0c806acdf4b26f56ff55 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 15:03:30 +0200 Subject: [PATCH 06/14] fix(json-rpc): provide the json-rpc request as first argument --- src/utils/json-rpc.ts | 20 +++++++++++++++++--- test/json-rpc.test.ts | 8 ++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index 48cccfa5b..cb969bcf6 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -15,7 +15,7 @@ interface JsonRpcRequest { jsonrpc: "2.0"; method: string; params?: T; - id?: string | number | null; + id?: string | number | null | undefined; } /** @@ -65,7 +65,11 @@ const INTERNAL_ERROR = -32_603; * It receives the parameters from the request and the original H3Event. */ export type JsonRpcMethodHandler = ( - params: T, + data: { + method: string; + params?: T; + id: string | number | null | undefined; + }, event: H3Event, ) => D | Promise; @@ -82,6 +86,16 @@ export type JsonRpcMethodMap = Record< * * @param methods A map of RPC method names to their handler functions. * @returns An H3 EventHandler. + * + * @example + * app.post("/rpc", defineJsonRpcHandler({ + * echo: ({ params }, event) => { + * return `Recieved \`${params}\` on path \`${event.url.pathname}\``; + * }, + * sum: ({ params }, event) => { + * return params.a + params.b; + * }, + * })); */ export function defineJsonRpcHandler( methods: JsonRpcMethodMap, @@ -151,7 +165,7 @@ export function defineJsonRpcHandler( // Execute the method handler. try { - const result = await handler(params || ({} as T), event); + const result = await handler({ id, method, params }, event); // For notifications, we don't send a response. if (id !== undefined && id !== null) { diff --git a/test/json-rpc.test.ts b/test/json-rpc.test.ts index e71532a90..56537ace0 100644 --- a/test/json-rpc.test.ts +++ b/test/json-rpc.test.ts @@ -3,7 +3,7 @@ import { describeMatrix } from "./_setup.ts"; describeMatrix("json-rpc", (t, { it, expect }) => { const eventHandler = defineJsonRpcHandler({ - test: (params, event) => { + echo: ({ params }, event) => { return `Recieved ${params} on path ${event.url.pathname}`; }, }); @@ -13,7 +13,7 @@ describeMatrix("json-rpc", (t, { it, expect }) => { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", - method: "test", + method: "echo", params: "Hello World", id: 1, }), @@ -32,7 +32,7 @@ describeMatrix("json-rpc", (t, { it, expect }) => { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", - method: "test", + method: "echo", params: "Hello World", // No ID for notification }), @@ -48,7 +48,7 @@ describeMatrix("json-rpc", (t, { it, expect }) => { method: "POST", body: JSON.stringify({ jsonrpc: "1.0", // Invalid version - method: "test", + method: "echo", // Missing params id: 1, }), From df71b74027c278c203f625715d87854c9a02e8e3 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 15:14:26 +0200 Subject: [PATCH 07/14] type(json-rpc): standardize type interfaces --- src/utils/json-rpc.ts | 57 +++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index cb969bcf6..3c2f2bd69 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -11,10 +11,10 @@ import { HTTPError } from "../error.ts"; /** * JSON-RPC 2.0 Request object. */ -interface JsonRpcRequest { +interface JsonRpcRequest { jsonrpc: "2.0"; method: string; - params?: T; + params?: I; id?: string | number | null | undefined; } @@ -30,13 +30,39 @@ interface JsonRpcError { /** * JSON-RPC 2.0 Response object. */ -interface JsonRpcResponse { +type JsonRpcResponse = { jsonrpc: "2.0"; - result?: D; - error?: JsonRpcError; id: string | number | null; + result: O; + error?: undefined; +} | { + jsonrpc: "2.0"; + id: string | number | null; + error: JsonRpcError; + result?: undefined; } +/** + * A function that handles a JSON-RPC method call. + * It receives the parameters from the request and the original H3Event. + */ +export type JsonRpcMethodHandler = ( + data: { + method: string; + params?: I; + id: string | number | null | undefined; + }, + event: H3Event, +) => O | Promise; + +/** + * A map of method names to their corresponding handler functions. + */ +export type JsonRpcMethodMap = Record< + string, + JsonRpcMethodHandler +>; + // Official JSON-RPC 2.0 error codes. /** * Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. @@ -60,27 +86,6 @@ const INVALID_PARAMS = -32_602; const INTERNAL_ERROR = -32_603; // -32_000 to -32_099 Reserved for implementation-defined server-errors. -/** - * A function that handles a JSON-RPC method call. - * It receives the parameters from the request and the original H3Event. - */ -export type JsonRpcMethodHandler = ( - data: { - method: string; - params?: T; - id: string | number | null | undefined; - }, - event: H3Event, -) => D | Promise; - -/** - * A map of method names to their corresponding handler functions. - */ -export type JsonRpcMethodMap = Record< - string, - JsonRpcMethodHandler ->; - /** * Creates an H3 event handler that implements the JSON-RPC 2.0 specification. * From 592612179efe58b6bde659b5c0538d553fedb412 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 15:14:54 +0200 Subject: [PATCH 08/14] lint: json-rpc --- src/utils/json-rpc.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index 3c2f2bd69..dd0e861df 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -30,17 +30,19 @@ interface JsonRpcError { /** * JSON-RPC 2.0 Response object. */ -type JsonRpcResponse = { - jsonrpc: "2.0"; - id: string | number | null; - result: O; - error?: undefined; -} | { - jsonrpc: "2.0"; - id: string | number | null; - error: JsonRpcError; - result?: undefined; -} +type JsonRpcResponse = + | { + jsonrpc: "2.0"; + id: string | number | null; + result: O; + error?: undefined; + } + | { + jsonrpc: "2.0"; + id: string | number | null; + error: JsonRpcError; + result?: undefined; + }; /** * A function that handles a JSON-RPC method call. From b25d03479a22b3ab0e9d513f021e19e624e0aee8 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 15:18:09 +0200 Subject: [PATCH 09/14] fix(json-rpc): prevent prototype access --- src/utils/json-rpc.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index dd0e861df..d6b2e650c 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -138,7 +138,36 @@ export function defineJsonRpcHandler( return undefined; })) as JsonRpcRequest | JsonRpcRequest[] | undefined; - if (hasErrored || !body) { + // Protect against prototype pollution + function hasUnsafeKeys(obj: any): boolean { + if (obj && typeof obj === "object") { + for (const key of Object.keys(obj)) { + if ( + key === "__proto__" || + key === "constructor" || + key === "prototype" + ) { + return true; + } + if ( + typeof obj[key] === "object" && + obj[key] !== null && + hasUnsafeKeys(obj[key]) + ) { + return true; + } + } + } + return false; + } + + if ( + hasErrored || + !body || + (Array.isArray(body) + ? body.some((element) => hasUnsafeKeys(element)) + : hasUnsafeKeys(body)) + ) { return sendJsonRpcError(null, PARSE_ERROR, "Parse error", error); } From 5f909deda6eaa2d45f330dc194139dffe501261d Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 15:45:55 +0200 Subject: [PATCH 10/14] type(json-rpc): don't share type input/ouput globally --- src/utils/json-rpc.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index d6b2e650c..f83786052 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -60,10 +60,7 @@ export type JsonRpcMethodHandler = ( /** * A map of method names to their corresponding handler functions. */ -export type JsonRpcMethodMap = Record< - string, - JsonRpcMethodHandler ->; +export type JsonRpcMethodMap = Record; // Official JSON-RPC 2.0 error codes. /** @@ -104,9 +101,7 @@ const INTERNAL_ERROR = -32_603; * }, * })); */ -export function defineJsonRpcHandler( - methods: JsonRpcMethodMap, -): EventHandler { +export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { return defineHandler(async (event: H3Event) => { // JSON-RPC requests must be POST. if (event.req.method !== "POST") { @@ -122,7 +117,7 @@ export function defineJsonRpcHandler( code: number, message: string, data?: any, - ): JsonRpcResponse => { + ): JsonRpcResponse => { const error: JsonRpcError = { code, message }; if (data) { error.data = data; @@ -136,7 +131,7 @@ export function defineJsonRpcHandler( hasErrored = true; error = error_; return undefined; - })) as JsonRpcRequest | JsonRpcRequest[] | undefined; + })) as JsonRpcRequest | JsonRpcRequest[] | undefined; // Protect against prototype pollution function hasUnsafeKeys(obj: any): boolean { @@ -172,12 +167,12 @@ export function defineJsonRpcHandler( } const isBatch = Array.isArray(body); - const requests: JsonRpcRequest[] = isBatch ? body : [body]; + const requests: JsonRpcRequest[] = isBatch ? body : [body]; // Processes a single JSON-RPC request. const processRequest = async ( - req: JsonRpcRequest, - ): Promise | undefined> => { + req: JsonRpcRequest, + ): Promise => { // Validate the request object. if (req.jsonrpc !== "2.0" || typeof req.method !== "string") { return sendJsonRpcError( @@ -235,7 +230,7 @@ export function defineJsonRpcHandler( // Filter out undefined results from notifications. const finalResponses = responses.filter( - (r): r is JsonRpcResponse => r !== undefined, + (r): r is JsonRpcResponse => r !== undefined, ); event.res.headers.set("Content-Type", "application/json"); From cb144c0225e98b42ee2755eb6cd75708f7e56b6e Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 15:49:28 +0200 Subject: [PATCH 11/14] test(json-rpc): coverage --- test/json-rpc.test.ts | 263 +++++++++++++++++++++++++++++++++++------- 1 file changed, 221 insertions(+), 42 deletions(-) diff --git a/test/json-rpc.test.ts b/test/json-rpc.test.ts index 56537ace0..d4f140f98 100644 --- a/test/json-rpc.test.ts +++ b/test/json-rpc.test.ts @@ -1,66 +1,245 @@ import { defineJsonRpcHandler } from "../src/index.ts"; import { describeMatrix } from "./_setup.ts"; -describeMatrix("json-rpc", (t, { it, expect }) => { +describeMatrix("json-rpc", (t, { describe, it, expect }) => { const eventHandler = defineJsonRpcHandler({ echo: ({ params }, event) => { return `Recieved ${params} on path ${event.url.pathname}`; }, + sum: ({ params }) => { + if ( + !params || + typeof params !== "object" || + !("a" in params) || + typeof params.a !== "number" || + !("b" in params) || + typeof params.b !== "number" + ) { + throw new Error("Invalid parameters for sum"); + } + return params.a + params.b; + }, + error: () => { + throw new Error("Handler error"); + }, + unsafe: () => { + return "ok"; + }, }); - it("should handle a valid JSON-RPC request", async () => { - t.app.post("/json-rpc", eventHandler); - const result = await t.fetch("/json-rpc", { - method: "POST", - body: JSON.stringify({ + + describe("success cases", () => { + it("should handle a valid JSON-RPC request", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "echo", + params: "Hello World", + id: 1, + }), + }); + + expect(await result.json()).toMatchObject({ jsonrpc: "2.0", - method: "echo", - params: "Hello World", id: 1, - }), + result: "Recieved Hello World on path /json-rpc", + }); }); - expect(await result.json()).toMatchObject({ - jsonrpc: "2.0", - id: 1, - result: "Recieved Hello World on path /json-rpc", + it("should handle batch requests with mixed results", async () => { + t.app.post("/json-rpc", eventHandler); + const batch = [ + { jsonrpc: "2.0", method: "echo", params: "A", id: 1 }, + { jsonrpc: "2.0", method: "sum", params: { a: 2, b: 3 }, id: 2 }, + { jsonrpc: "2.0", method: "notFound", id: 3 }, + { jsonrpc: "2.0", method: "echo", params: "Notify" }, // notification + ]; + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify(batch), + }); + const json = await result.json(); + expect(json).toEqual([ + { jsonrpc: "2.0", id: 1, result: "Recieved A on path /json-rpc" }, + { jsonrpc: "2.0", id: 2, result: 5 }, + { + jsonrpc: "2.0", + id: 3, + error: { code: -32_601, message: "Method not found" }, + }, + ]); }); - }); - it("should respond with a 202 for a valid JSON-RPC notification", async () => { - t.app.post("/json-rpc", eventHandler); - const result = await t.fetch("/json-rpc", { - method: "POST", - body: JSON.stringify({ - jsonrpc: "2.0", - method: "echo", - params: "Hello World", - // No ID for notification - }), + it("should respond with a 202 for a valid JSON-RPC notification", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "echo", + params: "Hello World", + // No ID for notification + }), + }); + + expect(await result.text()).toBe(""); + expect(result.status).toBe(202); }); - expect(await result.text()).toBe(""); - expect(result.status).toBe(202); + it("should return 202 for batch with only notifications", async () => { + t.app.post("/json-rpc", eventHandler); + const batch = [ + { jsonrpc: "2.0", method: "echo", params: "Notify1" }, + { jsonrpc: "2.0", method: "echo", params: "Notify2" }, + ]; + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify(batch), + }); + expect(result.status).toBe(202); + expect(await result.text()).toBe(""); + }); }); - it("should return an error for an invalid JSON-RPC request", async () => { - t.app.post("/json-rpc", eventHandler); - const result = await t.fetch("/json-rpc", { - method: "POST", - body: JSON.stringify({ - jsonrpc: "1.0", // Invalid version - method: "echo", - // Missing params + describe("error handling", () => { + it("should return an error for an invalid JSON-RPC request", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "1.0", // Invalid version + method: "echo", + // Missing params + id: 1, + }), + }); + + expect(await result.json()).toMatchObject({ + jsonrpc: "2.0", id: 1, - }), + error: { + code: -32_600, + message: "Invalid Request", + }, + }); + }); + + it("should return error for invalid method type", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: 123, + id: 5, + }), + }); + const json = await result.json(); + expect(json).toMatchInlineSnapshot(` + { + "error": { + "code": -32600, + "message": "Invalid Request", + }, + "id": 5, + "jsonrpc": "2.0", + } + `); + }); + + it("should handle handler errors and map to JSON-RPC error", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "error", + id: 4, + }), + }); + const json = await result.json(); + expect(json).toMatchInlineSnapshot(` + { + "error": { + "code": -32603, + "data": {}, + "message": "Internal error", + }, + "id": 4, + "jsonrpc": "2.0", + } + `); + }); + + it("should return error for method not found", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notFound", + id: 2, + }), + }); + expect(await result.json()).toMatchObject({ + jsonrpc: "2.0", + id: 2, + error: { + code: -32_601, + message: "Method not found", + }, + }); + }); + + it("should reject non-POST requests", async () => { + t.app.all("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "GET", + }); + expect(result.status).toBe(405); + expect(await result.text()).toContain("Method Not Allowed"); + }); + + it("should return parse error for invalid JSON", async () => { + t.app.post("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: "{ invalid json }", + }); + const json = await result.json(); + expect(json).toMatchInlineSnapshot(` + { + "error": { + "code": -32700, + "data": {}, + "message": "Parse error", + }, + "id": null, + "jsonrpc": "2.0", + } + `); }); - expect(await result.json()).toMatchObject({ - jsonrpc: "2.0", - id: 1, - error: { - code: -32_600, // Invalid Request - message: "Invalid Request", - }, + it("should return parse error for unsafe keys", async () => { + t.app.all("/json-rpc", eventHandler); + const result = await t.fetch("/json-rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "unsafe", + params: { __proto__: {} }, + id: 3, + }), + }); + const json = await result.json(); + expect(json).toMatchInlineSnapshot(` + { + "id": 3, + "jsonrpc": "2.0", + "result": "ok", + } + `); }); }); }); From 1894b9833e5844f2226c895275f8777379feea90 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 2 Aug 2025 17:28:04 +0200 Subject: [PATCH 12/14] fix(json-rpc): type exports --- src/index.ts | 12 ++++++++++-- src/utils/json-rpc.ts | 16 ++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2ac91f0f5..413893d06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,8 +42,6 @@ export { defineValidatedHandler, } from "./handler.ts"; -export { defineJsonRpcHandler } from "./utils/json-rpc.ts"; - export { defineMiddleware } from "./middleware.ts"; // Response @@ -184,6 +182,16 @@ export { // WebSocket export { defineWebSocketHandler, defineWebSocket } from "./utils/ws.ts"; +// JSON-RPC +export type { + JsonRpcRequest, + JsonRpcResponse, + JsonRpcError, + JsonRpcMethodHandler, + JsonRpcMethodMap, +} from "./utils/json-rpc.ts"; +export { defineJsonRpcHandler } from "./utils/json-rpc.ts"; + // ---- Deprecated ---- export * from "./_deprecated.ts"; diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index f83786052..bc72461dd 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -11,7 +11,7 @@ import { HTTPError } from "../error.ts"; /** * JSON-RPC 2.0 Request object. */ -interface JsonRpcRequest { +export interface JsonRpcRequest { jsonrpc: "2.0"; method: string; params?: I; @@ -21,7 +21,7 @@ interface JsonRpcRequest { /** * JSON-RPC 2.0 Error object. */ -interface JsonRpcError { +export interface JsonRpcError { code: number; message: string; data?: any; @@ -30,7 +30,7 @@ interface JsonRpcError { /** * JSON-RPC 2.0 Response object. */ -type JsonRpcResponse = +export type JsonRpcResponse = | { jsonrpc: "2.0"; id: string | number | null; @@ -49,11 +49,7 @@ type JsonRpcResponse = * It receives the parameters from the request and the original H3Event. */ export type JsonRpcMethodHandler = ( - data: { - method: string; - params?: I; - id: string | number | null | undefined; - }, + data: JsonRpcRequest, event: H3Event, ) => O | Promise; @@ -182,7 +178,7 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { ); } - const { id, method, params } = req; + const { jsonrpc, id, method, params } = req; const handler = methods[method]; // If the method is not found, return an error. @@ -196,7 +192,7 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { // Execute the method handler. try { - const result = await handler({ id, method, params }, event); + const result = await handler({ jsonrpc, id, method, params }, event); // For notifications, we don't send a response. if (id !== undefined && id !== null) { From 7fee14d46ab69deba69351529510361f80a9e496 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 3 Aug 2025 04:52:53 +0200 Subject: [PATCH 13/14] feat(json-rpc): support middlewares --- src/utils/json-rpc.ts | 56 +++++++++++++++++++----------- test/json-rpc.test.ts | 81 ++++++++++++++++++++----------------------- 2 files changed, 73 insertions(+), 64 deletions(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index bc72461dd..5d7acbd09 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -1,6 +1,10 @@ +import type { + EventHandler, + EventHandlerRequest, + Middleware, +} from "../types/handler.ts"; import type { H3Event } from "../event.ts"; import { defineHandler } from "../handler.ts"; -import type { EventHandler } from "../types/handler.ts"; import { HTTPError } from "../error.ts"; /** @@ -97,8 +101,13 @@ const INTERNAL_ERROR = -32_603; * }, * })); */ -export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { - return defineHandler(async (event: H3Event) => { +export function defineJsonRpcHandler< + RequestT extends EventHandlerRequest = EventHandlerRequest, +>( + methods: JsonRpcMethodMap, + middleware?: Middleware[], +): EventHandler { + const handler = async (event: H3Event) => { // JSON-RPC requests must be POST. if (event.req.method !== "POST") { throw new HTTPError({ @@ -107,20 +116,6 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { }); } - // Helper to construct and return a JSON-RPC error response. - const sendJsonRpcError = ( - id: string | number | null, - code: number, - message: string, - data?: any, - ): JsonRpcResponse => { - const error: JsonRpcError = { code, message }; - if (data) { - error.data = data; - } - return { jsonrpc: "2.0", id, error }; - }; - let hasErrored = false; let error = undefined; const body = (await event.req.json().catch((error_) => { @@ -159,7 +154,7 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { ? body.some((element) => hasUnsafeKeys(element)) : hasUnsafeKeys(body)) ) { - return sendJsonRpcError(null, PARSE_ERROR, "Parse error", error); + return createJsonRpcError(null, PARSE_ERROR, "Parse error", error); } const isBatch = Array.isArray(body); @@ -171,7 +166,7 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { ): Promise => { // Validate the request object. if (req.jsonrpc !== "2.0" || typeof req.method !== "string") { - return sendJsonRpcError( + return createJsonRpcError( req.id ?? null, INVALID_REQUEST, "Invalid Request", @@ -185,7 +180,7 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { if (!handler) { // But only if it's not a notification. if (id !== undefined && id !== null) { - return sendJsonRpcError(id, METHOD_NOT_FOUND, "Method not found"); + return createJsonRpcError(id, METHOD_NOT_FOUND, "Method not found"); } return undefined; } @@ -214,7 +209,7 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { ? INVALID_PARAMS : INTERNAL_ERROR; - return sendJsonRpcError(id, errorCode, statusMessage, h3Error.data); + return createJsonRpcError(id, errorCode, statusMessage, h3Error.data); } return undefined; } @@ -246,5 +241,24 @@ export function defineJsonRpcHandler(methods: JsonRpcMethodMap): EventHandler { // For a single request, return the single response object. // For a batch request, return the array of response objects. return isBatch ? finalResponses : finalResponses[0]; + }; + + return defineHandler({ + handler, + middleware, }); } + +// Helper to construct and return a JSON-RPC error response. +const createJsonRpcError = ( + id: string | number | null, + code: number, + message: string, + data?: any, +): JsonRpcResponse => { + const error: JsonRpcError = { code, message }; + if (data) { + error.data = data; + } + return { jsonrpc: "2.0", id, error }; +}; diff --git a/test/json-rpc.test.ts b/test/json-rpc.test.ts index d4f140f98..1a5fd8e1f 100644 --- a/test/json-rpc.test.ts +++ b/test/json-rpc.test.ts @@ -22,7 +22,7 @@ describeMatrix("json-rpc", (t, { describe, it, expect }) => { error: () => { throw new Error("Handler error"); }, - unsafe: () => { + constructor: () => { return "ok"; }, }); @@ -136,16 +136,14 @@ describeMatrix("json-rpc", (t, { describe, it, expect }) => { }), }); const json = await result.json(); - expect(json).toMatchInlineSnapshot(` - { - "error": { - "code": -32600, - "message": "Invalid Request", - }, - "id": 5, - "jsonrpc": "2.0", - } - `); + expect(json).toEqual({ + error: { + code: -32_600, + message: "Invalid Request", + }, + id: 5, + jsonrpc: "2.0", + }); }); it("should handle handler errors and map to JSON-RPC error", async () => { @@ -159,17 +157,15 @@ describeMatrix("json-rpc", (t, { describe, it, expect }) => { }), }); const json = await result.json(); - expect(json).toMatchInlineSnapshot(` - { - "error": { - "code": -32603, - "data": {}, - "message": "Internal error", - }, - "id": 4, - "jsonrpc": "2.0", - } - `); + expect(json).toEqual({ + error: { + code: -32_603, + data: {}, + message: "Internal error", + }, + id: 4, + jsonrpc: "2.0", + }); }); it("should return error for method not found", async () => { @@ -208,38 +204,37 @@ describeMatrix("json-rpc", (t, { describe, it, expect }) => { body: "{ invalid json }", }); const json = await result.json(); - expect(json).toMatchInlineSnapshot(` - { - "error": { - "code": -32700, - "data": {}, - "message": "Parse error", - }, - "id": null, - "jsonrpc": "2.0", - } - `); + expect(json).toEqual({ + error: { + code: -32_700, + data: {}, + message: "Parse error", + }, + id: null, + jsonrpc: "2.0", + }); }); - it("should return parse error for unsafe keys", async () => { + it("should return parse error for constructor keys", async () => { t.app.all("/json-rpc", eventHandler); const result = await t.fetch("/json-rpc", { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", - method: "unsafe", - params: { __proto__: {} }, + method: "constructor", + params: { constructor: {} }, id: 3, }), }); const json = await result.json(); - expect(json).toMatchInlineSnapshot(` - { - "id": 3, - "jsonrpc": "2.0", - "result": "ok", - } - `); + expect(json).toEqual({ + id: null, + jsonrpc: "2.0", + error: { + code: -32_700, + message: "Parse error", + }, + }); }); }); }); From 9c19de61618e7ad9a2876dd3535fb245a2c01115 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 3 Aug 2025 14:23:23 +0200 Subject: [PATCH 14/14] feat(json-rpc): support streamable responses --- src/utils/json-rpc.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index 5d7acbd09..57b0c5784 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -163,7 +163,7 @@ export function defineJsonRpcHandler< // Processes a single JSON-RPC request. const processRequest = async ( req: JsonRpcRequest, - ): Promise => { + ): Promise => { // Validate the request object. if (req.jsonrpc !== "2.0" || typeof req.method !== "string") { return createJsonRpcError( @@ -189,6 +189,17 @@ export function defineJsonRpcHandler< try { const result = await handler({ jsonrpc, id, method, params }, event); + if (isBatch && result instanceof ReadableStream) { + throw new HTTPError({ + status: 400, + message: "Streaming responses are not supported in batch requests.", + }); + } + + if (result instanceof ReadableStream) { + return result; + } + // For notifications, we don't send a response. if (id !== undefined && id !== null) { return { jsonrpc: "2.0", id, result }; @@ -224,6 +235,15 @@ export function defineJsonRpcHandler< (r): r is JsonRpcResponse => r !== undefined, ); + if ( + !isBatch && + finalResponses.length === 1 && + finalResponses[0] instanceof ReadableStream + ) { + event.res.headers.set("Content-Type", "text/event-stream"); + return finalResponses[0]; + } + event.res.headers.set("Content-Type", "application/json"); // If it was a batch request but contained only notifications, send 202 Accepted.