npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@avanzu/kernel

v1.4.4

Published

Robust foundation for creating scalable (micro-)services applications

Downloads

16

Readme

@avanzu/kernel

The package provides a robust foundation for creating scalable (micro-)services applications. It features a modular architecture, leveraging dependency injection for managing application components, and supporting middleware for handling HTTP requests and responses. The package is designed to promote clean, maintainable code and facilitate easy integration of additional functionality, making it ideal for building both simple and complex services.

Attention: If you are not interested in the introduction, feel free to use the quickstart project to get right into development.

Getting started

Create a new typescript enabled project.

mkdir <myproject> && cd <myproject>

npm init

npm i -D typescript

npx tsc --init

Make sure to enable experimental decorators.

// tsconfig.json
{
    "compilerOptions" : {
        //...
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
    }
}

Install dependencies

npm i @avanzu/kernel jsonwebtoken pino

Install minimum dev dependencies

npm i -D @types/koa

Install additional (dev) dependencies as needed.

Create boilerplate code

The code snippets provided represent the absolute minimal codebase that has to be present in order for the kernel to run. As such, they are organized as if everything was in one single file.However, it is highly recommended to split the codebase into separate files and folders.

Extend interfaces provided by the kernel to simplify tying in your application later on.

// kernel.ts

import * as Kernel from '@avanzu/kernel'

// declare configuration options that your application needs
interface ConfigValues extends Kernel.ConfigOptions {}

interface Configuration extends Kernel.Configuration<ConfigValues> {}

// declare services that will be managed by your DIC to help with type safety during development
interface Services extends Kernel.AppServices {
    appLogger: Kernel.Logger,
    appConfig: Configuration
}

// extend kernel interfaces to use as shorthand during development
interface Container extends Kernel.Container<Services> {}
interface State extends Kernel.AppState<Container> {}
interface Context extends Kernel.AppContext<Container, State> {}
interface App extends Kernel.App<Container, State, Context> {}

You will need to provide an implementation for the Configuration interface.

The Config class centralizes application settings, enabling consistent configuration management across the application. It simplifies access to configuration values and supports environment-specific settings, enhancing maintainability and scalability.

minimal example

export class Config implements Kernel.Configuration<ConfigValues> {
    constructor(protected values: ConfigValues) {}

    get<P extends keyof ConfigValues>(key: P): ConfigValues[P] {
        if (!(key in this.values) || null == this.values[key]) {
            throw new Error(`Key ${key} is not configured`)
        }
        return this.values[key]
    }
}

Next, you need to create your container builder.

The ContainerBuilder class facilitates dependency injection by managing the creation and lifecycle of application components. It ensures that dependencies are provided efficiently and promotes loose coupling, making the application easier to test and extend.

For detailed instructions on how to register dependencies, see the awilix and the awilix-manager documentation.


import { asValue } from 'awilix'

export class ContainerBuilder implements Kernel.ContainerBuilder<Container> {

    constructor(protected options: Config, protected logger: Kernel.Logger) {}

    async build(container: Container): Promise<void> {
        // register dependencies as usual in awilix
        container.register('appLogger', asValue(this.logger))
        container.register('appConfig', asValue(this.options))
    }
}

Now, yo are able to create your application kernel.

For sake of simplicity, we use the builtin PinoLogger as our application logger. If you would prefer a different logging solution, feel free to replace it.

export class MyProjectKernel extends Kernel.Kernel<Config, App, Container> {

    protected createContainerBuilder(): Kernel.ContainerBuilder {
        return new ContainerBuilder(this.options, this.logger)
    }

    protected createLogger(): Kernel.Logger {
        return new Kernel.PinoLogger({})
    }
}

With all that in Place, you can start to create your Controllers, UseCases and any service that is required by them.

Let's create a simple Controller that acts as a healthcheck.

@Kernel.Controller()
export class AppController {

    @Kernel.Get('/health')
    async healthCheck(context: Context) {
        context.body = 'OK'
    }
}

Controllers don't have to be explicitly registered in the DIC. However, you still have to make sure, that the code is present when the application starts so that the application is arware of them.

Finally, you need an entrypoint that creates and launches your application.

;(async function main() {
    const config = new Config({
        host: '0.0.0.0',
        port: 3000,
        namespace: '',
        resources: {}
    })

    const kernel = new MyProjectKernel(config)

    process.on('SIGTERM', kernel.shutdown.bind(kernel))
    process.on('SIGINT', kernel.shutdown.bind(kernel))

    try {
        await kernel.boot()
    } catch (error) {
        console.error(error)
        process.exit(1)
    }

    try {
        await kernel.serve()
    } catch (error) {
        console.error(error)
        process.exit(2)
    }
})()

Assuming that you have, in fact, pasted all of the snippets into a single file (index.ts), you should now be able to build an launch your application.

