Cloudflare Workers

SkillCloud & infra

Cloudflare Workers and Durable Objects conventions for TypeScript projects. Covers wrangler.jsonc configuration, type-safe env via `wrangler types` and `import { env } from 'cloudflare:workers'`, secrets.required for typed secrets, custom_domain for routing, preview/production environments, deploy scripts, Durable Objects with SQLite, Spiceflow as the web framework with Vite, WebSocket close codes on Durable Objects (1006 isolate kill, always reconnect), and Durable Object OOM detection (exceededMemory vs scriptThrewException, clientDisconnected, responseStreamDisconnected) plus heap profiling. ALWAYS load this skill when a project uses wrangler, Cloudflare Workers, Durable Objects, or deploys to Cloudflare. Load it before writing any wrangler config, worker code, deploy scripts, or Durable Object WebSocket clients. Load durable-object-memory.md when counting OOMs, splitting DO invocation errors, taking heap snapshots, or reducing isolate RSS.

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Cloudflare Workers skill

What this skill tells your AI

The instructions your AI receives, as published by remorses/opencode-config in skills/cloudflare-workers/SKILL.md and read by ahel’s review.

Conventions for Cloudflare Workers and Durable Objects in TypeScript projects.

Framework: Spiceflow with Vite + @cloudflare/vite-plugin

Always use Spiceflow as the web framework for Workers. Load the spiceflow skill first — it has the full API reference and conventions.

// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [
    react(),
    spiceflow({ entry: './src/app.tsx' }),
    cloudflare({
      viteEnvironment: {
        name: 'rsc',
        childEnvironments: ['ssr'],
      },
    }),
  ],
})

Entry file is always src/app.tsx — uses JSX for .page() routes. The entry file also exports the Cloudflare Worker default fetch handler and any DO class re-exports. No separate worker.ts file — the app.tsx IS the worker entry.

// wrangler.jsonc — main points to your app entry file
{
  "main": "./src/app.tsx"
}
// src/app.tsx — export DO classes and default fetch handler alongside the app
import { Spiceflow } from 'spiceflow'
import { env } from 'cloudflare:workers'

export { MyStore } from './my-store.ts'

export const app = new Spiceflow()
  .page('/', async () => <h1>Home</h1>)
  // ... routes

// Access env via `import { env } from 'cloudflare:workers'` anywhere — no need
// for .state('env') or threading env through handle(). The import works in any
// file, not just the fetch handler.
export default {
  async fetch(request: Request): Promise<Response> {
    return app.handle(request)
  },
} satisfies ExportedHandler<Env>

Background tasks with waitUntil

All background promises (fire-and-forget work like analytics, logging, cache writes, webhook processing) MUST use waitUntil. Never do void somePromise() or somePromise().catch(...) directly; the Workers runtime kills the isolate as soon as the response is sent, so untracked promises are silently dropped.

Inside a spiceflow route or middleware, use waitUntil from the handler context:

export const app = new Spiceflow().route({
  method: 'POST',
  path: '/webhook',
  async handler({ request, waitUntil }) {
    const payload = await request.json()
    waitUntil(processWebhookInBackground(payload))
    return { ok: true }
  },
})

Outside a route (e.g. inside a Durable Object, a utility function, or the top-level fetch handler), import waitUntil from cloudflare:workers:

import { waitUntil } from 'cloudflare:workers'

async function doSomething() {
  waitUntil(trackEvent('something_happened'))
}

Configuration: wrangler.jsonc

Always use wrangler.jsonc (not wrangler.toml). Newer features are exclusive to the JSON format.

compatibility_date: ALWAYS use today's date

MUST: Always set compatibility_date to today's date minus 30 days (we can't use today's date directly because the wrangler version used should also released after that day or it will show an error) when creating a new worker or updating an existing one. Old dates disable newer runtime features like WeakRef, FinalizationRegistry, and other JS globals — causing cryptic "X is not defined" errors at runtime. There is no benefit to using an old date unless you are pinning behavior for a production worker you cannot test.

{
  // GOOD — use today's date (2026-04-14 or later)
  "compatibility_date": "2026-04-14",

  // BAD — disables WeakRef, FinalizationRegistry, and other modern APIs
  // "compatibility_date": "2025-01-01"
}

Type-safe environment

Generate types with wrangler types

wrangler types generates a worker-configuration.d.ts file with a typed Env interface derived from your wrangler.jsonc bindings. This replaces @cloudflare/workers-types entirely.

# Add to package.json scripts
"types": "wrangler types"

