@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 };
});If several route schemas validate the same URL, the server selects the most specific route regardless of registration order. Static routes win, followed by routes with more literal path segments and then fewer dynamic segments. Registration order only breaks ties; WebSocket subscriptions use the same precedence.
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');
// Native Node req/res integration
return ActionResult.raw(async (req, res) => {
await webhookHandler(req, res);
});
// Bare status code
return ActionResult.status(202);URL-Encoded Bodies
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
// Parsed body:
{
tag: ['work', 'travel'],
title: 'Trip',
}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
})
})
],
// Optional: try multiple registered schemes in order.
trySchemes: 'all'
})
.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