npx tsc && node ./main.js

You should see a log line in your terminal similar to this one

{
    "level": 30,
    "time": 1716097817769,
    "pid": 1552845,
    "hostname": "...",
    "host": "0.0.0.0",
    "port": 3000,
    "url": "http://0.0.0.0:3000",
    "msg": "Server running"
}

With your server running, you are now able to call your health check. You can use your browser to do so or use something like curl or httpie in your terminal.

curl -v localhost:3000/health
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /health HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Content-Length: 2
< Date: Sun, 19 May 2024 05:56:00 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host localhost left intact
OK

Refactoring: divide and conquer

In order to provide a maintainable codebase, the recommended structure for your application would look something like this.

Feel free to modify file and folder names to your liking and/or naming conventions.

myproject/
    package.json
    tsconfig.json
    src/
        main.ts
        domain/
            entities/
            features/
            services/
        infrastructure/
            index.ts
        configuration/
            default.ts
            test.ts
            production.ts
            development.ts
        application/
            kernel.ts
            controllers/
                appController.ts
                index.ts
            dependencyInjection/
                containerBuilder.ts
                index.ts
            middleware/
            modules/
                configuration/
                    config.ts
            services/

Folder structure breakdown

Locating your codebase in a src/ folder allows to separate typescript files and typescript bild artifacts.

Inside of the sources folder, we organize horizontally in terms of abstraction layer. The folder structure inside of each layer is a suggestion but ultimately up to you.

  • domain/ - contains code that revolves around the buisness- or problem domain.Code in here has a very high abstraction level and should have no reason to import anything outside of the domain folder.Use interfaces to describe dependencies that your domain needs to function without them being part of the domain layer.
    • entities/ - Your domain models
    • features/ - business centric use cases
    • services/ - reusable behavior that can be shared amongst multiple use cases
  • infrastructure/ - contains concrete, technology centric implementations of the interfaces declared in the domain layer.
  • configuration/ - contains your application configuration. This setup assumes that you will run your application in different NODE_ENV environments which will have different configuration values.
  • application/ - contains application specific code like controllers, middlewares and the container builder.Here you will wire the abstractions of the domain layer with the concrete implementations of the infrastructure layer.
    • kernel.ts - your application kernel that manages the application lifecycle.
  • main.ts - the entrypoint of your application that loads the configuration, initializes and runs the kernel.

Build configuration

With this setup, the tsconfig.json needs some adjustments.

{
    "include": ["src"],
    "compilerOptions": {
        // ...
        "outDir": "./dist",
        "paths": {
            "~/*": ["./src/*"]
        }
    }
}

In order to use path mappings properly, we need some additional tooling.

npm i -D tsc-alias

Now let's add some scripts to your package.json to simplify the build process

{
    "scripts": {
        "build": "tsc -p tsconfig.json",
        "postbuild": "tsc-alias -p tsconfig.json",
        "start": "node ./dist/main.js"
    }
}

Attention: Although being very convenient, you don't have to use path mappings if you want to avoid the additional tooling that is required in the build process.

You should be able to divide an distribute the contents of our index.ts from Getting started into individual modules.

Loading controller code

As mentioned before, you don't have to register controllers explicitily in your container but the code itself needs to be loaded when the application starts.

In order to do so, make sure to barrel your controllers in an index.ts in your controllers folder which exports every controller.

Now you can import the barrel in your containerBuilder.ts

// ./src/application/dependencyInjection/containerBuilder.ts

import '~/application/controllers'

That way, when you add more controllers, you don't have to modify thecontainerBuilder.ts each time. Just make sure to add them to the exports of your index.ts barrel in the controllers/ folder.

Middlewares

Middleware functions in web applications handle requests and responses, performing tasks like logging, authentication, and validation. They enhance modularity and reusability, allowing developers to easily add or modify functionality.

Creating middlewares

In order to create a custom middlware, you can mainly follow the koa documentation. However, instead of using the types provided by koa, you will mostly use the shorthand declarations from getting started. Additionally, you will have access to your DIC in the scope property in the context object.

Assuming that you have your interfaces moved into ./src/application/interfaces, lets build a middleware that logs incoming requests.

// ./src/application/middlware/logRequests.ts

import { Next } from 'koa'
import { Context, Middleware } from '~/application/interfaces'

export function logRequests() : Middleware {
    return async function writeRequestLog(context: Context, next: Next) {
        const logger = context.scope.cradle.appLogger
        await next()
        logger.info(`${context.method} ${context.path} - ${context.status}`)
    }
}

Notice: it is totally fine to use arrow functions instead of named ones if you prefer the syntax.However, named functions provide the additional benefit to have their names show up in the stack trace which makes debugging much easier.

Adding middlewares

Since the server component is a koa application, you can use any pre made middleware from the koa ecosystem.

Application wide

