Handle Multiple HTTP Endpoints in a Cloudflare Workers Function Using a Router

Cloudflare Workers is a serverless computing platform, similar to AWS Lambda, that allows you to run JavaScript functions on the Cloudflare infrastructure around the world, as close to the end user as possible.

It comes with a generous free plan that gives you 100,000 function invocations per day. If that is not enough for you, you can get 10 million requests per month for $5. Read more about the pricing here.

The purpose of this article is to show how to organize your Cloudflare Workers function code to handle multiple HTTP endpoints using a router. First, we will examine a way to do this without a router, and then with a number of different routers.

Example Application

Let’s say we have a requirement to develop an API that would serve us various resources regarding NBA. For the sake of simplicity, the API will have only two resources—a list of teams and players. You will be able to request the list of available NBA teams and also retrieve the players that belong to a particular team.

Consider the following as the data for the API. Apologies for it being so incomplete, but it should be enough to understand how everything works.

// data.ts
export const teams = [
  {
    name: "Los Angeles Lakers",
    abbreviation: "LAL",
    city: "Los Angeles",
    state: "California",
    conference: "Western",
    division: "Pacific",
    championships: 17,
  },
  {
    name: "Golden State Warriors",
    abbreviation: "GSW",
    city: "San Francisco",
    state: "California",
    conference: "Western",
    division: "Pacific",
    championships: 6,
  },
  {
    name: "Dallas Mavericks",
    abbreviation: "DAL",
    city: "Dallas",
    state: "Texas",
    conference: "Western",
    division: "Southwest",
    championships: 1,
  } ,       
]

export const players = [
  {
    name: "LeBron James",
    commandName: "kingJames",
    team: "Los Angeles Lakers",
    pointsPerGame: 25.0,
    reboundsPerGame: 7.9,
    assistsPerGame: 7.9,
  },
  {
    name: "Stephen Curry",
    commandName: "splashBro",
    team: "Golden State Warriors",
    pointsPerGame: 30.2,
    reboundsPerGame: 5.5,
    assistsPerGame: 6.1,
  },
  {
    name: "Luka Dončić",
    commandName: "lukaMagic",
    team: "Dallas Mavericks",
    pointsPerGame: 28.8,
    reboundsPerGame: 8.6,
    assistsPerGame: 8.9,
  },
]

We will use it in the later sections.

A Single Purpose Function

This approach works by putting each workload into its own function and deployment. Meaning that for each function you will have to create a Worker project, a source file, and deploy it individually.

Below is an example of a worker function that returns a list of NBA team names:

// teamlistworker/src/index.ts
import {teams} from './data'

export default {
  async fetch(request: Request): Promise<Response> {
    const json = JSON.stringify(teams)
    return new Response(json, {
      headers: {
        "Content-Type": "application/json",
      },
    })
  },
}

And a separate worker function to return a list of players by a team name provided through the query string:

// playerlistworker/src/index.ts
import {players} from './data'

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)

    const team = url.searchParams.get('team')
    if (team === null) {
      return new Response("team name was empty", {
        status: 400,
      })
    }

    const teamPlayers = players.filter(x => x.team === team)
    if (teamPlayers.length === 0) {
      return new Response("team not found", {
        status: 400,
      })
    }

    const json = JSON.stringify(teamPlayers)
    return new Response(json, {
      headers: {
        "Content-Type": "application/json",
      },
    })
  }
}

A Function That Contains All the Work

Unlike the one function per resource approach, below is an example of a single worker function containing the code to handle both of the team and player resource requests.

// nbaworker/src/index.ts
import {teams, players} from './data'

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)

    if (request.method === "GET" && url.pathname === "/teams") {
      const json = JSON.stringify(teams)
      return new Response(json, {
        headers: {
          "Content-Type": "application/json",
        },
      })
    }

    if (request.method === "GET" && url.pathname === "/players") {
      const team = url.searchParams.get('team')
      if (team === null) {
        return new Response("team name was empty", {
          status: 400,
        })
      }

      const teamPlayers = players.filter(x => x.team === team)
      if (teamPlayers.length === 0) {
        return new Response("team not found", {
          status: 400,
        })
      }

      const json = JSON.stringify(teamPlayers)
      return new Response(json, {
        headers: {
          "Content-Type": "application/json",
        },
      })
    }

    return new Response(`resource was not found: ${request.method} ${url.pathname}`, {
      status: 400,
    })
  }
}

