fusion-plugin-rpc
Version:
Fetch data on the server and client with an RPC style interface.
279 lines (256 loc) • 8.28 kB
text/typescript
/** Copyright (c) 2018 Uber Technologies, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
/* eslint-env node */
import bodyparser from 'koa-bodyparser';
import formidable from 'formidable';
import {createPlugin, memoize, RouteTagsToken} from 'fusion-core';
import type {Context} from 'fusion-core';
import {UniversalEventsToken} from 'fusion-plugin-universal-events';
import type {Fetch} from 'fusion-tokens';
import MissingHandlerError from './missing-handler-error';
import ResponseError from './response-error';
import {
BodyParserOptionsToken,
RPCHandlersToken,
RPCHandlersConfigToken,
} from './tokens';
import type {HandlerType} from './tokens';
import type {RPCPluginType, IEmitter} from './types';
import {formatApiPath} from './utils';
const statKey = 'rpc:method';
/* Helper function */
function hasHandler(handlers: HandlerType, method: string): boolean {
return Object.prototype.hasOwnProperty.call(handlers, method);
}
class RPC {
ctx: Context | undefined | null;
emitter: IEmitter | undefined | null;
handlers: HandlerType | undefined | null;
fetch: Fetch | undefined | null;
constructor(emitter: IEmitter, handlers: any, ctx: Context) {
if (!ctx || !ctx.headers) {
throw new Error('fusion-plugin-rpc requires `ctx`');
}
this.ctx = ctx;
this.emitter = emitter;
this.handlers = handlers;
return this;
}
async request<TArgs, TResult>(method: string, args: TArgs): Promise<TResult> {
const startTime = ms();
if (!this.ctx) {
throw new Error('fusion-plugin-rpc requires `ctx`');
}
if (!this.emitter) {
throw new Error('fusion-plugin-rpc requires `emitter`');
}
const scopedEmitter = this.emitter.from(this.ctx);
if (!this.handlers) {
throw new Error('fusion-plugin-rpc requires `handlers`');
}
if (!hasHandler(this.handlers, method)) {
const e = new MissingHandlerError(method);
if (scopedEmitter) {
scopedEmitter.emit('rpc:error', {
method,
origin: 'server',
error: e,
});
}
throw e;
}
try {
const result = await this.handlers[method](args, this.ctx);
if (scopedEmitter) {
scopedEmitter.emit(statKey, {
method,
status: 'success',
origin: 'server',
timing: ms() - startTime,
});
}
return result;
} catch (e) {
if (scopedEmitter) {
scopedEmitter.emit(statKey, {
method,
error: e,
status: 'failure',
origin: 'server',
timing: ms() - startTime,
});
}
throw e;
}
}
}
const pluginFactory: () => RPCPluginType = () =>
createPlugin({
deps: {
RouteTags: RouteTagsToken.optional,
emitter: UniversalEventsToken,
handlers: RPCHandlersToken,
bodyParserOptions: BodyParserOptionsToken.optional,
rpcConfig: RPCHandlersConfigToken.optional,
},
provides: (deps) => {
const {emitter, handlers} = deps;
const service = {
from: memoize((ctx) => new RPC(emitter, handlers, ctx)),
};
return service;
},
middleware: (deps) => {
const {emitter, handlers, bodyParserOptions, rpcConfig} = deps;
if (!handlers)
throw new Error('Missing handlers registered to RPCHandlersToken');
if (!emitter)
throw new Error('Missing emitter registered to UniversalEventsToken');
const parseBody = bodyparser(bodyParserOptions);
const apiPath = formatApiPath(
rpcConfig && rpcConfig.apiPath ? rpcConfig.apiPath : 'api'
);
return async (ctx, next) => {
await next();
const routeTags = (deps.RouteTags && deps.RouteTags.from(ctx)) || {};
const scopedEmitter = emitter.from(ctx);
if (ctx.method === 'POST' && ctx.path.startsWith(apiPath)) {
const startTime = ms();
// eslint-disable-next-line no-useless-escape
const pathMatch = new RegExp(`${apiPath}([^/]+)`, 'i');
const [, method] = ctx.path.match(pathMatch) || [];
if (hasHandler(handlers, method)) {
routeTags.name = method;
let body;
try {
if (
ctx.req &&
ctx.req.headers &&
ctx.req.headers['content-type'] &&
ctx.req.headers['content-type'].indexOf(
'multipart/form-data'
) !== -1
) {
const form = new formidable.IncomingForm();
body = await new Promise((resolve, reject) => {
form.parse(
ctx.req,
(
err,
fields: {
[x: string]: any;
},
files
) => {
if (err) {
reject(err);
}
resolve({
...fields,
...files,
});
}
);
});
} else {
await parseBody(ctx, () => Promise.resolve());
}
} catch (e) {
ctx.body = {
status: 'failure',
data: {
message: e.message,
code: e.type || 'ERR_BAD_BODY',
meta: e.meta,
},
};
if (scopedEmitter) {
scopedEmitter.emit(statKey, {
method,
error: e,
status: 'failure',
origin: 'browser',
timing: ms() - startTime,
});
}
// don't try to call handler
return;
}
try {
const result = await handlers[method](
body || ctx.request.body,
ctx
);
ctx.body = {
status: 'success',
data: result,
};
if (scopedEmitter) {
scopedEmitter.emit(statKey, {
method,
status: 'success',
origin: 'browser',
timing: ms() - startTime,
});
}
} catch (e) {
const error =
e instanceof ResponseError
? e
: new Error(
__DEV__
? 'UnknownError - Use ResponseError from fusion-plugin-rpc (or fusion-plugin-rpc-redux-react if you are using React) package for more detailed error messages'
: 'Internal Server Error'
);
ctx.body = {
status: 'failure',
data: {
message: error.message,
// @ts-expect-error
code: error.code,
// @ts-expect-error
meta: error.meta,
},
};
if (scopedEmitter) {
scopedEmitter.emit(statKey, {
method,
error: e,
status: 'failure',
origin: 'browser',
timing: ms() - startTime,
});
}
}
} else {
const e = new MissingHandlerError(method);
ctx.body = {
status: 'failure',
data: {
message: e.message,
code: e.code,
},
};
ctx.status = 404;
if (scopedEmitter) {
scopedEmitter.emit('rpc:error', {
origin: 'browser',
method,
error: e,
});
}
}
}
};
},
});
/* Helper functions */
function ms() {
const [seconds, ns] = process.hrtime();
return Math.round(seconds * 1000 + ns / 1e6);
}
export default __NODE__ && (pluginFactory() as any as RPCPluginType);