Cloudflare Workers¶
Cloudflare Workers Hono Validation
A Cloudflare Workers service powered by Hono with a working Workers KV example baked in. The shipped app goes beyond “hello world”: it stores submitted greetings in a KV namespace via POST /greetings and reads them back via GET /greetings, so the moment you run wrangler dev you have a real binding to exercise — not a placeholder.
The runtime is V8 isolates, not Node. The template uses Hono’s native fetch export rather than @hono/node-server, and wrangler.jsonc (note: .jsonc, not .json) pre-declares the KV binding so the dev session boots with a real local store the first time you wrangler dev.
What You Get¶
my-project/
├── rescript.json
├── package.json
├── wrangler.jsonc # Workers config: name, main, compatibility_date, kv_namespaces
├── src/
│ ├── Server.res # Hono app + POST/GET /greetings; `export default app`
│ ├── Kv.res # Workers KV bindings (get / put / list)
│ ├── Hono.res # Hono bindings
│ ├── Validation.res # zod or sury — selected in the wizard
│ └── __tests__/Server.test.mjs # vitest smoke import
├── README.md # API / KV Setup / Deploy
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24 (for tooling — runtime is V8 isolates)
├── .gitignore # node_modules, .wrangler/, dist/, .dev.vars
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Affects |
Validation library |
|
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs the compiled |
|
|
HTTP router with native Workers fetch handler |
|
|
Request body validation (chosen in the wizard) |
|
|
Cloudflare CLI: dev server, KV, deploy |
|
|
Smoke test runner |
|
|
Coverage provider for |
|
@hono/node-server is not included — the Workers runtime takes Hono’s native fetch export directly (see Server.res).
Key Files¶
wrangler.jsonc¶
The Workers config. Comments are preserved (hence .jsonc), so the KV setup workflow lives next to the binding it documents:
{
"name": "my-project",
"main": "src/Server.res.mjs",
"compatibility_date": "2024-01-01",
"kv_namespaces": [
{
"binding": "GREETINGS",
"id": "REPLACE_WITH_PRODUCTION_KV_NAMESPACE_ID",
"preview_id": "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID"
}
]
}
id is what wrangler deploy uses (production); preview_id is what wrangler dev uses (local). Keeping them split means local experimentation never mutates production data — see the README’s KV Setup section for the full create-and-wire workflow.
src/Server.res¶
The Hono app. env is narrowed to the bindings this Worker actually uses (GREETINGS), and the file ends with %%raw("export default app") because the Workers runtime invokes the default export’s fetch(request, env, ctx):
type env = {"GREETINGS": Kv.namespace}
let app = Hono.createApp()
app->Hono.get("/", ctx => ctx->Hono.text("Workers + Hono + ReScript"))
app->Hono.post("/greetings", async ctx => {
let env: env = %raw("ctx.env")
let raw = await ctx->Hono.req->Hono.jsonBody
switch Validation.parseGreetingPayload(raw) {
| Error(msg) => ctx->Hono.status(400)->Hono.json({"error": msg})
| Ok(payload) =>
await env["GREETINGS"]->Kv.put(payload.name, Date.now()->Float.toString)
ctx->Hono.status(201)->Hono.json({"ok": true, "name": payload.name})
}
})
app->Hono.get("/greetings", async ctx => {
let env: env = %raw("ctx.env")
let result = await env["GREETINGS"]->Kv.list
ctx->Hono.json({"names": result.keys->Array.map(k => k["name"])})
})
%%raw("export default app")
src/Kv.res¶
Bindings over the Workers KV API. The shipped subset (get / put / list) is enough to run the example; the file is a drop-in seed for further bindings — Durable Objects, R2, queues, D1 — when you need them:
type namespace
type listResult = {keys: array<{"name": string}>}
@send external get: (namespace, string) => promise<Nullable.t<string>> = "get"
@send external put: (namespace, string, string) => promise<unit> = "put"
@send external list: namespace => promise<listResult> = "list"
The namespace type is opaque on purpose — Cloudflare injects the real KV instance at runtime via env.GREETINGS, and we read it through %raw("ctx.env") in the route handlers.
src/Validation.res¶
Both variants expose parseGreetingPayload: JSON.t => result<greetingPayload, string>. The route handler returns 400 on Error and proceeds on Ok — no exception propagation, no extra middleware. Choose zod if you want the broader ecosystem (zod-to-openapi, hono/zod-openapi, drizzle-zod), or sury if you prefer ReScript-native ergonomics and a smaller runtime footprint.
src/__tests__/Server.test.mjs¶
A one-line smoke test that asserts the module loads:
await expect(import("../Server.res.mjs")).resolves.toBeDefined();
It exists so CI catches obvious link-time regressions (missing imports, broken %raw blocks) without requiring you to wire up Miniflare or unstable_dev. Add KV-bound integration tests once your routes have logic worth covering — wrangler ships an unstable_dev API that boots a real Workers runtime in-process for vitest.
Endpoints¶
Endpoint |
Method |
Description |
|---|---|---|
|
GET |
Health check ( |
|
POST |
Validate |
|
GET |
List stored names |
Request and response shapes¶
POST /greetings
Content-Type: application/json
{ "name": "Ada" }
HTTP/1.1 201 Created
Content-Type: application/json
{ "ok": true, "name": "Ada" }
GET /greetings
HTTP/1.1 200 OK
Content-Type: application/json
{ "names": ["Ada", "Grace", "Linus"] }
A missing or invalid body produces a 400:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{ "error": "Validation failed" }
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
KV Setup Walkthrough¶
The shipped wrangler.jsonc has placeholder IDs that wrangler will refuse to use until you replace them. The README’s KV Setup section walks through the workflow; the gist is:
# Production namespace (used by wrangler deploy)
npx wrangler kv namespace create GREETINGS
# Preview namespace (used by wrangler dev)
npx wrangler kv namespace create GREETINGS --preview
Each command prints a JSON snippet with an id. Copy them into wrangler.jsonc under kv_namespaces[0].id and .preview_id respectively. Verify the wiring with:
npx wrangler kv namespace list
Local reads and writes go through the preview namespace by default:
# Writes against preview_id (what wrangler dev sees)
npx wrangler kv key put --binding=GREETINGS hello world
# Writes against the production id explicitly
npx wrangler kv key put --binding=GREETINGS --remote hello world
If you omit preview_id, wrangler falls back to a one-off in-memory store wiped between sessions. That is fine for throwaway demos but brittle for anything you want to inspect later.
Extending the Bindings¶
The shipped Kv.res covers the routes in the example, not the full Workers KV surface. When you need more, add the binding directly — most KV operations are a single @send line:
// Conditional writes (skip if the key already exists):
@send external putWithMeta:
(namespace, string, string, {"metadata": 'meta}) => promise<unit> = "put"
// Bulk delete:
@send external deleteKey: (namespace, string) => promise<unit> = "delete"
// Pagination — `list` accepts `{prefix, limit, cursor}`:
type listOpts = {prefix?: string, limit?: int, cursor?: string}
@send external listWithOpts:
(namespace, listOpts) => promise<listResult> = "list"
Other Workers primitives follow the same shape. Each gets its own thin module:
Binding |
Module |
Wrangler config key |
Typical methods |
|---|---|---|---|
KV |
|
|
|
R2 |
add |
|
|
Durable Objects |
add |
|
|
D1 |
add |
|
|
Queues |
add |
|
|
For each, declare the binding in wrangler.jsonc, add the field to type env in Server.res, and read it via %raw("ctx.env") in the route handler. The pattern is uniform so muscle memory transfers across services.
Try It¶
Once wrangler dev is running and the KV IDs are wired (see KV Setup Walkthrough above), exercise the example end-to-end:
# Store a greeting
curl -X POST http://localhost:8787/greetings \
-H 'Content-Type: application/json' \
-d '{"name":"Ada"}'
# => {"ok":true,"name":"Ada"}
# List the names you have stored
curl http://localhost:8787/greetings
# => {"names":["Ada"]}
# Send something the validator rejects
curl -X POST http://localhost:8787/greetings \
-H 'Content-Type: application/json' \
-d '{}'
# => 400 {"error":"Validation failed"}
When you’re ready to push to Cloudflare, npx wrangler login once and then pnpm deploy (or your PM equivalent). The first deploy provisions the route at <worker-name>.<account>.workers.dev; subsequent deploys are atomic and roll back automatically if the upload fails health-check.
Day-Two Recipes¶
Add a Hono Endpoint — add another route alongside
/greetingsConvert from TypeScript — port an existing Worker to ReScript module-by-module
Optimize Imports — cut bundle size (every byte counts at the edge)
For ReScript-side editor workflows once the project is open, see the Feature Overview.
Notes¶
No Node runtime. Cloudflare Workers run on V8 isolates, not Node. That’s why the template ships only
hono(no@hono/node-server) and the entry file ends with%%raw("export default app")— Workers invoke the default export’sfetch(request, env, ctx)..nvmrcstill says24because tooling (wrangler, esbuild under the hood, vitest) runs on Node locally.wrangler.jsonc, notwrangler.toml. Cloudflare supports both. We chose JSONC because it lets us keep the KV setup walkthrough next to the binding it documents — no separate file to skim.Two KV namespaces, on purpose.
id(production) andpreview_id(local) are kept distinct sowrangler devnever writes to production. If you omitpreview_id, wrangler falls back to a one-off in-memory store that is wiped between sessions — fine for throwaway demos, brittle for anything you want to inspect later. Runnpx wrangler kv namespace create GREETINGSand... --previewonce and paste the IDs in.Bindings are typed by hand, not generated. The template does not depend on
@cloudflare/workers-types; theenvshape lives inServer.resastype env = {"GREETINGS": Kv.namespace}. If you add bindings (Durable Objects, R2, queues), add a field to that type and a binding entry inwrangler.jsonc..dev.varsis gitignored. Use it for local-only secrets that wrangler reads automatically. For production, runnpx wrangler secret put NAME— secrets never live inwrangler.jsonc..wrangler/anddist/are gitignored. wrangler caches local state in the former; the latter is reserved for any bundling step you add later.Smoke test only verifies module load.
src/__tests__/Server.test.mjsdoesawait import("../Server.res.mjs")and asserts it resolves. Add KV-bound integration tests withunstable_devfromwrangleronce your routes have logic worth covering.Deploy needs auth.
npx wrangler loginonce per machine, thenpnpm deploy(or your PM equivalent) pushes to your account. The README’s Deploy section interpolates the right command for the package manager you chose.No
compatibility_flags, conservativecompatibility_date. The shippedwrangler.jsoncuses2024-01-01and no flags. Bump the date when you need newer Workers runtime behaviour (Node.js compat shims,nodejs_compat,streams_enable_constructors, etc) — the field is opt-in by design so older deployments don’t break when Cloudflare ships a runtime update.Bindings beyond KV. Add Durable Objects, R2 buckets, queues, D1 databases, or service bindings by editing
wrangler.jsoncand extendingtype envinServer.res. The pattern stays the same: declare the binding in JSONC, name its type in theenvrecord, read it via%raw("ctx.env"). Each new binding gets its own thin.resmodule mirroringKv.res.Free-plan limits matter. A Worker has a 50 ms CPU time budget per request on the free plan (10 ms before May 2024) — validation, KV reads, and JSON serialisation all count. The shipped routes stay well within that envelope, but the moment you reach for heavy crypto or large JSON, run
wrangler dev --inspectand profile.