Kamiq - a Typescript, lightweight, Nest-like, batteries-included web framework

valentinkuharic

Valentin Kuharic

Posted on August 10, 2023

Kamiq - a Typescript, lightweight, Nest-like, batteries-included web framework

Table of contents

1. Prelude

On my last job, I was handling a project consisting of a legacy backend + frontend codebase where I was constantly refactoring code - it was either unusable, totally unreadable, or both. To make things even worse, the backend was lacking any structure or goal - it was just code thrown around that somehow managed to work most of the time. In the midst of all the refactoring I found myself slowly adding some architectural elements to the codebase, since it was the only way any new code addition could be maintained later. The codebase didn't even separate the controller logic from service logic!

With time, things started to take shape, and all my architectural additions helped made the codebase kind of usable. Since moving away from Express wasn't a choice at the time, but numerous new features constantly asked for some new abstractions and design patterns just to keep things organized, I slowly found myself with a backend project that I tailored to my kind of thinking.

In the meantime, I left that job but all those concepts stuck with me, so as a learning project I decided to wrap them in a standalone package that can be used cleanly and simply. I never wrote a package before, but I thought this would be a short, fun and productive endeavour. Here I am sharing it with the world so we can discuss, learn and figure out what makes a good framework together. With that, I present to you: Kamiq!

⚠️ Disclaimer

Before you dive in:

  1. In this post I'll be talking about a personal learning project I've been working on. Expect a half-baked but interesting project and a discussion on what makes a fun to use web framework.

2. Enough intro - show me the code.

Short description first:

Kamiq is a TypeScript framework for building server-side applications with heavy decorator usage. It combines object-oriented and functional programming approaches to achieve it's minimal syntax design. Kamiq is built on top of Express.js and by design offers high interoperability with Express, enabling the user to easily port over their existing Express.js code including routes, middlewares and more.

I'm not treating this project as something I'd like to push through to being complete or production ready - it's merely a learning project and a discussion on node web frameworks in a codebase format. I'm always open to learning new stuff and one of the best ways to learn is to reinvent the wheel, which is what I'm doing here.

The following script covers the most important parts of the project and doesn't cover more specific features and possibilities. Check out the full README on the Github repository (link at the end).

Right, let's get to it.

2.1. Configuring the server

To configure your server, instantiate an object from the Server class. Use the public functions to set your configuration object and any other properties.

import "reflect-metadata";
import { Server } from "kamiq";
import { DefaultErrorHandler, DefaultRequestLogger } from "kamiq/middlewares";

import { SampleController } from "./controllers/sampleController";

const server = new Server();

server.setPort(8001);
server.useJsonBodyParser(true);
server.useController(SampleController);
server.useCors(true);
server.setGlobalRequestLogger(new DefaultRequestLogger());
server.setGlobalErrorHandler(new DefaultErrorHandler(true));

server.start();
Enter fullscreen mode Exit fullscreen mode

2.2. Example

Here's an example of how a controller looks like:

import { BaseController } from "kamiq";
import { Middleware, Post, Req, Res } from "kamiq/decorators";
import { MySampleMiddleware } from "../middlewares/sampleMiddleware.middleware";
import { MySampleMiddleware2 } from "../middlewares/sampleMiddleware2.middleware";

export class SampleController extends BaseController {
  path = "/users"; // Base path for the following routes.

