diff --git a/src/RPCClient.ts b/src/RPCClient.ts index abaed2c..af0c0c6 100644 --- a/src/RPCClient.ts +++ b/src/RPCClient.ts @@ -13,8 +13,8 @@ import type { ClientManifest, RPCStream, JSONRPCResponseResult, + ToError, } from './types'; -import type { ErrorRPC } from './errors'; import Logger from '@matrixai/logger'; import { Timer } from '@matrixai/timer'; import * as middleware from './middleware'; @@ -28,7 +28,7 @@ class RPCClient { protected idGen: IdGen; protected logger: Logger; protected streamFactory: StreamFactory; - protected toError?: typeof utils.toError; + protected toError: ToError; protected middlewareFactory: MiddlewareFactory< Uint8Array, JSONRPCRequest, @@ -86,7 +86,7 @@ class RPCClient { middlewareFactory = middleware.defaultClientMiddlewareWrapper(), streamKeepAliveTimeoutTime = Infinity, logger, - toError, + toError = utils.toError, idGen = () => Promise.resolve(null), }: { manifest: M; @@ -100,10 +100,7 @@ class RPCClient { streamKeepAliveTimeoutTime?: number; logger?: Logger; idGen?: IdGen; - toError?: ( - errorData: JSONValue, - metadata: Record, - ) => ErrorRPC; + toError?: ToError; }) { this.idGen = idGen; this.callerTypes = utils.getHandlerTypes(manifest); @@ -472,7 +469,20 @@ class RPCClient { ...(rpcStream.meta ?? {}), command: method, }; - throw utils.toError(messageValue.error, metadata); + const e: errors.ErrorRPCProtocol = + errors.ErrorRPCProtocol.fromJSON(messageValue.error); + if ( + e instanceof errors.ErrorRPCRemote && + messageValue.error.data != null && + typeof messageValue.error.data === 'object' && + 'cause' in messageValue.error.data + ) { + e.metadata = metadata; + e.cause = this.toError( + JSON.parse(messageValue.error.data.cause as string), + ); + } + throw e; } leadingMessage = messageValue; } catch (e) { diff --git a/src/RPCServer.ts b/src/RPCServer.ts index 513fcd4..b21775e 100644 --- a/src/RPCServer.ts +++ b/src/RPCServer.ts @@ -15,7 +15,9 @@ import type { UnaryHandlerImplementation, RPCStream, MiddlewareFactory, + FromError, } from './types'; +import type { POJO } from '@matrixai/errors'; import { ReadableStream, TransformStream } from 'stream/web'; import Logger from '@matrixai/logger'; import { PromiseCancellable } from '@matrixai/async-cancellable'; @@ -59,8 +61,8 @@ class RPCServer { protected defaultTimeoutMap: Map = new Map(); protected handlerTimeoutTime: number; protected activeStreams: Set> = new Set(); - protected fromError: (error: errors.ErrorRPC) => JSONValue; - protected filterSensitive: (key: string, value: any) => any; + protected fromError: FromError; + protected replacer?: (key: string, value: any) => any; protected middlewareFactory: MiddlewareFactory< JSONRPCRequest, Uint8Array, @@ -94,7 +96,7 @@ class RPCServer { logger, idGen = () => Promise.resolve(null), fromError = utils.fromError, - filterSensitive = utils.filterSensitive, + replacer, }: { middlewareFactory?: MiddlewareFactory< JSONRPCRequest, @@ -105,14 +107,14 @@ class RPCServer { handlerTimeoutTime?: number; logger?: Logger; idGen?: IdGen; - fromError?: (error: errors.ErrorRPC) => JSONValue; - filterSensitive?: (key: string, value: any) => any; + fromError?: FromError; + replacer?: (key: string, value: any) => any; }) { this.idGen = idGen; this.middlewareFactory = middlewareFactory; this.handlerTimeoutTime = handlerTimeoutTime; - this.fromError = fromError ?? utils.fromError; - this.filterSensitive = filterSensitive ?? utils.filterSensitive; + this.fromError = fromError; + this.replacer = replacer; this.logger = logger ?? new Logger(this.constructor.name); } @@ -196,7 +198,7 @@ class RPCServer { const handlerPs = new Array>(); if (force) { for await (const [activeStream] of this.activeStreams.entries()) { - if (force) activeStream.cancel(new errors.ErrorRPCStopping()); + if (force) activeStream.cancel(reason); handlerPs.push(activeStream); } await Promise.all(handlerPs); @@ -319,11 +321,26 @@ class RPCServer { } controller.enqueue(value); } catch (e) { - const rpcError: JSONRPCError = { - code: e.exitCode ?? errors.JSONRPCErrorCode.InternalError, - message: e.description ?? '', - data: JSON.stringify(this.fromError(e), this.filterSensitive), - }; + let rpcError: JSONRPCError; + if (e instanceof errors.ErrorRPCProtocol) { + rpcError = e.toJSON(); + } else { + rpcError = new errors.ErrorRPCRemote(e?.message).toJSON(); + try { + (rpcError.data as POJO).cause = JSON.stringify( + this.fromError(e), + this.replacer, + ); + } catch (e) { + (rpcError.data as POJO).cause = e; + // Dispatch error in the case where the thrown value could not be parsed + this.dispatchEvent( + new events.RPCErrorEvent({ + detail: e, + }), + ); + } + } const rpcErrorMessage: JSONRPCResponseError = { jsonrpc: '2.0', error: rpcError, @@ -504,7 +521,10 @@ class RPCServer { await timer.catch(() => {}); this.dispatchEvent( new events.RPCErrorEvent({ - detail: new errors.ErrorRPCOutputStreamError(), + detail: new errors.ErrorRPCOutputStreamError( + 'Stream failed waiting for header', + { cause: newErr }, + ), }), ); return; @@ -576,11 +596,26 @@ class RPCServer { { signal: abortController.signal, timer }, ); } catch (e) { - const rpcError: JSONRPCError = { - code: e.exitCode ?? errors.JSONRPCErrorCode.InternalError, - message: e.description ?? '', - data: JSON.stringify(this.fromError(e), this.filterSensitive), - }; + let rpcError: JSONRPCError; + if (e instanceof errors.ErrorRPCProtocol) { + rpcError = e.toJSON(); + } else { + rpcError = new errors.ErrorRPCRemote(e?.message).toJSON(); + try { + (rpcError.data as POJO).cause = JSON.stringify( + this.fromError(e), + this.replacer, + ); + } catch (e) { + (rpcError.data as POJO).cause = e; + // Dispatch error in the case where the thrown value could not be parsed + this.dispatchEvent( + new events.RPCErrorEvent({ + detail: e, + }), + ); + } + } const rpcErrorMessage: JSONRPCResponseError = { jsonrpc: '2.0', error: rpcError, diff --git a/src/callers/RawCaller.ts b/src/callers/RawCaller.ts index a4721cf..a5e3522 100644 --- a/src/callers/RawCaller.ts +++ b/src/callers/RawCaller.ts @@ -1,4 +1,3 @@ -import type { JSONValue } from '../types'; import Caller from './Caller'; class RawCaller extends Caller { public type: 'RAW' = 'RAW' as const; diff --git a/src/errors.ts b/src/errors.ts index f4c8af0..518984a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,5 @@ import type { Class } from '@matrixai/errors'; -import type { JSONValue } from '@/types'; +import type { JSONRPCError, JSONValue } from '@/types'; import { AbstractError } from '@matrixai/errors'; class ErrorRPC extends AbstractError { @@ -16,35 +16,64 @@ class ErrorRPCServerNotRunning extends ErrorRPC { static description = 'RPCServer is not running'; } -// Protocol Errors +/** + * This is an internal error, it should not reach the top level. + */ +class ErrorRPCHandlerFailed extends ErrorRPC { + static description = 'Failed to handle stream'; +} -const enum JSONRPCErrorCode { - ParseError = -32700, - InvalidRequest = -32600, - MethodNotFound = -32601, - InvalidParams = -32602, - InternalError = -32603, - HandlerNotFound = -32000, - RPCStopping = -32001, - RPCMessageLength = -32003, - RPCMissingResponse = -32004, - RPCOutputStreamError = -32005, - RPCRemote = -32006, - RPCStreamEnded = -32007, - RPCTimedOut = -32008, - RPCConnectionLocal = -32010, - RPCConnectionPeer = -32011, - RPCConnectionKeepAliveTimeOut = -32012, - RPCConnectionInternal = -32013, - MissingHeader = -32014, - HandlerAborted = -32015, - MissingCaller = -32016, +class ErrorRPCCallerFailed extends ErrorRPC { + static description = 'Failed to call stream'; } abstract class ErrorRPCProtocol extends ErrorRPC { static error = 'RPC Protocol Error'; code: number; - type: string; + + public static fromJSON>(json: any): InstanceType { + if ( + typeof json !== 'object' || + typeof json.code !== 'number' || + typeof json.message !== 'string' || + typeof json.data !== 'object' + ) { + return new ErrorRPCUnknown( + `Cannot decode JSON to ${this.name}`, + ) as InstanceType; + } + + const errorC = rpcProtocolErrors[json.code]; + + if (errorC == null) { + return new ErrorRPCUnknown( + `Unknown error.code found on RPC message`, + ) as InstanceType; + } + + const e: InstanceType = new errorC(json.message); + + e.stack = json.data.stack; + e.data = json.data.data; + e.timestamp = new Date(json.data.timestamp); + + return e; + } + /** + * The return type WILL NOT include cause, this will be handled by `fromError` + * @returns + */ + public toJSON(): JSONRPCError { + return { + code: this.code, + message: this.message, + data: { + timestamp: this.timestamp.toJSON(), + data: this.data, + stack: this.stack, + }, + }; + } } class ErrorRPCParse extends ErrorRPCProtocol { @@ -52,24 +81,16 @@ class ErrorRPCParse extends ErrorRPCProtocol { code = JSONRPCErrorCode.ParseError; } +class ErrorRPCInvalidParams extends ErrorRPCProtocol { + static description = 'Invalid paramaters provided to RPC'; + code = JSONRPCErrorCode.InvalidParams; +} + class ErrorRPCStopping extends ErrorRPCProtocol { static description = 'RPC is stopping'; code = JSONRPCErrorCode.RPCStopping; } -/** - * This is an internal error, it should not reach the top level. - */ -class ErrorRPCHandlerFailed extends ErrorRPCProtocol { - static description = 'Failed to handle stream'; - code = JSONRPCErrorCode.HandlerNotFound; -} - -class ErrorRPCCallerFailed extends ErrorRPCProtocol { - static description = 'Failed to call stream'; - code = JSONRPCErrorCode.MissingCaller; -} - class ErrorMissingCaller extends ErrorRPCProtocol { static description = 'Caller is missing'; code = JSONRPCErrorCode.MissingCaller; @@ -102,61 +123,8 @@ class ErrorRPCOutputStreamError extends ErrorRPCProtocol { class ErrorRPCRemote extends ErrorRPCProtocol { static description = 'Remote error from RPC call'; static message: string = 'The server responded with an error'; - metadata: JSONValue | undefined; - - constructor({ - metadata, - message, - options, - }: { - metadata?: JSONValue; - message?: string; - options?: any; - } = {}) { - super(message, options); - this.metadata = metadata; - this.code = JSONRPCErrorCode.RPCRemote; - this.data = options?.data; - this.type = this.constructor.name; - this.message = message || ErrorRPCRemote.message; - } - - public static fromJSON>( - this: T, - json: any, - ): InstanceType { - if ( - typeof json !== 'object' || - json.type !== this.name || - typeof json.data !== 'object' || - typeof json.data.message !== 'string' || - isNaN(Date.parse(json.data.timestamp)) || - typeof json.data.metadata !== 'object' || - typeof json.data.data !== 'object' || - ('stack' in json.data && typeof json.data.stack !== 'string') - ) { - throw new TypeError(`Cannot decode JSON to ${this.name}`); - } - - // Here, you can define your own metadata object, or just use the one from JSON directly. - const parsedMetadata = json.data.metadata; - - const e = new this(parsedMetadata, json.data.message, { - timestamp: new Date(json.data.timestamp), - data: json.data.data, - cause: json.data.cause, - }); - e.stack = json.data.stack; - return e; - } - public toJSON(): any { - return { - type: this.name, - data: { - description: this.description, - }, - }; - } + metadata: JSONValue = {}; + code = JSONRPCErrorCode.RPCRemote; } class ErrorRPCStreamEnded extends ErrorRPCProtocol { @@ -200,6 +168,55 @@ class ErrorRPCConnectionInternal extends ErrorRPCProtocol { code = JSONRPCErrorCode.RPCConnectionInternal; } +class ErrorRPCUnknown extends ErrorRPCProtocol { + static description = 'RPC Unknown Error'; + code = 0; +} + +const enum JSONRPCErrorCode { + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + HandlerNotFound = -32000, + RPCStopping = -32001, + RPCMessageLength = -32003, + RPCMissingResponse = -32004, + RPCOutputStreamError = -32005, + RPCRemote = -32006, + RPCStreamEnded = -32007, + RPCTimedOut = -32008, + RPCConnectionLocal = -32010, + RPCConnectionPeer = -32011, + RPCConnectionKeepAliveTimeOut = -32012, + RPCConnectionInternal = -32013, + MissingHeader = -32014, + HandlerAborted = -32015, + MissingCaller = -32016, +} + +const rpcProtocolErrors = { + [JSONRPCErrorCode.RPCRemote]: ErrorRPCRemote, + [JSONRPCErrorCode.RPCStopping]: ErrorRPCStopping, + [JSONRPCErrorCode.RPCMessageLength]: ErrorRPCMessageLength, + [JSONRPCErrorCode.ParseError]: ErrorRPCParse, + [JSONRPCErrorCode.InvalidParams]: ErrorRPCInvalidParams, + [JSONRPCErrorCode.HandlerNotFound]: ErrorRPCHandlerFailed, + [JSONRPCErrorCode.RPCMissingResponse]: ErrorRPCMissingResponse, + [JSONRPCErrorCode.RPCOutputStreamError]: ErrorRPCOutputStreamError, + [JSONRPCErrorCode.RPCTimedOut]: ErrorRPCTimedOut, + [JSONRPCErrorCode.RPCStreamEnded]: ErrorRPCStreamEnded, + [JSONRPCErrorCode.RPCConnectionLocal]: ErrorRPCConnectionLocal, + [JSONRPCErrorCode.RPCConnectionPeer]: ErrorRPCConnectionPeer, + [JSONRPCErrorCode.RPCConnectionKeepAliveTimeOut]: + ErrorRPCConnectionKeepAliveTimeOut, + [JSONRPCErrorCode.RPCConnectionInternal]: ErrorRPCConnectionInternal, + [JSONRPCErrorCode.MissingHeader]: ErrorMissingHeader, + [JSONRPCErrorCode.HandlerAborted]: ErrorRPCHandlerFailed, + [JSONRPCErrorCode.MissingCaller]: ErrorMissingCaller, +}; + export { ErrorRPC, ErrorRPCServer, @@ -207,6 +224,7 @@ export { ErrorRPCProtocol, ErrorRPCStopping, ErrorRPCParse, + ErrorRPCInvalidParams, ErrorRPCHandlerFailed, ErrorRPCMessageLength, ErrorRPCMissingResponse, @@ -224,5 +242,7 @@ export { ErrorHandlerAborted, ErrorRPCCallerFailed, ErrorMissingCaller, + ErrorRPCUnknown, JSONRPCErrorCode, + rpcProtocolErrors, }; diff --git a/src/events.ts b/src/events.ts index ceb5abb..565bfc7 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,4 +1,3 @@ -import type RPCServer from './RPCServer'; import type { ErrorRPCConnectionLocal, ErrorRPCConnectionPeer, diff --git a/src/handlers/ClientHandler.ts b/src/handlers/ClientHandler.ts index 5f9d80e..49571a6 100644 --- a/src/handlers/ClientHandler.ts +++ b/src/handlers/ClientHandler.ts @@ -9,10 +9,12 @@ abstract class ClientHandler< Output extends JSONValue = JSONValue, > extends Handler { public async handle( + /* eslint-disable */ input: AsyncIterableIterator, cancel: (reason?: any) => void, meta: Record | undefined, ctx: ContextTimed, + /* eslint-disable */ ): Promise { throw new ErrorRPCMethodNotImplemented(); } diff --git a/src/handlers/DuplexHandler.ts b/src/handlers/DuplexHandler.ts index e392c44..c917601 100644 --- a/src/handlers/DuplexHandler.ts +++ b/src/handlers/DuplexHandler.ts @@ -14,10 +14,12 @@ abstract class DuplexHandler< * `finally` block and check the abort signal for potential errors. */ public async *handle( + /* eslint-disable */ input: AsyncIterableIterator, cancel: (reason?: any) => void, meta: Record | undefined, ctx: ContextTimed, + /* eslint-disable */ ): AsyncIterableIterator { throw new ErrorRPCMethodNotImplemented('This method must be overwrtitten.'); } diff --git a/src/handlers/RawHandler.ts b/src/handlers/RawHandler.ts index 48677ac..c331b73 100644 --- a/src/handlers/RawHandler.ts +++ b/src/handlers/RawHandler.ts @@ -8,10 +8,12 @@ abstract class RawHandler< Container extends ContainerType = ContainerType, > extends Handler { public async handle( + /* eslint-disable */ input: [JSONRPCRequest, ReadableStream], cancel: (reason?: any) => void, meta: Record | undefined, ctx: ContextTimed, + /* eslint-disable */ ): Promise<[JSONValue, ReadableStream]> { throw new ErrorRPCMethodNotImplemented('This method must be overridden'); } diff --git a/src/handlers/ServerHandler.ts b/src/handlers/ServerHandler.ts index deee7ec..5a347d2 100644 --- a/src/handlers/ServerHandler.ts +++ b/src/handlers/ServerHandler.ts @@ -9,10 +9,12 @@ abstract class ServerHandler< Output extends JSONValue = JSONValue, > extends Handler { public async *handle( + /* eslint-disable */ input: Input, cancel: (reason?: any) => void, meta: Record | undefined, ctx: ContextTimed, + /* eslint-disable */ ): AsyncIterableIterator { throw new ErrorRPCMethodNotImplemented('This method must be overridden'); } diff --git a/src/handlers/UnaryHandler.ts b/src/handlers/UnaryHandler.ts index 3a379d7..98fad8d 100644 --- a/src/handlers/UnaryHandler.ts +++ b/src/handlers/UnaryHandler.ts @@ -9,10 +9,12 @@ abstract class UnaryHandler< Output extends JSONValue = JSONValue, > extends Handler { public async handle( + /* eslint-disable */ input: Input, cancel: (reason?: any) => void, meta: Record | undefined, ctx: ContextTimed, + /* eslint-disable */ ): Promise { throw new ErrorRPCMethodNotImplemented('This method must be overridden'); } diff --git a/src/types.ts b/src/types.ts index 7ed1295..cf51d18 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,6 @@ import type { ServerCaller } from './callers'; import type { ClientCaller } from './callers'; import type { UnaryCaller } from './callers'; import type Handler from './handlers/Handler'; -import type { ErrorRPC } from './errors'; /** * This is the type for the IdGenFunction. It is used to generate the request @@ -348,6 +347,10 @@ type HandlerTypes = T extends Handler< } : never; +type FromError = (error: any) => JSONValue; + +type ToError = (errorData: JSONValue) => any; + export type { IdGen, JSONRPCRequestMessage, @@ -372,8 +375,11 @@ export type { ClientManifest, HandlerType, MapCallers, + Opaque, JSONValue, POJO, PromiseDeconstructed, HandlerTypes, + FromError, + ToError, }; diff --git a/src/utils.ts b/src/utils.ts index f3b5b51..9e8e7e6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,28 +16,7 @@ import type { import { TransformStream } from 'stream/web'; import { JSONParser } from '@streamparser/json'; import { AbstractError } from '@matrixai/errors'; -import { JsonableValue } from 'ts-jest'; -import { - ErrorRPCRemote, - ErrorRPC, - ErrorRPCMethodNotImplemented, - ErrorRPCConnectionInternal, - JSONRPCErrorCode, - ErrorRPCStopping, - ErrorRPCMessageLength, - ErrorRPCParse, - ErrorRPCHandlerFailed, - ErrorRPCMissingResponse, - ErrorRPCOutputStreamError, - ErrorRPCTimedOut, - ErrorRPCStreamEnded, - ErrorRPCConnectionLocal, - ErrorRPCConnectionPeer, - ErrorRPCConnectionKeepAliveTimeOut, - ErrorMissingHeader, - ErrorMissingCaller, -} from './errors'; -import * as rpcErrors from './errors'; +import * as errors from './errors'; // Importing PK funcs and utils which are essential for RPC function isObject(o: unknown): o is object { @@ -64,13 +43,13 @@ function parseJSONRPCRequest( message: unknown, ): JSONRPCRequest { if (!isObject(message)) { - throw new rpcErrors.ErrorRPCParse('must be a JSON POJO'); + throw new errors.ErrorRPCParse('must be a JSON POJO'); } if (!('method' in message)) { - throw new rpcErrors.ErrorRPCParse('`method` property must be defined'); + throw new errors.ErrorRPCParse('`method` property must be defined'); } if (typeof message.method !== 'string') { - throw new rpcErrors.ErrorRPCParse('`method` property must be a string'); + throw new errors.ErrorRPCParse('`method` property must be a string'); } // If ('params' in message && !utils.isObject(message.params)) { // throw new rpcErrors.ErrorRPCParse('`params` property must be a POJO'); @@ -83,14 +62,14 @@ function parseJSONRPCRequestMessage( ): JSONRPCRequestMessage { const jsonRequest = parseJSONRPCRequest(message); if (!('id' in jsonRequest)) { - throw new rpcErrors.ErrorRPCParse('`id` property must be defined'); + throw new errors.ErrorRPCParse('`id` property must be defined'); } if ( typeof jsonRequest.id !== 'string' && typeof jsonRequest.id !== 'number' && jsonRequest.id !== null ) { - throw new rpcErrors.ErrorRPCParse( + throw new errors.ErrorRPCParse( '`id` property must be a string, number or null', ); } @@ -102,7 +81,7 @@ function parseJSONRPCRequestNotification( ): JSONRPCRequestNotification { const jsonRequest = parseJSONRPCRequest(message); if ('id' in jsonRequest) { - throw new rpcErrors.ErrorRPCParse('`id` property must not be defined'); + throw new errors.ErrorRPCParse('`id` property must not be defined'); } return jsonRequest as JSONRPCRequestNotification; } @@ -111,26 +90,26 @@ function parseJSONRPCResponseResult( message: unknown, ): JSONRPCResponseResult { if (!isObject(message)) { - throw new rpcErrors.ErrorRPCParse('must be a JSON POJO'); + throw new errors.ErrorRPCParse('must be a JSON POJO'); } if (!('result' in message)) { - throw new rpcErrors.ErrorRPCParse('`result` property must be defined'); + throw new errors.ErrorRPCParse('`result` property must be defined'); } if ('error' in message) { - throw new rpcErrors.ErrorRPCParse('`error` property must not be defined'); + throw new errors.ErrorRPCParse('`error` property must not be defined'); } // If (!utils.isObject(message.result)) { // throw new rpcErrors.ErrorRPCParse('`result` property must be a POJO'); // } if (!('id' in message)) { - throw new rpcErrors.ErrorRPCParse('`id` property must be defined'); + throw new errors.ErrorRPCParse('`id` property must be defined'); } if ( typeof message.id !== 'string' && typeof message.id !== 'number' && message.id !== null ) { - throw new rpcErrors.ErrorRPCParse( + throw new errors.ErrorRPCParse( '`id` property must be a string, number or null', ); } @@ -139,24 +118,24 @@ function parseJSONRPCResponseResult( function parseJSONRPCResponseError(message: unknown): JSONRPCResponseError { if (!isObject(message)) { - throw new rpcErrors.ErrorRPCParse('must be a JSON POJO'); + throw new errors.ErrorRPCParse('must be a JSON POJO'); } if ('result' in message) { - throw new rpcErrors.ErrorRPCParse('`result` property must not be defined'); + throw new errors.ErrorRPCParse('`result` property must not be defined'); } if (!('error' in message)) { - throw new rpcErrors.ErrorRPCParse('`error` property must be defined'); + throw new errors.ErrorRPCParse('`error` property must be defined'); } parseJSONRPCError(message.error); if (!('id' in message)) { - throw new rpcErrors.ErrorRPCParse('`id` property must be defined'); + throw new errors.ErrorRPCParse('`id` property must be defined'); } if ( typeof message.id !== 'string' && typeof message.id !== 'number' && message.id !== null ) { - throw new rpcErrors.ErrorRPCParse( + throw new errors.ErrorRPCParse( '`id` property must be a string, number or null', ); } @@ -165,19 +144,19 @@ function parseJSONRPCResponseError(message: unknown): JSONRPCResponseError { function parseJSONRPCError(message: unknown): JSONRPCError { if (!isObject(message)) { - throw new rpcErrors.ErrorRPCParse('must be a JSON POJO'); + throw new errors.ErrorRPCParse('must be a JSON POJO'); } if (!('code' in message)) { - throw new rpcErrors.ErrorRPCParse('`code` property must be defined'); + throw new errors.ErrorRPCParse('`code` property must be defined'); } if (typeof message.code !== 'number') { - throw new rpcErrors.ErrorRPCParse('`code` property must be a number'); + throw new errors.ErrorRPCParse('`code` property must be a number'); } if (!('message' in message)) { - throw new rpcErrors.ErrorRPCParse('`message` property must be defined'); + throw new errors.ErrorRPCParse('`message` property must be defined'); } if (typeof message.message !== 'string') { - throw new rpcErrors.ErrorRPCParse('`message` property must be a string'); + throw new errors.ErrorRPCParse('`message` property must be a string'); } // If ('data' in message && !utils.isObject(message.data)) { // throw new rpcErrors.ErrorRPCParse('`data` property must be a POJO'); @@ -189,7 +168,7 @@ function parseJSONRPCResponse( message: unknown, ): JSONRPCResponse { if (!isObject(message)) { - throw new rpcErrors.ErrorRPCParse('must be a JSON POJO'); + throw new errors.ErrorRPCParse('must be a JSON POJO'); } try { return parseJSONRPCResponseResult(message); @@ -201,22 +180,20 @@ function parseJSONRPCResponse( } catch (e) { // Do nothing } - throw new rpcErrors.ErrorRPCParse( - 'structure did not match a `JSONRPCResponse`', - ); + throw new errors.ErrorRPCParse('structure did not match a `JSONRPCResponse`'); } function parseJSONRPCMessage( message: unknown, ): JSONRPCMessage { if (!isObject(message)) { - throw new rpcErrors.ErrorRPCParse('must be a JSON POJO'); + throw new errors.ErrorRPCParse('must be a JSON POJO'); } if (!('jsonrpc' in message)) { - throw new rpcErrors.ErrorRPCParse('`jsonrpc` property must be defined'); + throw new errors.ErrorRPCParse('`jsonrpc` property must be defined'); } if (message.jsonrpc !== '2.0') { - throw new rpcErrors.ErrorRPCParse( + throw new errors.ErrorRPCParse( '`jsonrpc` property must be a string of "2.0"', ); } @@ -230,26 +207,57 @@ function parseJSONRPCMessage( } catch { // Do nothing } - throw new rpcErrors.ErrorRPCParse( + throw new errors.ErrorRPCParse( 'Message structure did not match a `JSONRPCMessage`', ); } /** - * Serializes an ErrorRPC instance into a JSONValue object suitable for RPC. - * @param {ErrorRPC} error - The ErrorRPC instance to serialize. - * @param {any} [id] - Optional id for the error object in the RPC response. + * Serializes an Error instance into a JSONValue object suitable for RPC. + * @param {Error} error - The Error instance to serialize. * @returns {JSONValue} The serialized ErrorRPC instance. + * @throws {TypeError} If the error is an instance of {@link Symbol}, {@link BigInt} or {@link Function}. */ -function fromError( - errorin: rpcErrors.ErrorRPCProtocol, - id?: any, -): JSONValue { - const error: { [key: string]: JSONValue } = { - errorCode: errorin.code, - message: errorin.message, - data: errorin.data, - type: errorin.constructor.name, - }; +function fromError(error: any): JSONValue { + // TODO: Linked-List traversal must be done iteractively rather than recusively to prevent stack overflow. + switch (typeof error) { + case 'symbol': + case 'bigint': + case 'function': + throw TypeError(`${error} cannot be serialized`); + } + + if (error instanceof Error) { + const cause = fromError(error.cause); + const timestamp: string = ((error as any).timestamp ?? new Date()).toJSON(); + if (error instanceof AbstractError) { + return error.toJSON(); + } else if (error instanceof AggregateError) { + // AggregateError has an `errors` property + return { + type: error.constructor.name, + message: error.message, + data: { + errors: error.errors.map(fromError), + stack: error.stack, + timestamp, + cause, + }, + }; + } + + // If it's some other type of error then only serialise the message and + // stack (and the type of the error) + return { + type: error.name, + message: error.message, + data: { + stack: error.stack, + timestamp, + cause, + }, + }; + } + return error; } @@ -257,7 +265,9 @@ function fromError( * Error constructors for non-Polykey rpcErrors * Allows these rpcErrors to be reconstructed from RPC metadata */ -const standardErrors = { +const standardErrors: { + [key: string]: typeof Error | typeof AggregateError | typeof AbstractError; +} = { Error, TypeError, SyntaxError, @@ -267,124 +277,112 @@ const standardErrors = { URIError, AggregateError, AbstractError, - ErrorRPCRemote, - ErrorRPC, }; + /** - * Creates a replacer function that omits a specific key during serialization. - * @returns {Function} The replacer function. + * The replacer function to customize the serialization process. */ -const createReplacer = () => { - return (keyToRemove) => { - return (key, value) => { - if (key === keyToRemove) { - return undefined; +const filterSensitive = (keyToRemove) => { + return (key, value) => { + if (key === keyToRemove) { + return undefined; + } + + if (key !== 'code') { + if (value instanceof errors.ErrorRPCProtocol) { + return { + code: value.code, + message: value.message, + data: value.data, + type: value.constructor.name, + }; } - if (key !== 'code') { - if (value instanceof rpcErrors.ErrorRPCProtocol) { - return { - code: value.code, + if (value instanceof AggregateError) { + return { + type: value.constructor.name, + data: { + errors: value.errors, message: value.message, - data: value.data, - type: value.constructor.name, - }; - } - - if (value instanceof AggregateError) { - return { - type: value.constructor.name, - data: { - errors: value.errors, - message: value.message, - stack: value.stack, - }, - }; - } + stack: value.stack, + }, + }; } + } - return value; - }; + return value; }; }; -/** - * The replacer function to customize the serialization process. - */ -const filterSensitive = createReplacer(); -const ErrorCodeToErrorType: { - [code: number]: new (...args: any[]) => ErrorRPC; -} = { - [JSONRPCErrorCode.RPCRemote]: ErrorRPCRemote, - [JSONRPCErrorCode.RPCStopping]: ErrorRPCStopping, - [JSONRPCErrorCode.RPCMessageLength]: ErrorRPCMessageLength, - [JSONRPCErrorCode.ParseError]: ErrorRPCParse, - [JSONRPCErrorCode.InvalidParams]: ErrorRPC, - [JSONRPCErrorCode.HandlerNotFound]: ErrorRPCHandlerFailed, - [JSONRPCErrorCode.RPCMissingResponse]: ErrorRPCMissingResponse, - [JSONRPCErrorCode.RPCOutputStreamError]: ErrorRPCOutputStreamError, - [JSONRPCErrorCode.RPCTimedOut]: ErrorRPCTimedOut, - [JSONRPCErrorCode.RPCStreamEnded]: ErrorRPCStreamEnded, - [JSONRPCErrorCode.RPCConnectionLocal]: ErrorRPCConnectionLocal, - [JSONRPCErrorCode.RPCConnectionPeer]: ErrorRPCConnectionPeer, - [JSONRPCErrorCode.RPCConnectionKeepAliveTimeOut]: - ErrorRPCConnectionKeepAliveTimeOut, - [JSONRPCErrorCode.RPCConnectionInternal]: ErrorRPCConnectionInternal, - [JSONRPCErrorCode.MissingHeader]: ErrorMissingHeader, - [JSONRPCErrorCode.HandlerAborted]: ErrorRPCHandlerFailed, - [JSONRPCErrorCode.MissingCaller]: ErrorMissingCaller, -}; /** * Deserializes an error response object into an ErrorRPCRemote instance. - * @param {any} errorResponse - The error response object. - * @param {any} [metadata] - Optional metadata for the deserialized error. - * @returns {ErrorRPCRemote} The deserialized ErrorRPCRemote instance. - * @throws {TypeError} If the errorResponse object is invalid. + * @param {JSONValue} errorData - The error data object. + * @returns {any} The deserialized error. */ - -function toError(errorData: any, clientMetadata?: any): ErrorRPC { - // Parsing if it's a string - if (typeof errorData === 'string') { +function toError(errorData: JSONValue): any { + // If the value is an error then reconstruct it + if ( + errorData != null && + typeof errorData === 'object' && + 'type' in errorData && + typeof errorData.type === 'string' && + 'data' in errorData && + typeof errorData.data === 'object' + ) { try { - errorData = JSON.parse(errorData); + const eClass = standardErrors[errorData.type]; + if (eClass != null) { + let e: Error; + switch (eClass) { + case AbstractError: + e = eClass.fromJSON(errorData); + break; + case AggregateError: + if ( + errorData.data == null || + !('errors' in errorData.data) || + !Array.isArray(errorData.data.errors) || + typeof errorData.message !== 'string' || + !('stack' in errorData.data) || + typeof errorData.data.stack !== 'string' + ) { + throw new TypeError(`cannot decode JSON to ${errorData.type}`); + } + e = new eClass( + errorData.data.errors.map(toError), + errorData.message, + ); + e.stack = errorData.data.stack; + break; + default: + if ( + errorData.data == null || + typeof errorData.message !== 'string' || + !('stack' in errorData.data) || + typeof errorData.data.stack !== 'string' + ) { + throw new TypeError(`Cannot decode JSON to ${errorData.type}`); + } + e = new (eClass as typeof Error)(errorData.message); + e.stack = errorData.data.stack; + break; + } + if (errorData.data != null && 'cause' in errorData.data) { + e.cause = toError(errorData.data.cause); + } + return e; + } } catch (e) { - throw new ErrorRPCConnectionInternal('Unable to parse string to JSON'); + // If `TypeError` which represents decoding failure + // then return value as-is + // Any other exception is a bug + if (!(e instanceof TypeError)) { + throw e; + } } } - - // Check if errorData is an object and not null - if (typeof errorData !== 'object' || errorData === null) { - throw new ErrorRPCConnectionInternal( - 'errorData should be a non-null object', - ); - } - - // Define default error values, you can modify this as per your needs - let errorCode = -32006; - let message = 'Unknown error'; - let data = {}; - - // Check for errorCode and update if exists - if ('errorCode' in errorData) { - errorCode = errorData.errorCode; - } - - if ('message' in errorData) { - message = errorData.message; - } - - if ('data' in errorData) { - data = errorData.data; - } - - // Map errorCode to a specific Error type - const ErrorType = ErrorCodeToErrorType[errorCode]; - if (!ErrorType) { - throw new ErrorRPC('Unknown Error Code'); // Handle unknown error codes - } - - const error = new ErrorType(message, { data, metadata: clientMetadata }); - return error; + // Other values are returned as-is + return errorData; } /** @@ -422,7 +420,7 @@ function clientInputTransformStream( * @param timer - Timer that gets refreshed each time a message is provided. */ function clientOutputTransformStream( - clientMetadata?: JSONValue, + clientMetadata: JSONValue, timer?: Timer, ): TransformStream, O> { return new TransformStream, O>({ @@ -430,7 +428,18 @@ function clientOutputTransformStream( timer?.refresh(); // `error` indicates it's an error message if ('error' in chunk) { - throw toError(chunk.error, clientMetadata); + const e: errors.ErrorRPCProtocol = + errors.ErrorRPCProtocol.fromJSON(chunk.error); + if ( + e instanceof errors.ErrorRPCRemote && + chunk.error.data != null && + typeof chunk.error.data === 'object' && + 'cause' in chunk.error.data + ) { + e.metadata = clientMetadata; + e.cause = toError(JSON.parse(chunk.error.data.cause as string)); + } + throw e; } controller.enqueue(chunk.result); }, @@ -492,12 +501,12 @@ function parseHeadStream( bytesWritten += chunk.byteLength; parser.write(chunk); } catch (e) { - throw new rpcErrors.ErrorRPCParse(undefined, { + throw new errors.ErrorRPCParse(undefined, { cause: e, }); } if (bytesWritten > bufferByteLimit) { - throw new rpcErrors.ErrorRPCMessageLength(); + throw new errors.ErrorRPCMessageLength(); } } else { // Wait for parser to end @@ -516,7 +525,7 @@ function parseHeadStream( } function never(): never { - throw new ErrorRPC('This function should never be called'); + throw new errors.ErrorRPC('This function should never be called'); } export { diff --git a/tests/RPC.test.ts b/tests/RPC.test.ts index 9cee06a..ab6f8a0 100644 --- a/tests/RPC.test.ts +++ b/tests/RPC.test.ts @@ -187,7 +187,7 @@ describe('RPC', () => { rpcClient.methods.testMethod({ hello: 'world', }), - ).rejects.toThrow(rpcErrors.ErrorRPCRemote); + ).rejects.toHaveProperty('message', 'some error'); await rpcServer.stop({ force: true }); }); @@ -467,69 +467,13 @@ describe('RPC', () => { const rejection = await callProm; // The error should have specific properties - expect(rejection).toBeInstanceOf(rpcErrors.ErrorRPCRemote); - expect(rejection).toMatchObject({ code: -32006 }); + expect(rejection).toBeInstanceOf(error.constructor); + expect(rejection).toEqual(error); // Cleanup await rpcServer.stop({ force: true }); }, ); - - testProp( - 'RPC handles and sends sensitive errors', - [ - rpcTestUtils.safeJsonValueArb, - rpcTestUtils.errorArb(rpcTestUtils.errorArb()), - ], - async (value, error) => { - const { clientPair, serverPair } = rpcTestUtils.createTapPairs< - Uint8Array, - Uint8Array - >(); - - class TestMethod extends UnaryHandler { - public handle = async ( - _input: JSONValue, - _cancel: (reason?: any) => void, - _meta: Record | undefined, - _ctx: ContextTimed, - ): Promise => { - throw error; - }; - } - - const rpcServer = new RPCServer({ - logger, - idGen, - }); - await rpcServer.start({ - manifest: { - testMethod: new TestMethod({}), - }, - }); - rpcServer.handleStream({ ...serverPair, cancel: () => {} }); - - const rpcClient = new RPCClient({ - manifest: { - testMethod: new UnaryCaller(), - }, - streamFactory: async () => { - return { ...clientPair, cancel: () => {} }; - }, - logger, - idGen, - }); - - const callProm = rpcClient.methods.testMethod(ErrorRPCRemote.description); - - // Use Jest's `.rejects` to handle the promise rejection - await expect(callProm).rejects.toBeInstanceOf(rpcErrors.ErrorRPCRemote); - await expect(callProm).rejects.not.toHaveProperty('cause.stack'); - - await rpcServer.stop({ force: true }); - }, - ); - test('middleware can end stream early', async () => { const { clientPair, serverPair } = rpcTestUtils.createTapPairs< Uint8Array, @@ -928,12 +872,9 @@ describe('RPC', () => { ); testProp( - 'RPC Serializes and Deserializes ErrorRPCRemote', - [ - rpcTestUtils.safeJsonValueArb, - rpcTestUtils.errorArb(rpcTestUtils.errorArb()), - ], - async (value, error) => { + 'RPC Serializes and Deserializes Error', + [rpcTestUtils.errorArb(rpcTestUtils.errorArb())], + async (error) => { const { clientPair, serverPair } = rpcTestUtils.createTapPairs< Uint8Array, Uint8Array @@ -971,34 +912,18 @@ describe('RPC', () => { idGen, }); - const errorInstance = new ErrorRPCRemote({ - metadata: -123123, - message: 'parse error', - options: { cause: 'Random cause' }, - }); - - const serializedError = fromError(errorInstance); - const callProm = rpcClient.methods.testMethod(serializedError); - await expect(callProm).rejects.toThrow(rpcErrors.ErrorRPCRemote); - - const deserializedError = toError(serializedError); + const callProm = rpcClient.methods.testMethod({}); + const callError = await callProm.catch((e) => e); - expect(deserializedError).toBeInstanceOf(ErrorRPCRemote); - - // Check properties explicitly - const { code, message, data } = deserializedError as ErrorRPCRemote; - expect(code).toBe(-32006); + expect(callError).toEqual(error); await rpcServer.stop({ force: true }); }, ); testProp( - 'RPC Serializes and Deserializes ErrorRPCRemote with Custom Replacer Function', - [ - rpcTestUtils.safeJsonValueArb, - rpcTestUtils.errorArb(rpcTestUtils.errorArb()), - ], - async (value, error) => { + 'RPC Serializes and Deserializes Error with Custom Replacer Function', + [rpcTestUtils.errorArb(rpcTestUtils.errorArb())], + async (error) => { const { clientPair, serverPair } = rpcTestUtils.createTapPairs< Uint8Array, Uint8Array @@ -1017,6 +942,7 @@ describe('RPC', () => { const rpcServer = new RPCServer({ logger, idGen, + replacer: filterSensitive('stack'), }); await rpcServer.start({ manifest: { @@ -1036,30 +962,10 @@ describe('RPC', () => { idGen, }); - const errorInstance = new ErrorRPCRemote({ - metadata: -32006, - message: '', - options: { - cause: error, - data: 'asda', - }, - }); - - const serializedError = JSON.parse( - JSON.stringify(fromError(errorInstance), filterSensitive('data')), - ); - - const callProm = rpcClient.methods.testMethod(serializedError); - const catchError = await callProm.catch((e) => e); + const callProm = rpcClient.methods.testMethod({}); + const callError = await callProm.catch((e) => e); - const deserializedError = toError(serializedError); - - expect(deserializedError).toBeInstanceOf(ErrorRPCRemote); - - // Check properties explicitly - const { code, message, data } = deserializedError as ErrorRPCRemote; - expect(code).toBe(-32006); - expect(data).toBe(undefined); + expect(callError).toEqual(error); await rpcServer.stop({ force: true }); }, @@ -1070,7 +976,9 @@ describe('RPC', () => { Uint8Array >(); - const testReason = Error('test error'); + const errorMessage = 'test error'; + + const testReason = Error(errorMessage); class TestMethod extends UnaryHandler { public handle = async ( @@ -1114,7 +1022,9 @@ describe('RPC', () => { const testProm = rpcClient.methods.testMethod({}); await rpcServer.stop({ force: true, reason: testReason }); - - await expect(testProm).toReject(); + const rejection = await testProm.catch((e) => e); + expect(rejection).toBeInstanceOf(ErrorRPCRemote); + expect(rejection.cause).toBeInstanceOf(Error); + expect(rejection.cause.message).toBe(errorMessage); }); }); diff --git a/tests/RPCClient.test.ts b/tests/RPCClient.test.ts index c55c33f..d518616 100644 --- a/tests/RPCClient.test.ts +++ b/tests/RPCClient.test.ts @@ -295,7 +295,7 @@ describe(`${RPCClient.name}`, () => { 'generic duplex caller can throw received error message with sensitive', [ fc.array(rpcTestUtils.jsonRpcResponseResultArb()), - rpcTestUtils.jsonRpcResponseErrorArb(rpcTestUtils.errorArb(), true), + rpcTestUtils.jsonRpcResponseErrorArb(rpcTestUtils.errorArb()), ], async (messages, errorMessage) => { const inputStream = rpcTestUtils.messagesToReadableStream([ @@ -336,7 +336,6 @@ describe(`${RPCClient.name}`, () => { fc.array(rpcTestUtils.jsonRpcResponseResultArb()), rpcTestUtils.jsonRpcResponseErrorArb( rpcTestUtils.errorArb(rpcTestUtils.errorArb()), - true, ), ], async (messages, errorMessage) => { diff --git a/tests/RPCServer.test.ts b/tests/RPCServer.test.ts index ecb6c97..7a24c16 100644 --- a/tests/RPCServer.test.ts +++ b/tests/RPCServer.test.ts @@ -465,7 +465,7 @@ describe(`${RPCServer.name}`, () => { rpcServer.handleStream(readWriteStream); const rawErrorMessage = (await outputResult)[0]!.toString(); const errorMessage = JSON.parse(rawErrorMessage); - expect(errorMessage.error.message).toEqual(error.description); + expect(errorMessage.error.message).toEqual(error.message); reject(); await expect(errorProm).toReject(); await rpcServer.stop({ force: true }); @@ -508,7 +508,7 @@ describe(`${RPCServer.name}`, () => { rpcServer.handleStream(readWriteStream); const rawErrorMessage = (await outputResult)[0]!.toString(); const errorMessage = JSON.parse(rawErrorMessage); - expect(errorMessage.error.message).toEqual(error.description); + expect(errorMessage.error.message).toEqual(error.message); reject(); await expect(errorProm).toReject(); await rpcServer.stop({ force: true }); @@ -557,7 +557,7 @@ describe(`${RPCServer.name}`, () => { await writer.write(Buffer.from(JSON.stringify(message))); } // Abort stream - const writerReason = Symbol('writerAbort'); + const writerReason = new Error('writerAbort'); await writer.abort(writerReason); // We should get an error RPC message await expect(outputResult).toResolve(); diff --git a/tests/utils.ts b/tests/utils.ts index b10c37d..7f2b3cb 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -12,6 +12,7 @@ import type { } from '@/types'; import { ReadableStream, WritableStream, TransformStream } from 'stream/web'; import { fc } from '@fast-check/jest'; +import { AbstractError } from '@matrixai/errors'; import * as utils from '@/utils'; import { fromError } from '@/utils'; import * as rpcErrors from '@/errors'; @@ -148,20 +149,19 @@ const jsonRpcErrorArb = ( fc .record( { - code: fc.integer(), + code: fc.constant(rpcErrors.JSONRPCErrorCode.RPCRemote), message: fc.string(), - data: error.map((e) => JSON.stringify(fromError(e))), + data: fc.record({ + cause: error.map((e) => JSON.stringify(fromError(e))), + }), }, { - requiredKeys: ['code', 'message'], + requiredKeys: ['code', 'message', 'data'], }, ) .noShrink() as fc.Arbitrary; -const jsonRpcResponseErrorArb = ( - error?: fc.Arbitrary>, - sensitive: boolean = false, -) => +const jsonRpcResponseErrorArb = (error?: fc.Arbitrary>) => fc .record({ jsonrpc: fc.constant('2.0'), @@ -260,20 +260,16 @@ const errorArb = ( ) => cause.chain((cause) => fc.oneof( - fc.constant(new rpcErrors.ErrorRPCRemote()), fc.constant(new rpcErrors.ErrorRPCMessageLength(undefined)), fc.constant( - new rpcErrors.ErrorRPCRemote( - { + new AbstractError('message', { + cause, + data: { command: 'someCommand', host: `someHost`, port: 0, }, - undefined, - { - cause, - }, - ), + }), ), ), );