Basic Worker example
examples/basic-worker in the library repository is a small but complete Worker. It is also the fixture for the library's end-to-end tests, which run it in real workerd against the built package, so everything on this page is exercised in CI.
| Route | What it shows |
|---|---|
POST /api/v1/notes | chanfana and zod body validation (422 VALIDATION_ERROR), a Drizzle D1 insert, c.respondCreated |
GET /api/v1/notes/:id | DrizzleD1Datasource read through withCache (KV), ApiError.notFound (404) |
DELETE /api/v1/notes/:id | c.respondNoContent() (204) |
POST /api/v1/tenant-notes | Module middleware resolving the tenant from X-Tenant-Id (401 without it), scopedInsert |
GET /api/v1/tenant-notes?page=&limit= | scopedSelect with orderBy/limit/offset, scopedCount, c.respondPaginated |
GET /api/v1/boom | An unexpected Error, masked as 500 INTERNAL_ERROR because NODE_ENV=production |
GET /health | Built-in health check |
GET /openapi.json, GET /docs | Generated OpenAPI document and Swagger UI |
| anything else | JSON 404 NOT_FOUND envelope |
Files
examples/basic-worker/
├── migrations/
│ ├── 0001_create_notes.sql
│ └── 0002_create_tenant_notes.sql
├── src/
│ ├── db-schema.ts Drizzle tables mirroring the migrations
│ ├── index.ts createApp + defineModule + fromHono
│ ├── note-datasource.ts DrizzleD1Datasource with a KV-cached read
│ ├── note-routes.ts OpenAPIRoute classes for notes
│ └── tenant-note-routes.ts tenant datasource, tenant middleware, paginated list
└── wrangler.jsonc local-only DB (D1) and KV bindings, nodejs_compat
Configuration
{
"name": "cwb-basic-worker",
"main": "src/index.ts",
"compatibility_date": "2026-01-14",
"compatibility_flags": ["nodejs_compat"],
"vars": {
// production mode: 5xx messages are masked in error responses
"NODE_ENV": "production",
"LOG_LEVEL": "warn",
"SERVICE_NAME": "cwb-basic-worker",
},
"kv_namespaces": [{"binding": "KV", "id": "00000000000000000000000000000000"}],
"d1_databases": [
{
"binding": "DB",
"database_name": "cwb-basic-worker",
"database_id": "00000000-0000-0000-0000-000000000000",
"migrations_dir": "migrations",
},
],
}
The binding ids are placeholders: the example is never deployed, and wrangler dev uses local D1 and KV.
The app
Two modules share version 1 under /api. The tenant module carries its own middleware, so the tenant check never runs for the plain notes routes.
import {fromHono} from 'chanfana';
import {createApp, defineModule} from '@ltv/cwb';
import {Boom, NoteCreate, NoteDelete, NoteGet} from './note-routes';
import {resolveTenant, TenantNoteCreate, TenantNoteList} from './tenant-note-routes';
const notes = defineModule({
version: 1,
routes: {
'POST /notes': NoteCreate,
'GET /notes/:id': NoteGet,
'DELETE /notes/:id': NoteDelete,
'GET /boom': Boom,
},
});
// Multi-tenant module: its middleware resolves the tenant for these routes only
const tenantNotes = defineModule({
version: 1,
middleware: [resolveTenant],
routes: {
'POST /tenant-notes': TenantNoteCreate,
'GET /tenant-notes': TenantNoteList,
},
});
const app = createApp({
path: '/api',
healthCheck: true,
modules: [notes, tenantNotes],
// OpenAPI document at /openapi.json, Swagger UI at /docs
openApi: (hono) => fromHono(hono, {docs_url: '/docs'}),
});
export default app;
Notes: D1 with a KV-cached read
export class NoteDatasource extends DrizzleD1Datasource<typeof schema> {
readonly name = 'notes';
constructor(c: Context) {
super(c, {schema});
}
create(title: string): Promise<schema.Note> {
return this.db.insert(schema.notes).values({title}).returning().get();
}
/** Cached for 60s under `notes:byId:{hash({id})}`; `null` (not found) is cached too */
findById(id: number): Promise<schema.Note | null> {
return this.withCache(
'byId',
{id},
async () =>
(await this.db.query.notes.findFirst({
where: eq(schema.notes.id, id),
})) ?? null,
{ttl: 60}
);
}
async remove(id: number): Promise<void> {
await this.db.delete(schema.notes).where(eq(schema.notes.id, id)).run();
}
}
- Returning
nullinstead ofundefinedfor a missing row matters:nullis cached,undefinedis never cached. removedoesn't invalidate the cached read, so a deleted note can still be served from KV for up to 60 seconds. For data that must disappear quickly, callthis.clear()after the delete (it clears the wholenotesnamespace, within the KV operation budget) or skip caching. See KV caching.
The routes validate input with zod and use the envelope helpers:
const NoteSchema = z.object({id: z.number().int(), title: z.string()});
const NoteParams = z.object({id: z.coerce.number().int().positive()});
export class NoteCreate extends OpenAPIRoute {
schema = {
tags: ['Notes'],
request: {body: contentJson(z.object({title: z.string().min(1).max(200)}))},
responses: jsonCreateResponses('Note', NoteSchema),
};
async handle(c: Context) {
const {body} = await this.getValidatedData<typeof this.schema>();
const note = await new NoteDatasource(c).create(body.title);
return c.respondCreated(note);
}
}
export class NoteGet extends OpenAPIRoute {
schema = {
tags: ['Notes'],
request: {params: NoteParams},
responses: jsonCrudResponses('Note', NoteSchema),
};
async handle(c: Context) {
const {params} = await this.getValidatedData<typeof this.schema>();
const note = await new NoteDatasource(c).findById(params.id);
if (!note) throw ApiError.notFound('Note');
return c.respond(note);
}
}
/** GET /api/v1/boom — an unexpected error; its message is masked in production */
export class Boom extends OpenAPIRoute {
schema = {tags: ['Diagnostics'], responses: jsonInternalError()};
handle(): Response {
throw new Error('internal detail: table "notes" is locked by host db-7');
}
}
Tenant notes: scoped queries and pagination
export class TenantNoteDatasource extends DrizzleD1TenantDatasource<typeof schema> {
readonly name = 'tenant-notes';
constructor(c: Context) {
super(c, {schema});
}
create(title: string): Promise<schema.TenantNote[]> {
return this.scopedInsert(schema.tenantNotes, {title}).returning();
}
async list(page: number, limit: number): Promise<{rows: schema.TenantNote[]; total: number}> {
const [rows, total] = await Promise.all([
this.scopedSelect(schema.tenantNotes, {
orderBy: desc(schema.tenantNotes.id),
limit,
offset: (page - 1) * limit,
}),
this.scopedCount(schema.tenantNotes),
]);
return {rows, total};
}
}
/** Module middleware (demo only; use real authentication in production) */
export const resolveTenant: MiddlewareHandler = async (c, next) => {
const tenantId = c.req.header('x-tenant-id');
if (!tenantId || !/^[a-z0-9-]{1,64}$/.test(tenantId)) {
throw ApiError.unauthorized('X-Tenant-Id header required');
}
(c as Context<{Variables: {tenantId: string}}>).set('tenantId', tenantId);
await next();
};
Taking the tenant from a client-supplied X-Tenant-Id header lets any caller read any tenant. In a real API, derive the tenant from verified credentials. See Multi-tenancy.
The list route validates page and limit in the schema, so respondPaginated never receives invalid input:
export class TenantNoteList extends OpenAPIRoute {
schema = {
tags: ['Tenant notes'],
request: {
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
},
responses: jsonListResponses('Tenant notes', TenantNoteSchema),
};
async handle(c: Context) {
const {query} = await this.getValidatedData<typeof this.schema>();
const {rows, total} = await new TenantNoteDatasource(c).list(query.page, query.limit);
return c.respondPaginated(rows, {page: query.page, limit: query.limit, total});
}
}
jsonListResponses (like jsonPaginated) wraps the item schema in z.array() itself, so pass the schema of a single item, not z.array(...).
Run it
From the root of the library repository:
bun run build # the example imports the built package
cd examples/basic-worker
bunx wrangler d1 migrations apply DB --local # create the tables in local D1
bunx wrangler dev
curl -X POST localhost:8787/api/v1/notes -H 'content-type: application/json' -d '{"title":"hello"}'
curl localhost:8787/api/v1/notes/1
curl -X POST localhost:8787/api/v1/tenant-notes -H 'x-tenant-id: acme' -H 'content-type: application/json' -d '{"title":"hi"}'
curl 'localhost:8787/api/v1/tenant-notes?page=1&limit=10' -H 'x-tenant-id: acme'
curl localhost:8787/api/v1/boom
The example has no package.json: @ltv/cwb and @ltv/cwb/datasources resolve through the root package's self-reference, so wrangler bundles dist/ exactly as a consumer receives it. In your own project, install the package as described in Installation.
End-to-end tests
bun run test:e2e builds the package and runs the example through wrangler's createTestHarness() under Node's test runner. The tests apply the D1 migrations, check the envelopes, prove that a second read is served from KV by changing the row directly in D1, and check that tenants only see their own rows and counts. See Testing your app for the same approach in your project.