Schema-first HTTP server for Node.js β typed endpoints, content negotiation, DI integration, and auth wiring out of the box.
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.
@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.
.body(), .query(), .headers(), .inject(), .authorize(): all type-safe.ActionResult.ok(), .created(), .file(), .stream(): no manual res.end().Accept header; pluggable handlers.getRegistrations() feeds @cleverbrush/server-openapi for spec generation.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');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' };
});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 };
});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);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 });
});| 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;
}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" }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] });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);
});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');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.
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() })),
},
});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));
}
};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() };
}
};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 });
}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.
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 documentimport { 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.
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.
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 bundleSelect 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 runtimeInverse 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: ... }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