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.
endpoint.get('/users').body(schema).query(schema).authorize() with fully typed handler context.ActionResult.ok(), .created(), .noContent(), .redirect(), .file(), .stream(), .status() — no manual res.write() / res.end().ContentTypeHandler registry; JSON registered by default; honours the Accept request header.server.use(middleware) for global middleware; per-endpoint middleware via handle(ep, handler, { middlewares }).endpoint.inject({ db: IDbContext }) resolves services per-request from a @cleverbrush/di container.server.useAuthentication() / server.useAuthorization() wired to @cleverbrush/auth schemes and policies.HttpError subclasses are serialized as application/problem+json.route() builds typed path parameters using ParseStringSchemaBuilder segments.getRegistrations() exposes endpoint metadata for @cleverbrush/server-openapi.getSubscriptionRegistrations() exposes subscription metadata for generateAsyncApiSpec() in @cleverbrush/server-openapi./health endpoint via server.withHealthcheck().endpoint.subscription('/ws/path') with typed incoming/outgoing schemas, tracked() events, and async generator handlers.mergeContracts, pickGroups, and omitGroups enable audience-scoped bundles: ship only the endpoints each consumer needs.npm install @cleverbrush/server @cleverbrush/schema
import { 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');
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 |
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.
mergeContractsCombine 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 bundle
pickGroupsReturns 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 runtime
omitGroupsInverse 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.