diff --git a/src/index.ts b/src/index.ts index 72e78fc3f..413893d06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -182,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 new file mode 100644 index 000000000..57b0c5784 --- /dev/null +++ b/src/utils/json-rpc.ts @@ -0,0 +1,284 @@ +import type { + EventHandler, + EventHandlerRequest, + Middleware, +} from "../types/handler.ts"; +import type { H3Event } from "../event.ts"; +import { defineHandler } from "../handler.ts"; +import { HTTPError } from "../error.ts"; + +/** + * JSON-RPC 2.0 Interfaces based on the specification. + * https://www.jsonrpc.org/specification + */ + +/** + * JSON-RPC 2.0 Request object. + */ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params?: I; + id?: string | number | null | undefined; +} + +/** + * JSON-RPC 2.0 Error object. + */ +export interface JsonRpcError { + code: number; + message: string; + data?: any; +} + +/** + * JSON-RPC 2.0 Response object. + */ +export 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. + * It receives the parameters from the request and the original H3Event. + */ +export type JsonRpcMethodHandler = ( + data: JsonRpcRequest, + event: H3Event, +) => O | Promise; + +/** + * A map of method names to their corresponding handler functions. + */ +export type JsonRpcMethodMap = Record; + +// 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. + +/** + * 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. + * + * @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< + 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({ + status: 405, + message: "Method Not Allowed", + }); + } + + let hasErrored = false; + let error = undefined; + const body = (await event.req.json().catch((error_) => { + hasErrored = true; + error = error_; + return undefined; + })) as JsonRpcRequest | JsonRpcRequest[] | undefined; + + // 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 createJsonRpcError(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 => { + // Validate the request object. + if (req.jsonrpc !== "2.0" || typeof req.method !== "string") { + return createJsonRpcError( + req.id ?? null, + INVALID_REQUEST, + "Invalid Request", + ); + } + + const { jsonrpc, 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 createJsonRpcError(id, METHOD_NOT_FOUND, "Method not found"); + } + return undefined; + } + + // Execute the method handler. + 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 }; + } + 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 createJsonRpcError(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, + ); + + 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. + 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]; + }; + + 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 new file mode 100644 index 000000000..1a5fd8e1f --- /dev/null +++ b/test/json-rpc.test.ts @@ -0,0 +1,240 @@ +import { defineJsonRpcHandler } from "../src/index.ts"; +import { describeMatrix } from "./_setup.ts"; + +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"); + }, + constructor: () => { + return "ok"; + }, + }); + + 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", + 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 + }), + }); + + 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(""); + }); + }); + + 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).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 () => { + 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).toEqual({ + error: { + code: -32_603, + 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).toEqual({ + error: { + code: -32_700, + data: {}, + message: "Parse error", + }, + id: null, + jsonrpc: "2.0", + }); + }); + + 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: "constructor", + params: { constructor: {} }, + id: 3, + }), + }); + const json = await result.json(); + expect(json).toEqual({ + id: null, + jsonrpc: "2.0", + error: { + code: -32_700, + message: "Parse error", + }, + }); + }); + }); +}); diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 51c304556..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",