Subscribe to receive notifications of new posts:

Serverless Rendering with Cloudflare Workers

07/17/2020

13 min read
Serverless Rendering with Cloudflare Workers

Cloudflare’s Workers platform is a powerful tool; a single compute platform for tasks as simple as manipulating requests or complex as bringing application logic to the network edge. Today I want to show you how to do server-side rendering at the network edge using Workers Sites, Wrangler, HTMLRewriter, and tools from the broader Workers platform.

Each page returned to the user will be static HTML, with dynamic content being rendered on our serverless stack upon user request. Cloudflare’s ability to run this across the global network allows pages to be rendered in a distributed fashion, close to the user, with miniscule cold start times for the application logic. Because this is all built into Cloudflare’s edge, we can implement caching logic to significantly reduce load times, support link previews, and maximize SEO rankings, all while allowing the site to feel like a dynamic application.

A Brief History of Web Pages

In the early days of the web pages were almost entirely static - think raw HTML. As Internet connections, browsers, and hardware matured, so did the content on the web. The world went from static sites to more dynamic content, powered by technologies like CGI, PHP, Flash, CSS, JavaScript, and many more.

A common paradigm in those maturing days was Server Side Rendering of web pages. To accomplish this, a user would request a page with some supplied parameters, a server would generate a static web page using those incoming parameters, and return that static HTML back to the user. These web pages were easily cacheable by proxies and other downstream services, an important benefit in the world of slower Internet connection speeds. Time to Interactive (TTI) in this model is usually faster than other rendering methods, as render-blocking JavaScripts are avoided.

This paradigm fell out of style as the web standardized and powerful hardware became easier to access. Time To First Byte (TTFB) is a concern with Server Side Rendering as this model incurs latency across the Internet and the latency of rendering pages on the server itself. Client side rendering allowed for a more seamless user experience for dynamic content. As a result of this shift, client applications became larger and larger, and SEO crawlers quickly had to adopt frameworks to be able to emulate the browser logic that is able to run and render these client applications. Tied into this is the idea of AJAX requests, allowing content on the single page application to change without the need for a full page reload. Application state is changed by requesting asynchronous updates from the server and allowing the client side application to update state based on the data returned by the server. This was great, it gave us amazingly interactive applications like Google Mail.

While this is a great structure for dynamic applications, rendering on the client side has a side effect of reducing shareability of content via link previews, increases time to interactive (TTI), and reduces SEO rankings on many search engines.

With Cloudflare’s Workers platform, you can get the benefits of server side rendering with greatly reduced latency concerns. The dynamic web pages in this example are delivered from any one of Cloudflare’s edge nodes, with application logic running upon request from the user. Server side rendering often leads to content that is more easily cacheable by downstream appliances; delivering better SEO rankings and obfuscating application logic from savvy users.

You get all the benefits of the old way things were done, with all the speed of the modern web.

Peer With Cloudflare, a Dynamic Web App

Without further ado, let’s dive into building a dynamic web page using the Cloudflare Workers platform! This example leverages Workers Sites, which allows you to serve static web pages from Cloudflare’s Key Value store. From there, Workers application logic (using HTMLRewriter) transforms that static response based on user input to deliver modified responses with the requested data embedded in the returned web page.

The Peer With Cloudflare application, hosted on peering.rad.workers.dev
The Peer With Cloudflare application, hosted on peering.rad.workers.dev

PeeringDB is a user-maintained public database of networks, exchanges, facilities, and interconnection on the Internet. The Peer With Cloudflare (PWC) application leverages the PeeringDB API to query live information on facilities and exchange points from multiple ASNs, compares the resulting networks, and lists to the user shared exchanges and facilities. In this example, we’ll also explore using templating languages in conjunction with Cloudflare’s HTMLRewriter.

Generate a Workers Site

We’ll start by generating a workers site using wrangler.

> wrangler generate --site peering

PWC will be entirely served from index.html, which will be generated in the /public directory. Next, ensure that we only serve index.html, regardless of the path supplied by the user. Modify index.js to serve a single page application, using the serveSinglePageApp method.

import { getAssetFromKV, serveSinglePageApp } from '@cloudflare/kv-asset-handler'

addEventListener('fetch', event => {
  try {
    event.respondWith(handleEvent(event))
  } catch (e) {
    if (DEBUG) {
      return event.respondWith(
        new Response(e.message || e.toString(), {
          status: 500,
        }),
      )
    }
    event.respondWith(new Response('Internal Error', { status: 500 }))
  }
})

async function handleEvent(event) {
  /**
   * You can add custom logic to how we fetch your assets
   * by configuring the function `mapRequestToAsset`.
   * In this case, we serve a single page app from index.html.
   */

  const response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })
  return response 
}

