Jump-start your NodeJS/Bun/... services with dependency & lifecycle management and handy helper methods.
Froge is Typescript-focused and allows type safe access to services in the context.
npm i froge- Basic usage
- Advanced example
- Start one specific service
- Inferred context
- Reverse dependencies (service plugs)
- Plugins and plugin development
- Full configuration reference
Froge lets you define service groups which depend on each other.
Calling froge() creates a Froge Server, which can be populated with services:
up()defines how to start servicesdown()defines how to stop themuse()adds services from another server instance (effectively, a plugin)
Services are stopped in the reverse order.
import froge from 'froge';
const plugin = froge().up({
externalService: ctx => 'I am a service from another instance',
});
const server = froge()
.up({
service1: ctx => 'I am service 1',
service2: async ctx => await new Promise(resolve => resolve('I am service 2')),
})
.up({
service3: ctx => `I am service 3 and I depend on "${ctx.services.service1}"`,
})
.use(plugin)
.up({
service4: ctx => `I depend on another instance service "${ctx.services.externalService}"`,
});
server.down({
service1: service1 => console.log(`I'm stopping "${service1}"`),
service4: async service4 => await new Promise(resolve => {
console.log(`I'm stopping "${service4}"`);
resolve();
}),
})
server.launch()
.then(() => console.log('Server is ready'));Slightly more realistic example demonstrating all available features
import froge from 'froge';
// 3rd party libraries used in an example:
import 'dotenv/config'; // load .env file
import mysql from 'mysql2/promise';
import { Telegraf } from 'telegraf';
import express from 'express';
import type { Server } from 'http';
froge()
.configure({
// If started with launch() method, Froge will handle Ctrl+C
// It's recommended to set timeout to kill the app if it didn't stop on it's own
gracefulShutdownTimeoutMs: 15000,
// If you don't want to see console output
verbose: false,
})
// First group of the services
.up({
db: ctx => mysql.createPool({
// Use handy helpers for validating common env var values
host: ctx.envs.MYSQL_HOST.string('localhost'),
port: ctx.envs.MYSQL_PORT.port(3306),
// ...
}),
hourlyJoke: async ctx => {
const fetchJoke = async () => (await fetch(`https://v2.jokeapi.dev/joke/${ctx.envs.JOKE_TOPIC.string('Programming')}?format=txt&type=single`)).text();
let joke = await fetchJoke();
let interval = setInterval(async () => {
joke = await fetchJoke();
}, 3600000);
return {
get joke() {
return joke;
},
stop: () => clearInterval(interval),
};
},
})
// Second group of services, which depend on first
.up({
api: ctx => {
const db = ctx.services.db;
return {
routes: express.Router()
.get('/joke', async (req, res) => {
// Access other services
res.send(ctx.services.hourlyJoke.joke);
})
.post('/like', async (req, res) => {
await db.query('UPDATE likes SET amount = amount + 1 WHERE joke = ?', [req.query.joke]);
}),
};
},
telegram: async ctx => {
const bot = new Telegraf(ctx.envs.TG_BOT_TOKEN.s() /* s() is short for string() */);
const webhookRoutes = await bot.createWebhook({domain: ctx.envs.PUBLIC_ADDRESS.string()});
return { bot, webhookRoutes };
},
})
// Now it's time for http server, which exposes routes from other services above
.up({
http: async ctx => {
let server: Server;
await new Promise<void>((resolve, reject) => {
server = express()
.use(ctx.services.telegram.webhookRoutes)
.use('/api', ctx.services.api.routes)
.listen(ctx.envs.LISTEN_PORT.port(8080), err => err ? reject(err) : resolve());
});
return server!;
},
})
// Define how to stop the services
.down({
db: async pool => await pool.end(),
hourlyJoke: service => service.stop(),
telegram: async service => service.bot.stop(),
http: async service => service.close()
})
// .launch() automates lifecycle management, but it can be handled manually using .start()/.stop() instead
.start()
.then(froge => {
console.log("I'm ready!");
process.once('SIGINT', () => {
froge.stop().catch(e => {
console.error('Failed to stop: ', e);
process.exit(1);
});
});
})
.catch(e => {
console.error('Failed to start: ', e);
process.exit(1);
});only method starts a specific service and all it's dependencies.
It can be useful to write cli commands for your server.
Imagine a server which has a db service and some others, defined in server.ts:
import froge from 'froge';
import { createPool } from 'mysql2/promise';
export default froge()
.up({ /* dependencies of db (imagine something here), will be started */ })
.up({
// db - will be started
db: ctx => createPool({
host: ctx.envs.MYSQL_HOST.s('localhost'),
port: ctx.envs.MYSQL_PORT.port(3306),
// ...
}),
something: () => 'something else', // won't start
})
.up({ /* more services that won't start */ })
.down({
db: pool => pool.end(),
})You only need to start db in the cli command migrate:
import { Command } from 'commander';
import server from './server';
const program = new Command();
program.command('migrate')
.description('Init database structure')
.action(async () => {
const db = await server.only('db'); // this will only start db and it's dependencies
try {
await db.query('CREATE TABLE ...');
} finally {
await server.shutdown();
}
});
program.parse();When service has lots of dependencies, you may want to pass the context as is to the service instead.
This is a more invasive approach as your services will have to know about the froge context, but it can be handy sometimes.
server.ts
import froge, { type InferContext } from "froge";
import { TestService } from "./test-service";
export const server = froge().up({
test1: () => 'test1',
test2: () => 'test2',
}, 'alpha' /* <== */).up({
testService: ctx => new TestService(ctx),
}, 'beta');
// Contains all services from the first group named "alpha"
export type AlphaContext = InferContext<typeof server, 'alpha' /* <== */>;
// Contains all services from the second group named "beta"
export type BetaContext = InferContext<typeof server, 'beta'>;test-service.ts
import type { AlphaContext } from "./server";
export class TestService {
constructor(private ctx: AlphaContext) {}
public test() {
// Services from the first group available
return this.ctx.services.test1 + '+' + this.ctx.services.test2;
}
}Sometimes the service may need to communicate with a service in the group below.
There are two ways to implement this.
import froge from "froge";
import EventEmitter from 'events';
const server = froge().up({
events: () => new EventEmitter(),
}).up({
service1: ctx => ({
// there is no service2 in the context, but we can send an event
sendFoo: () => ctx.services.events.push('foo', 'bar'),
}),
}).up({
service2: ctx => {
ctx.services.events.on('foo', data => console.log(data));
},
});
await server.launch();
server.services.service1.sendFoo(); // prints "bar"A more complex method would be to add a plug for a service, which itself will be added later.
import froge from "froge";
const server = froge().up({
service2: ctx => ctx.plug<{
acceptFoo: (data: string) => void,
}>(),
}).up({
service1: ctx => ({
// there is a plug for service2 in the context, with acceptFoo method available
sendFoo: () => {
// It must not be accessed before actual service2 started, it will cause an error
if (!ctx.service.service2.isReady) {
console.log('service2 not ready yet');
} else {
// Note! service2 is called as a function to access the service
ctx.services.service2().acceptFoo('bar');
}
},
}),
}).up({
// Normally, existing service can't be overwritten (unless it's a plug)
// Type declaration must be compatible with a plug defined above (extra properties are allowed)
service2: ctx => {
const myService = {
acceptFoo: (data: string) => console.log(data),
somethingElse: () => console.log('Something else!'),
};
// Note! Plug services must return a function
return () => myService;
},
});
await server.launch();
server.services.service1.sendFoo(); // prints "bar"
server.services.service2().somethingElse();Froge server use() method can be used for code organisation (split server into parts),
but it is also useful to develop plugins.
A callback can be passed to use() method, which allows to construct the plugin while having access
to services in the main instance.
Below is an example of a simple ExpressJS plugin:
import type { Server } from 'http';
function createExpressServer(app: any, port: number) {
return froge.up({
http: async ctx => {
let server: Server;
await new Promise<void>((resolve, reject) => {
server = app.listen(port, err => err ? reject(err) : resolve());
});
return server!;
},
}).down({
http: async service => service.close(),
});
}And how it can be used:
import froge from 'froge';
import mysql from 'mysql2/promise';
import express from 'express';
const server = froge().up({
db: ctx => mysql.createPool({
host: ctx.envs.MYSQL_HOST.string('localhost'),
port: ctx.envs.MYSQL_PORT.port(3306),
// ...
}),
}).use(ctx => {
// DB pool from a previous step
const db = ctx.services.db;
// Normal ExpressJS app
const app = express()
.post('/increment', async (req, res) => {
await db.query('UPDATE counts SET amount = amount + 1 WHERE key = ?', [req.query.key]);
});
// Create the plugin (server, which will start/stop express for us)
return createExpressServer(app, ctx.envs.LISTEN_PORT.port(8080));
}).down({
db: pool => pool.end(),
});
await server.launch();interface FrogeConfig {
/** Start services which don't depend on each other in parallel */
parallelStartGroups: boolean,
/** Stop services which don't depend on each other in parallel */
parallelStopGroups: boolean,
/** Kill the process if shutdown took longer than expected */
gracefulShutdownTimeoutMs?: number,
/** Force exit the current process after shutdown is completed */
forceExitAfterShutdown: boolean,
/** Print info logs */
verbose: boolean,
}