From e1940904ad099406ddfac85cf5c9c511304ffe21 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 5 Feb 2026 12:11:37 +0800 Subject: [PATCH 1/9] chore: replace hardcoded error codes with rpc error constants --- package-lock.json | 20 -------------------- src/jrpc/jrpc.ts | 5 +++-- src/jrpc/jrpcEngine.ts | 14 +++++++------- 3 files changed, 10 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba9fa8cb..2ce803a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,7 +120,6 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1657,7 +1656,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -3391,7 +3389,6 @@ "integrity": "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -4686,7 +4683,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4876,7 +4872,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -5736,7 +5731,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5783,7 +5777,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6582,7 +6575,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -8104,7 +8096,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8165,7 +8156,6 @@ "integrity": "sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8263,7 +8253,6 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -11621,7 +11610,6 @@ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -12872,7 +12860,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13565,7 +13552,6 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13753,7 +13739,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14904,7 +14889,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15047,7 +15031,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15349,7 +15332,6 @@ "integrity": "sha512-qTl3VF7BvOupTR85Zc561sPEgxyUSNSvTQ9fit7DEMP7yPgvvIGm5Zfa1dOM+kOwWGNviK9uFM9ra77+OjK7lQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -15442,7 +15424,6 @@ "integrity": "sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.7", "@vitest/mocker": "4.0.7", @@ -15559,7 +15540,6 @@ "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", 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" }); } } @@ -312,12 +312,12 @@ 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: "request must be plain object" }); 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" }); + const error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: "method must be string" }); return cb(error, { id: callerReq.id, jsonrpc: "2.0", error }); } From 77f69c92404baaf098f5c51db8f016c1b21f4a5d Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 5 Feb 2026 12:26:57 +0800 Subject: [PATCH 2/9] chore: revert package-lock.json --- package-lock.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/package-lock.json b/package-lock.json index 2ce803a9..ba9fa8cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1656,6 +1657,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3389,6 +3391,7 @@ "integrity": "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -4683,6 +4686,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4872,6 +4876,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -5731,6 +5736,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5777,6 +5783,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6575,6 +6582,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -8096,6 +8104,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8156,6 +8165,7 @@ "integrity": "sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8253,6 +8263,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -11610,6 +11621,7 @@ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -12860,6 +12872,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13552,6 +13565,7 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13739,6 +13753,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14889,6 +14904,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15031,6 +15047,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15332,6 +15349,7 @@ "integrity": "sha512-qTl3VF7BvOupTR85Zc561sPEgxyUSNSvTQ9fit7DEMP7yPgvvIGm5Zfa1dOM+kOwWGNviK9uFM9ra77+OjK7lQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -15424,6 +15442,7 @@ "integrity": "sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.7", "@vitest/mocker": "4.0.7", @@ -15540,6 +15559,7 @@ "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", From ee219ea158ac14c2f353a28c93fb60483ae99ec9 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 5 Feb 2026 17:31:06 +0800 Subject: [PATCH 3/9] chore: updated tests --- src/jrpc/errors/utils.ts | 13 +++++++++++++ src/jrpc/jrpcEngine.ts | 27 ++++++++++++++++++++++----- test/jrpcEngine.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 test/jrpcEngine.test.ts 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/jrpcEngine.ts b/src/jrpc/jrpcEngine.ts index 0a995203..2127a11c 100644 --- a/src/jrpc/jrpcEngine.ts +++ b/src/jrpc/jrpcEngine.ts @@ -2,7 +2,7 @@ import { Duplex } from "readable-stream"; import { log } from "../utils/logger"; import { errorCodes, JsonRpcError } from "./errors"; -import { getMessageFromCode, serializeJrpcError } from "./errors/utils"; +import { getMessageFromCode, isValidNumber, serializeJrpcError } from "./errors/utils"; import { JRPCEngineEndCallback, JRPCEngineNextCallback, @@ -29,7 +29,7 @@ function constructFallbackError(error: Error): JRPCError { stack = "Stack trace is not available.", data = "", } = error as { message?: string; code?: number; stack?: string; data?: string }; - const codeNumber = parseInt(code?.toString() || errorCodes.rpc.internal.toString()); + const codeNumber = isValidNumber(code) ? parseInt(code.toString()) : errorCodes.rpc.internal; return { message: message || error?.toString() || getMessageFromCode(codeNumber), code: codeNumber, @@ -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: errorCodes.rpc.invalidRequest, 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: errorCodes.rpc.invalidRequest, 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"); + }); +}); From 891890f179aa58fbfa755549c260ab7d5cb109a2 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 9 Feb 2026 15:26:18 +0800 Subject: [PATCH 4/9] feat: added 'cleanup' to the stream after close --- src/jrpc/jrpcEngine.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/jrpc/jrpcEngine.ts b/src/jrpc/jrpcEngine.ts index 2127a11c..184bae6c 100644 --- a/src/jrpc/jrpcEngine.ts +++ b/src/jrpc/jrpcEngine.ts @@ -429,9 +429,17 @@ export function createEngineStream(opts: EngineStreamOptions): Duplex { // forward notifications if (engine.on) { - engine.on("notification", (message) => { + const onNotification = (message: unknown) => { stream.push(message); - }); + }; + + // cleanup listener on stream close + const cleanup = () => { + engine.removeListener("notification", onNotification); + }; + + engine.on("notification", onNotification); + stream.once("close", cleanup); } return stream; } From 0b601ce9c2f96f59b76dc4d43632de2a58531dbd Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 9 Feb 2026 18:55:00 +0800 Subject: [PATCH 5/9] chore: added more tests for JrpcEngine and Middlewares --- src/jrpc/jrpcEngine.ts | 11 +- test/jrpcEngine.test.ts | 235 ++++++++++++++++++++++++++++++++++- test/jrpcMiddleware.test.ts | 239 ++++++++++++++++++++++++++++++++++++ 3 files changed, 478 insertions(+), 7 deletions(-) create mode 100644 test/jrpcMiddleware.test.ts diff --git a/src/jrpc/jrpcEngine.ts b/src/jrpc/jrpcEngine.ts index 184bae6c..d6a03c6b 100644 --- a/src/jrpc/jrpcEngine.ts +++ b/src/jrpc/jrpcEngine.ts @@ -281,10 +281,12 @@ export class JRPCEngine extends SafeEventEmitter { } // 2. Wait for all requests to finish, or throw on some kind of fatal // error - const responses = await Promise.all( - // 1. Begin executing each request in the order received - reqs.map(this._promiseHandle.bind(this)) - ); + const responses = ( + await Promise.all( + // 1. Begin executing each request in the order received + reqs.map(this._promiseHandle.bind(this)) + ) + ).filter((response): response is JRPCResponse => response !== undefined); // 3. Return batch response if (cb) { @@ -357,7 +359,6 @@ export class JRPCEngine extends SafeEventEmitter { // Ensure no result is present on an errored response delete res.result; if (!res.error) { - if (typeof error === "object" && Object.keys(error).includes("stack") === false) error.stack = "Stack trace is not available."; log.error(error); res.error = serializeJrpcError(error, { shouldIncludeStack: true, diff --git a/test/jrpcEngine.test.ts b/test/jrpcEngine.test.ts index b9e6e4ca..c6fb760f 100644 --- a/test/jrpcEngine.test.ts +++ b/test/jrpcEngine.test.ts @@ -1,7 +1,153 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { SerializableError } from "../src"; import { errorCodes } from "../src/jrpc/errors"; -import { JRPCEngine } from "../src/jrpc/jrpcEngine"; +import { JRPCEngineNextCallback, JRPCMiddleware, JRPCRequest, JRPCResponse } from "../src/jrpc/interfaces"; +import { createEngineStream, JRPCEngine, mergeMiddleware, providerAsMiddleware, providerFromEngine } from "../src/jrpc/jrpcEngine"; + +const MOCK_REQUEST: JRPCRequest = { + method: "mock", + params: {}, + id: "1", + jsonrpc: "2.0", +}; + +describe("JRPCEngine", () => { + it("should add middleware to the engine", async () => { + const engine = new JRPCEngine(); + const mockFn = vi.fn(); + + const middleware = (_req: JRPCRequest, _res: JRPCResponse, _next: () => void, end: () => void) => { + mockFn(); + end(); + }; + engine.push(middleware); + + await engine.handle(MOCK_REQUEST); + expect(mockFn).toHaveBeenCalled(); + }); + + it("should call the callback with an error for invalid requests", () => { + const engine = new JRPCEngine(); + const mockCallback = vi.fn(); + const error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: "Requests must be plain objects. Received: object" }); + + engine.handle(null, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(error, { + id: undefined, + jsonrpc: "2.0", + error, + }); + }); + + it("should throw an error if provided a non-function callback", () => { + const engine = new JRPCEngine(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- testing invalid callback + expect(() => engine.handle(MOCK_REQUEST, "not a function" as any)).toThrow('"callback" must be a function if provided.'); + }); + + it("should handle a batch of requests", () => { + const engine = new JRPCEngine(); + const mockCallback = vi.fn(); + const mockFn = vi.fn(); + const requests = [MOCK_REQUEST, MOCK_REQUEST]; + + const middleware = (_req: JRPCRequest, res: JRPCResponse, _next: () => void, end: () => void) => { + res.result = "test"; + mockFn(); + end(); + }; + engine.push(middleware); + + engine.handle(requests, mockCallback); + expect(mockFn).toHaveBeenCalledTimes(requests.length); + }); + + it("should handle error thrown from batch requests", async () => { + const engine = new JRPCEngine(); + const middleware = (_req: JRPCRequest, _res: JRPCResponse, _next: () => void, _end: () => void) => { + throw new Error("test error"); + }; + engine.push(middleware); + const response = await engine.handle([MOCK_REQUEST, MOCK_REQUEST]); + expect(response[0].error).toBeDefined(); + expect(response[0].error?.message).toBe("test error"); + expect(response[1].error).toBeDefined(); + expect(response[1].error?.message).toBe("test error"); + }); + + it("should execute the return handlers if provided in `next` function", async () => { + const engine = new JRPCEngine(); + const mockReturnHandler = vi.fn(); + + const middleware = (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + res.result = "test"; + next((done) => { + mockReturnHandler(); + done(); + }); + }; + engine.push(middleware); + + const middleware2 = (_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + res.result = "test2"; + end(); + }; + engine.push(middleware2); + + await engine.handle(MOCK_REQUEST); + + expect(mockReturnHandler).toHaveBeenCalled(); + }); +}); + +describe("JRPCEngine Middlewares", () => { + it("should merge middleware stacks", async () => { + const engine = new JRPCEngine(); + + const middleware = (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + res.result = ["merged-md-1"]; + next(); + }; + + const middleware2 = ((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + res.result = [...res.result, "merged-md-2"]; + end(); + }) as JRPCMiddleware; + + const mergedMiddleware = mergeMiddleware([middleware, middleware2]); + engine.push(mergedMiddleware); + const response = await engine.handle(MOCK_REQUEST); + expect(response.result).toEqual(["merged-md-1", "merged-md-2"]); + }); + + it("should be able to execute merged middleware stack and normal middleware stack together", async () => { + const engine = new JRPCEngine(); + + const mergedMiddleware = mergeMiddleware([ + (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + const prevResult = Array.isArray(res.result) ? res.result : []; + res.result = [...prevResult, "merged-md-1"]; + next(); + }, + (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + res.result = [...(res.result as string[]), "merged-md-2"]; + next(); + }, + ]); + engine.push(mergedMiddleware); + + const middleware = (_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + res.result = [...(res.result as string[]), "middleware"]; + end(); + }; + engine.push(middleware); + + const response = await engine.handle(MOCK_REQUEST); + expect(response.result).toEqual(["merged-md-1", "merged-md-2", "middleware"]); + }); +}); describe("JRPCEngine request validation", () => { it("returns invalidRequest for non-object requests", async () => { @@ -31,3 +177,88 @@ describe("JRPCEngine request validation", () => { expect(responses[0].jsonrpc).toBe("2.0"); }); }); + +describe("Provider", () => { + const createEngine = () => { + const engine = new JRPCEngine(); + engine.push((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + res.result = "engine-middleware-1"; + end(); + }); + return engine; + }; + + it("should return a provider from the engine", async () => { + const engine = createEngine(); + + const provider = providerFromEngine(engine); + expect(provider).toBeDefined(); + + const response = await provider.request({ method: "testProviderRequest" }); + expect(response).toBe("engine-middleware-1"); + }); + + it("should be able to use provider as middleware", async () => { + const engine1 = createEngine(); + const provider = providerFromEngine(engine1); + const providerMiddleware = providerAsMiddleware(provider); + + const engine2 = new JRPCEngine(); + engine2.push(providerMiddleware); + const response = await engine2.handle(MOCK_REQUEST); + expect(response.result).toBe("engine-middleware-1"); + }); +}); + +describe("createEngineStream", () => { + it("should throw an error when engine option is missing", () => { + expect(() => createEngineStream(undefined as never)).toThrow("Missing engine parameter!"); + expect(() => createEngineStream({} as never)).toThrow("Missing engine parameter!"); + }); + + it("should push responses for handled requests", async () => { + const engine = new JRPCEngine(); + engine.push((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + res.result = "stream-result"; + end(); + }); + + const stream = createEngineStream({ engine }); + const responsePromise = new Promise>((resolve) => { + stream.once("data", (data) => resolve(data as JRPCResponse)); + }); + + stream.write(MOCK_REQUEST); + const response = await responsePromise; + expect(response.result).toBe("stream-result"); + }); + + it("should forward notifications from the engine", async () => { + const engine = new JRPCEngine(); + const stream = createEngineStream({ engine }); + const notification = { type: "notification", payload: "notification-payload" }; + + const dataPromise = new Promise((resolve) => { + stream.once("data", (data) => resolve(data)); + }); + + engine.emit("notification", notification); + const received = await dataPromise; + expect(received).toEqual(notification); + }); + + it("should remove notification listener on stream close", async () => { + const engine = new JRPCEngine(); + const stream = createEngineStream({ engine }); + expect(engine.listenerCount("notification")).toBe(1); + + const closePromise = new Promise((resolve) => { + stream.once("close", resolve); + }); + stream.destroy(); + await closePromise; + + // notification listener should be removed after stream close + expect(engine.listenerCount("notification")).toBe(0); + }); +}); diff --git a/test/jrpcMiddleware.test.ts b/test/jrpcMiddleware.test.ts new file mode 100644 index 00000000..731cb13e --- /dev/null +++ b/test/jrpcMiddleware.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ConsoleLike, errorCodes, JRPCEngine, JRPCRequest, SerializableError } from "../src"; +import { + createAsyncMiddleware, + createErrorMiddleware, + createIdRemapMiddleware, + createLoggerMiddleware, + createScaffoldMiddleware, + createStreamMiddleware, +} from "../src/jrpc/jrpc"; + +const MOCK_REQUEST: JRPCRequest = { + method: "mock", + params: {}, + id: "1", + jsonrpc: "2.0", +}; + +describe("Middlewares", () => { + it("should create a Logger middleware", async () => { + const mockLogger = { + debug: vi.fn(), + } as unknown as ConsoleLike; + const logger = createLoggerMiddleware(mockLogger); + expect(logger).toBeDefined(); + + const engine = new JRPCEngine(); + engine.push(logger); + await engine.handle(MOCK_REQUEST); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + + it("should create an Error middleware", async () => { + const mockLogger = { + error: vi.fn(), + } as unknown as ConsoleLike; + const errorMiddleware = createErrorMiddleware(mockLogger); + expect(errorMiddleware).toBeDefined(); + + const engine = new JRPCEngine(); + engine.push(errorMiddleware); + engine.push((_req, res, next, _end) => { + res.error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: "invalid method" }); + next(); + }); + + const response = await engine.handle(MOCK_REQUEST); + expect(response.error).toBeDefined(); + expect(response.error?.message).toBe("invalid method"); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it("should push requests to the stream and resolve responses", async () => { + const engine = new JRPCEngine(); + const { middleware, stream } = createStreamMiddleware(); + engine.push(middleware); + + const request: JRPCRequest<{ hello: string }> = { + id: "stream-1", + jsonrpc: "2.0", + method: "hello", + params: { hello: "world" }, + }; + + const dataPromise = new Promise>((resolve) => { + stream.once("data", (data) => resolve(data as JRPCRequest)); + }); + + const responsePromise = engine.handle(request); + + const receivedRequest = await dataPromise; + expect(receivedRequest).toEqual(request); + + stream.write({ + id: "stream-1", + jsonrpc: "2.0", + result: "ok", + }); + + const response = await responsePromise; + expect(response.result).toBe("ok"); + }); + + it("should emit notifications for messages without id", async () => { + const { events, stream } = createStreamMiddleware(); + + const notificationPromise = new Promise>((resolve) => { + events.once("notification", (payload) => { + resolve(payload); + return true; + }); + }); + + const notification: JRPCRequest<{ value: number }> = { + jsonrpc: "2.0", + method: "notify", + params: { value: 42 }, + }; + + stream.write(notification); + + const received = await notificationPromise; + expect(received).toEqual(notification); + }); + + it("should error on responses with unknown ids", async () => { + const { stream } = createStreamMiddleware(); + + const errorPromise = new Promise((resolve) => { + stream.once("error", (err) => resolve(err as Error)); + }); + + stream.write({ + id: "missing-id", + jsonrpc: "2.0", + result: "noop", + }); + + const error = await errorPromise; + expect(error.message).toBe('StreamMiddleware - Unknown response id "missing-id"'); + }); + + it("should create a Scaffold middleware that returns constant results", async () => { + const engine = new JRPCEngine(); + engine.push( + createScaffoldMiddleware({ + hello: "world", + }) + ); + + const response = await engine.handle({ + id: "1", + jsonrpc: "2.0", + method: "hello", + }); + + expect(response.result).toBe("world"); + }); + + it("should create a Scaffold middleware that falls through when no handler", async () => { + const engine = new JRPCEngine(); + const mockFn = vi.fn(); + + engine.push( + createScaffoldMiddleware({ + hello: "world", + }) + ); + engine.push((_req, res, _next, end) => { + mockFn(); + res.result = "fallback"; + end(); + }); + + const response = await engine.handle({ + id: "2", + jsonrpc: "2.0", + method: "unknown", + }); + + expect(mockFn).toHaveBeenCalled(); + expect(response.result).toBe("fallback"); + }); + + it("should create an Async middleware that completes and runs return handlers", async () => { + const engine = new JRPCEngine(); + const returnHandlerSpy = vi.fn(); + + engine.push( + createAsyncMiddleware(async (_req, res, next) => { + res.result = "async-result"; + await next(); + }) + ); + engine.push((_req, _res, next, _end) => { + next((done) => { + returnHandlerSpy(); + done(); + }); + }); + engine.push((_req, _res, _next, end) => { + end(); + }); + + const response = await engine.handle({ + id: "1", + jsonrpc: "2.0", + method: "async", + }); + + expect(response.result).toBe("async-result"); + expect(returnHandlerSpy).toHaveBeenCalled(); + }); + + it("should create an Async middleware that propagates errors", async () => { + const engine = new JRPCEngine(); + const asyncError = new Error("async-fail"); + + engine.push( + createAsyncMiddleware(async () => { + throw asyncError; + }) + ); + + const response = await engine.handle({ + id: "2", + jsonrpc: "2.0", + method: "async-error", + }); + + expect(response.error?.message).toBe("async-fail"); + }); + + it("should remap request ids for downstream middleware and restore them", async () => { + const engine = new JRPCEngine(); + const seenIds: Array["id"]> = []; + const originalId = "original-id"; + + engine.push(createIdRemapMiddleware()); + engine.push((req, res, _next, end) => { + seenIds.push(req.id); + res.result = "ok"; + end(); + }); + + const response = await engine.handle({ + id: originalId, + jsonrpc: "2.0", + method: "remap", + }); + + expect(seenIds).toHaveLength(1); + expect(seenIds[0]).toBeDefined(); + expect(seenIds[0]).not.toBe(originalId); + expect(response.id).toBe(originalId); + expect(response.result).toBe("ok"); + }); +}); From be66bf7b5441b3a861da880267da3e4e8c156c42 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 10 Feb 2026 18:43:13 +0800 Subject: [PATCH 6/9] feat: types/interfaces updates and refactor utils --- src/jrpc/errors/utils.ts | 37 +++++++++++------- src/jrpc/interfaces.ts | 34 ++++++++++++---- src/jrpc/jrpc.ts | 46 +++++++++++++++------- src/jrpc/jrpcEngine.ts | 78 +++++++++++++++++++------------------ src/utils/index.ts | 1 + src/utils/jrpc.ts | 45 +++++++++++++++++++++ src/utils/utils.ts | 8 ++++ test/jrpcEngine.test.ts | 32 +++++++-------- test/jrpcMiddleware.test.ts | 14 +++---- test/jrpcUtils.test.ts | 8 ++-- 10 files changed, 202 insertions(+), 101 deletions(-) create mode 100644 src/utils/jrpc.ts diff --git a/src/jrpc/errors/utils.ts b/src/jrpc/errors/utils.ts index abd5300d..1416bb42 100644 --- a/src/jrpc/errors/utils.ts +++ b/src/jrpc/errors/utils.ts @@ -1,3 +1,4 @@ +import { hasProperty, isObject } from "../../utils"; import { JRPCError, Json } from "../interfaces"; import { errorCodes, errorValues } from "./error-constants"; @@ -37,17 +38,6 @@ export function isValidString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } -/** - * A type guard for {@link RuntimeObject}. - * - * @param value - The value to check. - * @returns Whether the specified value has a runtime type of `object` and is - * neither `null` nor an `Array`. - */ -export function isObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - /** * Check if the value is plain object. * @@ -189,6 +179,19 @@ export function serializeCause(error: unknown): Json { return null; } +/** + * Attempts to extract the original `message` property from an error value of uncertain shape. + * + * @param error - The error in question. + * @returns The original message, if it exists and is a non-empty string. + */ +function getOriginalMessage(error: unknown): string | undefined { + if (isObject(error) && hasProperty(error, "message") && typeof error.message === "string" && error.message.length > 0) { + return error.message; + } + return undefined; +} + /** * Construct a JSON-serializable object given an error and a JSON serializable `fallbackError` * @@ -196,7 +199,7 @@ export function serializeCause(error: unknown): Json { * @param fallbackError - A JSON serializable fallback error. * @returns A JSON serializable error object. */ -function buildError(error: unknown, fallbackError: JRPCError): JRPCError { +function buildError(error: unknown, fallbackError: JRPCError, shouldPreserveMessage: boolean): JRPCError { // If an error specifies a `serialize` function, we call it and return the result. if (error && typeof error === "object" && "serialize" in error && typeof error.serialize === "function") { return error.serialize(); @@ -206,10 +209,13 @@ function buildError(error: unknown, fallbackError: JRPCError): JRPCError { return error as JRPCError; } + const originalMessage = getOriginalMessage(error); + // If the error does not match the JsonRpcError type, use the fallback error, but try to include the original error as `cause`. const cause = serializeCause(error); const fallbackWithCause = { ...fallbackError, + ...(shouldPreserveMessage && originalMessage && { message: originalMessage }), data: { cause }, }; @@ -229,12 +235,15 @@ function buildError(error: unknown, fallbackError: JRPCError): JRPCError { * on the returned object. * @returns The serialized error. */ -export function serializeJrpcError(error: unknown, { fallbackError = FALLBACK_ERROR, shouldIncludeStack = true } = {}): JRPCError { +export function serializeJrpcError( + error: unknown, + { fallbackError = FALLBACK_ERROR, shouldIncludeStack = true, shouldPreserveMessage = true } = {} +): JRPCError { if (!isJsonRpcError(fallbackError)) { throw new Error("Must provide fallback error with integer number code and string message."); } - const serialized = buildError(error, fallbackError); + const serialized = buildError(error, fallbackError, shouldPreserveMessage); if (!shouldIncludeStack) { delete serialized.stack; diff --git a/src/jrpc/interfaces.ts b/src/jrpc/interfaces.ts index 93fe6d4e..5006c11d 100644 --- a/src/jrpc/interfaces.ts +++ b/src/jrpc/interfaces.ts @@ -10,22 +10,33 @@ export interface JRPCBase { id?: JRPCId; } +export type JRPCParams = Json[] | Record; + export interface JRPCResponse extends JRPCBase { result?: T; error?: any; } -export interface JRPCRequest extends JRPCBase { +export type JRPCNotification = { + jsonrpc: "2.0"; method: string; - params?: T; -} + params: Params; + id?: never; +}; + +export type JRPCRequest = { + jsonrpc: "2.0"; + method: string; + params?: Params; + id: string | number | null; +}; export type JRPCEngineNextCallback = (cb?: (done: (error?: Error) => void) => void) => void; export type JRPCEngineEndCallback = (error?: Error) => void; export type JRPCEngineReturnHandler = (done: (error?: Error) => void) => void; interface IdMapValue { - req: JRPCRequest; + req: JRPCRequest; res: JRPCResponse; next: JRPCEngineNextCallback; end: JRPCEngineEndCallback; @@ -35,7 +46,12 @@ export interface IdMap { [requestId: string]: IdMapValue; } -export type JRPCMiddleware = (req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, end: JRPCEngineEndCallback) => void; +export type JRPCMiddleware = ( + req: JRPCRequest, + res: JRPCResponse, + next: JRPCEngineNextCallback, + end: JRPCEngineEndCallback +) => void; export type AsyncJRPCEngineNextCallback = () => Promise; @@ -86,7 +102,11 @@ export interface JRPCFailure extends JRPCBase { error: JRPCError; } -export type AsyncJRPCMiddleware = (req: JRPCRequest, res: PendingJRPCResponse, next: AsyncJRPCEngineNextCallback) => Promise; +export type AsyncJRPCMiddleware = ( + req: JRPCRequest, + res: PendingJRPCResponse, + next: AsyncJRPCEngineNextCallback +) => Promise; export type ReturnHandlerCallback = (error: null | Error) => void; @@ -107,6 +127,6 @@ export interface RequestArguments { params?: T; } -export interface ExtendedJsonRpcRequest extends JRPCRequest { +export interface ExtendedJsonRpcRequest extends JRPCRequest { skipCache?: boolean; } diff --git a/src/jrpc/jrpc.ts b/src/jrpc/jrpc.ts index 66171f42..12961866 100644 --- a/src/jrpc/jrpc.ts +++ b/src/jrpc/jrpc.ts @@ -1,7 +1,19 @@ import { Duplex } from "readable-stream"; +import { isJRPCNotification, isValidMethod } from "../utils/jrpc"; import { errorCodes } from "./errors"; -import { AsyncJRPCMiddleware, ConsoleLike, IdMap, JRPCMiddleware, JRPCRequest, JRPCResponse, Json, ReturnHandlerCallback } from "./interfaces"; +import { + AsyncJRPCMiddleware, + ConsoleLike, + IdMap, + JRPCMiddleware, + JRPCNotification, + JRPCParams, + JRPCRequest, + JRPCResponse, + Json, + ReturnHandlerCallback, +} from "./interfaces"; import { SafeEventEmitter } from "./safeEventEmitter"; import { SerializableError } from "./serializableError"; @@ -17,11 +29,11 @@ export const getRpcPromiseCallback = } }; -export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware { +export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware { return (req, res, next, end) => { try { // json-rpc-engine will terminate the request when it notices this error - if (typeof req.method !== "string" || !req.method) { + if (!isValidMethod(req)) { res.error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: "invalid method" }); end(); return; @@ -43,10 +55,14 @@ export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware) => boolean; + notification: (arg1: JRPCNotification) => boolean; }; -export function createStreamMiddleware(): { events: SafeEventEmitter; middleware: JRPCMiddleware; stream: Duplex } { +export function createStreamMiddleware(): { + events: SafeEventEmitter; + middleware: JRPCMiddleware; + stream: Duplex; +} { const idMap: IdMap = {}; function readNoop() { @@ -69,16 +85,16 @@ export function createStreamMiddleware(): { events: SafeEventEmitter) { + function processNotification(res: JRPCNotification) { events.emit("notification", res); } function processMessage(res: JRPCResponse, _encoding: unknown, cb: (error?: Error | null) => void) { let err: Error; try { - const isNotification = !res.id; + const isNotification = isJRPCNotification(res as JRPCNotification | JRPCRequest); if (isNotification) { - processNotification(res as unknown as JRPCRequest); + processNotification(res as JRPCNotification); } else { processResponse(res); } @@ -95,7 +111,7 @@ export function createStreamMiddleware(): { events: SafeEventEmitter = (req, res, next, end) => { + const middleware: JRPCMiddleware = (req, res, next, end) => { // write req to stream stream.push(req); // register request on id map @@ -105,11 +121,11 @@ export function createStreamMiddleware(): { events: SafeEventEmitter = JRPCMiddleware | Json; +export type ScaffoldMiddlewareHandler = JRPCMiddleware | Json; export function createScaffoldMiddleware(handlers: { - [methodName: string]: ScaffoldMiddlewareHandler; -}): JRPCMiddleware { + [methodName: string]: ScaffoldMiddlewareHandler; +}): JRPCMiddleware { return (req, res, next, end) => { const handler = handlers[req.method]; // if no handler, return @@ -126,7 +142,7 @@ export function createScaffoldMiddleware(handlers: { }; } -export function createIdRemapMiddleware(): JRPCMiddleware { +export function createIdRemapMiddleware(): JRPCMiddleware { return (req, res, next, _end) => { const originalId = req.id; const newId = Math.random().toString(36).slice(2); @@ -140,14 +156,14 @@ export function createIdRemapMiddleware(): JRPCMiddleware { }; } -export function createLoggerMiddleware(logger: ConsoleLike): JRPCMiddleware { +export function createLoggerMiddleware(logger: ConsoleLike): JRPCMiddleware { return (req, res, next, _) => { logger.debug("REQ", req, "RES", res); next(); }; } -export function createAsyncMiddleware(asyncMiddleware: AsyncJRPCMiddleware): JRPCMiddleware { +export function createAsyncMiddleware(asyncMiddleware: AsyncJRPCMiddleware): JRPCMiddleware { return async (req, res, next, end) => { // nextPromise is the key to the implementation // it is resolved by the return handler passed to the diff --git a/src/jrpc/jrpcEngine.ts b/src/jrpc/jrpcEngine.ts index d6a03c6b..38866234 100644 --- a/src/jrpc/jrpcEngine.ts +++ b/src/jrpc/jrpcEngine.ts @@ -1,5 +1,6 @@ import { Duplex } from "readable-stream"; +import { isJRPCFailure, isJRPCSuccess, isValidJRPCRequest, isValidMethod } from "../utils/jrpc"; import { log } from "../utils/logger"; import { errorCodes, JsonRpcError } from "./errors"; import { getMessageFromCode, isValidNumber, serializeJrpcError } from "./errors/utils"; @@ -9,6 +10,7 @@ import { JRPCEngineReturnHandler, JRPCError, JRPCMiddleware, + JRPCParams, JRPCRequest, JRPCResponse, Maybe, @@ -43,7 +45,7 @@ function constructFallbackError(error: Error): JRPCError { * Give it a stack of middleware, pass it requests, and get back responses. */ export class JRPCEngine extends SafeEventEmitter { - private _middleware: JRPCMiddleware[]; + private _middleware: JRPCMiddleware[]; constructor() { super(); @@ -58,9 +60,9 @@ export class JRPCEngine extends SafeEventEmitter { * middleware-defined return handlers. */ private static async _runAllMiddleware( - req: JRPCRequest, + req: JRPCRequest, res: JRPCResponse, - middlewareStack: JRPCMiddleware[] + middlewareStack: JRPCMiddleware[] ): Promise< [ unknown, // error @@ -89,9 +91,9 @@ export class JRPCEngine extends SafeEventEmitter { * and a boolean indicating whether the request should end. */ private static _runMiddleware( - req: JRPCRequest, + req: JRPCRequest, res: JRPCResponse, - middleware: JRPCMiddleware, + middleware: JRPCMiddleware, returnHandlers: JRPCEngineReturnHandler[] ): Promise<[unknown, boolean]> { return new Promise((resolve) => { @@ -150,8 +152,8 @@ export class JRPCEngine extends SafeEventEmitter { * Throws an error if the response has neither a result nor an error, or if * the "isComplete" flag is falsy. */ - private static _checkForCompletion(_req: JRPCRequest, res: JRPCResponse, isComplete: boolean): void { - if (!("result" in res) && !("error" in res)) { + private static _checkForCompletion(_req: JRPCRequest, res: JRPCResponse, isComplete: boolean): void { + if (!isJRPCSuccess(res) && !isJRPCFailure(res)) { throw new SerializableError({ code: errorCodes.rpc.internal, message: "Response has no error or result for request" }); } if (!isComplete) { @@ -164,8 +166,8 @@ export class JRPCEngine extends SafeEventEmitter { * * @param middleware - The middleware function to add. */ - push(middleware: JRPCMiddleware): void { - this._middleware.push(middleware as JRPCMiddleware); + push(middleware: JRPCMiddleware): void { + this._middleware.push(middleware as JRPCMiddleware); } /** @@ -174,7 +176,7 @@ export class JRPCEngine extends SafeEventEmitter { * @param request - The request to handle. * @param callback - An error-first callback that will receive the response. */ - handle(request: JRPCRequest, callback: (error: unknown, response: JRPCResponse) => void): void; + handle(request: JRPCRequest, callback: (error: unknown, response: JRPCResponse) => void): void; /** * Handle an array of JSON-RPC requests, and return an array of responses. @@ -183,7 +185,7 @@ export class JRPCEngine extends SafeEventEmitter { * @param callback - An error-first callback that will receive the array of * responses. */ - handle(requests: JRPCRequest[], callback: (error: unknown, responses: JRPCResponse[]) => void): void; + handle(requests: JRPCRequest[], callback: (error: unknown, responses: JRPCResponse[]) => void): void; /** * Handle a JSON-RPC request, and return a response. @@ -192,7 +194,7 @@ export class JRPCEngine extends SafeEventEmitter { * @returns A promise that resolves with the response, or rejects with an * error. */ - handle(request: JRPCRequest): Promise>; + handle(request: JRPCRequest): Promise>; /** * Handle an array of JSON-RPC requests, and return an array of responses. @@ -201,10 +203,10 @@ export class JRPCEngine extends SafeEventEmitter { * @returns A promise that resolves with the array of responses, or rejects * with an error. */ - handle(requests: JRPCRequest[]): Promise[]>; + handle(requests: JRPCRequest[]): Promise[]>; // eslint-disable-next-line @typescript-eslint/no-explicit-any - handle(req: unknown, cb?: any) { + handle(req: JRPCRequest | JRPCRequest[], cb?: any) { if (cb && typeof cb !== "function") { throw new Error('"callback" must be a function if provided.'); } @@ -217,9 +219,9 @@ export class JRPCEngine extends SafeEventEmitter { } if (cb) { - return this._handle(req as JRPCRequest, cb); + return this._handle(req, cb); } - return this._promiseHandle(req as JRPCRequest); + return this._promiseHandle(req); } /** @@ -228,7 +230,7 @@ export class JRPCEngine extends SafeEventEmitter { * * @returns This engine as a middleware function. */ - asMiddleware(): JRPCMiddleware { + asMiddleware(): JRPCMiddleware { return async (req, res, next, end) => { try { const [middlewareError, isComplete, returnHandlers] = await JRPCEngine._runAllMiddleware(req, res, this._middleware); @@ -255,15 +257,15 @@ export class JRPCEngine extends SafeEventEmitter { /** * Like _handle, but for batch requests. */ - private _handleBatch(reqs: JRPCRequest[]): Promise[]>; + private _handleBatch(reqs: JRPCRequest[]): Promise[]>; /** * Like _handle, but for batch requests. */ - private _handleBatch(reqs: JRPCRequest[], cb: (error: unknown, responses?: JRPCResponse[]) => void): Promise; + private _handleBatch(reqs: JRPCRequest[], cb: (error: unknown, responses?: JRPCResponse[]) => void): Promise; private async _handleBatch( - reqs: JRPCRequest[], + reqs: JRPCRequest[], cb?: (error: unknown, responses?: JRPCResponse[]) => void ): Promise[] | void> { // The order here is important @@ -305,7 +307,7 @@ export class JRPCEngine extends SafeEventEmitter { /** * A promise-wrapped _handle. */ - private _promiseHandle(req: JRPCRequest): Promise> { + private _promiseHandle(req: JRPCRequest): Promise> { return new Promise((resolve, reject) => { this._handle(req, (_err, res) => { // There will always be a response, and it will always have any error @@ -323,8 +325,8 @@ export class JRPCEngine extends SafeEventEmitter { * * Does not reject. */ - private async _handle(callerReq: JRPCRequest, cb: (error: unknown, response: JRPCResponse) => void): Promise { - if (!callerReq || Array.isArray(callerReq) || typeof callerReq !== "object") { + private async _handle(callerReq: JRPCRequest, cb: (error: unknown, response: JRPCResponse) => void): Promise { + if (!isValidJRPCRequest(callerReq)) { const error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: `Requests must be plain objects. Received: ${typeof callerReq}`, @@ -332,7 +334,7 @@ export class JRPCEngine extends SafeEventEmitter { return cb(error, { id: undefined, jsonrpc: "2.0", error }); } - if (typeof callerReq.method !== "string" || !callerReq.method) { + if (!isValidMethod(callerReq)) { const error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: `Must specify a string method. Received: ${typeof callerReq.method}`, @@ -340,7 +342,7 @@ export class JRPCEngine extends SafeEventEmitter { return cb(error, { id: callerReq.id, jsonrpc: "2.0", error }); } - const req: JRPCRequest = { ...callerReq }; + const req: JRPCRequest = { ...callerReq }; const res: JRPCResponse = { id: req.id, jsonrpc: req.jsonrpc, @@ -375,7 +377,7 @@ export class JRPCEngine extends SafeEventEmitter { * handlers, if any, and ensures that internal request processing semantics * are satisfied. */ - private async _processRequest(req: JRPCRequest, res: JRPCResponse): Promise { + private async _processRequest(req: JRPCRequest, res: JRPCResponse): Promise { const [error, isComplete, returnHandlers] = await JRPCEngine._runAllMiddleware(req, res, this._middleware); // Throw if "end" was not called, or if the response has neither a result @@ -394,7 +396,7 @@ export class JRPCEngine extends SafeEventEmitter { } } -export function mergeMiddleware(middlewareStack: JRPCMiddleware[]): JRPCMiddleware { +export function mergeMiddleware(middlewareStack: JRPCMiddleware[]): JRPCMiddleware { const engine = new JRPCEngine(); middlewareStack.forEach((middleware) => { engine.push(middleware); @@ -419,7 +421,7 @@ export function createEngineStream(opts: EngineStreamOptions): Duplex { return undefined; } - function write(req: JRPCRequest, _encoding: unknown, cb: (error?: Error | null) => void) { + function write(req: JRPCRequest, _encoding: unknown, cb: (error?: Error | null) => void) { engine.handle(req, (_err, res) => { stream.push(res); }); @@ -450,15 +452,15 @@ export type ProviderEvents = { }; export interface SafeEventEmitterProvider extends SafeEventEmitter { - sendAsync: (req: JRPCRequest) => Promise; - send: (req: JRPCRequest, callback: SendCallBack>) => void; - request: (args: RequestArguments) => Promise>; + sendAsync: (req: JRPCRequest) => Promise; + send: (req: JRPCRequest, callback: SendCallBack>) => void; + request: (args: RequestArguments) => Promise>; } export function providerFromEngine(engine: JRPCEngine): SafeEventEmitterProvider { const provider: SafeEventEmitterProvider = new SafeEventEmitter() as SafeEventEmitterProvider; // handle both rpc send methods - provider.sendAsync = async (req: JRPCRequest) => { + provider.sendAsync = async (req: JRPCRequest) => { const res = await engine.handle(req); if (res.error) { if (typeof res.error === "object" && Object.keys(res.error).includes("stack") === false) res.error.stack = "Stack trace is not available."; @@ -475,11 +477,11 @@ export function providerFromEngine(engine: JRPCEngine): SafeEventEmitterProvider return res.result as U; }; - provider.send = (req: JRPCRequest, callback: (error: unknown, providerRes: JRPCResponse) => void) => { + provider.send = (req: JRPCRequest, callback: (error: unknown, providerRes: JRPCResponse) => void) => { if (typeof callback !== "function") { throw new Error('Must provide callback to "send" method.'); } - engine.handle(req, callback); + engine.handle(req as JRPCRequest, callback); }; // forward notifications if (engine.on) { @@ -488,8 +490,8 @@ export function providerFromEngine(engine: JRPCEngine): SafeEventEmitterProvider }); } - provider.request = async (args: RequestArguments) => { - const req: JRPCRequest = { + provider.request = async (args: RequestArguments) => { + const req: JRPCRequest = { ...args, id: Math.random().toString(36).slice(2), jsonrpc: "2.0", @@ -507,11 +509,11 @@ export function providerFromMiddleware(middleware: JRPCMiddleware { +export function providerAsMiddleware(provider: SafeEventEmitterProvider): JRPCMiddleware { return async (req, res, _next, end) => { // send request to provider try { - const providerRes: unknown = await provider.sendAsync(req); + const providerRes: unknown = await provider.sendAsync(req); res.result = providerRes; return end(); } catch (error: unknown) { diff --git a/src/utils/index.ts b/src/utils/index.ts index c17a6109..36a547a0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export * from "./browserStorage"; export * from "./constants"; export * from "./interfaces"; +export * from "./jrpc"; export * from "./utils"; export * from "./whitelabel"; diff --git a/src/utils/jrpc.ts b/src/utils/jrpc.ts new file mode 100644 index 00000000..e5a8cb4b --- /dev/null +++ b/src/utils/jrpc.ts @@ -0,0 +1,45 @@ +import { JRPCBase, JRPCFailure, JRPCNotification, JRPCParams, JRPCRequest, JRPCSuccess } from "../jrpc/interfaces"; +import { hasProperty, isObject } from "./utils"; + +/** + * Type guard to check if a JRPC message is a notification (no `id` property). + */ +export function isJRPCNotification(request: JRPCNotification | JRPCRequest): request is JRPCNotification { + return !hasProperty(request, "id"); +} + +/** + * Type guard to check if a JRPC message is a request (has `id` property). + */ +export function isJRPCRequest(request: JRPCNotification | JRPCRequest): request is JRPCRequest { + return hasProperty(request, "id"); +} + +/** + * Checks whether the given request has a valid (non-empty string) `method`. + */ +export function isValidMethod(request: Partial): boolean { + return typeof request.method === "string" && request.method.length > 0; +} + +/** + * Checks whether the given value is a valid JRPC request object + * (a plain, non-array object). + */ +export function isValidJRPCRequest(value: unknown): value is JRPCRequest { + return isObject(value) && !Array.isArray(value); +} + +/** + * Type guard to check if a JRPC response is a success (has `result`). + */ +export function isJRPCSuccess(response: JRPCBase): response is JRPCSuccess { + return "result" in response; +} + +/** + * Type guard to check if a JRPC response is a failure (has `error`). + */ +export function isJRPCFailure(response: JRPCBase): response is JRPCFailure { + return "error" in response; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 06ffaf82..681e22b4 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -39,3 +39,11 @@ export function cloneDeep(object: T): T { return JSON.parse(JSON.stringify(object)); } } + +export function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function hasProperty(value: unknown, key: Key): value is Record { + return isObject(value) && key in value; +} diff --git a/test/jrpcEngine.test.ts b/test/jrpcEngine.test.ts index c6fb760f..8c5d104b 100644 --- a/test/jrpcEngine.test.ts +++ b/test/jrpcEngine.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { SerializableError } from "../src"; import { errorCodes } from "../src/jrpc/errors"; -import { JRPCEngineNextCallback, JRPCMiddleware, JRPCRequest, JRPCResponse } from "../src/jrpc/interfaces"; +import { JRPCEngineNextCallback, JRPCMiddleware, JRPCParams, JRPCRequest, JRPCResponse } from "../src/jrpc/interfaces"; import { createEngineStream, JRPCEngine, mergeMiddleware, providerAsMiddleware, providerFromEngine } from "../src/jrpc/jrpcEngine"; -const MOCK_REQUEST: JRPCRequest = { +const MOCK_REQUEST: JRPCRequest = { method: "mock", params: {}, id: "1", @@ -17,7 +17,7 @@ describe("JRPCEngine", () => { const engine = new JRPCEngine(); const mockFn = vi.fn(); - const middleware = (_req: JRPCRequest, _res: JRPCResponse, _next: () => void, end: () => void) => { + const middleware = (_req: JRPCRequest, _res: JRPCResponse, _next: () => void, end: () => void) => { mockFn(); end(); }; @@ -53,7 +53,7 @@ describe("JRPCEngine", () => { const mockFn = vi.fn(); const requests = [MOCK_REQUEST, MOCK_REQUEST]; - const middleware = (_req: JRPCRequest, res: JRPCResponse, _next: () => void, end: () => void) => { + const middleware = (_req: JRPCRequest, res: JRPCResponse, _next: () => void, end: () => void) => { res.result = "test"; mockFn(); end(); @@ -66,7 +66,7 @@ describe("JRPCEngine", () => { it("should handle error thrown from batch requests", async () => { const engine = new JRPCEngine(); - const middleware = (_req: JRPCRequest, _res: JRPCResponse, _next: () => void, _end: () => void) => { + const middleware = (_req: JRPCRequest, _res: JRPCResponse, _next: () => void, _end: () => void) => { throw new Error("test error"); }; engine.push(middleware); @@ -81,7 +81,7 @@ describe("JRPCEngine", () => { const engine = new JRPCEngine(); const mockReturnHandler = vi.fn(); - const middleware = (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + const middleware = (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { res.result = "test"; next((done) => { mockReturnHandler(); @@ -90,7 +90,7 @@ describe("JRPCEngine", () => { }; engine.push(middleware); - const middleware2 = (_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + const middleware2 = (_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { res.result = "test2"; end(); }; @@ -106,15 +106,15 @@ describe("JRPCEngine Middlewares", () => { it("should merge middleware stacks", async () => { const engine = new JRPCEngine(); - const middleware = (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + const middleware = (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { res.result = ["merged-md-1"]; next(); }; - const middleware2 = ((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + const middleware2 = ((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { res.result = [...res.result, "merged-md-2"]; end(); - }) as JRPCMiddleware; + }) as JRPCMiddleware; const mergedMiddleware = mergeMiddleware([middleware, middleware2]); engine.push(mergedMiddleware); @@ -126,19 +126,19 @@ describe("JRPCEngine Middlewares", () => { const engine = new JRPCEngine(); const mergedMiddleware = mergeMiddleware([ - (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { const prevResult = Array.isArray(res.result) ? res.result : []; res.result = [...prevResult, "merged-md-1"]; next(); }, - (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { + (_req: JRPCRequest, res: JRPCResponse, next: JRPCEngineNextCallback, _end: () => void) => { res.result = [...(res.result as string[]), "merged-md-2"]; next(); }, ]); engine.push(mergedMiddleware); - const middleware = (_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + const middleware = (_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { res.result = [...(res.result as string[]), "middleware"]; end(); }; @@ -162,7 +162,7 @@ describe("JRPCEngine request validation", () => { 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 }); + const response = await engine.handle({ id: 1, jsonrpc: "2.0", method: 123 as any, params: null }); expect(response.error?.code).toBe(errorCodes.rpc.invalidRequest); expect(response.id).toBe(1); expect(response.jsonrpc).toBe("2.0"); @@ -181,7 +181,7 @@ describe("JRPCEngine request validation", () => { describe("Provider", () => { const createEngine = () => { const engine = new JRPCEngine(); - engine.push((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + engine.push((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { res.result = "engine-middleware-1"; end(); }); @@ -218,7 +218,7 @@ describe("createEngineStream", () => { it("should push responses for handled requests", async () => { const engine = new JRPCEngine(); - engine.push((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { + engine.push((_req: JRPCRequest, res: JRPCResponse, _next: JRPCEngineNextCallback, end: () => void) => { res.result = "stream-result"; end(); }); diff --git a/test/jrpcMiddleware.test.ts b/test/jrpcMiddleware.test.ts index 731cb13e..7f3fccdd 100644 --- a/test/jrpcMiddleware.test.ts +++ b/test/jrpcMiddleware.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { ConsoleLike, errorCodes, JRPCEngine, JRPCRequest, SerializableError } from "../src"; +import { ConsoleLike, errorCodes, JRPCEngine, JRPCNotification, JRPCParams, JRPCRequest, SerializableError } from "../src"; import { createAsyncMiddleware, createErrorMiddleware, @@ -10,7 +10,7 @@ import { createStreamMiddleware, } from "../src/jrpc/jrpc"; -const MOCK_REQUEST: JRPCRequest = { +const MOCK_REQUEST: JRPCRequest = { method: "mock", params: {}, id: "1", @@ -63,8 +63,8 @@ describe("Middlewares", () => { params: { hello: "world" }, }; - const dataPromise = new Promise>((resolve) => { - stream.once("data", (data) => resolve(data as JRPCRequest)); + const dataPromise = new Promise>((resolve) => { + stream.once("data", (data) => resolve(data as JRPCRequest)); }); const responsePromise = engine.handle(request); @@ -85,14 +85,14 @@ describe("Middlewares", () => { it("should emit notifications for messages without id", async () => { const { events, stream } = createStreamMiddleware(); - const notificationPromise = new Promise>((resolve) => { + const notificationPromise = new Promise((resolve) => { events.once("notification", (payload) => { resolve(payload); return true; }); }); - const notification: JRPCRequest<{ value: number }> = { + const notification: JRPCNotification<{ value: number }> = { jsonrpc: "2.0", method: "notify", params: { value: 42 }, @@ -214,7 +214,7 @@ describe("Middlewares", () => { it("should remap request ids for downstream middleware and restore them", async () => { const engine = new JRPCEngine(); - const seenIds: Array["id"]> = []; + const seenIds: Array["id"]> = []; const originalId = "original-id"; engine.push(createIdRemapMiddleware()); diff --git a/test/jrpcUtils.test.ts b/test/jrpcUtils.test.ts index ab9bf916..41976ec5 100644 --- a/test/jrpcUtils.test.ts +++ b/test/jrpcUtils.test.ts @@ -99,7 +99,7 @@ describe("serializeError", function () { const result = serializeJrpcError(invalidError7); expect(result).toStrictEqual({ code: rpcCodes.internal, - message: getMessageFromCode(rpcCodes.internal), + message: invalidError7.message, data: { cause: { code: invalidError7.code, @@ -159,7 +159,7 @@ describe("serializeError", function () { expect(result).toStrictEqual({ code: rpcCodes.parse, message: validError4.message, - data: { ...validError4.data }, + data: { ...validError4.data, cause: null }, }); }); @@ -205,7 +205,7 @@ describe("serializeError", function () { const result = serializeJrpcError(error); expect(result).toStrictEqual({ code: errorCodes.rpc.internal, - message: getMessageFromCode(errorCodes.rpc.internal), + message: error.message, data: { cause: { message: error.message, @@ -216,7 +216,7 @@ describe("serializeError", function () { expect(JSON.parse(JSON.stringify(result))).toStrictEqual({ code: errorCodes.rpc.internal, - message: getMessageFromCode(errorCodes.rpc.internal), + message: error.message, data: { cause: { message: error.message, From d89bc3eff4523bd0e43f09eef97485a933e328f9 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 10 Feb 2026 19:40:19 +0800 Subject: [PATCH 7/9] chore: updated tests and types --- src/jrpc/interfaces.ts | 15 +++---- src/jrpc/jrpcEngine.ts | 4 +- src/utils/jrpc.ts | 12 +----- test/jrpcUtils.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/src/jrpc/interfaces.ts b/src/jrpc/interfaces.ts index 5006c11d..19498b6f 100644 --- a/src/jrpc/interfaces.ts +++ b/src/jrpc/interfaces.ts @@ -17,19 +17,16 @@ export interface JRPCResponse extends JRPCBase { error?: any; } -export type JRPCNotification = { - jsonrpc: "2.0"; +export interface JRPCNotification { + jsonrpc?: JRPCVersion; method: string; - params: Params; - id?: never; -}; + params?: Params; +} -export type JRPCRequest = { - jsonrpc: "2.0"; +export interface JRPCRequest extends JRPCBase { method: string; params?: Params; - id: string | number | null; -}; +} export type JRPCEngineNextCallback = (cb?: (done: (error?: Error) => void) => void) => void; export type JRPCEngineEndCallback = (error?: Error) => void; diff --git a/src/jrpc/jrpcEngine.ts b/src/jrpc/jrpcEngine.ts index 38866234..bd9501d1 100644 --- a/src/jrpc/jrpcEngine.ts +++ b/src/jrpc/jrpcEngine.ts @@ -1,6 +1,6 @@ import { Duplex } from "readable-stream"; -import { isJRPCFailure, isJRPCSuccess, isValidJRPCRequest, isValidMethod } from "../utils/jrpc"; +import { isJRPCFailure, isJRPCSuccess, isValidMethod } from "../utils/jrpc"; import { log } from "../utils/logger"; import { errorCodes, JsonRpcError } from "./errors"; import { getMessageFromCode, isValidNumber, serializeJrpcError } from "./errors/utils"; @@ -326,7 +326,7 @@ export class JRPCEngine extends SafeEventEmitter { * Does not reject. */ private async _handle(callerReq: JRPCRequest, cb: (error: unknown, response: JRPCResponse) => void): Promise { - if (!isValidJRPCRequest(callerReq)) { + if (!callerReq || Array.isArray(callerReq) || typeof callerReq !== "object") { const error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: `Requests must be plain objects. Received: ${typeof callerReq}`, diff --git a/src/utils/jrpc.ts b/src/utils/jrpc.ts index e5a8cb4b..f6854a7b 100644 --- a/src/utils/jrpc.ts +++ b/src/utils/jrpc.ts @@ -1,11 +1,11 @@ import { JRPCBase, JRPCFailure, JRPCNotification, JRPCParams, JRPCRequest, JRPCSuccess } from "../jrpc/interfaces"; -import { hasProperty, isObject } from "./utils"; +import { hasProperty } from "./utils"; /** * Type guard to check if a JRPC message is a notification (no `id` property). */ export function isJRPCNotification(request: JRPCNotification | JRPCRequest): request is JRPCNotification { - return !hasProperty(request, "id"); + return !hasProperty(request, "id") || request.id === undefined; } /** @@ -22,14 +22,6 @@ export function isValidMethod(request: Partial): boolean { return typeof request.method === "string" && request.method.length > 0; } -/** - * Checks whether the given value is a valid JRPC request object - * (a plain, non-array object). - */ -export function isValidJRPCRequest(value: unknown): value is JRPCRequest { - return isObject(value) && !Array.isArray(value); -} - /** * Type guard to check if a JRPC response is a success (has `result`). */ diff --git a/test/jrpcUtils.test.ts b/test/jrpcUtils.test.ts index 41976ec5..e5535c8e 100644 --- a/test/jrpcUtils.test.ts +++ b/test/jrpcUtils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { dataHasCause, errorCodes, getMessageFromCode, rpcErrors, serializeJrpcError } from "../src/jrpc/errors"; +import { JRPCNotification, JRPCParams, JRPCRequest, JRPCResponse } from "../src/jrpc/interfaces"; +import { isJRPCFailure, isJRPCNotification, isJRPCRequest, isJRPCSuccess, isValidMethod } from "../src/utils/jrpc"; import { dummyData, dummyMessage, @@ -326,3 +328,99 @@ describe("dataHasCause", function () { expect(result).toBe(true); }); }); + +describe("isJRPCNotification", function () { + it("returns true for a message without id", function () { + const notification = { jsonrpc: "2.0", method: "notify", params: { value: 1 } } as unknown as JRPCNotification; + expect(isJRPCNotification(notification)).toBe(true); + }); + + it("returns false for a message with id", function () { + const request: JRPCRequest = { jsonrpc: "2.0", method: "test", params: {}, id: "1" }; + expect(isJRPCNotification(request)).toBe(false); + }); + + it("returns true when id is explicitly undefined", function () { + expect(isJRPCNotification({ id: undefined, jsonrpc: "2.0", method: "test", params: {} })).toBe(true); + }); +}); + +describe("isJRPCRequest", function () { + it("returns true for a message with id", function () { + const request: JRPCRequest = { jsonrpc: "2.0", method: "test", params: {}, id: "1" }; + expect(isJRPCRequest(request)).toBe(true); + }); + + it("returns true for a message with numeric id", function () { + const request: JRPCRequest = { jsonrpc: "2.0", method: "test", params: {}, id: 42 }; + expect(isJRPCRequest(request)).toBe(true); + }); + + it("returns false for a message without id", function () { + const notification: JRPCNotification = { jsonrpc: "2.0", method: "notify", params: { value: 1 } }; + expect(isJRPCRequest(notification)).toBe(false); + }); +}); + +describe("isValidMethod", function () { + it("returns true for a non-empty string method", function () { + expect(isValidMethod({ method: "eth_call" })).toBe(true); + }); + + it("returns false for an empty string method", function () { + expect(isValidMethod({ method: "" })).toBe(false); + }); + + it("returns false when method is undefined", function () { + expect(isValidMethod({})).toBe(false); + }); + + it("returns false when method is a number", function () { + // @ts-expect-error Intentionally using wrong type + expect(isValidMethod({ method: 123 })).toBe(false); + }); + + it("returns false when method is null", function () { + expect(isValidMethod({ method: null as unknown as string })).toBe(false); + }); +}); + +describe("isJRPCSuccess", function () { + it("returns true when response has result", function () { + expect(isJRPCSuccess({ jsonrpc: "2.0", id: "1", result: "ok" } as JRPCResponse)).toBe(true); + }); + + it("returns true when result is null", function () { + expect(isJRPCSuccess({ jsonrpc: "2.0", id: "1", result: null } as JRPCResponse)).toBe(true); + }); + + it("returns true when result is undefined", function () { + expect(isJRPCSuccess({ jsonrpc: "2.0", id: "1", result: undefined } as JRPCResponse)).toBe(true); + }); + + it("returns false when response has no result", function () { + expect(isJRPCSuccess({ jsonrpc: "2.0", id: "1" })).toBe(false); + }); + + it("returns false for a failure response", function () { + expect(isJRPCSuccess({ jsonrpc: "2.0", id: "1", error: { code: -32600, message: "bad" } } as JRPCResponse)).toBe(false); + }); +}); + +describe("isJRPCFailure", function () { + it("returns true when response has error", function () { + expect(isJRPCFailure({ jsonrpc: "2.0", id: "1", error: { code: -32600, message: "bad" } } as JRPCResponse)).toBe(true); + }); + + it("returns false when response has no error", function () { + expect(isJRPCFailure({ jsonrpc: "2.0", id: "1" })).toBe(false); + }); + + it("returns false for a success response", function () { + expect(isJRPCFailure({ jsonrpc: "2.0", id: "1", result: "ok" } as JRPCResponse)).toBe(false); + }); + + it("returns true when response has both result and error", function () { + expect(isJRPCFailure({ jsonrpc: "2.0", id: "1", result: "ok", error: { code: -32600, message: "bad" } } as JRPCResponse)).toBe(true); + }); +}); From 847220611744ec23894a48344cc6d4ff361e2719 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 10 Feb 2026 20:43:29 +0800 Subject: [PATCH 8/9] fix: updated 'isJRPCRequest' function to check for undefined value --- src/utils/jrpc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/jrpc.ts b/src/utils/jrpc.ts index f6854a7b..2d0cbdf2 100644 --- a/src/utils/jrpc.ts +++ b/src/utils/jrpc.ts @@ -12,7 +12,7 @@ export function isJRPCNotification(request: JRPCNotificati * Type guard to check if a JRPC message is a request (has `id` property). */ export function isJRPCRequest(request: JRPCNotification | JRPCRequest): request is JRPCRequest { - return hasProperty(request, "id"); + return hasProperty(request, "id") && request.id !== undefined; } /** From 3bef878623f26b63c1b8eeeeb160bbb33014e798 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 10 Feb 2026 22:51:57 +0800 Subject: [PATCH 9/9] chore: updated 'JRPCParams' type --- src/jrpc/interfaces.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jrpc/interfaces.ts b/src/jrpc/interfaces.ts index 19498b6f..72d6bcd5 100644 --- a/src/jrpc/interfaces.ts +++ b/src/jrpc/interfaces.ts @@ -10,7 +10,9 @@ export interface JRPCBase { id?: JRPCId; } -export type JRPCParams = Json[] | Record; +// `unknown` is added for the backward compatibility. +// TODO: remove `unknown` after the backward compatibility is no longer needed. +export type JRPCParams = Json[] | Record | unknown; export interface JRPCResponse extends JRPCBase { result?: T;