Cloudflare Workers
SkillCloud & infraCloudflare 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.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
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:
- Uninstall
@cloudflare/workers-types— it conflicts with generated runtime types - Install
@types/nodeif usingnodejs_compat - Include
worker-configuration.d.tsin tsconfig:
{
"compilerOptions": {
"types": []
},
"include": ["src", "worker-configuration.d.ts"]
}
- Rerun
wrangler typesevery time you changewrangler.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.varsfor local development. - Put shell env vars before
doppler run, never after. - Add
CLOUDFLARE_INCLUDE_PROCESS_ENV=truein Doppler so wrangler sees the injected env vars. - Read runtime values from
import { env } from 'cloudflare:workers', notprocess.env, even thoughprocess.envmay be populated undernodejs_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)(checkingvalue !== null) instead ofKV.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 cloudflareK6info
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
github.com/remorses/opencode-config
Related picks
Skill · thedaviddias
The pick for JavaScriptmodern-javascript-patterns
Skill · wshobson
The pick for JavaScriptsetup-ts-deep-modules
Skill · mattpocock
The pick for TypeScripttypescript-pro
Skill · jeffallan
The pick for TypeScriptreact-component-performance
Skill · davila7
The pick for Reactreact-doctor
Skill · millionco
The pick for React