  @Guard(new AgencyAuthorizer(), {
    ignore: true, // Optional way to ignore a guard (or middleware)
  }) // Guards
  @Middleware(new LogSignInEvent("user")) // Middlewares
  @Post("/siginin") // Get controller registeres the route with a GET method and handles errors
  signIn(
    @Req() req: Request,
    @Res() res: Response,
    @Body() body: IUserSignIn,
    @Param("userId") userId: string
  ) {
    const { password } = body;

    const signIn = AuthService.signIn(userId, password); // Kamiq operation

    if (signIn.error) throw new AuthorizationError(signIn.error); // Picked up by global err handling middleware

    res.json({ msg: "success" });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's brake it down:

2.3. Controllers and routes

2.3.1. Controllers

Controllers are classes that extend the BaseController class. In each controller class you provide the path property - a route prefix for all routes defined in the class.

3.3.2. Routes

Each route is a function with the name of your choosing to which a decorator is attached with the HTTP method name: @Get() for a GET method, @Post() for POST etc. An HTTP decorator combined with a handler function forms a route. HTTP method decorators take in a string argument that is the route specific suffix. Note that this also supports dynamic routes by adding parameters to the route, as shown above in the example with :userId.

2.3.3. Parameters

The route handler itself supports multiple decorators that you can inject to get access to various properties of the request lifecycle. Since Kamiq uses Express.js to process HTTP requests, you can inject Express Request and Response objects into the hander like shown above with the @Req() and @Res() decorators. Also, you have access to @Body, @Query(), @Param(), which extract some specific data from the request as shown above.

2.3.4. Middlewares

Kamiq also supports middlewares, which you can attach to routes by using the @Middleware decorator. Middlewares are classes that extend the KamiqMiddleware interface, which is very similar to Nest's interface where the use() function is really just a vanilla Express.js middleware function.

This also has a nice side-effect where middleware functions can be passed arguments which can alter their behaviour. Consider the middleware as shown:

export class MySampleMiddleware implements KamiqMiddleware {
    private readonly someValue: boolean;

    constructor(someValue: boolean) {
        this.someValue = someValue;
      }

    async use(req: Request, res: Response, next: NextFunction): Promise<void> {

        // Can use someValue to change behavior...

        next()
    }
}

    // Route
    @Middleware(new MySampleMiddleware(false))
    @Middleware(new MySampleMiddleware2())
    @Post('/users')
    createUser(@Req() req: Request, @Res() res: Response) {

        // @ts-ignore

        res.send('user created.')
    }
Enter fullscreen mode Exit fullscreen mode

Note that if a route has multiple middlewares attached, the order of execution is respected.

Middlewares also accept a second argument, an options object where you can specify middleware execution instructions like the ability to bypass execution while testing with the ignore property.

2.3.5. Guards

Guards are special type of middlewares that follow the rule of single responsibility by only handlding authorization and authentication logic. They implement an interface similar to the middleware interface but they return a boolean value, corresponds to a successful or a failed operation, where a successful one means the request proceeds to the next middleware in it's lifecycle and a failure results in an authentication error (or a custom error you can define).

2.4. Error handling

Error handling in routes is hidden in how they are registered on server start. When routes are being registered they are wrapped in an requestErrorHandler middleware which wraps the route handler in a try/catch block. This ensures any error being thrown, by the application or the user, will be caught.

Catching errors is now taken care of, but we still need to handle them. Kamiq offers a public function you can access on the server object called setGlobalErrorHandler which takes in a class that implements the KamiqErrorMiddleware interface. This is a wrapper for an express error middleware that you can pass to the function which will register the middleware and process all caught errors.
Kamiq provides a default defaultErrorHandler middleware you can use - or of course, you can easily write your own.

A BaseError class is included in the package that extends the Node's Error, making it trivial to write your own custom errors. Simply extend the BaseError class, add your own logic and due to the fact that all route handlers are wrapped in a try/catch block, simply throw your custom error anywhere in the handlers:

@Get('/test')
ping(@Res() res: Response) {

        throw new CustomAuthError("my error message!")

        res.send('success')
    }
Enter fullscreen mode Exit fullscreen mode

and Kamiq will handle everything for you.

2.5. Operations

Operations are a special function type that aide with inter-layer communication within your codebase. Let's consider a simple backend architecture, consisting of three layers: presentation, service and data-access layer.

Errors should be handled at the controller level, making communication between the layers troublesome, considering any logic may error. This also raises the question of how to handle user-invoked errors at sub-controller levels.

Any general function can be an Operation by attaching the Operation() decorator to it.

Operation functions are general functions that are wrapped in a try/catch block and have an OperationResult return type:

export type OperationResult<T> =
  | { success: true; data: T }
  | { success: false; error: Error };
Enter fullscreen mode Exit fullscreen mode

This ensures any Operation function will return an object with the success property set to true if the operation was successful, together with the data property. In case of failure, success will be false, and the error property will be returned, making it very simple for the receiver of the result to conditionally handle errors:

// Operation function

class MyService {
  @Operation
  static myOperationFunction() {
    throw new Error("oops!");
  }
}

// Operation receiver
function mockControllerFunction() {
  const operationResult = MyService.myOperationFunction();

  // handling the result:
  if (operationResult.error) {
    // handle error case (throw the error and it will be caught)
  }

  const result = operationResult.success;
  // continue...
}
Enter fullscreen mode Exit fullscreen mode

Tip: Combine service level functions as Operations with the controller-level error handling to create bulletproof controller-service logic.

2.6. Kamiq errors

Kamiq offers descriptive, prettified framework-level error handling. If you make any configuration or definition errors at the framework level, Kamiq will throw it's custom error to help you resolve the problem. Here's an example of such error being thrown, invoked due to a misconfigured port variable:

InvalidArgumentError: 
   ┌ Kamiq encountered an error! ────────────────────────────────────────────┐
   │                                                                         │
   │                                                                         │
   │   InvalidArgumentError:                                                 │
   │   Port must be a number between 1 and 65535. Provided port is 5236203   │
   │                                                                         │
   │   Suggestion: Please check your server configuration.                   │
   │                                                                         │
   │                                                                         │
   │                                                                         │
   └─────────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

there's only a few of such errors as of now, but more checks can be easily added.

3. Here's the repo

You can visit the repository here, or visit the npm page here.

4. There is a lot more to do

It ain't much, but it's honest work, right?

There's a lot of features I'd like to add, and many bugs I'd like to fix. Many important functionalities are missing, such as:

  1. Handling cookies
  2. Handling content types
  3. File uploading and management
  4. Monitoring
  5. Input validation and sanitization
  6. Rendering templates
  7. Sessions

and more.

5. Outro

Thank you for reading this through. I'd like to hear your opinion on this project and can't wait to learn from your ideas and discussions. Check out the repository and feel free to voice your opinions. I know there's plenty wrong I did, but improving it is why I'm sharing this in the first place. Enjoy!

Cover image by Trina Snow (reinventing the wheel, get it?)

💖 💪 🙅 🚩
valentinkuharic
Valentin Kuharic

Posted on August 10, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related