Workers Sites will now load up index.html (in the /public directory) regardless of the supplied URL path. This means we can apply the application to any route on the site, and have the same user experience. We define this in our wrangler.toml under the [site] section.

[site]
bucket="./public"
entry-point="./"

Use URL Parameters to Control Application State

The application itself needs a way to store state between requests. There are multiple methods to do so, but in this case URL query parameters are used for two primary reasons:

  • Users can use browser-based search functionality to quickly look up an ASN and compare it with Cloudflare’s network
  • State can be stored in a single search parameter for the purposes of this application, and the null state can be handled easily

Modify index.js to read in the asn search parameter:

async function handleEvent(event) {
  const response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })
  const url = new URL(event.request.url) // create a URL object from the request url
  const asn = url.searchParams.get('asn') // get the 'asn' parameter
}

PWC will have three cases to cover with regards to application state:

  1. Null state (no ASN is provided). In this case we can simply return the vanilla index.html page
  2. ASN is provided and has an entry on PeeringDB’s API
  3. ASN is provided but is malformed or has no PeeringDB entry
try {
  if (asn) {
    // B) asn is provided
  } else { 
    return response 
    // A) no asn is provided; return index.html
  }  
} catch (e) {
  // C) error state
}

To provide the initial state and styling of the PWC application, index.html uses a third party framework called milligram, chosen due to its lightweight nature, which requires normalize.css and the Roboto font family. Also defined is a custom style for basic formatting. For state storage, a form is defined such that upon submission a GET request is sent to #, which is effectively a request to self with supplied parameters. The parameter in this case is named asn and must be a number:

<!doctype html>
<html>
    <head>
        <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
        <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css">
        <link href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.min.css" rel="stylesheet"/>
        <style>
            .centered {
                max-width: 80rem;
            }
        </style>
    </head>
    <body>
        <div id="formContainer" class="centered container">
            <h2 class="title">Peer With Cloudflare</h1>
            <p class="description">Welcome to the peering calculator, built entirely on Cloudflare Workers. Input an ASN below to see where it peers with Cloudflare's network.</p>
            <form action="#" method="GET">
              <fieldset>
                <label for="asnField" class="">ASN</label>
                <input type="number" placeholder="13335" id="asnField" name="asn">
              </fieldset>
            </form>
        </div>
    </body>
</html>

Modelling Data from a Third Party API

The PeeringDB API defines networks primarily with metadata outlining key information about the network and owners, as well as two lists of public peering exchange points and private peering facilities. The PWC application will list any peering points (exchanges and facilities) shared between the user-provided network and Cloudflare’s network in a single table. PWC uses a model-view paradigm to retrieve, store, and display these data from the PeeringDB API. Defined below are the three data models representing a Network, Facility, and Exchange.

