-
Notifications
You must be signed in to change notification settings - Fork 297
feat(handler): new defineJsonRpcHandler
#1180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
440cda1
1537f10
cb95ab6
d75436d
79d2c2b
60aa75e
df71b74
5926121
b25d034
5f909de
cb144c0
1894b98
7fee14d
9c19de6
4d0d8c0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<I = unknown> { | ||
| 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<O = unknown> = | ||
| | { | ||
| 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<I = unknown, O = I> = ( | ||
| data: JsonRpcRequest<I>, | ||
| event: H3Event, | ||
| ) => O | Promise<O>; | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| 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<RequestT> { | ||
| 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<JsonRpcResponse | ReadableStream | undefined> => { | ||
| // 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]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems we might need some builtin protection against proto access There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. with b25d034 I've implemented a simple safeguard for it. Let me know if I'm on the right track, as I do realize this is a topic which I'm weak in knowledge :/ |
||
|
|
||
| // 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<RequestT>({ | ||
| 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 }; | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.