@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 };
});

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

Reference table
OptionTypeDefaultDescription
maxFileSizenumber10 MBMaximum file size per file in bytes
allowedMimeTypesstring[]allMIME type allowlist (supports image/* glob)
maxFileCountnumber10Maximum 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 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.

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 bundle

pickGroups

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 runtime

omitGroups

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