A schema-first HTTP server framework for Node.js. Combines @cleverbrush/schema for request validation, @cleverbrush/di for dependency injection, and @cleverbrush/auth for authentication — all wired together through a fluent builder API.
- Fluent endpoint builder —
endpoint.get('/users').body(schema).query(schema).authorize()with fully typed handler context. - Action results —
ActionResult.ok(),.created(),.noContent(),.redirect(),.file(),.stream(),.raw(),.status()— no manualres.write()/res.end()unless you explicitly opt in. - Content negotiation — pluggable
ContentTypeHandlerregistry; JSON andapplication/x-www-form-urlencodedregistered by default; honours theAcceptrequest header. - Middleware pipeline —
server.use(middleware)for global middleware; per-endpoint middleware viahandle(ep, handler, { middlewares }). - DI integration —
endpoint.inject({ db: IDbContext })resolves services per-request from a@cleverbrush/dicontainer. - Authentication & authorization —
server.useAuthentication()/server.useAuthorization()wired to@cleverbrush/authschemes and policies. - RFC 9457 Problem Details — validation errors and
HttpErrorsubclasses are serialized asapplication/problem+json. - Type-safe routes —
route()builds typed path parameters usingParseStringSchemaBuildersegments. - OpenAPI-ready —
getRegistrations()exposes endpoint metadata for@cleverbrush/server-openapi. - AsyncAPI-ready —
getSubscriptionRegistrations()exposes subscription metadata forgenerateAsyncApiSpec()in@cleverbrush/server-openapi. - Health check — optional
/healthendpoint viaserver.withHealthcheck(). - WebSocket subscriptions —
endpoint.subscription('/ws/path')with typed incoming/outgoing schemas,tracked()events, and async generator handlers. - Contract composition —
mergeContracts,pickGroups, andomitGroupsenable audience-scoped bundles: ship only the endpoints each consumer needs.
npm install @cleverbrush/server @cleverbrush/schemaimport { ServerBuilder, endpoint, ActionResult } from '@cleverbrush/server';
import { object, string, number } from '@cleverbrush/schema';
const CreateUserBody = object({ name: string(), age: number() });
const createUser = endpoint
.post('/api/users')
.body(CreateUserBody);
const server = new ServerBuilder();
server.handle(createUser, ({ body }) => {
// body is fully typed: { name: string; age: number }
return ActionResult.created({ id: 1, ...body }, '/api/users/1');
});
await server.listen(3000);Use the endpoint singleton to start a builder chain:
import { endpoint } from '@cleverbrush/server';
const getUser = endpoint.get('/api/users/:id');
const postUser = endpoint.post('/api/users');
const putUser = endpoint.put('/api/users/:id');
const delUser = endpoint.delete('/api/users/:id');Attach schemas for body, query string, and headers. Validation errors automatically produce a 400 Problem Details response.
import { object, string, number } from '@cleverbrush/schema';
const ListUsers = endpoint
.get('/api/users')
.query(object({ page: number().coerce().optional(), search: string().optional() }));
const CreateUser = endpoint
.post('/api/users')
.body(object({ name: string(), email: string() }));Use route() to define path parameters with the full schema type system:
import { route } from '@cleverbrush/server';
import { number } from '@cleverbrush/schema';
const GetUser = endpoint.get(
route({ id: number().coerce() })`/api/users/${t => t.id}`
);
server.handle(GetUser, ({ params }) => {
params.id; // number (already coerced from the URL)
});When several route schemas validate the same URL, the server selects the most specific route independently of registration order. Exact static routes win, followed by routes with more literal path segments and then fewer dynamic segments. Registration order only breaks ties between equally specific routes. The same precedence applies to WebSocket subscriptions.
import { object, string } from '@cleverbrush/schema';
const UserPrincipal = object({ sub: string(), role: string() });
// Any authenticated user
const ProtectedEp = endpoint.get('/api/profile').authorize(UserPrincipal);
// Specific roles
const AdminEp = endpoint.delete('/api/users/:id').authorize(UserPrincipal, 'admin');const CreateUser = endpoint
.post('/api/users')
.body(CreateUserBody)
.returns(UserSchema)
.summary('Create a new user')
.description('Creates a user and returns the full record.')
.tags('users')
.operationId('createUser');Tag-based cache invalidation. Tags declared on endpoints flow to the
cacheTags middleware for automatic HTTP caching and
invalidation on mutating requests.
const ListTodos = endpoint
.get('/api/todos')
.query(TodoListQuerySchema)
.cacheTag('todo-list', p => ({ page: p.query.page, limit: p.query.limit }))
.returns(array(TodoSchema));
const UpdateTodo = endpoint
.patch('/api/todos/:id')
.body(UpdateTodoBody)
.clearsCacheTag('todo-list') // clears the collection cache
.clearsCacheTag('todo', p => ({ id: p.params.id })) // clears specific entity
.returns(TodoSchema);.cacheTag(name)— declares the endpoint's data belongs to a cache group. Use on GET endpoints..clearsCacheTag(name)— declares that this mutation clears matching cache entries on success. Use on POST / PUT / PATCH / DELETE..cacheTag(name, p => ({ ... }))— property-based tag; each selected property becomes part of the cache key (different pages → different entries).- Immutability — both methods return a new builder; the original is unchanged.
const server = new ServerBuilder();
server.handle(CreateUser, ({ body, context }) => {
return ActionResult.created({ id: 42, ...body }, `/api/users/42`);
});
// Per-endpoint middleware
server.handle(AdminEp, ({ params }) => { /* … */ }, {
middlewares: [loggingMiddleware]
});
await server.listen(3000);| Method | Status | Notes |
|---|---|---|
ActionResult.ok(body) |
200 | Content-negotiated JSON |
ActionResult.created(body, location?) |
201 | Sets Location header |
ActionResult.noContent() |
204 | No body |
ActionResult.redirect(url, permanent?) |
302 / 301 | |
ActionResult.json(body, status?) |
any | Forces application/json |
ActionResult.file(buffer, fileName) |
200 | Attachment download |
ActionResult.content(body, contentType) |
200 | Arbitrary string body |
ActionResult.stream(readable, contentType) |
200 | Pipes a Readable |
ActionResult.raw(handler) |
custom | Native Node req / res escape hatch |
ActionResult.status(status) |
any | Bare status, no body |
Use ActionResult.raw() when integrating a library that already writes to
Node's http.ServerResponse:
server.handle(WebhookEndpoint, () =>
ActionResult.raw(async (req, res) => {
await thirdPartyWebhookHandler(req, res);
})
);application/x-www-form-urlencoded request bodies are parsed by default and
validated against the endpoint body schema. Repeated fields become arrays:
// tag=work&tag=travel&title=Trip
// → { tag: ['work', 'travel'], title: 'Trip' }Accept file uploads via multipart/form-data by chaining .upload() on an endpoint:
import { endpoint } from '@cleverbrush/server';
import { object, string } from '@cleverbrush/schema';
const UploadAvatar = endpoint
.post('/api/avatar')
.upload({ maxFileSize: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] })
.body(object({ description: string().optional() }))
.authorize(UserPrincipal);
const handler: Handler<typeof UploadAvatar> = async ({ body, files }) => {
const avatar = files['avatar'];
// avatar: FilePart { filename, mimeType, buffer, size }
return ActionResult.created({ name: avatar.filename });
};The files object on the handler context contains one FilePart entry per uploaded file field. Non-file form fields are validated against the body schema and available via body.
| Option | Type | Default | Description |
|---|---|---|---|
maxFileSize |
number |
10 MB | Maximum file size per file in bytes |
allowedMimeTypes |
string[] |
all | MIME type allowlist (supports image/* glob) |
maxFileCount |
number |
10 | Maximum number of files per request |
interface FilePart {
readonly filename: string;
readonly mimeType: string;
readonly buffer: Buffer;
readonly size: number;
}import type { Middleware } from '@cleverbrush/server';
const logger: Middleware = async (ctx, next) => {
console.log(ctx.method, ctx.url.pathname);
await next();
};
server.use(logger);import { ServiceCollection } from '@cleverbrush/di';
import { object, func, string } from '@cleverbrush/schema';
const IUserRepo = object({ findById: func() });
const GetUser = endpoint
.get('/api/users/:id')
.inject({ repo: IUserRepo });
server
.services(svc => svc.addSingleton(IUserRepo, () => new UserRepository()))
.handle(GetUser, ({ params }, { repo }) => {
return repo.findById(params.id);
});import { jwtScheme } from '@cleverbrush/auth';
server.useAuthentication({
defaultScheme: 'jwt',
schemes: [
jwtScheme({
secret: process.env.JWT_SECRET!,
mapClaims: claims => ({ sub: claims.sub as string, role: claims.role as string })
})
]
});
server.useAuthorization();By default, only defaultScheme is attempted. Use trySchemes when an app
accepts multiple credential types on the same endpoints:
server.useAuthentication({
defaultScheme: 'cookie',
schemes: [cookieScheme(options), jwtScheme(options)],
trySchemes: ['cookie', 'jwt'] // or 'all'
});Throw any HttpError subclass from a handler — it becomes a Problem Details response automatically:
import { NotFoundError, BadRequestError, ForbiddenError } from '@cleverbrush/server';
server.handle(GetUser, ({ params }) => {
const user = db.find(params.id);
if (!user) throw new NotFoundError(`User ${params.id} not found`);
return user;
});| Class | Status |
|---|---|
BadRequestError |
400 |
UnauthorizedError |
401 |
ForbiddenError |
403 |
NotFoundError |
404 |
ConflictError |
409 |
HttpError |
any (base class) |
Define real-time endpoints using endpoint.subscription():
import { endpoint, tracked } from '@cleverbrush/server';
import { object, string, number } from '@cleverbrush/schema';
// Server-push subscription
const liveUpdates = endpoint
.subscription('/ws/updates')
.outgoing(object({ action: string(), id: number() }))
.summary('Live updates');
// Bidirectional subscription
const chat = endpoint
.subscription('/ws/chat')
.incoming(object({ text: string() }))
.outgoing(object({ user: string(), text: string(), ts: number() }))
.authorize('user');Handlers are async generators that yield outgoing events:
import type { SubscriptionHandler } from '@cleverbrush/server';
const handler: SubscriptionHandler<typeof liveUpdates> = async function* () {
while (true) {
await new Promise(r => setTimeout(r, 1000));
yield { action: 'tick', id: Date.now() };
}
};
// Bidirectional — read from `incoming` async iterable
const chatHandler: SubscriptionHandler<typeof chat> = async function* ({ incoming, principal }) {
yield { user: 'system', text: `${principal.name} joined`, ts: Date.now() };
for await (const msg of incoming) {
yield { user: principal.name, text: msg.text, ts: Date.now() };
}
};Use tracked(id, data) to send events with a unique ID for client-side deduplication:
yield tracked('evt-123', { action: 'created', id: 42 });// Direct subscription
const sub = client.live.updates();
for await (const event of sub) {
console.log(event); // typed as { action: string, id: number }
}
// Send messages (bidirectional)
const chat = client.live.chat();
chat.send({ text: 'hello' });import { useSubscription } from '@cleverbrush/client/react';
function LiveFeed() {
const { events, state, send, close } = useSubscription(
() => client.live.updates(),
{ maxEvents: 100 }
);
return (
<div>
<p>Status: {state}</p>
{events.map((e, i) => <div key={i}>{e.action} #{e.id}</div>)}
</div>
);
}Enable the server-side batch endpoint so the client can coalesce many concurrent requests into a single HTTP round-trip.
import { createServer } from '@cleverbrush/server';
const server = await createServer()
.useBatching() // enables POST /__batch
.handleAll(mapping)
.listen(3000);The batch endpoint (POST /__batch by default) accepts a JSON body:
{
"requests": [
{ "method": "GET", "url": "/api/todos", "headers": { "authorization": "Bearer ..." } },
{ "method": "POST", "url": "/api/todos", "headers": { "content-type": "application/json", "authorization": "Bearer ..." }, "body": "{\"title\":\"Buy milk\"}" }
]
}Each sub-request is processed through the full middleware and handler pipeline (including auth and DI) in parallel by default. The response is:
{
"responses": [
{ "status": 200, "headers": { "content-type": "application/json" }, "body": "[{\"id\":1}]" },
{ "status": 201, "headers": { "content-type": "application/json" }, "body": "{\"id\":2,\"title\":\"Buy milk\"}" }
]
}One sub-request failing returns its error status in its own slot — the rest succeed normally.
| Option | Type | Default | Description |
|---|---|---|---|
path |
string |
'/__batch' |
URL path for the batch endpoint |
maxSize |
number |
20 |
Maximum sub-requests per batch (400 if exceeded) |
parallel |
boolean |
true |
Run sub-requests in parallel (false for sequential) |
createServer()
.useBatching({ path: '/_batch', maxSize: 50, parallel: false })
.handleAll(mapping)
.listen(3000);When building applications with distinct audiences — a public client and an admin panel, for example — you want each consumer to import only the endpoints it needs. This eliminates leaking admin schemas into the client bundle and improves tree-shaking.
The @cleverbrush/server/contract entry point ships three utilities for this.
Combine two ApiContract objects into one. Groups that only exist in one contract are kept as-is; groups that share a key have their endpoint maps shallowly merged.
import { defineApi, mergeContracts } from '@cleverbrush/server/contract';
// public-api.ts — safe to import in every consumer
export const publicApi = defineApi({
todos: { list: ..., get: ..., create: ... },
auth: { login: ..., register: ... },
});
// admin-api.ts — only imported by the admin application
const adminApi = defineApi({
admin: { activityLog: ..., banUser: ... },
});
// admin-app/contract.ts
export const fullAdminApi = mergeContracts(publicApi, adminApi);
// TypeScript sees: { todos, auth, admin } — all fully typed
// client-app/contract.ts
import { publicApi } from '../shared/public-api';
// TypeScript sees: { todos, auth } — admin is absent from the bundleReturns a new contract containing only the listed groups. The TypeScript return type is Pick<T, K> — the compiler sees exactly the selected groups.
import { pickGroups } from '@cleverbrush/server/contract';
const fullApi = defineApi({ todos: {...}, auth: {...}, admin: {...}, debug: {...} });
// Only expose what the frontend needs
const clientApi = pickGroups(fullApi, 'todos', 'auth');
// TypeScript: { todos: ..., auth: ... }
// 'admin' and 'debug' do not exist on the type or at runtimeInverse of pickGroups — strips the listed groups and keeps everything else. Return type is Omit<T, K>.
import { omitGroups } from '@cleverbrush/server/contract';
const publicApi = omitGroups(fullApi, 'admin', 'debug');
// TypeScript: { todos: ..., auth: ... }The key to keeping admin endpoints out of the client bundle is file-level separation. Export different slices from different entry points:
packages/
shared-contracts/
src/
public.ts // export const publicApi = defineApi({ ... })
admin.ts // export const adminApi = defineApi({ ... })
full.ts // export const fullApi = mergeContracts(publicApi, adminApi)
apps/
client/ // imports publicApi — admin endpoints never bundled
admin-panel/ // imports fullApi — full set of endpoints
backend/ // imports fullApi — handles all routes
BSD-3-Clause — see LICENSE.