mservice-node
Version:
minimal node micro service framework on top of fastify
353 lines (339 loc) • 17.9 kB
JavaScript
;
const Fastify = require("fastify");
// Node.js require:
const Ajv7 = require("ajv")
const ajv = new Ajv7() // options can be passed, e.g. {allErrors: true}
const addFormats = require("ajv-formats")
addFormats(ajv)
const nodeOptionsSchema = require("./node-schema.json")
const nodeOptionsSchemaValidate = ajv.compile(nodeOptionsSchema);
const nodeServiceOptionsSchema = require("./service-schema.json")
const nodeServiceOptionsSchemaValidate = ajv.compile(nodeServiceOptionsSchema);
const path = require("path");
const activeNodes = new Set();
function clone(object) {
return JSON.parse(JSON.stringify(object));
}
const util = require("./lib/util");
const fs = require("fs");
class MicroServiceNode {
constructor(json_config, baseDir) {
//if pass json string then parse and replace envs first
const config_object = typeof (json_config) == "string" ? JSON.parse(util.replaceByEnv(json_config)) : json_config
const valid = nodeOptionsSchemaValidate(config_object)
if (!valid) {
const errors = nodeOptionsSchemaValidate.errors
throw new Error(
`Invalid mservice-node options:\n\t${errors.map(schemaError => {
return `error at: ${schemaError.instancePath}\n\terror: ${schemaError.message}`
}).join("\n================================================================================")}\n\n`);
}
const config = Object.freeze(config_object); //options can not change
Object.defineProperty(this, 'config', { get: () => config });
const fastify = Fastify(config.fastify != null ? config.fastify.options : null);
Object.defineProperty(this, 'fastify', { get: () => fastify });
const log = require('pino')({ level: config.logger.level, base: { pid: process.pid, service_node: "core" } });
Object.defineProperty(this, 'log', { get: () => log, enumerable: true });
const services = new Set();
Object.defineProperty(this, 'services', { get: () => services, enumerable: true });
for (const serviceName of Object.keys(config.services)) {
if (config.services[serviceName].active !== true) continue; //ignore in active service
const serviceconfig_string = fs.readFileSync(baseDir + config.services[serviceName].config, { encoding: 'utf8', flag: 'r' });
const serviceconfig = JSON.parse(util.replaceByEnv(serviceconfig_string));//not freeze on service config up to service to freeze it
const servicePath = path.dirname(require.resolve(baseDir + config.services[serviceName].config));
services.add(new NodeService(serviceName, serviceconfig, servicePath));
}
const authentication_config = config.authentication != null ? clone(config.authentication) : {};
Object.defineProperty(this, 'authentication_config', { get: () => authentication_config, enumerable: false });
Object.defineProperty(this, 'baseDir', { get: () => baseDir, enumerable: true });
Object.defineProperty(this, 'name', { get: () => config.node.name, enumerable: true });
log.info(`Load node options successfully.`);
}
/**
* start node
*/
async start() {
//register all fastify's plug in here
const fastify = this.fastify;
this.log.info(`Start node.`);
// start all servicee
this.log.debug({ services_size: this.services.size });
const dbServices = [];
if (this.config.database != null) {
const dbs = Object.keys(this.config.database);
for (const db of dbs) {
switch (db) {
case "mysql":
const mysql_config_object = clone(this.config.database.mysql);
if (mysql_config_object.pool.ssl.ca != null) {
mysql_config_object.pool.ssl.ca = fs.readFileSync(this.baseDir + mysql_config_object.pool.ssl.ca);
}
const { MySQLDatabaseService } = new require("./DatabaseService");
const mysql = new MySQLDatabaseService(mysql_config_object);
await mysql.start();
dbServices.push(mysql);
this.log.info({ mysql: "mysql start successfully" });
break;
}
}
}
const serviceStartPromises = [];
const schemas = []
if (this.config.schema != null) {
const schema_config = clone(this.config.schema);
if (schema_config.use_basic === true) {
const base_service_schema = require(__dirname + "/basic-schema_for_route.json");
fastify.addSchema(base_service_schema);
schemas.push(base_service_schema)
}
delete schema_config.use_basic;
if (schema_config.config != null) {
for (const [a_schema_name, a_schema_config_path] of Object.entries(schema_config.config)) {
const a_schema = require(this.baseDir + a_schema_config_path);
fastify.addSchema(a_schema);
schemas.push(a_schema)
}
}
}
if (this.config.swagger != null) {
const swagger = require("@fastify/swagger");
const swagger_config = clone(this.config.swagger);
const routePrefix = swagger_config.routePrefix;
delete swagger_config.routePrefix;
await fastify.register(swagger, {
routePrefix: routePrefix,
swagger: swagger_config,
exposeRoute: true
});
fastify.ready(err => {
if (err) throw err
fastify.swagger()
})
}
await fastify.after();
for (const service of this.services) {
this.log.debug({ start_service: service.config.service.baseURL });
//clone authentication_config prevent change in config in service level
const authentication_config = JSON.parse(JSON.stringify(this.authentication_config));
await service.start(this.fastify, authentication_config, dbServices);
}
await Promise.all(serviceStartPromises);
if (this.config.cors != null) {
await fastify.register(require("@fastify/cors"), this.config.cors);
this.log.info(`support cors on ${this.config.cors.origin}`);
await fastify.after();
}
this.log.info(`Start all service.`);
await fastify.ready();
this.log.debug(`fastiy ready.`);
await fastify.listen(this.config.node.port, this.config.node.listen);
activeNodes.add(this);// add after start successfully
this.log.info(`Start node successfully.`);
}
async close() {
try {
this.log.info(`closing '${this.name}' node and it's services.`);
const serviceClosePromises = [];
for (const service of this.services) {
serviceClosePromises.push(service.close())
}
await Promise.all(serviceClosePromises);
} finally {
await this.fastify.close();
}
}
}
class NodeService {
constructor(serviceName, config_object, servicePath) {
const valid = nodeServiceOptionsSchemaValidate(config_object)
if (!valid) {
const errors = nodeServiceOptionsSchemaValidate.errors;
throw new Error(
`Invalid node-service '${serviceName}' options:\n\t${errors.map(schemaError => {
return `error at: ${schemaError.instancePath}\n\terror: ${schemaError.message}`
}).join("\n================================================================================")}\n\n`);
}
const config = config_object;
Object.defineProperty(this, 'config', { get: () => config });
const log_base = { pid: process.pid, service: `${serviceName}` };
const log = require('pino')({ level: this.config.service.logger.level, base: log_base });
log.debug({ config: this.config })
Object.defineProperty(this, 'log', { get: () => log, enumerable: true });
const _handlers = new Set();
Object.defineProperty(this, '_handlers', { get: () => _handlers, enumerable: false });
const name = serviceName;
Object.defineProperty(this, 'name', { get: () => name, enumerable: true });
const path = servicePath;
Object.defineProperty(this, 'path', { get: () => path, enumerable: true });
}
async start(fastify, node_authentication_config, dbServices) {
const serviceInstance = this;
const databaseService = dbServices;
const error = {};
await fastify.register(async (fastifyServiceContext, options, done) => {
try {
const service_handler_config = JSON.parse(JSON.stringify(serviceInstance.config)); //copy config
//merge both, same auth will be replaced by service level
const authentication_config = { ...(node_authentication_config != null ? node_authentication_config : {}), ...service_handler_config.service.authentication != null ? service_handler_config.service.authentication : {} };
console.log(authentication_config)
const auth_verify_functions = [];
for (const authType of Object.keys(authentication_config)) {
switch (authType) {
case "bearer":
const bearer_config = authentication_config[authType];
if (bearer_config == null) break;
const bearerAuthPlugin = require('@fastify/bearer-auth');
bearer_config["addHook"] = false; //so can be override by service
//register to service level fastify not root
await fastifyServiceContext.register(bearerAuthPlugin, bearer_config);
await fastifyServiceContext.after();//wait for register to complete first before add
auth_verify_functions.push(fastifyServiceContext.verifyBearerAuth);
break;
case "jwt":
const jwt_config = authentication_config[authType];
if (jwt_config == null) break;
if (jwt_config.secret == null) {
throw new Error("for jwt, secret option is required")
}
const jwt_options = {};
if (jwt_config.secret.jwks != null) {
const buildGetJwks = require('get-jwks');
const domain = jwt_config.secret.jwks.domain;
const getJwks = buildGetJwks({
ttl: 60 * 60 * 1000,
allowedDomains: [jwt_config.secret.jwks.domain],
jwksPath: jwt_config.secret.jwks.jwksPath
})
jwt_options.decode = { complete: true };
jwt_options.secret = (request, token) => {
const { header: { kid, alg } } = token;
return getJwks.getPublicKey({ kid, domain, alg });
}
} else {
jwt_options.secret = {
private: Buffer.from(jwt_config.secret.private_base64, 'base64').toString('utf8'),
public: Buffer.from(jwt_config.secret.public_base64, 'base64').toString('utf8')
}
}
if (jwt_config.sign != null) {
jwt_options.sign = jwt_config.sign;
}
if (jwt_config.verify != null) {
jwt_options.verify = jwt_config.verify;
}
await fastifyServiceContext.register(require("@fastify/jwt"), jwt_options);
await fastifyServiceContext.after();//wait for register to complete first before add
fastifyServiceContext.decorate("verifyJWT", async function (request, reply) {
await request.jwtVerify();
});
auth_verify_functions.push(fastifyServiceContext.verifyJWT);
break;
}
}
//register as hook so it affect while service (all routes below)
if (auth_verify_functions.length > 0) {
fastifyServiceContext.register(require('@fastify/auth'));
await fastifyServiceContext.after();
fastifyServiceContext.addHook('preHandler', fastifyServiceContext.auth(auth_verify_functions))
}
delete service_handler_config.service.routes;//remove service config since it use for fastify service only
//Object.freeze(service_handler_config);// freeze config
serviceInstance.log.debug({ routes: serviceInstance.config.service.routes })
for (const orgRoute of serviceInstance.config.service.routes) {
const route = JSON.parse(JSON.stringify(orgRoute));//copy since we gonna change it
serviceInstance.log.debug({ route });
route.url = "/" + serviceInstance.config.service.baseURL + route.url
const funcName = route.handler.function;
const handler_path = serviceInstance.path + "/" + route.handler.file;
const obj_handler = require(handler_path);
if (!obj_handler instanceof NodeServiceHandler) {
throw new Error(` ${serviceInstance.name}'s handler must be instance of ServiceHandler`);
}
serviceInstance._handlers.add(obj_handler);
if (typeof (obj_handler[funcName]) !== 'function') {
throw new Error(` ${serviceInstance.name}'s handler function for route:${route.url} is not a function`);
}
serviceInstance.log.debug("Load function successfully");
route.handler = obj_handler[funcName];
await obj_handler.init(service_handler_config, serviceInstance.log, databaseService);
await fastifyServiceContext.route(route);
serviceInstance.log.debug({ service: serviceInstance.name, route: route.url, status: "initialized" })
}
await fastifyServiceContext.after();
} catch (fastifyRegisterError) {
serviceInstance.log.error({ error: `error while in fastify register`, stack: fastifyRegisterError.stack });
error.fastifyRegisterError = fastifyRegisterError;
}
done()
});
await fastify.after(); //make sure all plug in loaded after register
if (error.fastifyRegisterError) throw error.fastifyRegisterError;
this.log.info(`start service successfully.`);
}
async close() {
this.log.info(`closing '${this.name}' service and it's handlers.`)
const handlerClosePromises = [];
for (const handler of this._handlers) {
handlerClosePromises.push(handler.close(this.log))
}
return Promise.all(handlerClosePromises);
}
}
const NodeServiceHandler = require("./ServiceHandler");
const { config } = require("process");
/**
* shut down properly
*/
const errorTypes = ['unhandledRejection', 'uncaughtException']
const signalTraps = ['SIGTERM', 'SIGINT', 'SIGUSR2']
let isUnhandledRejectionLoop = false;
errorTypes.map(type => {
process.on(type, async error => {
try {
if (isUnhandledRejectionLoop) {
console.error(type, "is looped");
process.exit(1);
}
isUnhandledRejectionLoop = true;
console.error(`${type}.\nerror: ${error.message}\nstack: ${error.stack}`);
console.log(`clean resounce on ${type}`)
const nodeClosePromises = [];
for (const node of activeNodes) {
nodeClosePromises.push(node.close())
}
await Promise.all(nodeClosePromises);
console.log(`clean all nodes successfully`)
} catch (_) {
process.exit(1);
} finally {
process.exit(1);
}
})
})
signalTraps.map(type => {
process.on(type, async () => {
try {
console.log(`clean resounce on ${type}`)
const nodeClosePromises = [];
for (const node of activeNodes) {
nodeClosePromises.push(node.close())
}
await Promise.all(nodeClosePromises);
} catch (err) {
console.error(`error while try to close process.\nerror: ${error.message}\nstack: ${error.stack}`);
process.exit(1);
} finally {
process.exit(0);
}
})
})
const { MySQLDatabaseService } = require("./DatabaseService");
module.exports = {
mserviceNode:
(options, baseDir) => {
return new MicroServiceNode(options, baseDir)
},
ServiceHandler: NodeServiceHandler,
MySQLDatabaseService,
util
};