Adding Support for Cross-Origin Resource Sharing (CORS)

If you tried to run the example above using cURL or some sort of API testing tool, you may not have noticed a problem that would occur if you tested it using an Internet browser.

The problem has a name, and it is called CORS. Or it would be more correct to say a feature rather than a problem? As it makes the web a bit more secure place.

When you publish a worker, it is usually deployed to a server under a name of workers.dev. All modern browsers impose the same-origin security policy (SOP), which means that only requests coming from the same domain can be allowed.

For example, if an HTML page is hosted on the domain A, and it contains a script that makes a request to the domain B, the browser will block the request unless the server provides a custom CORS policy.

So, to make the above function work, we need to add the Access-Control-Allow-Origin header to each of the responses.

For example, the code block that handles the return of the teams resource will need to become like this:

// nbaworker/src/index.ts

// ...

if (request.method === "GET" && url.pathname === "/teams") {
  const json = JSON.stringify(teams)
  return new Response(json, {
    headers: {
      "Content-Type": "application/json",
      // a heaer entry to allow the client to send a request to this resource
      "Access-Control-Allow-Origin": "*",
    },
  })
}

// ...

Introducing JavaScript routers for multi-endpoint worker functions

What is a routing? Simply put, routing is a process to determine how a worker function responds to a particular request. We carried out a manual routing in the previous section-we checked whether it was a GET request and the path matched the name of the available resources.

The code works fine and doesn’t look difficult. However, an increase in the number of endpoints or added support for features such as authentication, logging, or CORS can make it difficult to maintain the code.

That’s why I’d like to introduce a few JavaScript router libraries that will make your life easier.

I want to give a brief overview of the most popular JavaScript router libraries, and also show how the original worker code would look using them.

Hono

Hono is a JavaScript router library. The library is pretty young but has already earned a lot of GitHub stars. The creator of the library claims that the library is very fast and supports many JavaScript runtimes, including Cloudflare Workers.

Let’s look at how the original worker code looks after converting it to use the Hono library:

import {teams, players} from './data'
import {Hono} from 'hono'
import {cors} from 'hono/cors'

const app = new Hono()

// set CORS header for all requests
app.use("*", cors())

app.get("/teams", c => c.json(teams))

app.get("/players", c => {

  const team = c.req.query("team")
  if (typeof team === "undefined") {
    return c.text("team name was empty", 400)
  }

  const teamPlayers = players.filter(x => x.team === team)
  return c.json(teamPlayers)
})

export default app

worktop

Another routing library in the list is worktop. The first release of the worktop library was back in 2021, and now it has 1.6k stars. The main selling point of the library is being small and written in TypeScript. Unlike the Hono library, worktop runs only on the Cloudflare platform. However, there are plans to support more runtimes in the future.

import {teams, players} from './data'
import {Router} from 'worktop'
import * as Cache from 'worktop/cache';
import * as CORS from 'worktop/cors';

const api = new Router()

api.prepare = CORS.preflight({
  origin: '*',
})

api.add('GET', '/teams', (req, res) => {
  res.send(200, teams)
})

api.add('GET', '/players', (req, res) => {
  const team = req.query.get("team")
  if (team === null) {
    res.send(400, "team name was empty")
  }

  const teamPlayers = players.filter(x => x.team === team)
  res.send(200, teamPlayers)
})

Cache.listen(api.run);

itty-router

Itty-router is the smallest of the routing libraries. Initially, the library was designed to work on Cloudflare workers, but later more platforms were added. The project has 1.4k starts on GitHub, with the first release going back to 2020.

import {teams, players} from './data'
import {json, text, Router, createCors} from 'itty-router'

const router = Router()
const {preflight, corsify} = createCors({ origins: ['*'] })

router.all('*', preflight)

router.get('/teams', () => json(teams))

router.get('/players', (req) => {
  const team = req.query["team"]
  if (typeof team === "undefined") {
    return text("team name was empty", {status: 400})
  }

  const teamPlayers = players.filter(x => x.team === team)
  return json(teamPlayers)
})

export default {
  fetch: (request: Request, ...args: any[]) =>
    router .handle(request, ...args).then(corsify),
}

Conclusions

In this article, we made a simple REST API using plain JavaScript and later tried to do the same but using different routing libraries.

While there are clear benefits to using a routing library over wiring up everything manually, the advantages of one library over the other are hard to see.

In such cases, I always choose the one that is more actively developed and with better documentation.