Deploy your SPA and programmatically manage traffic with Cloudflare Workers
Tags: react, cloudflare, devops, serverless
Published: 2021-04-23 12:15:39

**TL;DR;** Checkout [Cloudflare Workers](https://developers.cloudflare.com/workers/) for deploying Single Page Applications (SPA) or handling your traffic programmatically.

I previously wrote about [how we deploy our backend services](https://www.keypup.io/blog/deploying-production-rails-applications-to-gcp) on GCP. This week we'll talk about frontend deployment and traffic handling. For this we will use Cloudflare workers.

Cloudflare Workers are a serverless environment allowing you to deploy JS or WebAssembly logic to Cloudflare's edge endpoints across the world.

Not only does this allow you to easily distribute your SPA but you will also benefit from Cloudflare's anti-DdoS features and be able to manage your traffic programmatically.

For this article we will assume the following:
- Your app is hosted under `https://app.mydomain.com`
- The `app.mydomain.com` DNS is already pointing to Cloudflare
- Your API backend is hosted on a PaaS at `xyz-us.saas.net`
- The SPA and the API must share the same domain for cookie sharing reasons
- Traffic going to `https://app.mydomain.com/api/*` must go to your backend API
- Traffic going to `https://app.mydomain.com/auth/*` must go to your backend API
- Traffic going to `https://app.mydomain.com/*` must go to your SPA

With the requirements above you will need two things:
- The ability to compile and deploy your SPA
- The ability to route traffic going to `https://app.mydomain.com` to the SPA or the API backend based on path matching.

# Prerequisite
For this tutorial you will need npm setup on your machine as well as wrangler.

Wrangler's is Cloudflare's CLI for Cloudflare Workers. You can install it by running:
```bash
# Install wrangler
npm install -g @cloudflare/wrangler

# Setup your API token. This will open a web page asking you to login to Cloudflare
wrangler login

# Confirm your are logged in
wrangler whoami
```

# Introduction to Cloudflare Workers
Cloudflare Workers are a JS/WebAssembly serverless runtime allowing you to run any kind of HTTP-based application. Workers pushed to Cloudflare get deployed to all edge locations (100+ across the world).

The most minimal application looks like this:
![A minimal Cloudflare Worker](https://uploads-ssl.webflow.com/5ebd54899f7ac255cd8e0950/60214e47ef1e829ae8371290_Cloudflare%20-%20Minimal%20Worker.png)

You do not need to write code directly on the Cloudflare console to deploy applications. You can actually compile any JS app and push it to Cloudflare workers using the wrangler CLI.

If you wish to learn more about the Cloudflare Worker's runtime API, feel free to have a look at [their doc](https://developers.cloudflare.com/workers/runtime-apis). We will be using some of their runtime API in the second part of this article (the router).

If you wish to explore building workers, feel free to have a look at [their starter apps](https://developers.cloudflare.com/workers/starters).

# Deploying your (React) SPA

Using wrangler, this is fairly straightforward.

Note that I use React as an example because this is what we use at Keypup. But any JS application can be deployed using the steps below.

Go to your React app folder and initialize a Cloudflare Worker project for your app:
```bash
wrangler init --site
```

This step will generate two assets:
- A `workers-site` folder with bootstrap worker code to invoke your app
- A `wrangler.toml` file to configure the settings of your app worker

Update the `wrangler.toml` file to reflect your Cloudflare configuration. Only fill the name and account_id for now.
```toml
# This is the name of your application. In this case the app will be published under
# a Clouflare-generated domain that looks like: https://my-app.my-worker-domain.workers.dev
name = "my-app"

# Which build strategy to use: webpack, javascript, and rust. Keep webpack.
type = "webpack"

# Your Cloudflare account id, which you can find by going to your Cloudflare Workers' page.
account_id = ""

# If set to true you app will be deployed under your *.workers.dev domain (as
# my-app.my-worker-domain.workers.dev).
#
# If you set it to false then you will have to specify the 'route' or 'routes' attribute
# using your production domain such as https://app.mydomain.com/*
workers_dev = true

# Can be left empty for now as we will deploy to the *.workers.dev domain
route = ""
# routes = []

# Can be left empty for now as we will deploy to the *.workers.dev domain
# Can be used to specify a domain ID when deploying to a production domain (e.g. *.mydomain.com)
zone_id = ""

[site]
# The directory containing your static assets (output of your build)
bucket = "./build"

# The location of the worker entry-point. Leave untouched.
entry-point = "workers-site"
```

Once you are done just build and deploy your app:
```bash
# Build your app
npm run build
# Or
yarn build

# Publish your app to Cloudflare workers
wrangler publish
```

That's it! 

Your React app is now available at `https://my-app.my-worker-domain.workers.dev`

# Routing traffic
When it comes to routing traffic to your SPA and your backend there are two options:
1. Leverage the native routing of Cloudflare (DNS + Worker routing)
2. Build our own router using another Cloudflare Worker.

We prefer to use **option (2)** because it gives us more flexibility in terms of programmatically controlling routing rules but I will still show you what **option (1)** looks like for completeness purpose.

# Option 1: DNS and worker routing
The simplest approach for SPA + backend routing is using the native routing functionalities provided by Cloudflare DNS and Workers.

First we configure Cloudflare DNS to point `app.mydomain.com` to `xyz-us.saas.net` (our backend API). As such it will forward all traffic to your backend. This is not what we want just now but it will act as a default and will allow us to configure bypass routes when we are done with  the worker routing part.

Now update the wrangler.toml file and specify that your SPA should receive all traffic:
```toml
# This is the name of your application.
name = "my-app"

# Which build strategy to use: webpack, javascript, and rust. Keep webpack.
type = "webpack"

# Your Cloudflare account id, which you can find by going to your Cloudflare Workers' page.
account_id = ""

# We do not want the worker to be deployed on the dev domain
workers_dev = false

# We want our SPA to receive all traffic by default
route = "app.mydomain.com/*"

# You need to fill the zone id for the mydomain.com zone
zone_id = ""

[site]
# The directory containing your static assets (output of your build)
bucket = "./build"

# The location of the worker entry-point. Leave untouched.
entry-point = "workers-site"
```

Re-deploy your application using wrangler:
```bash
wrangler publish
```

All traffic to `app.mydomain.com` is now forwarded to your SPA.

Now let's configure bypass rules so that `/api` and `/auth` traffic is actually routed to the original DNS (the backend).

Go to the Cloudflare Workers page and add two routes to bypass Workers for `/api/*` and `/auth/*`. Ensure the Worker dropdown is set to None.

![Bypass rule for /api](https://uploads-ssl.webflow.com/5ebd54899f7ac255cd8e0950/60214f7cef1e8215ee37c9d1_Cloudflare%20-%20Bypass%201.png)

![Bypass rule for /auth](https://uploads-ssl.webflow.com/5ebd54899f7ac255cd8e0950/60214f90af6eba2f72e9150d_Cloudflare%20-%20Bypass%202.png)

That's it! Your app is now configured to send all traffic to your Cloudflare-hosted SPA except for the `/api` and `/auth` endpoints which are pointing to the original DNS (your backend)

It's a bit counterintuitive to setup two default routes followed by exclusion rules, but it's the only way - as far as I know - to ensure wildcard traffic gets routed to the SPA eventually.

Now let's explore the other alternative: building a custom router.

# Option 2: Custom routing using a Worker
In this section, we will leave your SPA on the Cloudflare dev domain and ask Cloudflare to direct all traffic to a routing Worker which will then decide where traffic should be forwarded.

If you have modified the `wrangler.toml` file of your SPA in the previous section, make sure to reset it to the **dev** version:
```toml
# This is the name of your application. In this case the app will be published under
# a Clouflare-generated domain that looks like: https://my-app.my-worker-domain.workers.dev
name = "my-app"

# Which build strategy to use: webpack, javascript, and rust. Keep webpack.
type = "webpack"

# Your Cloudflare account id, which you can find by going to your Cloudflare Workers' page.
account_id = ""

# If set to true you app will be deployed under your *.workers.dev domain (as
# my-app.my-worker-domain.workers.dev).
#
# If you set it to false then you will have to specify the 'route' or 'routes' attribute
# using your production domain such as https://app.mydomain.com/*
workers_dev = true

# Can be left empty for now as we will deploy to the *.workers.dev domain
route = ""
# routes = []

# Can be left empty for now as we will deploy to the *.workers.dev domain
# Can be used to specify a domain ID when deploying to a production domain (e.g. *.mydomain.com)
zone_id = ""

[site]
# The directory containing your static assets (output of your build)
bucket = "./build"

# The location of the worker entry-point. Leave untouched.
entry-point = "workers-site"
```

## Deploying your router
Use wrangler to create a new worker project:
```bash
# Generate new worker project using the cloudflare-provided router template
wrangler generate app-router https://github.com/cloudflare/worker-template-router

# Navigate to the newly created folder
cd app-router
```

Replace the `index.js` of the project with the following logic. The logic below tells the router to proxy traffic to `/api` and `/auth` to our backend API and all other traffic to our SPA:
```javascript
const Router = require('./router')

// -------------------------------------------------
// Registration logic
// -------------------------------------------------
// Declare router
const RT = new Router();

// Helper function used to register route handlers
// See Routing rules section
const proxyTo = hostname => request => {
    // Point to backend
    const url = new URL(request.url);
    const forwardedHost = url.hostname;
    url.hostname = hostname;

    // Build request. Keep track of the original Host.
    const req = new Request(url, request);
    req.headers.append('X-Forwarded-Host', forwardedHost);

    // Execute request
    return fetch(req);
}

// -------------------------------------------------
// Configuration
// -------------------------------------------------
const SPA_HOST = 'my-app.my-worker-domain.workers.dev'
const API_HOST = 'xyz-us.saas.net'

// -------------------------------------------------
// Routing rules
// -------------------------------------------------
RT.any('app.mydomain.com/api/*', proxyTo(API_HOST))
RT.any('app.mydomain.com/auth/*', proxyTo(API_HOST))
RT.any('app.mydomain.com/*', proxyTo(SPA_HOST))

// -------------------------------------------------
// Handler
// -------------------------------------------------
async function handleRequest(request) {
    const resp = await RT.route(request);
    return resp;
}

// Entrypoint
addEventListener('fetch', event => {
    event.respondWith(handleRequest(event.request))
})
```

Update the `wrangler.toml` of the project to tell Cloudflare that all traffic to `app.mydomain.com` should be handled by your router:
```toml
name = "app-router"
type = "webpack"
account_id = "you-account-id"
zone_id = "your-zone-id"
routes = [
  "app.mydomain.com/*"
]
```

Now publish your newly created router using wrangler:
```bash
wrangler publish
```

That's it! Your traffic is now programmatically routed to your SPA and backend API by the app router.

## Testing locally
It is possible to test your routing rules locally using wrangler in development mode.

Use the following command:
```bash
wrangler dev --host=app.mydomain.com
```

Then visit `http://localhost:8787` to test your logic locally and ensure traffic is routed as expected.

**Note:** Cloudflare headers are not present when testing locally. If your routing logic relies on these you'll need to add them manually in you local requests (using curl or Postman).

## Going beyond simple routing
You are now in full control of the routing logic to your application. This means you can:

Manage multiple domains (just add domains to the routes array in your `wrangler.toml`)
- Stitch multiple backend services together under the same domain
- Route traffic based on IP addresses or source country
- Inject custom headers in the requests
- ...and more!

Here is an example of doing country-specific routing for your backend API:
```javascript
const Router = require('./router')

// -------------------------------------------------
// Configuration
// -------------------------------------------------
const SPA_HOST = 'my-app.my-worker-domain.workers.dev'
const API_HOSTS = {
  US: 'xyz-us.saas.net',
  FR: 'xyz-fr.saas.net'
}

// -------------------------------------------------
// Registration logic
// -------------------------------------------------

// Declare router
const RT = new Router();

// Helper function used to register route handlers
// See Routing rules section
const proxyTo = hostname => request => {
    // Point to backend
    const url = new URL(request.url);
    const forwardedHost = url.hostname;
    url.hostname = hostname;

    // Build request. Keep track of the original Host.
    const req = new Request(url, request);
    req.headers.append('X-Forwarded-Host', forwardedHost);

    // Execute request
    return fetch(req);
}

// Handler for backend requests based on country
const backendProxy = request => {
  // Extract request information
  const url = new URL(request.url);
  const forwardedHost = url.hostname;

  // Select destination host based on country
  // Default to US if no backend configured for that specific country
  const country = request.headers.get('cf-ipcountry');
  const backend = API_HOSTS[country] || API_HOSTS['US'];
  url.hostname = backend;

  // Build request. Keep track of the original Host.
  const req = new Request(url, request);
  req.headers.append('X-Forwarded-Host', forwardedHost);

  // Execute request
  return fetch(req);
}

// -------------------------------------------------
// Routing rules
// -------------------------------------------------
RT.any('app.mydomain.com/api/*', backendProxy)
RT.any('app.mydomain.com/auth/*', backendProxy)
RT.any('app.mydomain.com/*', proxyTo(SPA_HOST))

// -------------------------------------------------
// Handler
// -------------------------------------------------
async function handleRequest(request) {
    const resp = await RT.route(request);
    return resp;
}

// Entrypoint
addEventListener('fetch', event => {
    event.respondWith(handleRequest(event.request))
})
```

I recommend you look at the [Cloudflare Worker examples](https://developers.cloudflare.com/workers/examples) to get a feel of what you can achieve.

# Wrapping up
Cloudflare Workers allow you to not only deploy your SPA(s) but also take control of your whole application facade.

Their serverless approach combined with the fact that workers are deployed on their worldwide-distributed edge endpoints make it a very efficient way to manage entrypoint traffic as a whole.

If you find yourself constrained by your current traffic management capabilities I recommend you give Cloudflare Workers a try.