Bootstrapping a Typescript Worker

June 27, 2018 0 Comments

Bootstrapping a Typescript Worker

 

 

Cloudflare Workers allows you to quickly deploy Javascript code to our 150+ data centers around the world and execute very close to your end-user. The edit/compile/debug story is already pretty amazing using the Workers IDE with integrated Chrome Dev Tools. However, for those hankering for some Typescript and an IDE with static analysis, autocomplete and that jazz, follow along to see one way to set up a Typescript project with Webstorm and npm run upload your code straight to the edge.

Pre Requisites

My environment looks like this:

  • macOS High Sierra
  • node v8.11.3
  • npm v5.6.0
  • Webstorm v2018.1.3

You'll also need a Cloudflare domain and to activate Workers on it.

I'll be using cryptoserviceworker.com

I'll also use Yeoman to build our initial scaffolding. Install it with npm install yo -g

Getting Started

Let's start with a minimal node app with a "hello world" class and a test.

mkdir cryptoserviceworker && cd cryptoserviceworker
npm install generator-node-typescript -g
yo node-typescript

That generator creates the following directory structure:

drwxr-xr-x 16 steve staff 512 Jun 18 20:40 .
drwxr-xr-x 10 steve staff 320 Jun 18 20:35 ..
-rw-r--r-- 1 steve staff 197 Jun 18 20:40 .editorconfig
-rw-r--r-- 1 steve staff 96 Jun 18 20:40 .gitignore
-rw-r--r-- 1 steve staff 147 Jun 18 20:40 .npmignore
-rw-r--r-- 1 steve staff 267 Jun 18 20:40 .travis.yml
drwxr-xr-x 5 steve staff 160 Jun 18 20:40 .vscode
-rw-r--r-- 1 steve staff 1066 Jun 18 20:40 LICENSE
-rw-r--r-- 1 steve staff 2071 Jun 18 20:40 README.md
drwxr-xr-x 4 steve staff 128 Jun 18 20:40 __tests__
drwxr-xr-x 479 steve staff 15328 Jun 18 20:40 node_modules
-rw-r--r-- 1 steve staff 244624 Jun 18 20:40 package-lock.json
-rw-r--r-- 1 steve staff 1506 Jun 18 20:40 package.json
drwxr-xr-x 4 steve staff 128 Jun 18 20:40 src
-rw-r--r-- 1 steve staff 454 Jun 18 20:40 tsconfig.json
-rw-r--r-- 1 steve staff 73 Jun 18 20:40 tslint.json

It includes default settings, a task runner, an initial Typescript config and more. We won't use all of it, but it's a good starting point.

First Test

If we take a look at the contents of src/greeter.ts, we'll see it's a very Typescript implementation of hello world.

$ cat greeter.ts export class Greeter { private greeting: string; constructor(message: string) { this.greeting = message; } public greet(): string { return `Bonjour, ${this.greeting}!`; }
}

Because Yeoman has set up our test infrastructure, we should be able exercise the code using the greeter test in __tests__/greeter-spec.ts

import { Greeter } from '../src/greeter'; test('Should greet with message', () => { const greeter = new Greeter('friend'); expect(greeter.greet()).toBe('Bonjour, friend!');
});

This generator uses jest. It's installed locally, but let's install it globally for convenience and run it!

npm install jest -g
jest PASS __tests__/greeter-spec.ts PASS __tests__/index-spec.ts Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.867s
Ran all test suites. 

OK, so we have a testable Typescript template. Let's fire up Webstorm and write some code!

Hello, World with Workers

A hello world implementation in Typescript might look something like this:

Hello world first attempt

Webstorm doesn't like it as you can see from the red error highlights. Even though Request and Response are part of the Service Worker API and will be available to us in the V8 runtime, Typescript doesn't know about them yet. node-fetch provides an implementation for node, so let’s install that.

npm install node-fetch
npm install @types/node-fetch

That made Webstorm happier. It’s been able to locate the type definitions.

Hello world with type defs

Now let's write a test. Create a new file tests/worker-spec.ts:

