# Routing and worker bindings

> How the router resolves requests: `/api/*` and `/blueprint-screenshot/*` to the backend, `/gatekeeper/<name>/*` derived by lowercasing `GATEKEEPER_*` env keys, everything else to `ASSETS` or the dev fallback, and inbound email dispatch to `GATEKEEPER_EMAIL`. Lists backend bindings (`BLUEPRINTS`, `BLUEPRINT_CONTENT`, `AVATARS`, `LOADER`, `BROWSER`, optional reporter and rate limiter) and DO migration tags.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `packages/router/src/index.ts`
- `packages/workshop-backend/wrangler.jsonc`
- `wrangler.jsonc`
- `packages/workshop-backend/src/env.d.ts`
- `packages/gatekeeper-email/README.md`
- `packages/workshop-backend/src/client-errors.ts`

---

---
title: "Routing and worker bindings"
description: "How the router resolves requests: `/api/*` and `/blueprint-screenshot/*` to the backend, `/gatekeeper/<name>/*` derived by lowercasing `GATEKEEPER_*` env keys, everything else to `ASSETS` or the dev fallback, and inbound email dispatch to `GATEKEEPER_EMAIL`. Lists backend bindings (`BLUEPRINTS`, `BLUEPRINT_CONTENT`, `AVATARS`, `LOADER`, `BROWSER`, optional reporter and rate limiter) and DO migration tags."
---

`packages/router/src/index.ts` is the public origin of a Cloudflare OS instance. It exports a single `ExportedHandler<Env>` with a `fetch` handler that dispatches by path prefix and an `email` handler that forwards inbound mail. Routing configuration *is* the binding set: gatekeeper prefixes are derived at request time by scanning `env` for keys starting with `GATEKEEPER_`, so installing a gatekeeper only requires re-deploying the router with one more service binding — no router code changes.

## Router env shape

```ts
// packages/router/src/index.ts
export interface Env {
  WORKSHOP_BACKEND: Fetcher;
  // Present in production (wrangler.jsonc assets stanza); absent in dev.
  ASSETS?: Fetcher;
  // Dormant until custom domains + Email Routing exist; the handler ships anyway.
  GATEKEEPER_EMAIL?: Service<EmailEntrypoint>;
  [key: string]: unknown;
}
```

`EmailEntrypoint` is `CloudflareWorkersModule.WorkerEntrypoint` narrowed with `Required<Pick<…, "email">>`, so `GATEKEEPER_EMAIL` is typed as a service whose optional `email()` handler is known to be present.

<ParamField body="WORKSHOP_BACKEND" type="Fetcher" required>
Service binding to the `workshop-backend` worker. Receives `/api*` and `/blueprint-screenshot*`, plus everything else when `ASSETS` is unbound.
</ParamField>

<ParamField body="ASSETS" type="Fetcher">
Static-asset fetcher. Bound in production via the wrangler `assets` stanza; deliberately absent in dev so frontend requests fall through to the backend.
</ParamField>

<ParamField body="GATEKEEPER_EMAIL" type="Service<EmailEntrypoint>">
Optional. Target of the `email()` handler. Absent means inbound mail is rejected.
</ParamField>

<ParamField body="[key: string]" type="unknown">
Index signature. Any additional `GATEKEEPER_*` service binding is discovered generically by the prefix scan.
</ParamField>

## Resolution order

The `fetch` handler evaluates in a fixed order; the first match wins.

```mermaid
flowchart TD
  REQ["fetch(req, env)"] --> GK{"any GATEKEEPER_* key whose\nderived prefix matches pathname?"}
  GK -- yes --> GKW["env[key].fetch(req)\n(gatekeeper Worker)"]
  GK -- no --> API{"/api, /api/*,\n/blueprint-screenshot,\n/blueprint-screenshot/*"}
  API -- yes --> BE["env.WORKSHOP_BACKEND.fetch(req)"]
  API -- no --> ASSETS{"env.ASSETS bound?"}
  ASSETS -- "yes (prod)" --> A["env.ASSETS.fetch(req)"]
  ASSETS -- "no (dev)" --> BE

  subgraph Origin["router worker"]
    REQ; GK; API; ASSETS
  end
  subgraph Downstream["service bindings"]
    GKW; BE; A
  end
```

