Skip to content

Search docs

Jump between documentation pages.

Browse docs

Cloudflare Workers

The Cloudflare adapter exports a Workers module entrypoint: the canonical export default { fetch } shape. Service Worker style (addEventListener("fetch", ...)) is no longer recommended. The adapter does not emit it.

Workers modules-format request path
  1. platformWorker fetch(request, env, ctx)export default { fetch }
  2. adaptertoFetchHandler(app)bindings via cloudflare:workers env
  3. coreapp.fetch(Request)routing · hooks · handler
  4. platformResponse
toFetchHandler returns the { fetch } object Workers expect. To reach KV, R2, D1, or secrets, import env from cloudflare:workers and app.decorate() it before registering routes, so bindings land on ctx.state inside every handler.

When to choose Workers

  • You want global, low-latency execution without managing regions yourself.
  • You can live without raw TCP sockets (Workers Hyperdrive solves Postgres).
  • You want bindings (KV, R2, D1, Durable Objects, Queues) instead of standalone services.

Scaffold

bash
pnpm create daloy@latest my-api --template cloudflare-worker
cd my-api
pnpm dev   # wrangler dev under the hood

Worker entrypoint (no bindings)

If you don't need env bindings or the Worker ExecutionContext, toFetchHandler is a one-liner. It returns the { fetch } object Workers expect as the default export, so do not wrap it again.

ts
// src/index.ts
import { toFetchHandler } from "@daloyjs/core/cloudflare";
import { app } from "./server.js";

export default toFetchHandler(app);

Workers have no NODE_ENV, so set production mode explicitly. If you write src/server.ts by hand instead of scaffolding with create-daloy, configure the App the way the Cloudflare template does:

ts
// src/server.ts
import { App, rateLimit } from "@daloyjs/core";

export const app = new App({
  bodyLimitBytes: 256 * 1024,
  requestTimeoutMs: 5_000,
  production: true, // no NODE_ENV on Workers: without this, production guards stay off
  // Cloudflare's edge sets X-Forwarded-For. One hop tells DaloyJS which entry
  // Cloudflare appended, so rateLimit() keys on the real client.
  behindProxy: { hops: 1 },
});

// The in-memory store is per isolate: an abuse brake, not a global quota.
app.use(rateLimit({ windowMs: 60_000, max: 120 }));

Without production: true, the production-only refuse-to-boot guards do not refuse; each would-be refusal is only logged as a secure_defaults.env_indeterminate warning. 5xx detail is still redacted, because an unset environment fails closed. Without behindProxy, production requests carrying X-Forwarded-For are refused with a 500. Fix that with { hops: 1 }, not "none" or trustProxy: a Worker has no TCP peer, so those settings make rateLimit() put every caller in one shared bucket. DaloyJS logs a rate-limit.shared-bucket warning in production when that happens.

wrangler.jsonc

Cloudflare now recommends wrangler.jsonc over wrangler.toml for new projects. Both are still supported. The single nodejs_compat flag is all you need on a recent compatibility date, there's no separate nodejs_compat_v2 to add.

jsonc
// wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-05-22",
  "compatibility_flags": ["nodejs_compat"],

  "kv_namespaces": [
    { "binding": "CACHE", "id": "<kv-id>" }
  ],
  "d1_databases": [
    { "binding": "DB", "database_name": "my-api", "database_id": "<d1-id>" }
  ],
  "placement": { "mode": "smart" }
}

Deploy

bash
# local dev
pnpm wrangler dev

# secrets (not committed)
pnpm wrangler secret put SESSION_SECRET

# ship it
pnpm wrangler deploy

wrangler publish was renamed to wrangler deploy in 2024. Do not use the old name. Some CI templates still reference it.

Bindings (env)

toFetchHandler(app) only forwards the Request. To expose Worker bindings (KV, R2, D1, Durable Objects, Queues, Hyperdrive, secrets) to your handlers, import env from cloudflare:workers and pass it to app.decorate(...) before you register routes. That's how DaloyJS makes runtime values available on ctx.state inside every handler.

ts
// src/server.ts
import { env } from "cloudflare:workers";
import { App } from "@daloyjs/core";

export interface Env {
  CACHE: KVNamespace;
  DB: D1Database;
  SESSION_SECRET: string;
}

export const app = new App();
// Decorate before the routes: each route captures its scope's decorations
// when it is registered, and decorate() throws if a key is set twice.
app.decorate("env", env as Env);

// ...register routes below
ts
// src/index.ts
import { toFetchHandler } from "@daloyjs/core/cloudflare";
import { app } from "./server.js";

export default toFetchHandler(app);

Do not call app.decorate() inside the Worker fetch handler. It throws on the second request, and a first decoration added after the routes were registered never reaches them. The imported env can be read at module scope, but binding I/O (a KV read, a D1 query) still has to happen inside a request, which is where your handlers run.

Inside any route handler, read the binding from ctx.state.env (the key you passed to decorate):

ts
app.get(
  "/cached/:key",
  {
    request: { params: z.object({ key: z.string() }) },
    responses: { 200: { body: z.object({ value: z.string().nullable() }) } },
  },
  async ({ params, ctx }) => {
    const value = await ctx.state.env.CACHE.get(params.key);
    return { status: 200, body: { value } };
  },
);

Gotchas

  • Workers have no raw TCP. Use Hyperdrive for Postgres/MySQL, or HTTP drivers like Neon's serverless driver, PlanetScale's @planetscale/database, or Turso/libSQL. See Database hosting.
  • Workers have no filesystem. Use multipart uploads with R2 rather than node:fs.
  • For background work, import waitUntil from cloudflare:workers and call it inside the handler: toFetchHandler does not forward the Worker ExecutionContext to your routes. (It does use ctx.waitUntil itself to flush telemetry.)

See also