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(),.status()— no manualres.write()/res.end(). - Content negotiation — pluggable
ContentTypeHandlerregistry; JSON registered 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)
});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.status(status) |
any | Bare status, no body |
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();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.