@cleverbrush/server

Schema-first HTTP server for Node.js β€” typed endpoints, content negotiation, DI integration, and auth wiring out of the box.

$npm install @cleverbrush/server @cleverbrush/schema

Pair with @cleverbrush/di for dependency injection and @cleverbrush/auth for authentication.

πŸ’‘ 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 manual res.end().
  • Content negotiation β€” honours the Accept header; pluggable handlers.
  • RFC 9457 Problem Details β€” validation errors and HTTP errors become structured JSON automatically.
  • OpenAPI-ready β€” getRegistrations() feeds @cleverbrush/server-openapi for 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);

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 document

Custom 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.