diff --git a/src/jrpc/errors/utils.ts b/src/jrpc/errors/utils.ts index 2f61f8f1..abd5300d 100644 --- a/src/jrpc/errors/utils.ts +++ b/src/jrpc/errors/utils.ts @@ -9,6 +9,19 @@ declare type PropertyKey = string | number | symbol; type ErrorValueKey = keyof typeof errorValues; +export function isValidNumber(value: unknown): value is number { + try { + if (typeof value === "number" && Number.isInteger(value)) { + return true; + } + + const parsedValue = Number(value.toString()); + return Number.isInteger(parsedValue); + } catch { + return false; + } +} + /** * Returns whether the given code is valid. * A code is valid if it is an integer. diff --git a/src/jrpc/jrpc.ts b/src/jrpc/jrpc.ts index 08b95096..66171f42 100644 --- a/src/jrpc/jrpc.ts +++ b/src/jrpc/jrpc.ts @@ -1,5 +1,6 @@ import { Duplex } from "readable-stream"; +import { errorCodes } from "./errors"; import { AsyncJRPCMiddleware, ConsoleLike, IdMap, JRPCMiddleware, JRPCRequest, JRPCResponse, Json, ReturnHandlerCallback } from "./interfaces"; import { SafeEventEmitter } from "./safeEventEmitter"; import { SerializableError } from "./serializableError"; @@ -21,7 +22,7 @@ export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware { } else { if (returnHandler) { if (typeof returnHandler !== "function") { - end(new SerializableError({ code: -32603, message: "JRPCEngine: 'next' return handlers must be functions" })); + end(new SerializableError({ code: errorCodes.rpc.internal, message: "JRPCEngine: 'next' return handlers must be functions" })); } returnHandlers.push(returnHandler); } @@ -152,10 +152,10 @@ export class JRPCEngine extends SafeEventEmitter { */ private static _checkForCompletion(_req: JRPCRequest, res: JRPCResponse, isComplete: boolean): void { if (!("result" in res) && !("error" in res)) { - throw new SerializableError({ code: -32603, message: "Response has no error or result for request" }); + throw new SerializableError({ code: errorCodes.rpc.internal, message: "Response has no error or result for request" }); } if (!isComplete) { - throw new SerializableError({ code: -32603, message: "Nothing ended request" }); + throw new SerializableError({ code: errorCodes.rpc.internal, message: "Nothing ended request" }); } } @@ -268,6 +268,17 @@ export class JRPCEngine extends SafeEventEmitter { ): Promise[] | void> { // The order here is important try { + if (reqs.length === 0) { + const error = new SerializableError({ + code: errorCodes.rpc.invalidRequest, + message: "Request batch must contain plain objects. Received an empty array", + }); + const response: JRPCResponse[] = [{ id: undefined, jsonrpc: "2.0" as const, error }]; + if (cb) { + return cb(error, response); + } + return response; + } // 2. Wait for all requests to finish, or throw on some kind of fatal // error const responses = await Promise.all( @@ -312,12 +323,18 @@ export class JRPCEngine extends SafeEventEmitter { */ private async _handle(callerReq: JRPCRequest, cb: (error: unknown, response: JRPCResponse) => void): Promise { if (!callerReq || Array.isArray(callerReq) || typeof callerReq !== "object") { - const error = new SerializableError({ code: -32603, message: "request must be plain object" }); + const error = new SerializableError({ + code: errorCodes.rpc.invalidRequest, + message: `Requests must be plain objects. Received: ${typeof callerReq}`, + }); return cb(error, { id: undefined, jsonrpc: "2.0", error }); } - if (typeof callerReq.method !== "string") { - const error = new SerializableError({ code: -32603, message: "method must be string" }); + if (typeof callerReq.method !== "string" || !callerReq.method) { + const error = new SerializableError({ + code: errorCodes.rpc.invalidRequest, + message: `Must specify a string method. Received: ${typeof callerReq.method}`, + }); return cb(error, { id: callerReq.id, jsonrpc: "2.0", error }); } diff --git a/test/jrpcEngine.test.ts b/test/jrpcEngine.test.ts new file mode 100644 index 00000000..b9e6e4ca --- /dev/null +++ b/test/jrpcEngine.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { errorCodes } from "../src/jrpc/errors"; +import { JRPCEngine } from "../src/jrpc/jrpcEngine"; + +describe("JRPCEngine request validation", () => { + it("returns invalidRequest for non-object requests", async () => { + const engine = new JRPCEngine(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response = await engine.handle(123 as any); + expect(response.error?.code).toBe(errorCodes.rpc.invalidRequest); + expect(response.id).toBeUndefined(); + expect(response.jsonrpc).toBe("2.0"); + }); + + it("returns invalidRequest for non-string method", async () => { + const engine = new JRPCEngine(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response = await engine.handle({ id: 1, jsonrpc: "2.0", method: 123 as any }); + expect(response.error?.code).toBe(errorCodes.rpc.invalidRequest); + expect(response.id).toBe(1); + expect(response.jsonrpc).toBe("2.0"); + }); + + it("returns invalidRequest for empty batch requests", async () => { + const engine = new JRPCEngine(); + const responses = await engine.handle([]); + expect(responses).toHaveLength(1); + expect(responses[0].error?.code).toBe(errorCodes.rpc.invalidRequest); + expect(responses[0].id).toBeUndefined(); + expect(responses[0].jsonrpc).toBe("2.0"); + }); +});