| Order | Match | Target |
| --- | --- | --- |
| 1 | `pathname === /gatekeeper/<suffix>` or starts with `/gatekeeper/<suffix>/` | `env[GATEKEEPER_*]` |
| 2 | `/api`, `/api/*`, `/blueprint-screenshot`, `/blueprint-screenshot/*` | `env.WORKSHOP_BACKEND` |
| 3 | anything else, `ASSETS` bound | `env.ASSETS` |
| 4 | anything else, `ASSETS` unbound | `env.WORKSHOP_BACKEND` |

<Note>
Gatekeeper matching runs before the `/api` check, so a gatekeeper binding named such that its prefix collides with an API path would shadow the backend. The exact-or-slash test (`pathname === prefix || pathname.startsWith(prefix + "/")`) prevents a prefix like `/gatekeeper/git` from swallowing `/gatekeeper/github`.
</Note>

## Gatekeeper prefix derivation

Each `env` key is transformed into a path prefix:

```ts
const suffix = key.slice("GATEKEEPER_".length).toLowerCase().replaceAll("_", "-");
const prefix = `/gatekeeper/${suffix}`;
```

| Env key | Derived prefix | Matches |
| --- | --- | --- |
| `GATEKEEPER_EMAIL` | `/gatekeeper/email` | `/gatekeeper/email`, `/gatekeeper/email/mailbox/myinbox` |
| `GATEKEEPER_GITHUB` | `/gatekeeper/github` | `/gatekeeper/github`, `/gatekeeper/github/oauth` |
| `GATEKEEPER_GOOGLE_DRIVE` | `/gatekeeper/google-drive` | `/gatekeeper/google-drive/*` |

The matched request is forwarded unmodified: `return (env[key] as Fetcher).fetch(req)` — the router does not strip the prefix, so gatekeeper Workers see the full path.

<Warning>
Gatekeeper OAuth redirects land on the gatekeeper Workers themselves at `/gatekeeper/<name>/oauth`, handled by this same prefix loop. There are no backend `/auth` callback routes.
</Warning>

## Frontend and dev fallback

When `ASSETS` is bound (the production wrangler `assets` stanza), every non-API, non-gatekeeper request goes to the asset fetcher. When it is absent, the request goes to `WORKSHOP_BACKEND` instead. That fallback is what makes one worker serve both roles:

- **`run-local` mode** — the backend has its own static `assets` binding with `run_worker_first` for the API routes, so it serves the pre-built single-page app for frontend requests arriving through the fallback.
- **normal dev mode** — the backend has no assets and frontend requests are not expected on the router; run `pnpm dev-client` and open `localhost:3000` directly. The router deliberately does not proxy to `localhost:3000` because Vite's HMR socket drops every time wrangler restarts workerd.

The repo-root `wrangler.jsonc` defines the dev router:

```jsonc
// wrangler.jsonc (repo root)
{
  "name": "dev-router",
  "main": "packages/router/src/index.ts",
  "compatibility_date": "2025-11-01",
  "compatibility_flags": ["enable_ctx_exports"],
  "services": [
    { "binding": "WORKSHOP_BACKEND", "service": "workshop-backend" }
  ]
}
```

Gatekeeper service bindings are added to this file dynamically by `run-dev-server.js`; no `ASSETS` binding is configured here, which is what activates the dev fallback branch.

## Inbound email dispatch

```ts
async email(message, env) {
  if (!env.GATEKEEPER_EMAIL) {
    message.setReject("No email gatekeeper is installed on this instance.");
    return;
  }
  await env.GATEKEEPER_EMAIL.email(message);
}
```

The router performs no parsing or addressing of its own; it hands the raw `message` to the email gatekeeper's `email()` entrypoint. Inside `packages/gatekeeper-email`, the recipient local part selects an `EmailAddress` Durable Object, which loads the stored hook `Fetcher` from its KV storage and invokes the Gadget's `receiveEmail()` with a postal-mime–parsed email.

```
Internet email → Cloudflare Email Routing → router.email()
                                               │
                                               ▼
                                     GATEKEEPER_EMAIL.email(message)
                                               │
                                               ├── email() handler parses recipient
                                               ▼
                                     EmailAddress DO (per username)
                                               │
                                               ├── loads stored hook Fetcher
                                               ▼
                                     Gadget's hook entrypoint
                                     (via Overseer loopback)
```

