Architecture

The httpc server

The main task of an httpc server is to:

  1. translate the incoming standard http request into an httpc request (parsing)
  2. execute the function linked to the httpc request
  3. translate the result back into a standard http response (rendering)

The default translation is performed according the httpc call convention, which defines simple rules to identify which server function to call and extract the eventual arguments.


Any incoming request must match a function, otherwise the HTTP 404 is returned.


An httpc request is an HttpCall object defined with:

propertytypedescription
pathstringthe function handler to call, each function is identified by a unique path
access"read" | "write"if the request is a read call or a write call
metadataRecord<string, any>?optional metadata enriching the request
paramsany[]The parameters to pass to the function handler

Request processing flow

The following chart shows the processing of a single http request, from the parsing phase to the rendering.

http requesthttp responseParsingMiddleware 1Middleware NHandlerpipelineRenderingPreprocess

The main blocks are

Context

Before starting the processing flow, the server instantiates a new IHttpCContext for each incoming request. The context is a simple plain javascript object, where each field represents a context property.

Accessing the context

The context can be accessed from anywhere inside the processing flow.

const context = useContext();

Reading a context property is just a line of code

// accessing property by destructuring
const { requestId } = useContext();

// or use the property hook
const userId = useContextProperty("userId");

both work fine and both have typescript with autocompletion support.


Writing a property to context is easy

const user = readUser(); // getting the user from somewhere
// write the property
useContextProperty("userId", user.id);

// optionally, assign the new value at same time
const userId = useContextProperty("userId", user.id);

You can access the context from anywhere in the execution context of the request. Being inside a Parser, a Middleware or a function handler, the context is always available.

There’s no need to pass context data as function parameters in your logic. This has a fundamental impact on how to write code and how to test things.

Builtin properties

propertytypedescription
requestIdstringrandom uuid auto generated at the beginning
requesthttp.IncomingMessagenode native http request
startedAtnumberunix timestamp as returned by Date.now()

Extending the context

The context isn’t limited to the predefined properties. Additional properties can be defined with full typescript support.

Usually middlewares process the request and populate the context with common business data like authentication, session info, cached values, … which will be available later in the function handler.


See Extending for details.

Parsing

In the parsing step, the server transforms the http request into an httpc request. This is achieved by an HttpCServerCallParser.


A httpc request is an HttpCall object defined with

propertytypedescription
pathstringthe function handler to call, each function is identified by a unique path
access"read" | "write"if the request is a read call or a write call
metadataRecord<string, any>?optional metadata enriching the request
paramsany[]The parameters to pass to the function handler

A parser extracts the required info from the http request and creates the HttpCall object.


By default, the @httpc/server uses the builtin HttpCCallParser, which follows the httpc call convention to make calling a server function very easy.


The server is not limited to httpc calls only. It can handle browser form submissions, rest requests, web hooks or any raw http request, with custom parsers. You can have an hybrid server that, in addition to httpc calls, can respond to any http requests.


Each parser tells the server if it can manage the request. In that case, the parser returns the HttpCall object, otherwise the server will skip to the next parser. Therefore, the order used to define parsers matters.

HttpCCallParser

The builtin HttpCCallParser provides a straightforward way to expose a json-like API. It transforms an http request into an HttpCall by following the httpc call convention.


Details and configuration options are available in reference.

Builtin parsers

In addition to json API, the @httpc/server package offers several parsers covering additional use cases. For example, FormUrlEncodedParser to handle form submissions, or RawBodyParser to handle binary data.


You can check the reference for details.

Custom parsers

You can define a custom parser to handle a scenario not covered by the builtin parsers. You can define any number of parsers. The order by which the parsers are listed determines the priority.


The factory pattern is often used to define a custom parser.

import { HttpCServerCallParser } from "@httpc/server";

export type CustomerParserOptions = {
  // options
}

export function CustomParser(options: CustomerParserOptions) : HttpCServerCallParser | undefined {
    return async req => {

        if (!shouldParse(req)) {
            return; // this parser does not apply to this request, skip to the next
        }

        // parsing logic and return a call

        const call: HttpCall = { /* */ }

        return call;
    }
}

See Extending for details.

Preprocessing

After the parsing, the resulting HttpCall can be preprocessed and modified before it executes.


Common scenarios are:


You can define multiple rewriters. The server executes them following the order they are listed in a chain-like way, where the output of the first rewriter become the input of the second one.


The Preprocessing is an optional step. If no rewriters are defined, the HttpCall continues to the next step, with no modification.

Pipeline

On each incoming HttpCall, the server looks up for the relative function to execute. The match is based on the call path. When the sever starts, it setups one pipeline for each function you defined and associates each one with a unique path. More about the call path in the Call nesting and composition section.


After the function is found, the server executes it. If no function is found, the server will respond with the standard HTTP 404.


Each function defines its own pipeline with middlewares.

import { httpCall } from "@httpc/server";

const getUserById = httpCall(
    Authenticated(),   // <--  authentication middleware
    Validate(String),  // <--  validation middleware
    Cache("5minutes"), // <--  caching middleware

    // actual function logic
    async (userId: string) => { 
        return await db.select("users").where("id", userId).first();
    }
);

Middlewares

