Claude Skill

cloudflare-ops

Cloudflare Workers + Wrangler edge ops: runtime, bindings, local dev, secrets, deploy/CI, Pages-vs-Workers. Triggers on: cloudflare workers, wrangler, wrangler deploy, wrangler.toml, KV, D1, R2, durable objects, queues, vectorize, compatibility_date, edge functions, illegal invoc

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_cloudflare-ops-3dfaf0b.zip · 32 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/cloudflare-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Cloudflare Operations

Cloudflare Workers + Wrangler: runtime patterns, bindings, local dev, secrets, deploy, CI/CD, observability.

Ecosystem facts verified as of 2026-07.

Version context (verified 2026-07): Wrangler v4.x · config is wrangler.jsonc (Cloudflare's recommended format for new projects — some newer features are JSON-config-only; wrangler.toml still works and is widespread in older repos) · deploy command is wrangler deploy (the old wrangler publish is deprecated — see gotchas). Workers can now serve static assets, which is the current direction for full-stack and static sites over Pages (see Workers vs Pages).

Reference Files

File Covers
references/bindings.md Every binding (KV/D1/R2/DO/Queues/Hyperdrive/AI/Vectorize/Service/Analytics Engine) — config block, runtime API, when to reach for each, consistency model
references/workers-runtime.md Runtime APIs, handlers (fetch/scheduled/queue/email/tail), CORS, caching, streaming, WebSockets, Durable Objects deep-dive, limits
references/workers-runtime-gotchas.md Production footguns: detached fetch ("Illegal invocation"), per-colo caches vs KV vs D1, waitUntil semantics + outbox pattern, testing cron handlers, test-workerd lagging production, Email Service account states, Smart Placement, wrangler dev host rewriting
references/deploy-and-cicd.md wrangler deploy, environments, secrets, Workers Builds, GitHub Actions + OIDC/API-token, gradual deployments, rollbacks, observability
assets/wrangler.jsonc.template Commented, current wrangler.jsonc covering all common bindings + assets

Access / Zero Trust auth patterns (verifying Cf-Access-Jwt-Assertion, AUD tags, service auth, closed origins) → auth-ops skill, references/cloudflare-access.md.

Workers vs Pages Decision

Cloudflare added static-asset hosting to Workers; a single Worker now serves a static site, a full-stack app, or an API + SPA. For new projects, default to Workers with static assets. Pages still works and isn't deprecated, but Workers has the broader, faster-moving feature set (Durable Objects, Cron Triggers, Queues, richer observability) and is where Cloudflare's investment goes.

New project?
│
├─ Pure static site (no server logic)
│  └─ Workers + assets binding (asset-only — requests matching files never invoke Worker code, $0 for those).
│     Pages is also fine here; Workers keeps one platform if you later add logic.
│
├─ Full-stack / SPA + API / SSR framework (Next, Astro, Remix, SvelteKit, Hono)
│  └─ Workers + assets + a Worker script. Use the framework's Cloudflare adapter (C3: `npm create cloudflare@latest`).
│     This is the current recommended path — Pages' framework story is converging into Workers.
│
├─ Already on Pages and happy
│  └─ Stay. "What works in Pages works in Workers" — migrate only when you need a Workers-only
│     feature (DO, Cron, Queues, advanced observability). See the migrate-from-pages guide.
│
└─ Need Durable Objects / Cron Triggers / Queues / Tail Workers
   └─ Workers (these are Workers-only).

Asset serving modes (in the assets block): asset-only (no main) serves files directly and never bills Worker invocations for matches; assets + Worker serves matching files first, falls through to your fetch handler for everything else (or set run_worker_first to invoke the Worker before asset matching). Reach assets from code via env.ASSETS.fetch(request).

Wrangler Config Skeleton (jsonc)

Full annotated version: assets/wrangler.jsonc.template.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",   // pins the runtime version — REQUIRED, bump deliberately
  "compatibility_flags": ["nodejs_compat"],  // opt-in runtime features (Node built-ins, etc.)

  "observability": { "enabled": true },  // turn on Workers Logs (off by default)

  "assets": { "directory": "./public", "binding": "ASSETS" },

  "kv_namespaces": [{ "binding": "CACHE", "id": "<kv-id>" }],
  "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<d1-id>" }],
  "r2_buckets":   [{ "binding": "BUCKET", "bucket_name": "uploads" }],

  "vars": { "ENVIRONMENT": "production" },  // NON-secret config only — never put secrets here

  "env": {
    "staging": { "vars": { "ENVIRONMENT": "staging" } }  // named env: deploy with --env staging
  }
}
  • compatibility_date = yyyy-mm-dd, selects the runtime version. It's required and load-bearing: bumping it can change behaviour, so do it deliberately and test. compatibility_flags opt into upcoming/Node-compat features (e.g. nodejs_compat).
  • Keep secrets OUT of vars — they land in plaintext in the deployed config. Use wrangler secret put / .dev.vars (secrets).
  • TOML equivalent still parses; the binding shapes map 1:1 ([[kv_namespaces]], [[d1_databases]], …). New repos: prefer jsonc.

Bindings Table — When Each

Full config + runtime API for every binding: references/bindings.md.

Binding Reach for it when… Consistency / note
KV Read-heavy config/cache, infrequent writes, global reads Eventually consistent (~60s propagation). Fast reads, slow-ish writes. Not for "read your own write".
D1 Relational/SQL data, moderate scale, per-app database SQLite at the edge. Strong within a DB; read replication is async. Use for app data with joins.
R2 Object/blob storage, large files, zero egress fees S3-compatible. Replaces S3 for media/backups/assets you serve.
Durable Objects Strong consistency, coordination, stateful realtime (chat, presence, game rooms, rate limit counters) Single-threaded per object instance = serialized = consistent. The answer when KV's eventual consistency bites. SQLite-backed storage available.
Queues Async/background work, decoupling, batching, retries Producer binding + consumer Worker. Smooths spikes; guaranteed delivery with retries + DLQ.
Hyperdrive Connecting to an existing external Postgres/MySQL with pooling + edge caching Makes a regional DB feel fast from Workers. Needs nodejs_compat.
Workers AI Run inference (LLM, embeddings, image) on Cloudflare's GPUs ai binding → env.AI.run(model, ...). Pairs with Vectorize for RAG.
Vectorize Vector DB for embeddings / semantic search / RAG vectorize binding. Store + query embeddings, often fed by Workers AI.
Service bindings Worker-to-Worker RPC without a network hop Zero-latency internal calls; compose Workers as services.

Decision shortcut: need strong consistency or coordination → Durable Objects. Relational queries → D1. Big files → R2. Cheap global cache → KV. Background work → Queues. External SQL DB → Hyperdrive.

Minimal Worker

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === "/health") return Response.json({ ok: true });
    return new Response("Hello from the edge");
  },
};

env carries every binding (env.DB, env.CACHE, env.ASSETS, secrets, vars). ctx.waitUntil(promise) runs background work after the response is sent. Workers require ES module format (export default { fetch }) — the old service-worker addEventListener("fetch") format is legacy. Full handler patterns (scheduled/queue/email/tail, CORS, caching, WebSockets, DO): references/workers-runtime.md.

Local Dev & Secrets

npm create cloudflare@latest my-app   # C3 scaffolder — picks framework + adapter + wrangler.jsonc
wrangler dev                          # local dev server (Miniflare/workerd) on localhost:8787
wrangler dev --remote                 # run on Cloudflare's edge (real bindings) instead of local sim
wrangler types                        # generate TS types for env from your bindings → worker-configuration.d.ts

Secrets (never in vars):

Where Mechanism
Local dev .dev.vars file (dotenv format, gitignored) — wrangler dev loads it as env.*. Per-env: .dev.vars.staging.
Deployed wrangler secret put NAME (prompts for value, encrypts it) · wrangler secret list · wrangler secret delete NAME
CI bulk wrangler secret bulk secrets.json
Newer Cloudflare Secrets Store bindings (account-level shared secrets) — see deploy reference

Add .dev.vars* to .gitignore. vars in config = plaintext public config; secrets are encrypted and write-only.

Deploy & CI/CD

Full detail: references/deploy-and-cicd.md.

wrangler deploy                  # build + upload + activate (NOT `wrangler publish` — deprecated)
wrangler deploy --env staging    # deploy a named environment
wrangler versions upload         # upload a new version WITHOUT making it live (gradual deploys)
wrangler versions deploy         # split traffic across versions (e.g. 10% new / 90% old)
wrangler rollback                # revert to the previous deployed version
wrangler tail                    # stream live logs from the deployed Worker
  • Workers Builds — Cloudflare's native git-connected CI: push to GitHub/GitLab, Cloudflare builds + deploys. Zero-config for simple Workers; the default for most teams.
  • GitHub Actions — cloudflare/wrangler-action. Authenticate with a scoped API token (CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID as secrets), least-privilege (Workers Scripts:Edit). Template + workflow in the deploy reference.
  • Gradual deployments — versions upload then versions deploy to shift a percentage of traffic; instant rollback if metrics regress.

Observability

  • "observability": { "enabled": true } in config turns on Workers Logs (structured console.log capture in the dashboard) — off by default, opt in.
  • wrangler tail for live request log streaming during an incident.
  • Tail Workers — a Worker that receives execution traces of another Worker (centralised logging/alerting).
  • Analytics Engine — write custom time-series metrics from a Worker (env.AE.writeDataPoint(...)), query via GraphQL/SQL API.

Common Gotchas

Runtime-level footguns that pass tests and ship — detached fetch ("Illegal invocation"), per-colo caches, waitUntil guarantees, cron testing, the test workerd lagging production, Email Service account states, Smart Placement, wrangler dev rewriting the request host — each with symptom/why/fix: references/workers-runtime-gotchas.md.

