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), then c.var.ds.tenantNotes (created after the tenant is set) and 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 |
Handlers reach their datasources through the datasource registry, c.var.ds.notes and c.var.ds.tenantNotes: each is created on first use and reused for the rest of the request.
Files
examples/basic-worker/
├── migrations/
│ ├── 0001_create_notes.sql
│ └── 0002_create_tenant_notes.sql
├── src/
│ ├── datasources.ts defineDatasources + DatasourceRegistry augmentation (c.var.ds)
│ ├── db-schema.ts Drizzle tables mirroring the migrations
│ ├── index.ts createApp (with datasources) + defineModule + fromHono
│ ├── note-datasource.ts DrizzleD1Datasource with a KV-cached read
│ ├── note-routes.ts OpenAPIRoute classes for notes
│ ├── tenant-note-datasource.ts DrizzleD1TenantDatasource with scoped helpers
│ └── tenant-note-routes.ts tenant middleware, create and paginated list routes
├── tsconfig.json own TypeScript project (one DatasourceRegistry augmentation per program)
└── 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 {datasources} from './datasources';
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,
// `c.var.ds.notes` / `c.var.ds.tenantNotes` in every handler
datasources,
modules: [notes, tenantNotes],
// OpenAPI document at /openapi.json, Swagger UI at /docs
openApi: (hono) => fromHono(hono, {docs_url: '/docs'}),
});
export default app;
The datasources are declared once, with their types inferred from the factories:
import {defineDatasources, type InferDatasources} from '@ltv/cwb';
import {NoteDatasource} from './note-datasource';
import {TenantNoteDatasource} from './tenant-note-datasource';
/**
* One factory per datasource, available as `c.var.ds.<name>`. Each runs lazily, the first
* time a request uses it, and the instance is reused for the rest of that request.
*/
export const datasources = defineDatasources({
notes: (c) => new NoteDatasource(c),
tenantNotes: (c) => new TenantNoteDatasource(c),
});
// Types of `c.var.ds` are inferred from the factories above
declare module '@ltv/cwb' {
interface DatasourceRegistry extends InferDatasources<typeof datasources> {}
}
The example has its own tsconfig.json because a TypeScript program can hold only one DatasourceRegistry augmentation, and the library's tests declare another.
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 c.var.ds.notes.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 c.var.ds.notes.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 c.var.ds.tenantNotes.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.