---
paths:
  - "**/wrangler.*"
  - "**/.dev.vars*"
  - "**/worker/**"
  - "**/workers/**"
  - "**/src/**/*.ts"
---
# Cloudflare Worker

Conventions for Cloudflare Workers — `wrangler.jsonc` setup, vars and secrets, and code shape (dispatch, errors, modules, WebSockets). Schema design doctrine is the `schema-design` skill.

## 1. Project & wrangler.jsonc Setup

### 1.1 Wrangler version

- Use Wrangler **v4**. If an editor flags valid config as invalid, check the wrangler version first.
- **No `package.json`** (dep-free worker): use `npx wrangler@4` in every command and doc. Never create a `package.json` just to record the version.
- **`package.json` exists**: pin wrangler to the exact version tested against (not `^4.0.0`), reinstall, and verify with `wrangler deploy --dry-run` before pushing.

### 1.2 Config format

- Use `wrangler.jsonc` (not `wrangler.toml`).
- Include `"$schema": "node_modules/wrangler/config-schema.json"`.

### 1.3 Comments in jsonc

None by default. Keep a comment only when it captures a non-obvious **WHY** — a hidden constraint, workaround, or account-specific quirk. Never narrate what a key already says.

### 1.4 Public entry points — workers.dev & preview URLs

- **Don't expose on `workers.dev` by default** — it is public and guessable (`<worker>.<account>.workers.dev`). Default to `"workers_dev": false`; when a deploy is requested and no custom domain or route exists yet, warn the user that enabling it makes the Worker public and enable it only on confirmation.
- **`preview_urls` is a second public door** (`<version>-<worker>.<account>.workers.dev`) — `workers_dev` alone is not the whole switch. Set `"preview_urls": false` with it.
- Pair a custom domain with a `routes` entry using `custom_domain: true`. **The zone must be in the same account as the Worker** — a route pointing at a zone the account doesn't hold fails at deploy, so check the zone before writing the route.
- **Declare both keys explicitly, in the same edit that adds the route** — never leave them to the implicit default, and never delete them as redundant once a route exists.

> **Adding a route cuts over atomically — you cannot stage it.** The moment a `routes` entry lands, an absent `workers_dev` key disables `workers.dev` on that same deploy; "add the route now, turn it off once the custom domain answers" is not a sequence wrangler will execute. Where the certificate is slow to provision, that gap is downtime — confirm the outage window with the user before cutting over.

Deleting a route is likewise a cutover: `workers.dev` does not return until `"workers_dev": true` is set explicitly, and a torn-down hostname `404`s rather than redirecting — callers holding the old URL get no forwarding.

### 1.5 Default config

Every Worker carries these keys. Deviate only where a rule below says to.

```jsonc
"workers_dev": false,
"preview_urls": false,
"observability": {
    "logs": {
        "enabled": false,
        "invocation_logs": true
    }
}
```

- `workers_dev` / `preview_urls` — entry-point policy and cutover mechanics: §1.4.
- **Bootstrap override:** a new Worker (especially cron-only) runs `logs.enabled: true` until a few real runs look clean, then flips back — with logs off, `console.log` is live-only via `npx wrangler tail` and nothing persists after a failed run.

### 1.6 Project structure & dependencies

- Main entry file: `index.ts`. Reusable helpers: `helper.ts`.
- TypeScript under `strict: true` — no build step, and a minimal `tsconfig.json` ships even in dep-free Workers.
- Don't run `npm run build` or `npm run deploy` in examples.
- **`package.json` / `package-lock.json` are opt-in.** Create them only when code under `src/` imports an npm package at runtime; add both the moment the first real dep lands. Run wrangler, linters, and one-offs via `npx <tool>`. `node_modules/` stays in `.gitignore` either way.

### 1.7 Environment & configuration

- Use the deployed Cloudflare URL in docs/tests, not `localhost`.
- Prefer SQLite-based Durable Objects over in-memory-only designs; add classes under `migrations.new_sqlite_classes`.

### 1.8 Cloudflare Secrets Store

Use when the user mentions it. Use the same `binding` name as the `secret_name`.

```jsonc
"secrets_store_secrets": [
    { "binding": "CLICKUP_KEY", "store_id": "792206780c0c46e8ba8ade3f21b44eac", "secret_name": "CLICKUP_KEY" }
]
```

Access in code: `await env.CLICKUP_KEY.get()`.

### 1.9 Naming

- Repo/dir: `cf-<name>` (e.g. `cf-extract-design`).
- Worker name: `<name>` — repo name minus the `cf-` prefix.
- R2 bucket, D1 database, and KV namespace names: equal to the worker name.

### 1.10 Vars & secrets

**Non-secret vars live in the `vars` block in `wrangler.jsonc`**, checked in. Never move them to the dashboard. A single-tenant application Worker declares them at the top level; a multi-tenant Worker declares per-tenant vars under each `env.<tenant>` block (§11).

**Secrets never touch git.** Local dev: a gitignored `.dev.vars` (`KEY=VALUE` dotenv, auto-loaded by `wrangler dev`) with a committed `.dev.vars.example` listing the keys and placeholder values. Production: `wrangler secret put <NAME>`, a gitignored `.secrets` file pushed with `wrangler secret bulk .secrets` (or shipped with a deploy via `wrangler deploy --secrets-file .secrets`), or the Secrets Store (§1.8). Never log or echo a secret value.

**No `keep_vars`** — leave it unset. It guards only plaintext vars set out-of-band in the dashboard and never touches secrets; `wrangler secret put` secrets survive every deploy regardless, so never add it to protect them. Only the multi-tenant pattern sets `keep_vars: true` (§11).