If no hook is configured for the address, the email is rejected. Mailbox local parts are canonicalized to lowercase and may contain letters, numbers, dots, underscores, plus signs, or hyphens; they cannot start or end with a dot or contain consecutive dots.

<Info>
The email gatekeeper's own `BASE_URL` must be the full base URL (protocol + host + optional path, no trailing slash) at which its `fetch` handler is served — e.g. `https://app.example.com/gatekeeper/email` when co-hosted behind this router, matching the prefix derived from `GATEKEEPER_EMAIL`.
</Info>

### Simulating inbound mail locally

Wrangler exposes `/cdn-cgi/handler/email` in local dev. With `pnpm run dev-server` running:

<RequestExample>
```bash Send a test email
curl -X POST 'http://localhost:8787/cdn-cgi/handler/email' \
  --url-query 'from=sender@example.com' \
  --url-query 'to=myinbox@example.com' \
  --header 'Content-Type: application/json' \
  --data-raw 'From: "Alice" <sender@example.com>
To: myinbox@example.com
Subject: Hello from local dev
Content-Type: text/plain; charset="utf-8"
Date: Mon, 16 Feb 2026 12:00:00 +0000
Message-ID: <test-123@example.com>

This is a test email body.'
```
</RequestExample>

Real SMTP delivery is not supported locally. In production, Cloudflare Email Routing must be enabled with an Email Workers route (for example a catch-all `*@yourdomain.com`) whose action sends to the deployed `gatekeeper-email` worker.

## Backend worker bindings

`packages/workshop-backend/wrangler.jsonc` declares the storage and runtime bindings the backend receives.

| Binding | Type | Config | Purpose |
| --- | --- | --- | --- |
| `BLUEPRINTS` | `KVNamespace` | `preview_id: gadgets-blueprint-metadata` | Blueprint metadata lookup |
| `BLUEPRINT_CONTENT` | `R2Bucket` | `bucket_name: gadgets-blueprint-content` | Blueprint code snapshots |
| `AVATARS` | `KVNamespace` | `preview_id: gadgets-avatars` | User avatar images |
| `LOADER` | worker loader | `worker_loaders[].binding` | Loads Dynamic Workers |
| `BROWSER` | `BrowserRun` | `browser.binding` | Renders Gadget exports; optional for self-hosted |
| `PRODUCT_ANALYTICS` | `Pipeline<ProductAnalyticsRecord>` | not in wrangler.jsonc | Optional analytics stream; no-ops when unbound |
| `FRONTEND_ERROR_REPORTER` | `Service<ErrorReporter>` | not in wrangler.jsonc | Optional browser error forwarding |
| `FRONTEND_ERROR_RATE_LIMITER` | `RateLimit` | not in wrangler.jsonc | Optional per-key limit on browser reports |

Gatekeeper service bindings and the Workers AI binding are **not** declared in `packages/workshop-backend/wrangler.jsonc`; they are added dynamically by `run-dev-server.js` (dev) and `generate-wrangler-prod.js` (production). `GATEKEEPER_*` bindings are also deliberately absent from `packages/workshop-backend/src/env.d.ts` — the backend discovers them generically by prefix scan (`buildGatekeeperVendorMap`) and never references a specific gatekeeper by name.

### Optional error-reporting pair

`FRONTEND_ERROR_REPORTER` and `FRONTEND_ERROR_RATE_LIMITER` must both be present before any browser report dispatches. `handleClientErrorRequest` in `packages/workshop-backend/src/client-errors.ts` short-circuits otherwise:

```ts
const reporter = env.FRONTEND_ERROR_REPORTER;
const limiter = env.FRONTEND_ERROR_RATE_LIMITER;
if (!reporter || !limiter) return new Response(null, { status: 204 });
```

Behavior of that endpoint:

| Condition | Response |
| --- | --- |
| Method is not `POST` | `405` with `allow: POST` |
| `origin` header ≠ request origin | `403 Cross-origin API access not allowed.` |
| `content-type` is not `application/json` | `415 Expected application/json.` |
| Reporter or limiter unbound | `204` |
| `CF_ACCESS_AUD` set and JWT invalid | `403 Invalid CF access JWT.` |
| Access JWT carries no user identity | `403 Access JWT didn't specify a user identity.` |
| Rate limit not `success`, or limiter throws | `204` |
| Body exceeds `MAX_BODY_BYTES` (128 KiB) | `413 Payload Too Large` |
| Body is unreadable or not JSON | `400 Invalid JSON` |
| `normalizeFrontendErrorReport` rejects the payload | `400 Invalid frontend error report` |
| Accepted | `204`, dispatch via `ctx.waitUntil` |