In order to add application wide middlewares, you can overwrite the middlewares() method in your kernel.

As an example, let's integrate @koa/bodyparser and koa-helmet

Install dependencies

npm i @koa/bodyparser koa-helmet

Extend your kernel class

// ./src/application/kernel.ts

import { bodyParser } from '@koa/bodyparser'
import koaHelmet from 'koa-helmet'
// ...

export class MyProjectKernel extends Kernel.Kernel<Config, App, Container> {
    // ....
    protected middlewares() : Kernel.Middleware[] {

        return [
            koaHelmet(),
            bodyParser()
        ]
    }
}

Controller wide

In order to add middleware to every endpoint of a controller, you can add them in the Controller decorator.

Let's add our logRequests middleware to our AppController for example.

import * as Kernel from '@avanzu/kernel'
import { logRequests } from '~/application/middleware'
// ...
@Kernel.Controller('', logRequests()) // add additional middlewares as needed
export class AppController {
    // ...
}

Endpoint isolated

Attaching a middleware to a single endpoint is pretty much the same deal as attaching it to a controller. Simply add them to the decorator for that endpoint.

import * as Kernel from '@avanzu/kernel'
import { logRequests } from '~/application/middleware'
// ...
@Kernel.Controller() // add additional middlewares as needed
export class AppController {
    // ...
    @Kernel.Get('/health', logRequests())
    async healthCheck(context: Context) {
        context.body = 'OK'
    }
}

UseCases

In concept, a UseCase is responsible for handling a single, well-defined action or task within the domain. UseCases are designed to be isolated from the application and infrastructure layers, ensuring a clear separation of concerns within your architecture.

While the application kernel requires UseCases to be implemented as classes, it does not impose strict technical constraints on their structure beyond this requirement. This flexibility allows developers to design UseCases in a way that best suits their specific needs and domain logic.

To ensure consistency and interoperability across different UseCase implementations, it is recommended to define a common interface that all UseCases should follow. This interface standardizes how UseCases are invoked and handle input and output, making it easier to integrate them within the application layer.

One example for such an interface might look like this

export interface Feature<Input = any, Output = any> {
    invoke(value: Input): Promise<Output>
}

Creating UseCases

Assuming that you are using an interface similar to the one provided as example, let's imagine that the problem domain revolves around authentication.

One such single, well-defined operation could be a user login.

// ./src/domain/features/loginFeature.ts

import * as Kernel from '@avanzu/kernel'

export type LoginInput = {
    username: string
    password: string
}

export type LoginOutput = {
    token: string
    userId: string
}

@Kernel.UseCase({ id: 'login' })
export class LoginUseCase implements Feature<LoginInput, LoginOutput> {

    constructor(
        private userRepository: UserRepository,
        private authenticator: Authenticator
    ){}

    async invoke(value: LoginInput) : Promise<LoginOutput> {

        const user = await this.userRepository.findByUsername(value.username)
        const passwordHash = this.authenticator.hashPassword(value.password)
        if(user.password === passwordHash) {
            const token = await this.authenticator.createToken(user)
            return { token, userId: user.id }
        }
        throw new Error('NotAuthenticated')
    }
}

The UseCase itself is a relatively simple class. What makes it a UseCase from the kernel perspective is the UseCase annotation. Similar to the Controller annotation, it causes the kernel to register that class as a UseCase. This will come in handy when we integrate it into the application layer later on.

As you may have noticed, this UseCase expects two dependencies to be injected. Both of them revolve around a third type, the user which also needs to be defined.

Let's start to define the User interface which both components rely on. At least so far as we can extrapolate by now.

// ./src/domain/interfaces/user.ts
export interface User {
    id: string
    username: string
}

Now, we can declare the remaining two interfaces based on how we intend to use them in our UseCase.

// ./src/domain/interfaces/authenticator.ts
import type { User } from './user'

export interface Authenticator {
    createToken(user: User) : Promise<string>
    hashPassword(passwordString: string) : string
}
// ./src/domain/interfaces/userRepository.ts
import type { User } from './user'

export interface UserRepository {
    findByUsername(username: string) : Promise<User>
}

That's almost all we need to do in the domain layer.

Interface implementations

Now that we have declared our interfaces, we need to provide at least one concrete implementation in order to end up with a working login feature.

Attention: keep in mind, that the following implementations are kept extremely simple and only serve demonstrative purposes in context of this document.

User entity

Since the User is apparently a concept that exists in the business domain, the concrete implementation of the interface also needs to exist in the domain layer, we could replace the interface with a concrete entity class.

// ./src/domain/entities/user.ts

export class User {
    id!: string
    username!: string
    password!: string

    constructor(id?: string, username?: string, password?: string) {
        this.id = id
        this.username = username
        this.password = password
    }
}

MD5Authenticator

