data-transport
Version:
A simple and responsive transport
331 lines (317 loc) • 10.6 kB
text/typescript
import {
listenerKey,
originalListensMapKey,
requestsMapKey,
listensMapKey,
senderKey,
timeoutKey,
transportKey,
prefixKey,
transportType,
produceKey,
listenKey,
serializerKey,
logKey,
verboseKey,
beforeEmitKey,
beforeEmitResolveKey,
} from './constant';
import type {
EmitOptions,
IRequest,
IResponse,
ListenerOptions,
Request,
ListensMap,
Response,
TransportOptions,
EmitParameter,
BaseInteraction,
} from './interface';
import { generateId } from './utils';
const DEFAULT_TIMEOUT = 60 * 1000;
const DEFAULT_RESPOND = true;
const DEFAULT_SILENT = false;
const DEFAULT_PREFIX = 'DataTransport';
export const getAction = (prefix: string, name: string) =>
`${prefix}-${name.toString()}`;
const getListenName = (prefix: string, action: string) =>
action.replace(new RegExp(`^${prefix}-`), '');
/**
* Create a base transport
*/
export abstract class Transport<T extends BaseInteraction = any> {
private [listenerKey]: TransportOptions['listener'];
private [listenKey]: (options?: ListenerOptions) => void;
private [senderKey]: TransportOptions['sender'];
private [timeoutKey]: TransportOptions['timeout'];
private [prefixKey]: TransportOptions['prefix'];
private [serializerKey]: TransportOptions['serializer'];
private [requestsMapKey]: Map<string, (value: unknown) => void> = new Map();
private [listensMapKey]!: ListensMap;
private [originalListensMapKey]!: Map<string, Function>;
private [logKey]?: (listenOptions: ListenerOptions<any>) => void;
private [verboseKey]: boolean;
protected [beforeEmitKey]?: Promise<void>;
protected [beforeEmitResolveKey]?: () => void;
/**
* dispose transport
*/
public dispose: () => any;
constructor({
listener,
sender,
timeout = DEFAULT_TIMEOUT,
verbose = false,
prefix = DEFAULT_PREFIX,
listenKeys = [],
checkListen = true,
serializer,
logger,
}: TransportOptions) {
this[listensMapKey] = this[listensMapKey] ?? new Map();
this[originalListensMapKey] = this[originalListensMapKey] ?? new Map();
this[listenerKey] = listener.bind(this);
this[senderKey] = sender.bind(this);
this[timeoutKey] = timeout;
this[prefixKey] = prefix;
this[serializerKey] = serializer;
this[verboseKey] = verbose;
this[logKey] = logger;
new Set(listenKeys).forEach((key) => {
const fn = (this as any as Record<string, Function>)[key];
if (__DEV__) {
if (typeof fn !== 'function') {
console.warn(`'${key}' is NOT a methods or function.`);
}
}
this[originalListensMapKey].set(key, fn);
Object.assign(this, {
[key]() {
if (__DEV__) {
throw new Error(
`The method '${key}' is a listen function that can NOT be actively called.`
);
}
},
});
});
this[originalListensMapKey].forEach((value, name) => {
this[produceKey](name, value);
});
this[listenKey] = (options?: ListenerOptions) => {
if (this[verboseKey]) {
if (typeof this[logKey] === 'function' && options) {
this[logKey]!(options);
} else {
console.info('DataTransport Receive: ', options);
}
}
if (options?.[transportKey]) {
const listenName = getListenName(this[prefixKey]!, options.action);
const hasListen = typeof (this as any)[listenName] === 'function';
if ((options as IResponse).type === transportType.response) {
const resolve = this[requestsMapKey].get(options[transportKey]);
if (resolve) {
const { response } = options as IResponse;
resolve(
typeof response === 'string' && this[serializerKey]?.parse
? this[serializerKey]!.parse!(response)
: response
);
} else if (hasListen) {
if (__DEV__ && checkListen) {
console.warn(
`The type '${options.action}' event '${options[transportKey]}' has been resolved. Please check for a duplicate response.`
);
}
}
} else if ((options as IRequest).type === transportType.request) {
const respond = this[listensMapKey].get(options.action);
if (typeof respond === 'function') {
const { request } = options as IRequest;
respond(
typeof request === 'string' && this[serializerKey]?.parse
? this[serializerKey]!.parse!(request)
: request,
{
...options,
transportId: options[transportKey],
hasRespond: (options as IRequest).hasRespond,
}
);
} else if (hasListen) {
if (__DEV__ && checkListen) {
console.error(
`The listen method or function '${listenName}' is NOT decorated by decorator '@listen' or be added 'listenKeys' list.`
);
}
}
}
}
};
const dispose = this[listenerKey](this[listenKey]);
this.dispose = () => {
if (typeof dispose === 'function') {
this[requestsMapKey].clear();
this[listensMapKey].clear();
this[originalListensMapKey].clear();
return dispose();
} else if (__DEV__) {
console.warn(
`The return value of the the '${this.constructor.name}' transport's listener should be a 'dispose' function for removing the listener`
);
}
};
}
private [produceKey]<K extends string, P extends Record<string, Function>>(
name: K,
fn: P[K]
) {
// https://github.com/microsoft/TypeScript/issues/40465
const action = getAction(this[prefixKey]!, name);
this[listensMapKey].set(
action,
async (request, { hasRespond, transportId, request: _, ...args }) => {
if (typeof fn === 'function') {
const response: Response<P[K]> = await fn.apply(this, request);
if (!hasRespond) return;
const data: IResponse = {
...args,
action,
response: (typeof response !== 'undefined' &&
this[serializerKey]?.stringify
? this[serializerKey]!.stringify!(response)
: response) as string | undefined,
hasRespond,
[transportKey]: transportId,
type: transportType.response,
responseId: this.id,
};
this[senderKey](data);
} else {
throw new Error(
`The listener for event ${name} should be a function.`
);
}
}
);
}
/**
* Listen an event that transport data.
*
* @param name A transport action as listen message data action type
* @param fn A transport listener
*/
public listen<K extends keyof T['listen']>(name: K, fn: T['listen'][K]) {
if (typeof name === 'string') {
if (this[originalListensMapKey].get(name)) {
throw new Error(
`Failed to listen to the event "${name}", the event "${name}" is already listened to.`
);
}
if (typeof fn === 'function') {
this[originalListensMapKey].set(name, fn);
this[produceKey](name, fn);
} else {
throw new Error(`The listener for event ${name} should be a function.`);
}
} else {
throw new Error(
`The event name "${name.toString()}" is not a string, it should be a string.`
);
}
return () => {
this[originalListensMapKey].delete(name);
const action = getAction(this[prefixKey]!, name);
this[listensMapKey].delete(action);
};
}
public id = generateId();
/**
* Emit an event that transport data.
*
* @param emitOptions A option for the transport data
* @param request A request data
*
* @returns Return a response for the request.
*/
public async emit<K extends keyof T['emit']>(
options: EmitOptions<K>,
...request: Request<T['emit'][K]>
): Promise<Response<T['emit'][K]>> {
const params =
typeof options === 'object' ? options : ({} as EmitParameter<K>);
const hasRespond = params.respond ?? DEFAULT_RESPOND;
const isSilent = params.silent ?? DEFAULT_SILENT;
const timeout = params.timeout ?? this[timeoutKey];
const name = params.name ?? options;
const transportId = generateId();
if (__DEV__ && (!name || typeof name !== 'string')) {
throw new Error(`The event name should be a string, and it's required.`);
}
const action = getAction(this[prefixKey]!, name as string);
const rawRequestData: IRequest = {
...(params._extra ? { _extra: params._extra } : {}),
type: transportType.request,
action,
request: (typeof request !== 'undefined' && this[serializerKey]?.stringify
? this[serializerKey]!.stringify!(request)
: request) as unknown[],
hasRespond,
[transportKey]: transportId,
requestId: this.id,
};
if (this[verboseKey]) {
if (typeof this[logKey] === 'function') {
this[logKey]!(rawRequestData);
} else {
console.info('DataTransport Send: ', rawRequestData);
}
}
if (!hasRespond) {
if (this[beforeEmitKey] && !params.skipBeforeEmit) {
await this[beforeEmitKey];
}
this[senderKey](rawRequestData);
return Promise.resolve(undefined as Response<T['emit'][K]>);
}
let timeoutId: NodeJS.Timeout | number;
const promise = Promise.race<any>([
new Promise(async (resolve) => {
if (this[beforeEmitKey] && !params.skipBeforeEmit) {
await this[beforeEmitKey];
}
this[requestsMapKey].set(transportId, resolve);
this[senderKey](rawRequestData);
}),
new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject();
}, timeout);
}),
]);
return promise
.then((response) => {
// support Safari 10-11.1
clearTimeout(timeoutId as NodeJS.Timeout);
this[requestsMapKey].delete(transportId);
return response;
})
.catch((error) => {
clearTimeout(timeoutId as NodeJS.Timeout);
this[requestsMapKey].delete(transportId);
if (typeof error === 'undefined') {
if (isSilent) return;
console.warn(
`The event '${action}' timed out for ${timeout} seconds...`,
rawRequestData
);
} else {
if (__DEV__) {
throw error;
}
}
});
}
}