froge
Version:
Jump-start your NodeJS/Bun/... services with dependency & lifecycle management and handy helper methods
289 lines • 10.9 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.envs = void 0;
exports.default = froge;
const env_1 = __importDefault(require("./env"));
exports.envs = env_1.default;
const plug_1 = require("./plug");
const AsyncFunction = async function () { }.constructor;
const defaultConfig = {
parallelStartGroups: true,
parallelStopGroups: true,
forceExitAfterShutdown: false,
verbose: true,
};
;
class FrogeServer {
map = new Map();
groups = new Set();
config = { ...defaultConfig };
plugins = new Map;
currentLevel = -1;
symbol = Symbol();
configure(config) {
this.config = { ...this.config, ...config };
return this;
}
services = new Proxy({}, {
get: (_, prop) => {
const service = this.map.get(prop);
if (typeof service === 'undefined') {
throw new Error(`Can't access service "${prop}", make sure it exists and started`);
}
if (service.plug) {
return service.plug;
}
if (!service.up) {
throw new Error(`Can't access service "${prop}" before it was started`);
}
return service.value;
},
});
up(services, group) {
if (group && this.groups.has(group)) {
throw new Error(`Group with key ${group} already exists, trying to add new group with the same name (${Object.keys(services).join(', ')})`);
}
const level = ++this.currentLevel;
for (const key in services) {
const existing = this.map.get(key);
let maybePlug;
if (existing) {
const errorMsg = `Trying to override existing service ${key} from group ${existing.group ?? 'undefined'}.`
+ '\nOnly plugs can be overridden. Function defining a plug must not be async and must only use ctx.plug() method.'
+ '\nExample: ctx => ctx.plug<MyService>()';
if (existing.init instanceof AsyncFunction) {
throw new Error(errorMsg + `\nInit function for ${key} is async`);
}
const plugContextMock = new Proxy({}, {
get(_, prop) {
if (prop === 'plug') {
return plug_1.plug;
}
throw new Error(errorMsg + `\nInit function for ${key} tried accessing ctx.${prop}`);
}
});
try {
maybePlug = existing.init(plugContextMock);
}
catch (e) {
throw new Error(errorMsg + `\nInit function for ${key} raised an error: ${e}`);
}
if (!maybePlug?.isFrogePlug) {
throw new Error(errorMsg + `\nInit function for ${key} didn't return a Froge plug`);
}
// Ok, we are satisfied, it's definitely a plug. Deleted to ensure correct startup order.
this.map.delete(key);
}
this.map.set(key, { level, group, up: false, init: services[key], plug: maybePlug, serverSymbol: this.symbol });
}
group && this.groups.add(group);
return this;
}
down(destroyers) {
for (const key in destroyers) {
const service = this.map.get(key);
if (typeof service === 'undefined') {
throw new Error(`Trying to add destroyer to unknown service ${key}`);
}
service.destroy = destroyers[key];
}
return this;
}
use(other,
/** Change plugin config to match main instance */
pushConfig = true) {
const container = {
factory: other instanceof FrogeServer ? () => other : other,
pushConfig,
};
const after = this.currentLevel;
if (this.plugins.has(after)) {
this.plugins.get(after)?.push(container);
}
else {
this.plugins.set(after, [container]);
}
return this;
}
async startPlugins(level) {
const groupPlugins = this.plugins.get(level);
if (!groupPlugins) {
return;
}
for (const plugin of groupPlugins) {
if (plugin.server) {
continue;
}
plugin.server = await plugin.factory({
services: this.services,
envs: env_1.default,
});
if (plugin.pushConfig) {
plugin.server.configure(this.config);
}
for (const key of plugin.server.map.keys()) {
if (this.map.has(key)) {
throw new Error(`Plugin service ${key} is conflicting with existing service ${key}`);
}
}
await plugin.server.start();
for (const [key, container] of plugin.server.map) {
this.map.set(key, container);
}
}
}
async stopPlugins(level) {
const groupPlugins = this.plugins.get(level);
if (!groupPlugins) {
return;
}
for (const plugin of groupPlugins.toReversed()) {
if (!plugin.server) {
continue;
}
await plugin.server.stop();
for (const key of plugin.server.map.keys()) {
this.map.delete(key);
}
plugin.server = undefined;
}
}
async startService(key, container) {
if (container.up) {
this.config.verbose && console.log(`[${key}] Already initialized`);
return;
}
this.config.verbose && console.log(`[${key}] Initializing...`);
const value = container.init({
services: this.services,
envs: env_1.default,
log: (...items) => this.config.verbose && console.log(`[${key}]`, ...items),
plug: () => (0, plug_1.plug)(key),
});
if (value instanceof Promise) {
container.value = await value;
}
else {
container.value = value;
}
if (container.plug) {
container.plug.__startedService = container.value;
}
container.up = true;
if (container.value?.isFrogePlug) {
console.warn(`[${key}] Got a plug instead of the service`);
}
else {
this.config.verbose && console.log(`[${key}] Ready`);
}
}
async stopService(key, container) {
if (typeof container.destroy === 'undefined' || typeof container.value === 'undefined') {
return;
}
this.config.verbose && console.log(`[${key}] Destroying...`);
await container.destroy(container.value);
container.value = undefined;
if (container.plug) {
container.plug.__startedService = undefined;
}
container.up = false;
this.config.verbose && console.log(`[${key}] Destroyed`);
}
async startInternal(target) {
const targetLevel = target && this.map.get(target)?.level;
const startGroups = Map.groupBy(this.map.entries().filter(([key, info]) => key === target || typeof targetLevel === 'undefined' || info.level < targetLevel), ([, info]) => info.level);
await this.startPlugins(-1);
for (const [level, group] of startGroups.entries()) {
if (this.config.parallelStartGroups) {
await Promise.all(group.map(entry => this.startService(...entry)));
}
else {
for (const entry of group) {
await this.startService(...entry);
}
}
if (level !== targetLevel) {
await this.startPlugins(level);
}
}
}
async only(key) {
const info = this.map.get(key);
if (!info) {
throw new Error(`Service ${key} doesn't exist or is from a plugin`);
}
if (!info.up) {
this.config.verbose && console.log(`Starting only service '${String(key)}' and dependencies...`);
await this.startInternal(key);
}
return this.services[key];
}
async start() {
this.config.verbose && console.log('Starting...');
await this.startInternal();
return this;
}
async stop(reasonText) {
this.config.verbose && console.log(`Stopping (${reasonText ?? 'unspecified reason'})...`);
const stopGroups = Map.groupBy(Array.from(this.map.entries())
// only stop my services
.filter(([, c]) => c.serverSymbol === this.symbol)
// in reverse order
.reverse(), ([, info]) => info.level);
for (const [level, group] of stopGroups.entries()) {
await this.stopPlugins(level);
if (this.config.parallelStopGroups) {
await Promise.all(group.map(entry => this.stopService(...entry)));
}
else {
for (const entry of group) {
await this.stopService(...entry);
}
}
}
await this.stopPlugins(-1);
}
async launch() {
if (!this.config.gracefulShutdownTimeoutMs) {
console.info('gracefulShutdownTimeoutMs config option not set, fallback to 60 sec');
this.config.gracefulShutdownTimeoutMs = 60000;
}
try {
await this.start();
process.once('SIGINT', () => this.shutdown('SIGINT'));
process.once('SIGTERM', () => this.shutdown('SIGTERM'));
}
catch (e) {
console.error('Failed to start: ', e);
await this.shutdown('failed start cleanup');
}
return this;
}
async shutdown(reasonText) {
const timeoutInfo = this.config.gracefulShutdownTimeoutMs ? `timeout: ${this.config.gracefulShutdownTimeoutMs}ms` : 'no timeout';
if (this.config.gracefulShutdownTimeoutMs) {
setTimeout(() => {
console.error(`Reached shutdown timeout ${this.config.gracefulShutdownTimeoutMs}ms, killing...`);
process.exit(1);
}, this.config.gracefulShutdownTimeoutMs).unref();
}
try {
await this.stop((reasonText ?? 'shutdown') + ', ' + timeoutInfo);
if (this.config.forceExitAfterShutdown) {
process.exit(0);
}
}
catch (e) {
console.error('Shutdown incomplete, killing... Reason:', e);
process.exit(1);
}
}
}
function froge() {
return new FrogeServer();
}
//# sourceMappingURL=index.js.map