import { Request } from "node-fetch";
import { Worker } from "../src/worker"; test('Should say hello', () => { const worker = new Worker(); const request = new Request("https://cryptoserviceworker.com/"); const response = worker.handle(request); expect(response.status).toEqual(200); expect(response.body).toEqual("Hello, world!");
});

And delete the other files and tests so we're just working worker.ts and worker-spect.ts

Run jest

 PASS __tests__/worker-spec.ts PASS __tests__/worker-spec.js Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.213s, estimated 2s

OK, so our test passed, but notice it ran both the Typescript and the Javascript? Let's restrict to just Typescript. Go into package.json, locate jest and change

"testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|js)$", to
"testRegex": "(/__tests__/.*)\\-spec.ts$"

Run it again:

jest PASS __tests__/worker-spec.ts ✓ Should say hello (8ms) Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.123s, estimated 2s
Ran all test suites. 

Better. OK, ship it!

From Local Typescript to Worker Compatible Javascript.

Let's take a look at src/worker.js to see how our Typescript transpiled.

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const node_fetch_1 = require("node-fetch");
class Worker { handle(request) { return new node_fetch_1.Response('Hello, world!'); }
}
exports.Worker = Worker;

Actually, let's try it in the Cloudflare Workers IDE and try it for real. Go to your dashboard, click the Workers icon and then "Launch Editor"

Workers Dashboard

First things first, check the canonical Hello World implementation works.

Hello world in IDE

Awesome, now let's replace it with our "transpiled from Typescript" version:

Fail 1

Fail. OK, so the out of the box "transpiled from typescript" is not going to work. Let's make the changes necessary to get it run manually, then incorporate that into the build process.

Error #1: Uncaught ReferenceError: exports is not defined at line 2
That's easy enough, let's add var exports = {}. Update Preview.

Error #2: Uncaught ReferenceError: require is not defined at line 4

True, we're running in V8 on the Cloudflare Edge and the only code is what we uploaded. There are no "node_modules" to include. Plus, that line was only for dev anyway. Remove it. Update Preview.

Error #3: No event handlers were registered. This script does nothing.

Right, we need to invoke the code. Let's add a snippet to the top of the file to actually invoke our worker.

addEventListener('fetch', event => { let worker = new exports.Worker(); event.respondWith(worker.handle(event.request));
})

Error #4: Uncaught ReferenceError: node_fetch_1 is not defined

Right, we removed that because Response is a native object when it runs in the context of a worker. So remove the node_fetch_1 prefix.

Error #5: exports.__esModule = true does nothing

So let's remove that.

Success!!

Success

OK, so with some massaging, we got a Worker transpiled from Typescript to execute. We:

  • Added a line to create an exports object
  • Removed the dev dependency on "node_fetch"
  • Removed the exports.__esModule = true line

Let's add that to our build process so we can have "Worker-ready" Javascript every time we make a change to our Typescript.

Grunt

I'm going to use Grunt to automate that. Here's my new worker.ts

// --BEGIN PREAMBLE--
/// //Invoke worker
/// var exports = {};
/// addEventListener('fetch', event => {
/// event.respondWith(fetchAndApply(event.request))
/// });
///
/// async function fetchAndApply(request) {
/// let worker = new exports.Worker();
/// return worker.handle(request);
/// }
// --END PREAMBLE-- // --BEGIN COMMENT--
// mock the methods and objects that will be available in the browser
import { Request, Response } from 'node-fetch';
// --END COMMENT--
export class Worker { public handle(request: Request) { return new Response("Hello, world!") }
}

I want to uncomment the preamble to invoke our script, comment out the dev dependencies and remove the __esmodule line. Let's install Grunt, a text-replace module and create a Gruntfile.js

npm install grunt-cli -g
npm install grunt --save-dev
npm install grunt-replace --save-dev
touch Gruntfile.js

My Gruntfile.js looks like this

