impress
Version:
Enterprise application server for Node.js
236 lines (213 loc) • 7.84 kB
JavaScript
;
process.title = 'impress';
const fsp = require('node:fs').promises;
const { Worker } = require('node:worker_threads');
const path = require('node:path');
const { Config } = require('metaconfiguration');
const metavm = require('metavm');
const { Pool, isError } = require('metautil');
const { loadSchema } = require('metaschema');
const { Logger } = require('metalog');
const { Planner } = require('./lib/planner.js');
const CONFIG_SECTIONS = ['log', 'scale', 'server', 'sessions'];
const PATH = process.cwd();
const WORKER_PATH = path.join(__dirname, 'lib/worker.js');
const REPORTER_PATH = 'file://' + path.join(__dirname, 'lib/reporter.js');
const LOG_PATH = path.join(PATH, 'log');
const CTRL_C = 3;
const LOG_OPTIONS = { path: LOG_PATH, home: PATH, workerId: 0 };
const CONTEXT = metavm.createContext({ process });
const CFG_OPTIONS = { mode: process.env.MODE, context: CONTEXT };
const impress = {
logger: null,
config: null,
planner: null,
close: () => {},
finalization: false,
initialization: true,
console,
applications: new Map(),
lastWorkerId: 0,
startTimer: null,
};
const exit = async (message, code) => {
if (impress.finalization) return;
impress.finalization = true;
impress.console.info(message);
if (impress.logger && impress.logger.active) await impress.logger.close();
process.exit(code);
};
const logError = (type) => (err) => {
const error = isError(err) ? err : new Error('Unknown');
if (error.name === 'ExperimentalWarning') return;
const msg = error?.stack || error?.message || 'exit';
impress.console.error(`${type}: ${msg}`);
if (type === 'warning') return;
if (impress.initialization) exit('Can not start Application server', 1);
};
const broadcast = (app, data) => {
for (const thread of app.threads.values()) {
thread.postMessage(data);
}
};
const startWorker = async (app, kind, port, id = ++impress.lastWorkerId) => {
const workerData = { id, kind, root: app.root, path: app.path, port };
const execArgv = [...process.execArgv, `--test-reporter=${REPORTER_PATH}`];
const options = { trackUnmanagedFds: true, workerData, execArgv };
const worker = new Worker(WORKER_PATH, options);
if (kind === 'worker') {
app.pool.add(worker);
await app.pool.capture();
}
app.threads.set(id, worker);
worker.on('error', (error) => {
impress.console.error(error.message);
});
worker.on('exit', (code) => {
if (code !== 0) startWorker(app, kind, port, id);
else app.threads.delete(id);
if (impress.initialization) exit('Can not start Application server', 1);
if (app.threads.size === 0) {
impress.applications.delete(app.path);
if (impress.applications.size === 0) impress.close();
}
});
const handlers = {
started: ({ kind }) => {
app.ready++;
if (kind === 'worker') app.pool.release(worker);
if (app.threads.size === app.ready) {
clearTimeout(impress.startTimer);
impress.initialization = false;
impress.console.info(`App started: ${app.path}`);
broadcast(app, { name: 'ready' });
}
},
task: async ({ action, port, task }) => {
const { planner } = impress;
task.app = app.path;
if (action === 'add') port.postMessage({ id: await planner.add(task) });
else if (action === 'remove') planner.remove(task.id);
else if (action === 'stop') planner.stop(task.name);
},
invoke: async (msg) => {
const { from, to, exclusive } = msg;
if (to) {
const back = app.threads.get(to);
return void back.postMessage(msg);
}
const promised = exclusive ? app.pool.capture() : app.pool.next();
const next = await promised.catch(() => {
const error = { message: 'No thread available' };
const back = app.threads.get(from);
const data = { id, status: 'error', error };
back.postMessage({ name: 'invoke', to: from, data });
return null;
});
if (!next) return;
next.postMessage(msg);
},
release: () => {
app.pool.release(worker);
},
terminate: ({ code }) => {
process.emit('TERMINATE', code);
},
};
worker.on('message', (msg) => {
const handler = handlers[msg.name];
if (handler) handler(msg);
});
};
const validateConfig = async (config) => {
let valid = true;
const schemaPath = path.join(__dirname, 'schemas/config');
for (const section of CONFIG_SECTIONS) {
const fileName = path.join(schemaPath, section + '.js');
const schema = await loadSchema(fileName);
const checkResult = schema.check(config[section]);
if (!checkResult.valid) {
for (const error of checkResult.errors) {
impress.console.error(`${error} in application/config/${section}.js`);
}
valid = false;
}
}
if (!valid) exit('Application server configuration is invalid', 1);
};
const loadApplication = async (root, dir, master) => {
impress.console.info(`Start: ${dir}`);
const configPath = path.join(dir, 'config');
const config = await new Config(configPath, CFG_OPTIONS).catch((error) => {
exit(`Can not read configuration: ${configPath}\n${error.stack}`, 1);
});
await validateConfig(config);
if (master) {
impress.startTimer = setTimeout(
logError('Initialization timeout'),
config.server.timeouts.start,
);
const logger = await new Logger({ ...LOG_OPTIONS, ...config.log });
logger.on('error', logError('Logger'));
if (logger.active) impress.console = logger.console;
impress.logger = logger;
const tasksPath = path.join(dir, 'tasks');
const tasksConfig = config.server.scheduler;
impress.planner = await new Planner(tasksPath, tasksConfig, impress);
impress.config = config;
}
const { balancer, ports = [], workers = {} } = config.server;
const threads = new Map();
const pool = new Pool({ timeout: workers.wait });
const app = { root, path: dir, config, threads, pool, ready: 0 };
if (balancer) await startWorker(app, 'balancer', balancer);
for (const port of ports) await startWorker(app, 'server', port);
const poolSize = workers.pool || 0;
for (let i = 0; i < poolSize; i++) await startWorker(app, 'worker');
impress.applications.set(dir, app);
};
const loadApplications = async () => {
const applications = await fsp
.readFile('.applications', 'utf8')
.then((data) => data.split(/[\r\n\s]+/).filter((s) => s.length !== 0))
.catch(() => [path.join(PATH, 'application')]);
let master = true;
for (const dir of applications) {
const location = path.isAbsolute(dir) ? dir : path.join(PATH, dir);
await loadApplication(PATH, location, master);
if (master) master = false;
}
};
const stop = async (signal = 'SIGINT', code = 0) => {
const portsClosed = new Promise((resolve) => {
impress.console.info('Graceful shutdown in worker 0');
const timeout = setTimeout(() => {
impress.console.error('Exit with graceful shutdown timeout');
resolve();
}, impress.config.server.timeouts.stop);
impress.close = () => {
clearTimeout(timeout);
resolve();
};
});
for (const app of impress.applications.values()) {
broadcast(app, { name: 'stop' });
}
await portsClosed;
exit(`Application server stopped by ${signal}`, code);
};
process.removeAllListeners('warning');
process.on('warning', logError('warning'));
process.on('uncaughtException', logError('Uncaught exception'));
process.on('unhandledRejection', logError('Unhandled rejection'));
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
process.on('TERMINATE', stop);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.on('data', (data) => {
const key = data[0];
if (key === CTRL_C) stop();
});
}
loadApplications().catch(logError('Initialization'));