Gotcha Detail Fix
wrangler publish is gone Renamed to wrangler deploy (Wrangler v3+). Old tutorials/CI still say publish. Use wrangler deploy. Update any publish in scripts/CI.
wrangler.toml vs .jsonc Both parse, but newer features are JSON-config-only and Cloudflare recommends jsonc for new projects. New projects: wrangler.jsonc. Migrating: wrangler.toml → jsonc is a mechanical 1:1.
Missing compatibility_date Required; absent or stale date silently pins old runtime behaviour. Set it; bump deliberately and test — it can change semantics.
CPU time limit Default 30s CPU per invocation (was 10ms/50ms historically; raised). Wall-clock can be longer while awaiting I/O. CPU-bound loops still get killed. Offload heavy compute; use Queues for long async work; check the limits page for your plan.
Script size limit 3 MB (free) / 10 MB (paid) gzipped. Trim deps, dynamic-import large modules, avoid bundling node-only libs.
KV eventual consistency A write isn't globally visible for up to ~60s; not "read your own write". Use Durable Objects when you need strong consistency.
Node built-ins fail fs, crypto, etc. aren't there by default. "compatibility_flags": ["nodejs_compat"] enables a polyfill subset; check what's actually supported.
Secrets in vars vars ships plaintext in the deployed config. wrangler secret put (deployed) / .dev.vars (local).
request/response body read twice Streams are single-use. request.clone() before the first read.
Bundling surprises Wrangler uses esbuild; some packages assume Node/CommonJS. Prefer Workers-compatible libs; set nodejs_compat; check the build output.

Setup

  1. Install: npm install -g wrangler (or use npx wrangler / npm create cloudflare@latest to scaffold).
  2. Auth: wrangler login (OAuth) for local; API token for CI.
  3. Copy assets/wrangler.jsonc.template, strip the bindings you don't need, fill in IDs.
  4. wrangler dev → wrangler deploy.

Staleness verifier

This skill encodes fast-moving facts (Wrangler major line, recommended compatibility_date, wrangler.jsonc config convention). scripts/check-cloudflare-facts.py guards them against silent drift:

# Structural (PR CI, no network): every catalogued fact's prose_token is still
# named in this skill's prose (incl. the jsonc template), and the currency
# note still carries a year.
python scripts/check-cloudflare-facts.py --offline        # exit 0 consistent, 10 drift

# Live (freshness job, never blocks a PR): wrangler still resolves on npm and
# its latest major matches the documented v4.x line.
python scripts/check-cloudflare-facts.py --live            # exit 10 major drift, 7 npm unreachable

The canonical fact set lives in assets/cloudflare-facts.json; when the Wrangler major, the recommended compatibility_date, or the config convention changes, update it to match or --offline fails CI.

