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 manualres.write()/res.end()unless you explicitly opt in. - Content negotiation — pluggable
ContentTypeHandlerregistry; JSON andapplication/x-www-form-urlencodedregistered by default; honours theAcceptrequest header. - Middleware pipeline —
server.use(middleware)for global middleware; per-endpoint middleware viahandle(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/dicontainer. - Authentication & authorization —
server.useAuthentication()/server.useAuthorization()wired to@cleverbrush/authschemes and policies. - RFC 9457 Problem Details — validation errors and
HttpErrorsubclasses are serialized asapplication/problem+json. - Type-safe routes —
route()builds typed path parameters usingParseStringSchemaBuildersegments. - OpenAPI-ready —
getRegistrations()exposes endpoint metadata for@cleverbrush/server-openapi. - AsyncAPI-ready —
getSubscriptionRegistrations()exposes subscription metadata forgenerateAsyncApiSpec()in@cleverbrush/server-openapi. - Health check — optional
/healthendpoint viaserver.withHealthcheck(). - WebSocket subscriptions —
endpoint.subscription('/ws/path')with typed incoming/outgoing schemas,tracked()events, and async generator handlers. - Contract composition —
mergeContracts,pickGroups, andomitGroupsenable 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()andwithErrors()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.
injectmerges 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
authorizeschema 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
tagsreplace group tags; group tags replace existing tags. Other metadata changes only when supplied:summary,description,operationId, anddeprecated: 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/schemaimport { 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 bundleReturns 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 runtimeInverse 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.