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(), .status() — no manual res.write() / res.end().
    • Content negotiation — pluggable ContentTypeHandler registry; JSON 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)
    });
    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');
    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.status(status) any Bare status, no body
    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();

    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