module.exports = function (grunt) { grunt.loadNpmTasks('grunt-replace'); grunt.initConfig({ replace: { comments: { options: { patterns: [ { /* Comment imports for node during dev */ match: /--BEGIN COMMENT--[\s\S]*?--END COMMENT--/g, replacement: 'Dev environment code block removed by build' }, { /* Uncomment preamble for production to process the request */ match: /\/\/\//mg, replacement: '' } ] }, files: [ { expand: true, flatten: true, src: ['src/worker.ts'], dest: 'build/' } ] }, exports: { //remove the exports line that typescript includes without an option to //suppress, but is not in the v8 env that workers run in. options: { patterns: [ { match: /exports.__esModule = true;/g, replacement: "// exports line commented by build" } ] }, files: [ { expand: true, flatten: true, src: ['build/worker.js'], dest: 'build/' } ] } } }); grunt.registerTask('prepare-typescript', 'replace:comments'); grunt.registerTask('fix-export', 'replace:exports');
}; 

There are two tasks. The first is the comment/uncomment step that we want before our Typescript is transpiled.

The second is to remove the exports.__esmodule = true line

$ grunt prepare-typescript
Running "replace:comments" (replace) task
>> 11 replacements in 1 file. Done.

If we open build/worker.ts, we see this:

// --BEGIN PREAMBLE-- //Invoke worker var exports = {}; addEventListener('fetch', event => { event.respondWith(fetchAndApply(event.request)) }); async function fetchAndApply(request) { let worker = new exports.Worker(); return worker.handle(request); }
// --END PREAMBLE-- // Dev environment code block removed by build
export class Worker { public handle(request: Request) { return new Response("Hello, world!") }
}

Opening build/worker.js you'll see a whole lot of code generated for handling async functions. That's because we're using the async keyword in the preamble.

"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); });
};
var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (_) try { if (f = 1, y && (t = y[op[0] & 2 ? "return" : op[0] ? "throw" : "next"]) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [0, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; }
};
exports.__esModule = true;
// --BEGIN PREAMBLE--
//Invoke worker
var exports = {};
addEventListener('fetch', function (event) { event.respondWith(fetchAndApply(event.request));
});
function fetchAndApply(request) { return __awaiter(this, void 0, void 0, function () { var worker; return __generator(this, function (_a) { worker = new exports.Worker(); return [2 /*return*/, worker.handle(request)]; }); });
}
// --END PREAMBLE--
// Dev environment code block removed by build
var Worker = /** @class */ (function () { function Worker() { } Worker.prototype.handle = function (request) { return new Response("Hello, world!"); }; return Worker;
}());
exports.Worker = Worker;

Now let's remove that exports.__esModule = true line.

grunt fix-export

and now we'll see instead in the worker.js // exports line commented by build.

Put it together

I just want to run npm run build and get Worker-friendly Javascript. Let's modify package.json to do just that. Change

"build": "tsc --pretty" to "build": "grunt prepare-typescript && tsc build/*.ts --pretty --skipLibCheck; grunt fix-export",

And run it.

npm run build will result in

"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); });
};
var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (_) try { if (f = 1, y && (t = y[op[0] & 2 ? "return" : op[0] ? "throw" : "next"]) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [0, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; }
};
// exports line commented by build
// --BEGIN PREAMBLE--
//Invoke worker
var exports = {};
addEventListener('fetch', function (event) { event.respondWith(fetchAndApply(event.request));
});
function fetchAndApply(request) { return __awaiter(this, void 0, void 0, function () { var worker; return __generator(this, function (_a) { worker = new exports.Worker(); return [2 /*return*/, worker.handle(request)]; }); });
}
// --END PREAMBLE--
// Dev environment code block removed by build
var Worker = /** @class */ (function () { function Worker() { } Worker.prototype.handle = function (request) { return new Response('Hello, world!'); }; return Worker;
}());
exports.Worker = Worker;

Paste that into the Workers IDE... works first time.

Automated upload

It's going to get old uploading from our IDE to the Web IDE every time we want to test a change and we're going to want to auto deploy from CI at some point. Thankfully there's Workers Configuration API, which makes it very simple to upload a Worker automatically:

curl -X PUT "https://api.cloudflare.com/client/v4/zones/:zone_id/workers/script" -H "X-Auth-Email:YOUR_CLOUDFLARE_EMAIL" -H "X-Auth-Key:ACCOUNT_AUTH_KEY" -H "Content-Type:application/javascript" --data-binary "@PATH_TO_YOUR_WORKER_SCRIPT"

OK, so we need our zone ID, Cloudflare email, auth key and path to the binary. I'm going to create Grunt task that uses the dotenv package to load config from a .env file or environment variables.

Create a .env file that looks like this:

CF_WORKER_ZONE_ID=xxxxxxxxxxxxxxxxxxx
CF_WORKER_EMAIL=steve@example.com
CF_WORKER_AUTH_KEY=xxxxxxxxxxxxxxxxxx
CF_WORKER_PATH=build/worker.js

To locate your zone ID and auth key, go to the dashboard, select your zone and click the "Overview" icon.

Overview

The zone ID is right there, then click "Get API key" and choose the "Global API Key" to get the Auth Key.

API key

Fill out your .env with those values and then add the following to your Gruntfile which will:

  • Read your config
  • Upload to Cloudflare
  • Parse any success or error messages.
grunt.registerTask('upload-worker', 'Uploads workers to Cloudflare', function(path) { require('dotenv').config(); const fs = require('fs'); const log = console; const done = this.async(); const conf = readConfig(); path = path || grunt.option('path') || process.env.CF_WORKER_PATH; if (!path) { fail("path is required"); } if (!fs.existsSync(path)) { fail(`path not found ${path}`); } let script = fs.readFileSync(path); log.info("Uploading..."); let url = `https://api.cloudflare.com/client/v4/zones/${conf.zoneId}/workers/script`; let options = { url: url, method: 'PUT', headers: { 'Content-Type': 'application/javascript' }, body: script }; invokeApi(options, conf, done); }); function invokeApi(options, conf, done) { // Add authentication to the request options.headers = options.headers || {}; Object.assign(options.headers, { 'X-Auth-Email': conf.email, 'X-Auth-Key': conf.apiKey, }); request(options, function(error, response) { try { if (error) { log.error(error); fail(`API failure ${response.statusCode} error: ${error}`); done(); return; } let body = JSON.parse(response.body); if (body) { logResult(body); } done(); } catch (e) { fail(`Unhandled error. ${e}`); done(); } }); } function logResult(body) { body.success ? log.error("Status: Success") : log.error("Status: Failed"); let errors = body.errors || []; if (errors) { log.info(` Errors: ${errors.length}`); for (let e of errors) { log.error(` Code: ${e.code} Message: ${e.message}`); } } let messages = body.messages || []; if (messages) { log.info(` Messages ${messages.length}`); for (let msg of messages) { log.info(` ${msg}`); } } let result = body.result; log.info(" Result"); log.info(` ${JSON.stringify(result, null, 2)}`); } function readConfig() { let zoneId = grunt.option('zoneId') || process.env.CF_WORKER_ZONE_ID; let email = grunt.option('email') || process.env.CF_WORKER_EMAIL; let apiKey = grunt.option('apiKey') || process.env.CF_WORKER_AUTH_KEY; log.debug("zoneID: " + zoneId); log.debug("email: " + email); log.debug("apiKey: " + "*".repeat(apiKey.length)); if (!zoneId || !email || !apiKey) { fail("zone id, cloudflare email and api key are required"); } return { zoneId: zoneId, email: email, apiKey: apiKey } } function fail(message) { grunt.fail.fatal(message, TASK_FAILED); }

Finally, let's add a new task to package.json so we can just npm run upload any time we update our Worker.

"upload": "grunt upload-worker"

npm run upload-worker Running "upload-worker" task
zoneID: **************
email: steve@example.com
apiKey: *************************************
Uploading...
Status: Success Errors: 0 Messages 0 Result { "script"... }

Voila! Script uploaded. OK, so if it's uploaded, we can call call it remotely:

$ curl https://cryptoserviceworker.com/hello

Hmmm... nothing. Ah, we haven't actually configured Workers to route any requests to our Worker. You can do this via the API, but since it's a one off, I'll do it in the web IDE.

Add routes

And try again:

$ curl https://cryptoserviceworker.com/hello
Hello, world!

Success! OK, so to recap:


  • We've bootstrapped a Typescript project using NodeJS and Webstorm

  • Written a "Hello, World" worker in Typescript

  • Setup build tasks to modify the code for Workers

  • Automatically uploading to the Cloudflare edge with npm run upload

  • ...

  • Profit

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.



Tag cloud