A brief introduction to bundling your Service Worker scripts.
Photo by Joyce Romero / Unsplash
// The simplest Service Worker: A passthrough script
addEventListener('fetch', event => {
event.respondWith(fetch(event.request))
})
The code above is simple and sweet: when a request comes into one of Cloudflare’s data centers, passthrough to the origin server. There is absolutely no need for us to introduce any complex tooling or dependencies. Nevertheless, introduce we will! The problem is, once your script grows even just a little bit, you’ll be tempted to use JavaScript’s fancy new module system. However, in doing so, you’ll have a little bit of trouble uploading your script via our API (we only accept a single JS file).
Throughout this post, we’ll use contrived examples, shaky metaphors, and questionably accurate weather predictions to explain how to bundle your Service Worker with Webpack.
Webpack
Let’s just say Webpack is a module bundler. That is, if you have code in multiple files, and you tie them together like this:
app.js
// Import the CoolSocks class from dresser.js
import { CoolSocks } from './dresser'
import { FancyShoes } from './closet'
Then you can tell webpack to follow all of those import statements to produce a single file. This is useful because Service Workers running on Cloudflare need to be a single file as well.
Show me the code
Remember when I said something about predicting weather? Let’s build a worker with TypeScript that responds with the current weather.
Make sure to have NodeJS installed.
# Make a new project directory
mkdir weather-worker
cd weather-worker
mkdir src dist
# Initialize project and install dependencies
npm init
npm install --save-dev \
awesome-typescript-loader \
typescript \
webpack \
webpack-cli \
workers-preview
touch src/index.ts src/fetch-weather.ts webpack.config.js tsconfig.json
You should now have a file in your project called package.json
. Add the following code to that file:
"scripts": {
"build": "webpack",
"build:watch": "webpack --watch",
"preview": "workers-preview < dist/bundle.js"
}
Now edit the following files to match what is shown:
tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"target": "esnext",
"lib": ["es2015", "webworker"],
"jsx": "react",
"noImplicitAny": true,
"preserveConstEnums": true,
"outDir": "./dist",
"moduleResolution": "node"
},
"include": ["src/*.ts", "src/**/*.ts", "src/*.tsx", "src/**/*.tsx"]
}
webpack.config.js
const path = require('path')
module.exports = {
entry: {
bundle: path.join(__dirname, './src/index.ts'),
},
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
mode: process.env.NODE_ENV || 'development',
watchOptions: {
ignored: /node_modules|dist|\.js/g,
},
devtool: 'cheap-module-eval-source-map',
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
plugins: [],
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'awesome-typescript-loader',
},
],
},
}
For newcomers, this file will seem incredibly cryptic. All I can say is just to accept it as magic for now. You’ll eventually understand what’s going on.
A note about
devtool: 'cheap-module-eval-source-map'
. Specifying this type of sourcemap is fast, lightweight, and results in stacktraces much more representative of your source code. They’re not exact (yet!), but we’re getting there.
Cloudflare fiddle devtools uses source maps to point you to the correct source file. Click through to see the problematic line.
src/index.ts
import { fetchWeather } from './fetch-weather'
addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request: Request) {
const weather = await fetchWeather('austin')
const body = `
${weather.location.city}, ${weather.location.region}<br>
${weather.item.condition.temp} ${weather.item.condition.text}
`.trim()
return new Response(body, {
headers: { 'Content-Type': 'text/html' },
})
}
src/fetch-weather.ts
/**
* Fetch the current weather conditions and forecast for a particular location
* @param location location string to fetch
* @param unit temperature units (c or f)
*/
export async function fetchWeather(location: string, unit = 'f') {
const url = `https://query.yahooapis.com/v1/public/yql?q=select *
from weather.forecast
where u='${unit}'
AND woeid in (
select woeid from geo.places(1)
where text="${location}"
)&format=json`
.split('\n')
.join(' ')
// yahoo's api doesn't like spaces unless they're encoded
.replace(/\s/g, '%20')
const res = await fetch(url)
if (res.status >= 400) {
throw new Error('Bad response from server')
}
const result = await res.json()
return result.query.results && result.query.results.channel
}
Now simply run:
npm run build && npm run preview
This ought to build your script and open a page very similar to:
This is great, but instead of returning the weather for every single resource request, maybe we should only return the weather on pathnames that match a particular pattern. Something like:
GET /weather/:city
GET /weather/austin
GET /weather/toronto
In that pattern, city
is a variable. Anything that starts with /weather/
will match, and everything after will be our city. This shouldn’t match a path like /weather/austin/tatious
. Luckily there are off-the-shelf solutions on npm to handle exactly this sort of logic.
Loading node modules
Webpack also understands how to import npm modules into your bundle. To illustrate this, we’re going to use the fantastic path-to-regexp module.
Install and save the module:
npm install -S path-to-regexp
The path-to-regexp module converts the url path pattern /weather/:city
to a regular expression. Using that regular expression, we can extract the variable city
out of a pathname string. For instance, in the string ‘/weather/toronto’, the city variable is ‘toronto’. However, for the string ‘/users/123’, there is no match at all.
Let’s modify our src/index.ts
file to include this new routing logic.
src/index.ts
import * as pathToRegExp from 'path-to-regexp'
import { fetchWeather } from './fetch-weather'
type TWeatherRequestParams = { city: string }
const weatherPath = '/weather/:city'
addEventListener('fetch', (event: FetchEvent) => {
// Create a regular expression based on the pathname of the request
const weatherPathKeys: pathToRegExp.Key[] = []
const weatherRegex = pathToRegExp(weatherPath, weatherPathKeys)
const url = new URL(event.request.url)
const result = weatherRegex.exec(url.pathname)
// No result, return early and passthrough
if (!Array.isArray(result)) return
// Build the request parameters object
const params = weatherPathKeys.reduce(
(params, key, i) => {
params[key.name as keyof TWeatherRequestParams] = result[i + 1]
return params
},
{} as TWeatherRequestParams,
)
event.respondWith(handleWeatherRequest(params))
})
async function handleWeatherRequest(params: TWeatherRequestParams) {
const weather = await fetchWeather(params.city)
const body = `
${weather.location.city}, ${weather.location.region}<br>
${weather.item.condition.temp} ${weather.item.condition.text}
`.trim()
return new Response(body, {
headers: { 'Content-Type': 'text/html' },
})
}
Notice that after installing the module, all we have to do is import by its name on npm. This is because webpack knows to look inside of your node_modules directory to resolve import statement paths.
Run:
npm run build && npm run preview -- \
--preview-url https://foo.bar.com/weather/austin
You should see the weather for Austin, TX displayed. Congrats!
Conclusion
Webpack is an incredibly robust and configurable piece of technology. It’s not just a module bundler, but rather a general static asset build system for web development. Although it was built with front-end assets in mind, it’s a perfect fit for bundling Cloudflare Service Workers.
You can view the full source of our weather script here: github.com/jrf0110/weather-workers
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.