After generating types:

  1. Uninstall @cloudflare/workers-types — it conflicts with generated runtime types
  2. Install @types/node if using nodejs_compat
  3. Include worker-configuration.d.ts in tsconfig:
{
  "compilerOptions": {
    "types": []
  },
  "include": ["src", "worker-configuration.d.ts"]
}
  1. Rerun wrangler types every time you change wrangler.jsonc

NEVER define custom Env types

The generated worker-configuration.d.ts declares a global Env interface. Never create your own Env type or interface. All bindings, vars, and secrets are available on Env automatically.

// BAD — never do this
export interface Env {
  MY_KV: KVNamespace
  API_KEY: string
}

// GOOD — Env is global from worker-configuration.d.ts
// Just use it directly in your code
export class MyDO extends DurableObject<Env> { ... }

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) { ... }
} satisfies ExportedHandler<Env>

Importing common types

The generated types include all Cloudflare runtime types. Import only from cloudflare:workers for Worker-specific classes:

// DurableObject base class
import { DurableObject } from 'cloudflare:workers'

// For accessing env from anywhere (not just fetch handler)
import { env } from 'cloudflare:workers'

All other types are available globally from the generated file — DurableObjectState, DurableObjectStorage, KVNamespace, ExecutionContext, ExportedHandler, DurableObjectNamespace, DurableObjectStub, etc. No imports needed.

import { env } from 'cloudflare:workers'

// Access env from the cloudflare:workers import — no function params needed.
// Note: wrangler generates DurableObjectNamespace without a generic param,
// so do NOT annotate the return type with DurableObjectStub<MyDO> — just
// let TypeScript infer it. Fix with env.d.ts augmentation (see below).
function getStub() {
  const id = env.MY_STORE.idFromName('main')
  return env.MY_STORE.get(id)
}

export default {
  async fetch(request: Request): Promise<Response> {
    const stub = getStub()
    // Call named RPC methods — do NOT use stub.fetch()
    return stub.handleRequest(request)
  },
} satisfies ExportedHandler<Env>

Avoid overriding fetch() on Durable Objects

Prefer named RPC methods over overriding fetch() on DOs. RPC methods are type-safe, self-documenting, and avoid the legacy fetch-based routing pattern.

// GOOD — named RPC methods
export class MyStore extends DurableObject<Env> {
  async handleRequest(request: Request): Promise<Response> { ... }
  async hranaHandler(request: Request): Promise<Response> { ... }
  async restore(timestamp: number) { ... }
}

// BAD — overriding fetch()
export class MyStore extends DurableObject<Env> {
  async fetch(request: Request): Promise<Response> { ... }
}

The worker calls stub.handleRequest(request) or stub.hranaHandler(request) directly — clear what each method does, and TypeScript checks the call.

Fixing DurableObjectNamespace generics

wrangler types generates DurableObjectNamespace without the generic type param, so the stub type is DurableObjectStub<undefined> — RPC methods are invisible. Interface augmentation doesn't work because the existing property type wins in the intersection.

Fix with a typed helper that casts the stub return:

// src/get-stub.ts
import { env } from 'cloudflare:workers'
import type { MyStore } from './my-store.ts'

export function getStub() {
  const id = env.MY_STORE.idFromName('main')
  return env.MY_STORE.get(id) as DurableObjectStub<MyStore>
}

Import and call getStub() instead of accessing env.MY_STORE directly.

Secrets

Declare secrets in wrangler.jsonc

Use secrets.required to declare secrets. This makes wrangler types generate typed string properties on Env, and wrangler deploy validates they are set.

{
  "secrets": {
    "required": ["API_KEY", "DB_PASSWORD", "AUTH_SECRET"]
  }
}

After adding secrets, rerun wrangler types. The generated Env will include:

interface Env {
  API_KEY: string;
  DB_PASSWORD: string;
  AUTH_SECRET: string;
  // ... other bindings
}

Local development: use Doppler, not .env

Do not use checked-in .env files for Worker local development in this workspace. Use Doppler to inject local env vars and secrets into wrangler dev / vite dev instead.

Wrangler local dev now loads local dev vars from .env files or the process environment, so doppler run works fine for local Worker runtime bindings. Keep secrets.required in wrangler.jsonc so local dev only loads the keys the Worker actually expects.

# Local wrangler dev
doppler run -c development -- wrangler dev

# Local vite dev against preview env
CLOUDFLARE_ENV=preview doppler run -c preview -- vite dev

# Preview build + deploy
CLOUDFLARE_ENV=preview doppler run -c preview -- vite build && wrangler deploy --env preview

