Claude Skill

cloudflare

Use when working on Cloudflare's edge platform — wrangler.jsonc bindings, choosing between D1/KV/R2/Durable Objects/Queues, deploying a Worker or SPA via Static Assets, or designing around a Workers runtime limit. NOT generic CI/release (that is `deployment`), NOT Next.js framewo

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

Full trust report

Download ericrisco-rsc-harness-skills_cloudflare-953fef5.zip · 13 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/cloudflare
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git git clone https://github.com/ericrisco/rsc-harness.git

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

Skill manifest

Cloudflare Workers & edge primitives

The model in one paragraph

A Worker is a fetch handler that runs at the edge. Everything else — R2, D1, KV, Queues, static assets, Durable Objects — is a binding declared in wrangler.jsonc and reached through env. If a resource is not bound, it is not reachable from your code. There is no connection string and no import of the bucket; you wire it in config, type it on Env, and call env.BINDING. Hold this picture and most "how do I access X" questions answer themselves: declare the binding, redeploy, use env.

Quick start

npm create cloudflare@latest (the C3 scaffolder) bootstraps a Worker or a full framework. Use it — it pins a correct compatibility_date and generates types.

npm create cloudflare@latest my-app          # plain Worker
npm create cloudflare@latest my-app -- --framework=react   # Vite + React SPA, GA plugin
cd my-app
npx wrangler dev          # local edge emulation at http://localhost:8787
npx wrangler deploy       # ships Worker + bound assets in one operation

Wrangler is v4 (an incremental release over the v3 rewrite — same config model, updated deps). Pin it: npx wrangler@4.

wrangler.jsonc anatomy

Config may be wrangler.toml, wrangler.json, or wrangler.jsonc. Prefer jsonc so you can comment bindings. Minimum keys: name, main, compatibility_date.

{
  "name": "my-app",
  "main": "src/index.ts",
  // Set to TODAY's date when you start. Why: it pins runtime + flag behavior;
  // bumping it later opts into new defaults (e.g. nodejs_compat auto-enables at 2025-10-01+).
  "compatibility_date": "2026-06-02",
  "compatibility_flags": ["nodejs_compat"],

  // Static Assets — the default way to host a SPA / full-stack app.
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",                       // env.ASSETS.fetch(request)
    "not_found_handling": "single-page-application"
  },

  // Non-secret config only. Secrets go via `wrangler secret put`, never here.
  "vars": { "API_BASE": "https://api.example.com" },

  "r2_buckets":   [{ "binding": "BUCKET",  "bucket_name": "uploads" }],
  "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<id>" }],
  "kv_namespaces":[{ "binding": "CACHE",  "id": "<namespace-id>" }],
  "queues": {
    "producers": [{ "binding": "JOBS", "queue": "thumbnails" }],
    "consumers": [{ "queue": "thumbnails", "max_batch_size": 10, "max_retries": 3,
                    "dead_letter_queue": "thumbnails-dlq" }]
  }
}

Named environments inherit top-level config and override per env.<name>. See references/wrangler-config.md for the full annotated config, routes, custom domains, and compatibility flags.

Pick the right storage primitive

This is the decision that shapes the architecture. Pick by access pattern and consistency, not by familiarity.

Primitive Use for Consistency Hard limit Don't use for
D1 Relational app data, per-tenant DBs Strong (single SQLite) 10 GB per database A single >10 GB monolith; Postgres features (it is SQLite)
KV Read-heavy config, cached lookups, feature flags Eventual (~60s to propagate globally) 25 MiB per value Counters, sessions you read-after-write, anything strongly consistent
R2 Files, blobs, uploads, backups Strong on object Object storage; no egress fees Querying/indexing structured data
Durable Objects Strongly-consistent coordination, per-entity state, WebSockets Strong (single-threaded per object) One object = one serialized actor Bulk storage; high-fanout reads
Queues Async/batch work, decoupling, retries At-least-once delivery Batch ≤100 (default 10) Synchronous request/response

Rule of thumb: need read-after-write? Not KV. Need SQL joins? D1. Need a file? R2. Need a counter or lock? Durable Object. Per-primitive binding config and code, consistency semantics, the complete limits/pricing tables, and Hyperdrive for external Postgres are in references/storage-primitives.md.

Static & full-stack hosting

