Skip to content

rlqd/froge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Froge

Jump-start your NodeJS/Bun/... services with dependency & lifecycle management and handy helper methods.

froge

Froge is Typescript-focused and allows type safe access to services in the context.

npm i froge

NPM Version NPM Downloads

Learn more

Basic usage

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 services
  • down() defines how to stop them
  • use() 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'));

Advanced example

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);
        });

Start one specific service

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();

Inferred context

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;
    }
}

Reverse dependencies (service plugs)

Sometimes the service may need to communicate with a service in the group below.

There are two ways to implement this.

Event emitter:

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"

Service plugs:

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();

Plugins and plugin development

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();

Full configuration reference

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,
}

About

Jump-start your NodeJS services with dependency & lifecycle management

Resources

License

Stars

Watchers

Forks

Packages

No packages published