@cleverbrush/server
Schema-first HTTP server for Node.js β typed endpoints, content negotiation, DI integration, and auth wiring out of the box.
π‘ Why @cleverbrush/server?
The Problem
Most Node.js frameworks leave request validation, dependency injection, and authentication as separate concerns that you wire together yourself. The result is boilerplate: parse the body, validate it, inject services, check auth β before the handler even runs.
The Solution
@cleverbrush/server integrates schema validation, DI, and authentication into a single fluent endpoint builder. You declare what an endpoint expects and the framework handles the rest β fully typed, no decorators, no magic strings.
Key Features
- Fluent endpoint builder β
.body(),.query(),.headers(),.inject(),.authorize(): all type-safe. - Action results β
ActionResult.ok(),.created(),.file(),.stream(): no manualres.end(). - Content negotiation β honours the
Acceptheader; pluggable handlers. - RFC 9457 Problem Details β validation errors and HTTP errors become structured JSON automatically.
- OpenAPI-ready β
getRegistrations()feeds@cleverbrush/server-openapifor spec generation.
Quick Start
import { createServer, 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 = createServer();
server.handle(createUser, ({ body }) => {
// body is typed: { name: string; age: number }
return ActionResult.created({ id: 1, ...body }, '/api/users/1');
});
await server.listen(3000);
console.log('Listening on http://localhost:3000');Defining Endpoints
Use the endpoint singleton to start a builder chain. Chain .body(), .query(), .headers(), and .authorize() to build a fully typed handler context:
import { endpoint } from '@cleverbrush/server';
import { object, string, number } from '@cleverbrush/schema';
const UserPrincipal = object({ sub: string(), role: string() });
const GetUser = endpoint
.get('/api/users')
.query(object({ id: number().coerce() }))
.authorize(UserPrincipal, 'admin')
.returns(object({ id: number(), name: string() }))
.summary('Get a user by ID')
.tags('users');
server.handle(GetUser, ({ query, principal }) => {
// query.id β number (coerced from URL string)
// principal β { sub: string; role: string }
return { id: query.id, name: 'Alice' };
});Type-Safe Path Parameters
Use the route() helper to define path parameters using @cleverbrush/schema types. Parameters are parsed and validated before the handler runs:
import { endpoint, route } from '@cleverbrush/server';
import { number } from '@cleverbrush/schema';
const GetUser = endpoint.get(
'/api/users',
route({ id: number().coerce() })`/${t => t.id}`
);
server.handle(GetUser, ({ params }) => {
params.id; // number β already coerced from the URL segment
return { id: params.id };
});Action Results
Return an ActionResult from any handler for full control over status codes, headers, and body serialization:
import { ActionResult } from '@cleverbrush/server';
// 200 with content negotiation
return ActionResult.ok(user);
// 201 Created with Location header
return ActionResult.created(user, '/api/users/42');
// 204 No Content
return ActionResult.noContent();
// 302 Redirect
return ActionResult.redirect('/login');
// File download
return ActionResult.file(pdfBuffer, 'report.pdf', 'application/pdf');
// Bare status code
return ActionResult.status(202);File Upload
Accept file uploads via multipart/form-data by chaining .upload() on an endpoint. File fields are received as FilePart objects on the handler context's files property; non-file form fields are validated against the body schema and available via body.
import { endpoint, type FilePart } from '@cleverbrush/server';
import { object, string } from '@cleverbrush/schema';
const UserPrincipal = object({ sub: string(), role: string() });
const UploadAvatar = endpoint
.post('/api/avatar')
.upload({
maxFileSize: 2 * 1024 * 1024,
allowedMimeTypes: ['image/*']
})
.body(object({ description: string().optional() }))
.authorize(UserPrincipal);
server.handle(UploadAvatar, ({ body, files }) => {
const avatar: FilePart = files['avatar'];
// avatar.filename, avatar.mimeType, avatar.buffer, avatar.size
return ActionResult.created({ name: avatar.filename });
});Options
| 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 |
FilePart type
interface FilePart {
readonly filename: string;
readonly mimeType: string;
readonly buffer: Buffer;
readonly size: number;
}HTTP Errors
Throw any HttpError subclass from a handler. The server automatically serializes it as an RFC 9457 Problem Details response:
import {
NotFoundError,
BadRequestError,
ForbiddenError,
ConflictError
} 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;
});
// Response: 404 application/problem+json
// { "type": "https://httpstatuses.com/404", "status": 404, "title": "Not Found", "detail": "User 99 not found" }Middleware
Add global middleware with server.use() or per-endpoint middleware via the third argument to handle():
import type { Middleware } from '@cleverbrush/server';
const logger: Middleware = async (ctx, next) => {
const start = Date.now();
await next();
console.log(`${ctx.method} ${ctx.url.pathname} ${Date.now() - start}ms`);
};
server.use(logger);
// Per-endpoint
server.handle(AdminEp, handler, { middlewares: [rateLimiter] });Dependency Injection
Use .inject() to declare per-request services. They are resolved from the @cleverbrush/di container and passed as the second handler argument:
import { ServiceCollection } from '@cleverbrush/di';
import { object, number } from '@cleverbrush/schema';
import { UserRepository } from './UserRepository';
// Schemas are immutable β they work as safe, typed DI keys.
// .hasType() brands the schema with the real class type.
const IUserRepo = object().hasType<typeof UserRepository>();
const GetUser = endpoint
.get('/api/users')
.query(object({ id: number().coerce() }))
.inject({ repo: IUserRepo });
server
.services(svc => svc.addSingleton(IUserRepo, () => new UserRepository()))
.handle(GetUser, ({ query }, { repo }) => {
// repo is typed as UserRepository β full autocomplete & type safety
return repo.findById(query.id);
});Authentication & Authorization
Wire @cleverbrush/auth schemes and policies through useAuthentication() and useAuthorization():
import { jwtScheme } from '@cleverbrush/auth';
import { endpoint, route } from '@cleverbrush/server';
import { number } from '@cleverbrush/schema';
server
.useAuthentication({
defaultScheme: 'jwt',
schemes: [
jwtScheme({
secret: process.env.JWT_SECRET!,
mapClaims: claims => ({
sub: claims.sub as string,
role: claims.role as string
})
})
]
})
.useAuthorization();
// Protect an endpoint
const AdminEp = endpoint
.delete('/api/users', route({ id: number().coerce() })`/${t => t.id}`)
.authorize(UserPrincipal, 'admin');WebSocket Subscriptions
Define real-time endpoints with endpoint.subscription(). Handlers are async generators β yield events to push them to the client, and consume the incoming iterable for bidirectional messaging.
Contract
import { endpoint, defineApi } from '@cleverbrush/server/contract';
import { object, string, number } from '@cleverbrush/schema';
export const api = defineApi({
live: {
// Server-push only
events: endpoint
.subscription('/ws/events')
.outgoing(object({ action: string(), id: number() })),
// Bidirectional
chat: endpoint
.subscription('/ws/chat')
.incoming(object({ text: string() }))
.outgoing(object({ user: string(), text: string(), ts: number() })),
},
});Handler β Server Push
import type { SubscriptionHandler } from '@cleverbrush/server';
import { api } from './contract';
const eventsHandler: SubscriptionHandler<typeof api.live.events> =
async function* ({ context, signal }) {
while (!signal.aborted) {
yield { action: 'heartbeat', id: Date.now() };
await new Promise(r => setTimeout(r, 2000));
}
};Handler β Bidirectional
const chatHandler: SubscriptionHandler<typeof api.live.chat> =
async function* ({ incoming }) {
for await (const msg of incoming) {
// Echo back with server timestamp
yield { user: 'server', text: msg.text, ts: Date.now() };
}
};Tracked Events
Wrap events with tracked(id, data) to include a correlation ID for client-side acknowledgement:
import { tracked } from '@cleverbrush/server';
async function* handler({ signal }) {
yield tracked('evt-001', { action: 'created', id: 1 });
yield tracked('evt-002', { action: 'updated', id: 2 });
}AsyncAPI Documentation
Generate an AsyncAPI 3.0 document from your WebSocket subscription registrations β no annotations required. Use serveAsyncApi() from @cleverbrush/server-openapi to serve it as middleware alongside your OpenAPI spec.
Serving the spec
import { serveAsyncApi } from '@cleverbrush/server-openapi';
server
.use(serveAsyncApi({
server,
info: { title: 'My API', version: '1.0.0' },
servers: {
production: { host: 'api.example.com', protocol: 'wss' },
},
}))
.handle(/* ... */);
// GET /asyncapi.json β AsyncAPI 3.0 documentCustom path & programmatic use
import { generateAsyncApiSpec } from '@cleverbrush/server-openapi';
// Serve at a custom path
server.use(serveAsyncApi({
server,
info: { title: 'My API', version: '1.0.0' },
path: '/docs/asyncapi.json',
}));
// Or generate programmatically (e.g. write to file):
const spec = generateAsyncApiSpec({
subscriptions: server.getSubscriptionRegistrations(),
info: { title: 'My API', version: '1.0.0' },
});
await fs.writeFile('asyncapi.json', JSON.stringify(spec, null, 2));Each subscription is emitted as a channel with its address, and one or two operations: a send operation for serverβclient events and a receive operation for clientβserver messages. Named schemas (set via .schemaName()) are automatically collected into components.schemas with $ref pointers.
Contract Composition
When an application has distinct audiences β a public client and an admin panel, for example β you want each consumer to import only the endpoints it needs. This keeps admin schemas out of the client bundle and improves tree-shaking.
Import the composition utilities from @cleverbrush/server/contract.
mergeContracts
Combine two contracts into one. Groups that share a key have their endpoint maps shallowly merged; unique groups are passed through unchanged.
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 uses publicApi directly
// TypeScript sees: { todos, auth } β admin is absent from the bundlepickGroups
Select a subset of groups. The TypeScript return type is Pick<T, K> β the compiler sees exactly the selected groups and no others.
import { pickGroups } from '@cleverbrush/server/contract';
const fullApi = defineApi({
todos: { ... },
auth: { ... },
admin: { ... },
debug: { ... },
});
const clientApi = pickGroups(fullApi, 'todos', 'auth');
// TypeScript: { todos: ..., auth: ... }
// 'admin' and 'debug' do not exist on the type or at runtimeomitGroups
Inverse of pickGroups β remove the listed groups and keep everything else. Return type is Omit<T, K>.
import { omitGroups } from '@cleverbrush/server/contract';
const publicApi = omitGroups(fullApi, 'admin', 'debug');
// TypeScript: { todos: ..., auth: ... }Bundle isolation pattern
The key is file-level separation: export different slices from different entry points so bundlers only pull in what each app imports.
// shared-contracts/src/public.ts
export const publicApi = defineApi({ todos, auth, users });
// shared-contracts/src/admin.ts
export const adminApi = defineApi({ admin, internalTools });
// shared-contracts/src/full.ts
export const fullApi = mergeContracts(publicApi, adminApi);
// client app
import { publicApi } from 'shared-contracts/public'; // admin never bundled
// admin panel
import { fullApi } from 'shared-contracts/full'; // full set
// backend
import { fullApi } from 'shared-contracts/full'; // handles all routes