Imported from daloyjs/daloy (
packages/create-daloy/templates/cloudflare-worker/_agents/skills/daloyjs-best-practices/SKILL.md). Install upstream withnpx skills add daloyjs/daloy --skill daloyjs-best-practices. Copyright stays with the author (MIT).
SKILL.md — DaloyJS best practices (Cloudflare Workers)
Operational guidance and best practices for AI coding agents working in this DaloyJS Cloudflare Workers project. This is the project's single source of truth for how to add routes, write tests, ship secure defaults, and run the quality gates. Read this in full before making non-trivial changes.
When to use this skill
Use this skill when you need to:
- Add, modify, or remove HTTP routes in this Worker.
- Adjust middleware, validation, or error handling.
- Change Worker bindings (KV, D1, R2, Queues, env vars) in
wrangler.toml. - Run tests/typecheck or deploy the Worker.
- Harden the API (auth, CORS, rate limits, secrets, dependency hygiene).
- User phrasing such as "add GET /books", "new endpoint", "add a KV binding", or "fix the 401".
Do not use this skill for frontend UI, infra-only work, or unrelated
docs. Rare topics live under references/: read
references/mcp.md only when adding an MCP endpoint,
and read references/ci-workflows.md only
when editing .github/ or a --with-ci workflow. Do not load those files
up front.
Core principles
DaloyJS is a contract-first framework. On Workers, additionally:
- Stay on the Workers runtime. Only Web Standards APIs and
Cloudflare-specific bindings. No
node:modules unless the user explicitly addsnodejs_compattowrangler.tomland opts in. - The route definition is the contract. Method, path, request
schemas, and response schemas live in one place. Use the shorthand
app.get(path, contract, handler)(app.post,app.put,app.patch,app.delete,app.head) for ordinary routes; useapp.route({...})for reusabledefineRoute()contracts, metadata-heavy routes, or when composing many routes viaregisterRoutes(). - Validation schemas protect every boundary. This template uses Zod, and Daloy accepts any Standard Schema-compatible library.
- Preserve literal types. Return
status: 200 as const. - Secure by default.
requestId(),secureHeaders(), andrateLimit()are registered. Note: the in-memory rate limiter resets per isolate — for production traffic, prefer Cloudflare's native rate-limit binding. - Bindings flow through
env. Read KV/D1/R2/secrets from theenvargument tofetch, never from globals. - Contract gates are part of done. Keep
operationIdvalues stable, examples schema-valid, declared error responses accurate, and the generated OpenAPI contract in sync with the live route table.
Project shape
src/index.ts— the Worker entrypoint. Builds theApp, registers routes/middleware, and exportsdefault toFetchHandler(app)from@daloyjs/core/cloudflare. Do not wrap the result in another{ fetch }.wrangler.toml— Worker config (name, compatibility date, bindings, routes).tests/— test files using Workers-compatible test runners (e.g.vitest+@cloudflare/vitest-pool-workers) or in-processapp.request(...)for pure logic.
Commands cheat-sheet
pnpm dev # wrangler dev on http://localhost:8787
pnpm typecheck # tsc --noEmit
pnpm test # run test suite
pnpm contract # daloy inspect --check src/index.ts
pnpm deploy # wrangler deploy
pnpm audit # supply-chain audit
Always run pnpm typecheck and pnpm test before declaring a task done.
pnpm test includes the contract gate; if you need a focused contract
check, run pnpm contract.
OpenAPI & docs routes
This Worker starter sets docs: true in new App({...}), so three routes
are auto-mounted off the spec generated from your route definitions.
DaloyJS is dependency-free and the Scalar UI loads from a CDN, so the bundle
cost is negligible; drop docs (and the openapi block) if you need the
smallest possible Worker. The routes:
GET /openapi.json— OpenAPI 3.1 spec as JSON.GET /openapi.yaml— OpenAPI 3.1 spec as YAML (served inline astext/yaml; charset=utf-8).GET /docs— Scalar API reference UI that loads the spec.
On Workers the Scalar UI adds the most weight; consider
docs: { ui: "swagger" } or docs: "auto" (off in production), or pass
docs: { openapiYamlPath: false } to drop the YAML route only.
For hand-rolled mounting, openapiToYAML is exported from
@daloyjs/core/openapi.
AI-ready contract metadata
Daloy can expose route metadata to OpenAPI and agent tooling. Add metadata when it helps consumers understand or safely automate the route:
- Use
summary,description, andtagsfor concise human-facing docs. - Use
meta.examplesfor realistic happy-path and unhappy-path examples. Examples must match the declared schemas; the contract gate rejects drift. - Use
meta.extensionsfor stablex-*fields consumed by internal tools. - Use
deprecatedandsunsetwhen changing API lifecycle. Do not remove a route or response shape silently if generated clients may depend on it.
Workflow: add a new route
- Open
src/index.ts. - Design schemas first. Use
z.object({...}).strict()for inputs. - Call
app.get(path, contract, handler)(or the matchingapp.post/app.put/app.patch/app.delete/app.headshorthand) with a contract object holdingoperationId,tags,responses(plusrequestwhen accepting input). Addmetaexamples / descriptions when the route is user-facing or consumed by agents. Reach for the fullapp.route({...})form instead when the contract is a reusabledefineRoute()value, is metadata-heavy, or is being composed with many other routes viaregisterRoutes(). - Return
{ status, body, headers? }withstatus: 200 as const. - Throw typed errors (
NotFoundError,BadRequestError, etc.). - Add a test under
tests/. Useapp.request(...)for pure logic; useunstable_dev(Wrangler) or@cloudflare/vitest-pool-workerswhen you need bindings. - Run the contract gate:
pnpm contractorpnpm test. - Run the quality gates:
pnpm typecheck && pnpm test.
Example: a typed route with bindings
import { z } from "zod";
import { App, NotFoundError, rateLimit, requestId, secureHeaders } from "@daloyjs/core";
import { toFetchHandler } from "@daloyjs/core/cloudflare";
interface Env {
BOOKS: KVNamespace;
JWT_SECRET: string;
}
const Book = z.object({ id: z.string(), title: z.string() }).strict();
function buildApp(env: Env) {
const app = new App({ bodyLimitBytes: 1024 * 1024, requestTimeoutMs: 5_000 });
app.use(requestId());
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 120 }));
app.get(
"/books/:id",
{
operationId: "getBookById",
tags: ["Books"],
request: { params: z.object({ id: z.string().min(1) }).strict() },
responses: {
200: { description: "Found", body: Book },
404: { description: "Not found" },
},
},
async ({ params }) => {
const raw = await env.BOOKS.get(params.id, "json");
if (!raw) throw new NotFoundError(`Book ${params.id} not found`);
return { status: 200 as const, body: Book.parse(raw) };
}
);
return app;
}
export default {
fetch: (req: Request, env: Env, ctx: ExecutionContext) =>
toFetchHandler<Env>(buildApp(env)).fetch(req, env, ctx),
};
Validation & schema conventions
- Inputs: use
.strict()on top-level object schemas. - IDs: prefer
z.string().min(1); usez.string().uuid()when applicable. - Numbers from query strings:
z.coerce.number().int().min(...). - Optional vs nullable: differ in OpenAPI output.
- Pagination: standardize on
{ items, nextCursor }cursor pagination. - Discriminated unions:
z.discriminatedUnion("kind", [...]). - Keep response examples close to the route definition and schema-valid. The contract test intentionally fails invalid examples.
Error handling
- Throw typed errors from
@daloyjs/core— they serialize to RFC 9457 problem responses. - Add a
responses[code]entry for every error you throw. - For unexpected errors, let them bubble. The framework's error middleware converts them to a 500 problem response.
Middleware
Register middleware before route definitions. Order matters.
Keep the secure baseline (requestId, secureHeaders, rateLimit).
Add CORS only when needed, with an explicit origin allowlist.
Working with bindings
- Add the binding (
[[kv_namespaces]],[[d1_databases]],[vars], etc.) towrangler.toml. - Type the binding in the
Envinterface insidesrc/index.ts. - Pass
envintobuildApp(env)so handlers receive bindings via closure or factory argument. Never read bindings via globals. - Store secrets via
wrangler secret put— they appear onenvbut are not committed towrangler.toml.
Background jobs
Side effects that must outlive the HTTP request (welcome emails, webhook fan-out, thumbnails, nightly reconciliation) belong in a job, not inline in the handler and not in a fire-and-forget promise.
app.useJobs({ store, handlers, startWorker })wires a queue (and an optional in-process worker) into the app lifecycle, including the graceful-shutdown drain.- Enqueue after the DB commit, always with an idempotency key:
import { jobIdempotencyKey } from "@daloyjs/core";
await app.jobs!.enqueue({
name: "email.welcome",
payload: { userId: user.id, to: user.email }, // ids, never file bytes
idempotencyKey: jobIdempotencyKey({ name: "email.welcome", key: user.id }),
});
- Delivery is at-least-once: handlers must be idempotent. Pass a key
through to downstream APIs (e.g. Stripe's
Idempotency-Key) when a duplicate run would move money or send email. - Throw
JobFatalErrorfor permanent failures (no retry); any other throw retries with full-jitter backoff, then dead-letters. app.cronEnqueue(def, { name, payload })turns a cron tick into an idempotent enqueue. Use it for side effects that must run once cluster-wide; keep plainapp.cron()for process-local maintenance (other replicas have their own memory to sweep).- Payloads are plain JSON capped at 64 KiB: enqueue ids and blob URLs, never file contents.
MemoryJobStoreis for tests (worker.runOnce()) and single-process dev. Production needs a sharedJobStoreadapter (Redis/Postgres/SQS) in app code;useJobswarns on Memory in production, andstrictProduction: truerefuses to boot.- A Workers isolate enqueues only: never
startWorker: truehere — the isolate is torn down once the response flushes. Run the worker as a separate long-lived Node service (same app code,useJobswithstartWorker) against the same remote store.
Full reference: https://daloyjs.dev/docs/jobs
Testing best practices
Two patterns:
- In-process with
app.request(...)for pure logic that does not need bindings. - Workers-aware runners (
@cloudflare/vitest-pool-workersor Wranglerunstable_dev) when KV/D1/etc. are involved.
Cover happy paths and unhappy paths for every route: valid input,
validation failures (400), auth failures (401/403), not-found (404),
conflict (409), rate limiting (429). For external services, inject an
in-memory fake into buildApp(env) during tests.
For user-owned or tenant-owned resources, use at least two principals and
prove that Alice's valid token cannot list, read, update, or delete Bob's
record.
The shipped contract test should fail invalid examples, duplicate/missing
operationId, or missing responses.
Aim for complete happy- and unhappy-path test coverage of the routes you add.
Security best practices
- Keep
secureHeaders(),requestId(), andrateLimit()enabled. For high-traffic routes, attach Cloudflare's native rate-limit binding so limits are shared across isolates. - Never make a failing test pass by deleting or weakening a security guard. If a guard blocks a legitimate route, add the narrowest per-route override or configuration knob and cover both the allowed and rejected paths in tests.
- Never log secrets — filter
authorization,cookie, etc. - Read secrets via
wrangler secret put, never via plain[vars]inwrangler.toml. - For auth, verify JWT signatures with the Web Crypto API
(
crypto.subtle). Never trust thealgheader from the token. - Authentication and scopes are not resource authorization. For every route that accepts a resource id, classify the resource as public, user-owned, tenant-owned, shared, or administrator-only.
- Scope user-owned and tenant-owned database reads and writes with both the caller-controlled id and the trusted owner / tenant from the verified principal. Do not fetch by id alone and rely on the UI or a later caller to remember the ownership check.
- Never accept
ownerId,userId,tenantId,role, or another privileged ownership field from an ordinary request body. Derive it from the verified principal and reject the field with a strict request schema. - Use an explicit, permissioned, audited path for administrator bypasses.
- Validate redirects against an allowlist.
- Set
bodyLimitBytesandrequestTimeoutMsonnew App({...})to mitigate DoS. - For outbound HTTP, prefer
fetchGuard()or a transport layered on top of it when URLs can be influenced by users or tenants. SSRF protections should fail closed for private ranges and cloud metadata endpoints. - Workers have CPU and bundle-size limits; be cautious about adding
heavy dependencies. Run
wrangler deploy --dry-run --outdir=distto inspect bundle size. - Use
ctx.waitUntil(...)for fire-and-forget work so the response returns promptly. - Pin a
compatibility_dateinwrangler.tomland only bump it deliberately. New compat flags can change runtime semantics.
CI and workflows (--with-ci scaffolds)
Only when editing .github/, Dependabot, or a --with-ci workflow:
read references/ci-workflows.md as
reference. Skip that file for ordinary route work.
Logging & observability
- Use
ctx.log— it carries the request id. console.login Workers shows up inwrangler tail. Prefer structured logs through the framework logger.- For tracing, the
tracing()middleware emits OpenTelemetry-compatible spans; wire up a Workers-friendly exporter when needed.
Configuration & secrets
- Centralize env shape in an
Envinterface. - Validate env via Zod once per request (cheap with Workers) or on first access via a memoized helper.
- Treat env as immutable during a request.
Pitfalls and guardrails
- Use
toFetchHandler(app)from@daloyjs/core/cloudflare— never hand-roll afetch(req, env, ctx)adapter. - Do not import
@daloyjs/core/node,@daloyjs/core/bun, etc. — only@daloyjs/coreand@daloyjs/core/cloudflare. - Do not hand-edit OpenAPI paths or client types. Fix the route definition, schema, or metadata and regenerate.
- Avoid Node-only APIs (
Buffer,fs,processbeyondprocess.env) unlessnodejs_compatis enabled and required. - Do not weaken response literal types (
as const). - Do not return errors as
{ status: 4xx, body }. Throw a typed error. - Do not add runtime dependencies without checking the hardened
.npmrc(installs wait 24h after publish by default). - Long-running work belongs in
ctx.waitUntil(...), not blocking the response. - If a route intentionally returns a body the contract cannot describe (a
raw
Response, HTML, a proxied payload), setacknowledgeNoResponseBodySchema: trueon that route — never silence thesecurity.response.bodySchemaMissingboot warning by widening a schema toz.any().
Process expectations
- Every new feature ships with happy-path and unhappy-path tests.
- Bug fixes include a regression test.
pnpm typecheckandpnpm testmust pass before completion.- When route metadata, examples, lifecycle flags, or operation IDs change, run the contract gate and inspect the relevant generated OpenAPI diff.
- For deploys, ask the user to run
wrangler loginfirst if needed — do not attempt to authenticate on their behalf. - Keep
README.md, thisSKILL.md, andAGENTS.mdconsistent.
Exposing this API over MCP
Only when adding or changing an MCP endpoint: read references/mcp.md as reference (do not treat it as a script to run). Skip that file for ordinary HTTP route work.
More
- Framework docs: https://daloyjs.dev/docs
- Issues: https://github.com/daloyjs/daloy/issues