Skip to content
50 changes: 36 additions & 14 deletions src/jrpc/errors/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hasProperty, isObject } from "../../utils";
import { JRPCError, Json } from "../interfaces";
import { errorCodes, errorValues } from "./error-constants";

Expand All @@ -9,6 +10,19 @@

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.
Expand All @@ -24,17 +38,6 @@
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<PropertyKey, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

/**
* Check if the value is plain object.
*
Expand Down Expand Up @@ -176,14 +179,27 @@
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`
*
* @param error - The error in question.
* @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();
Expand All @@ -193,10 +209,13 @@
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 },
};

Expand All @@ -210,18 +229,21 @@
*
* @param error - The error to serialize.
* @param options - Options bag.
* @param options.fallbackError - The error to return if the given error is

Check warning on line 232 in src/jrpc/errors/utils.ts

View workflow job for this annotation

GitHub Actions / test (22.x, ubuntu-latest)

tsdoc-param-tag-with-invalid-name: The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
* not compatible. Should be a JSON serializable value.
* @param options.shouldIncludeStack - Whether to include the error's stack

Check warning on line 234 in src/jrpc/errors/utils.ts

View workflow job for this annotation

GitHub Actions / test (22.x, ubuntu-latest)

tsdoc-param-tag-with-invalid-name: The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
* 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;
Expand Down
31 changes: 25 additions & 6 deletions src/jrpc/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,32 @@ export interface JRPCBase {
id?: JRPCId;
}

// `unknown` is added for the backward compatibility.
// TODO: remove `unknown` after the backward compatibility is no longer needed.
export type JRPCParams = Json[] | Record<string, Json> | unknown;

export interface JRPCResponse<T> extends JRPCBase {
result?: T;
error?: any;
}

export interface JRPCRequest<T> extends JRPCBase {
export interface JRPCNotification<Params extends JRPCParams = JRPCParams> {
jsonrpc?: JRPCVersion;
method: string;
params?: T;
params?: Params;
}

export interface JRPCRequest<Params extends JRPCParams = JRPCParams> extends JRPCBase {
method: string;
params?: Params;
}

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<unknown>;
req: JRPCRequest<JRPCParams>;
res: JRPCResponse<unknown>;
next: JRPCEngineNextCallback;
end: JRPCEngineEndCallback;
Expand All @@ -35,7 +45,12 @@ export interface IdMap {
[requestId: string]: IdMapValue;
}

export type JRPCMiddleware<T, U> = (req: JRPCRequest<T>, res: JRPCResponse<U>, next: JRPCEngineNextCallback, end: JRPCEngineEndCallback) => void;
export type JRPCMiddleware<T extends JRPCParams = JRPCParams, U = unknown> = (
req: JRPCRequest<T>,
res: JRPCResponse<U>,
next: JRPCEngineNextCallback,
end: JRPCEngineEndCallback
) => void;

export type AsyncJRPCEngineNextCallback = () => Promise<void>;

Expand Down Expand Up @@ -86,7 +101,11 @@ export interface JRPCFailure extends JRPCBase {
error: JRPCError;
}

export type AsyncJRPCMiddleware<T, U> = (req: JRPCRequest<T>, res: PendingJRPCResponse<U>, next: AsyncJRPCEngineNextCallback) => Promise<void>;
export type AsyncJRPCMiddleware<T extends JRPCParams, U> = (
req: JRPCRequest<T>,
res: PendingJRPCResponse<U>,
next: AsyncJRPCEngineNextCallback
) => Promise<void>;

export type ReturnHandlerCallback = (error: null | Error) => void;

Expand All @@ -107,6 +126,6 @@ export interface RequestArguments<T> {
params?: T;
}

export interface ExtendedJsonRpcRequest<T> extends JRPCRequest<T> {
export interface ExtendedJsonRpcRequest<T extends JRPCParams> extends JRPCRequest<T> {
skipCache?: boolean;
}
51 changes: 34 additions & 17 deletions src/jrpc/jrpc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { Duplex } from "readable-stream";

import { AsyncJRPCMiddleware, ConsoleLike, IdMap, JRPCMiddleware, JRPCRequest, JRPCResponse, Json, ReturnHandlerCallback } from "./interfaces";
import { isJRPCNotification, isValidMethod } from "../utils/jrpc";
import { errorCodes } from "./errors";
import {
AsyncJRPCMiddleware,
ConsoleLike,
IdMap,
JRPCMiddleware,
JRPCNotification,
JRPCParams,
JRPCRequest,
JRPCResponse,
Json,
ReturnHandlerCallback,
} from "./interfaces";
import { SafeEventEmitter } from "./safeEventEmitter";
import { SerializableError } from "./serializableError";

Expand All @@ -16,12 +29,12 @@ export const getRpcPromiseCallback =
}
};

export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware<unknown, unknown> {
export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware<JRPCParams, unknown> {
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) {
res.error = new SerializableError({ code: -32603, message: "invalid method" });
if (!isValidMethod(req)) {
res.error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: "invalid method" });
end();
return;
}
Expand All @@ -35,17 +48,21 @@ export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware<unknown,
});
} catch (error: unknown) {
log.error(`Auth - RPC Error thrown: ${(error as Error).message}`, error);
res.error = new SerializableError({ code: -32603, message: (error as Error).message });
res.error = new SerializableError({ code: errorCodes.rpc.internal, message: (error as Error).message });
end();
}
};
}

export type StreamEvents = {
notification: (arg1: JRPCRequest<unknown>) => boolean;
notification: (arg1: JRPCNotification) => boolean;
};

export function createStreamMiddleware(): { events: SafeEventEmitter<StreamEvents>; middleware: JRPCMiddleware<unknown, unknown>; stream: Duplex } {
export function createStreamMiddleware(): {
events: SafeEventEmitter<StreamEvents>;
middleware: JRPCMiddleware<JRPCParams, unknown>;
stream: Duplex;
} {
const idMap: IdMap = {};

function readNoop() {
Expand All @@ -68,16 +85,16 @@ export function createStreamMiddleware(): { events: SafeEventEmitter<StreamEvent
setTimeout(context.end);
}

function processNotification(res: JRPCRequest<unknown>) {
function processNotification(res: JRPCNotification<JRPCParams>) {
events.emit("notification", res);
}

function processMessage(res: JRPCResponse<unknown>, _encoding: unknown, cb: (error?: Error | null) => void) {
let err: Error;
try {
const isNotification = !res.id;
const isNotification = isJRPCNotification(res as JRPCNotification<JRPCParams> | JRPCRequest<JRPCParams>);
if (isNotification) {
processNotification(res as unknown as JRPCRequest<unknown>);
processNotification(res as JRPCNotification<JRPCParams>);
} else {
processResponse(res);
}
Expand All @@ -94,7 +111,7 @@ export function createStreamMiddleware(): { events: SafeEventEmitter<StreamEvent
write: processMessage,
});

const middleware: JRPCMiddleware<unknown, unknown> = (req, res, next, end) => {
const middleware: JRPCMiddleware<JRPCParams, unknown> = (req, res, next, end) => {
// write req to stream
stream.push(req);
// register request on id map
Expand All @@ -104,11 +121,11 @@ export function createStreamMiddleware(): { events: SafeEventEmitter<StreamEvent
return { events, middleware, stream };
}

export type ScaffoldMiddlewareHandler<T, U> = JRPCMiddleware<T, U> | Json;
export type ScaffoldMiddlewareHandler<T extends JRPCParams, U> = JRPCMiddleware<T, U> | Json;

export function createScaffoldMiddleware(handlers: {
[methodName: string]: ScaffoldMiddlewareHandler<unknown, unknown>;
}): JRPCMiddleware<unknown, unknown> {
[methodName: string]: ScaffoldMiddlewareHandler<JRPCParams, unknown>;
}): JRPCMiddleware<JRPCParams, unknown> {
return (req, res, next, end) => {
const handler = handlers[req.method];
// if no handler, return
Expand All @@ -125,7 +142,7 @@ export function createScaffoldMiddleware(handlers: {
};
}

export function createIdRemapMiddleware(): JRPCMiddleware<unknown, unknown> {
export function createIdRemapMiddleware(): JRPCMiddleware<JRPCParams, unknown> {
return (req, res, next, _end) => {
const originalId = req.id;
const newId = Math.random().toString(36).slice(2);
Expand All @@ -139,14 +156,14 @@ export function createIdRemapMiddleware(): JRPCMiddleware<unknown, unknown> {
};
}

export function createLoggerMiddleware(logger: ConsoleLike): JRPCMiddleware<unknown, unknown> {
export function createLoggerMiddleware(logger: ConsoleLike): JRPCMiddleware<JRPCParams, unknown> {
return (req, res, next, _) => {
logger.debug("REQ", req, "RES", res);
next();
};
}

export function createAsyncMiddleware<T, U>(asyncMiddleware: AsyncJRPCMiddleware<T, U>): JRPCMiddleware<T, U> {
export function createAsyncMiddleware<T extends JRPCParams, U>(asyncMiddleware: AsyncJRPCMiddleware<T, U>): JRPCMiddleware<T, U> {
return async (req, res, next, end) => {
// nextPromise is the key to the implementation
// it is resolved by the return handler passed to the
Expand Down
Loading
Loading