Add CLOUDFLARE_INCLUDE_PROCESS_ENV=true to your Doppler config so wrangler automatically picks up all Doppler-injected env vars as Worker bindings during local dev. Without it, doppler run populates process.env but wrangler ignores those values. Sigillo (sigillo run) sets this automatically; Doppler requires it to be added manually.

Rules:

  • Prefer Doppler over .env / .dev.vars for local development.
  • Put shell env vars before doppler run, never after.
  • Add CLOUDFLARE_INCLUDE_PROCESS_ENV=true in Doppler so wrangler sees the injected env vars.
  • Read runtime values from import { env } from 'cloudflare:workers', not process.env, even though process.env may be populated under nodejs_compat.

Upload secrets from Doppler to Cloudflare

Cloudflare Workers store their own deployed secret values. Local doppler run is only for local development — it does not upload secrets to Cloudflare. Sync them explicitly with wrangler secret bulk.

{
  "scripts": {
    "secrets:preview": "doppler run -c preview --mount .env.preview --mount-format env -- wrangler secret bulk --env preview .env.preview",
    "secrets:prod": "doppler run -c production --mount .env.prod --mount-format env -- wrangler secret bulk .env.prod"
  }
}

Run these whenever Worker secrets change:

pnpm secrets:preview
pnpm secrets:prod

Do not loop over wrangler secret put one key at a time. It is interactive and hangs in scripts. Always use wrangler secret bulk.

First deploy: secrets chicken-and-egg

wrangler secret bulk and wrangler secret put require the worker to already have at least one deployed version. But wrangler deploy with secrets.required refuses to deploy if secrets aren't set yet. This creates a chicken-and-egg problem on first deploy.

Fix: use --secrets-file on the first deploy. This flag passes secrets inline during deploy, creating the worker and setting secrets in one shot. No need to temporarily remove secrets.required.

Use sigillo run --mount to write secrets to a temp file that only exists while the command runs. The --mount-format env flag writes a KEY=value file that --secrets-file expects:

# Preview — sigillo mounts secrets as a temp .env file, wrangler reads it
sigillo run -c preview \
  --mount /tmp/pw-secrets.env --mount-format env \
  -- wrangler deploy --env preview --secrets-file /tmp/pw-secrets.env

# Production
sigillo run -c prod \
  --mount /tmp/pw-secrets.env --mount-format env \
  -- wrangler deploy --secrets-file /tmp/pw-secrets.env

The mounted file is created before the command starts and cleaned up after it exits. Secrets never stay on disk.

For the full build + deploy chain, combine --mount with --command so vite build and wrangler deploy share the same sigillo session:

CLOUDFLARE_ENV=preview sigillo run -c preview \
  --mount /tmp/pw-secrets.env --mount-format env \
  --command 'vite build && wrangler deploy --env preview --secrets-file /tmp/pw-secrets.env'

After the first deploy, subsequent deploys work normally because the worker and its secrets already exist. wrangler secret bulk also works from this point on for updating secrets.

Production / preview secret values

# Set for production
wrangler secret put API_KEY
wrangler secret put API_KEY --env preview

Prefer the bulk upload scripts above over manual secret put commands.

KV operations: always use --remote

wrangler kv commands default to local storage, not the deployed remote KV. If you kv key list, kv key get, or kv key put without --remote, you're reading/writing to a local SQLite file that the deployed worker never sees. This causes confusing debugging sessions where writes appear to succeed but data seems missing.

# BAD — reads/writes local storage only
wrangler kv key list --namespace-id abc123
wrangler kv key get --namespace-id abc123 "my-key"
wrangler kv key put --namespace-id abc123 "my-key" "value"

# GOOD — reads/writes the actual deployed KV
wrangler kv key list --namespace-id abc123 --remote
wrangler kv key get --namespace-id abc123 "my-key" --remote
wrangler kv key put --namespace-id abc123 "my-key" "value" --remote

When debugging whether a Worker's KV writes are persisting, always use --remote on the verification commands. The Worker itself always writes to the remote KV; only the wrangler CLI defaults to local.

KV consistency model

  • KV.get() is strongly consistent in the datacenter that wrote the key. Cross-datacenter reads are eventually consistent (up to 60s).
  • KV.list() is always eventually consistent, even in the same datacenter. Recently written keys may not appear for several seconds.
  • Use KV.getWithMetadata(key) (checking value !== null) instead of KV.list() when verifying that specific keys exist after writing them.

Dynamic workers with LOADER

See ./dynamic-workers.md

CORS for static assets

