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
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/cloudflare-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
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_flagsopt into upcoming/Node-compat features (e.g.nodejs_compat).- Keep secrets OUT of
vars— they land in plaintext in the deployed config. Usewrangler 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_IDas secrets), least-privilege (Workers Scripts:Edit). Template + workflow in the deploy reference. - Gradual deployments —
versions uploadthenversions deployto shift a percentage of traffic; instantrollbackif metrics regress.
Observability
"observability": { "enabled": true }in config turns on Workers Logs (structuredconsole.logcapture in the dashboard) — off by default, opt in.wrangler tailfor 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
- Install:
npm install -g wrangler(or usenpx wrangler/npm create cloudflare@latestto scaffold). - Auth:
wrangler login(OAuth) for local; API token for CI. - Copy assets/wrangler.jsonc.template, strip the bindings you don't need, fill in IDs.
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.
Reviews (0)
No reviews yet.
No comments yet.