If you followed part one, I have an environment setup where I can write Typescript with tests and deploy to the Cloudflare Edge with npm run upload
. For this post, I want to take one of the Worker Recipes further.
I'm going to build a mini HTTP request routing and handling framework, then use it to build a gateway to multiple cryptocurrency API providers. My point here is that in a single file, with no dependencies, you can quickly build pretty sophisticated logic and deploy fast and easily to the Edge. Furthermore, using modern Typescript with async/await and the rich type structure, you also write clean, async code.
OK, here we go...
My API will look like this:
Verb
Path
Description
GET
/api/ping
Check the Worker is up
GET
/api/all/spot/:symbol
Aggregate the responses from all our configured gateways
GET
/api/race/spot/:symbol
Return the response of the provider who responds fastest
GET
/api/direct/:exchange/spot/:symbol
Pass through the request to the gateway. E.g. gdax or bitfinex
The Framework
OK, this is Typescript, I get interfaces and I'm going to use them. Here's my ultra-mini-http-routing framework definition:
export interface IRouter {
route(req: RequestContextBase): IRouteHandler;
}
/**
* A route
*/
export interface IRoute {
match(req: RequestContextBase): IRouteHandler | null;
}
/**
* Handles a request.
*/
export interface IRouteHandler {
handle(req: RequestContextBase): Promise<Response>;
}
/**
* Request with additional convenience properties
*/
export class RequestContextBase {
public static fromString(str: string) {
return new RequestContextBase(new Request(str));
}
public url: URL;
constructor(public request: Request) {
this.url = new URL(request.url);
}
}
So basically all requests will go to IRouter
. If it finds an IRoute
that returns an IRouterHandler
, then it will call that and pass in RequestContextBase
, which is just the request with a parsed URL for convenience.
I stopped short of dependency injection, so here's the router implementation with 4 routes we've implemented (Ping, Race, All and Direct). Each route corresponds to one of the four operations I defined in the API above and returns the corresponding IRouteHandler
.
export class Router implements IRouter {
public routes: IRoute[];
constructor() {
this.routes = [
new PingRoute(),
new RaceRoute(),
new AllRoute(),
new DirectRoute(),
];
}
public async handle(request: Request): Promise<Response> {
try {
const req = new RequestContextBase(request);
const handler = this.route(req);
return handler.handle(req);
} catch (e) {
return new Response(undefined, {
status: 500,
statusText: `Error. ${e.message}`,
});
}
}
public route(req: RequestContextBase): IRouteHandler {
const handler: IRouteHandler | null = this.match(req);
if (handler) {
logger.debug(`Found handler for ${req.url.pathname}`);
return handler;
}
return new NotFoundHandler();
}
public match(req: RequestContextBase): IRouteHandler | null {
for (const route of this.routes) {
const handler = route.match(req);
if (handler != null) {
return handler;
}
}
return null;
}
}
You can see above I return a NotFoundHandler if we can't find a matching route. Its implementation is below. It's easy to see how 401, 405, 500 and all the common handlers could be implemented.
/**
* 404 Not Found
*/
export class NotFoundHandler implements IRouteHandler {
public async handle(req: RequestContextBase): Promise<Response> {
return new Response(undefined, {
status: 404,
statusText: 'Unknown route',
});
}
}
Now let's start with Ping. The framework separates matching a route and handling the request. Firstly the route:
export class PingRoute implements IRoute {
public match(req: RequestContextBase): IRouteHandler | null {
if (req.request.method !== 'GET') {
return new MethodNotAllowedHandler();
}
if (req.url.pathname.startsWith('/api/ping')) {
return new PingRouteHandler();
}
return null;
}
}
Simple enough, if the URL starts with /api/ping
, handle the request with a PingRouteHandler
export class PingRouteHandler implements IRouteHandler {
public async handle(req: RequestContextBase): Promise<Response> {
const pong = 'pong;';
const res = new Response(pong);
logger.info(`Responding with ${pong} and ${res.status}`);
return new Response(pong);
}
}
So at this point, if you followed along with Part 1, you can do:
$ npm run upload
$ curl https://cryptoserviceworker.com/api/ping
pong
OK, next the AllHandler
, this aggregates the responses. Firstly the route matcher:
export class AllRoute implements IRoute {
public match(req: RequestContextBase): IRouteHandler | null {
if (req.url.pathname.startsWith('/api/all/')) {
return new AllHandler();
}
return null;
}
}
And if the route matches, we'll handle it by farming off the requests to our downstream handlers:
export class AllHandler implements IRouteHandler {
constructor(private readonly handlers: IRouteHandler[] = []) {
if (handlers.length === 0) {
const factory = new HandlerFactory();
logger.debug('No handlers, getting from factory');
this.handlers = factory.getProviderHandlers();
}
}
public async handle(req: RequestContextBase): Promise<Response> {
const responses = await Promise.all(
this.handlers.map(async h => h.handle(req))
);
const jsonArr = await Promise.all(responses.map(async r => r.json()));
return new Response(JSON.stringify(jsonArr));
}
}
I'm cheating a bit here because I haven't shown you the code for HandlerFactory
or the implementation of handle
for each one. You can look up the full source here.
Take a moment here to appreciate just what's happening. You're writing very expressive async code that in a few lines, is able to multiplex a request to multiple endpoints and aggregate the results. Furthermore, it's running in a sandboxed environment in a data center very close to your end user. Edge-side code is a game changer.
Let's see it in action.
$ curl https://cryptoserviceworker.com/api/all/spot/btc-usd
[
{
"symbol":"btc-usd",
"price":"6609.06000000",
"utcTime":"2018-06-20T05:26:19.512000Z",
"provider":"gdax"
},
{
"symbol":"btc-usd",
"price":"6600.7",
"utcTime":"2018-06-20T05:26:22.284Z",
"provider":"bitfinex"
}
]
Cool, OK, who's fastest? First, the route handler:
export class RaceRoute implements IRoute {
public match(req: RequestContextBase): IRouteHandler | null {
if (req.url.pathname.startsWith('/api/race/')) {
return new RaceHandler();
}
return null;
}
}
And the handler. Basically just using Promise.race
to pick the winner
export class RaceHandler implements IRouteHandler {
constructor(private readonly handlers: IRouteHandler[] = []) {
const factory = new HandlerFactory();
this.handlers = factory.getProviderHandlers();
}
public handle(req: RequestContextBase): Promise<Response> {
return this.race(req, this.handlers);
}
public async race(
req: RequestContextBase,
responders: IRouteHandler[]
): Promise<Response> {
const arr = responders.map(r => r.handle(req));
return Promise.race(arr);
}
}
So who's fastest? Tonight it's gdax.
curl https://cryptoserviceworker.com/api/race/spot/btc-usd
{
"symbol":"btc-usd",
"price":"6607.15000000",
"utcTime":"2018-06-20T05:33:16.074000Z",
"provider":"gdax"
}
Summary
Using Typescript+Workers, in < 500 lines of code, we were able to
Define an interface for a mini HTTP routing and handling framework
Implement a basic implementation of that framework
Build Routes and Handlers to provide Ping, All, Race and Direct handlers
Deploy it to 160+ data centers with
npm run upload
Stay tuned for more, and PRs welcome, particularly for more providers.
If you have a worker you'd like to share, or want to check out workers from other Cloudflare users, visit the “Recipe Exchange” in the Workers section of the Cloudflare Community Forum.