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);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.