To define a network, first inspect a sample response from the PeeringDB API (use https://peeringdb.com/api/net?asn__in=13335&depth=2 for a sample from Cloudflare’s network). Some key pieces of information displayed in PWC are the network name, website, notes, exchanges, and facilities.

Network begins with a constructor to initialize itself with an Autonomous System Number. This is used for lookup of the network from the PeeringDB API:

export class Network {
	constructor(asn) {
		this.asn = asn
	}

A populate() function is then implemented to fetch information from a third party API and fill in required data. The populate() method additionally creates instances of NetworkFacility and NetworkExchange objects to be stored as attributes of the Network model.

async populate(){
		const net = await findAsn(this.asn)
		this.id = net['id']
		this.name = net['name']
		this.website = net['website']
		this.notes = net['notes']

		this.exchanges = {}
		for (let i in net['netixlan_set']) {
			const netEx = new NetworkExchange(net['netixlan_set'][i])
			this.exchanges[netEx.id] = netEx
		}

		this.facilities = {}
		for (let i in net['netfac_set']) {
			const netFac = new NetworkFacility(net['netfac_set'][i])
			this.facilities[netFac.id] = netFac
		}
		return this
	}

Any Network defined in the PWC application can compare itself to another Network object. This generic approach allows PWC to be extended to arbitrary network comparison in the future. To accomplish this, implement a compare() and compareItems() function to compare both NetworkExchanges and NetworkFacilities.

compareItems(listA, listB, sharedItems) {
		for (let key in listA) {
			if(listB[key]) {
				sharedItems[key] = listA[key]
			}
		}
		return sharedItems
	}

	async compare(network) {
		const sharedFacilities = this.compareItems(this.facilities, network.facilities, {})
		const sharedExchanges = this.compareItems(this.exchanges, network.exchanges, {})
		return await fetchAdditionalDetails(sharedFacilities, sharedExchanges)
	}

Both the NetworkFacility and NetworkExchange models implement a constructor to initialize with supplied data, as well as a populate method to add in extra information. These models also take care of converting PeeringDB API information into more human-readable formats.

export class NetworkFacility {
	constructor(netfac){
		this.name = netfac['name']
		this.id = netfac['fac_id']
		this.type = 'Facility'
		this.url = `https://peeringdb.com/fac/${this.id}`
		this.location = netfac['city'] + ", " + netfac['country']
	}

	populate(details) {
		this.networks = details['net_count']
		this.website = details['website']
	}
}

export class NetworkExchange {
	constructor(netixlan){
		this.id = netixlan['ix_id']		
		this.name = netixlan['name']
		this.type = 'Exchange'
		this.url = `https://peeringdb.com/fac/${this.id}`
	}

	populate(details) {
		this.website = details['website']
		this.networks = details['net_count']
		this.location = details['city'] + ", " + details['country']
	}
}

Notice that the compare() and populate() functions call out to fetchAdditionalDetails and findAsn methods; these are implemented to gather additional information for each model. Both methods are implemented in an ‘interface’ under src/utils/.

import {peeringDb} from './constants'

async function fetchPdbData(path) {
	const response = await fetch(new Request(peeringDb['baseUrl'] + path))
	const body = await response.json()
	return body['data']
}

async function fetchAdditionalDetails(facilities, exchanges) {
	const sharedItems = []
	if (Object.keys(facilities).length > 0) {
		const facilityDetails = await fetchPdbData(peeringDb['facEndpoint'] + "?id__in=" + Object.keys( facilities ).join(","))
		for (const facility of facilityDetails) {
			facilities[facility.id].populate(facility)
			sharedItems.push(facilities[facility.id])
		}
	}
	if (Object.keys(exchanges).length > 0) {
		const exchangeDetails = await fetchPdbData(peeringDb['ixEndpoint'] + "?id__in=" + Object.keys( exchanges ).join(","))
		for (const exchange of exchangeDetails) {
			exchanges[exchange.id].populate(exchange)
			sharedItems.push(exchanges[exchange.id])
		}
	}
	return sharedItems
}

async function findAsn(asn) {
	const data = await fetchPdbData(peeringDb['netEndpoint'] + "?" + `asn__in=${asn}&depth=2`)
	return data[0]
}

export {findAsn, fetchAdditionalDetails}

Presenting Results using HTMLRewriter

In building a single page application with workers, the PWC application needs the ability to modify HTML responses returned to the user. To accomplish this, PWC uses Cloudflare’s HTMLRewriter interface. HTMLRewriter streams any supplied response through a transformer, applying any supplied transformations to the raw response object. This returns a modified response object that can then be returned to the user.

In the case of PWC, three cases need to be handled, and two of them require some form of transformation before returning index.html to the user. Define a generic AsnHandler to provide to the user their supplied ASN. The element() method in this handler will simply set a value attribute on the target element.

class AsnHandler {
	constructor(asn) {
		this.asn = asn
	}
	element(element) {
		element.setAttribute("value", this.asn)
	}
}
The ASNHandler fills the form field with the user-supplied ASN.
The ASNHandler fills the form field with the user-supplied ASN.

For error cases, PWC needs to provide feedback to the user that the supplied ASN was not found on PeeringDB. In this case a simple header tag is appended to the target element.

class ErrorConditionHandler {
	constructor(asn) {
		this.asn = asn
	}
	element(element) {
		element.append(`<h4>ASN ${this.asn} Not Found on PeeringDB</h4>`, {html: true})
	}
}
The ErrorConditionHandler provides feedback on invalid user-supplied input.
The ErrorConditionHandler provides feedback on invalid user-supplied input.

For cases where a result needs to be returned, a NetworkComparisonHandler is implemented. Instead of defining raw HTML in a string format, NetworkComparisonHandler uses a templating language (Handlebars) to provide a dynamic transformation based on data returned from PeeringDB. First, install both handlebars and handlebars loader with npm:

> npm install handlebars handlebars-loader

Now define the NetworkComparisonHandler, including an import of the networkTable template.

import networkTable from '../templates/networktable.hbs'

class NetworkComparisonHandler {
	constructor({cfNetwork, otherNetwork, sharedItems}) {
		this.sharedItems = sharedItems
		this.otherNetwork = otherNetwork
		this.cfNetwork = cfNetwork
	}

	element(element) {
		element.append(networkTable(this), { html: true })
	}
}

The Handlebars template itself uses conditional logic to handle cases where there is no direct overlap between the two supplied networks, and a custom helper to provide references to each piece of returned data. Handlebars provides an easy-to-read interface for conditional logic, iteration, and custom views.

{{#if this.sharedItems.length}}
  <h4>Shared facilities and exchanges between {{this.cfNetwork.name}} and {{this.otherNetwork.name}}</h4>
  <table>
      <thead>
          <tr>
            <th>Name</th>
            <th>Location</th>
            <th>Networks</th>
            <th>Type</th>
          </tr>
      </thead>
      <tbody>
        {{#each this.sharedItems}}
          <tr>
            <td>{{link this.name this.url}}</td>
            <td>{{this.location}}</td>
            <td>{{this.networks}}</td>
            <td>{{this.type}}</td>
          </tr>
        {{/each}}
      </tbody>
  </table>
{{else}}
  <h4>No shared exchanges or facilities between {{this.cfNetwork.name}} and {{this.otherNetwork.name}}</h4>
{{/if}}

A custom link helper is used to display an <a> tag with a reference to each datum.

import handlebars from 'handlebars'

export default function(text, url) {
	return new handlebars.SafeString("<a href='" + handlebars.escapeExpression(url) + "'>" + handlebars.escapeExpression(text) + "</a>");
}

Great! Handlebars and other templating languages are extremely useful for building complex view logic into Cloudflare’s HTMLRewriter. To tie Handlebars into our build process, and have wrangler understand the currently foreign code, modify wrangler.toml to use a custom webpack configuration:

type = "webpack"
webpack_config = "webpack.config.js"

In webpack.config.js, configure any .hbs files to be compiled using the handlebars-loader module. Custom webpack configurations can be used in conjunction with Wrangler to create more complex build schemes, including environment-specific schemes.

module.exports = {
  target: 'webworker',
  entry: './index.js',
  module: {
    rules: [{ test: /\.hbs$/, loader: 'handlebars-loader' }],
  }
}

Time to tie it all together in index.js! Handle each case by returning to the user either a raw HTML response or a modified response using HTMLRewriter. The #asnField will be updated, and the #formContainer will be used to present either an error message or a table of results.

async function handleEvent(event) {
  const response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })
  const url = new URL(event.request.url)
  const asn = url.searchParams.get('asn')

  try {
    if (asn) {
      const cfNetwork = await new Network(cloudflare['asn']).populate()
      const otherNetwork = await new Network(asn).populate()
      const sharedItems = await cfNetwork.compare(otherNetwork)
      return await new HTMLRewriter()
        .on('#asnField', new AsnHandler(asn))
        .on('#formContainer', new NetworkComparisonHandler({cfNetwork, otherNetwork, sharedItems}))
        .transform(response)
    } else { return response }  
  } catch (e) {
    return await new HTMLRewriter()
      .on('#asnField', new AsnHandler(asn))
      .on('#formContainer', new ErrorConditionHandler(asn))
      .transform(response)
  }
}
The NetworkComparisonHandler and associated Handlebars template allows PWC to present PeeringDB information in a user-friendly format.
The NetworkComparisonHandler and associated Handlebars template allows PWC to present PeeringDB information in a user-friendly format.

Publish to Cloudflare

You can view the final code on Github, or navigate to peering.rad.workers.dev to see a working example. The final wrangler.toml includes instructions to publish the code up to a workers.dev site, allowing you to easily build, deploy, and test without a domain - simply by setting workers_dev to “true”.

name = "peering"
type = "webpack"
webpack_config = "webpack.config.js"
account_id = "<REDACTED>"
workers_dev = true
route = "<REDACTED>"
zone_id = "<REDACTED>"

[site]
bucket="./public"
entry-point="./"

Finally, publish your code using wrangler.

> wrangler publish

Cache At The Edge

Taking advantage of our server-rendered content is as simple as matching the request against any previously cached assets. To accomplish this, add a few simple lines to the top of our handleEvent function using Cloudflare’s Cache API. If an asset is found, return the response without going into the application logic.

async function handleEvent(event) {
  let cache = caches.default
  let response = await cache.match(event.request)
  if (response) {
    return response
  }
  response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })

What’s Next?

Using the Workers platform to deploy applications allow users to load lightweight and static html, with all application logic residing on the network edge. While there are certainly a host of improvements which can be made to the Peer With Cloudflare application (use of Workers KV, more input validation, or mixing in other APIs to present more interesting information); it should present a compelling introduction to the possibilities of Workers!

Check out Built With Workers for more examples of applications built on the Workers platform, or build your own projects at workers.cloudflare.com! For more information on peering with Cloudflare, please visit our Peering Portal.

We protect entire corporate networks, help customers build Internet-scale applications efficiently, accelerate any website or Internet application, ward off DDoS attacks, keep hackers at bay, and can help you on your journey to Zero Trust.

Visit 1.1.1.1 from any device to get started with our free app that makes your Internet faster and safer.

To learn more about our mission to help build a better Internet, start here. If you're looking for a new career direction, check out our open positions.
Cloudflare Workers

Follow on X

Kabir Sikand|@kabirsikand
Cloudflare|@cloudflare