Cloudflare Workers Static Assets are served by the CDN before Worker code runs. CORS headers set in Worker code don't apply to static files. This breaks cross-origin <canvas> image drawing, @font-face loading, and fetch() reads from other origins.

Fix: create a public/_headers file (Vite copies it to the build output, which becomes assets.directory):

/*
  Access-Control-Allow-Origin: *

Cloudflare reads _headers and applies the rules to all static asset responses. The file itself is not served. See Cloudflare headers docs.

Importing non-JS files as text

For things like .txt, .md, and .sql, tell Wrangler/Vite to import them as text with rules, then add a TypeScript declaration file. Do not silence the import with // @ts-expect-error.

{
  "rules": [
    { "type": "Text", "globs": ["**/*.sql"], "fallthrough": true },
    { "type": "Text", "globs": ["**/*.md", "**/*.txt"], "fallthrough": true }
  ]
}
// src/import-text.d.ts
declare module '*.sql' {
  const content: string
  export default content
}

declare module '*.md' {
  const content: string
  export default content
}

declare module '*.txt' {
  const content: string
  export default content
}
import schemaSql from './schema.sql'
import promptMd from './prompt.md'
import fixtureTxt from './fixture.txt'

Use a real declare module file so TypeScript understands the import shape. Never paper over missing module types with @ts-expect-error.

Routing: prefer custom_domain when you actually need routing

Do not add routes / custom_domain entries just because a project uses Spiceflow, Vite, or @cloudflare/vite-plugin. Spiceflow does not need wrangler routing rules to run, build, or deploy, and Vite does not need them either.

Only add routes when you are intentionally binding a real hostname to the worker. If you do need that, prefer custom_domain instead of path-based routes. Custom domains work without needing a proxied A/AAAA DNS record first — Cloudflare creates it automatically.

{
  // GOOD — custom_domain, no DNS setup needed
  "routes": [
    { "pattern": "api.example.com", "custom_domain": true },
    { "pattern": "api.preview.example.com", "custom_domain": true, "zone_name": "example.com" }
  ]

  // BAD — requires pre-existing proxied DNS record
  // "routes": [
  //   { "pattern": "api.example.com/*", "zone_name": "example.com" }
  // ]
}

