Libraries
    Preparing search index...

    Module @cleverbrush/server

    @cleverbrush/server

    CI License: BSD-3-Clause

    A schema-first HTTP server framework for Node.js. Combines @cleverbrush/schema for request validation, @cleverbrush/di for dependency injection, and @cleverbrush/auth for authentication — all wired together through a fluent builder API.

    • Fluent endpoint builderendpoint.get('/users').body(schema).query(schema).authorize() with fully typed handler context.
    • Action resultsActionResult.ok(), .created(), .noContent(), .redirect(), .file(), .stream(), .raw(), .status() — no manual res.write() / res.end() unless you explicitly opt in.
    • Content negotiation — pluggable ContentTypeHandler registry; JSON and application/x-www-form-urlencoded registered by default; honours the Accept request header.
    • Middleware pipelineserver.use(middleware) for global middleware; per-endpoint middleware via handle(ep, handler, { middlewares }).
    • DI integrationendpoint.inject({ db: IDbContext }) resolves services per-request from a @cleverbrush/di container.
    • Authentication & authorizationserver.useAuthentication() / server.useAuthorization() wired to @cleverbrush/auth schemes and policies.
    • RFC 9457 Problem Details — validation errors and HttpError subclasses are serialized as application/problem+json.
    • Type-safe routesroute() builds typed path parameters using ParseStringSchemaBuilder segments.
    • OpenAPI-readygetRegistrations() exposes endpoint metadata for @cleverbrush/server-openapi.
    • AsyncAPI-readygetSubscriptionRegistrations() exposes subscription metadata for generateAsyncApiSpec() in @cleverbrush/server-openapi.
    • Health check — optional /health endpoint via server.withHealthcheck().
    • WebSocket subscriptionsendpoint.subscription('/ws/path') with typed incoming/outgoing schemas, tracked() events, and async generator handlers.
    • Contract compositionmergeContracts, pickGroups, and omitGroups enable audience-scoped bundles: ship only the endpoints each consumer needs.
    npm install @cleverbrush/server @cleverbrush/schema
    
    import { ServerBuilder, 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 = new ServerBuilder();

    server.handle(createUser, ({ body }) => {
    // body is fully typed: { name: string; age: number }
    return ActionResult.created({ id: 1, ...body }, '/api/users/1');
    });

    await server.listen(3000);

    Use the endpoint singleton to start a builder chain:

    import { endpoint } from '@cleverbrush/server';

    const getUser = endpoint.get('/api/users/:id');
    const postUser = endpoint.post('/api/users');
    const putUser = endpoint.put('/api/users/:id');
    const delUser = endpoint.delete('/api/users/:id');

    Attach schemas for body, query string, and headers. Validation errors automatically produce a 400 Problem Details response.

    import { object, string, number } from '@cleverbrush/schema';

    const ListUsers = endpoint
    .get('/api/users')
    .query(object({ page: number().coerce().optional(), search: string().optional() }));

    const CreateUser = endpoint
    .post('/api/users')
    .body(object({ name: string(), email: string() }));

    Use route() to define path parameters with the full schema type system:

    import { route } from '@cleverbrush/server';
    import { number } from '@cleverbrush/schema';

    const GetUser = endpoint.get(
    route({ id: number().coerce() })`/api/users/${t => t.id}`
    );

    server.handle(GetUser, ({ params }) => {
    params.id; // number (already coerced from the URL)
    });

    When several route schemas validate the same URL, the server selects the most specific route independently of registration order. Exact static routes win, followed by routes with more literal path segments and then fewer dynamic segments. Registration order only breaks ties between equally specific routes. The same precedence applies to WebSocket subscriptions.

    import { object, string } from '@cleverbrush/schema';

    const UserPrincipal = object({ sub: string(), role: string() });

    // Any authenticated user
    const ProtectedEp = endpoint.get('/api/profile').authorize(UserPrincipal);

    // Specific roles
    const AdminEp = endpoint.delete('/api/users/:id').authorize(UserPrincipal, 'admin');
    const CreateUser = endpoint
    .post('/api/users')
    .body(CreateUserBody)
    .returns(UserSchema)
    .summary('Create a new user')
    .description('Creates a user and returns the full record.')
    .tags('users')
    .operationId('createUser');

    Tag-based cache invalidation. Tags declared on endpoints flow to the cacheTags middleware for automatic HTTP caching and invalidation on mutating requests.

    const ListTodos = endpoint
    .get('/api/todos')
    .query(TodoListQuerySchema)
    .cacheTag('todo-list', p => ({ page: p.query.page, limit: p.query.limit }))
    .returns(array(TodoSchema));

    const UpdateTodo = endpoint
    .patch('/api/todos/:id')
    .body(UpdateTodoBody)
    .clearsCacheTag('todo-list') // clears the collection cache
    .clearsCacheTag('todo', p => ({ id: p.params.id })) // clears specific entity
    .returns(TodoSchema);
    • .cacheTag(name) — declares the endpoint's data belongs to a cache group. Use on GET endpoints.
    • .clearsCacheTag(name) — declares that this mutation clears matching cache entries on success. Use on POST / PUT / PATCH / DELETE.
    • .cacheTag(name, p => ({ ... })) — property-based tag; each selected property becomes part of the cache key (different pages → different entries).
    • Immutability — both methods return a new builder; the original is unchanged.
    const server = new ServerBuilder();

    server.handle(CreateUser, ({ body, context }) => {
    return ActionResult.created({ id: 42, ...body }, `/api/users/42`);
    });

    // Per-endpoint middleware
    server.handle(AdminEp, ({ params }) => { /* … */ }, {
    middlewares: [loggingMiddleware]
    });

    await server.listen(3000);
    Method Status Notes
    ActionResult.ok(body) 200 Content-negotiated JSON
    ActionResult.created(body, location?) 201 Sets Location header
    ActionResult.noContent() 204 No body
    ActionResult.redirect(url, permanent?) 302 / 301
    ActionResult.json(body, status?) any Forces application/json
    ActionResult.file(buffer, fileName) 200 Attachment download
    ActionResult.content(body, contentType) 200 Arbitrary string body
    ActionResult.stream(readable, contentType) 200 Pipes a Readable
    ActionResult.raw(handler) custom Native Node req / res escape hatch
    ActionResult.status(status) any Bare status, no body

    Use ActionResult.raw() when integrating a library that already writes to Node's http.ServerResponse:

    server.handle(WebhookEndpoint, () =>
    ActionResult.raw(async (req, res) => {
    await thirdPartyWebhookHandler(req, res);
    })
    );

    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
    // → { tag: ['work', 'travel'], title: 'Trip' }

    Accept file uploads via multipart/form-data by chaining .upload() on an endpoint:

    import { endpoint } from '@cleverbrush/server';
    import { object, string } from '@cleverbrush/schema';

    const UploadAvatar = endpoint
    .post('/api/avatar')
    .upload({ maxFileSize: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] })
    .body(object({ description: string().optional() }))
    .authorize(UserPrincipal);

    const handler: Handler<typeof UploadAvatar> = async ({ body, files }) => {
    const avatar = files['avatar'];
    // avatar: FilePart { filename, mimeType, buffer, size }
    return ActionResult.created({ name: avatar.filename });
    };

    The files object on the handler context contains one FilePart entry per uploaded file field. Non-file form fields are validated against the body schema and available via body.

    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;
    }
    import type { Middleware } from '@cleverbrush/server';

    const logger: Middleware = async (ctx, next) => {
    console.log(ctx.method, ctx.url.pathname);
    await next();
    };

    server.use(logger);
    import { ServiceCollection } from '@cleverbrush/di';
    import { object, func, string } from '@cleverbrush/schema';

    const IUserRepo = object({ findById: func() });

    const GetUser = endpoint
    .get('/api/users/:id')
    .inject({ repo: IUserRepo });

    server
    .services(svc => svc.addSingleton(IUserRepo, () => new UserRepository()))
    .handle(GetUser, ({ params }, { repo }) => {
    return repo.findById(params.id);
    });
    import { jwtScheme } from '@cleverbrush/auth';

    server.useAuthentication({
    defaultScheme: 'jwt',
    schemes: [
    jwtScheme({
    secret: process.env.JWT_SECRET!,
    mapClaims: claims => ({ sub: claims.sub as string, role: claims.role as string })
    })
    ]
    });

    server.useAuthorization();

    By default, only defaultScheme is attempted. Use trySchemes when an app accepts multiple credential types on the same endpoints:

    server.useAuthentication({
    defaultScheme: 'cookie',
    schemes: [cookieScheme(options), jwtScheme(options)],
    trySchemes: ['cookie', 'jwt'] // or 'all'
    });

    Throw any HttpError subclass from a handler — it becomes a Problem Details response automatically:

    import { NotFoundError, BadRequestError, ForbiddenError } 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;
    });
    Class Status
    BadRequestError 400
    UnauthorizedError 401
    ForbiddenError 403
    NotFoundError 404
    ConflictError 409
    HttpError any (base class)

    Define real-time endpoints using endpoint.subscription():

    import { endpoint, tracked } from '@cleverbrush/server';
    import { object, string, number } from '@cleverbrush/schema';

    // Server-push subscription
    const liveUpdates = endpoint
    .subscription('/ws/updates')
    .outgoing(object({ action: string(), id: number() }))
    .summary('Live updates');

    // Bidirectional subscription
    const chat = endpoint
    .subscription('/ws/chat')
    .incoming(object({ text: string() }))
    .outgoing(object({ user: string(), text: string(), ts: number() }))
    .authorize('user');

    Handlers are async generators that yield outgoing events:

    import type { SubscriptionHandler } from '@cleverbrush/server';

    const handler: SubscriptionHandler<typeof liveUpdates> = async function* () {
    while (true) {
    await new Promise(r => setTimeout(r, 1000));
    yield { action: 'tick', id: Date.now() };
    }
    };

    // Bidirectional — read from `incoming` async iterable
    const chatHandler: SubscriptionHandler<typeof chat> = async function* ({ incoming, principal }) {
    yield { user: 'system', text: `${principal.name} joined`, ts: Date.now() };
    for await (const msg of incoming) {
    yield { user: principal.name, text: msg.text, ts: Date.now() };
    }
    };

    Use tracked(id, data) to send events with a unique ID for client-side deduplication:

    yield tracked('evt-123', { action: 'created', id: 42 });
    
    // Direct subscription
    const sub = client.live.updates();
    for await (const event of sub) {
    console.log(event); // typed as { action: string, id: number }
    }

    // Send messages (bidirectional)
    const chat = client.live.chat();
    chat.send({ text: 'hello' });
    import { useSubscription } from '@cleverbrush/client/react';

    function LiveFeed() {
    const { events, state, send, close } = useSubscription(
    () => client.live.updates(),
    { maxEvents: 100 }
    );

    return (
    <div>
    <p>Status: {state}</p>
    {events.map((e, i) => <div key={i}>{e.action} #{e.id}</div>)}
    </div>
    );
    }

    Enable the server-side batch endpoint so the client can coalesce many concurrent requests into a single HTTP round-trip.

    import { createServer } from '@cleverbrush/server';

    const server = await createServer()
    .useBatching() // enables POST /__batch
    .handleAll(mapping)
    .listen(3000);

    The batch endpoint (POST /__batch by default) accepts a JSON body:

    {
    "requests": [
    { "method": "GET", "url": "/api/todos", "headers": { "authorization": "Bearer ..." } },
    { "method": "POST", "url": "/api/todos", "headers": { "content-type": "application/json", "authorization": "Bearer ..." }, "body": "{\"title\":\"Buy milk\"}" }
    ]
    }

    Each sub-request is processed through the full middleware and handler pipeline (including auth and DI) in parallel by default. The response is:

    {
    "responses": [
    { "status": 200, "headers": { "content-type": "application/json" }, "body": "[{\"id\":1}]" },
    { "status": 201, "headers": { "content-type": "application/json" }, "body": "{\"id\":2,\"title\":\"Buy milk\"}" }
    ]
    }

    One sub-request failing returns its error status in its own slot — the rest succeed normally.

    Option Type Default Description
    path string '/__batch' URL path for the batch endpoint
    maxSize number 20 Maximum sub-requests per batch (400 if exceeded)
    parallel boolean true Run sub-requests in parallel (false for sequential)
    createServer()
    .useBatching({ path: '/_batch', maxSize: 50, parallel: false })
    .handleAll(mapping)
    .listen(3000);

    When building applications with distinct audiences — a public client and an admin panel, for example — you want each consumer to import only the endpoints it needs. This eliminates leaking admin schemas into the client bundle and improves tree-shaking.

    The @cleverbrush/server/contract entry point ships three utilities for this.

    Combine two ApiContract objects into one. Groups that only exist in one contract are kept as-is; groups that share a key have their endpoint maps shallowly merged.

    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/contract.ts
    import { publicApi } from '../shared/public-api';
    // TypeScript sees: { todos, auth } — admin is absent from the bundle

    Returns a new contract containing only the listed groups. The TypeScript return type is Pick<T, K> — the compiler sees exactly the selected groups.

    import { pickGroups } from '@cleverbrush/server/contract';

    const fullApi = defineApi({ todos: {...}, auth: {...}, admin: {...}, debug: {...} });

    // Only expose what the frontend needs
    const clientApi = pickGroups(fullApi, 'todos', 'auth');
    // TypeScript: { todos: ..., auth: ... }
    // 'admin' and 'debug' do not exist on the type or at runtime

    Inverse of pickGroups — strips the listed groups and keeps 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 to keeping admin endpoints out of the client bundle is file-level separation. Export different slices from different entry points:

    packages/
    shared-contracts/
    src/
    public.ts // export const publicApi = defineApi({ ... })
    admin.ts // export const adminApi = defineApi({ ... })
    full.ts // export const fullApi = mergeContracts(publicApi, adminApi)

    apps/
    client/ // imports publicApi — admin endpoints never bundled
    admin-panel/ // imports fullApi — full set of endpoints
    backend/ // imports fullApi — handles all routes

    BSD-3-Clause — see LICENSE.

    Modules

    contract