@akala/json-rpc-ws
Version:
json-rpc websocket transport
447 lines (399 loc) • 15.6 kB
text/typescript
import { default as Errors, type Error as ConnectionError, type ErrorTypes } from './errors.js';
import debug from 'debug';
import type { EventListener, SerializableObject, SocketAdapter, SocketAdapterAkalaEventMap, SocketAdapterEventMap, Subscription } from '@akala/core';
import { IsomorphicBuffer, SocketProtocolAdapter } from '@akala/core';
const logger = debug('akala:json-rpc-ws');
export type PayloadDataType<T> = number | SerializableObject | SerializableObject[] | boolean | boolean[] | number[] | string | string[] | null | undefined | void | { event: string, isBuffer: boolean, data: string | SerializedBuffer } | T;
export type SerializedBuffer = { type: 'Buffer', data: Uint8Array | number[] };
export type Payload<T> = SerializablePayload | StreamPayload<T>;
export class JsonRpcSocketAdapter<TStreamable> extends SocketProtocolAdapter<Payload<TStreamable> | Payload<TStreamable>[]> implements SocketAdapter<Payload<TStreamable>>
{
constructor(socket: SocketAdapter)
{
super({
receive(data, self)
{
logger('message %j', data);
let payload: Payload<TStreamable>;
if (typeof (data) !== 'string')
{
if (data instanceof IsomorphicBuffer)
payload = jsonParse(data.toString('utf8'));
}
else if (typeof (data) == 'string')
{
payload = jsonParse(data);
}
if (!payload)
{
console.error(data);
socket.send(self.transform.send(Errors('parseError', -1), self));
}
return payload;
},
send(payload)
{
return JSON.stringify(payload);
}
}, socket);
}
}
interface CommonPayload
{
jsonrpc?: '2.0';
id?: string | number;
method?: string;
error?: ConnectionError;
}
export interface SerializablePayload extends CommonPayload
{
params?: PayloadDataType<void>;
result?: PayloadDataType<void>;
stream?: false;
}
export interface StreamPayload<T> extends CommonPayload
{
params?: T;
result?: PayloadDataType<T>;
stream?: true;
}
export type Handler<TConnection extends Connection<TStreamable>, TStreamable, ParamType extends PayloadDataType<TStreamable>, ParamCallbackType extends PayloadDataType<TStreamable>> = (this: TConnection, params: ParamType, reply: ReplyCallback<ParamCallbackType>) => void;
export type ReplyCallback<ParamType> = (error: ConnectionError, params?: ParamType) => void;
/**
* Quarantined JSON.parse try/catch block in its own function
*
* @param {String} data - json data to be parsed
* @returns {Object} Parsed json data
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const jsonParse = function jsonParse(data: string): any
{
let payload;
try
{
payload = JSON.parse(data);
}
catch (error)
{
logger(error);
payload = null;
}
return payload;
};
/**
* JSON spec requires a reply for every request, but our lib doesn't require a
* callback for every sendMethod. We need a dummy callback to throw into responseHandlers
* for when the user doesn't supply callback to sendMethod
*/
const emptyCallback = function emptyCallback()
{
logger('emptycallback');
};
export interface Parent<TStreamable, TConnection extends Connection<TStreamable>>
{
type: string;
getHandler: (method: string) => Handler<TConnection, TStreamable, PayloadDataType<TStreamable>, PayloadDataType<TStreamable>>;
disconnected: (connection: TConnection) => void
}
/**
* json-rpc-ws connection
*
* @constructor
* @param {SocketAdapter} socket - socket adapter for this connection
* @param {Object} parent - parent that controls this connection
*/
export abstract class Connection<TStreamable>
{
sub: Subscription;
/**
*
*/
constructor(public readonly socket: SocketAdapter<Payload<TStreamable>>, public readonly parent: Parent<TStreamable, Connection<TStreamable>>)
{
if (!this.socket.send)
throw new Error('socket.send is not defined');
logger('new Connection to %s', parent.type);
this.sub = socket.on('message', this.message.bind(this));
// this.on('message', this.message.bind(this));
this.once('close', this.close.bind(this));
this.once('error', this.close.bind(this));
// if (isBrowserSocket(parent, socket))
// {
// socket.addEventListener('close', socketClosed.bind(this), { once: true });
// socket.addEventListener('error', socketError.bind(this), { once: true });
// }
// else
// {
// socket.once('close', this.close.bind(this));
// socket.once('error', this.close.bind(this));
// }
}
public on<K extends keyof SocketAdapterEventMap>(event: K, handler: EventListener<SocketAdapterAkalaEventMap<Payload<TStreamable>>[K]>): Subscription
{
return this.socket.on(event, handler);
}
public once<K extends keyof SocketAdapterEventMap>(event: K, handler: EventListener<SocketAdapterAkalaEventMap<Payload<TStreamable>>[K]>): Subscription
{
return this.socket.once(event, handler);
}
public readonly id = crypto.randomUUID();
protected readonly responseHandlers: { [messageId: string]: ReplyCallback<unknown> } = {};
/**
* Send json payload to the socket connection
*
* @param {Object} payload - data to be stringified
* @private
* @todo validate payload
* @todo make sure this.connection exists, is connected
* @todo if we're not connected look up the response handler from payload.id
*/
public sendRaw(payload: Payload<TStreamable>): void
{
payload.jsonrpc = '2.0';
this.socket.send(payload);
}
/**
* Validate payload as valid jsonrpc 2.0
* http://www.jsonrpc.org/specification
* Reply or delegate as needed
*
* @param {Object} payload - payload coming in to be validated
* @returns {void}
*/
public processPayload(payload: Payload<TStreamable>): void
{
const version = payload.jsonrpc;
const id = payload.id;
const method = payload.method;
let params = payload.params;
let result = payload.result;
if (typeof payload.error == 'object' && payload.error && 'stack' in payload.error && 'message' in payload.error)
{
const error = new Error();
payload.error = Object.assign(error, { stack: error.stack, message: error.message, name: error.name }, payload.error);
}
const error = payload.error;
if (version !== '2.0')
{
return this.sendError('invalidRequest', id, { info: 'jsonrpc must be exactly "2.0"' });
}
//Will either have a method (request), or result or error (response)
if (typeof method === 'string')
{
const handler = this.parent.getHandler(method);
if (!handler)
{
return this.sendError('methodNotFound', id, { info: 'no handler found for method ' + method });
}
if (id !== undefined && id !== null && typeof id !== 'string' && typeof id !== 'number')
{
return this.sendError('invalidRequest', id, { info: 'id, if provided, must be one of: null, string, number' });
}
if (params !== undefined && params !== null && typeof params !== 'object')
{
return this.sendError('invalidRequest', id, { info: 'params, if provided, must be one of: null, object, array' });
}
logger('message method %s', payload.method);
if (id === null || typeof id === 'undefined')
{
return handler.call(this, params, emptyCallback);
}
const handlerCallback = function handlerCallback(this: Connection<TStreamable>, err: ConnectionError, reply: PayloadDataType<TStreamable>)
{
logger('handler got callback %j, %j', err, reply);
if (typeof this.socket != 'undefined')
this.sendResult(id, err, reply);
else
console.error('no socket to reply to');
};
if (payload.stream)
params = this.buildStream(id, params);
return handler.call(this, params, handlerCallback.bind(this));
}
// needs a result or error at this point
if (result === undefined && error === undefined)
{
return this.sendError('invalidRequest', id, { info: 'replies must have either a result or error' });
}
if (typeof id === 'string' || typeof id === 'number')
{
logger('message id %s result %j error %j', id, result, error);
const responseHandler = this.responseHandlers[id];
if (!responseHandler)
{
return this.sendError('invalidRequest', id, { info: 'no response handler for id ' + id });
}
delete this.responseHandlers[id];
if (payload.stream)
{
result = this.buildStream(id, result as TStreamable);
}
return responseHandler.call(this, error, result);
}
}
protected abstract buildStream(id: string | number, result: PayloadDataType<TStreamable>): TStreamable;
protected abstract sendStream(id: string | number, result: TStreamable): Promise<void> | void;
protected abstract isStream(result: PayloadDataType<TStreamable>): result is TStreamable;
/**
* Send a result message
*
* @param {String} id - id for the message
* @param {Object} error - error for the message
* @param {String|Object|Array|Number} result - result for the message
* @public
*
*/
public sendResult(id: string | number | undefined, error: ConnectionError | undefined, result?: PayloadDataType<TStreamable>, isStream?: boolean): void
{
logger('sendResult %s %s %j %j', id, isStream, error, result);
// Assert(id, 'Must have an id.');
// Assert(error || result, 'Must have an error or a result.');
if (error && result)
throw new Error('Cannot have both an error and a result');
const response: Payload<TStreamable> = { id: id, stream: typeof isStream === 'undefined' ? undefined : !!isStream || this.isStream(result) };
if (result)
{
let cleanResult: PayloadDataType<TStreamable>;
if (typeof result == 'object' && !Array.isArray(result))
{
cleanResult = {};
Object.getOwnPropertyNames(result).forEach(p =>
{
if (p[0] != '_')
Object.defineProperty(cleanResult, p, Object.getOwnPropertyDescriptor(result, p) as PropertyDescriptor)
});
}
else
cleanResult = result;
response.result = cleanResult;
if (response.stream)
{
if (typeof id == 'undefined')
throw new Error('streams are not supported without an id');
logger('result is stream');
this.sendStream(id, result as TStreamable);
}
}
else
{
if (error instanceof Error)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error = Object.fromEntries([...Object.entries(error), ['message', error.message], ['stack', error.stack]]) as any;
response.error = error;
}
this.sendRaw(response);
}
/**
* Send a method message
*
* @param {String} method - method for the message
* @param {Array|Object|null} params - params for the message
* @param {function} callback - optional callback for a reply from the message
* @public
*/
public sendMethod<TParamType extends PayloadDataType<TStreamable>, TReplyType extends PayloadDataType<TStreamable>>(method: string, params?: TParamType, callback?: ReplyCallback<TReplyType>): void
{
const id = crypto.randomUUID();
if (typeof method !== 'string' || !method.length)
throw new Error('method must be a non-empty string');
if (params !== null && params !== undefined && !(params instanceof Object))
throw new Error('params, if provided, must be an array, object or null');
logger('sendMethod %s', method, id);
if (callback)
{
this.responseHandlers[id] = callback;
}
else
{
this.responseHandlers[id] = emptyCallback;
}
const request: Payload<TStreamable> = {
id: id,
method: method
};
if (params)
{
if (this.isStream(params))
{
request.stream = true;
this.sendStream(id, params)
}
request.params = params;
}
this.sendRaw(request);
}
/**
* Send an error message
*
* @param {Object} error - json-rpc error object (See Connection.errors)
* @param {String|Number|null} id - Optional id for reply
* @param {Any} data - Optional value for data portion of reply
* @public
*/
public sendError(error: ErrorTypes, id: number | string | undefined, data?: SerializableObject): void
{
logger('sendError %s', error);
//TODO if id matches a responseHandler, we should dump it right?
this.sendRaw(Errors(error, id, data));
}
/**
* Called when socket gets 'close' event
*
* @param {ConnectionError} error - optional error object of close wasn't expected
* @private
*/
public close(error?: ConnectionError | 1000 | Error | Event): void
{
logger('close');
if (error && error !== 1000)
{
// debugger;
logger('close error %s', error['stack'] || error);
}
this.sub?.();
this.parent.disconnected(this); //Tell parent what went on so it can track connections
// delete this.socket;
}
/**
* Hang up the current socket
*/
public hangup(): Promise<CloseEvent>
{
logger('hangup');
if (!this.socket)
throw new Error('Not connected');
return new Promise<CloseEvent>((resolve, reject) =>
{
const socket = this.socket;
socket.once('error', reject);
socket.once('close', resolve);
this.socket.close();
});
}
/**
* Incoming message handler
*
* @param {String} data - message from the websocket
* @returns {void}
* @private
*/
private message(payload: Payload<TStreamable> | Payload<TStreamable>[]): Payload<TStreamable> | void
{
//Validate as json first, easy reply if it's not
//If it's an array iterate and handle
//If it's an object handle
//name of handle function ?!?!?
if (!payload)
return;
//Object or array
if (payload instanceof Array)
{
for (const innerPayload of payload)
this.processPayload(innerPayload);
}
else
{
this.processPayload(payload);
}
}
}