**Never name a deployed-value file `.env` or `.env.<anything>`** — `wrangler dev` auto-loads a root `.env` as local dev vars, so a prod-valued `.env` silently becomes the local environment on a fresh clone or in CI. Committed `*.example` files carry keys with placeholder values only.

`.gitignore`: `.dev.vars`, `.secrets`, `.secrets.*`, `.env`, `.env.*`

### 1.11 Deploys

**Never auto-deploy.** `wrangler deploy` (any variant — `--env`, `versions upload`) runs only when the user explicitly asks. Building, dry-running (`wrangler deploy --dry-run`), and `wrangler dev` are fine unprompted. Finish the change, then tell the user it's ready to deploy.

## 2. Code & Helpers

- Use `generateUuidV7()` in `helper.ts` for IDs.
- Only `await` what's needed (e.g. `request.json()`, `do.fetch(...)`).

## 3. fetch() Structure

`validateRequest()` first, then `switch (path)` for dispatch. Single `try/catch` wrapping the entire handler. No if-chains, no regex in fetch.

```js
export default {
  async fetch(request, env, ctx) {
    try {
      const route = validateRequest(request);
      switch (route.path) {
        case "monitors":
          return await handleMonitors({ ...route, env, ctx });
        default:
          throw { code: 404, message: "Route not found" };
      }
    } catch (error) {
      console.error("Unhandled error in fetch()", error);
      return createErrorResponse(
        error.message || "Internal server error",
        error.code || 500,
      );
    }
  },
};
```

No nested `try/catch` except around WebSocket `send`/`close` (see §9).

## 4. validateRequest()

Lives in `helper.ts`. Parses the URL in one place and returns `{ path, method, query, resourceId, subPath }`; throws `{ code: 400 }` when a path requires a resource ID and none is present.

## 5. Validation

Fail fast with thrown `{ code, message }` (`400` bad input, `401` missing/invalid auth). Validate required fields explicitly — no optional chaining for required fields.

## 6. Errors & Responses

- Throw structured errors: `{ code, message }`. Always `console.error()` with context (path, user, etc.). No empty catch blocks.
- Response envelope `{ status: 1|0, message, data }` — the shared cross-service contract.
- `createResponse(data, message, code)` in `helper.ts` bakes CORS in, returns a null body for 204/304, and uses proper HTTP codes; `createErrorResponse(message, code)` delegates to it.
- CORS is passive: a `CORS_HEADERS` const in `helper.ts`, spread into `createResponse()` — no explicit OPTIONS handler, no wrapper functions. Origin-restricted CORS builds the headers from an allowlist (plus `localhost` in dev), still inside `createResponse()`.

## 7. Handler Signatures

Single destructured object — never positional args. Compose by spreading route with env/ctx: `handleGetStats({ request, env, resourceId })`, not `handleGetStats(request, id, env)`.

## 8. Module Structure

Feature-based folders with `handle.ts` + `helper.ts` pairs. `index.ts` only does dispatch.

| Path | Role |
| --- | --- |
| `src/index.ts` | Entry point — dispatch only |
| `src/helper.ts` | Shared CORS, responses, validation, utilities |
| `src/database.ts` | Shared D1 queries |
| `src/<feature>/handle.ts` | Route handlers |
| `src/<feature>/helper.ts` | Feature-specific helpers & DB ops |

## 9. WebSockets

Standard `WebSocketPair` handshake (`server.accept()`, return 101 with `webSocket: client`). Check `readyState === WebSocket.OPEN` before sending. The only acceptable nested `try/catch`: wrap `send`; in its catch, `close(1011)` inside a bare inner try, then evict the session.

## 10. D1 Schema

Follows the shared database naming and column-type vocabulary; what earns a table and the rest of the design doctrine is the `schema-design` skill.

## 11. Multi-tenant Workers (native environments)

Rare — only when **one Worker codebase ships as several independent tenants**: separate Workers, domains, and data stores from a single `src/`. Use **native wrangler environments**, never one Worker that switches tenant at runtime. For a single-tenant application Worker, §1.10 applies and this section does not.

- **One config, `env.<tenant>` blocks.** The top-level block is **local dev** (`wrangler dev`): a `*-dev` name and placeholder D1/KV ids, simulated locally. Each `env.<tenant>` block is **one production tenant**; deploy it with `wrangler deploy --env <tenant>`.
- **Named environments never inherit** `vars` / `d1_databases` / `kv_namespaces` / `routes` / `secrets_store_secrets` / `assets` from the top level — **each `env.<tenant>` declares them in full**. Dropping a binding from an env block disables that capability for that one tenant.
- **Per-tenant non-secret vars live in `env.<tenant>.vars`**, checked in.
- **Local-dev vars** come from a gitignored `.vars.local`, injected as `--var` by the dev script (the top-level block carries no in-config `vars`).
- **Per-tenant secrets, never in git** — `wrangler secret put <NAME> --env <tenant>`, or a gitignored per-tenant `.secrets` (e.g. `worker/tenants/<tenant>/.secrets`) pushed with `wrangler secret bulk --env <tenant>`, plus a committed `.secrets.example`. Secrets Store bindings are declared inside each env block.
- **`keep_vars: true`** (top level) keeps a per-tenant deploy **additive**: tenants inject their non-secret vars via `--var` (or the dashboard), and without it a deploy would wipe them. On secrets it changes nothing (§1.10).

`.gitignore`: §1.10's list, with `.vars.local` in place of `.dev.vars`.
