Http Server

Repository: https://github.com/well-known-components/http-server

The port library @well-known-components/http-server implements the interface IHttpServerComponent.

This implementation is based in Koa sources, it provides a small code footprint to create HTTP servers with a powerful async/await programming model.

As it is defined in the component, the port only exposes a function use(handler) and setContext(context)

export interface IHttpServerComponent<Context extends object> {
  /**
   * Register a global handler
   */
  use: (handler: IHttpServerComponent.IRequestHandler<Context>) => void

  /**
   * Sets a context to be passed on to the handlers.
   */
  setContext(ctx: Context): void
}

Creating the component

The function createServerComponent creates an HTTP http server for your application. It requires a logs and a config components to startup.

Check out the example code in the template-server

IHttpServerComponent interface

One of the main points of using components and ports is that we can document the behavior of the component (IHttpServerComponent) instead of the port in particular. Enabling multiple implementations of the port maintaining the business logic untouched. Examples of different ports could be:

  • This port to handle node.js http(s) servers
  • A port to implement HTTP2/3 servers
  • A port to handle AWS Lambda requests

Hello world

This is a complete example of a server, although in a single file and not following the file conventions. It has everything to start a server and experiment with it.

Next examples will only contain handler code for clarity.

import { IConfigComponent, IHttpServerComponent, ILoggerComponent, Lifecycle } from "@well-known-components/interfaces"
import { createConfigComponent } from "@well-known-components/env-config-provider"
import { createServerComponent } from "@well-known-components/http-server"
import { createLogComponent } from "@well-known-components/logger"

// Record of components
type Components = {
  config: IConfigComponent
  logs: ILoggerComponent
  server: IHttpServerComponent<AppContext>
}

// Context passed to all handlers, we always include the components
// here
type AppContext = {
  components: Components
}

// Lifecycle.run manages the lifecycle of the application and components
// it is particularly useful for servers with many components with state
// like database connectors, servers, or batch jobs.
// It also handles POSIX signals like SIGTERM to gracefully stop the
// components
Lifecycle.run<Components>({ initComponents, main })

// main entry point of the application, it's role is to wire components
// together (controllers, handlers) and ultimately start the components
// by calling startComponents
async function main({ components, startComponents }: Lifecycle.EntryPointParameters<Components>) {
  const globalContext: AppContext = { components }

  // wire the server
  components.server.setContext(globalContext)

  components.server.use(async function logger(ctx, next) {
    // Log the response time of all the requests handled by this server
    console.time(ctx.url.toString())
    const response = await next()
    console.timeEnd(ctx.url.toString())
    return response
  })

  components.server.use(async function handler(ctx) {
    // Respond hello world
    return {
      status: 200,
      body: {
        json: true,
        text: "Hello world",
      },
    }
  })

  // start server and other components
  await startComponents()
}

// initComponents role is to create BUT NOT START the components,
// this function is only called once by the Lifecycle manager
async function initComponents(): Promise<Components> {
  const logs = createLogComponent()

  const config = createConfigComponent({
    HTTP_SERVER_PORT: "5000",
    HTTP_SERVER_HOST: "0.0.0.0",
  })

  const server = await createServerComponent<AppContext>({ logs, config }, {})

  return /*components*/ {
    logs,
    config,
    server,
  }
}

Middlewares

Middlewares are called in the same order as they were passed to the server, and the next middleware is awaiteable. That makes possible and easy many configurations like pre-order calling, in-order and post-order.

// function main()

// Log the response time of all the requests handled by this server
components.server.use(async function timeLogger(ctx, next) {
  // start measuring time
  console.time(ctx.url.toString())

  // get the actual response, calling the next middleware
  const response = await next()

  // measure total time and print to console
  console.timeEnd(ctx.url.toString())

  // return response from the middleware
  return response
})

components.server.use(async function handler(ctx, _nextMiddleware) {
  // although _nextMiddleware may exist, this handler simply returns
  // a response, therefore, the _nextMiddleware will never be called
  // and could be ommited from the signature

  // Respond hello world
  return {
    status: 200,
    body: {
      json: true,
      text: "Hello world",
    },
  }
})

Routes

Since the http-server is overly-simplistic in its implementation, routes should be handled by the user, but the library also includes a Router class that can be used to create the handler middleware.

// to keep things testable, it is recommended to return the router
// instead of binding it to the server directly
function createRouter() {
  const router = new Router<AppContext>()

  router.get("/users/:id", handleUserById)
  router.post("/users", handleCreateUser)

  return router
}

async function main({ components, startComponents }) {
  const globalContext: AppContext = { components }

  /// Wire the server
  components.server.setContext(globalContext)

  /// Maybe use some middlewares to log every request?
  //    components.server.use(globalLoggerInterceptor)

  /// Wire the server to the global router
  components.server.use(createRouter().middleware())

  /// Start server and other components
  await startComponents()
}

Request object

The request follows the WHATWG Fetch Request standard. And it is part of the context received by the handlers:

type HttpContext<AppContext> = AppContext & {
  request: Request
  url: URL
}

Responses

Same as Requests, responses derive from the WHATWG Fetch Response standard. It is worth mentioning that in order to avoid many complications, both the Response and ResponseInit objects are accepted as valid responses.

There is one main addition: .body field, which was added by us to enable returning the body of the response.

// WHATWG standard
interface StandardResponseInit {
  headers?: HeadersInit
  status?: number
  statusText?: string
}

// our implementation
type Response = StandardResponseInit & {
  body?: JsonBody | stream.Readable | Uint8Array | Buffer | string
}

As you can infer from the code, there are several supported response body types, some of them have default mime-types:

  • (no default content type): Buffer, Uint8Array, ArrayBuffer, Node.Stream
  • text/plain: string
  • application/json: anything else

Handling FormData

Handling FormData is enabled by third-party libraries like Busboy

import Busboy from "busboy"

export async function handleFormData(ctx) {
  // in this record, we are going to store every form field as it is read
  // from the request.body stream
  const fields: Record<string, any> = {}

  // first, create a Busboy instance to read our stream
  const formDataParser = new Busboy({
    headers: {
      "content-type": ctx.request.headers.get("content-type"),
    },
  })

  // promise to detect when we finish
  const finished = new Promise((ok, err) => {
    formDataParser.on("error", err)
    formDataParser.on("finish", ok)
  })

  // every time a field is read, this function will be called
  formDataParser.on("field", function(fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
    fields[fieldname] = val
  })

  // lastly, send the input stream to Busboy to process
  ctx.request.body.pipe(formDataParser)

  // await until it finishes processing the input data
  await finished

  // respond
  return {
    status: 201,
    body: {
      fields,
    },
  }
}

Cookies

To keep things simple and to not “glue” the batteries, cookies are not part of this implementation. Same as FormData handling.

To handle cookies you may write your own code or use third party libraries like:

import cookie from "cookie" // https://www.npmjs.com/package/cookie

async function handler(ctx) {
  var cookies = cookie.parse(ctx.request.headers.get("cookie") || "")

  // do something with cookies

  return {
    status: 200,
    headers: {
      "Set-Cookie": cookie.serialize(name, "value", opts),
    },
  }
}