@bluelibs/runner - v1.0.0

BlueLibs Runner

Build Status Coverage Status Docs

These are the building blocks to create amazing applications. It's a more functional approach to building small and large-scale applications.

These are the building blocks:

  • Tasks: Core units of logic that encapsulate specific tasks. They can depend on resources, other tasks, and event emitters.
  • Resources: Singleton objects providing shared functionality. They can be constants, services, functions. They can depend on other resources, tasks, and event emitters.
  • Events: Facilitate asynchronous communication between different parts of your application. All tasks and resources emit events, allowing you to easily hook. Events can be listened to by tasks, resources, and middleware.
  • Middleware: Intercept and modify the execution of tasks. They can be used to add additional functionality to your tasks. Middleware can be global or task-specific.

These are the concepts and philosophy:

  • Async: Everything is async, no more sync code for this framework. Sync-code can be done via resource services or within tasks, but the high-level flow needs to run async.
  • Type safety: Built with TypeScript for enhanced developer experience and type-safety everywhere, no more type mistakes.
  • Functional: We use functions and objects instead of classes for DI. This is a functional approach to building applications.
  • Explicit Registration: All tasks, resources, events, and middleware have to be explicitly registered to be used.
  • Dependencies: Tasks, resources, and middleware can have access to each other by depending on one another and event emitters. This is a powerful way to explicitly declare the dependencies.

Resources return through async init() their value to the container which can be used throughout the application. Resources might not have a value, they can just register things, like tasks, events, or middleware.

Tasks return through async run() function and the value from run, can be used throughout the application.

All tasks, resources, events, and middleware have to be explicitly registered to be used. Registration can only be done in resources.

npm install @bluelibs/runner
import { task, run, resource } from "@bluelibs/runner";

const minimal = resource({
async init() {
return "Hello world!";
},
});

run(minimal).then((result) => {
expect(result).toBe("Hello world!");
});

Resources are singletons. They can be constants, services, functions, etc. They can depend on other resources, tasks, and event emitters.

On the other hand, tasks are designed to be trackable units of logic. Like things that handle a specific route on your HTTP server, or any kind of action that is needed from various places. This will allow you to easily track what is happening in your application.

import { task, run, resource } from "@bluelibs/runner";

const helloTask = task({
id: "app.hello",
run: async () => console.log("Hello World!"),
});

const app = resource({
id: "app",
register: [helloTask],
dependencies: {
hello: helloTask,
},
async init(_, deps) {
await deps.hello();
},
});

It is unrealistic to create a task for everything you're doing in your system, not only it will be tedious for the developer, but it will affect performance unnecessarily. The idea is to think of a task of something that you want trackable as an action, for example:

  • "app.user.register" - this is a task, registers the user, returns a token
  • "app.user.createComment" - this is a task, creates a comment, returns the comment
  • "app.user.updateFriendList" - this task can be re-used from many other tasks or resources as necessary

Resources are more like services, they are singletons, they are meant to be used as a shared functionality across your application. They can be constants, services, functions, etc.

Resources can have a dispose() method that can be used to clean up resources. This is useful for cleaning up resources like closing database connections, etc. You typically want to use this when you have opened pending connections or you need to do some cleanup or a graceful shutdown.

import { task, run, resource } from "@bluelibs/runner";

const dbResource = resource({
async init(config, deps) {
const db = await connectToDatabase();
return db;
},
async dispose(db, config, deps) {
return db.close();
},
});

If you want to call dispose, you have to do it through the global store.

import { task, run, resource, global } from "@bluelibs/runner";

const app = resource({
id: "app",
register: [dbResource],
dependencies: {
store: global.resources.store,
},
async init(_, deps) {
return {
dispose: async () => deps.store.dispose(),
};
},
});

const value = await run(app);
// To begin the disposal process.
await value.dispose();

We want to make sure that our tasks are not dependent on the outside world. This is why we have the dependencies object.

You cannot call on an task outside from dependencies. And not only that, it has to be explicitly registered to the container.

You can depend on tasks, resources, events and middleware.

import { task, resource, run, event } from "@bluelibs/runner";

const helloWorld = task({
middleware: [logMiddleware],
dependencies: {
userRegisteredEvent,
},
});

const app = resource({
id: "app",
register: [helloWorld],
dependencies: {
helloWorld,
},
async init(_, deps) {
await deps.helloWorld();
},
});

