Lab 07 — First Worker
Duration: 60 minutes
The engagement platform’s control plane lives in a Cloudflare Worker. Everything
in Labs 08-14 runs through this single Worker: device enrollment, command
dispatch, ChatOps decoding, signed artifact URLs. In this lab you deploy the
skeleton: a real /v1/health endpoint and stub endpoints that later labs will
fill in. When this lab is done, https://api.<DOMAIN>/v1/health returns valid
JSON from the Cloudflare edge, and the cloudflared tunnel from Lab 06 is no
longer the authority for that path.
The Worker is the engagement platform’s front door from here on. Labs 08-14 extend it; nothing here changes the route dispatcher structure.
Note on
src/index.js: the file you are reading is the complete Labs 07-14 implementation in a single file. In this lab onlyhandleHealthis wired to live bindings; the remaining handlers either guard against missing bindings (D1, KV, R2) or are stubs returning HTTP 501. Each later lab uncomments the binding inwrangler.tomland runs the handlers against real infrastructure.
Learning objectives
- Understand the wrangler project model:
wrangler.toml,src/index.js,package.json. - Deploy a Worker to a custom domain route (
api.<DOMAIN>/v1/*). - Read environment variables and future binding stubs in
wrangler.toml. - Use
wrangler tailto stream live logs from a deployed Worker. - Understand how a Worker route preempts the cloudflared tunnel for matching paths.
Pre-state
Before starting this lab confirm:
# wrangler is installed and authenticated (confirmed in Lab 04)
wrangler whoami
# Node 20.x is available (operator console provides it)
node --version # should be v20.x
# DOMAIN is exported in your shell
echo ${DOMAIN} # e.g. a00f3f13.eplabs.cloud
If DOMAIN is not set:
export DOMAIN="<your-8-char-hex>.eplabs.cloud"
Lab 04 set DOMAIN and ran wrangler login. If either is missing, complete
Lab 04 before proceeding.
Codespace users: wrangler is pre-installed in the operator console and your
~/.config/directory is persisted as a named volume. Runwrangler whoamito confirm your session is active. If it is not, runwrangler loginagain.
Where each step runs
All steps in this lab run in the operator console. No bench hardware is involved.
| Step | Operator console |
|---|---|
| 1–9. All steps | yes |
| Validation | yes |
Walkthrough
This lab provides a ready-to-deploy worker/ directory. Inspect it before
making any changes:
ls courses/engagement-platform-labs/labs/lab07-first-worker/worker/
# worker/
# ├── package.json
# ├── wrangler.toml
# └── src/
# └── index.js
The three files are the complete wrangler project. wrangler.toml is the
project manifest; src/index.js is the Worker script; package.json pins
the local wrangler version used for this lab.
cd courses/engagement-platform-labs/labs/lab07-first-worker/worker
npm install
# added N packages
This installs a locally pinned wrangler 4.x. The operator console also has
a globally installed wrangler, but pinning locally keeps every student on the
same version regardless of when they run the lab.
Open worker/wrangler.toml. Key sections:
name = "fleet-gateway"
main = "src/index.js"
compatibility_date = "2024-09-23"
routes = [
{ pattern = "api.YOUR_DOMAIN/v1/*", zone_name = "YOUR_DOMAIN" },
{ pattern = "api.YOUR_DOMAIN/relay/*", zone_name = "YOUR_DOMAIN" }
]
[vars]
ENVIRONMENT = "development"
WORKER_VERSION = "1.0.0"
RELAY_BACKEND = "https://app.YOUR_DOMAIN"
The routes block tells Cloudflare to intercept any request to
api.<DOMAIN>/v1/* (and /relay/*) and send it to this Worker instead of
passing it through the tunnel. Paths outside those prefixes still flow through
the cloudflared origin.
RELAY_BACKEND is the backend hostname for the Lab 13 redirector relay. Both
the routes array and RELAY_BACKEND contain YOUR_DOMAIN placeholders.
Both must be substituted before deploying. The sed command below handles
both occurrences in one pass.
The D1, KV, and R2 binding sections are present but commented out. You will uncomment them in Labs 09 and 10 when those services are provisioned. Do not remove the comments.
Substitute your domain (and the parent zone) in wrangler.toml:
wrangler.toml ships with two distinct placeholders. YOUR_DOMAIN is your
slot’s full hostname (e.g., 0b38904c.eplabs.cloud); YOUR_PARENT_ZONE
is the actual Cloudflare zone the route binds against. Under the
in-class Option A path the parent zone is eplabs.cloud (not your
slot — Cloudflare needs the real zone name on the route binding).
Under the take-home BYO-domain path they are usually the same value.
# Run from inside worker/
# In-class (Option A): slot under shared eplabs.cloud
sed -i "s/YOUR_DOMAIN/${DOMAIN}/g; s/YOUR_PARENT_ZONE/eplabs.cloud/g" wrangler.toml
# Take-home (BYO): your own zone is both the slot and the parent
# sed -i "s/YOUR_DOMAIN/${DOMAIN}/g; s/YOUR_PARENT_ZONE/${DOMAIN}/g" wrangler.toml
Verify no placeholders remain:
grep -E "YOUR_DOMAIN|YOUR_PARENT_ZONE" wrangler.toml
# Expected: no output (empty)
Verify your domain appears in three places (two route patterns +
RELAY_BACKEND):
grep "${DOMAIN}" wrangler.toml
# Expected: three matches
Why the placeholder ships intentionally.
wrangler.tomlis committed to the repo and contains no credentials. TheYOUR_DOMAINplaceholder requires the student to make a conscious substitution rather than deploying to the wrong domain silently. Delivery notes (Section 1.2) document both occurrences.
Open worker/src/index.js and read through the route dispatcher. The
structure follows the reference implementation in
docs/technical_specifications.md:
fetch()handles CORS preflight, then delegates tohandleRequest().handleRequest()dispatches onpathname; each case calls a handler.handleHealth()is fully implemented: returns{ok: true, version, timestamp}.handleEnroll(),handleDeviceList(),handleCommand()and the other handlers are either stubs returning HTTP 501 (Lab 07 state) or guard against missing bindings. Each handler carries a comment indicating which lab activates it.
The stub and guard pattern is intentional. Later labs extend this file by replacing stubs with real D1, KV, and R2 calls. The route dispatcher does not change; only the handler bodies grow.
The logAudit() utility at the bottom writes to the D1 audit_log table
when env.FLEET_DB is bound. In Lab 07, env.FLEET_DB is not bound, so
logAudit() returns immediately (the guard at the top of the function handles
this). This is why the health endpoint works before Lab 09 sets up D1.
cd courses/engagement-platform-labs/labs/lab07-first-worker/worker
npx wrangler deploy
Expected output:
Total Upload: N kB / gzip: N kB
Uploaded fleet-gateway (N sec)
Published fleet-gateway (N sec)
https://fleet-gateway.<YOUR_CF_SUBDOMAIN>.workers.dev
api.<YOUR_DOMAIN>/v1/*
api.<YOUR_DOMAIN>/relay/*
The custom domain routes are what matter. The workers.dev subdomain also
exists but bypasses CF Access (addressed in Lab 08).
If you see a “route already taken” error, another Worker on your account may have a conflicting route. Check the Cloudflare dashboard under Workers & Pages
fleet-gateway > Triggers > Routes and remove any stale route from earlier testing.
curl -s https://api.${DOMAIN}/v1/health | jq .
Expected response:
{
"ok": true,
"version": "1.0.0",
"timestamp": "2024-09-23T10:00:00.000Z"
}
HTTP status must be 200. The timestamp reflects actual wall-clock time.
If the response is a Cloudflare 522 or 524 error, the Worker route may not have propagated yet. Wait 30 seconds and retry.
# Enroll endpoint (requires CF Access JWT or service token in Lab 08+)
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://api.${DOMAIN}/v1/devices/enroll
# Expected: 401 (auth required — Lab 08 wires the Access JWT path)
# Device list (same)
curl -s -o /dev/null -w "%{http_code}" \
https://api.${DOMAIN}/v1/devices
# Expected: 401
# Command (any device id)
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://api.${DOMAIN}/v1/commands/test-device
# Expected: 400 (missing required body fields) or 401 if the auth gate is hit first
HTTP 401/400 here is correct for Lab 07: the Worker is reachable via your
custom domain route, the request was dispatched to the right handler, and
the handler refused before doing any D1/KV/R2 work. Lab 08 adds the
Cloudflare Access service token that turns the 401 into 200/501-equivalent
behavior. A 404 means the request fell through to the default case in
handleRequest(); check your curl path for typos.
Behavior note. The worker code is the complete Labs 07–14 implementation. Earlier README versions called this section “stub verification” and expected 501; the actual handlers are wired and the auth/binding guards activate first, so 401/400 is what you see in Lab 07 state.
Open a second terminal in the operator console and start tailing:
cd courses/engagement-platform-labs/labs/lab07-first-worker/worker
npx wrangler tail
In your first terminal, curl the health endpoint:
curl -s https://api.${DOMAIN}/v1/health > /dev/null
A log entry should appear in the wrangler tail window within a second or
two. This is your primary debugging surface for all Worker development in later
labs.
Press Ctrl+C to stop tailing when done.
The cloudflared tunnel from Lab 06 is still running. The Worker now handles
all paths matching api.<DOMAIN>/v1/* and api.<DOMAIN>/relay/*. The
Cloudflare edge evaluates Worker routes first; any non-matching path falls
through to the tunnel’s DNS record.
| Request path | Handler |
|---|---|
api.<DOMAIN>/v1/health | Worker (this lab) |
api.<DOMAIN>/v1/devices/enroll | Worker stub (501) |
api.<DOMAIN>/relay/* | Worker relay handler (Lab 13) |
api.<DOMAIN>/ | cloudflared tunnel, operator console origin |
api.<DOMAIN>/status | cloudflared tunnel, operator console origin |
The Worker is the API surface; the tunnel is the operator escape hatch for direct access to the operator console during development. Both coexist intentionally.
Post-state
When this lab is complete you should be able to answer yes to all of the following:
-
https://api.${DOMAIN}/v1/healthreturns HTTP 200 with{ok: true, version, timestamp}. -
/v1/devices/enroll,/v1/devices, and/v1/commands/<id>return HTTP 501. -
wrangler tailproduces a log entry for each request to the Worker. -
worker/wrangler.tomlcontains your actual domain in both routes entries and inRELAY_BACKEND(noYOUR_DOMAINliterals remain). -
validate.shexits 0.
Validation
Three validation paths. Pick whichever matches your setup. All three call the
same validate.sh and post the result to the same backend; only the delivery
channel differs.
Path 1: epl CLI (recommended from the operator console).
epl validate lab07-first-worker
Output streams to your terminal; pass/fail is posted automatically. Re-run as many times as you like; only the most recent result is recorded.
Path 2: direct script.
bash courses/engagement-platform-labs/labs/lab07-first-worker/validate.sh
Or via the Makefile:
cd courses/engagement-platform-labs/labs
make validate-lab07-first-worker
The script exits 0 on success and prints the first failing assertion on failure.
Path 3: paste output into the widget.
Run validate.sh anywhere, copy the full output, and paste it into the
widget below.
What the script checks:
$DOMAINis set.GET https://api.${DOMAIN}/v1/healthreturns HTTP 200.- Response body contains
"ok": true. - Response body contains a
versionfield. - Response body contains a
timestampfield.
View the Worker source this lab deploys:
Primitives and drop-in substitutes
Cloudflare Workers are V8 isolates running on Cloudflare’s edge: an HTTP request lands at a point of presence, a JS function runs in milliseconds, a response leaves. The primitive itself is broader than that. Edge compute decomposes into an isolation model (V8 isolates, WASM modules, microVMs, containers), a runtime API (Web standards plus per-platform extensions like KV bindings), and a deployment model (managed cloud, self-hosted runtime, or plain VPS with a CDN in front). Each axis has substitutes.
Understanding those axes means you can swap providers when a client blocks Cloudflare, when a vendor deprecates an API, or when a self-hosted option fits better than a managed one.
Substitutes
| Name | Isolation model | Deployment | License | When to consider it |
|---|---|---|---|---|
| Vercel Edge Functions | V8 isolates | managed cloud | proprietary | closest API peer to Workers; same Web-standards runtime |
| Deno Deploy | V8 isolates | managed cloud | proprietary (runtime: Apache-2.0) | when you want Deno semantics (TS-native, npm via specifiers); same isolation model |
| AWS Lambda@Edge | full Node.js / Python runtime per-invocation | managed cloud | proprietary | when you are AWS-aligned; slower cold starts than V8 isolates |
| AWS CloudFront Functions | small JS subset, very limited | managed cloud | proprietary | for trivial header/URL rewrites at AWS edge |
| Fastly Compute@Edge | WASM (Wasmtime) | managed cloud | proprietary | when you want WASM as the runtime; non-JS languages with Compute SDKs |
| Netlify Edge Functions | Deno (V8) | managed cloud | proprietary | similar to Deno Deploy with Netlify integration |
| workerd | V8 isolates (Cloudflare’s open-source runtime) | self-hosted | Apache-2.0 | the same code Workers runs, in your own datacenter; needs a frontend (e.g., a CDN or load balancer) |
| miniflare | V8 (uses workerd under the hood) | local dev | MIT | local Worker development without deploying |
| Bun.serve / Hono on a VPS | Node-style runtime with Web-standards Request/Response | self-hosted | MIT | when “edge compute” means a Go/Rust/Bun handler on one VPS |
| OpenFaaS | container per function | self-hosted Kubernetes | MIT | when you have Kubernetes and want function-style deployment without per-PoP topology |
| Knative Serving | container, scale-to-zero | self-hosted Kubernetes | Apache-2.0 | the upstream “serverless on Kubernetes” pattern |
| Wasmer Edge / wasmCloud | WASM | mixed | Apache-2.0 | when WASM-everywhere is a goal of your platform |
Take-home exercise
Take Lab 07’s /health handler and redeploy it on (a) Deno Deploy, (b) workerd on a VPS behind Caddy, (c) a single Bun process behind nginx. Measure cold-start latency from three different clients. The contrast is the primitives map: same handler, three platforms, three pricing models, three operational shapes.
Further reading
Cloudflare Workers:
- Workers overview.
The platform reference: runtime model, limits, pricing, and the
fetch()handler contract. - wrangler CLI reference.
wrangler deploy,wrangler tail,wrangler secret put, andwrangler devare the four commands you will use most in Labs 07-14. - Routes and domains.
How Worker routes interact with DNS records, zone-based routes vs
workers.devroutes, and route priority when multiple Workers share a zone.
Workshop-internal:
- Lab 04: Domain verification. Where
wrangler loginwas run and DNS delegation was verified. Ifwrangler whoamifails, complete Lab 04 first. - Lab 09: D1 database. Uncomments the
[[d1_databases]]binding and activateshandleEnroll()andhandleDeviceList(). - Lab 13: Redirector relay. Activates the
/relay/*routes and sets theRELAY_BACKENDvar to a real backend hostname (the Lab 13sedstep extends the Lab 07 substitution to cover the second tunnel).
View this page’s source:
labs/lab07-first-worker/README.mdx
Troubleshooting
wrangler deploy fails: “Authentication error”
Run wrangler whoami. If it returns an error, run wrangler login and
re-authenticate. In the operator console, ~/.config/ is mounted as a named
volume; if the volume was reset, log in again after each container restart.
wrangler deploy fails: “Missing field: zone_name”
The YOUR_DOMAIN placeholder was not substituted. Run the sed command in
Step 3 and verify with grep "YOUR_DOMAIN" wrangler.toml. Expect no output.
wrangler deploy fails: “route already taken”
Another Worker on your account has a conflicting route. Check the Cloudflare dashboard: Workers & Pages > fleet-gateway > Triggers > Routes. Remove any stale route from earlier testing, then redeploy.
curl returns “Error 1101: Worker threw exception”
Run wrangler tail in one terminal and repeat the curl in another to see the
exception message. The most common cause is a syntax error in src/index.js.
Redeploy after fixing: npx wrangler deploy.
curl returns the cloudflared tunnel origin, not the Worker
The Worker route may not have propagated yet. Wait 30 seconds after deploy and
retry. Verify the route in the Cloudflare dashboard: Workers & Pages >
fleet-gateway > Triggers > Routes. The pattern
api.<YOUR_DOMAIN>/v1/* must appear. If it is missing, the sed
substitution may have failed silently; check wrangler.toml directly and
re-run sed.
wrangler tail shows no logs
wrangler tail connects to the Cloudflare log stream for your deployed
Worker. If the deployment failed, there is nothing to tail. If the Worker is
deployed but logs are silent, confirm you are curling the custom domain route
(api.${DOMAIN}/v1/health) and not only the workers.dev subdomain. Both
routes exist; wrangler tail shows traffic from either.
HTTP 404 instead of 501 for stub endpoints
A 404 means the request matched the Worker route but fell through to the
default case in handleRequest(). Check that your curl command targets the
exact path (/v1/devices/enroll, /v1/devices, /v1/commands/<id>). A path
typo is the most common cause.
RELAY_BACKEND still shows YOUR_DOMAIN after sed
The sed command substitutes all occurrences of YOUR_DOMAIN in a single
pass. If grep "YOUR_DOMAIN" wrangler.toml still returns output, the DOMAIN
shell variable was not set when sed ran. Confirm echo ${DOMAIN} returns
your subdomain, then re-run the sed command.
ValidateOutputPaster lab="lab07")