A pipeline is a chain of middlewares with the function handler itself at the last step. A middleware is a piece of code that wraps the execution of the next middleware in the chain. The last middleware wraps the actual function handler. Thus, the sequence of execution is defined by the order by witch the middlewares are defined.

HttpCallHandlerResultMiddleware 1Middleware 2Middleware NMiddleware 1Middleware 2Middleware N

A middleware is typed by HttpCServerMiddleware. Usually a middleware is defined with a factory, a function the returns the middleware itself.

import { HttpCServerMiddleware } from "@httpc/server";

export function CustomMiddleware(): HttpCServerMiddleware {
    return async (call, next) => {

        // middleware logic
        // with a call to the next middleware somewhere 
        return await next(call);

    };
}

With a middleware you can execute shared logic among all your functions. Common scenarios include:

import { HttpCServerMiddleware, UnauthorizedError } from "@httpc/server";

//
// Allow only authenticated users
//
export function AuthenticatedMiddleware(): HttpCServerMiddleware {
    return async (call, next) => {
        const { user } = useContext();
        if(!user) {
            throw new UnauthorizedError();
        }

        return await next(call);
    };
}

A middleware can be applied globally, on a specific sub area or on a single call.

// file: users.calls.ts

const getUserById = httpCall(
    Validate(String),   // <-- validation middleware
    Cache("5minutes"),  // <-- caching middleware
    async (userId: string) => {
        // omitted logic
    }
);

const createUser = httpCall(
    Validate(UserCreateModel) // <-- validation middleware,
    async (data: UserCreateModel) => {
    // omitted logic
    }
);

export default {
    getUserById,
    createUser
};

Call nesting and composition

Each call is associated with a unique path, in a tree-like folder/file structure.

You can arrange and group functions as you like. For example you can have users and articles functions in the following structure

/users
   /getById
   /create
   /update
/articles
   /getById
   /getByUserId

For the getById users function, the call the path is: /users/getById and, from the client, the code to call the function will be

const user = await client.users.getById(...);

You can have a flat structure where all functions are available at the first level, or you can nest and group them. The structure is just a plain object where each property represents a function call.


To group functions just assign them to a property, as a sub-object.

const getById = httpCall( /** omitted */ );

const create = httpCall( /** omitted */ );

export default {
    users: { // <-- users function group
        getById,
        create
    }
}

From the client, the calls will be available with the users property.

const user = await client.users.getById(userId);

const newUser = await client.users.create({
    username: "[email protected]",
    password: "strong-password"
});

Or, for better code organization, you can compose calls with standard imports. Define all users calls inside a dedicated file

// file: users.calls.ts

const getById = httpCall( /** omitted */ );

const create = httpCall( /** omitted */ );

export default {
    getById,
    create
}

and import them in the main calls file

// file: calls.ts
import users from "./users.calls";
import articles from "./articles.calls";

export default {
    users,
    articles,
}

You can further nest calls group inside another one. Because a call group is a simple javascript object, you can nest calls with a simple property assignment.


For example if you want friends calls as sub calls within users, just set a property friends on the users call object.

// file: friends.calls.ts

const add = httpCall(
    Validate(String, String, String),
    async (userId: string, friendId: string, listId: String) => {
        // omitted
    }  
);

export default {
  add
}

You can import calls and compose them, like there are a simple object.

// file: users.calls.ts
import friends from "./friends.calls";

const getById = httpCall( /** omitted */ );

export default {
    friends, // <-- composing all friends calls as nested
    getById,
}

From the client, the nested calls are available as properties.

const friend = await client.users.friends.add(userId, friendId, "friend-list-2");

Result

Anything a function handler returns will be the result. An handler can also throw an error to halt the execution. There are several builtin errors that map to the relative http error like BadRequestError, UnauthorizedError and so on.


If an error is thrown, the server will catch it, stop the pipeline execution and the error itself will be the result.


The pipeline result, either the object returned by the function handler (maybe post processed by the middleware chain) or the error if the an exception is thrown, will be passed to the rendering step.

Rendering

In the rendering step, the server transforms the pipeline result into an actual http response. This is achieved by several renderers.

An HttpCServerRenderer transforms the pipeline result into an HttpCServerResponse. You can define multiple renderers to handle different use cases, like a json response, an html template render, a redirect or, in general, anything the http protocol supports.


The @httpc/server package offers some builtin renderers. In addition, custom renderers can be defined.

import { HttpCServerRenderer, HttpCServerResponse } from "@httpc/server";

export function MyCustomRenderer() : HttpCServerRenderer {
    return async (result: unknown) : HttpCServerResponse | undefined => {
        
        if (!canIRenderThis(result)) {
            return; // this render doesn't handle this result, skip to the next render
        }

        // transform logic
        const response = /* omitted */

        return response;
    };
}

The server runs the renders in order. Thus the definition order matters as it sets the run sequence. A renderer can skip the transformation, if it cannot handle the result. When a renderer returns a response, the rendering phase stops, no additional renderers is executed and the response sent to the client.


As the @httpc/server is tailored for the json-API scenario, by default the builtin JsonRenderer is added to the renderer list. The JsonRenderer serializes any result object into json and sent it to the client.

A pipeline result can be: