Running RxJS on Koa
Refining the server/application interface

Now that we have a basic Koa server running an RxJS reactive pipeline for each request from building our core server, we’re in a good place to consider how a route handler should process each request: what inputs and outputs are necessary?

Solidifying a design for the server/application interface

To start with, using the Koa Context directly in application route handlers isn’t ideal — it’s a low level interface that an application shouldn’t generally be concerned with.

Each application route should have a route matcher that does the following:

  1. Receive information about the request.
  2. Determine if the request should be handled by its corresponding route handler based on information it extracts from the request.
  3. Return extracted information from the request to be consumed by its route handler.

Each route will also have a route handler that should:

  1. Receive extracted information about the request.
  2. Return a structured response.

The server will call into the route matcher and handler for each route as necessary, processing the result and writing it to the final response via Koa’s Context API.

Responses

The response API is brand new; the core implementation had the route handler write directly to the Koa Context. Working backwards from this will make it clear what adjustments are needed in the router, so I’ll start by defining this API:

// response.ts
 
enum ResponseType {
  Json,
  Error,
}
 
type ResponseWith<T extends ResponseType, R> = {
  type: T;
  status: number;
  body: R;
};
 
type JsonResponse<T> = ResponseWith<ResponseType.Json, T>;
 
type ErrorResponse = ResponseWith<ResponseType.Error, { message: string }>;
 
export type Response<T> = JsonResponse<T> | ErrorResponse;
 
export const json = <T>(body: T): JsonResponse<T> => ({
  type: ResponseType.Json,
  status: 200,
  body,
});
 
export const notFound = (message = 'Not found'): ErrorResponse => ({
  type: ResponseType.Error,
  status: 404,
  body: {
    message,
  },
});

We now have a straightforward language of types to use when thinking about responses. A Response is either a successful JsonResponse with a custom body for a given route, or an ErrorResponse with a standard message field. By keeping the status and body properties of a given response the exact shape we want a user to receive, we make it easy for the layer that will handle these responses to do so with minimal overhead or knowledge of different response types. Additionally, we provide standard functions for application routes to use when constructing responses.

Router adjustments

With the Response infrastructure in place, it’s time to modify the router to use these in router.ts. If you recall the design above, the handler will also need to receive route params from the matcher. We’ll start by udpating the type contract through which the two application-defined route hooks interact.

We can update RouteHandler to be a generic of the params it receives and its response body type. Whatever the body type is will be wrapped in an Observable<Response>. We’ll also update the RoutePredicate type to be RouteMatcher, which returns a generic of matched params or a falsey indication that it’s not a match for a given route. The updated types are as follows:

export type Route<P, R> = {
  matcher: RouteMatcher<P>;
  handler: RouteHandler<P, R>;
};
 
type Match<P> = {
  params: P;
};
 
type RouteMatchResult<P> = Match<P> | false | undefined;
 
type RouteMatcher<P> = (request: Request) => RouteMatchResult<P>;
 
type RouteHandler<P, T> = (params: P, ctx: Context) => Observable<Response<T>>;

Updating the router’s vended utilities, we’ll provide matchers a match() function to indicate positive matches and wrap their parsed params. Although we could make them simply return the raw params, wrapping them in an object makes the Match<P> | false | undefined type safe to work with and assume falsey values are non-matches to reason about without needing to use generic type constraints. This will allow routes with no route params to return an empty match(undefined) without tripping up the router’s matching logic.

We’ll also update route() with the necessary generic typing for the generic matcher and handler.

export const match = <P>(params: P): Match<P> => ({
  params,
});
 
export const route = <PR>(
  matcherRouteMatcher<P>,
  handlerRouteHandler<P, R>,
)Route<P, R=> ({
  matcher,
  handler,
});

With the above in place, we can update the handleRoute() function to manage passing any params for a matched route into the matched route handler. If no routes match, we’ll return an Observable with a single NotFoundResponse built with notFound().

Additionally, we’ll make the router do the work of emitting a response to the Koa Context.

// router.ts
 
import { Context, Request } from 'koa';
import { Observable, of } from 'rxjs';
import { Response, notFound } from './response';
import { map } from 'rxjs/operators';
 
// ... updated types and utilities omitted ...
 
export const handleRoute = (ctx: Context, routes: Route<any, any>[]) => {
  let result$: Observable<Response<any>> | undefined;
 
  routes.some(({ matcher, handler }) => {
    const match = matcher(ctx.request);
    if (!match) {
      return false;
    }
    result$ = handler(match.params, ctx);
    return true;
  });
 
  return (result$ || of(notFound())).pipe(map(handleResponse(ctx)));
};
 
const handleResponse = (ctx: Context) => (response: Response<any>): Context => {
  ctx.status = ctx.status;
  ctx.type = 'json';
  ctx.body = JSON.stringify(response.body);
  return ctx;
};

Routes and server

Only a few small adjustments remain for us to tie everything together.

In routes.ts:

  • Update the array to reflect the generic Route type: export const routes: Route<any, any>[] = [getGreeting];

And finally, server.ts needs minimal modifications:

  • Update the Props type to reflect the generic Route type: routes: Route<any, any>[];
  • Update the reactive pipeline’s line to remove the mapTo after calling into handleRoute: mergeMap(ctx => handleRoute(ctx, routes)),

This last change is because the ctx object is now returned by handleRoute() when it pipes the response through handleResponse(). We can also remove the import of mapTo from the top of the file.

Building routes with the new interface

With the new capabilities in place, a route in the application will be able to parse out any params in the route matcher, consume them in the route handler, and return an Observable<Response> that emits the response when the route handler has completed its work.

// greetings/greeting.ts
 
import { of } from 'rxjs';
import { route, match } from '../router';
import { json } from '../response';
 
export const getGreeting = route(
  request => request.url === '/hello' && match({ name: 'world' }),
  (params, ctx) => {
    return of(json({ url: ctx.request.url, message: `hello, ${params.name}` }));
  },
);

Next steps

Now that application-layer routes interface with the server through a defined API, the remaining concerns of building an application on this system are fairly straightforward. It should be clear how the asynchronous, Observable-based routes will work with typical application logic — calling external web APIs, performing database operations, and other such tasks.

My next addition to this routing framework would be more definition around serialization and deserialization: our route matchers could be composed with a system for type-aware route param and body validation, and similarly, response serialization could respect traits of returned data to provide conventions against unintentional leakage of internals through our web API.