run(app);

Resources can also depend on other resources and tasks. We have a circular dependency checker which ensures consistency. If a circular dependency is detected, an error will be thrown showing you the exact pathways.

Tasks are not limited to this constraint, actions can use depend on each other freely.

You emit events when certain things in your app happen, a user registered, a comment has been added, etc. You listen to them through tasks and resources, and you can emit them from tasks and resources through dependencies.

import { task, run, event } from "@bluelibs/runner";

const afterRegisterEvent = event<{ userId: string }>({
id: "app.user.afterRegister",
});

const root = resource({
id: "app",
register: [afterRegisterEvent],
dependencies: {
afterRegisterEvent,
},

async init(_, deps) {
// the event becomes a function that you run with the propper payload
await deps.afterRegisterEvent({ userId: string });
},
});

There are only 2 ways to listen to events:

import { task, run, event } from "@bluelibs/runner";

const afterRegisterEvent = event<{ userId: string }>({
id: "app.user.afterRegister",
});

const helloTask = task({
id: "app.hello",
on: afterRegisterEvent,
run(event) {
console.log("User has been registered!");
},
});

const root = resource({
id: "app",
register: [afterRegisterEvent, helloTask],
dependencies: {
afterRegisterEvent,
},
async init(_, deps) {
deps.afterRegisterEvent({ userId: "XXX" });
},
});

This is only for resources

import { task, resource, run, event } from "@bluelibs/runner";

const afterRegisterEvent = event<{ userId: string }>({
id: "app.user.registered",
});

const root = resource({
id: "app",
register: [afterRegisterEvent],
dependencies: {
// ...
}
hooks: [
{
event: afterRegisterEvent,
async run(event, deps) {
// the dependencies from init() also arrive inside the hooks()
console.log("User has been registered!");
},
},
],
async init(_, deps) {
deps.afterRegisterEvent({ userId: "XXX" });
},
});

Middleware is a way to intercept the execution of tasks. It's a powerful way to add additional functionality to your tasks. First middleware that gets registered is the first that runs, the last middleware that runs is 'closest' to the task, most likely the last element inside middleware array at task level.

import { task, run, event } from "@bluelibs/runner";

const logMiddleware = middleware({
id: "app.middleware.log",
dependencies: {
// inject tasks, resources, eventCallers here.
},
async run(data, deps) {
const { taskDefinition, next, input } = data;

console.log("Before task", taskDefinition.id);
const result = await next(input); // pass the input to the next middleware or task
console.log("After task", taskDefinition.id);

return result;
},
});

const helloTask = task({
id: "app.hello",
middleware: [logMiddleware],
run(event) {
console.log("User has been registered!");
},
});

You can use middleware creators (function that returns) for configurable middlewares such as:

import { middleware } from "@bluelibs/runner";

function createLogMiddleware(config) {
return middleware({
// your config-based middleware here.
});
}

However, if you want to register a middleware for all tasks, here's how you can do it:

import { run, resource } from "@bluelibs/runner";

const logMiddleware = middleware({
id: "app.middleware.log",
async run(data, deps) {
const { taskDefinition, next, input } = data;

console.log("Before task", task.id);
const result = await next(input);
console.log("After task", task.id);

return result;
},
});

const root = resource({
id: "app",
register: [logMiddleware.global() /* this will apply to all tasks */],
});

The middleware can only be registered once. This means that if you register a middleware as global, you cannot specify it as a task middleware.

Unfortunately, middleware for resources is not supported at the moment. The main reason for this is simplicity and the fact that resources are not meant to be executed, but rather to be initialized.

You have access to the global events if you want to hook into the initialisation system.

  • hooks are for resources to extend each other, compose functionalities, they are mostly used for configuration and blending in the system.
  • on is for when you want to perform a task when something happens.

If an error is thrown in a task, the error will be propagated up.

import { task, run, event } from "@bluelibs/runner";

const helloWorld = task({
id: "app.helloWorld",
run() {
throw new Error("Something went wrong");
},
});

const app = resource({
id: "app",
register: [helloWorld],
dependencies: {
helloWorld,
},
async init() {
await helloWorld();
},
});

run(app);

You can listen to errors via events:

const helloWorld = task({
id: "app.onError",
on: helloWorld.events.onError,
run({ error, input }, deps) {
// this will be called when an error happens
},
});