Files (claude-mods)
  • assets
    • cloudflare-facts.json 1.3 KB
      {
        "_comment": "Canonical fast-moving facts the cloudflare-ops skill encodes: the Wrangler major line, the recommended compatibility_date, and the config filename convention. scripts/check-cloudflare-facts.py asserts each is still stated in the prose + a dated currency note (--offline), and probes npm for Wrangler major drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping.",
        "schema": "claude-mods.cloudflare-ops.facts/v1",
        "as_of": "2026-07-05",
        "wrangler": {
          "documented_major": 4,
          "prose_token": "v4.x",
          "live": {"source": "npm", "product": "wrangler", "compare": "major"},
          "_comment": "Wrangler v4.x is the skill's assumed major line. --live flags drift if npm's latest wrangler dist-tag leaves major 4 (a 5.x would be a breaking-change release warranting a skill review)."
        },
        "compatibility_date": {
          "documented": "2026-06-01",
          "prose_token": "2026-06-01",
          "_comment": "The compatibility_date the skill's jsonc skeleton recommends users pin. Offline-only: it is a deliberate runtime pin, not a live registry fact."
        },
        "config_format": {
          "documented": "wrangler.jsonc",
          "prose_token": "wrangler.jsonc",
          "_comment": "The recommended config filename for new projects (Cloudflare's direction; some newer features are JSON-config-only). Offline-only."
        }
      }
      
    • wrangler.jsonc.template 5 KB · in bundle
  • references
    • bindings.md 8.7 KB
      # Cloudflare Bindings — config + runtime API
      
      A *binding* is a capability injected into `env` at runtime. You declare it in `wrangler.jsonc`; the runtime hands you a live client. All config snippets are jsonc; the TOML form is a mechanical translation (`kv_namespaces` → `[[kv_namespaces]]`, nested objects → `[table]`).
      
      Verified 2026-06 against developers.cloudflare.com/workers/wrangler/configuration.
      
      ## Quick chooser
      
      | Need | Binding |
      |------|---------|
      | Global cache / config, read-heavy, writes rare | **KV** |
      | Relational data, SQL, joins | **D1** |
      | Files / blobs / media, no egress fee | **R2** |
      | Strong consistency, coordination, realtime state | **Durable Objects** |
      | Background jobs, batching, retries | **Queues** |
      | Existing external Postgres/MySQL | **Hyperdrive** |
      | Model inference (LLM/embeddings/image) | **Workers AI** |
      | Vector search / RAG | **Vectorize** |
      | Worker-to-Worker RPC | **Service binding** |
      | Custom metrics | **Analytics Engine** |
      | Static files | **Assets** |
      
      ---
      
      ## KV — eventually-consistent key-value
      
      ```jsonc
      { "kv_namespaces": [{ "binding": "CACHE", "id": "<namespace-id>", "preview_id": "<preview-id>" }] }
      ```
      
      ```javascript
      await env.CACHE.put("key", "value", { expirationTtl: 3600, metadata: { v: 1 } });
      const v   = await env.CACHE.get("key");                  // string | null
      const j   = await env.CACHE.get("key", { type: "json" });
      const { value, metadata } = await env.CACHE.getWithMetadata("key");
      const list = await env.CACHE.list({ prefix: "user:" });
      await env.CACHE.delete("key");
      ```
      
      - **Eventually consistent**: a write propagates globally in up to ~60s. Reading your own write from another colo may return stale. Not a database — a cache/config store. Choosing between KV, the per-colo `caches` API, and D1: [workers-runtime-gotchas.md](workers-runtime-gotchas.md#2-caches-is-per-colo--not-a-kv-substitute).
      - Reads fast (cached at edge); writes + `list` are comparatively expensive. Use TTLs; avoid hot `list` in the request path.
      - CLI: `wrangler kv namespace create CACHE`, `wrangler kv key put --binding=CACHE k v`.
      
      ## D1 — SQLite at the edge
      
      ```jsonc
      { "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<d1-id>" }] }
      ```
      
      ```javascript
      const { results } = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(id).all();
      const row = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(id).first();
      await env.DB.prepare("INSERT INTO users (name) VALUES (?)").bind(name).run();
      await env.DB.batch([stmt1, stmt2]);   // batched, atomic
      ```
      
      - Always use **`.bind()`** parameters — never string-interpolate SQL.
      - Strong consistency within the primary; read replication (Sessions API / read replicas) is async — opt in when you need it.
      - Migrations: `wrangler d1 migrations create` / `apply`. Local: `wrangler d1 execute DB --local --file=schema.sql`.
      
      ## R2 — S3-compatible object storage, zero egress
      
      ```jsonc
      { "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "uploads" }] }
      ```
      
      ```javascript
      await env.BUCKET.put("path/file.png", request.body, { httpMetadata: { contentType: "image/png" } });
      const obj = await env.BUCKET.get("path/file.png");
      if (obj) return new Response(obj.body, { headers: { "etag": obj.httpEtag } });
      await env.BUCKET.delete("path/file.png");
      const listed = await env.BUCKET.list({ prefix: "path/" });
      ```
      
      - **No egress fees** — the reason to move media/backups/static delivery off S3.
      - S3 API compatible (use existing S3 SDKs against the R2 endpoint for external access).
      - Pair with the Cache API or a custom domain for public serving.
      
      ## Durable Objects — strong consistency + coordination
      
      ```jsonc
      {
        "durable_objects": { "bindings": [{ "name": "ROOM", "class_name": "ChatRoom" }] },
        "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }]
      }
      ```
      
      ```javascript
      export class ChatRoom {
        constructor(state, env) { this.state = state; this.env = env; }
        async fetch(request) {
          let count = (await this.state.storage.get("count")) || 0;
          await this.state.storage.put("count", ++count);   // serialized — no races
          return Response.json({ count });
        }
      }
      
      // From another Worker:
      const id   = env.ROOM.idFromName("room-42");
      const stub = env.ROOM.get(id);
      const res  = await stub.fetch(request);
      ```
      
      - **Single-threaded per object instance** → operations on one object serialize → strong consistency. This is the answer when KV's eventual consistency hurts (counters, locks, presence, realtime rooms, rate limiters).
      - Storage: transactional KV-style API, or **SQLite-backed** DO storage (`new_sqlite_classes` in migrations) for relational state per object.
      - Migrations are required to register/rename/delete DO classes — `tag` each migration; never edit an applied one.
      - WebSocket hibernation API lets idle connections sleep without billing.
      
      ## Queues — async background work
      
      ```jsonc
      {
        "queues": {
          "producers": [{ "binding": "JOBS", "queue": "jobs" }],
          "consumers": [{ "queue": "jobs", "max_batch_size": 10, "max_batch_timeout": 30, "dead_letter_queue": "jobs-dlq" }]
        }
      }
      ```
      
      ```javascript
      export default {
        async fetch(req, env)  { await env.JOBS.send({ task: "resize", id: 7 }); return new Response("queued"); },
        async queue(batch, env) {
          for (const msg of batch.messages) {
            try { await handle(msg.body); msg.ack(); }
            catch { msg.retry(); }            // retried; exhausted → dead-letter queue
          }
        },
      };
      ```
      
      - Decouples spikes from processing; guaranteed delivery with retries + DLQ.
      - Batch settings tune throughput vs latency. `ack()`/`retry()` per message or per batch.
      
      ## Hyperdrive — pooled, cached access to external SQL
      
      ```jsonc
      {
        "compatibility_flags": ["nodejs_compat"],
        "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<hyperdrive-config-id>" }]
      }
      ```
      
      ```javascript
      import postgres from "postgres";
      const sql = postgres(env.HYPERDRIVE.connectionString);
      const rows = await sql`SELECT * FROM orders WHERE id = ${id}`;
      ```
      
      - Fronts an **existing** regional Postgres/MySQL with connection pooling + edge query caching, so a far-away DB feels fast from Workers.
      - Requires `nodejs_compat`. Use a Workers-compatible driver (`postgres`, `pg` with compat, `mysql2`).
      - Not a database itself — it's an accelerator for one you already run (RDS, Neon, Supabase, etc.).
      
      ## Workers AI — inference on Cloudflare GPUs
      
      ```jsonc
      { "ai": { "binding": "AI" } }
      ```
      
      ```javascript
      const out = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", { prompt: "Hello" });
      const emb = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: ["doc one", "doc two"] });
      ```
      
      - Single `ai` object binding (no array). Run text-gen, embeddings, image, speech models by ID.
      - Feed embeddings into **Vectorize** for RAG.
      
      ## Vectorize — vector database
      
      ```jsonc
      { "vectorize": [{ "binding": "INDEX", "index_name": "docs" }] }
      ```
      
      ```javascript
      await env.INDEX.upsert([{ id: "1", values: embedding, metadata: { url } }]);
      const matches = await env.INDEX.query(queryEmbedding, { topK: 5, returnMetadata: true });
      ```
      
      - Store + similarity-search embeddings for semantic search / RAG. Commonly fed by Workers AI embeddings.
      - CLI: `wrangler vectorize create docs --dimensions=768 --metric=cosine`.
      
      ## Service bindings — Worker-to-Worker RPC
      
      ```jsonc
      { "services": [{ "binding": "AUTH", "service": "auth-worker", "entrypoint": "AuthEntrypoint" }] }
      ```
      
      ```javascript
      const ok = await env.AUTH.verify(token);          // RPC method (WorkerEntrypoint) — no network hop
      const res = await env.AUTH.fetch(internalRequest); // or HTTP-style
      ```
      
      - Zero-latency internal calls; compose a system as multiple Workers. Supports RPC method calls (via `WorkerEntrypoint`) or `fetch`-style.
      
      ## Analytics Engine — custom metrics
      
      ```jsonc
      { "analytics_engine_datasets": [{ "binding": "AE", "dataset": "my_metrics" }] }
      ```
      
      ```javascript
      env.AE.writeDataPoint({ blobs: [country], doubles: [latencyMs], indexes: [route] });
      ```
      
      - Write high-cardinality time-series from a Worker; query via the GraphQL/SQL Analytics API. Cheap, sampled, fast.
      
      ## Assets — static files
      
      ```jsonc
      { "assets": { "directory": "./public", "binding": "ASSETS",
                    "html_handling": "auto-trailing-slash", "not_found_handling": "single-page-application" } }
      ```
      
      ```javascript
      // In a Worker with both `main` and `assets`:
      export default {
        async fetch(request, env) {
          if (new URL(request.url).pathname.startsWith("/api/")) return handleApi(request, env);
          return env.ASSETS.fetch(request);   // serve static files
        },
      };
      ```
      
      - Asset-only (omit `main`) = pure static host; matching requests never invoke Worker code (and aren't billed as invocations).
      - `not_found_handling: "single-page-application"` serves `index.html` for unmatched routes (SPA routing); `"404-page"` serves a custom 404.
      - `run_worker_first: true` runs your Worker before asset matching (for auth gates, rewrites).
      
    • deploy-and-cicd.md 5.7 KB
      # Deploy, Environments, Secrets, CI/CD, Observability
      
      Verified 2026-06. **The deploy command is `wrangler deploy`. `wrangler publish` is deprecated** (renamed in Wrangler v3; v4 is current) — update any CI/script still calling `publish`.
      
      ## Core commands
      
      ```bash
      wrangler deploy                 # build + upload + activate the Worker
      wrangler deploy --env staging   # deploy the "staging" named environment
      wrangler deploy --dry-run --outdir=dist   # build only, inspect bundle, no upload
      wrangler dev                    # local dev (workerd/Miniflare) at localhost:8787
      wrangler dev --remote           # run on Cloudflare's edge with real bindings
      wrangler tail                   # stream live logs from the deployed Worker
      wrangler types                  # generate worker-configuration.d.ts from bindings
      wrangler delete                 # remove the deployed Worker
      ```
      
      ## Environments
      
      Named environments share one config file; each can override `vars`, bindings, routes, name.
      
      ```jsonc
      {
        "name": "my-worker",
        "vars": { "ENVIRONMENT": "production" },
        "env": {
          "staging": {
            "vars": { "ENVIRONMENT": "staging" },
            "kv_namespaces": [{ "binding": "CACHE", "id": "<staging-kv-id>" }]
          }
        }
      }
      ```
      
      - Deploy: `wrangler deploy` (top-level / production) vs `wrangler deploy --env staging`.
      - The deployed Worker is named `my-worker` for top-level and `my-worker-staging` for the named env (unless you override `name` inside the env).
      - Bindings are NOT inherited into named envs by default — redeclare what each env needs.
      
      ## Secrets
      
      | Scope | How |
      |-------|-----|
      | Local dev | **`.dev.vars`** (dotenv, gitignored). `wrangler dev` injects as `env.*`. Per-env: `.dev.vars.staging`. |
      | Deployed | `wrangler secret put NAME` (prompts, encrypts) · `wrangler secret list` · `wrangler secret delete NAME` |
      | Named env | `wrangler secret put NAME --env staging` |
      | Bulk (CI) | `wrangler secret bulk secrets.json` |
      | Account-shared | **Secrets Store** bindings — define a secret once at the account level, bind it into multiple Workers |
      
      ```jsonc
      // Secrets Store binding
      { "secrets_store_secrets": [{ "binding": "API_KEY", "store_id": "<id>", "secret_name": "api-key" }] }
      ```
      
      Rules: never put secrets in `vars` (plaintext in deployed config). Gitignore `.dev.vars*`. Rotate via `secret put` (overwrites).
      
      ## Workers Builds (native CI)
      
      Cloudflare's git-connected build+deploy: connect a GitHub/GitLab repo in the dashboard, Cloudflare runs your build command and `wrangler deploy` on push. Zero extra CI for most projects; supports per-branch preview deployments, build env vars, and monorepo build paths. Default choice unless you need custom CI steps.
      
      ## GitHub Actions
      
      Use `cloudflare/wrangler-action`. Authenticate with a **scoped API token**, not your global key.
      
      ```yaml
      name: deploy
      on:
        push:
          branches: [main]
      jobs:
        deploy:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@<pinned-sha>
            - uses: actions/setup-node@<pinned-sha>
              with: { node-version: 20 }
            - run: npm ci
            - uses: cloudflare/wrangler-action@<pinned-sha>
              with:
                apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
                accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
                command: deploy
                # secrets: |          # optional: push secrets at deploy time
                #   API_KEY
              # env:
              #   API_KEY: ${{ secrets.API_KEY }}
      ```
      
      **API token scope (least privilege):** create a token with `Account > Workers Scripts > Edit` (plus `Workers KV/R2/D1` edit if the deploy provisions them). Store `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` as repo secrets. Pin action SHAs (supply-chain hygiene), not floating tags.
      
      Cloudflare does not offer OIDC trusted-publishing for Workers deploys today — the auth path is a scoped API token. Keep it least-privilege and rotate it; treat it like a publish credential.
      
      ## Gradual deployments + rollback
      
      ```bash
      wrangler versions upload          # upload a new version, NOT live (0% traffic)
      wrangler versions deploy          # interactively split traffic across versions (e.g. 10% new / 90% old)
      wrangler versions list            # see versions + their traffic split
      wrangler rollback [VERSION_ID]    # revert to a previous version instantly
      ```
      
      Pattern: `versions upload` → `versions deploy` at 10% → watch metrics/logs → ramp to 100%, or `rollback` if it regresses. This is the safe path for risky changes vs a straight `deploy` (100% instantly).
      
      ## Observability
      
      | Tool | Use |
      |------|-----|
      | **Workers Logs** | `"observability": { "enabled": true }` in config — captures `console.log`/errors, queryable in dashboard. **Off by default.** Sampling configurable (`head_sampling_rate`). |
      | `wrangler tail` | Live log stream during an incident / local debugging of prod. |
      | **Tail Workers** | A Worker bound to receive execution traces of another Worker — centralised logging, alerting, forwarding to a SIEM. |
      | **Analytics Engine** | `env.AE.writeDataPoint(...)` for custom high-cardinality metrics; query via GraphQL/SQL Analytics API. |
      | Dashboard metrics | Per-Worker requests, errors, CPU time, subrequests — built in, no setup. |
      
      ```jsonc
      { "observability": { "enabled": true, "head_sampling_rate": 1 } }
      ```
      
      ## Deploy safety checklist
      
      ```
      □ `wrangler deploy` (not publish) — CI updated
      □ compatibility_date present and intentional (bumping changes runtime behaviour)
      □ secrets via `secret put` / Secrets Store — none in `vars`
      □ bindings declared for the target env (named envs don't inherit)
      □ risky change → versions upload + gradual deploy, not 100% deploy
      □ observability enabled so you can see errors after rollout
      □ API token scoped least-privilege; action SHAs pinned in the workflow
      □ rollback path known: `wrangler rollback`
      ```
      
    • workers-runtime-gotchas.md 16.3 KB
      # Workers Runtime Gotchas — production footguns
      
      Battle-tested failure modes from a multi-tenant production Worker (D1 + R2 + Email +
      cron, behind Cloudflare Access). Each entry: the failing symptom, why the runtime
      behaves that way, and the pattern that fixes it. These are the bugs that pass local
      tests and ship — several shipped more than once before the pattern below was adopted.
      
      Companion to [workers-runtime.md](workers-runtime.md) (the API surface) and
      [bindings.md](bindings.md) (per-binding config). Verified 2026-08.
      
      ## 1. Detached `fetch` — "Illegal invocation"
      
      **Symptom:** `TypeError: Illegal invocation` the moment a request handler calls an
      external API — but only in production/`wrangler dev`, never in unit tests.
      
      **Why:** workerd's global `fetch` is a native method that validates its receiver.
      Calling it *as a method of something else* — `this.fetchImpl(...)` after
      `constructor(env, fetchImpl = fetch)`, or `obj.fetch(...)` after stashing it on an
      object — binds that object as `this`, and the native binding rejects any receiver
      that isn't the global scope. The classic carriers: an injectable-for-tests
      `fetchImpl` property, destructuring, or passing `fetch` around as a value.
      
      **Why tests don't catch it:** the injected test double is a plain JS function with no
      receiver check, so the suite is green while the production default (`= fetch`) throws
      on first use. This exact bug shipped **three separate times** in one repo — each new
      API client copied the injectable-fetch constructor pattern and reintroduced it.
      
      **Fix — either bind at the assignment site or strip the receiver at the call site:**
      
      ```typescript
      export class ApiClient {
        private readonly fetchImpl: typeof fetch;
      
        constructor(env: Env, fetchImpl: typeof fetch = fetch) {
          // Option A: bind once at assignment — every later call site is safe.
          this.fetchImpl = fetchImpl === fetch ? fetch.bind(globalThis) : fetchImpl;
        }
      
        private async get(path: string): Promise<Response> {
          // Option B: call fetch DETACHED — pulling it into a bare local strips the
          // receiver so `this` is undefined and the global fetch runs. Without this,
          // `this.fetchImpl(...)` binds `this` to this object → "Illegal invocation".
          const doFetch = this.fetchImpl;
          return doFetch(`${this.base}${path}`);
        }
      }
      ```
      
      Whichever you pick, leave a guard comment at the site — this pattern *looks* like a
      pointless local and a future refactor will inline it straight back into the bug.
      (`(...args) => fetch(...args)` as the default parameter also works.)
      
      ## 2. `caches` is per-colo — not a KV substitute
      
      **Symptom:** cache entries "disappear" (a write in Sydney is invisible to a request
      landing in London); `cache.delete()` "doesn't work" (it only deletes in the colo that
      ran it); a cache-backed feature behaves differently per user by geography.
      
      **Why:** the Cache API (`caches.default` / `caches.open`) is a **per-datacenter**
      store. There is no replication and **no cross-colo invalidation** — every colo has an
      independent cache, populated only by requests that landed there. It is also
      best-effort: entries can be evicted anytime, and `put()` refuses responses marked
      `private` or `no-store`.
      
      **Choosing the right store:**
      
      | Need | Use | Why |
      |------|-----|-----|
      | Collapse repeated reads of slow/rate-limited upstreams, staleness in seconds is fine | **`caches` + short TTL** | Free, no binding, per-colo is acceptable when the TTL is shorter than "who cares" |
      | Global read-mostly config, staleness up to ~60 s is fine | **KV** | Actually replicated globally (eventually) |
      | Read-your-own-write, transactions, anything money-shaped | **D1** (or a Durable Object) | The only options with real consistency |
      
      A production worked example (recorded as two ADRs in the source repo — "no KV; D1 is
      the store" and "short-TTL `caches` for upstream reads"): client-portal reads each made
      2–3 live calls into an upstream API where **every tenant shares one org-wide rate
      budget** (~60 req/min). A client polling hard enough starved the admin's *write* path
      of that budget. The fix was a 45 s TTL cache over the `caches` API — long enough to
      collapse a poll storm into one upstream call per window, short enough that staleness
      is immaterial. Anything needing consistency (idempotency keys, tokens, config that
      admins toggle live) stayed in D1.
      
      ```typescript
      const TTL_S = 45;
      // Synthetic internal-host key: can never collide with a real request URL, and the
      // identity (tenant, resolved client, resource) is IN the key — never cache across
      // an authorization boundary you didn't encode into the key.
      const key = (t: string, c: string, r: string) =>
        new Request(`https://xero-cache.internal/v1/${t}/${c}/${r}`);
      
      export async function cached<T>(t: string, c: string, r: string, produce: () => Promise<T>): Promise<T> {
        const hit = await caches.default.match(key(t, c, r));
        if (hit) return (await hit.json()) as T;
        const value = await produce();           // an unexpected error THROWS OUT — never cached
        await caches.default.put(key(t, c, r), new Response(JSON.stringify(value), {
          // max-age on the STORED copy only (put() refuses `private`/`no-store`); build the
          // wire response fresh so this header never reaches the browser/edge.
          headers: { 'content-type': 'application/json', 'cache-control': `max-age=${TTL_S}` },
        }));
        return value;
      }
      ```
      
      Two safety rules from that example: put the caller's *validated* identity in the key
      (never a raw query param), and let unexpected errors throw out of the producer so
      failures are never cached. Also note the [Smart Placement](#7-smart-placement--run-near-the-data-not-the-user)
      interaction below — placement concentrates invocations into few colos, which turns a
      "fragmented per-colo cache" into an effectively shared one.
      
      ## 3. `waitUntil` — a latency optimisation, not a guarantee
      
      **What it guarantees:** `ctx.waitUntil(promise)` keeps the isolate alive after the
      response is returned (or the cron tick ends) until the promise settles, within the
      runtime's post-response window. The response is never blocked on it.
      
      **What it does NOT guarantee:** execution. A rejected promise is logged and dropped —
      no retry. An isolate can be evicted; a crash in the request path can take the
      background work with it. Anything that *must* happen cannot live only in `waitUntil`.
      
      **The pattern — outbox + drain:** make the durable intent a transactional write in
      the request path, use `waitUntil` only to *accelerate* processing, and let a cron
      re-drain as the guarantee:
      
      ```typescript
      // Request path: the D1 write IS the guarantee; waitUntil is just promptness.
      app.post('/api/referrals', async (c) => {
        const referral = await c.get('repo').createReferral(body);   // enqueues outbox rows in the same txn
        c.executionCtx.waitUntil(dispatchNotifications(c.env));      // immediate drain attempt
        return c.json({ referral }, 201);
      });
      
      // Cron (*/5): the real delivery guarantee — re-drains anything the fast path missed.
      export default {
        scheduled(controller, env, ctx) {
          if (controller.cron === '*/5 * * * *') ctx.waitUntil(dispatchNotifications(env));
        },
      };
      ```
      
      Second rule: **`waitUntil` each independent job separately.** `ctx.waitUntil(a); ctx.waitUntil(b)`
      isolates failures; `ctx.waitUntil(a.then(b))` means a failure in `a` silently
      suppresses `b`. Chain only when the order is the point (e.g. "generate, *then* drain
      the outbox so the notification goes out in the same tick").
      
      ## 4. Testing cron `scheduled()` handlers
      
      `@cloudflare/vitest-pool-workers` gives tests real bindings via
      `import { env } from 'cloudflare:test'` (a real D1 with your migrations applied, per
      the pool's `miniflare` config). The pattern that makes cron testable:
      
      **Keep the dispatcher thin; test the jobs.** Make `scheduled()` a switch on
      `controller.cron` whose only job is `ctx.waitUntil()`-ing exported job functions.
      Then tests import and await the job functions directly with `env` — no scheduled
      controller machinery, no waiting on an execution context:
      
      ```typescript
      // src/index.ts — dispatch only, no logic
      scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
        if (controller.cron === '*/15 * * * *') ctx.waitUntil(runHostingPinger(env));
      }
      
      // test/cron.test.ts — the job is just an async function taking env
      import { env } from 'cloudflare:test';
      import { runHostingPinger } from '../src/cron';
      
      it('records status only for enabled tenants', async () => {
        const summary = await withMockedFetch(mockedFetch, () => runHostingPinger(env));
        expect(summary).toEqual({ tenantsProcessed: 1, checks: 2 });
      });
      ```
      
      Outbound calls are mocked by swap-restoring the global (`globalThis.fetch = impl` in
      a `try`/`finally`); bindings a job touches (`EMAIL`, an R2 bucket) are stubbed as
      plain objects on a synthetic env — which is exactly the blind spot in
      [gotcha 5](#5-vitest-pool-workers-runs-an-older-workerd-than-production).
      
      **Design jobs self-guarding.** Every job checks its own preconditions and no-ops when
      they aren't met — a secret unset (`ASANA_TOKEN` absent ⇒ the nightly ingest is
      disabled everywhere), an app flag not enabled for a tenant (skip that tenant), already
      ran this period (idempotence guard). Two payoffs: crons ship OFF and are enabled by
      config rather than a deploy, and a tick never throws as a whole — guard per tenant /
      per item with try/catch so one bad row can't suppress the rest of the run.
      
      For a manual end-to-end poke there's also `wrangler dev --test-scheduled` +
      `curl "localhost:8787/__scheduled?cron=*+*+*+*+*"` (see
      [workers-runtime.md](workers-runtime.md#scheduled-cron)).
      
      ## 5. vitest-pool-workers runs an OLDER workerd than production
      
      **Symptom:** the whole suite is green; the deployed Worker rejects a binding call at
      runtime.
      
      **Why:** the pool pins its own workerd, which lags the production runtime. A newer
      runtime API simply *does not exist* in the test workerd — the concrete case: the
      structured Email Service send, `env.EMAIL.send({from, to, subject, text})`. Tests
      exercise a stub (`EMAIL: { send: async () => {} }`), which proves your code path but
      can never prove the production binding accepts that call shape. A wrong shape ships
      green.
      
      **Mitigation ladder, cheapest first:**
      
      1. **Unit tests against a stub** — proves your logic, not the binding. Necessary,
         insufficient.
      2. **`wrangler dev` smoke** — wrangler bundles a *current* workerd that has the new
         API. Exercising one real call locally validates the shape before shipping. Cheap;
         do this whenever you touch a newer binding API.
      3. **One real post-deploy invocation + log check** — the only real proof. Trigger one
         send (or wait for the next cron tick) and confirm the effect, or check
         `wrangler tail` / Workers Logs for the failure line. Essential when the calling
         code swallows errors (see gotcha 6).
      
      The same test-double blind spot powers gotcha 1: a mock `fetch` has no receiver
      check, a stub `EMAIL` has no shape check. **A stub proves the caller, never the
      callee.** When production is the first place the real API runs, budget a step 2/3.
      
      ## 6. Email Service — two account states, and the silent-failure trap
      
      The `send_email` binding's recipient reach depends on **account setup, not code**:
      
      | Account state | Who you can send to |
      |---------------|--------------------|
      | Before a sending domain is onboarded (free) | **Verified destination addresses only** — external sends fail |
      | After onboarding a domain (dashboard → Email Service → Domains; Workers Paid) | **Any recipient**, immediately, SPF/DKIM provisioned — with zero code change |
      
      Design for state one: make self-sends (to the org's own verified address) the
      critical path — admin summaries, alerts — and let external mail (customer/partner
      sends) be gated on the onboarding step rather than on a deploy.
      
      **The trap:** the from-address must belong to a domain the account can send from. An
      unverified sender rejects with `E_SENDER_NOT_VERIFIED` — and if your send wrapper is
      never-throw (a sensible design so one bad recipient doesn't abort a batch run), that
      rejection is swallowed into `false` and **every** send silently fails, including the
      self-sends that worked yesterday. The only visible symptom is a log line:
      
      ```typescript
      export async function sendEmail(env: EmailEnv, msg: OutgoingEmail): Promise<boolean> {
        if (!env.EMAIL) { console.warn(`EMAIL binding absent; would send "${msg.subject}"`); return false; }
        try {
          // Structured send — the current API. The legacy `new EmailMessage(from, to, rawMime)`
          // form is documented as backward-compat only (for callers holding raw RFC 5322).
          await env.EMAIL.send({ from: msg.from, to: msg.to, subject: msg.subject, text: msg.text });
          return true;
        } catch (err) {
          // Never-throw means the CATCH can't throw either: a non-Error rejection would
          // TypeError out of the catch and reject the caller's whole cron tick.
          console.error(`email to ${msg.to} failed: ${err instanceof Error ? err.message : String(err)}`);
          return false;   // E_SENDER_NOT_VERIFIED lands HERE — the log line is the only symptom
        }
      }
      ```
      
      So: verify the from-domain is a routing/sending domain on the account *before*
      trusting any email leg, and pair every never-throw wrapper with the post-deploy
      log check from gotcha 5 — one real send, then `wrangler tail` for `failed`. (Sanitise
      CR/LF out of any DB-sourced value that becomes a header while you're here.)
      
      ## 7. Smart Placement — run near the data, not the user
      
      By default a Worker runs in the colo nearest the *user*. If the handler makes
      multiple sequential round trips to a **region-pinned resource** — a D1 primary, a
      Hyperdrive-fronted Postgres, one origin API — every trip pays the user↔data distance:
      a Paris user hitting a Sydney D1 pays ~280 ms × N queries. Smart Placement profiles
      the Worker's traffic and moves the *invocation* near the data instead, so N queries
      become N × ~1 ms plus one user↔Worker hop.
      
      ```toml
      # wrangler.toml
      [placement]
      mode = "smart"
      ```
      
      ```jsonc
      // wrangler.jsonc
      { "placement": { "mode": "smart" } }
      ```
      
      **Right call when:** the handler is chatty with one region-pinned backend (several D1
      queries per request is the canonical case), and total response time is dominated by
      backend round trips.
      
      **Wrong call when:** the Worker is pure edge work — static assets, cached responses,
      a single pass-through fetch, latency-to-user-critical logic. Placement can only help
      when there's a data dependency to move toward; it observes your subrequests and
      falls back to default placement when it wouldn't win.
      
      **Side effect worth knowing:** placement concentrates invocations into a small number
      of colos, which makes the per-colo `caches` API (gotcha 2) behave much closer to a
      shared cache — one more reason the short-TTL `caches` pattern holds up in a
      Smart-Placed Worker that would fragment badly in a default-placed one.
      
      ## 8. `wrangler dev` rewrites the request host to your `[[routes]]` pattern
      
      **Symptom:** everything works when deployed, but locally every request misbehaves in
      a host-shaped way — tenant resolution picks the production tenant, a dev-only auth
      shim never activates, every `/api/*` returns 403 as if the local affordance were
      absent. Nothing errors; the Worker just behaves like production.
      
      **Why:** `wrangler dev` simulates the deployed origin. When the config declares
      `[[routes]]` (or `routes`/`route` in jsonc), a local request to
      `http://localhost:8787` arrives at your `fetch` handler with the URL rewritten to the
      **production hostname** — `new URL(request.url).hostname` is `app.example.com`, not
      `localhost`. Any logic keyed off the host silently takes the production branch:
      multi-tenant host→tenant resolution, hostname-gated dev shims, host-conditional
      CORS/redirects.
      
      **Fix:** override the simulated origin with `dev.host` (CLI: `--local-upstream`).
      It's dev-only config — `wrangler deploy` ignores it:
      
      ```toml
      # wrangler.toml — LOCAL DEV ONLY, ignored by deploy
      [dev]
      host = "localhost"
      ```
      
      ```jsonc
      // wrangler.jsonc
      { "dev": { "host": "localhost" } }
      ```
      
      Leave a guard comment on the block: it *looks* deletable ("why is there a dev host
      override?"), and removing it re-breaks every host-keyed code path locally in the
      silent way described above. If your host-keyed logic includes a security affordance
      (a dev-only identity shim), make its gate fail closed — require the loopback host
      *and* an explicit opt-in flag, so a wrong simulated origin disables the shim rather
      than enabling it against production-shaped requests.
      
    • workers-runtime.md 5.7 KB
      # Workers Runtime — handlers, APIs, patterns, limits
      
      The Workers runtime is **workerd** (open source), running V8 isolates at the edge. It implements web-platform APIs (`Request`/`Response`/`fetch`/`URL`/`crypto.subtle`/streams) — not Node, unless `nodejs_compat` is set. Workers use **ES module** format; the legacy service-worker (`addEventListener("fetch")`) format is deprecated.
      
      ## Handlers
      
      ```javascript
      export default {
        async fetch(request, env, ctx)    { /* HTTP requests */ },
        async scheduled(event, env, ctx)  { /* cron triggers */ },
        async queue(batch, env, ctx)      { /* queue consumer */ },
        async email(message, env, ctx)    { /* Email Workers */ },
        async tail(events, env, ctx)      { /* Tail Worker — traces of another Worker */ },
      };
      ```
      
      - **`env`** — all bindings, vars, secrets.
      - **`ctx.waitUntil(p)`** — keep the isolate alive for background work after the response is returned (analytics, cache writes).
      - **`ctx.passThroughOnException()`** — on an unhandled error, fall through to origin instead of erroring.
      
      ### Scheduled (cron)
      
      ```jsonc
      { "triggers": { "crons": ["0 0 * * *", "*/15 * * * *"] } }
      ```
      
      ```javascript
      async scheduled(event, env, ctx) {
        ctx.waitUntil(cleanup(env));   // event.cron tells you which schedule fired
      }
      ```
      
      Test locally: `wrangler dev --test-scheduled` then `curl "localhost:8787/__scheduled?cron=0+0+*+*+*"`.
      
      ## CORS
      
      ```javascript
      const CORS = {
        "Access-Control-Allow-Origin": "https://app.example.com",   // prefer an explicit origin over "*"
        "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
      };
      export default {
        async fetch(request) {
          if (request.method === "OPTIONS") return new Response(null, { headers: CORS });
          const res = Response.json({ ok: true });
          for (const [k, v] of Object.entries(CORS)) res.headers.set(k, v);
          return res;
        },
      };
      ```
      
      If you send credentials, you cannot use `*` — echo a validated origin and add `Access-Control-Allow-Credentials: true`.
      
      ## Cache API
      
      ```javascript
      async fetch(request, env, ctx) {
        const cache = caches.default;
        let res = await cache.match(request);
        if (!res) {
          res = await fetch(request);
          res = new Response(res.body, res);
          res.headers.set("Cache-Control", "public, max-age=3600");
          ctx.waitUntil(cache.put(request, res.clone()));   // clone — body is single-use
        }
        return res;
      }
      ```
      
      The Cache API is per-colo (not global). For global caching use Cloudflare's CDN/Cache Rules at the zone level, or KV for app-controlled cache. Per-colo semantics, the caches-vs-KV-vs-D1 decision, and a production short-TTL pattern: [workers-runtime-gotchas.md](workers-runtime-gotchas.md#2-caches-is-per-colo--not-a-kv-substitute).
      
      ## Streaming
      
      ```javascript
      // Stream rather than buffer large bodies
      const { readable, writable } = new TransformStream();
      streamInto(writable);                       // write chunks asynchronously
      return new Response(readable, { headers: { "Content-Type": "application/octet-stream" } });
      ```
      
      `Response` accepts a `ReadableStream`; stream from R2 (`obj.body`) or `fetch` directly to keep memory flat.
      
      ## WebSockets
      
      ```javascript
      async fetch(request) {
        if (request.headers.get("Upgrade") !== "websocket")
          return new Response("expected websocket", { status: 426 });
        const [client, server] = Object.values(new WebSocketPair());
        server.accept();
        server.addEventListener("message", (e) => server.send(`echo: ${e.data}`));
        return new Response(null, { status: 101, webSocket: client });
      }
      ```
      
      For stateful/multi-client sockets (chat, presence) terminate them in a **Durable Object** and use the **WebSocket Hibernation API** so idle connections don't bill compute.
      
      ## Body reuse
      
      Streams are single-use. To read a body twice, `clone()` before the first read:
      
      ```javascript
      const copy = request.clone();
      const text = await request.text();
      const json = await copy.json();
      ```
      
      ## Error handling
      
      ```javascript
      async fetch(request, env, ctx) {
        try {
          return Response.json({ data: await work(env) });
        } catch (err) {
          console.error("worker error", err);   // captured by Workers Logs when observability is on
          return Response.json({ error: err.message }, { status: 500 });
        }
      }
      ```
      
      ## Subrequests, timeouts, abort
      
      ```javascript
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), 5000);
      try {
        return await fetch(url, { signal: ctrl.signal });
      } catch (e) {
        if (e.name === "AbortError") return new Response("upstream timeout", { status: 504 });
        throw e;
      } finally { clearTimeout(t); }
      ```
      
      ## Limits (check the live limits page — these move)
      
      | Limit | Free | Paid |
      |-------|------|------|
      | CPU time / invocation | 10 ms default, configurable | up to **30 s** (raised from the old 50 ms; set via limits config) |
      | Script size (gzipped) | 3 MB | 10 MB |
      | Subrequests / invocation | 50 | 1000+ |
      | Memory | 128 MB | 128 MB |
      | Env vars + secrets | bounded | bounded |
      
      - CPU time ≠ wall-clock: awaiting I/O doesn't burn CPU budget; a tight compute loop does and gets killed.
      - Configure CPU limit explicitly: `"limits": { "cpu_ms": 50 }` (or higher on paid).
      
      ## Node compatibility
      
      `"compatibility_flags": ["nodejs_compat"]` enables a subset of Node built-ins (`node:crypto`, `node:buffer`, `node:async_hooks`, streams, etc.). Not everything is polyfilled — verify the specific module is supported rather than assuming. Many "Node" npm packages work once this flag is on; some assume `fs`/native addons and won't.
      
      ## Bundling
      
      Wrangler bundles with esbuild. Pitfalls: CommonJS-only packages, packages that reach for Node natives, and large transitive deps blowing the size limit. Prefer Workers-/edge-labelled libraries; dynamic-import heavy modules so they're only pulled when used; inspect the build output if a deploy is unexpectedly large.
      
  • scripts
    • .gitkeep 0 B · in bundle
    • check-cloudflare-facts.py 9.7 KB
      #!/usr/bin/env python3
      """Staleness verifier for cloudflare-ops: the Wrangler major line, the
      recommended compatibility_date, and the config filename convention must stay
      real, stated, and current.
      
      cloudflare-ops assumes Wrangler v4.x, recommends a `wrangler.jsonc` config,
      and pins a specific `compatibility_date` in its skeleton. Those are exactly
      the facts that drift silently (SKILL-RESOURCE-PROTOCOL.md §7): Wrangler ships
      a v5 and the old `wrangler publish`-era advice rots, the prose stops naming
      `wrangler.jsonc`, or the compatibility_date sits stale — and nobody notices
      for months. Two modes:
      
        --offline (default, safe for PR CI): structural consistency, no network.
          * assets/cloudflare-facts.json parses and carries the schema + an as_of date
          * every catalogued fact's prose_token is still named in the skill prose
            (SKILL.md + references/*.md + assets/wrangler.jsonc.template)
          * SKILL.md still carries a dated "verified as of <year>" currency note
        --live (scheduled freshness job, never a PR gate): does Wrangler still
          resolve on npm, and has its major moved off the documented v4 line?
      
      Usage:   check-cloudflare-facts.py [--offline | --live] [--catalog FILE] [--skill DIR] [--json] [--timeout S]
      Input:   argv flags only (no stdin).
      Output:  stdout = findings (plain rows, or a --json envelope). Data only.
      Stderr:  the verdict line, notices, errors.
      Exit:    0 ok, 2 usage, 3 catalog/skill missing, 4 catalog unparseable,
               7 npm unreachable (live, advisory — never a real failure),
               10 drift found (offline: fact no longer named / currency note gone;
                  live: wrangler gone from npm or major drifted off v4)
      
      Examples:
        check-cloudflare-facts.py --offline                 # PR CI: facts ⇆ prose consistency
        check-cloudflare-facts.py --live                    # weekly: wrangler still v4 on npm?
        check-cloudflare-facts.py --offline --json | jq '.data[]'
      """
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      import urllib.error
      import urllib.parse
      import urllib.request
      from pathlib import Path
      
      EX_OK = 0
      EX_USAGE = 2
      EX_NOTFOUND = 3
      EX_UNPARSEABLE = 4
      EX_UNAVAILABLE = 7
      EX_DRIFT = 10
      
      SCHEMA = "claude-mods.cloudflare-ops.facts/v1"
      
      HERE = Path(__file__).resolve().parent
      DEFAULT_CATALOG = HERE.parent / "assets" / "cloudflare-facts.json"
      DEFAULT_SKILL = HERE.parent
      
      NPM_REGISTRY = "https://registry.npmjs.org"
      
      CURRENCY_RE = re.compile(r"verified as of\s+(\d{4})", re.IGNORECASE)
      AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
      
      
      class Finding:
          __slots__ = ("check", "status", "detail")
      
          def __init__(self, check: str, status: str, detail: str) -> None:
              self.check = check
              self.status = status  # ok | drift | unavailable
              self.detail = detail
      
          def as_dict(self) -> dict:
              return {"check": self.check, "status": self.status, "detail": self.detail}
      
      
      def load_catalog(path: Path) -> dict:
          if not path.is_file():
              print(f"error: facts catalog not found: {path}", file=sys.stderr)
              raise SystemExit(EX_NOTFOUND)
          try:
              data = json.loads(path.read_text(encoding="utf-8"))
              if not isinstance(data, dict) or data.get("schema") != SCHEMA:
                  raise ValueError(f"schema must be {SCHEMA!r}")
              if not AS_OF_RE.match(str(data.get("as_of", ""))):
                  raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}")
              for key in ("wrangler", "compatibility_date", "config_format"):
                  if not isinstance(data.get(key), dict) or "prose_token" not in data[key]:
                      raise ValueError(f"fact {key!r} missing prose_token")
              return data
          except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
              print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr)
              raise SystemExit(EX_UNPARSEABLE)
      
      
      def read_corpus(skill_dir: Path) -> tuple[str, str]:
          """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md
          + assets/wrangler.jsonc.template (the compatibility_date pin lives there)."""
          doc = skill_dir / "SKILL.md"
          if not doc.is_file():
              print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
              raise SystemExit(EX_NOTFOUND)
          skill_md = doc.read_text(encoding="utf-8", errors="replace")
          parts = [skill_md]
          ref_dir = skill_dir / "references"
          if ref_dir.is_dir():
              for ref in sorted(ref_dir.glob("*.md")):
                  parts.append(ref.read_text(encoding="utf-8", errors="replace"))
          template = skill_dir / "assets" / "wrangler.jsonc.template"
          if template.is_file():
              parts.append(template.read_text(encoding="utf-8", errors="replace"))
          return skill_md, "\n".join(parts)
      
      
      def check_offline(catalog: dict, skill_dir: Path) -> list[Finding]:
          skill_md, corpus = read_corpus(skill_dir)
          lower = corpus.lower()
          findings: list[Finding] = []
      
          if CURRENCY_RE.search(skill_md):
              m = CURRENCY_RE.search(skill_md)
              findings.append(Finding("currency-note", "ok", f"currency note dated {m.group(1)}"))
          else:
              findings.append(Finding("currency-note", "drift",
                                      "no dated 'verified as of <year>' currency note in SKILL.md"))
      
          for key in ("wrangler", "compatibility_date", "config_format"):
              fact = catalog[key]
              token = str(fact["prose_token"])
              if token.lower() in lower:
                  findings.append(Finding(f"fact:{key}", "ok", f"{token!r} named in skill prose"))
              else:
                  findings.append(Finding(f"fact:{key}", "drift",
                                          f"prose_token {token!r} no longer named in skill prose"))
          return findings
      
      
      def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]:
          """Return (status, version-or-detail). status in ok|notfound|unavailable."""
          url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='')}/latest"
          req = urllib.request.Request(url, headers={"User-Agent": "claude-mods-cloudflare-ops-check/1",
                                                     "Accept": "application/json"})
          try:
              with urllib.request.urlopen(req, timeout=timeout) as resp:
                  payload = resp.read().decode("utf-8", errors="replace")
          except urllib.error.HTTPError as exc:
              if exc.code in (404, 410):
                  return "notfound", str(exc.code)
              return "unavailable", str(exc.code)
          except (urllib.error.URLError, TimeoutError, OSError):
              return "unavailable", ""
          try:
              return "ok", json.loads(payload).get("version", "")
          except json.JSONDecodeError:
              return "unavailable", "bad-json"
      
      
      def check_live(catalog: dict, timeout: float) -> list[Finding]:
          findings: list[Finding] = []
          wr = catalog["wrangler"]
          documented_major = str(wr["documented_major"])
          status, ver = _npm_latest(wr["live"]["product"], timeout)
          if status == "notfound":
              findings.append(Finding("npm:wrangler", "drift",
                                      "wrangler gone from npm — renamed/removed, review skill"))
              return findings
          if status != "ok":
              findings.append(Finding("npm:wrangler", "unavailable", "npm registry unreachable"))
              return findings
          m = re.match(r"\s*(\d+)", ver)
          latest_major = m.group(1) if m else ""
          if latest_major and latest_major != documented_major:
              findings.append(Finding("npm:wrangler", "drift",
                                      f"wrangler@{ver} major {latest_major} != documented v{documented_major}.x — review skill"))
          else:
              findings.append(Finding("npm:wrangler", "ok", f"latest {ver} (major {latest_major})"))
          return findings
      
      
      def main(argv: list[str]) -> int:
          p = argparse.ArgumentParser(
              prog="check-cloudflare-facts.py",
              description="Verify cloudflare-ops' Wrangler major + config facts stay stated (offline) and current (live).",
          )
          mode = p.add_mutually_exclusive_group()
          mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
          mode.add_argument("--live", action="store_true", help="probe npm for wrangler major drift")
          p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON")
          p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/ + assets/)")
          p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
          p.add_argument("--json", action="store_true", help="emit a JSON envelope")
          p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary")
          try:
              args = p.parse_args(argv)
          except SystemExit as exc:
              return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
      
          catalog = load_catalog(Path(args.catalog))
          live = args.live and not args.offline
          mode_name = "live" if live else "offline"
          findings = check_live(catalog, args.timeout) if live else check_offline(catalog, Path(args.skill))
      
          n_drift = sum(1 for f in findings if f.status == "drift")
          n_unavail = sum(1 for f in findings if f.status == "unavailable")
      
          if args.json:
              print(json.dumps({
                  "data": [f.as_dict() for f in findings],
                  "meta": {"mode": mode_name, "count": len(findings),
                           "drift": n_drift, "unavailable": n_unavail, "schema": SCHEMA},
              }, indent=2))
          else:
              for f in findings:
                  print(f"{f.check}\t{f.status}\t{f.detail}")
      
          for f in findings:
              if f.status != "ok":
                  print(f"  [{f.status.upper()}] {f.check}: {f.detail}", file=sys.stderr)
          if not args.quiet:
              print(f"-- {len(findings)} checks: {n_drift} drift, {n_unavail} unavailable", file=sys.stderr)
      
          if n_drift:
              return EX_DRIFT
          if n_unavail:
              return EX_UNAVAILABLE
          return EX_OK
      
      
      if __name__ == "__main__":
          sys.exit(main(sys.argv[1:]))
      
  • tests
    • run.sh 5.4 KB
      #!/usr/bin/env bash
      # Offline self-test for the cloudflare-ops skill — structure, frontmatter, and
      # the staleness-verifier contract (SKILL-RESOURCE-PROTOCOL §7, §10).
      #
      # Usage:   tests/run.sh
      # Input:   none (self-contained; no network, no wrangler install required)
      # Output:  TAP-ish progress on stderr; final PASS/FAIL line.
      # Exit:    0 all pass (or skipped on unsupported platform), 1 any failure.
      #
      # Examples:
      #   tests/run.sh
      #   bash skills/cloudflare-ops/tests/run.sh
      set -uo pipefail
      
      here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
      fail=0
      pass=0
      note() { printf '  %s %s\n' "$1" "$2" >&2; }
      ok()   { pass=$((pass+1)); note "ok  " "$1"; }
      bad()  { fail=$((fail+1)); note "FAIL" "$1"; }
      
      # Resolve a *working* python (python3, else python). The bare `command -v` is not
      # enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero.
      PY=""
      for cand in python3 python; do
        if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then
          PY="$cand"; break
        fi
      done
      if [ -z "$PY" ]; then
        echo "SKIP: no working python interpreter on this platform" >&2
        exit 0
      fi
      
      # 1. Required directories exist
      for d in scripts references assets tests; do
        [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/"
      done
      
      # 2. SKILL.md frontmatter house rules
      skill="$here/SKILL.md"
      if [ -f "$skill" ]; then
        ok "SKILL.md present"
        grep -q '^name: cloudflare-ops$' "$skill" && ok "name matches directory" || bad "name != cloudflare-ops"
        grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT"
        grep -q '^  author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author"
      else
        bad "SKILL.md missing"
      fi
      
      # 3. Every reference on disk is cited from SKILL.md (no dead weight)
      for ref in "$here"/references/*.md; do
        base="references/$(basename "$ref")"
        grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base"
      done
      
      # 4. Every SKILL.md-cited bundled resource exists on disk
      for res in assets/cloudflare-facts.json assets/wrangler.jsonc.template scripts/check-cloudflare-facts.py; do
        [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res"
      done
      
      # 5. check-cloudflare-facts.py — staleness verifier contract (§7, §10), offline-safe
      verifier="$here/scripts/check-cloudflare-facts.py"
      catalog="$here/assets/cloudflare-facts.json"
      ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$?
             [ "$got" = "$want" ] && ok "$lbl (exit $got)" || bad "$lbl (want $want got $got)"; }
      if [ -f "$verifier" ]; then
        "$PY" -m py_compile "$verifier" && ok "verifier: py_compile clean" || bad "verifier: py_compile failed"
        grep -qE '^Examples:$' "$verifier" && ok "verifier: has Examples block" || bad "verifier: no Examples block (docstring)"
        "$PY" "$verifier" --help >/dev/null 2>&1 && ok "verifier: --help exits 0" || bad "verifier: --help nonzero"
        # Offline mode must pass on the skill's own content (internal consistency).
        ec 0 "verifier: --offline consistent"  "$PY" "$verifier" --offline
        # Bad flag → USAGE (exit 2); conflicting modes → USAGE.
        ec 2 "verifier: bad flag → exit 2"     "$PY" "$verifier" --bogus
        ec 2 "verifier: --offline --live → 2"  "$PY" "$verifier" --offline --live
        # stdout is data-only: --offline --json must emit parseable JSON.
        "$PY" "$verifier" --offline --json -q 2>/dev/null \
          | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.cloudflare-ops.facts/v1"' \
          && ok "verifier: --json envelope parses (stdout clean)" || bad "verifier: --json envelope broken"
        # Error paths: missing catalog → 3, malformed catalog → 4, drift catalog → 10.
        TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
        ec 3 "verifier: missing catalog -> 3"  "$PY" "$verifier" --offline --catalog "$TMP/nope.json"
        printf '{"packages":"x"}' > "$TMP/bad.json"
        ec 4 "verifier: malformed catalog -> 4" "$PY" "$verifier" --offline --catalog "$TMP/bad.json"
        # Minimal drift catalog (argv path so MSYS translates it): a fact whose
        # prose_token is not in the real skill prose -> drift -> exit 10.
        printf '%s\n' '{"schema":"claude-mods.cloudflare-ops.facts/v1","as_of":"2026-07-05","wrangler":{"documented_major":4,"prose_token":"v9.x","live":{"source":"npm","product":"wrangler","compare":"major"}},"compatibility_date":{"documented":"2030-01-01","prose_token":"2030-01-01"},"config_format":{"documented":"wrangler.jsonc","prose_token":"wrangler.jsonc"}}' > "$TMP/drift.json"
        ec 10 "verifier: missing fact -> 10" "$PY" "$verifier" --offline --catalog "$TMP/drift.json"
        # cited from SKILL.md
        grep -qF "scripts/check-cloudflare-facts.py" "$skill" && ok "verifier: cited from SKILL.md" || bad "verifier: uncited"
      else
        bad "check-cloudflare-facts.py missing"
      fi
      
      # 6. cloudflare-facts.json — parses, carries schema + the catalogued facts
      "$PY" -c "
      import json, sys
      d = json.load(open(sys.argv[1], encoding='utf-8'))
      assert d['schema'] == 'claude-mods.cloudflare-ops.facts/v1'
      assert d['wrangler']['documented_major'] == 4
      assert d['compatibility_date']['documented']
      assert d['config_format']['documented'] == 'wrangler.jsonc'
      " "$catalog" && ok "cloudflare-facts.json schema + facts" || bad "cloudflare-facts.json invalid"
      
      # 7. Currency note present near the top of the body
      grep -qE 'verified as of [0-9]{4}' "$skill" && ok "currency note present" || bad "no dated currency note"
      
      echo "cloudflare-ops self-test: $pass passed, $fail failed" >&2
      [ "$fail" -eq 0 ]
      
  • SKILL.md 14.6 KB
    ---
    name: cloudflare-ops
    description: "Cloudflare Workers + Wrangler edge ops: runtime, bindings, local dev, secrets, deploy/CI, Pages-vs-Workers. Triggers on: cloudflare workers, wrangler, wrangler deploy, wrangler.toml, KV, D1, R2, durable objects, queues, vectorize, compatibility_date, edge functions, illegal invocation, waitUntil, caches API, smart placement, email service, vitest-pool-workers, wrangler dev host."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: "terraform-ops, nginx-ops"
    ---
    
    # Cloudflare Operations
    
    Cloudflare Workers + Wrangler: runtime patterns, bindings, local dev, secrets, deploy, CI/CD, observability.
    
    > Ecosystem facts verified as of 2026-07.
    
    **Version context (verified 2026-07):** Wrangler **v4.x** · config is **`wrangler.jsonc`** (Cloudflare's recommended format for new projects — some newer features are JSON-config-only; `wrangler.toml` still works and is widespread in older repos) · deploy command is **`wrangler deploy`** (the old **`wrangler publish` is deprecated** — see [gotchas](#common-gotchas)). Workers can now **serve static assets**, which is the current direction for full-stack and static sites over Pages (see [Workers vs Pages](#workers-vs-pages-decision)).
    
    ## Reference Files
    
    | File | Covers |
    |------|--------|
    | [references/bindings.md](references/bindings.md) | Every binding (KV/D1/R2/DO/Queues/Hyperdrive/AI/Vectorize/Service/Analytics Engine) — config block, runtime API, when to reach for each, consistency model |
    | [references/workers-runtime.md](references/workers-runtime.md) | Runtime APIs, handlers (fetch/scheduled/queue/email/tail), CORS, caching, streaming, WebSockets, Durable Objects deep-dive, limits |
    | [references/workers-runtime-gotchas.md](references/workers-runtime-gotchas.md) | Production footguns: detached `fetch` ("Illegal invocation"), per-colo `caches` vs KV vs D1, `waitUntil` semantics + outbox pattern, testing cron handlers, test-workerd lagging production, Email Service account states, Smart Placement, `wrangler dev` host rewriting |
    | [references/deploy-and-cicd.md](references/deploy-and-cicd.md) | `wrangler deploy`, environments, secrets, Workers Builds, GitHub Actions + OIDC/API-token, gradual deployments, rollbacks, observability |
    | [assets/wrangler.jsonc.template](assets/wrangler.jsonc.template) | Commented, current `wrangler.jsonc` covering all common bindings + assets |
    
    > Access / Zero Trust auth patterns (verifying `Cf-Access-Jwt-Assertion`, AUD tags, service auth, closed origins) → **auth-ops** skill, `references/cloudflare-access.md`.
    
    ## Workers vs Pages Decision
    
    Cloudflare added static-asset hosting to Workers; a single Worker now serves a static site, a full-stack app, or an API + SPA. **For new projects, default to Workers with static assets.** Pages still works and isn't deprecated, but Workers has the broader, faster-moving feature set (Durable Objects, Cron Triggers, Queues, richer observability) and is where Cloudflare's investment goes.
    
    ```
    New project?
    │
    ├─ Pure static site (no server logic)
    │  └─ Workers + assets binding (asset-only — requests matching files never invoke Worker code, $0 for those).
    │     Pages is also fine here; Workers keeps one platform if you later add logic.
    │
    ├─ Full-stack / SPA + API / SSR framework (Next, Astro, Remix, SvelteKit, Hono)
    │  └─ Workers + assets + a Worker script. Use the framework's Cloudflare adapter (C3: `npm create cloudflare@latest`).
    │     This is the current recommended path — Pages' framework story is converging into Workers.
    │
    ├─ Already on Pages and happy
    │  └─ Stay. "What works in Pages works in Workers" — migrate only when you need a Workers-only
    │     feature (DO, Cron, Queues, advanced observability). See the migrate-from-pages guide.
    │
    └─ Need Durable Objects / Cron Triggers / Queues / Tail Workers
       └─ Workers (these are Workers-only).
    ```
    
    **Asset serving modes** (in the `assets` block): asset-only (no `main`) serves files directly and never bills Worker invocations for matches; **assets + Worker** serves matching files first, falls through to your `fetch` handler for everything else (or set `run_worker_first` to invoke the Worker before asset matching). Reach assets from code via `env.ASSETS.fetch(request)`.
    
    ## Wrangler Config Skeleton (jsonc)
    
    Full annotated version: [assets/wrangler.jsonc.template](assets/wrangler.jsonc.template).
    
    ```jsonc
    {
      "$schema": "node_modules/wrangler/config-schema.json",
      "name": "my-worker",
      "main": "src/index.ts",
      "compatibility_date": "2026-06-01",   // pins the runtime version — REQUIRED, bump deliberately
      "compatibility_flags": ["nodejs_compat"],  // opt-in runtime features (Node built-ins, etc.)
    
      "observability": { "enabled": true },  // turn on Workers Logs (off by default)
    
      "assets": { "directory": "./public", "binding": "ASSETS" },
    
      "kv_namespaces": [{ "binding": "CACHE", "id": "<kv-id>" }],
      "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<d1-id>" }],
      "r2_buckets":   [{ "binding": "BUCKET", "bucket_name": "uploads" }],
    
      "vars": { "ENVIRONMENT": "production" },  // NON-secret config only — never put secrets here
    
      "env": {
        "staging": { "vars": { "ENVIRONMENT": "staging" } }  // named env: deploy with --env staging
      }
    }
    ```
    
    - **`compatibility_date`** = `yyyy-mm-dd`, selects the runtime version. It's required and load-bearing: bumping it can change behaviour, so do it deliberately and test. **`compatibility_flags`** opt into upcoming/Node-compat features (e.g. `nodejs_compat`).
    - Keep secrets OUT of `vars` — they land in plaintext in the deployed config. Use `wrangler secret put` / `.dev.vars` ([secrets](#local-dev--secrets)).
    - TOML equivalent still parses; the binding shapes map 1:1 (`[[kv_namespaces]]`, `[[d1_databases]]`, …). New repos: prefer jsonc.
    
    ## Bindings Table — When Each
    
    Full config + runtime API for every binding: [references/bindings.md](references/bindings.md).
    
    | Binding | Reach for it when… | Consistency / note |
    |---------|--------------------|--------------------|
    | **KV** | Read-heavy config/cache, infrequent writes, global reads | **Eventually consistent** (~60s propagation). Fast reads, slow-ish writes. Not for "read your own write". |
    | **D1** | Relational/SQL data, moderate scale, per-app database | SQLite at the edge. Strong within a DB; read replication is async. Use for app data with joins. |
    | **R2** | Object/blob storage, large files, **zero egress fees** | S3-compatible. Replaces S3 for media/backups/assets you serve. |
    | **Durable Objects** | **Strong consistency**, coordination, stateful realtime (chat, presence, game rooms, rate limit counters) | Single-threaded per object instance = serialized = consistent. The answer when KV's eventual consistency bites. SQLite-backed storage available. |
    | **Queues** | Async/background work, decoupling, batching, retries | Producer binding + consumer Worker. Smooths spikes; guaranteed delivery with retries + DLQ. |
    | **Hyperdrive** | Connecting to an **existing external Postgres/MySQL** with pooling + edge caching | Makes a regional DB feel fast from Workers. Needs `nodejs_compat`. |
    | **Workers AI** | Run inference (LLM, embeddings, image) on Cloudflare's GPUs | `ai` binding → `env.AI.run(model, ...)`. Pairs with Vectorize for RAG. |
    | **Vectorize** | Vector DB for embeddings / semantic search / RAG | `vectorize` binding. Store + query embeddings, often fed by Workers AI. |
    | **Service bindings** | Worker-to-Worker RPC without a network hop | Zero-latency internal calls; compose Workers as services. |
    
    Decision shortcut: **need strong consistency or coordination → Durable Objects. Relational queries → D1. Big files → R2. Cheap global cache → KV. Background work → Queues. External SQL DB → Hyperdrive.**
    
    ## Minimal Worker
    
    ```javascript
    export default {
      async fetch(request, env, ctx) {
        const url = new URL(request.url);
        if (url.pathname === "/health") return Response.json({ ok: true });
        return new Response("Hello from the edge");
      },
    };
    ```
    
    `env` carries every binding (`env.DB`, `env.CACHE`, `env.ASSETS`, secrets, vars). `ctx.waitUntil(promise)` runs background work after the response is sent. Workers require **ES module** format (`export default { fetch }`) — the old service-worker `addEventListener("fetch")` format is legacy. Full handler patterns (scheduled/queue/email/tail, CORS, caching, WebSockets, DO): [references/workers-runtime.md](references/workers-runtime.md).
    
    ## Local Dev & Secrets
    
    ```bash
    npm create cloudflare@latest my-app   # C3 scaffolder — picks framework + adapter + wrangler.jsonc
    wrangler dev                          # local dev server (Miniflare/workerd) on localhost:8787
    wrangler dev --remote                 # run on Cloudflare's edge (real bindings) instead of local sim
    wrangler types                        # generate TS types for env from your bindings → worker-configuration.d.ts
    ```
    
    **Secrets** (never in `vars`):
    
    | Where | Mechanism |
    |-------|-----------|
    | Local dev | **`.dev.vars`** file (dotenv format, gitignored) — `wrangler dev` loads it as `env.*`. Per-env: `.dev.vars.staging`. |
    | Deployed | **`wrangler secret put NAME`** (prompts for value, encrypts it) · `wrangler secret list` · `wrangler secret delete NAME` |
    | CI bulk | `wrangler secret bulk secrets.json` |
    | Newer | Cloudflare **Secrets Store** bindings (account-level shared secrets) — see deploy reference |
    
    Add `.dev.vars*` to `.gitignore`. `vars` in config = plaintext public config; secrets are encrypted and write-only.
    
    ## Deploy & CI/CD
    
    Full detail: [references/deploy-and-cicd.md](references/deploy-and-cicd.md).
    
    ```bash
    wrangler deploy                  # build + upload + activate (NOT `wrangler publish` — deprecated)
    wrangler deploy --env staging    # deploy a named environment
    wrangler versions upload         # upload a new version WITHOUT making it live (gradual deploys)
    wrangler versions deploy         # split traffic across versions (e.g. 10% new / 90% old)
    wrangler rollback                # revert to the previous deployed version
    wrangler tail                    # stream live logs from the deployed Worker
    ```
    
    - **Workers Builds** — Cloudflare's native git-connected CI: push to GitHub/GitLab, Cloudflare builds + deploys. Zero-config for simple Workers; the default for most teams.
    - **GitHub Actions** — `cloudflare/wrangler-action`. Authenticate with a scoped **API token** (`CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` as secrets), least-privilege (Workers Scripts:Edit). Template + workflow in the deploy reference.
    - **Gradual deployments** — `versions upload` then `versions deploy` to shift a percentage of traffic; instant `rollback` if metrics regress.
    
    ## Observability
    
    - `"observability": { "enabled": true }` in config turns on **Workers Logs** (structured `console.log` capture in the dashboard) — **off by default**, opt in.
    - `wrangler tail` for live request log streaming during an incident.
    - **Tail Workers** — a Worker that receives execution traces of another Worker (centralised logging/alerting).
    - **Analytics Engine** — write custom time-series metrics from a Worker (`env.AE.writeDataPoint(...)`), query via GraphQL/SQL API.
    
    ## Common Gotchas
    
    Runtime-level footguns that pass tests and ship — detached `fetch` ("Illegal invocation"), per-colo `caches`, `waitUntil` guarantees, cron testing, the test workerd lagging production, Email Service account states, Smart Placement, `wrangler dev` rewriting the request host — each with symptom/why/fix: [references/workers-runtime-gotchas.md](references/workers-runtime-gotchas.md).
    
    | Gotcha | Detail | Fix |
    |--------|--------|-----|
    | **`wrangler publish` is gone** | Renamed to `wrangler deploy` (Wrangler v3+). Old tutorials/CI still say `publish`. | Use `wrangler deploy`. Update any `publish` in scripts/CI. |
    | **`wrangler.toml` vs `.jsonc`** | Both parse, but newer features are JSON-config-only and Cloudflare recommends jsonc for new projects. | New projects: `wrangler.jsonc`. Migrating: `wrangler.toml` → jsonc is a mechanical 1:1. |
    | **Missing `compatibility_date`** | Required; absent or stale date silently pins old runtime behaviour. | Set it; bump deliberately and test — it can change semantics. |
    | **CPU time limit** | Default **30s** CPU per invocation (was 10ms/50ms historically; raised). Wall-clock can be longer while awaiting I/O. CPU-bound loops still get killed. | Offload heavy compute; use Queues for long async work; check the limits page for your plan. |
    | **Script size limit** | 3 MB (free) / 10 MB (paid) gzipped. | Trim deps, dynamic-import large modules, avoid bundling node-only libs. |
    | **KV eventual consistency** | A write isn't globally visible for up to ~60s; not "read your own write". | Use **Durable Objects** when you need strong consistency. |
    | **Node built-ins fail** | `fs`, `crypto`, etc. aren't there by default. | `"compatibility_flags": ["nodejs_compat"]` enables a polyfill subset; check what's actually supported. |
    | **Secrets in `vars`** | `vars` ships plaintext in the deployed config. | `wrangler secret put` (deployed) / `.dev.vars` (local). |
    | **`request`/`response` body read twice** | Streams are single-use. | `request.clone()` before the first read. |
    | **Bundling surprises** | Wrangler uses esbuild; some packages assume Node/CommonJS. | Prefer Workers-compatible libs; set `nodejs_compat`; check the build output. |
    
    ## Setup
    
    1. Install: `npm install -g wrangler` (or use `npx wrangler` / `npm create cloudflare@latest` to scaffold).
    2. Auth: `wrangler login` (OAuth) for local; **API token** for CI.
    3. Copy [assets/wrangler.jsonc.template](assets/wrangler.jsonc.template), strip the bindings you don't need, fill in IDs.
    4. `wrangler dev` → `wrangler deploy`.
    
    ## Staleness verifier
    
    This skill encodes fast-moving facts (Wrangler major line, recommended `compatibility_date`, `wrangler.jsonc` config convention). [`scripts/check-cloudflare-facts.py`](scripts/check-cloudflare-facts.py) guards them against silent drift:
    
    ```bash
    # Structural (PR CI, no network): every catalogued fact's prose_token is still
    # named in this skill's prose (incl. the jsonc template), and the currency
    # note still carries a year.
    python scripts/check-cloudflare-facts.py --offline        # exit 0 consistent, 10 drift
    
    # Live (freshness job, never blocks a PR): wrangler still resolves on npm and
    # its latest major matches the documented v4.x line.
    python scripts/check-cloudflare-facts.py --live            # exit 10 major drift, 7 npm unreachable
    ```
    
    The canonical fact set lives in [`assets/cloudflare-facts.json`](assets/cloudflare-facts.json); when the Wrangler major, the recommended compatibility_date, or the config convention changes, update it to match or `--offline` fails CI.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related