The Cloudflare Workers backend for Moku — Durable Objects, Queues, R2, D1, and KV, each a composable plugin.
Every Cloudflare primitive is a Moku plugin that resolves its binding per request off the Worker env — never stored, never shared across the concurrent requests one isolate serves. A server plugin owns HTTP routing and dispatch; build-time deploy/cli ship the Worker but stay out of the runtime bundle. Not an ORM, not a router framework, not a replacement for Wrangler — the thin, typed seam between your handlers and Cloudflare's runtime, built on @moku-labs/core and designed to compose with @moku-labs/web.
Why · Quick start · How it works · Plugins · Configuration · Scripts
- Every primitive is a plugin. KV, D1, R2, Queues, and Durable Objects each compose into
createApp— add only what you use; the rest tree-shakes away. envis a call argument, never state. Bindings are threaded per request and live only on the call stack, so one isolate serving concurrent requests can never leak bindings between them.- One bundle, no Node leakage. The build-time
deploy/cliplugins reach fornode:fsandnode:child_process, but"sideEffects": falsekeeps them out of any request-time Worker bundle that doesn't list them. - Not an ORM, not a router framework. Thin, typed wrappers over the real Cloudflare APIs (
prepare().bind(),KVNamespace,R2Bucket) — no abstraction to learn on top of the platform. - The server half of Moku.
@moku-labs/websupplies the request/island layer; this supplies the Cloudflare primitives — same kernel, same plugin model, no second code path to keep in sync.
bun add @moku-labs/worker
bun add -d @cloudflare/workers-typesNote
Status: 0.x — early. API may shift before 1.0. Built on @moku-labs/core (1.x, semver-compliant). wrangler is an optional peer — needed only when you add the deploy/cli plugins.
Declare your routes as data, then hand-assemble the Worker entry from app.server:
// app.ts
import { createApp, endpoint } from "@moku-labs/worker";
export const app = createApp({
config: { name: "my-api", compatibilityDate: "2026-06-17" },
pluginConfigs: {
server: {
endpoints: [
endpoint("/health").get(() => new Response("ok")),
endpoint("/api/data/{lang:?}").get(({ params }) =>
Response.json({ lang: params.lang ?? "en" })
),
endpoint("/users/{userId}").get(
({ params }) => new Response(`user=${params.userId}`)
)
]
}
}
});// worker.ts — the default export is hand-assembled; no plugin produces it.
import { app } from "./app";
export default {
fetch: (request: Request, env: Record<string, unknown>, ctx: ExecutionContext) =>
app.server.handle(request, env, ctx)
} satisfies ExportedHandler;createApp is synchronous, built once per isolate at module load, and frozen. bindings and server are wired in by default — you never list them. A request to /api/data/fr returns { "lang": "fr" }; /api/data returns { "lang": "en" }; an unmatched path returns 404. Path params mirror @moku-labs/web: {name} is required (typed string), {name:?} is optional (typed string | undefined).
Three layers, one kernel. createCoreConfig declares config + events and registers the core plugins; createCore wires the framework defaults; your code calls createApp. At runtime, each Cloudflare invocation threads its env down through the entry into app.server and out to whichever resource plugins a handler reaches:
flowchart LR
REQ["fetch · scheduled · queue<br/>(env per invocation)"] --> ENTRY["your worker.ts<br/>default export"]
ENTRY --> APP["app.server.handle<br/>matches & dispatches"]
APP --> RES["kv · d1 · r2 · queues · DO<br/>resolve binding off env"]
RES --> OUT["Response"]
classDef io fill:#0b7285,stroke:#08525f,color:#fff;
classDef mach fill:#1864ab,stroke:#0d3d6e,color:#fff;
class REQ,OUT io
class ENTRY,APP,RES mach
| Layer | File | Produces |
|---|---|---|
| 1 — config + events | src/config.ts |
createCoreConfig → WorkerConfig, WorkerEvents; registers core plugins (log, env) |
| 2 — framework + plugins | src/index.ts |
createCore → createApp / createPlugin; wires bindings + server defaults |
| 3 — consumer app | your code | createApp({ … }) |
The binding lives on the request, not on the plugin.
One Cloudflare isolate serves many concurrent requests, so every binding-resolving method takes the per-request env as its first argument and reads it on the call stack — env is never captured in plugin state:
app.kv.get(env, "feature-flags"); // env-first KV read
app.d1.query(env, "SELECT 1"); // env-first D1 query
app.durableObjects.get(env, "board", "room-42"); // env-first DO stubInside a server handler you receive env (plus a cross-plugin require and an has presence check) on the per-request RequestContext, and thread it onward — so two requests in flight at once can never observe each other's bindings:
endpoint("/cache/{key}").get(async ({ params, env, require, has }) => {
if (!has("kv")) return new Response("kv not configured", { status: 501 });
const value = await require(kvPlugin).get(env, params.key);
return value === null ? new Response("miss", { status: 404 }) : new Response(value);
});The core plugins are flat-injected on every plugin's ctx — ctx.log, ctx.env — and also mounted on the app surface (app.log, app.env). Deployment stage is plain global config: ctx.global.stage.
endpoint.new(guard) derives a factory that runs guard before every handler it builds (chain .new to stack guards). A guard can gate — return a Response to short-circuit — and now also enrich: return an object and it is merged into the context handed to the handler (and to later guards), which then read it as a typed field. So a guard resolves a value once and the handler reuses it — no re-resolve, no defensive null-check:
// Resolve the session ONCE in the guard; gate if absent, else hand the actor forward.
const authed = endpoint.new(async (ctx) => {
const actor = await ctx.require(authPlugin).resolveActor(ctx.request, ctx.env);
if (!actor) return new Response("Unauthorized", { status: 401 });
return { actor }; // ← enrich
});
authed("/api/boards/{id}").post(async (ctx) => {
// ctx.actor: Actor (non-null, typed) — AND ctx.params.id: string still inferred from the path
return Response.json(await ctx.require(boardsPlugin).rename(ctx.env, ctx.params.id, ctx.actor));
});A gate-only guard (returns Response/void) is unchanged and adds nothing to ctx; the enrichment type is inferred per guard and accumulates across a .new chain. Purely additive — existing guards keep working.
Name strings are bare ("server", "kv"); the exported instances carry the Plugin suffix (serverPlugin, kvPlugin). Everything ships from the @moku-labs/worker root.
| Plugin | Tier | Responsibility | Key API |
|---|---|---|---|
bindings |
Standard | Resolves Cloudflare bindings off the per-request env; the binding-family dependency root. |
require(env, name), has(env, name) |
server |
Standard | HTTP routing + request/scheduled dispatch; the Worker-entry surface. | handle, scheduled, endpoint |
kv |
Micro | Thin env-first wrapper over one KV namespace. | get, put, delete, list |
d1 |
Standard | Typed wrappers over D1's prepare().bind(). Not an ORM. |
query, first, run, batch, prepare |
queues |
Standard | Cloudflare Queues producer + consumer. | send, sendBatch, consume |
storage |
Complex | R2 object storage behind a provider-adapter seam. | get, put, delete, list |
durableObjects |
Standard | Resolves DO stubs off env; ships defineDurableObject. |
get, defineDurableObject |
deploy |
Complex | Build-time orchestrator: detect → provision → wrangler-config → upload → deploy. Node-only. | run, dev, init |
cli |
Standard | Developer-facing dev / deploy verbs + live progress TUI. Node-only. |
dev, deploy |
The
logandenvcore plugins are not authored here — they come from@moku-labs/commonand are re-exported (logPlugin,envPlugin).envis environment-variable access; deployment stage is plain global config (config.stage, read viactx.global.stage). Helpersendpoint(path)anddefineDurableObject(name)ship from the root too.
Add a resource plugin by appending it — defaults stay implicit:
import { createApp, kvPlugin } from "@moku-labs/worker";
const app = createApp({
plugins: [kvPlugin], // append only what you add
pluginConfigs: {
bindings: { required: ["MY_KV"] }, // fail fast if the binding is missing
kv: { cache: { name: "my-cache", binding: "MY_KV" } } // keyed map; single entry = default
}
});Important
The final plugin list is […frameworkDefaults, …yourPlugins]. Defaults are [log, env, bindings, server], registered first; your plugins append after. Do not re-list a default — re-listing collides on name and throws TypeError: [worker] Duplicate plugin name: "bindings" at init. pluginConfigs is keyed by name, so you can still configure a default (e.g. bindings.required) without listing it.
Deploy tooling is built from the same plugin model but kept strictly out of the request-time bundle.
| Surface | Entry | In the Worker bundle? | Carries |
|---|---|---|---|
| Runtime | @moku-labs/worker (.) |
Always | createApp, createPlugin, all resource plugins, server, helpers, types |
| Node-only | @moku-labs/worker → deployPlugin / cliPlugin |
Only if you add them | deploy + cli; pulls in node:fs / node:child_process |
Tip
Everything ships from the root entry — including deployPlugin/cliPlugin. Because the package is "sideEffects": false, a Worker that imports createApp and never lists those two tree-shakes the Node built-ins away, with no separate entry point. The ./cli subpath remains as a back-compat alias.
The global WorkerConfig, passed as createApp({ config }) — flat, with complete defaults:
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string |
"worker" |
Worker name; deploy uses it as the wrangler name. |
stage |
"production" | "development" | "test" |
"production" |
Production-safe default. Read via ctx.global.stage; deploy/cli use it to suffix resource names (production = bare). |
compatibilityDate |
string |
"" |
Cloudflare compatibility date; deploy uses it as compatibility_date. |
Per-plugin config goes under pluginConfigs.<name> (e.g. server.endpoints, kv.binding, bindings.required, deploy.configFile). Every config is flat with complete defaults — overriding one key never drops siblings — and frozen after createApp. Each field is documented in that plugin's README, linked in the Plugins table.
Events are fire-and-forget observability — request/response and deploy work flows through API return values, never through emit. Global events live on WorkerEvents (src/config.ts) and are visible to every plugin; plugin-local events are reached via depends: [<plugin>].
| Event(s) | Emitted by | When |
|---|---|---|
request:start · request:end |
server |
Around each handle — start (fresh requestId), end (final status + ms). |
server:matched (local) |
server |
After a request matches an endpoint, before the handler runs. Not on 404. |
queue:message (local) |
queues |
After config.onMessage settles for a message inside consume. |
deploy:phase · deploy:complete |
deploy |
Each pipeline stage; final deployed url. |
provision:plan · provision:resource · provision:skip |
deploy |
Provisioning plan, then per-resource create or skip. |
auth:verified |
deploy |
After Cloudflare auth resolves (account, accountId, scopes). |
dev:phase · dev:rebuilt · dev:error |
deploy |
Local dev server: stage, incremental rebuild (files + ms), error. |
// A plugin's `hooks` factory receives `ctx` and returns an event → handler map.
hooks: (ctx) => ({
"deploy:phase": ({ phase, detail }) => ctx.log.info(`▸ ${phase}${detail ? ` (${detail})` : ""}`),
"deploy:complete": ({ url }) => ctx.log.info(`✓ ${url}`)
})Run with bun — never npm/yarn/pnpm.
bun run build # build with tsdown → dist/
bun run test # all tests (vitest)
bun run test:unit # unit tests only
bun run test:integration # integration tests only
bun run test:coverage # tests with coverage (90% threshold)
bun run typecheck # tsc --noEmit
bun run lint # biome check + eslint
bun run lint:fix # auto-fix lint issues
bun run format # biome format --write
bun run validate # publint + are-the-types-wrong- Node
>= 24and Bun>= 1.3.14— usebunexclusively (never npm/yarn/pnpm). - TypeScript in strict mode, with
exactOptionalPropertyTypesandnoUncheckedIndexedAccess. @moku-labs/core— the micro-kernel this framework is built on (installed transitively, with@moku-labs/common).@cloudflare/workers-types(dev) — ambient runtime types (KVNamespace,D1Database,R2Bucket,ExecutionContext, …); add to your tsconfigtypes. Type-only, never bundled.wrangler(optional peer) — required only when you adddeployPlugin/cliPlugin. Invoked as a subprocess; never bundled.
- Per-plugin READMEs — authoritative API, config, and events for each plugin, linked in the Plugins table.
- Moku Core specification — the underlying kernel model:
createCoreConfig,createCore,createApp, lifecycle, events.