Use routes (non-custom_domain) only when you need path-based routing (example.com/api/*) on a domain that already has another worker or Pages project on the root.

If a Worker has no named environments and uses only *.workers.dev, leave routes out entirely. A named preview environment is different: routes is an inherited Wrangler property. If production defines routes and preview should use workers.dev, preview must override them with "routes": [].

Environments: preview and production

Every project has two environments. Preview is the default for development and testing.

wrangler.jsonc structure

Critical: bindings are NOT inherited by environments. Wrangler environments do not inherit durable_objects, kv_namespaces, secrets, r2_buckets, etc. from the top level. You MUST duplicate all bindings in both top-level (production) and env.preview. If you don't, wrangler types generates optional (?) types for bindings that only exist in one environment, causing possibly undefined errors everywhere.

Keep binding names, Durable Object classes, secret names, and migration shapes consistent so generated Env types stay stable. Environment values, routes, and physical resource identifiers should differ where isolation requires it.

Routes behave in the opposite way: they ARE inherited. If top-level production owns app.example.com and env.preview omits routes, then wrangler deploy --env preview attempts to reassign that production domain to the preview Worker. Wrangler prints a warning before doing this. Treat that warning as a deployment blocker, never as informational output.

Use one of these explicit preview configurations:

// workers.dev preview — disable inherited production routes
"routes": []

// dedicated preview hostname
"routes": [
  { "pattern": "app.preview.example.com", "custom_domain": true }
]

If preview accidentally takes a production domain, add the correct preview routes, redeploy preview, then redeploy production so it reclaims its domain.

{
  "name": "my-worker",
  "compatibility_date": "2026-04-14",
  "compatibility_flags": ["nodejs_compat"],
  "main": "./src/app.tsx",

  // ── Production (top-level) ──────────────────────────────────
  "durable_objects": {
    "bindings": [{ "name": "MY_STORE", "class_name": "MyStore" }]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["MyStore"] }
  ],
  "vars": {
    "APP_URL": "https://app.example.com"
  },
  "secrets": {
    "required": ["API_KEY", "AUTH_SECRET"]
  },
  // Optional: only add this when you want a custom hostname.
  "routes": [
    { "pattern": "app.example.com", "custom_domain": true }
  ],

  // ── Preview ─────────────────────────────────────────────────
  // Must duplicate ALL bindings, secrets, migrations
  "env": {
    "preview": {
      "name": "my-worker-preview",
      "durable_objects": {
        "bindings": [{ "name": "MY_STORE", "class_name": "MyStore" }]
      },
      "migrations": [
        { "tag": "v1", "new_sqlite_classes": ["MyStore"] }
      ],
      "vars": {
        "APP_URL": "https://app.preview.example.com"
      },
      "secrets": {
        "required": ["API_KEY", "AUTH_SECRET"]
      },
      // Use [] for workers.dev, or list a distinct preview hostname.
      "routes": []
    }
  }
}

Preview bindings must also point at isolated resources. Create separate KV namespaces, R2 buckets, and D1 databases instead of copying production IDs or bucket names into env.preview. Matching binding names keep Env types stable; different resource identifiers keep preview tests away from production data.

Deploy scripts

The @cloudflare/vite-plugin resolves and flattens your wrangler.jsonc at build time and writes it into dist/rsc/wrangler.json. Set CLOUDFLARE_ENV during vite build so the plugin resolves the correct environment section:

wrangler deploy deploys one environment at a time. It does not deploy every configured env.* block. With no --env flag, Wrangler deploys the top-level/default config (usually production). Use wrangler deploy --env preview or another explicit env name when targeting a non-production environment.

IMPORTANT: Cloudflare D1 does NOT auto-apply migrations on deploy. If you deploy new worker code that references columns or tables from a pending migration, the worker will crash with "no such table" or "no such column" errors. Always run D1 migrations before deploying. Bake them into the deploy scripts so they can't be skipped.

Always deploy preview first, then production. D1 migrations can fail (bad SQL, constraint violations on existing data) and there is no automatic rollback. Running against preview first catches these failures safely. If the preview migration or deploy fails, stop. Do not continue to production.

For projects using D1, bake migrations into the deploy chain. The remote migration scripts print a unix timestamp before running so you can restore via D1 time travel if something goes wrong:

{
  "scripts": {
    "deploy": "pnpm db:migrate:preview && tsc && CLOUDFLARE_ENV=preview vite build && wrangler deploy --env preview",
    "deploy:prod": "pnpm db:migrate:prod && tsc && vite build && wrangler deploy"
  }
}

If a migration corrupts data, use the printed timestamp to restore:

wrangler d1 time-travel restore DB --timestamp=<unix_timestamp>
wrangler d1 time-travel restore DB --timestamp=<unix_timestamp> --env preview

For projects without D1 (no migrations needed):

{
  "scripts": {
    "deploy": "tsc && CLOUDFLARE_ENV=preview vite build && wrangler deploy --env preview",
    "deploy:prod": "tsc && vite build && wrangler deploy"
  }
}
  • pnpm run deploy → migrates + builds for preview env, deploys to preview (safe default)
  • pnpm run deploy:prod → migrates + builds for production, deploys to production

Always include run for package scripts. pnpm deploy is pnpm's own built-in deployment command and does not reliably invoke a "deploy" script.

Preview is the default deploy target. This prevents accidental production deploys. Production deploys should be deliberate.

Deployment sequence for D1 projects:

# 1. Deploy preview (migration + build + deploy)
pnpm run deploy

# 2. Verify preview works (load the page, hit health endpoint, check logs)

# 3. Deploy production
pnpm run deploy:prod

Secrets per environment

Secrets are set per environment. Set them separately:

# Preview
wrangler secret put API_KEY --env preview

# Production
wrangler secret put API_KEY

Using preview for integration tests

Preview environments are useful for tests that depend on Cloudflare infrastructure (Durable Objects, KV, R2, etc.) which can't be fully emulated locally.

// test/integration.test.ts
import { describe, test, expect } from 'vitest'

const PREVIEW_URL = 'https://app.preview.example.com'

describe('integration', () => {
  test('health check', async () => {
    const res = await fetch(`${PREVIEW_URL}/health`)
    expect(res.status).toBe(200)
    const body = await res.json()
    expect(body).toEqual({ ok: true })
  })

  test('auth flow redirects to provider', async () => {
    const res = await fetch(`${PREVIEW_URL}/api/auth/sign-in/social?provider=sigillo`, {
      redirect: 'manual',
    })
    expect(res.status).toBe(302)
    expect(res.headers.get('location')).toContain('auth.sigillo.dev')
  })
})

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
43
Forks
2
Last commit
Sep 2026

ahel review

  • S4info
    community integration, published by remorses, not cloudflare
  • K6info
    bundled executables the agent is told to run

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Item type
skill
Key
cloudflare-workers-remorses
Source
github.com/remorses/opencode-config