Workers Static Assets is the recommended way to host SPAs and full-stack apps. The Worker and the assets deploy together.

  • assets.directory — your build output, e.g. ./dist.
  • assets.binding: "ASSETS" — lets the Worker serve files via env.ASSETS.fetch(request).
  • assets.not_found_handling — "single-page-application" (serve index.html on miss, for client-side routing) or "404-page".
  • assets.run_worker_first — run the Worker before serving static assets, e.g. so /api/* hits your handler not a file.

Do not use Workers Sites for new projects — it is deprecated in Wrangler v4 and unsupported by the Cloudflare Vite plugin. Migrating off Pages? Pages still works, but new full-stack work targets Workers; the asset-routing rules and the migration checklist are in references/wrangler-config.md.

Bindings in code

Type every binding on Env. Why: without the interface you lose autocompletion and ship undefined binding bugs to the edge.

export interface Env {
  ASSETS: Fetcher;
  DB: D1Database;
  BUCKET: R2Bucket;
  CACHE: KVNamespace;
  JOBS: Queue<{ key: string }>;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);

    if (url.pathname.startsWith("/api/user")) {
      const row = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
        .bind(url.searchParams.get("id")).first();
      return Response.json(row);
    }
    if (req.method === "PUT" && url.pathname.startsWith("/upload/")) {
      await env.BUCKET.put(url.pathname.slice(8), req.body);
      await env.JOBS.send({ key: url.pathname.slice(8) }); // enqueue thumbnail job
      return new Response("ok", { status: 201 });
    }
    const cached = await env.CACHE.get("config", { cacheTtl: 3600 });
    if (url.pathname === "/config" && cached) return new Response(cached);

    return env.ASSETS.fetch(req); // fall through to the SPA
  },
} satisfies ExportedHandler<Env>;

Secrets, env vars, local dev

wrangler secret put STRIPE_KEY      # encrypted, never in wrangler.jsonc or git
echo "STRIPE_KEY=sk_test_..." >> .dev.vars   # local only — gitignore it
  • Secrets via wrangler secret put only. Why: vars in wrangler.jsonc is committed plaintext.
  • .dev.vars supplies secrets for wrangler dev; add it to .gitignore.
  • vars block = non-secret config (API base URLs, feature flags).
  • wrangler dev emulates bindings locally; add --remote to run against real edge resources.

Queues wiring

Producer and consumer are both bindings/handlers — same Worker or different Workers.

// Producer (in fetch): enqueue work
await env.JOBS.send({ key });

// Consumer: a queue() handler on the same module
export default {
  async fetch(/* ... */) { /* ... */ },
  async queue(batch: MessageBatch<{ key: string }>, env: Env): Promise<void> {
    for (const msg of batch.messages) {
      try {
        await processThumbnail(msg.key, env); // make this idempotent — delivery is at-least-once
        msg.ack();
      } catch {
        msg.retry();   // up to max_retries (default 3), then dead-letter
      }
    }
  },
};

Defaults: max_batch_size 10 (max 100), max_retries 3, plus max_batch_timeout. Route exhausted messages to a dead_letter_queue. Make consumers idempotent — at-least-once means a message can arrive twice.

Limits that reshape your design

These numbers are architecture inputs, not trivia. Read them before you design.

  • Subrequests per request are capped — fan-out to dozens of origins fails. Batch, cache in KV, or move work to a Queue consumer.
  • CPU time per request is bounded (raised on the paid plan). Long CPU work → Queue + consumer, or Durable Object alarms.
  • KV value ≤ 25 MiB and eventually consistent — large or write-hot data belongs in R2 or D1.
  • D1 ≤ 10 GB per database — shard per tenant/user (D1 is built for many small DBs), don't grow one monolith.
  • Queue batch ≤ 100 — size max_batch_size to your downstream throughput, not the max.

Plan note: the Workers Paid plan is a $5/mo minimum bundling Workers, Pages Functions, KV, Hyperdrive, and Durable Objects; a Free plan exists with reduced limits (D1 free-tier limits enforced since 2025-02-10).

Anti-patterns