The rate-limit key is the Access-derived identity when `CF_ACCESS_AUD` is set, otherwise the `cf-connecting-ip` header, falling back to the literal `"unknown"`. Dispatch failures are logged at debug level under `component: "workshop.client-errors"` and never affect the UI response.

## Compatibility flags

`packages/workshop-backend/wrangler.jsonc` sets `compatibility_date: "2026-02-02"` and these flags:

| Flag | Reason |
| --- | --- |
| `allow_irrevocable_stub_storage` | RPC stub storage |
| `enhanced_error_serialization` | Error propagation |
| `global_fetch_strictly_public` | SSRF protection for the global `fetch()`, notably the `webFetch` agent tool |
| `nodejs_compat` | Provider SDKs (`@anthropic-ai/sdk`, `openai`, `@google/genai`) and Puppeteer for PDF exports |

<Warning>
`wrangler dev` intentionally reconfigures its global outbound to permit fetching any address so local services stay reachable, so `global_fetch_strictly_public` effectively only takes effect in production or when running `workerd` stand-alone. Under stand-alone `workerd`, blocking private-network addresses is already the default.
</Warning>

The router's own `compatibility_flags` are `["enable_ctx_exports"]` at `compatibility_date: "2025-11-01"`.

## Durable Object migration tags

All backend DO classes are reached via `ctx.exports` and need no explicit `durable_objects` binding — only migration entries.

:::updates

@update v0 - Initial SQLite classes: `UserDurableObject`, `OverseerDurableObject`.

@update v1 - Adds `AdminSettings`.

@update v2 - Adds `PendingLogin`: sign-in via authentication gatekeepers uses a short-lived DO to bridge each gatekeeper login back to the waiting browser.

:::

```jsonc
// packages/workshop-backend/wrangler.jsonc
"migrations": [
  { "tag": "v0", "new_sqlite_classes": [ "UserDurableObject", "OverseerDurableObject" ] },
  { "tag": "v1", "new_sqlite_classes": [ "AdminSettings" ] },
  { "tag": "v2", "new_sqlite_classes": [ "PendingLogin" ] }
]
```

## Production asset hosting

The backend can host the frontend itself rather than relying on a router `ASSETS` binding. The commented stanza in `packages/workshop-backend/wrangler.jsonc` shows the shape, with `run_worker_first` reserving the backend-owned routes:

```jsonc
// "assets": {
//   "directory": "../workshop-frontend/dist",
//   "not_found_handling": "single-page-application",
//   "run_worker_first": ["/api", "/api/*", "/blueprint-screenshot/*"]
// },
```

<Check>
The `run_worker_first` list mirrors the router's backend-routed prefixes (`/api*` and `/blueprint-screenshot/*`). Keep the two in sync when adding a backend-owned path, or the SPA handler will intercept it.
</Check>

Observability on the backend is enabled with `head_sampling_rate: 1` and `logs.invocation_logs: false`.

## Related pages

<CardGroup cols={2}>
  <Card title="Environment variables" href="/environment-variables">
    Every backend environment variable and its default, including `PUBLIC_BASE_URL`, the `CF_AI_GATEWAY*` family, and `CF_ACCESS_AUD`/`CF_ACCESS_ISS`.
  </Card>
  <Card title="Local development" href="/local-development">
    The two-terminal workflow, generated dev wrangler files, and dynamic gatekeeper service-binding discovery.
  </Card>
  <Card title="Build a gatekeeper" href="/build-a-gatekeeper">
    Add a connector package and install it by adding a `GATEKEEPER_*` binding.
  </Card>
  <Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
    The `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect-URI contract and per-connector secrets.
  </Card>
  <Card title="Gadgets and sandboxing" href="/gadgets-and-sandboxing">
    How the `LOADER` binding and `global_fetch_strictly_public` isolate a gadget.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    No-op error reporting without its bindings, and other known failure modes.
  </Card>
</CardGroup>
