Dev mode — no Cloudflare Access header present. Authenticated as dev@local.
EPL
dev@local

Lab 07 · Day 2, Session 1

First Worker

Duration: 60 minutes

Not started
Environment:

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 only handleHealth is 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 in wrangler.toml and 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 tail to 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. Run wrangler whoami to confirm your session is active. If it is not, run wrangler login again.


Where each step runs

All steps in this lab run in the operator console. No bench hardware is involved.

StepOperator console
1–9. All stepsyes
Validationyes

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.toml is committed to the repo and contains no credentials. The YOUR_DOMAIN placeholder 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 to handleRequest().
  • handleRequest() dispatches on pathname; 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 pathHandler
api.<DOMAIN>/v1/healthWorker (this lab)
api.<DOMAIN>/v1/devices/enrollWorker stub (501)
api.<DOMAIN>/relay/*Worker relay handler (Lab 13)
api.<DOMAIN>/cloudflared tunnel, operator console origin
api.<DOMAIN>/statuscloudflared 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/health returns HTTP 200 with {ok: true, version, timestamp}.
  • /v1/devices/enroll, /v1/devices, and /v1/commands/<id> return HTTP 501.
  • wrangler tail produces a log entry for each request to the Worker.
  • worker/wrangler.toml contains your actual domain in both routes entries and in RELAY_BACKEND (no YOUR_DOMAIN literals remain).
  • validate.sh exits 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:

  1. $DOMAIN is set.
  2. GET https://api.${DOMAIN}/v1/health returns HTTP 200.
  3. Response body contains "ok": true.
  4. Response body contains a version field.
  5. Response body contains a timestamp field.
Validate Output Paste your validate.sh output below

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

NameIsolation modelDeploymentLicenseWhen to consider it
Vercel Edge FunctionsV8 isolatesmanaged cloudproprietaryclosest API peer to Workers; same Web-standards runtime
Deno DeployV8 isolatesmanaged cloudproprietary (runtime: Apache-2.0)when you want Deno semantics (TS-native, npm via specifiers); same isolation model
AWS Lambda@Edgefull Node.js / Python runtime per-invocationmanaged cloudproprietarywhen you are AWS-aligned; slower cold starts than V8 isolates
AWS CloudFront Functionssmall JS subset, very limitedmanaged cloudproprietaryfor trivial header/URL rewrites at AWS edge
Fastly Compute@EdgeWASM (Wasmtime)managed cloudproprietarywhen you want WASM as the runtime; non-JS languages with Compute SDKs
Netlify Edge FunctionsDeno (V8)managed cloudproprietarysimilar to Deno Deploy with Netlify integration
workerdV8 isolates (Cloudflare’s open-source runtime)self-hostedApache-2.0the same code Workers runs, in your own datacenter; needs a frontend (e.g., a CDN or load balancer)
miniflareV8 (uses workerd under the hood)local devMITlocal Worker development without deploying
Bun.serve / Hono on a VPSNode-style runtime with Web-standards Request/Responseself-hostedMITwhen “edge compute” means a Go/Rust/Bun handler on one VPS
OpenFaaScontainer per functionself-hosted KubernetesMITwhen you have Kubernetes and want function-style deployment without per-PoP topology
Knative Servingcontainer, scale-to-zeroself-hosted KubernetesApache-2.0the upstream “serverless on Kubernetes” pattern
Wasmer Edge / wasmCloudWASMmixedApache-2.0when 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, and wrangler dev are 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.dev routes, and route priority when multiple Workers share a zone.

Workshop-internal:

  • Lab 04: Domain verification. Where wrangler login was run and DNS delegation was verified. If wrangler whoami fails, complete Lab 04 first.
  • Lab 09: D1 database. Uncomments the [[d1_databases]] binding and activates handleEnroll() and handleDeviceList().
  • Lab 13: Redirector relay. Activates the /relay/* routes and sets the RELAY_BACKEND var to a real backend hostname (the Lab 13 sed step 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.

Validate output paster — available in Wave 2D (ValidateOutputPaster lab="lab07")
Downloadable artifacts for lab07 — served from R2 after Wave 3B deployment