You can attach metadata to tasks, resources, events, and middleware.

import { task, run, event } from "@bluelibs/runner";

const helloWorld = task({
id: "app.helloWorld",
meta: {
title: "Hello World",
description: "This is a hello world task",
tags: ["api"],
},
run() {
return "Hello World!";
},
});

This is particularly helpful to use in conjunction with global middlewares, or global events, etc.

The interfaces look like this:

export interface IMeta {
title?: string;
description?: string;
tags: string[];
}

export interface ITaskMeta extends IMeta {}
export interface IResourceMeta extends IMeta {}
export interface IEventMeta extends IMeta {}
export interface IMiddlewareMeta extends IMeta {}

Which means you can extend them in your system to add more keys to better describe your actions.

We expose direct access to the following internal services:

  • Store (contains Map()s for events, tasks, resources, middleware configurations)
  • TaskRunner (can run tasks definitions directly and within D.I. context)
  • EventManager (can emit and listen to events)

Attention, it is not recommended to use these services directly, but they are exposed for advanced use-cases, for when you do not have any other way.

import { task, run, event, globals } from "@bluelibs/runner";

const helloWorld = task({
id: "app.helloWorld",
dependencies: {
store: globals.resources.store,
taskRunner: globals.resources.taskRunner,
eventManager: globals.resources.eventManager,
}
run(_, deps) {
// you benefit of full autocompletion here
},
});

We typically namespace using . like app.helloWorld. This is a convention that we use to make sure that we can easily identify where the task belongs to.

When creating special packages the convention is:

  • {companyName}.{packageName}.{taskName}

You can always create helpers for you as you're creating your tasks, resources, middleware:

function getNamespace(id) {
return `bluelibs.core.${id}`;
}

We need to import all the tasks, resources, events, and middlewares, a convention for their naming is to export them like this

import { userCreatedEvent } from "./events";
export const events = {
userCreated: userCreatedEvent,
// ...
};

export const tasks = {
doSomething: doSomethingTask,
};

export const resources = {
root: rootResource,
user: userResource,
};

Often the root will register all needed items, so you don't have to register anything but the root.

import { resource } from "@bluelibs/runner";
import * as packageName from "package-name";

const app = resource({
id: "app",
register: [packageName.resources.root],
});

run(app);

Now you can freely use any of the tasks, resources, events, and middlewares from the packageName namespace.

This approach is very powerful when you have multiple packages and you want to compose them together.

Typically you have an express server (to handle HTTP requests), a database, and a bunch of services. You can define all of these in a single file and run them.

import { task, resource, run, event } from "@bluelibs/runner";
import express from "express";

const expressServer = resource({
id: "app.express",
async init() {
const app = express();
app.listen(3000).then(() => {
console.log("Server is running on port 3000");
});

// because we return it you can now access it via dependencies
return app;
},
});

const setupRoutes = resource({
id: "app.routes",
dependencies: {
expressServer,
},
async init(_, deps) {
deps.expressServer.use("/api", (req, res) => {
res.json({ hello: "world" });
});
},
});

// Just run them, init() will be called everywhere.
const app = resource({
id: "app",
register: [expressServer, setupRoutes],
});

run();

The system is smart enough to know which init() to call first. Typically all dependencies are initialised first. If there are circular dependencies, an error will be thrown with the exact paths.

There's a resource for that! You can define a resource that holds your business configuration.

import { resource, run } from "@bluelibs/runner";

// we keep it as const because we will also benefit of type-safety
const businessData = {
pricePerSubscription: 9.99,
};

const businessConfig = resource({
id: "app.config",
async init() {
return businessData;
},
});

const app = resource({
id: "app",
register: [businessConfig],
dependencies: { businessConfig },
async init(_, deps) {
console.log(deps.businessConfig.pricePerSubscription);
},
});

run();

Resources are super configurable.

import { resource, run } from "@bluelibs/runner";

type EmailerOptions = {
smtpUrl: string;
defaultFrom: string;
};

const emailerResource = resource({
id: "app.config",
async init(config: EmailerOptions) {
return {
sendEmail: async (to: string, subject: string, body: string) => {
// send *email*
},
};
// or return some service that sends email
},
});

const app = resource({
id: "app",
register: [
// You can pass the config here
emailerResource.with({
smtpUrl: "smtp://localhost",
defaultFrom: "",
}),
// Leaving it simply emailerResource is similar to passing an empty object.
// We leave this for simplicity in some cases but we recommend using .with() for clarity.
],
});

