chore: add full source code
This commit is contained in:
106
src/core/RPCClient.ts
Normal file
106
src/core/RPCClient.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { isHandshakeAccepted, makeHandshakePacket } from "./RPCCommon";
|
||||
import { RPCConnection } from "./RPCConnection";
|
||||
import { RPCHandler } from "./RPCHandler";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
import { RPCSession } from "./RPCSession";
|
||||
import { createSocketClient } from "./SocketClient";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export class RPCClient {
|
||||
|
||||
constructor(private rpcHandler: RPCHandler) { }
|
||||
|
||||
public async connect(options: {
|
||||
url: string;
|
||||
accessKey?: string;
|
||||
timeout: number;
|
||||
}): Promise<RPCSession> {
|
||||
// make socket connection
|
||||
const socket = createSocketClient();
|
||||
|
||||
// handshake by socket
|
||||
const thisAccessKey = this.rpcHandler.getAccessKey();
|
||||
/** set 'null' is to make sure all property will not be remove in network transmission */
|
||||
const handshakePacket = makeHandshakePacket({
|
||||
state: 0,
|
||||
thisAccessKey: thisAccessKey || null,
|
||||
accessKey: options.accessKey || null,
|
||||
});
|
||||
|
||||
/** send handshake request */
|
||||
const finalClearFns: (() => any | Promise<any>)[] = [];
|
||||
async function finalClear() {
|
||||
for (const fn of finalClearFns) {
|
||||
try { await fn() } catch { }
|
||||
}
|
||||
}
|
||||
let connection: SocketConnection | undefined;
|
||||
|
||||
// task1: timeout
|
||||
let isTimeouted = false;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
let t = setTimeout(async () => {
|
||||
if (isRequestFinished) {
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Connect timeout'));
|
||||
}, options.timeout);
|
||||
|
||||
finalClearFns.push(() => {
|
||||
clearTimeout(t);
|
||||
})
|
||||
})
|
||||
|
||||
// task2: send and wait for response
|
||||
let isRequestFinished = false;
|
||||
let requestPromise = new Promise<RPCSession>(async (resolve, reject) => {
|
||||
/** timeout, but connection is still keep */
|
||||
finalClearFns.push(() => {
|
||||
if (isTimeouted && connection) {
|
||||
connection.close().catch(e => { })
|
||||
}
|
||||
})
|
||||
|
||||
/** clear listener of waiting for response */
|
||||
finalClearFns.push(() => {
|
||||
if (connection) {
|
||||
connection.off('msg', handleListenHandshakeReply);
|
||||
}
|
||||
})
|
||||
|
||||
connection = await socket.connect(options.url);
|
||||
if (isTimeouted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleListenHandshakeReply = (msg: unknown) => {
|
||||
const packet = RPCPacket.Parse(msg, true);
|
||||
if (packet === null) {
|
||||
reject(new Error('Connect occured an unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHandshakeAccepted(packet)) {
|
||||
resolve(new RPCSession(
|
||||
new RPCConnection(connection!),
|
||||
this.rpcHandler,
|
||||
));
|
||||
} else {
|
||||
reject(new Error('Server rejected handshake request'));
|
||||
}
|
||||
}
|
||||
/** listen msg from server, and make sure handshake status */
|
||||
connection.on('msg', handleListenHandshakeReply);
|
||||
|
||||
await connection.send(handshakePacket);
|
||||
if (isTimeouted) {
|
||||
return;
|
||||
}
|
||||
})
|
||||
|
||||
return Promise
|
||||
.race([timeoutPromise, requestPromise])
|
||||
.finally(() => finalClear());
|
||||
}
|
||||
}
|
||||
229
src/core/RPCCommon.ts
Normal file
229
src/core/RPCCommon.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { isObject } from "@/utils/utils";
|
||||
import { RPC_ERROR_MESSAGES, RPCErrorCode } from "./RPCError";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
|
||||
function makeHandshakePacket(data: {
|
||||
state: 0;
|
||||
thisAccessKey: string | null;
|
||||
accessKey: string | null;
|
||||
}): RPCPacket;
|
||||
function makeHandshakePacket(data: {
|
||||
state: 1;
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}): RPCPacket;
|
||||
function makeHandshakePacket(data: {
|
||||
state: 0 | 1,
|
||||
thisAccessKey?: string | null;
|
||||
accessKey?: string | null;
|
||||
accept?: boolean;
|
||||
reason?: string;
|
||||
}): RPCPacket {
|
||||
return new RPCPacket({
|
||||
type: 'handshake',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
makeHandshakePacket,
|
||||
};
|
||||
|
||||
export function verifyHandshakeRequest(options: {
|
||||
/** handshake request packet */
|
||||
packet: RPCPacket;
|
||||
/** If empty, all connections are allowed */
|
||||
thisAccessKey?: string;
|
||||
/** If empty, all connections are allowed */
|
||||
thatAccessKeys?: string[];
|
||||
}) {
|
||||
const { data } = options.packet;
|
||||
if (!isObject(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('state' in data && 'thisAccessKey' in data && 'accessKey' in data) {
|
||||
const { state, thisAccessKey: thatAccessKey, accessKey } = data;
|
||||
if (state !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.thisAccessKey && options.thisAccessKey !== accessKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.thatAccessKeys && !options.thatAccessKeys.includes(thatAccessKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isHandshakeAccepted(packet: RPCPacket) {
|
||||
const { data } = packet;
|
||||
if (!isObject(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('state' in data && data.state === 1) {
|
||||
if ('accept' in data && typeof data.accept === 'boolean') {
|
||||
return data.accept;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function isValidMethodPathExtended(str: unknown): str is string {
|
||||
if (typeof str !== 'string' || str.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const regex = /^[a-zA-Z0-9_$]+(?::[a-zA-Z0-9_$]+)*$/;
|
||||
return regex.test(str);
|
||||
}
|
||||
|
||||
|
||||
export function makeCallPacket(options: {
|
||||
fnPath: string;
|
||||
args: any[];
|
||||
// timeout: number;
|
||||
}) {
|
||||
const data = {
|
||||
...options,
|
||||
}
|
||||
|
||||
return new RPCPacket({
|
||||
type: 'call',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function parseCallPacket(packet: RPCPacket) {
|
||||
if (!RPCPacket.isCallPacket(packet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isObject(packet.data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data } = packet;
|
||||
if ('fnPath' in data && 'args' in data) {
|
||||
const { fnPath, args } = data;
|
||||
if (!isValidMethodPathExtended(fnPath)) {
|
||||
console.log('66', fnPath)
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(args)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
fnPath,
|
||||
args,
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type BaseCallResponseOptions = (
|
||||
| { requestPacket: RPCPacket; requestPacketId?: never }
|
||||
| { requestPacket?: never; requestPacketId: string }
|
||||
);
|
||||
|
||||
type SuccessResponseOptions = BaseCallResponseOptions & {
|
||||
status: 'success';
|
||||
data: any;
|
||||
requestPacket?: RPCPacket;
|
||||
requestPacketId?: string;
|
||||
};
|
||||
|
||||
type ErrorResponseOptions = BaseCallResponseOptions & {
|
||||
status: 'error';
|
||||
errorCode?: number;
|
||||
reason?: string;
|
||||
data?: never;
|
||||
requestPacket?: RPCPacket;
|
||||
requestPacketId?: string;
|
||||
};
|
||||
|
||||
export function makeCallResponsePacket(options: SuccessResponseOptions | ErrorResponseOptions): RPCPacket {
|
||||
let { requestPacket, requestPacketId, ...o } = options;
|
||||
requestPacketId = requestPacketId ?? requestPacket?.id;
|
||||
if (!requestPacketId) {
|
||||
throw new Error('Request Packet Id is required');
|
||||
}
|
||||
|
||||
const data = {
|
||||
...o,
|
||||
}
|
||||
if (data.status === 'error') {
|
||||
if (!data.errorCode) {
|
||||
const errorCode = RPCErrorCode.GENERAL_ERROR;
|
||||
data.errorCode = errorCode;
|
||||
data.reason = RPC_ERROR_MESSAGES[errorCode];
|
||||
}
|
||||
|
||||
if (data.errorCode && !data.reason) {
|
||||
data.reason = RPC_ERROR_MESSAGES[data.errorCode]
|
||||
?? RPC_ERROR_MESSAGES[RPCErrorCode.GENERAL_ERROR];
|
||||
}
|
||||
}
|
||||
|
||||
return new RPCPacket({
|
||||
id: requestPacketId,
|
||||
type: 'response',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export function parseCallResponsePacket(packet: RPCPacket) {
|
||||
if (!RPCPacket.isCallResponsePacket(packet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isObject(packet.data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data } = packet;
|
||||
if (!('status' in data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { status } = data;
|
||||
if (typeof status !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return {
|
||||
success: {
|
||||
data: data.data,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
} else if (status === 'error') {
|
||||
if ('errorCode' in data && 'reason' in data) {
|
||||
const { errorCode, reason } = data;
|
||||
if (typeof errorCode !== 'number' || typeof reason !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
success: null,
|
||||
error: {
|
||||
errorCode,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
204
src/core/RPCConnection.ts
Normal file
204
src/core/RPCConnection.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
import { makeCallPacket, makeCallResponsePacket, parseCallPacket, parseCallResponsePacket } from "./RPCCommon";
|
||||
import { RPCProvider } from "./RPCProvider";
|
||||
import { RPCError, RPCErrorCode } from "./RPCError";
|
||||
|
||||
interface RPCConnectionEvents {
|
||||
call: RPCPacket;
|
||||
callResponse: RPCPacket;
|
||||
handshake: RPCPacket;
|
||||
unknownMsg: unknown;
|
||||
unknownPacket: RPCPacket;
|
||||
closed: void;
|
||||
}
|
||||
|
||||
export class RPCConnection extends EventEmitter<RPCConnectionEvents> {
|
||||
|
||||
closed: boolean = false;
|
||||
|
||||
private callResponseEmitter = new EventEmitter<{
|
||||
[id: string]: RPCPacket;
|
||||
}>();
|
||||
|
||||
constructor(public socket: SocketConnection) {
|
||||
super();
|
||||
socket.on('closed', () => {
|
||||
this.emit('closed');
|
||||
this.callResponseEmitter.removeAllListeners();
|
||||
this.closed = true;
|
||||
});
|
||||
|
||||
socket.on('msg', (msg) => {
|
||||
const packet = RPCPacket.Parse(msg, true);
|
||||
if (packet === null) {
|
||||
this.emit('unknownMsg', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RPCPacket.isCallPacket(packet)) {
|
||||
this.emit('call', packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RPCPacket.isCallResponsePacket(packet)) {
|
||||
this.emit('callResponse', packet);
|
||||
return;
|
||||
}
|
||||
|
||||
/** In fact, it will never be triggered */
|
||||
if (RPCPacket.isHandshakePacket(packet)) {
|
||||
this.emit('handshake', packet);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('unknownPacket', packet);
|
||||
});
|
||||
|
||||
/** route by packet.id */
|
||||
this.on('callResponse', (packet) => {
|
||||
this.callResponseEmitter.emit(packet.id, packet);
|
||||
})
|
||||
}
|
||||
|
||||
/** @throws */
|
||||
public async callRequest(options: {
|
||||
fnPath: string;
|
||||
args: any[];
|
||||
timeout: number;
|
||||
}): Promise<any> {
|
||||
const { fnPath, args } = options;
|
||||
const packet = makeCallPacket({
|
||||
fnPath,
|
||||
args
|
||||
});
|
||||
|
||||
let resolve: (data: any) => void;
|
||||
let reject: (data: any) => void;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
const cancelTimeoutTimer = (() => {
|
||||
const t = setTimeout(() => {
|
||||
reject(new RPCError({
|
||||
errorCode: RPCErrorCode.TIMEOUT_ERROR,
|
||||
}))
|
||||
}, options.timeout);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
})();
|
||||
|
||||
promise.finally(() => {
|
||||
this.callResponseEmitter.removeAllListeners(packet.id);
|
||||
cancelTimeoutTimer();
|
||||
})
|
||||
|
||||
const handleCallResponsePacket = (packet: RPCPacket) => {
|
||||
const result = parseCallResponsePacket(packet);
|
||||
if (result === null) {
|
||||
return reject(new RPCError({
|
||||
errorCode: RPCErrorCode.UNKNOWN_ERROR,
|
||||
}));;
|
||||
}
|
||||
|
||||
const { success, error } = result;
|
||||
if (success) {
|
||||
return resolve(success.data);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return reject(new RPCError({
|
||||
errorCode: error.errorCode,
|
||||
reason: error.reason
|
||||
}));
|
||||
}
|
||||
|
||||
return reject(new RPCError({
|
||||
errorCode: RPCErrorCode.UNKNOWN_ERROR,
|
||||
}));;
|
||||
}
|
||||
this.callResponseEmitter.on(packet.id, handleCallResponsePacket);
|
||||
|
||||
/** send call request */
|
||||
this.socket.send(packet);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
public onCallRequest(getProvider: () => RPCProvider | undefined) {
|
||||
this.on('call', async (packet) => {
|
||||
const request = parseCallPacket(packet);
|
||||
if (request === null) {
|
||||
return this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.CALL_PROTOCOL_ERROR,
|
||||
})).catch(() => { })
|
||||
}
|
||||
|
||||
// call the function
|
||||
const provider = getProvider();
|
||||
if (!provider) {
|
||||
return this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.PROVIDER_NOT_AVAILABLE,
|
||||
}))
|
||||
}
|
||||
|
||||
const { fnPath, args } = request;
|
||||
const fn = this.getProviderFunction(provider, fnPath);
|
||||
if (!fn) {
|
||||
return this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.METHOD_NOT_FOUND,
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
this.socket.send(makeCallResponsePacket({
|
||||
status: 'success',
|
||||
requestPacket: packet,
|
||||
data: result,
|
||||
}))
|
||||
} catch (error) {
|
||||
this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.SERVER_ERROR,
|
||||
...(error instanceof RPCError ? {
|
||||
errorCode: error.errorCode,
|
||||
reason: error.reason,
|
||||
} : {})
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getProviderFunction(provider: RPCProvider, fnPath: string) {
|
||||
const paths = fnPath.split(':');
|
||||
let fnThis: any = provider;
|
||||
let fn: any = provider;
|
||||
try {
|
||||
while (paths.length) {
|
||||
const path = paths.shift()!;
|
||||
fn = fn[path];
|
||||
if (paths.length !== 0) {
|
||||
fnThis = fn;
|
||||
}
|
||||
}
|
||||
if (typeof fn === 'function') {
|
||||
return fn.bind(fnThis);
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/core/RPCError.ts
Normal file
48
src/core/RPCError.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export enum RPCErrorCode {
|
||||
GENERAL_ERROR = -1,
|
||||
UNKNOWN_ERROR = -2,
|
||||
SERVER_ERROR = -200,
|
||||
METHOD_NOT_FOUND = -201,
|
||||
METHOD_PROTECTED = -202,
|
||||
PROVIDER_NOT_AVAILABLE = -203,
|
||||
AUTH_REJECTED = -300,
|
||||
HANDSHAKE_INCOMPLETE = -400,
|
||||
TIMEOUT_ERROR = -500,
|
||||
CALL_PROTOCOL_ERROR = -600,
|
||||
}
|
||||
|
||||
export const RPC_ERROR_MESSAGES: Record<RPCErrorCode | number, string> = {
|
||||
[RPCErrorCode.GENERAL_ERROR]: 'General error',
|
||||
[RPCErrorCode.UNKNOWN_ERROR]: 'Unknown error',
|
||||
[RPCErrorCode.SERVER_ERROR]: 'Server error',
|
||||
[RPCErrorCode.METHOD_NOT_FOUND]: 'Method not found',
|
||||
[RPCErrorCode.METHOD_PROTECTED]: 'Method is protected',
|
||||
[RPCErrorCode.PROVIDER_NOT_AVAILABLE]: 'Provider not available',
|
||||
[RPCErrorCode.AUTH_REJECTED]: 'Authentication rejected',
|
||||
[RPCErrorCode.HANDSHAKE_INCOMPLETE]: 'Handshake not completed',
|
||||
[RPCErrorCode.TIMEOUT_ERROR]: 'Request timeout',
|
||||
[RPCErrorCode.CALL_PROTOCOL_ERROR]: 'Call protocol error',
|
||||
} as const;
|
||||
|
||||
export class RPCError extends Error {
|
||||
|
||||
public errorCode: number;
|
||||
public reason: string;
|
||||
|
||||
constructor(
|
||||
args: {
|
||||
errorCode?: number;
|
||||
reason?: string;
|
||||
} = {}
|
||||
) {
|
||||
let { errorCode, reason } = args;
|
||||
errorCode = errorCode ?? RPCErrorCode.GENERAL_ERROR;
|
||||
reason = reason
|
||||
?? RPC_ERROR_MESSAGES[errorCode]
|
||||
?? RPC_ERROR_MESSAGES[RPCErrorCode.UNKNOWN_ERROR];
|
||||
|
||||
super(`[${errorCode}] ${reason}`);
|
||||
this.errorCode = errorCode;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
142
src/core/RPCHandler.ts
Normal file
142
src/core/RPCHandler.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { RPCClient } from "./RPCClient";
|
||||
import { RPCServer } from "./RPCServer";
|
||||
import { RPCProvider } from "./RPCProvider";
|
||||
import { RPCSession } from "./RPCSession";
|
||||
|
||||
const DefaultListenOptions = {
|
||||
port: 5201,
|
||||
path: '/'
|
||||
} as const;
|
||||
|
||||
const DefaultConnectOptions = {
|
||||
url: new URL(DefaultListenOptions.path, `http://localhost:${DefaultListenOptions.port}`).href,
|
||||
/** default is 30 * 1000 in server side */
|
||||
timeout: 10 * 3000,
|
||||
} as const;
|
||||
|
||||
interface RPCHandlerEvents {
|
||||
connnect: RPCSession;
|
||||
}
|
||||
|
||||
export class RPCHandler extends EventEmitter<RPCHandlerEvents> {
|
||||
|
||||
private rpcClient?: RPCClient;
|
||||
private rpcServer?: RPCServer;
|
||||
private provider?: RPCProvider;
|
||||
private accessKey?: string;
|
||||
|
||||
constructor(
|
||||
args?: {
|
||||
rpcClient?: RPCClient;
|
||||
rpcServer?: RPCServer;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
|
||||
if (args?.rpcClient) {
|
||||
this.setRPCProvider(args.rpcClient);
|
||||
}
|
||||
|
||||
if (args?.rpcServer) {
|
||||
this.setRPCProvider(args.rpcServer);
|
||||
}
|
||||
}
|
||||
|
||||
setProvider<T extends RPCProvider>(provider: T) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
setAccessKey(accessKey: string | undefined) {
|
||||
this.accessKey = accessKey;
|
||||
}
|
||||
|
||||
getAccessKey() {
|
||||
return this.accessKey;
|
||||
}
|
||||
|
||||
setRPCProvider(provider?: RPCClient | RPCServer) {
|
||||
if (provider instanceof RPCServer) {
|
||||
this.rpcServer = provider;
|
||||
} else if (provider instanceof RPCClient) {
|
||||
this.rpcClient = provider;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async connect(options: {
|
||||
url?: string;
|
||||
accessKey?: string;
|
||||
timeout?: number;
|
||||
} = {}) {
|
||||
const rpcClient = this.getRPCProvider('client', true);
|
||||
|
||||
return rpcClient.connect({
|
||||
...DefaultConnectOptions,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async listen(options: {
|
||||
port?: number;
|
||||
} = {}) {
|
||||
const rpcServer = this.getRPCProvider('server', true);
|
||||
|
||||
return rpcServer.listen({
|
||||
...DefaultListenOptions,
|
||||
...options,
|
||||
}).finally(() => {
|
||||
rpcServer.on('connect', rpcSession => {
|
||||
this.emit('connnect', rpcSession);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public getRPCProvider(type: 'client', init: true): RPCClient;
|
||||
public getRPCProvider(type: 'client', init?: boolean): RPCClient | undefined;
|
||||
public getRPCProvider(type: 'server', init: true): RPCServer;
|
||||
public getRPCProvider(type: 'server', init?: boolean): RPCServer | undefined;
|
||||
public getRPCProvider(type: 'client' | 'server', init?: boolean) {
|
||||
if (type === 'client') {
|
||||
if (!this.rpcClient && init) {
|
||||
this.rpcClient = new RPCClient(this);
|
||||
}
|
||||
return this.rpcClient;
|
||||
} else if (type === 'server') {
|
||||
if (!this.rpcServer && init) {
|
||||
this.rpcServer = new RPCServer(this);
|
||||
}
|
||||
return this.rpcServer;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// const h = new RPCHandler();
|
||||
|
||||
// h.setProvider<{
|
||||
// plus: (a: number, b: number) => number;
|
||||
// math: {
|
||||
// minus: (a: number, b: number) => number;
|
||||
// multiply: (a: number, b: number) => number;
|
||||
// }
|
||||
// }>({
|
||||
// plus(a, b) {
|
||||
// return a + b
|
||||
// },
|
||||
// math: {
|
||||
// minus(a, b) {
|
||||
// return a - b;
|
||||
// },
|
||||
// multiply(a, b) {
|
||||
// return a * b;
|
||||
// },
|
||||
// }
|
||||
// })
|
||||
100
src/core/RPCPacket.ts
Normal file
100
src/core/RPCPacket.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { isObject, isString, makeId, ObjectType } from "@/utils/utils";
|
||||
|
||||
export type RPCPacketType = 'handshake' | 'call' | 'response';
|
||||
|
||||
export class RPCPacket {
|
||||
|
||||
id: string;
|
||||
type: RPCPacketType;
|
||||
data: ObjectType;
|
||||
|
||||
constructor(
|
||||
args: {
|
||||
id?: string,
|
||||
type: RPCPacketType,
|
||||
data: ObjectType;
|
||||
}
|
||||
) {
|
||||
this.id = args.id ?? makeId();
|
||||
this.type = args.type;
|
||||
this.data = args.data;
|
||||
}
|
||||
|
||||
static Parse(value: unknown, safe: true): RPCPacket | null;
|
||||
static Parse(value: unknown, safe?: boolean): RPCPacket;
|
||||
static Parse(value: unknown, safe: boolean = false) {
|
||||
try {
|
||||
if (isObject(value) && this.isRPCPacket(value)) {
|
||||
return this.ObjToRPCPacket(value);
|
||||
} else if (isString(value)) {
|
||||
const obj = JSON.parse(value);
|
||||
return this.ObjToRPCPacket(obj);
|
||||
}
|
||||
|
||||
throw new Error(`${value} is not a RPCPacket`);
|
||||
} catch (error) {
|
||||
if (safe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjToRPCPacket(obj: RPCPacket) {
|
||||
const { id, type, data } = obj;
|
||||
return new RPCPacket({
|
||||
id, type, data,
|
||||
})
|
||||
}
|
||||
|
||||
static isRPCPacket(value: unknown): value is RPCPacket {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('id' in value) || !('type' in value) || !('data' in value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (this.isRPCPacketID(value.id)
|
||||
&& this.isRPCPacketType(value.type)
|
||||
&& this.isRPCPacketData(value.data))
|
||||
}
|
||||
|
||||
static isRPCPacketID(value: unknown) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static isRPCPacketType(type: unknown) {
|
||||
if (typeof type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!['handshake', 'call', 'response'].includes(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static isRPCPacketData(data: unknown) {
|
||||
return isObject(data);
|
||||
}
|
||||
|
||||
static isHandshakePacket(packet: RPCPacket) {
|
||||
return packet.type === 'handshake';
|
||||
}
|
||||
|
||||
static isCallPacket(packet: RPCPacket) {
|
||||
return packet.type === 'call';
|
||||
}
|
||||
|
||||
static isCallResponsePacket(packet: RPCPacket) {
|
||||
return packet.type === 'response';
|
||||
}
|
||||
}
|
||||
1
src/core/RPCProvider.ts
Normal file
1
src/core/RPCProvider.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type RPCProvider = { [key: string]: RPCProvider | Function | any };
|
||||
101
src/core/RPCServer.ts
Normal file
101
src/core/RPCServer.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { RPCHandler } from "./RPCHandler";
|
||||
import { createSocketServer, SocketServer } from "./SocketServer";
|
||||
import { RPCSession } from "./RPCSession";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
import { makeCallResponsePacket, makeHandshakePacket, verifyHandshakeRequest } from "./RPCCommon";
|
||||
import { RPCErrorCode } from "./RPCError";
|
||||
import { RPCConnection } from "./RPCConnection";
|
||||
|
||||
interface RPCServerEvents {
|
||||
connect: RPCSession;
|
||||
}
|
||||
|
||||
const DefaultRPCServerConfig = {
|
||||
handshakeTimeout: 30 * 1000,
|
||||
}
|
||||
|
||||
export class RPCServer extends EventEmitter<RPCServerEvents> {
|
||||
|
||||
private socketServer?: SocketServer;
|
||||
|
||||
constructor(private rpcHandler: RPCHandler) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async listen(options: {
|
||||
port: number;
|
||||
}) {
|
||||
// call the listen method of socket server
|
||||
if (!this.socketServer) {
|
||||
this.socketServer = createSocketServer();
|
||||
}
|
||||
|
||||
const socketServer = this.socketServer;
|
||||
this.registerSocketServerListener(socketServer);
|
||||
|
||||
return socketServer.listen(options);
|
||||
}
|
||||
|
||||
private registerSocketServerListener(socketServer: SocketServer) {
|
||||
socketServer.on('connect', (socketConnection) => {
|
||||
let cancelTimeoutTimer = (() => {
|
||||
let t = setTimeout(() => {
|
||||
socketConnection.send(makeHandshakePacket({
|
||||
state: 1,
|
||||
accept: false,
|
||||
reason: 'Timeout',
|
||||
})).catch(() => { });
|
||||
socketConnection.close();
|
||||
}, DefaultRPCServerConfig.handshakeTimeout);
|
||||
return () => clearTimeout(t);
|
||||
})();
|
||||
|
||||
const handleHandshakeRequest = (msg: unknown) => {
|
||||
// before handshake successfully, it should reject any packet expect handshake
|
||||
const packet = RPCPacket.Parse(msg, true);
|
||||
if (!packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (RPCPacket.isCallPacket(packet) || RPCPacket.isCallResponsePacket(packet)) {
|
||||
socketConnection.send(makeCallResponsePacket({
|
||||
requestPacket: packet,
|
||||
status: 'error',
|
||||
errorCode: RPCErrorCode.HANDSHAKE_INCOMPLETE,
|
||||
})).catch(() => { });
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptHandshake = verifyHandshakeRequest({
|
||||
packet,
|
||||
/** @todo */
|
||||
thatAccessKeys: undefined,
|
||||
thisAccessKey: this.rpcHandler.getAccessKey(),
|
||||
});
|
||||
|
||||
if (acceptHandshake) {
|
||||
this.emit('connect', new RPCSession(
|
||||
new RPCConnection(socketConnection),
|
||||
this.rpcHandler,
|
||||
));
|
||||
}
|
||||
|
||||
socketConnection.send(makeHandshakePacket({
|
||||
state: 1,
|
||||
accept: acceptHandshake,
|
||||
}));
|
||||
cancelTimeoutTimer();
|
||||
removeListener();
|
||||
}
|
||||
socketConnection.on('msg', handleHandshakeRequest);
|
||||
|
||||
let removeListener = () => {
|
||||
socketConnection.off('msg', handleHandshakeRequest);
|
||||
removeListener = () => { };
|
||||
}
|
||||
|
||||
socketConnection.on('closed', removeListener);
|
||||
});
|
||||
}
|
||||
}
|
||||
40
src/core/RPCSession.ts
Normal file
40
src/core/RPCSession.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ToDeepPromise } from "@/utils/utils";
|
||||
import { RPCConnection } from "./RPCConnection";
|
||||
import { RPCHandler } from "./RPCHandler";
|
||||
import { RPCProvider } from "./RPCProvider";
|
||||
|
||||
|
||||
export class RPCSession {
|
||||
|
||||
constructor(
|
||||
public readonly connection: RPCConnection,
|
||||
public readonly rpcHandler: RPCHandler,
|
||||
) {
|
||||
connection.onCallRequest(rpcHandler.getProvider.bind(rpcHandler));
|
||||
}
|
||||
|
||||
getAPI<T extends RPCProvider>(): ToDeepPromise<T> {
|
||||
const createProxy = (path: string[] = []) => {
|
||||
const func = function () { };
|
||||
|
||||
const handler: ProxyHandler<any> = {
|
||||
get(target, prop) {
|
||||
const newPath = [...path, prop.toString()];
|
||||
return createProxy(newPath);
|
||||
},
|
||||
apply: (target, thisArg, args) => {
|
||||
return this.connection.callRequest({
|
||||
fnPath: path.join(':'),
|
||||
args: args,
|
||||
/** @todo accept from caller */
|
||||
timeout: 10 * 1000,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
return new Proxy(func, handler);
|
||||
}
|
||||
|
||||
return createProxy() as unknown as ToDeepPromise<T>;
|
||||
}
|
||||
}
|
||||
27
src/core/SocketClient.ts
Normal file
27
src/core/SocketClient.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export abstract class SocketClient {
|
||||
public abstract connect(url: string): Promise<SocketConnection>;
|
||||
}
|
||||
|
||||
interface SocketClientConstructor {
|
||||
new(...args: any[]): SocketClient;
|
||||
};
|
||||
|
||||
let socketClient: SocketClientConstructor | null = null;
|
||||
|
||||
export function injectSocketClient(constructor: SocketClientConstructor) {
|
||||
socketClient = constructor;
|
||||
}
|
||||
|
||||
export function getSocketClient() {
|
||||
if (!socketClient) {
|
||||
throw new Error('No SocketClient constructor has been injected')
|
||||
}
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
export function createSocketClient(...args: any[]): SocketClient {
|
||||
const Constructor = getSocketClient();
|
||||
return new Constructor(...args);
|
||||
}
|
||||
12
src/core/SocketConnection.ts
Normal file
12
src/core/SocketConnection.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
|
||||
export interface SocketConnectionBaseEvents {
|
||||
msg: any;
|
||||
/** reason */
|
||||
closed: string | undefined;
|
||||
}
|
||||
|
||||
export abstract class SocketConnection extends EventEmitter<SocketConnectionBaseEvents> {
|
||||
public abstract send(data: any): Promise<void>;
|
||||
public abstract close(): Promise<void>;
|
||||
}
|
||||
35
src/core/SocketServer.ts
Normal file
35
src/core/SocketServer.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export interface SocketServerBaseEvents {
|
||||
connect: SocketConnection;
|
||||
}
|
||||
|
||||
export abstract class SocketServer extends EventEmitter<SocketServerBaseEvents> {
|
||||
/** @throws Error */
|
||||
public abstract listen(options: {
|
||||
port: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
interface SocketServerConstructor {
|
||||
new(...args: any[]): SocketServer;
|
||||
};
|
||||
|
||||
let socketServer: SocketServerConstructor | null = null;
|
||||
|
||||
export function injectSocketServer(constructor: SocketServerConstructor) {
|
||||
socketServer = constructor;
|
||||
}
|
||||
|
||||
export function getSocketServer() {
|
||||
if (!socketServer) {
|
||||
throw new Error('No SocketServer constructor has been injected')
|
||||
}
|
||||
return socketServer;
|
||||
}
|
||||
|
||||
export function createSocketServer(...args: any[]): SocketServer {
|
||||
const Constructor = getSocketServer();
|
||||
return new Constructor(...args);
|
||||
}
|
||||
40
src/implements/socket.io/SocketClient.ts
Normal file
40
src/implements/socket.io/SocketClient.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { SocketClient as SocketClientBase } from "@/core/SocketClient";
|
||||
import { io } from "socket.io-client";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export class SocketClient implements SocketClientBase {
|
||||
|
||||
public async connect(url: string): Promise<SocketConnection> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = io(url, {
|
||||
autoConnect: false,
|
||||
reconnection: false,
|
||||
});
|
||||
|
||||
const conn = new SocketConnection({
|
||||
sendMethod: (data) => {
|
||||
socket.emit('c', data);
|
||||
},
|
||||
closeMethod: () => {
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
resolve(conn);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason, description) => {
|
||||
conn.emit('closed', reason);
|
||||
})
|
||||
|
||||
/** subscribe messages from server */
|
||||
socket.on('s', (data) => {
|
||||
conn.emit('msg', data);
|
||||
})
|
||||
|
||||
socket.connect();
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
25
src/implements/socket.io/SocketConnection.ts
Normal file
25
src/implements/socket.io/SocketConnection.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SocketConnection as SocketConnectionBase, SocketConnectionBaseEvents } from "@/core/SocketConnection";
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
|
||||
interface SocketConnectionEvents extends SocketConnectionBaseEvents {
|
||||
|
||||
};
|
||||
|
||||
export class SocketConnection
|
||||
extends EventEmitter<SocketConnectionBaseEvents> implements SocketConnectionBase {
|
||||
|
||||
constructor(private args: {
|
||||
sendMethod: (data: any) => void;
|
||||
closeMethod: () => void;
|
||||
}) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async send(data: any): Promise<void> {
|
||||
this.args.sendMethod(data);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.args.closeMethod();
|
||||
}
|
||||
}
|
||||
42
src/implements/socket.io/SocketServer.ts
Normal file
42
src/implements/socket.io/SocketServer.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { SocketServer as SocketServerBase, SocketServerBaseEvents } from "@/core/SocketServer";
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
interface SocketServerEvents extends SocketServerBaseEvents {
|
||||
|
||||
}
|
||||
|
||||
export class SocketServer extends EventEmitter<SocketServerEvents> implements SocketServerBase {
|
||||
|
||||
public async listen(options: { port: number; }): Promise<void> {
|
||||
const { port } = options;
|
||||
/** only run it */
|
||||
const { Server } = await import("socket.io");
|
||||
const io = new Server();
|
||||
|
||||
io.on('connection', socket => {
|
||||
const conn = new SocketConnection({
|
||||
sendMethod: (data) => {
|
||||
socket.emit('s', data);
|
||||
},
|
||||
closeMethod: () => {
|
||||
socket.conn.close();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason, description) => {
|
||||
conn.emit('closed', reason);
|
||||
})
|
||||
|
||||
/** subscribe messages from client */
|
||||
socket.on('c', (data) => {
|
||||
conn.emit('msg', data);
|
||||
})
|
||||
|
||||
this.emit('connect', conn);
|
||||
})
|
||||
|
||||
io.listen(port);
|
||||
}
|
||||
|
||||
}
|
||||
9
src/implements/socket.io/index.ts
Normal file
9
src/implements/socket.io/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { injectSocketClient } from "@/core/SocketClient";
|
||||
import { SocketClient } from "./SocketClient";
|
||||
import { injectSocketServer } from "@/core/SocketServer";
|
||||
import { SocketServer } from "./SocketServer";
|
||||
|
||||
export function injectSocketIOImplements() {
|
||||
injectSocketClient(SocketClient);
|
||||
injectSocketServer(SocketServer);
|
||||
}
|
||||
22
src/index.ts
Normal file
22
src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { RPCHandler } from "./core/RPCHandler";
|
||||
export { RPCClient } from "./core/RPCClient";
|
||||
export { RPCServer } from "./core/RPCServer";
|
||||
export { RPCConnection } from "./core/RPCConnection";
|
||||
export { RPC_ERROR_MESSAGES, RPCErrorCode } from "./core/RPCError";
|
||||
export type { RPCProvider } from './core/RPCProvider';
|
||||
export type { RPCPacketType } from './core/RPCPacket';
|
||||
export { RPCPacket } from './core/RPCPacket';
|
||||
export { RPCSession } from "./core/RPCSession";
|
||||
export { SocketClient } from "./core/SocketClient";
|
||||
export { SocketConnection } from "./core/SocketConnection";
|
||||
export { SocketServer } from "./core/SocketServer";
|
||||
|
||||
export { injectSocketClient } from "./core/SocketClient";
|
||||
export { injectSocketServer } from "./core/SocketServer";
|
||||
import { injectSocketIOImplements } from "./implements/socket.io";
|
||||
|
||||
injectSocketIOImplements();
|
||||
|
||||
export {
|
||||
injectSocketIOImplements,
|
||||
}
|
||||
0
src/libs/core.errors.ts
Normal file
0
src/libs/core.errors.ts
Normal file
66
src/utils/EventEmitter.ts
Normal file
66
src/utils/EventEmitter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type EventType = Record<string, any>;
|
||||
|
||||
export class BaseEventEmitter<T extends EventType> {
|
||||
|
||||
private events: Map<keyof T, Set<(args: any) => void>> = new Map();
|
||||
|
||||
public on<K extends keyof T>(event: K, listener: (args: T[K]) => void) {
|
||||
this.addListener({ event, listener });
|
||||
return this;
|
||||
}
|
||||
|
||||
public once<K extends keyof T>(event: K, listener: (args: T[K]) => void) {
|
||||
this.addListener({ event, listener, once: true });
|
||||
return this;
|
||||
}
|
||||
|
||||
private addListener<K extends keyof T>({ event, listener, ...args }: {
|
||||
event: K,
|
||||
listener: (args: T[K]) => void,
|
||||
once?: boolean
|
||||
}) {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, new Set());
|
||||
}
|
||||
|
||||
const fn = args.once ? (data: T[K]) => {
|
||||
listener(data);
|
||||
this.off(event, fn);
|
||||
} : listener;
|
||||
|
||||
this.events.get(event)!.add(fn);
|
||||
}
|
||||
|
||||
public off<K extends keyof T>(event: K, listener: (args: T[K]) => void) {
|
||||
if (!this.events.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.get(event)!.delete(listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected emit<K extends keyof T>(event: K, data?: T[K]) {
|
||||
if (!this.events.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = new Set(this.events.get(event)!);
|
||||
listeners.forEach(fn => fn(data));
|
||||
}
|
||||
|
||||
public removeAllListeners<K extends keyof T>(event?: K) {
|
||||
if (event !== undefined) {
|
||||
this.events.delete(event);
|
||||
} else {
|
||||
this.events.clear();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class EventEmitter<T extends EventType> extends BaseEventEmitter<T> {
|
||||
public emit<K extends keyof T>(event: K, data?: T[K] | undefined): void {
|
||||
return super.emit(event, data);
|
||||
}
|
||||
}
|
||||
17
src/utils/utils.ts
Normal file
17
src/utils/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import md5 from 'md5';
|
||||
|
||||
export const makeId = () => md5(`${Date.now()}${Math.random()}`);
|
||||
|
||||
export const isObject = (v: unknown): v is Record<string, any> => typeof v === 'object' && v !== null;
|
||||
|
||||
export const isString = (v: unknown): v is string => typeof v === 'string';
|
||||
|
||||
export type ObjectType = Record<string, any>;
|
||||
|
||||
export type ToDeepPromise<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer P) => infer R
|
||||
? (...args: P) => Promise<R>
|
||||
: T[K] extends object
|
||||
? ToDeepPromise<T[K]>
|
||||
: T[K]
|
||||
};
|
||||
Reference in New Issue
Block a user