Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

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

Features

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

Installation

npm install @cleverbrush/server @cleverbrush/schema

Quick Start

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

Defining Endpoints

HTTP Methods

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

Request Validation

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

Type-Safe Path Parameters

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

Authorization

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

OpenAPI Metadata

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

Cache Tags

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.

Registering and Handling Endpoints

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

Action Results

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

File Upload

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.

Options

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

FilePart type

interface FilePart {
    readonly filename: string;
    readonly mimeType: string;
    readonly buffer: Buffer;
    readonly size: number;
}

Middleware

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

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

server.use(logger);

Dependency Injection

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

Authentication

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

HTTP Errors

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)

WebSocket Subscriptions

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

Subscription Handlers

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

Tracked Events

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

yield tracked('evt-123', { action: 'created', id: 42 });

Client Usage

// 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' });

React Hook

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

Request Batching

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

How it works

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.

Options

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

Contract Composition

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.

mergeContracts

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

pickGroups

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

omitGroups

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: ... }

Bundle isolation pattern

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

License

BSD-3-Clause — see LICENSE.