Libraries v5.0.0
    Preparing search index...

    Module @cleverbrush/server - v5.0.0

    @cleverbrush/server

    CI License: BSD-3-Clause

    Unit coverage

    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.

    Request validation retains its existing 400 Problem Details envelope and errors: [{ pointer, detail }] extension. Nested arrays now expose indexed paths such as /body/addresses/0/city alongside existing aggregate errors. Property names use JSON Pointer escaping (~0 for ~, ~1 for /), including query and header names. These paths describe the request, not a particular UI.

    The optional client decodeValidationIssues(error, { source: 'body' }) adapter produces serializable form-relative issues; see the multi-file consumer example. Application-owned business errors need explicit structured paths to appear beside fields; the framework never derives a field name from an exception's message.

    • Fluent endpoint builder — endpoint.get('/users').body(schema).query(schema).authorize() with fully typed handler context.
    • Action results — ActionResult.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 pipeline — server.use(middleware) for global middleware; per-endpoint middleware via handle(ep, handler, { middlewares }).
    • Opt-in CORS — server.useCors() handles route-aware preflights before authentication, with explicit origins or asynchronous origin predicates.
    • DI integration — endpoint.inject({ db: IDbContext }) resolves services per-request from a @cleverbrush/di container.
    • Authentication & authorization — server.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 routes — route() builds typed path parameters using ParseStringSchemaBuilder segments.
    • OpenAPI-ready — getRegistrations() exposes endpoint metadata for @cleverbrush/server-openapi.
    • AsyncAPI-ready — getSubscriptionRegistrations() exposes subscription metadata for generateAsyncApiSpec() in @cleverbrush/server-openapi.
    • Health check — optional /health endpoint via server.withHealthcheck().
    • WebSocket subscriptions — endpoint.subscription('/ws/path') with typed incoming/outgoing schemas, tracked() events, and async generator handlers.
    • Contract composition — mergeContracts, pickGroups, and omitGroups enable audience-scoped bundles: ship only the endpoints each consumer needs.
    • Modular implementations — implement(api) derives server-configured scopes, keeps separate handler files strongly typed, and checks full contract coverage at final registration.
    • Typed error policies — errorMap() and withErrors() translate known handler exceptions without repeated catch blocks or widening endpoint responses.

    Enable CORS with an explicit server-wide policy:

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

    const server = createServer().useCors({
    origin: ['https://app.example.com', 'http://localhost:5173'],
    methods: ['GET', 'POST', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
    exposedHeaders: ['WWW-Authenticate', 'X-Request-Id'],
    credentials: true,
    maxAgeSeconds: 600
    });

    ServerCorsOptions is exported from @cleverbrush/server. The policy is validated and copied before listening. Origins are exact serialized URL origins (scheme, host and optional port, without a path or trailing slash). The special origin string null can be included explicitly for opaque browser origins.

    Option Behavior / default
    origin Required: an exact origin, readonly origin list, '*', or (origin: string) => boolean | Promise<boolean>. Empty lists deny all origins.
    methods Optional preflight allowlist, intersected with the registered routes. By default, any method registered for the requested URL may be preflighted.
    allowedHeaders Explicit preflight request-header names; case-insensitive, default []. Include Authorization or custom auth headers when needed.
    exposedHeaders Additional response-header names browsers may read; default [].
    credentials Default false. With true, an exact accepted origin is returned. origin: '*' with credentials is rejected at startup.
    maxAgeSeconds Non-negative integer browser preflight cache duration, default 0.

    Method and header lists use explicit names; wildcard entries are not supported. origin: '*' explicitly permits all valid origins, including opaque null origins, without credential support. CORS is disabled until useCors is called.

    For domains determined at request time, use a predicate backed by application configuration or a domain registry:

    server.useCors({
    origin: async origin => tenantDomains.isAllowed(origin),
    allowedHeaders: ['Content-Type', 'Authorization']
    });

    The predicate is awaited once per physical HTTP request carrying a valid Origin, including preflights; it is not called for requests without Origin. Results are not cached by the server. Browser preflight caching follows maxAgeSeconds. Returning false rejects the request with 403 before authentication or handlers. Throwing or rejecting returns a generic 500 without exposing the callback error. Calling useCors again replaces the policy for subsequently started servers.

    CORS runs before routing, body parsing, DI scopes and ordinary middleware, regardless of where useCors appears in the builder chain. An OPTIONS request with both Origin and Access-Control-Request-Method is a preflight. Accepted preflights return an empty 204 without running authentication or handlers. Only registered HTTP routes and enabled health/batch endpoints are eligible. Malformed preflights return 400, unknown routes 404, unsupported route methods 405, and policy denials 403. Denied preflights have no CORS permission headers. Ordinary OPTIONS requests still use registered handlers and normal routing.

    Actual requests from accepted origins follow the existing middleware and authentication pipeline. CORS headers accompany successful and error responses, including authentication challenges, validation failures and routing errors. Disallowed or malformed origins receive 403 before handlers run. Requests without Origin retain normal processing and receive no CORS permission headers. Authentication remains responsible for resource access; the method/header lists govern browser preflight permission, not ordinary HTTP routing.

    When enabled, the CORS stage owns its six standard response headers and finalizes them as headers are sent, including raw/streamed responses and cache/idempotency replays. Configure CORS through this API rather than writing competing CORS headers in middleware. Existing Vary values are preserved and merged with Origin, plus the requested method/header fields on preflights. Cached CORS permissions are not reused for another origin. Virtual batch subrequests retain their usual auth pipeline; CORS applies to the outer HTTP request. WebSocket upgrades are outside this policy. Ordinary middleware, including middleware-based tracing, does not run for CORS short-circuits.

    implement adds server-only configuration and complete handler registration to an existing shared API contract. It does not replace endpoint builders, change the wire contract, or require business logic inside one fluent expression. errorMap and withErrors also work independently of this registration API.

    The application functions and service registration below are application-owned. The contract in this example has exactly two operations, so its root is complete.

    // contracts.ts — browser-safe; no server configuration or handler imports
    import { defineApi, endpoint, route } from '@cleverbrush/server/contract';
    import { array, number, object, string } from '@cleverbrush/schema';

    export const Item = object({ id: number(), title: string() });
    const Message = object({ message: string() });
    export const Principal = object({ userId: string() });
    const resource = endpoint.resource('/items').authorize(Principal);

    export const api = defineApi({
    items: {
    list: resource.get().responses({ 200: array(Item) }),
    remove: resource.delete(
    route({ id: number().coerce() })`/${p => p.id}`
    ).responses({ 204: null, 404: Message })
    }
    });
    // features/items/scope.ts
    import { implement } from '@cleverbrush/server';
    import { api } from '../../contracts.js';
    import { DbToken } from '../../di/tokens.js';

    export const items = implement(api).group('items', {
    inject: { db: DbToken },
    tags: ['items'],
    operations: {
    list: { summary: 'List items', operationId: 'listItems' },
    remove: { summary: 'Remove an item', operationId: 'removeItem' }
    }
    });

    items.endpoints contains immutable, server-configured builders. Export the scope constant and reference it with typeof; do not annotate it with a broad base type that erases operation names or service types.

    // features/items/handlers/list.ts
    import type { Handler } from '@cleverbrush/server';
    import type { items } from '../scope.js';
    import { listItems } from '../../../application/items.js';

    export const list: Handler<typeof items.endpoints.list> = (
    { principal }, { db }
    ) => listItems(db, principal.userId);
    // features/items/handlers/remove.ts
    import { ActionResult, type Handler } from '@cleverbrush/server';
    import type { items } from '../scope.js';
    import { removeItem } from '../../../application/items.js';

    export const remove: Handler<typeof items.endpoints.remove> = async (
    { params, principal }, { db }
    ) => {
    await removeItem(db, principal.userId, params.id);
    return ActionResult.noContent();
    };

    The application function can throw a domain exception. Handlers do not need a repeated catch block. Requests, principals, injected services, and responses are derived at the handler's declaration site, not inferred retrospectively from a later registration call. Type-only scope imports avoid runtime import cycles.

    // features/items/errors.ts
    import { ActionResult, errorMap } from '@cleverbrush/server';
    import { MissingItemError } from '../../application/errors.js';

    export const itemErrors = errorMap().on(MissingItemError, () =>
    ActionResult.notFound({ message: 'Item not found' })
    );
    // features/items/index.ts
    import { items } from './scope.js';
    import { itemErrors } from './errors.js';
    import { list } from './handlers/list.js';
    import { remove } from './handlers/remove.js';

    export const itemsModule = items.withHandlers({
    list,
    remove: { handler: remove, errors: itemErrors }
    });

    Attaching this policy to list is a type error: list does not declare 404. Declaring 204 on remove does not permit arbitrary other statuses or bodies.

    // server.ts — after the application's ordinary server/DI setup
    import { implement } from '@cleverbrush/server';
    import { api } from './contracts.js';
    import { itemsModule } from './features/items/index.js';

    server.handleAll(implement(api).use(itemsModule).complete());

    complete() fails at compile time if an operation is missing, and checks again at runtime for JavaScript callers and erased types. The output is the existing HandlerMapping. Authentication, validation, caching, batching, middleware, serialization, logging, and OpenAPI generation use their existing paths.

    export const reads = items.pick('list').withHandlers({ list });
    export const writes = items.pick('remove').withHandlers({
    remove: { handler: remove, errors: itemErrors }
    });
    export const feature = implement(api).use(reads, writes);

    // Partial roots can be exported and composed; finalize only at the full root.
    server.handleAll(implement(api).use(feature).complete());

    Other groups are separate feature modules added to the same use(...) call. Pass modules directly or in a literal tuple, not an array widened to a generic module type. Duplicate bindings are rejected across modules and calls. Source endpoint identity is checked at runtime as well: independently recreated endpoints are not interchangeable just because their TypeScript shapes match. pickGroups, omitGroups, and mergeContracts preserve endpoint references, so intentional contract slices can be implemented and recomposed.

    Subscriptions use the same scopes, SubscriptionHandler<typeof scope.endpoints.x>, and existing subscription handler descriptors. They count toward completeness. HTTP error policies are not accepted on subscription handlers and do not change the WebSocket error protocol.

    • inject merges contract bindings, then group bindings, then operation bindings. A same-name binding is replaced by the later value in both the type and runtime service map. Other dependencies remain available. Endpoint .inject() itself retains its existing replacement behavior.
    • An operation's authorize schema takes precedence over a group's schema; existing endpoint roles are retained. Supplying authorization explicitly makes even a previously public operation authenticated. Omit it when authorization already lives entirely in the shared contract. There is no implicit auth default.
    • Operation tags replace group tags; group tags replace existing tags. Other metadata changes only when supplied: summary, description, operationId, and deprecated: true. Deprecation is additive and cannot be cleared here.
    • Request/response schemas, cache definitions, upload settings, links, examples, and other endpoint metadata remain on the shared endpoint. Scopes do not edit the wire contract or silently add error responses.
    • Handler descriptors support existing per-operation middlewares. Configuration scopes do not register anything until explicitly bound and composed.

    Policies are immutable. Each .on(ErrorClass, translator) returns a new policy. Rules match by instanceof in declaration order; put subclasses before base classes. Both handlers and translators may be synchronous or asynchronous.

    Translators return explicit JSON or bodyless ActionResult values. Every result must fit the target endpoint's .responses() status/body map. Policies cannot use raw/file/stream results to escape that check, and endpoints without explicit responses cannot attach a policy. TypeScript escape hatches (any, assertions, or deliberately erased annotations) still bypass static guarantees; this is not a replacement for runtime response validation.

    Only exceptions from the handler invocation are translated. Authentication, request validation, DI resolution, middleware, and response serialization are outside the wrapper. Unknown thrown values are rethrown unchanged. If a translator fails, its failure propagates without recursively applying the policy again. Existing centralized handling still logs unexpected failures and sends safe 500 Problem Details. There is no automatic catch-all or exposure of Error.message.

    Application authors decide which errors are safe to map and which details can be returned. Framework has no knowledge of domain ownership or privacy rules.

    The old handle and mapHandlers APIs remain unchanged. Adopt just error policies without migrating registration:

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

    server.handle(
    RemoveItemEndpoint,
    withErrors(RemoveItemEndpoint, itemErrors, removeHandler)
    );

    The returned function can also be placed in an existing handler map. To migrate registration, move server-only metadata/DI into scopes, type the existing handler files against scope.endpoints, bind feature modules, and finalize at the root. Keep the shared contract in /contract imports; implementation facilities and policies are server-entry-point exports only.

    The repository tests include strict type assertions against emitted package declarations and a generated 1,000-operation consumer with one handler per file. The generated consumer verifies cross-file inference and records compiler diagnostics, memory, and timings for both existing and modular registration without using environment-dependent timing thresholds.

    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 })) // entity label + external computed key
    .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.

    computeCacheKey (also exported from the browser-safe @cleverbrush/server/contract) and the client's computeCacheTagKey use the same deterministic ct2: encoding. It distinguishes types, normalizes property order and preserves date milliseconds. cacheResponse() invalidates only after successful mutations and prevents older in-flight reads from refilling any invalidated alias. In-memory invalidation retains tag-name-prefix coverage; it is not limited to the mutation's selected entity ID.

    Breaking: computed keys changed, even for property-free tags. Upgrade external writers/invalidators together and retire old entries. Literal base labels and TTLs are unchanged. Endpoint response identity and auth/tenant isolation remain the consumer's responsibility. See the migration guide.

    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' }

    Declare uploaded fields with schema builders. Import contracts through the browser-safe entry point when sharing them with a client:

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

    export const UploadAssets = endpoint.post('/api/assets')
    .upload(object({
    images: array(file()).minLength(1).maxLength(3),
    cover: file().optional()
    }), { allowedMimeTypes: ['image/*'] })
    .body(object({ description: string().optional() }));

    Handlers receive files.images: FilePart[] in request order and an optional files.cover: FilePart. Text fields are validated through .body(). Omit .body() for a file-only endpoint. Text and file field names must be distinct. A missing required array becomes []; use .minLength(1) to require a file. Absent optional file fields are omitted.

    Typed upload endpoints reject malformed or invalid requests with 400 Problem Details before calling the handler. Resource limits return 413, including oversized files, text fields, field names, request bodies and part counts. Truncated content is never passed to handlers. Files are buffered in memory within these limits; authorization runs before parsing. MIME allowlists compare the declared multipart MIME type; they do not inspect file contents.

    The options-only .upload(options?) overload accepts one FilePart per field. It reports MIME exclusions through rejectedFiles (including fieldName) and rejects duplicate file fields and limit violations.

    Option Default Meaning
    maxFileSize 10 MiB Bytes per file
    maxFileCount 10 Files per request, including rejected files
    allowedMimeTypes All Declared MIME types; supports patterns such as image/*
    maxFieldSize 1 MiB Bytes per text field
    maxFieldCount 100 Text fields per request
    maxFieldNameSize 100 UTF-8 bytes per field name
    maxPartCount File-count limit + field-count limit Total file and text parts

    Limits must be positive safe integers. The server's maxBodySize (default 5 MiB) also limits the entire multipart request, including boundaries and headers. Configure it large enough for the permitted file collection. It applies even when the request has no Content-Length.

    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)

    Middleware that rejects a subscription closes its WebSocket with code 1008. Closing or disconnecting aborts the handler signal and disposes its DI scope.

    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

    Declare mutation replay in the shared contract with .idempotent(). Bind an explicit scope on the server; registration fails at startup when it is missing. The optional prepare callback receives validated request data and injected services and returns the request passed to scope resolution and the handler. Use it to authorize resource access and resolve request defaults before replay.

    const createItem = endpoint.post('/items')
    .idempotent()
    .body(CreateItemSchema)
    .authorize(UserPrincipal)
    .inject({ db: DbToken })
    .responses({ 201: ItemSchema, 403: MessageSchema });

    server.handle(createItem, createItemHandler, {
    prepare: async (request, { db }) => {
    const workspace = await requireWorkspaceAccess(db, request.principal,
    request.body.workspaceId);
    return { ...request, body: { ...request.body, workspaceId: workspace.id } };
    },
    idempotency: {
    scope: ({ principal, body }) => [principal.userId, body.workspaceId]
    },
    errors: itemErrors
    });

    The same options work in mapHandlers and implement(api).group(...).withHandlers. Authentication, validation, preparation and scope resolution run before each replay, including batch subrequests. Preparation/scope exceptions use the bound error policy without reserving a key. Handler exceptions translated by that policy become replayable responses. Validation, DI and serialization errors retain their normal Framework handling; standalone withErrors still wraps only its handler.

    X-Idempotency-Key is optional, case-insensitive, and must contain 1–256 characters when present. It is transport metadata, so no .headers() schema is needed. OpenAPI includes this header and the 400/409/503 Problem Details responses. When enabling cross-origin browser access, explicitly include this header in your CORS allowedHeaders. Each server endpoint retains its own bounded store with the defaults below; idempotency options can override its limits.

    idempotency({ scope }) coalesces concurrent mutations and replays their completed responses within one middleware instance. Install it after authentication and authorization. Derive scope(ctx) from verified identity/tenant context; returning undefined bypasses replay. A constant scope is appropriate only for intentionally public operations. Keys also include method and full URL. Reusing a key asserts identical input; request bodies are not fingerprinted.

    Defaults: ttl: 86400000, maxEntries: 1000, maxResponseBytes: 65536. Capacity exhaustion returns 503 before execution. Oversized or incomplete responses reserve the key; retries return 409 so the caller can verify the outcome. This is process-local replay, not durable exactly-once execution: crashes, expiry or thrown handler errors can permit a later execution. Use database deduplication for durable business guarantees.

    cacheResponse() retains at most 1000 keys and 65,536-byte complete response bodies by default (maxEntries, maxResponseBytes); oldest keys are evicted at capacity. It bypasses Set-Cookie and Cache-Control: private/no-store responses. defaultTtl and ttlByTag are finite, non-negative milliseconds; retention uses the longest TTL among an endpoint's tags (all zero disables retention). Tags must distinguish endpoint shapes, verified identities, tenants and all representation inputs. Both middleware capture write()/end() chunks and restore response hooks on failure.

    See security guidance and the v4.x-to-v5 migration guide.

    BSD-3-Clause — see LICENSE.

    contract