@moku-labs/worker
v0.20.0
Published
Cloudflare Worker framework for Moku — Durable Objects, Queues, R2, D1, and KV plugins that compose with Moku Web.
Readme
@moku-labs/worker
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
Why @moku-labs/worker
- 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.
Quick start
bun add @moku-labs/worker
bun add -d @cloudflare/workers-types[!NOTE] Status:
0.x— early. API may shift before1.0. Built on@moku-labs/core(1.x, semver-compliant).wrangleris an optional peer — needed only when you add thedeploy/cliplugins.
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).
How it works
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({ … }) |
Env is the contract
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.
Guards that gate AND enrich
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.
Plugins
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; yourpluginsappend after. Do not re-list a default — re-listing collides on name and throwsTypeError: [worker] Duplicate plugin name: "bindings"at init.pluginConfigsis keyed by name, so you can still configure a default (e.g.bindings.required) without listing it.
Runtime vs. node-only
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 importscreateAppand never lists those two tree-shakes the Node built-ins away, with no separate entry point. The./clisubpath remains as a back-compat alias.
Configuration
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
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}`)
})Scripts
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-wrongRequirements
- 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.
Docs
- 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.