Anti-pattern Why it bites Do instead
KV for sessions / counters you read after writing Eventual consistency: a read after a write can be stale up to ~60s D1 (strong) or a Durable Object (per-entity strong)
Growing one D1 database past 10 GB Hard cap; you hit a wall mid-scale Shard per tenant/user; large blobs go to R2
Omitting compatibility_date Runtime/flag behavior drifts; deploys become non-reproducible Set it to today's date at project start; bump deliberately
Avoiding R2 over egress cost R2 has no egress charges — you're optimizing a cost that doesn't exist Use R2 for files/blobs; pay only storage + ops
Workers Sites for a new SPA Deprecated in Wrangler v4; unsupported by the Vite plugin assets (Static Assets) with not_found_handling
Secrets in vars or committed Plaintext in repo / config = leak wrangler secret put; .dev.vars (gitignored) for local
Treating D1 like a pooled SQL connection D1 is accessed over HTTP, not a persistent pool — no transactions across requests, no long-held connections One prepared statement per call; batch with db.batch()
Heavy fan-out to many subrequests Hits the subrequest cap and fails the request Cache in KV, batch, or offload to a Queue consumer
Files (rsc-harness)
  • evals
    • cases.yaml 3 KB
      skill: cloudflare
      
      should_trigger:
        - prompt: "Set up wrangler.jsonc with an R2 bucket and a D1 binding for my Worker."
          why: Core config/binding task — the heart of the skill (declare bindings in wrangler.jsonc).
        - prompt: "My KV reads are returning stale data right after I write — what's wrong?"
          why: Non-obvious symptom; the answer is KV eventual consistency (~60s propagation), a load-bearing fact.
        - prompt: "Deploy my Vite React SPA to Cloudflare Workers."
          why: Static Assets path — the modern default for hosting SPAs on Workers.
        - prompt: "Volem desplegar una API a Cloudflare Workers amb una cua per processar emails."
          why: Catalan trigger; Workers + Queues producer/consumer wiring.
        - prompt: "I'm hitting 'too many subrequests' in my Worker — how do I restructure it?"
          why: Non-obvious; a Workers runtime limit that forces an architecture fix (batch / KV / Queue).
        - prompt: "Should I use D1 or KV to store user sessions on Cloudflare?"
          why: Storage decision-table trigger; sessions are read-after-write so KV's eventual consistency is wrong.
        - prompt: "Where do I put my Stripe secret key for a Cloudflare Worker — in wrangler.jsonc vars?"
          why: Secrets handling; answer is wrangler secret put / .dev.vars, never committed vars.
      
      should_not_trigger:
        - prompt: "Set up a CI pipeline to deploy my app on every push to main."
          route_to: deployment
          why: Generic release automation with no Cloudflare primitive involved.
        - prompt: "Configure my Next.js app router and server components."
          route_to: nextjs
          why: Framework wiring, not the Cloudflare adapter/binding side.
        - prompt: "Add an A record and configure DNS for my domain on Cloudflare."
          route_to: domains-dns
          why: Cloudflare DNS product, not Workers/bindings.
        - prompt: "Design a Postgres schema with foreign keys and indexes."
          route_to: postgresdb
          why: D1 is SQLite and the ask is relational schema design, not edge deployment.
        - prompt: "Set up Redis caching with TTL eviction and LRU for my API."
          route_to: redis
          why: Redis-specific semantics; KV is not Redis (no atomic ops, eventual consistency).
      
      capability:
        - scenario: "Wire a Worker that serves a React SPA, stores uploads in R2, reads/writes user records in D1, and pushes a thumbnail-generation job to a Queue. Give me wrangler.jsonc and the binding code."
          must_include:
            - "valid wrangler.jsonc with name, main, and compatibility_date set to a real date"
            - "assets block with directory, binding ASSETS, and not_found_handling: single-page-application"
            - "r2_buckets and d1_databases binding blocks"
            - "queues with both a producers and a consumers entry"
            - "an Env interface typing every binding (Fetcher, R2Bucket, D1Database, Queue)"
            - "env.QUEUE/JOBS.send in the fetch handler and a queue() consumer handler"
            - "note that secrets go via wrangler secret put, not vars in wrangler.jsonc"
            - "idempotency note on the consumer (at-least-once delivery)"
      
    • README.md 1020 B
      # Evals — cloudflare
      
      These cases exercise the skill's routing and coverage. `should_trigger` lists prompts the
      `cloudflare` skill must claim (config/bindings, storage choice, Static Assets deploy, Queues,
      runtime-limit fixes), including a non-obvious symptom (stale KV reads) and a Catalan phrasing.
      `should_not_trigger` lists adjacent prompts that must route elsewhere (deployment, nextjs,
      domains-dns, postgresdb, redis) — each names the real sibling it belongs to. The `capability`
      case checks the body actually produces a working wrangler.jsonc plus typed binding code for a
      realistic multi-primitive Worker.
      
      To run: feed `cases.yaml` to the repo's eval harness, which scores the skill description+body
      against each prompt's expected routing and the capability rubric. To check manually, read
      `cases.yaml` and confirm `SKILL.md` (and its references) answers each should_trigger prompt,
      sends each should_not_trigger prompt to the named sibling, and covers every `must_include` item
      in the capability scenario.
      
  • references
    • storage-primitives.md 4.4 KB
      # Storage primitives — config, code, limits
      
      Each primitive is a binding in `wrangler.jsonc` and a typed field on `Env`. Pick by access pattern and consistency.
      
      ## D1 — serverless SQLite
      
      Relational data with strong consistency. Billed on rows read, rows written, and storage; scales to zero. Each database stores up to **10 GB**; D1 is designed for horizontal scale-out — create a database per tenant/user rather than one monolith. D1 is **SQLite, not Postgres**: no Postgres extensions, accessed over HTTP (not a connection pool).
      
      ```jsonc
      "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<id>" }]
      ```
      
      ```ts
      // Single statement
      const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
        .bind(id).first<User>();
      
      // Batch (one round trip, implicit transaction over the batch)
      await env.DB.batch([
        env.DB.prepare("INSERT INTO orders (id, user_id) VALUES (?, ?)").bind(oid, id),
        env.DB.prepare("UPDATE users SET orders = orders + 1 WHERE id = ?").bind(id),
      ]);
      ```
      
      Create + migrate:
      
      ```bash
      wrangler d1 create app
      wrangler d1 execute app --file=./schema.sql --remote
      ```
      
      Need real Postgres at the edge? Use **Hyperdrive** to pool and accelerate an external Postgres connection — that is not D1. For standalone Postgres work see `../../postgresdb/SKILL.md`.
      
      ## KV — eventually-consistent edge key/value
      
      Read-optimized global key/value. **Eventually consistent**: a write can take up to ~60s to propagate to all edge locations, so a read right after a write may be stale. Max value size **25 MiB**. Use for read-heavy, change-rarely data (config, feature flags, cached renders). Not for counters, sessions you read after writing, or anything strongly consistent — use D1 or a Durable Object. KV is not Redis (no atomic ops, no pub/sub); for Redis semantics see `../../redis/SKILL.md`.
      
      ```jsonc
      "kv_namespaces": [{ "binding": "CACHE", "id": "<namespace-id>" }]
      ```
      
      ```ts
      await env.CACHE.put("flags", JSON.stringify(flags), { expirationTtl: 3600 });
      const flags = await env.CACHE.get("flags", { type: "json", cacheTtl: 3600 });
      ```
      
      ## R2 — object storage, zero egress
      
      S3-compatible object storage with **no egress charges**. Billed on stored volume plus Class A operations (mutating, pricier) and Class B operations (reads). Use for uploads, blobs, backups, large media. Not for querying structured data.
      
      ```jsonc
      "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "uploads" }]
      ```
      
      ```ts
      await env.BUCKET.put(key, req.body, { httpMetadata: { contentType: req.headers.get("content-type") ?? "" } });
      const obj = await env.BUCKET.get(key);
      if (!obj) return new Response("not found", { status: 404 });
      return new Response(obj.body, { headers: { "content-type": obj.httpMetadata?.contentType ?? "application/octet-stream" } });
      ```
      
      ## Queues — async batch processing
      
      At-least-once delivery; producers send, consumers process batches. Defaults: `max_batch_size` 10 (max **100**), `max_retries` 3, plus `max_batch_timeout`. Exhausted messages route to a `dead_letter_queue`. Make consumers idempotent.
      
      ```jsonc
      "queues": {
        "producers": [{ "binding": "JOBS", "queue": "thumbnails" }],
        "consumers": [{
          "queue": "thumbnails",
          "max_batch_size": 10,
          "max_batch_timeout": 5,
          "max_retries": 3,
          "dead_letter_queue": "thumbnails-dlq"
        }]
      }
      ```
      
      ```ts
      await env.JOBS.send({ key });           // single
      await env.JOBS.sendBatch(items.map(i => ({ body: i }))); // batch enqueue
      
      async queue(batch: MessageBatch<Job>, env: Env) {
        for (const m of batch.messages) {
          try { await handle(m.body, env); m.ack(); }
          catch { m.retry(); }   // or batch.retryAll()
        }
      }
      ```
      
      ## Durable Objects — strongly-consistent coordination
      
      A single-threaded actor with strongly-consistent transactional storage, addressable by ID. Use for per-entity state, counters, locks, rate limiters, and WebSocket coordination — the things KV cannot do consistently. Each object serializes its own requests. Included in the Workers Paid plan bundle. Full lifecycle and storage API in the Cloudflare docs; reach for a DO whenever you need read-after-write on a single logical entity.
      
      ## Limits quick table
      
      | Resource | Limit |
      |---|---|
      | KV value size | 25 MiB |
      | D1 database size | 10 GB each (shard per tenant) |
      | Queue batch size | 10 default, 100 max |
      | Queue retries | 3 default |
      | R2 egress | none (free) |
      | Subrequests / CPU time | bounded per request; raised on Workers Paid ($5/mo min) |
      
    • wrangler-config.md 4.1 KB
      # wrangler.jsonc — full config, environments, migration
      
      Config may be `wrangler.toml`, `wrangler.json`, or `wrangler.jsonc`. Prefer **jsonc** so bindings can carry comments. Wrangler is **v4**. Minimum keys: `name`, `main`, `compatibility_date`.
      
      ## Annotated config
      
      ```jsonc
      {
        "name": "my-app",
        "main": "src/index.ts",
        // Set to the date you start the project. It pins runtime + flag defaults so deploys
        // are reproducible. Bump it deliberately to opt into newer behavior.
        "compatibility_date": "2026-06-02",
        // Flags toggle behaviors not yet default for your date. nodejs_compat auto-enables
        // at compatibility_date 2025-10-01+, so you can drop it once you bump past that.
        "compatibility_flags": ["nodejs_compat"],
      
        "observability": { "enabled": true },     // structured logs in the dashboard
        "vars": { "API_BASE": "https://api.example.com" },  // non-secret config only
      
        "assets": {
          "directory": "./dist",
          "binding": "ASSETS",
          "not_found_handling": "single-page-application", // or "404-page"
          "run_worker_first": true                 // Worker runs before static serving (API routes)
        },
      
        "r2_buckets":   [{ "binding": "BUCKET",  "bucket_name": "uploads" }],
        "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<id>" }],
        "kv_namespaces":[{ "binding": "CACHE",  "id": "<namespace-id>" }],
        "queues": {
          "producers": [{ "binding": "JOBS", "queue": "thumbnails" }],
          "consumers": [{ "queue": "thumbnails", "max_batch_size": 10, "max_retries": 3,
                          "dead_letter_queue": "thumbnails-dlq" }]
        },
      
        // Named environments inherit top-level config and override per env.
        "env": {
          "staging": {
            "vars": { "API_BASE": "https://staging-api.example.com" },
            "d1_databases": [{ "binding": "DB", "database_name": "app-staging", "database_id": "<id>" }]
          }
        }
      }
      ```
      
      Deploy a named env: `wrangler deploy --env staging`.
      
      ## Routes & custom domains
      
      ```jsonc
      "routes": [
        { "pattern": "app.example.com", "custom_domain": true },     // Cloudflare-managed cert
        { "pattern": "example.com/api/*", "zone_name": "example.com" }
      ]
      ```
      
      `workers_dev: false` disables the `*.workers.dev` URL once you have a custom domain. DNS records themselves (A/CNAME/MX) are the Cloudflare DNS product — see `../../domains-dns/SKILL.md`.
      
      ## Compatibility dates & flags
      
      - `compatibility_date` pins the runtime semantics for your Worker. Set it once at project start; bumping it can change defaults.
      - `compatibility_flags` opt into individual behaviors ahead of or behind the date. Example: `nodejs_compat` auto-enables at `2025-10-01`+.
      - Reproducibility rule: never leave `compatibility_date` unset. An unpinned Worker silently shifts behavior across deploys.
      
      ## Static Assets routing options
      
      - `not_found_handling: "single-page-application"` — serve `index.html` on any miss (client-side router).
      - `not_found_handling: "404-page"` — serve `/404.html`.
      - `run_worker_first: true` — invoke the Worker before serving assets, so `/api/*` reaches your handler.
      - Without `run_worker_first`, a matching static file is served and the Worker never runs for that path.
      
      ## Pages → Workers migration checklist
      
      Workers Static Assets is the recommended target for new full-stack work; Pages still functions but new projects point at Workers. Workers Sites is deprecated in Wrangler v4 — do not use it.
      
      1. Move build output dir into `assets.directory` (e.g. `./dist`).
      2. Replace Pages Functions in `functions/` with a single Worker `main` entrypoint that routes (`run_worker_first` for API paths).
      3. Convert Pages bindings (dashboard) into `wrangler.jsonc` binding blocks.
      4. Set `compatibility_date` + needed `compatibility_flags`.
      5. `wrangler deploy` and verify each binding resolves (run `verify.sh` / `--dry-run`).
      6. Remove any `[site]` / `site =` Workers-Sites config — it is deprecated and unsupported by the Vite plugin.
      
      ## Scaffolding
      
      ```bash
      npm create cloudflare@latest my-app                       # plain Worker
      npm create cloudflare@latest my-app -- --framework=react  # Vite + React SPA (GA plugin)
      ```
      
      C3 picks a correct `compatibility_date` and generates `Env` types — start here rather than hand-writing config.
      
  • scripts
    • verify.sh 5.2 KB
      #!/usr/bin/env bash
      #
      # verify.sh — validate a Cloudflare wrangler config (read-only).
      #
      # WHAT IT DOES (read-only; never edits or writes your project)
      #   Points at ONE wrangler config (wrangler.jsonc / wrangler.json / wrangler.toml)
      #   and confirms it is a deployable Worker config:
      #     1. If wrangler is available (npx wrangler), run a `deploy --dry-run` against
      #        the config -> success means the config parses AND bindings/bundle resolve.
      #        This is the real checkable artifact (a parse + emitted bundle).
      #     2. Fallback static lint when wrangler/network is absent:
      #        - required keys present: `name`, AND (`main` OR `assets`), AND `compatibility_date`
      #        - `compatibility_date` looks like a real date (YYYY-MM-DD)
      #        - no deprecated Workers-Sites key (`site =` / `[site]` / `"site"`)
      #
      #   No config file in the target dir -> nothing to check, exit 0 (no false fail).
      #
      # HOW TO RUN (inside YOUR project)
      #   ./verify.sh                      # auto-detect wrangler.* in the current dir
      #   ./verify.sh path/to/wrangler.jsonc
      #   ./verify.sh --no-wrangler        # force the static lint, skip the dry-run
      #
      # EXIT CODES
      #   0  config valid / dry-run succeeded / no config found (nothing to check)
      #   1  config invalid (dry-run failed, or a static-lint check failed)
      #   2  bad usage (named file does not exist)
      #
      # Runs on stock macOS bash 3.2: no mapfile, no associative arrays.
      
      set -euo pipefail
      
      if [ -t 1 ]; then
        RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; NC=$'\033[0m'
      else
        RED=''; GREEN=''; YELLOW=''; NC=''
      fi
      ok()   { printf '%s[ ok ]%s %s\n' "$GREEN"  "$NC" "$*"; }
      warn() { printf '%s[warn]%s %s\n' "$YELLOW" "$NC" "$*"; }
      fail() { printf '%s[fail]%s %s\n' "$RED"    "$NC" "$*"; }
      
      usage() { sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; }
      
      FILE=""
      USE_WRANGLER=1
      while [ $# -gt 0 ]; do
        case "$1" in
          -h|--help) usage; exit 0 ;;
          --no-wrangler) USE_WRANGLER=0; shift ;;
          -*) printf '%sunknown option: %s%s\n' "$RED" "$1" "$NC" >&2; usage; exit 2 ;;
          *) if [ -z "$FILE" ]; then FILE="$1"; fi; shift ;;
        esac
      done
      
      # Resolve the config file: explicit arg, else auto-detect in CWD.
      if [ -n "$FILE" ]; then
        if [ ! -f "$FILE" ]; then
          printf '%sfile not found: %s%s\n' "$RED" "$FILE" "$NC" >&2; exit 2
        fi
      else
        for cand in wrangler.jsonc wrangler.json wrangler.toml; do
          if [ -f "$cand" ]; then FILE="$cand"; break; fi
        done
      fi
      
      # No config to check -> clean exit, never a false failure.
      if [ -z "$FILE" ]; then
        ok "no wrangler config found in this directory — nothing to check"
        exit 0
      fi
      
      printf 'cloudflare verify — %s\n\n' "$FILE"
      
      # --- 1. wrangler dry-run (the real artifact check) ----------------------------
      if [ "$USE_WRANGLER" -eq 1 ] && command -v npx >/dev/null 2>&1; then
        OUTDIR="$(mktemp -d 2>/dev/null || echo /tmp/cf-verify.$$)"
        if npx --no-install wrangler@4 deploy --dry-run --config "$FILE" --outdir "$OUTDIR" >/tmp/cf-verify.log 2>&1 \
           || npx wrangler@4 deploy --dry-run --config "$FILE" --outdir "$OUTDIR" >/tmp/cf-verify.log 2>&1; then
          ok "wrangler dry-run succeeded — config parses and bindings/bundle resolve"
          rm -rf "$OUTDIR" 2>/dev/null || true
          exit 0
        else
          # wrangler ran but the build/parse failed -> real failure.
          if grep -Eq 'Missing|Invalid|ParseError|Unexpected|deprecated|not supported' /tmp/cf-verify.log 2>/dev/null; then
            fail "wrangler dry-run rejected the config:"
            sed 's/^/    /' /tmp/cf-verify.log | tail -15
            rm -rf "$OUTDIR" 2>/dev/null || true
            exit 1
          fi
          # Could not run (offline / not installed / auth) -> fall through to static lint.
          warn "wrangler dry-run unavailable (offline or not installed) — falling back to static lint"
          rm -rf "$OUTDIR" 2>/dev/null || true
        fi
      fi
      
      # --- 2. static lint -----------------------------------------------------------
      # Strip jsonc comments for grep so // notes don't false-match.
      STRIPPED="$(sed 's://.*$::' "$FILE")"
      errs=0
      
      has() { printf '%s' "$STRIPPED" | grep -Eq "$1"; }
      
      if has '("name"|^[[:space:]]*name)[[:space:]]*[:=]'; then
        ok "name present"
      else
        fail "missing required key: name"; errs=$((errs + 1))
      fi
      
      if has '("main"|^[[:space:]]*main)[[:space:]]*[:=]' || has '("assets"|^[[:space:]]*\[assets\]|^[[:space:]]*assets)[[:space:]]*[:={[]'; then
        ok "entrypoint present (main or assets)"
      else
        fail "missing required key: main or assets"; errs=$((errs + 1))
      fi
      
      CDATE="$(printf '%s' "$STRIPPED" | grep -Eo '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1 || true)"
      if printf '%s' "$STRIPPED" | grep -Eq 'compatibility_date' && [ -n "$CDATE" ]; then
        ok "compatibility_date is a real date ($CDATE)"
      else
        fail "compatibility_date missing or not a YYYY-MM-DD date — pin it (deploys become non-reproducible without it)"; errs=$((errs + 1))
      fi
      
      if has '(^[[:space:]]*\[site\]|"site"[[:space:]]*:|^[[:space:]]*site[[:space:]]*=)'; then
        fail "deprecated Workers Sites config present (site) — Workers Sites is deprecated in Wrangler v4; use the assets block instead"; errs=$((errs + 1))
      else
        ok "no deprecated Workers-Sites (site) key"
      fi
      
      printf '\n'
      if [ "$errs" -gt 0 ]; then
        printf '%s%d static-lint failure(s)%s\n' "$RED" "$errs" "$NC"
        exit 1
      fi
      printf '%sconfig looks valid (static lint)%s\n' "$GREEN" "$NC"
      exit 0
      
  • SKILL.md 10.1 KB
    ---
    name: cloudflare
    description: "Use when working on Cloudflare's edge platform — wrangler.jsonc bindings, choosing between D1/KV/R2/Durable Objects/Queues, deploying a Worker or SPA via Static Assets, or designing around a Workers runtime limit. NOT generic CI/release (that is `deployment`), NOT Next.js framework wiring (that is `nextjs`), NOT DNS records (that is `domains-dns`)."
    tags: [cloudflare, workers, edge, r2, d1, kv, queues, wrangler, serverless]
    recommends: [deployment, nextjs, domains-dns, postgresdb, redis, secure-coding]
    origin: risco
    ---
    
    # Cloudflare Workers & edge primitives
    
    ## The model in one paragraph
    
    A Worker is a `fetch` handler that runs at the edge. Everything else — R2, D1, KV, Queues, static assets, Durable Objects — is a **binding** declared in `wrangler.jsonc` and reached through `env`. If a resource is not bound, it is not reachable from your code. There is no connection string and no `import` of the bucket; you wire it in config, type it on `Env`, and call `env.BINDING`. Hold this picture and most "how do I access X" questions answer themselves: declare the binding, redeploy, use `env`.
    
    ## Quick start
    
    `npm create cloudflare@latest` (the C3 scaffolder) bootstraps a Worker or a full framework. Use it — it pins a correct `compatibility_date` and generates types.
    
    ```bash
    npm create cloudflare@latest my-app          # plain Worker
    npm create cloudflare@latest my-app -- --framework=react   # Vite + React SPA, GA plugin
    cd my-app
    npx wrangler dev          # local edge emulation at http://localhost:8787
    npx wrangler deploy       # ships Worker + bound assets in one operation
    ```
    
    Wrangler is **v4** (an incremental release over the v3 rewrite — same config model, updated deps). Pin it: `npx wrangler@4`.
    
    ## wrangler.jsonc anatomy
    
    Config may be `wrangler.toml`, `wrangler.json`, or `wrangler.jsonc`. Prefer **jsonc** so you can comment bindings. Minimum keys: `name`, `main`, `compatibility_date`.
    
    ```jsonc
    {
      "name": "my-app",
      "main": "src/index.ts",
      // Set to TODAY's date when you start. Why: it pins runtime + flag behavior;
      // bumping it later opts into new defaults (e.g. nodejs_compat auto-enables at 2025-10-01+).
      "compatibility_date": "2026-06-02",
      "compatibility_flags": ["nodejs_compat"],
    
      // Static Assets — the default way to host a SPA / full-stack app.
      "assets": {
        "directory": "./dist",
        "binding": "ASSETS",                       // env.ASSETS.fetch(request)
        "not_found_handling": "single-page-application"
      },
    
      // Non-secret config only. Secrets go via `wrangler secret put`, never here.
      "vars": { "API_BASE": "https://api.example.com" },
    
      "r2_buckets":   [{ "binding": "BUCKET",  "bucket_name": "uploads" }],
      "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<id>" }],
      "kv_namespaces":[{ "binding": "CACHE",  "id": "<namespace-id>" }],
      "queues": {
        "producers": [{ "binding": "JOBS", "queue": "thumbnails" }],
        "consumers": [{ "queue": "thumbnails", "max_batch_size": 10, "max_retries": 3,
                        "dead_letter_queue": "thumbnails-dlq" }]
      }
    }
    ```
    
    Named environments inherit top-level config and override per `env.<name>`. See `references/wrangler-config.md` for the full annotated config, routes, custom domains, and compatibility flags.
    
    ## Pick the right storage primitive
    
    This is the decision that shapes the architecture. Pick by access pattern and consistency, not by familiarity.
    
    | Primitive | Use for | Consistency | Hard limit | Don't use for |
    |---|---|---|---|---|
    | **D1** | Relational app data, per-tenant DBs | Strong (single SQLite) | 10 GB per database | A single >10 GB monolith; Postgres features (it is SQLite) |
    | **KV** | Read-heavy config, cached lookups, feature flags | Eventual (~60s to propagate globally) | 25 MiB per value | Counters, sessions you read-after-write, anything strongly consistent |
    | **R2** | Files, blobs, uploads, backups | Strong on object | Object storage; **no egress fees** | Querying/indexing structured data |
    | **Durable Objects** | Strongly-consistent coordination, per-entity state, WebSockets | Strong (single-threaded per object) | One object = one serialized actor | Bulk storage; high-fanout reads |
    | **Queues** | Async/batch work, decoupling, retries | At-least-once delivery | Batch ≤100 (default 10) | Synchronous request/response |
    
    Rule of thumb: **need read-after-write? Not KV.** Need SQL joins? D1. Need a file? R2. Need a counter or lock? Durable Object. Per-primitive binding config and code, consistency semantics, the complete limits/pricing tables, and Hyperdrive for external Postgres are in `references/storage-primitives.md`.
    
    ## Static & full-stack hosting
    
    Workers **Static Assets** is the recommended way to host SPAs and full-stack apps. The Worker and the assets deploy together.
    
    - `assets.directory` — your build output, e.g. `./dist`.
    - `assets.binding: "ASSETS"` — lets the Worker serve files via `env.ASSETS.fetch(request)`.
    - `assets.not_found_handling` — `"single-page-application"` (serve `index.html` on miss, for client-side routing) or `"404-page"`.
    - `assets.run_worker_first` — run the Worker before serving static assets, e.g. so `/api/*` hits your handler not a file.
    
    Do **not** use Workers Sites for new projects — it is deprecated in Wrangler v4 and unsupported by the Cloudflare Vite plugin. Migrating off Pages? Pages still works, but new full-stack work targets Workers; the asset-routing rules and the migration checklist are in `references/wrangler-config.md`.
    
    ## Bindings in code
    
    Type every binding on `Env`. Why: without the interface you lose autocompletion and ship `undefined` binding bugs to the edge.
    
    ```ts
    export interface Env {
      ASSETS: Fetcher;
      DB: D1Database;
      BUCKET: R2Bucket;
      CACHE: KVNamespace;
      JOBS: Queue<{ key: string }>;
    }
    
    export default {
      async fetch(req: Request, env: Env): Promise<Response> {
        const url = new URL(req.url);
    
        if (url.pathname.startsWith("/api/user")) {
          const row = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
            .bind(url.searchParams.get("id")).first();
          return Response.json(row);
        }
        if (req.method === "PUT" && url.pathname.startsWith("/upload/")) {
          await env.BUCKET.put(url.pathname.slice(8), req.body);
          await env.JOBS.send({ key: url.pathname.slice(8) }); // enqueue thumbnail job
          return new Response("ok", { status: 201 });
        }
        const cached = await env.CACHE.get("config", { cacheTtl: 3600 });
        if (url.pathname === "/config" && cached) return new Response(cached);
    
        return env.ASSETS.fetch(req); // fall through to the SPA
      },
    } satisfies ExportedHandler<Env>;
    ```
    
    ## Secrets, env vars, local dev
    
    ```bash
    wrangler secret put STRIPE_KEY      # encrypted, never in wrangler.jsonc or git
    echo "STRIPE_KEY=sk_test_..." >> .dev.vars   # local only — gitignore it
    ```
    
    - Secrets via `wrangler secret put` only. Why: `vars` in `wrangler.jsonc` is committed plaintext.
    - `.dev.vars` supplies secrets for `wrangler dev`; add it to `.gitignore`.
    - `vars` block = non-secret config (API base URLs, feature flags).
    - `wrangler dev` emulates bindings locally; add `--remote` to run against real edge resources.
    
    ## Queues wiring
    
    Producer and consumer are both bindings/handlers — same Worker or different Workers.
    
    ```ts
    // Producer (in fetch): enqueue work
    await env.JOBS.send({ key });
    
    // Consumer: a queue() handler on the same module
    export default {
      async fetch(/* ... */) { /* ... */ },
      async queue(batch: MessageBatch<{ key: string }>, env: Env): Promise<void> {
        for (const msg of batch.messages) {
          try {
            await processThumbnail(msg.key, env); // make this idempotent — delivery is at-least-once
            msg.ack();
          } catch {
            msg.retry();   // up to max_retries (default 3), then dead-letter
          }
        }
      },
    };
    ```
    
    Defaults: `max_batch_size` 10 (max 100), `max_retries` 3, plus `max_batch_timeout`. Route exhausted messages to a `dead_letter_queue`. Make consumers idempotent — at-least-once means a message can arrive twice.
    
    ## Limits that reshape your design
    
    These numbers are architecture inputs, not trivia. Read them before you design.
    
    - **Subrequests per request** are capped — fan-out to dozens of origins fails. Batch, cache in KV, or move work to a Queue consumer.
    - **CPU time per request** is bounded (raised on the paid plan). Long CPU work → Queue + consumer, or Durable Object alarms.
    - **KV value ≤ 25 MiB** and eventually consistent — large or write-hot data belongs in R2 or D1.
    - **D1 ≤ 10 GB per database** — shard per tenant/user (D1 is built for many small DBs), don't grow one monolith.
    - **Queue batch ≤ 100** — size `max_batch_size` to your downstream throughput, not the max.
    
    Plan note: the **Workers Paid** plan is a $5/mo minimum bundling Workers, Pages Functions, KV, Hyperdrive, and Durable Objects; a Free plan exists with reduced limits (D1 free-tier limits enforced since 2025-02-10).
    
    ## Anti-patterns
    
    | Anti-pattern | Why it bites | Do instead |
    |---|---|---|
    | KV for sessions / counters you read after writing | Eventual consistency: a read after a write can be stale up to ~60s | D1 (strong) or a Durable Object (per-entity strong) |
    | Growing one D1 database past 10 GB | Hard cap; you hit a wall mid-scale | Shard per tenant/user; large blobs go to R2 |
    | Omitting `compatibility_date` | Runtime/flag behavior drifts; deploys become non-reproducible | Set it to today's date at project start; bump deliberately |
    | Avoiding R2 over egress cost | R2 has **no egress charges** — you're optimizing a cost that doesn't exist | Use R2 for files/blobs; pay only storage + ops |
    | Workers Sites for a new SPA | Deprecated in Wrangler v4; unsupported by the Vite plugin | `assets` (Static Assets) with `not_found_handling` |
    | Secrets in `vars` or committed | Plaintext in repo / config = leak | `wrangler secret put`; `.dev.vars` (gitignored) for local |
    | Treating D1 like a pooled SQL connection | D1 is accessed over HTTP, not a persistent pool — no transactions across requests, no long-held connections | One prepared statement per call; batch with `db.batch()` |
    | Heavy fan-out to many subrequests | Hits the subrequest cap and fails the request | Cache in KV, batch, or offload to a Queue consumer |
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related