In the spirit of keeping things simple, we implement the authenticator interface which used and MD5 hash to authenticate the given user with the given passowrd string.

import type { Authenticator } from '~/domain/interfaces'
import { User } from '~/doamin/entities'
import { createHash, randomBytes } from 'node:crypto'

export class MD5Authenticator implements Authenticator {

    async createToken(user: User) : Promise<string> {
        return randomBytes(65).toString('hex')
    }

    hashPassword(passwordString: string): string {
        return createHash('md5').update(passwordString).digest('hex')
    }
}

InMemoryUserRespository

To keep it relatively simple, we first impelement the UserRepository as in memory storage.

// ./src/infrastructure/inMemoryUserRepository.ts

import type { UserRepository, Authenticator } from '~/domain/interfaces'
import { User } from '~/domain/entities'

export class InMemoryUserRepository implements UserRepository {
    private users: User[]
    constructor(private authenticator: Authenticator) {
        this.users = [
            new User(
                '012ae4d3',
                'Joseph',
                authenticator.hashPassword('qtZm4vzVv7')
            )
        ]
    }

    async findByUsername(username: string) : Promise<User> {
        const user = this.users.find((user) => user.username === username)
        if(false === Boolean(user)) {
            throw new Error('UserNotFound')
        }

        return user
    }
}

Application integration

Now that we have our indivdual parts, we need to bring them together in the application layer.

Application level interfaces

We intend to utilise our dependency injection container, we need to extend our Services interface in order to remain type safe.

import type { Authenticator, UserRepository } from '~/domain/interfaces'

interface Services extends Kernel.AppServices {
    userRepository: UserRepository
    authenticator: Authenticator
}

Instead of doing it this way, we could also declare a DomainServices interface in the domain layer and have our Services interface extend both.

ContainerBuilder

In our container builder we register the concrete implementations that we intend to use and make the application aware of our usecases.

// ./src/application/dependencyInjection/containerBuilder.ts

import { asValue, asClass } from 'awilix'
import {InMemoryUserRepository, MD5Authenticator } from '~/infrastructure'
// ...
import '~/domain/features'

export class ContainerBuilder implements Kernel.ContainerBuilder<Container> {
    // ...
    async build(container: Container): Promise<void> {
        // ...
        container.register('userRepository', asClass(InMemoryUserRepository))
        container.register('authenticator', asClass(MD5Authenticator))
    }
}

Controller

Depending on how you intend to design your api endpoints, you can approach the controller implementation differently.

UseCase Dispatcher

This approach goes for one single endpoint that handles every registered usecase.

import { Context } from '~/application/interfaces'
import * as Kernel from '@avanzu/kernel'

@Kernel.Controller('/features')
export class UseCaseDispatcher {

    @Kernel.All('/:useCaseId')
    async dispatch(context: Context) {
        const useCaseInfo = Kernel.getUseCase(context.params.useCaseId)
        if(false === Boolean(useCaseInfo)) {
            context.throw(404)
        }

        const useCase = context.scope.build(useCaseInfo.useCase)
        const input = context.method === 'get' ? context.query : context.body
        context.body = await useCase.invoke(input)
    }
}

Pros:

  • Flexibility: Handles various UseCases dynamically, making it ideal for smaller applications or those with rapidly changing UseCase requirements.
  • Unified Endpoint: Simplifies routing logic by having a single endpoint for all UseCases.

Cons:

  • Complex Input Handling: Requires additional logic to manage different input types and methods. Less Granular Control: Harder to enforce specific validation, authentication, and authorization rules per UseCase.
UseCase specific

This approach provides multiple endpoints where each one is responsible to handle exactly one usecase.

import { Context } from '~/application/interfaces'
import * as Kernel from '@avanzu/kernel'

@Kernel.Controller('/authentication')
export class AuthenticationController {

    @Kernel.Post('/signin')
    async handleSignIn(context: Context) {

        const useCaseInfo = Kernel.getUseCase('signin')

        if(false === Boolean(useCaseInfo)) {
            context.throw(404)
        }

        const useCase = context.scope.build(useCaseInfo.useCase)
        context.body = await useCase.invoke(context.body)
    }
}

Pros:

  • Granular Control: Easier to apply specific validation, authentication, and authorization rules for each endpoint.
  • Clear Structure: Each endpoint has a clear purpose, making the codebase easier to understand and maintain.
  • Simplified Error Handling: More straightforward error handling tailored to each specific UseCase.

Cons:

  • Increased Boilerplate: Requires more code to set up individual endpoints for each UseCase.
  • Scalability Challenges: Adding new UseCases involves creating new endpoints and potentially duplicating code.

Mixed approach

You could also adopt a mixed strategy, using the dispatcher for quick progression during the early development stages and transitioning to the UseCase-specific strategy as your application grows or when you encounter UseCases with different requirements that the dispatcher cannot accommodate.