run(app);
import { task, run, event } from "@bluelibs/runner";

const helloWorld = task({
id: "app.helloWorld",
});

// Each task and constant have their own events
const app = resource({
id: "app",
register: [helloWorld],
hooks: [
{
event: helloWorld.events.beforeRun,
async run(event, deps) {
event.data.input; // read the input
},
},
{
event: helloWorld.events.afterRun,
async run(event, deps) {
event.data.output; // you can read the input or output
},
},
{
event: helloWorld.events.onError,
async run(event, deps) {
event.data.error; // read the error that happened during execution
},
},
],
});

run(app);
import { task, run, event } from "@bluelibs/runner";

const businessConfig = resource({
id: "app.config",
async init() {
return businessData;
},
});

const app = resource({
id: "app",
register: [businessConfig],
hooks: [
{
event: businessConfig.events.beforeInit,
async run(event, deps) {
event.data.config; // read the input
},
},
{
event: businessConfig.events.afterInit,
async run(event, deps) {
event.data.value; // you can read the returned value of the resource
},
},
{
event: businessConfig.events.onError,
async run(event, deps) {
event.data.error; // read the error that happened during initialization
},
},
],
});

run(app);

This is just a "language" of developing applications. It simplifies dependency injection to the barebones, it forces you to think more functional and use classes less.

You can add many services or external things into the runner ecosystem with things like:

import { task, run, event } from "@bluelibs/runner";

const expressResource = resource<express.Application>({
id: "app.helloWorld",
run: async (config) => config,
});

const app = resource({
id: "app",
register: [expressResource.with(express())],
init: async (express) => {
express.get("/", (req, res) => {
res.send("Hello World!");
});
},
});

run(app);

This shows how easy you encapsulate an external service into the runner ecosystem. This 'pattern' of storing objects like this is not that common because usually they require a configuration with propper options and stuff, not an express instance(), like this:

const expressResource = resource({
id: "app.helloWorld",
run: async (config) => {
const app = express();
app.listen(config.port);
return app;
},
});

const app = resource({
id: "app",
register: [expressResource.with({ port: 3000 })],
init: async (express) => {
// type is automagically infered.
express.get("/", (req, res) => {
res.send("Hello World!");
});
},
});

run(app);

By stating dependencies you often don't care about the initialisation order, but sometimes you really do, for example, let's imagine a security service that allows you to inject a custom hashing function let's say to shift from md5 to sha256.

This means your resource needs to provide a way for other resources to update it. The most obvious way is to expose a configuration that allows you to set a custom hasher register: [securityResource.with({ ... })].

But other resources might want to do this dynamically as extensions. This is where hooks come in.

import { resource, run, event } from "@bluelibs/runner";

type SecurityOptions = {
hashFunction: (input: string) => string;
};

const securityResource = resource({
id: "app.security",
async init(config: SecurityOptions) {
let hasher = config.hashFunction;
return {
setHasher: (hashFunction: (input: string) => string) => {
hasher = hashFunction;
},
hash: (input: string) => hasher(input),
};
},
});

const app = resource({
id: "app",
register: [securityResource],
hooks: [
{
event: securityResource.events.afterInit,
async run(event, deps) {
const { config, value } = event.data;
const security = value;

security.setHasher((input) => {
// custom implementation here.
});
},
},
],
});

Another approach is to create a new event that holds the config and it allows it to be updated.

import { resource, run, event } from "@bluelibs/runner";

const securityConfigurationPhaseEvent = event<SecurityOptions>({
id: "app.security.configurationPhase",
});

const securityResource = resource({
id: "app.security",
dependencies: {
securityConfigurationPhaseEvent,
},
async init(config: SecurityOptions) {
securityConfigurationPhaseEvent(config);

return {
// ... based on config
};
},
});

const app = resource({
id: "app",
register: [securityResource],
hooks: [
{
event: securityConfigurationPhaseEvent,
async run(event, deps) {
const { config } = event.data; // config is SecurityOptions
config.setHasher(newHashFunction);
},
},
],
});

This package is part of the BlueLibs family. If you enjoy this work, please show your support by starring the main repository.

For feedback or suggestions, please use our feedback form.

This project is licensed under the MIT License - see the LICENSE.md file for details.