@cleverbrush/server 3.1.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,7 @@ A schema-first HTTP server framework for Node.js. Combines [`@cleverbrush/schema
19
19
  - **AsyncAPI-ready** — `getSubscriptionRegistrations()` exposes subscription metadata for `generateAsyncApiSpec()` in `@cleverbrush/server-openapi`.
20
20
  - **Health check** — optional `/health` endpoint via `server.withHealthcheck()`.
21
21
  - **WebSocket subscriptions** — `endpoint.subscription('/ws/path')` with typed incoming/outgoing schemas, `tracked()` events, and async generator handlers.
22
+ - **Contract composition** — `mergeContracts`, `pickGroups`, and `omitGroups` enable audience-scoped bundles: ship only the endpoints each consumer needs.
22
23
 
23
24
  ## Installation
24
25
 
@@ -123,6 +124,36 @@ const CreateUser = endpoint
123
124
  .operationId('createUser');
124
125
  ```
125
126
 
127
+ ### Cache Tags
128
+
129
+ Tag-based cache invalidation. Tags declared on endpoints flow to the
130
+ [`cacheTags` middleware](/client/cache-tags) for automatic HTTP caching and
131
+ invalidation on mutating requests.
132
+
133
+ ```ts
134
+ const ListTodos = endpoint
135
+ .get('/api/todos')
136
+ .query(TodoListQuerySchema)
137
+ .cacheTag('todo-list', p => ({ page: p.query.page, limit: p.query.limit }))
138
+ .returns(array(TodoSchema));
139
+
140
+ const UpdateTodo = endpoint
141
+ .patch('/api/todos/:id')
142
+ .body(UpdateTodoBody)
143
+ .clearsCacheTag('todo-list') // clears the collection cache
144
+ .clearsCacheTag('todo', p => ({ id: p.params.id })) // clears specific entity
145
+ .returns(TodoSchema);
146
+ ```
147
+
148
+ - **`.cacheTag(name)`** — declares the endpoint's data belongs to a cache
149
+ group. Use on GET endpoints.
150
+ - **`.clearsCacheTag(name)`** — declares that this mutation clears matching
151
+ cache entries on success. Use on POST / PUT / PATCH / DELETE.
152
+ - **`.cacheTag(name, p => ({ ... }))`** — property-based tag; each selected
153
+ property becomes part of the cache key (different pages → different entries).
154
+ - **Immutability** — both methods return a new builder; the original is
155
+ unchanged.
156
+
126
157
  ## Registering and Handling Endpoints
127
158
 
128
159
  ```ts
@@ -154,6 +185,48 @@ await server.listen(3000);
154
185
  | `ActionResult.stream(readable, contentType)` | 200 | Pipes a `Readable` |
155
186
  | `ActionResult.status(status)` | any | Bare status, no body |
156
187
 
188
+ ## File Upload
189
+
190
+ Accept file uploads via `multipart/form-data` by chaining `.upload()` on an endpoint:
191
+
192
+ ```ts
193
+ import { endpoint } from '@cleverbrush/server';
194
+ import { object, string } from '@cleverbrush/schema';
195
+
196
+ const UploadAvatar = endpoint
197
+ .post('/api/avatar')
198
+ .upload({ maxFileSize: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] })
199
+ .body(object({ description: string().optional() }))
200
+ .authorize(UserPrincipal);
201
+
202
+ const handler: Handler<typeof UploadAvatar> = async ({ body, files }) => {
203
+ const avatar = files['avatar'];
204
+ // avatar: FilePart { filename, mimeType, buffer, size }
205
+ return ActionResult.created({ name: avatar.filename });
206
+ };
207
+ ```
208
+
209
+ 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`.
210
+
211
+ ### Options
212
+
213
+ | Option | Type | Default | Description |
214
+ |--------|------|---------|-------------|
215
+ | `maxFileSize` | `number` | 10 MB | Maximum file size per file in bytes |
216
+ | `allowedMimeTypes` | `string[]` | all | MIME type allowlist (supports `image/*` glob) |
217
+ | `maxFileCount` | `number` | 10 | Maximum number of files per request |
218
+
219
+ ### FilePart type
220
+
221
+ ```ts
222
+ interface FilePart {
223
+ readonly filename: string;
224
+ readonly mimeType: string;
225
+ readonly buffer: Buffer;
226
+ readonly size: number;
227
+ }
228
+ ```
229
+
157
230
  ## Middleware
158
231
 
159
232
  ```ts
@@ -368,6 +441,83 @@ createServer()
368
441
  .listen(3000);
369
442
  ```
370
443
 
444
+ ## Contract Composition
445
+
446
+ 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.
447
+
448
+ The `@cleverbrush/server/contract` entry point ships three utilities for this.
449
+
450
+ ### `mergeContracts`
451
+
452
+ 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.
453
+
454
+ ```ts
455
+ import { defineApi, mergeContracts } from '@cleverbrush/server/contract';
456
+
457
+ // public-api.ts — safe to import in every consumer
458
+ export const publicApi = defineApi({
459
+ todos: { list: ..., get: ..., create: ... },
460
+ auth: { login: ..., register: ... },
461
+ });
462
+
463
+ // admin-api.ts — only imported by the admin application
464
+ const adminApi = defineApi({
465
+ admin: { activityLog: ..., banUser: ... },
466
+ });
467
+
468
+ // admin-app/contract.ts
469
+ export const fullAdminApi = mergeContracts(publicApi, adminApi);
470
+ // TypeScript sees: { todos, auth, admin } — all fully typed
471
+
472
+ // client-app/contract.ts
473
+ import { publicApi } from '../shared/public-api';
474
+ // TypeScript sees: { todos, auth } — admin is absent from the bundle
475
+ ```
476
+
477
+ ### `pickGroups`
478
+
479
+ Returns a new contract containing only the listed groups. The TypeScript return type is `Pick<T, K>` — the compiler sees exactly the selected groups.
480
+
481
+ ```ts
482
+ import { pickGroups } from '@cleverbrush/server/contract';
483
+
484
+ const fullApi = defineApi({ todos: {...}, auth: {...}, admin: {...}, debug: {...} });
485
+
486
+ // Only expose what the frontend needs
487
+ const clientApi = pickGroups(fullApi, 'todos', 'auth');
488
+ // TypeScript: { todos: ..., auth: ... }
489
+ // 'admin' and 'debug' do not exist on the type or at runtime
490
+ ```
491
+
492
+ ### `omitGroups`
493
+
494
+ Inverse of `pickGroups` — strips the listed groups and keeps everything else. Return type is `Omit<T, K>`.
495
+
496
+ ```ts
497
+ import { omitGroups } from '@cleverbrush/server/contract';
498
+
499
+ const publicApi = omitGroups(fullApi, 'admin', 'debug');
500
+ // TypeScript: { todos: ..., auth: ... }
501
+ ```
502
+
503
+ ### Bundle isolation pattern
504
+
505
+ The key to keeping admin endpoints out of the client bundle is **file-level separation**. Export different slices from different entry points:
506
+
507
+ ```
508
+ packages/
509
+ shared-contracts/
510
+ src/
511
+ public.ts // export const publicApi = defineApi({ ... })
512
+ admin.ts // export const adminApi = defineApi({ ... })
513
+ full.ts // export const fullApi = mergeContracts(publicApi, adminApi)
514
+
515
+ apps/
516
+ client/ // imports publicApi — admin endpoints never bundled
517
+ admin-panel/ // imports fullApi — full set of endpoints
518
+ backend/ // imports fullApi — handles all routes
519
+ ```
520
+
371
521
  ## License
372
522
 
373
523
  BSD-3-Clause — see [LICENSE](../../LICENSE).
@@ -0,0 +1,76 @@
1
+ import { type ObjectSchemaBuilder, type SchemaBuilder } from '@cleverbrush/schema';
2
+ /**
3
+ * An accessor that can extract a property value from a structured
4
+ * request root. Wraps a {@link PropertyDescriptor}'s `getValue`
5
+ * closure so the middleware layer does not need to know about schemas.
6
+ */
7
+ export interface CacheTagPropertyAccessor {
8
+ getValue(root: {
9
+ params: Record<string, unknown>;
10
+ body: unknown;
11
+ query: Record<string, unknown>;
12
+ headers: Record<string, string>;
13
+ }): {
14
+ value?: unknown;
15
+ success: boolean;
16
+ };
17
+ }
18
+ /**
19
+ * A serialisable cache-tag definition stored on endpoint metadata.
20
+ *
21
+ * `properties` maps human-readable key names (used as label segments
22
+ * in the final cache key) to accessors that resolve the actual value
23
+ * from call-time request data.
24
+ */
25
+ export interface CacheTagDefinition {
26
+ readonly name: string;
27
+ readonly properties: Readonly<Record<string, CacheTagPropertyAccessor>>;
28
+ }
29
+ /**
30
+ * Builds a synthetic `object({ params, body, query, headers })` schema
31
+ * from the endpoint's schema definitions and returns its
32
+ * `PropertyDescriptorTree` so callers can write type-safe selectors like:
33
+ *
34
+ * ```ts
35
+ * endpoint.cacheTag('todo', p => ({
36
+ * id: p.query.id,
37
+ * fromBodyId: p.body.id
38
+ * }))
39
+ * ```
40
+ *
41
+ * Only non-null schemas are included in the synthetic schema.
42
+ */
43
+ export declare function createCacheTagTree(schemas: {
44
+ paramsSchema?: SchemaBuilder<any, any, any, any, any> | null;
45
+ bodySchema?: SchemaBuilder<any, any, any, any, any> | null;
46
+ querySchema?: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null;
47
+ headerSchema?: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null;
48
+ }): any;
49
+ /**
50
+ * Serialises the result of a cache-tag selector callback into a
51
+ * {@link CacheTagDefinition} that can be stored on endpoint metadata
52
+ * and forwarded to the client middleware.
53
+ *
54
+ * Each value in `descriptors` must be a {@link PropertyDescriptor} —
55
+ * its `getValue` closure is wrapped in a {@link CacheTagPropertyAccessor}.
56
+ *
57
+ * @throws If any value is not a valid property descriptor.
58
+ */
59
+ export declare function serializeTag(name: string, descriptors: Record<string, unknown>): CacheTagDefinition;
60
+ /**
61
+ * Computes a deterministic cache key from a tag definition and live
62
+ * request data.
63
+ *
64
+ * - Simple tags (no properties) produce just the tag name.
65
+ * - Tags with properties produce `name:key1=val1,key2=val2` where
66
+ * keys are sorted alphabetically for determinism.
67
+ *
68
+ * Properties whose `getValue` returns `success: false` are skipped
69
+ * (their value is not included in the key).
70
+ */
71
+ export declare function computeCacheKey(tag: CacheTagDefinition, root: {
72
+ params: Record<string, unknown>;
73
+ body: unknown;
74
+ query: Record<string, unknown>;
75
+ headers: Record<string, string>;
76
+ }): string;
@@ -1,13 +1,15 @@
1
1
  import type { InferType, ObjectSchemaBuilder, ParseStringSchemaBuilder, PropertyDescriptorTree, SchemaBuilder } from '@cleverbrush/schema';
2
+ import { SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR } from '@cleverbrush/schema';
2
3
  import type { ActionResult, ContentResult, FileResult, JsonResult, NoContentResult, RedirectResult, StatusCodeResult, StreamResult } from './ActionResult.js';
4
+ import type { CacheTagDefinition } from './CacheTag.js';
3
5
  import type { RequestContext } from './RequestContext.js';
4
6
  import { type SubscriptionBuilder, type SubscriptionHandlerEntry } from './Subscription.js';
5
- import type { Middleware } from './types.js';
7
+ import type { FilePart, Middleware, RejectedFile, UploadOptions } from './types.js';
6
8
  type Simplify<T> = {
7
9
  [K in keyof T]: T[K];
8
10
  } & {};
9
11
  type HasKeys<T> = keyof T extends never ? false : true;
10
- type ActionContextParts<TParams, TBody, TQuery, THeaders, TPrincipal> = {
12
+ type ActionContextParts<TParams, TBody, TQuery, THeaders, TPrincipal, TUpload extends boolean> = {
11
13
  context: RequestContext;
12
14
  } & (HasKeys<TParams> extends true ? {
13
15
  params: TParams;
@@ -19,14 +21,17 @@ type ActionContextParts<TParams, TBody, TQuery, THeaders, TPrincipal> = {
19
21
  headers: THeaders;
20
22
  } : {}) & (TPrincipal extends undefined ? {} : {
21
23
  principal: TPrincipal;
22
- });
24
+ }) & (TUpload extends true ? {
25
+ files: Record<string, FilePart>;
26
+ rejectedFiles?: RejectedFile[];
27
+ } : {});
23
28
  /**
24
29
  * The fully-typed argument object passed to endpoint handlers.
25
30
  *
26
31
  * The shape is inferred from the `EndpointBuilder` chain — only the keys
27
32
  * actually configured (body, query, headers, params, principal) are present.
28
33
  */
29
- export type ActionContext<E> = E extends EndpointBuilder<infer TParams, infer TBody, infer TQuery, infer THeaders, any, infer TPrincipal, any, any, any> ? Simplify<ActionContextParts<TParams, TBody, TQuery, THeaders, TPrincipal>> : never;
34
+ export type ActionContext<E> = E extends EndpointBuilder<infer TParams, infer TBody, infer TQuery, infer THeaders, any, infer TPrincipal, any, any, any, infer TUpload> ? Simplify<ActionContextParts<TParams, TBody, TQuery, THeaders, TPrincipal, TUpload>> : never;
30
35
  type InferServices<T> = {
31
36
  [K in keyof T]: T[K] extends SchemaBuilder<any, any, any, any, any> ? InferType<T[K]> : never;
32
37
  };
@@ -34,13 +39,13 @@ type InferServices<T> = {
34
39
  * Extracts the injected service schemas map from an `EndpointBuilder` type.
35
40
  * Used internally by the `Handler` type to derive the `services` argument.
36
41
  */
37
- export type ServiceSchemas<E> = E extends EndpointBuilder<any, any, any, any, infer TServices, any, any, any, any> ? TServices : {};
38
- type ResponseType<E> = E extends EndpointBuilder<any, any, any, any, any, any, any, infer TResponse, any> ? TResponse extends SchemaBuilder<any, any, any, any, any> ? InferType<TResponse> : TResponse : any;
42
+ export type ServiceSchemas<E> = E extends EndpointBuilder<any, any, any, any, infer TServices, any, any, any, any, any> ? TServices : {};
43
+ type ResponseType<E> = E extends EndpointBuilder<any, any, any, any, any, any, any, infer TResponse, any, any> ? TResponse extends SchemaBuilder<any, any, any, any, any> ? InferType<TResponse> : TResponse : any;
39
44
  /**
40
45
  * Extracts the `TResponses` map from an `EndpointBuilder` type.
41
46
  * `TResponses` is a `Record<number, BodyType>` inferred from `.responses()`.
42
47
  */
43
- export type ResponsesOf<E> = E extends EndpointBuilder<any, any, any, any, any, any, any, any, infer TResponses> ? TResponses : never;
48
+ export type ResponsesOf<E> = E extends EndpointBuilder<any, any, any, any, any, any, any, any, infer TResponses, any> ? TResponses : never;
44
49
  type HasResponses<E> = keyof ResponsesOf<E> extends never ? false : true;
45
50
  /**
46
51
  * The union of permitted return values for a handler whose endpoint
@@ -64,7 +69,7 @@ export type AllowedResponseReturn<TResponses extends Record<number, any>> = {
64
69
  */
65
70
  type HandlerReturn<E> = HasResponses<E> extends true ? AllowedResponseReturn<ResponsesOf<E>> : ResponseType<E> | ActionResult;
66
71
  export type Handler<E> = HasKeys<ServiceSchemas<E>> extends true ? (arg: ActionContext<E>, services: Simplify<InferServices<ServiceSchemas<E>>>) => HandlerReturn<E> | Promise<HandlerReturn<E>> : (arg: ActionContext<E>) => HandlerReturn<E> | Promise<HandlerReturn<E>>;
67
- type AnyEndpoint = EndpointBuilder<any, any, any, any, any, any, any, any, any>;
72
+ type AnyEndpoint = EndpointBuilder<any, any, any, any, any, any, any, any, any, any>;
68
73
  type AnySubscriptionBuilder = SubscriptionBuilder<any, any, any, any, any, any, any, any>;
69
74
  /**
70
75
  * A single handler entry in a {@link HandlerMap}.
@@ -298,6 +303,17 @@ export interface EndpointMetadata {
298
303
  * OpenAPI Operation Object.
299
304
  */
300
305
  readonly callbacks: Record<string, CallbackDefinition> | null;
306
+ /**
307
+ * When set, the endpoint accepts `multipart/form-data` uploads.
308
+ * The configuration controls max file size, allowed MIME types, etc.
309
+ * @see `EndpointBuilder.upload()`
310
+ */
311
+ readonly fileUpload: UploadOptions | null;
312
+ /**
313
+ * Cache tags declared via `.clearsCacheTag()`, providing tag-based cache
314
+ * key computation for the client middleware.
315
+ */
316
+ readonly cacheTags: readonly CacheTagDefinition[];
301
317
  }
302
318
  /**
303
319
  * Immutable, fluent builder for HTTP endpoint definitions.
@@ -324,7 +340,37 @@ export interface EndpointMetadata {
324
340
  type InferResponsesMap<T extends Record<number, SchemaBuilder<any, any, any, any, any> | null>> = {
325
341
  [K in keyof T]: T[K] extends SchemaBuilder<any, any, any, any, any> ? InferType<T[K]> : null;
326
342
  };
327
- export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {}, THeaders = {}, TServices = {}, TPrincipal = undefined, TRoles extends string = string, TResponse = any, TResponses extends Record<number, any> = {}> {
343
+ /**
344
+ * A leaf node in a cache-tag property tree — mirrors the shape of the
345
+ * actual runtime {@link PropertyDescriptor} so the compiler accepts
346
+ * values selected by the consumer.
347
+ */
348
+ interface CacheTagPropertyLeaf {
349
+ readonly [SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]: {
350
+ readonly getValue: (obj: Record<string, unknown>) => {
351
+ readonly value?: unknown;
352
+ readonly success: boolean;
353
+ };
354
+ };
355
+ }
356
+ /** Recursively builds a typed property tree from an inferred object shape. */
357
+ type CacheTagPropertyTree<T> = CacheTagPropertyLeaf & (T extends Record<string, unknown> ? {
358
+ readonly [K in keyof T]-?: CacheTagPropertyTree<T[K]>;
359
+ } : unknown);
360
+ /**
361
+ * The typed tree passed to the `.clearsCacheTag(name, selector)` callback.
362
+ *
363
+ * `p.params`, `p.query`, and `p.headers` provide IDE completion for
364
+ * each schema's property names, while `p.body` resolves through the
365
+ * body schema's `InferType`.
366
+ */
367
+ type CacheTagSelector<TParams, TBody, TQuery, THeaders> = {
368
+ readonly params: [keyof TParams] extends [never] ? Record<string, never> : TParams extends Record<string, unknown> ? CacheTagPropertyTree<TParams> : Record<string, never>;
369
+ readonly body: TBody extends undefined ? undefined : TBody extends SchemaBuilder<any, any, any, any, any> ? CacheTagPropertyTree<InferType<TBody>> : Record<string, never>;
370
+ readonly query: [keyof TQuery] extends [never] ? Record<string, never> : TQuery extends Record<string, unknown> ? CacheTagPropertyTree<TQuery> : Record<string, never>;
371
+ readonly headers: [keyof THeaders] extends [never] ? Record<string, never> : THeaders extends Record<string, unknown> ? CacheTagPropertyTree<THeaders> : Record<string, never>;
372
+ };
373
+ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {}, THeaders = {}, TServices = {}, TPrincipal = undefined, TRoles extends string = string, TResponse = any, TResponses extends Record<number, any> = {}, TUpload extends boolean = false> {
328
374
  #private;
329
375
  constructor(method: string, basePath: string, pathTemplate: RoutePath, bodySchema: SchemaBuilder<any, any, any, any, any> | null, querySchema: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null, headerSchema: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null, serviceSchemas?: Record<string, SchemaBuilder<any, any, any, any, any>> | null, authRoles?: readonly string[] | null, summary?: string | null, description?: string | null, tags?: readonly string[], operationId?: string | null, deprecated?: boolean, responseSchema?: SchemaBuilder<any, any, any, any, any> | null, responsesSchemas?: Record<number, SchemaBuilder<any, any, any, any, any> | null> | null, example?: unknown | null, examples?: Record<string, {
330
376
  summary?: string;
@@ -338,15 +384,15 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
338
384
  }> | null, responseHeaderSchema?: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null, externalDocs?: {
339
385
  url: string;
340
386
  description?: string;
341
- } | null, links?: Record<string, LinkDefinition> | null, callbacks?: Record<string, CallbackDefinition> | null);
387
+ } | null, links?: Record<string, LinkDefinition> | null, callbacks?: Record<string, CallbackDefinition> | null, fileUpload?: UploadOptions | null, cacheTags?: readonly CacheTagDefinition[]);
342
388
  /** Define the request body schema. Validation failures return 422 Problem Details. */
343
- body<TSchema extends SchemaBuilder<any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TSchema, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
389
+ body<TSchema extends SchemaBuilder<any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TSchema, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
344
390
  /** Define the query string schema (must be an object schema). Validation failures return 422. */
345
- query<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, InferType<TSchema>, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
391
+ query<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, InferType<TSchema>, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
346
392
  /** Define an expected request headers schema (must be an object schema). */
347
- headers<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, InferType<TSchema>, TServices, TPrincipal, TRoles, TResponse, TResponses>;
393
+ headers<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, InferType<TSchema>, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
348
394
  /** Declare DI services to be resolved per-request and passed as the second handler argument. */
349
- inject<TSchemas extends Record<string, SchemaBuilder<any, any, any, any, any>>>(schemas: TSchemas): EndpointBuilder<TParams, TBody, TQuery, THeaders, TSchemas, TPrincipal, TRoles, TResponse, TResponses>;
395
+ inject<TSchemas extends Record<string, SchemaBuilder<any, any, any, any, any>>>(schemas: TSchemas): EndpointBuilder<TParams, TBody, TQuery, THeaders, TSchemas, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
350
396
  /**
351
397
  * Mark this endpoint as requiring authorization.
352
398
  *
@@ -356,8 +402,8 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
356
402
  *
357
403
  * If no roles are specified, any authenticated user is allowed.
358
404
  */
359
- authorize<TSchema extends SchemaBuilder<any, any, any, any, any>>(principalSchema: TSchema, ...roles: TRoles[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, InferType<TSchema>, TRoles, TResponse, TResponses>;
360
- authorize(...roles: TRoles[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, unknown, TRoles, TResponse, TResponses>;
405
+ authorize<TSchema extends SchemaBuilder<any, any, any, any, any>>(principalSchema: TSchema, ...roles: TRoles[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, InferType<TSchema>, TRoles, TResponse, TResponses, TUpload>;
406
+ authorize(...roles: TRoles[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, unknown, TRoles, TResponse, TResponses, TUpload>;
361
407
  /**
362
408
  * Declare the response type for OpenAPI spec generation.
363
409
  *
@@ -365,8 +411,8 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
365
411
  * - `returns<T>()` — generic type only, no runtime schema
366
412
  * - `returns(schema)` — provides a schema for spec generation and type inference
367
413
  */
368
- returns<T>(): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, T, TResponses>;
369
- returns<TSchema extends SchemaBuilder<any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TSchema, TResponses>;
414
+ returns<T>(): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, T, TResponses, TUpload>;
415
+ returns<TSchema extends SchemaBuilder<any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TSchema, TResponses, TUpload>;
370
416
  /**
371
417
  * Declare per-status-code response schemas for OpenAPI generation and
372
418
  * handler return-type enforcement.
@@ -389,17 +435,17 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
389
435
  * };
390
436
  * ```
391
437
  */
392
- responses<const T extends Record<number, SchemaBuilder<any, any, any, any, any> | null>>(map: T): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, InferResponsesMap<T>>;
438
+ responses<const T extends Record<number, SchemaBuilder<any, any, any, any, any> | null>>(map: T): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, InferResponsesMap<T>, TUpload>;
393
439
  /** Short, human-readable summary for OpenAPI operation objects. */
394
- summary(text: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
440
+ summary(text: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
395
441
  /** Longer description for OpenAPI operation objects. Supports Markdown. */
396
- description(text: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
442
+ description(text: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
397
443
  /** OpenAPI tags grouping this operation in generated documentation. */
398
- tags(...tags: string[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
444
+ tags(...tags: string[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
399
445
  /** A unique, stable identifier for this operation in OpenAPI spec. */
400
- operationId(id: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
446
+ operationId(id: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
401
447
  /** Mark this endpoint as deprecated in OpenAPI spec output. */
402
- deprecated(): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
448
+ deprecated(): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
403
449
  /**
404
450
  * Provide a single example value for the request body.
405
451
  *
@@ -408,7 +454,7 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
408
454
  *
409
455
  * @param value - An example request body value.
410
456
  */
411
- example(value: TBody): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
457
+ example(value: TBody): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
412
458
  /**
413
459
  * Provide named examples for the request body.
414
460
  *
@@ -421,7 +467,7 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
421
467
  summary?: string;
422
468
  description?: string;
423
469
  value: TBody;
424
- }>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
470
+ }>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
425
471
  /**
426
472
  * Declare that this endpoint produces a binary file response.
427
473
  *
@@ -431,7 +477,41 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
431
477
  * @param contentType - MIME type (default: `'application/octet-stream'`).
432
478
  * @param description - Optional response description for the spec.
433
479
  */
434
- producesFile(contentType?: string, description?: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
480
+ producesFile(contentType?: string, description?: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
481
+ /**
482
+ * Mark this endpoint as accepting `multipart/form-data` file uploads.
483
+ *
484
+ * When set, the server parses the request body with a streaming multipart
485
+ * parser instead of the default JSON deserializer. File fields are made
486
+ * available to the handler via `arg.files` (a `Record<string, FilePart>`),
487
+ * while non-file form fields are validated against the body schema and
488
+ * available via `arg.body`.
489
+ *
490
+ * @param options - Upload configuration (max file size, allowed MIME types, etc.).
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * const UploadAvatar = endpoint
495
+ * .post('/api/avatar')
496
+ * .upload({ maxFileSize: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] })
497
+ * .authorize(PrincipalSchema)
498
+ * .responses({ 200: AvatarSchema });
499
+ *
500
+ * const handler: Handler<typeof UploadAvatar> = async ({ files }) => {
501
+ * const avatar = files['avatar'];
502
+ * // avatar: { filename, mimeType, buffer, size }
503
+ * };
504
+ * ```
505
+ */
506
+ upload(options?: UploadOptions): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, true>;
507
+ /**
508
+ * The full path for this endpoint, combining `basePath` and `pathTemplate`.
509
+ *
510
+ * For static routes this is the exact URL path (e.g. `"/todos"`).
511
+ * For dynamic routes the template placeholders are included
512
+ * (e.g. `"/todos/:id"`).
513
+ */
514
+ get path(): string;
435
515
  /** Return an immutable snapshot of this builder's configuration as {@link EndpointMetadata}. */
436
516
  introspect(): EndpointMetadata;
437
517
  /**
@@ -457,7 +537,7 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
457
537
  */
458
538
  produces(contentTypes: Record<string, {
459
539
  schema?: SchemaBuilder<any, any, any, any, any>;
460
- }>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
540
+ }>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
461
541
  /**
462
542
  * Declare response headers emitted by this endpoint.
463
543
  *
@@ -476,7 +556,7 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
476
556
  * }))
477
557
  * ```
478
558
  */
479
- responseHeaders<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
559
+ responseHeaders<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
480
560
  /**
481
561
  * Link external documentation to this operation.
482
562
  *
@@ -485,7 +565,7 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
485
565
  * @param url - The URL to the external documentation.
486
566
  * @param description - Optional short description of the external docs.
487
567
  */
488
- externalDocs(url: string, description?: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
568
+ externalDocs(url: string, description?: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
489
569
  /**
490
570
  * Declare response links for OpenAPI spec generation.
491
571
  *
@@ -506,7 +586,7 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
506
586
  * })
507
587
  * ```
508
588
  */
509
- links(defs: Record<string, LinkDefinition<TResponse>>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
589
+ links(defs: Record<string, LinkDefinition<TResponse>>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
510
590
  /**
511
591
  * Declare callbacks for OpenAPI spec generation.
512
592
  *
@@ -529,7 +609,51 @@ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {
529
609
  * })
530
610
  * ```
531
611
  */
532
- callbacks(defs: Record<string, CallbackDefinition<TBody>>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
612
+ callbacks(defs: Record<string, CallbackDefinition<TBody>>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses, TUpload>;
613
+ /**
614
+ * Declare a cache group for this endpoint.
615
+ *
616
+ * Use on GET / query endpoints to group responses into a named cache.
617
+ * The client-side {@code cacheTags} middleware caches responses keyed
618
+ * by this tag and flushes matching entries when a mutation calls
619
+ * {@link clearsCacheTag}.
620
+ *
621
+ * @overload Simple tag (no properties — single cache entry).
622
+ * @overload Tag with property descriptors for fine-grained keys.
623
+ *
624
+ * @example
625
+ * ```ts
626
+ * // GET — responses cached under "todo" group, keyed by id
627
+ * endpoint.get('/api/todos/:id')
628
+ * .cacheTag('todo', p => ({
629
+ * id: p.params.id
630
+ * }))
631
+ * ```
632
+ */
633
+ cacheTag(name: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
634
+ cacheTag(name: string, selector: (tree: CacheTagSelector<TParams, TBody, TQuery, THeaders>) => Record<string, unknown>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
635
+ /**
636
+ * Declare which cache groups are cleared when this mutation succeeds.
637
+ *
638
+ * Use on POST / PUT / PATCH / DELETE endpoints. When the mutation
639
+ * completes, the {@code cacheTags} client middleware invalidates all
640
+ * cache entries matching the declared tag names (prefix match).
641
+ *
642
+ * @overload Simple tag (clears all entries prefixed with the name).
643
+ * @overload Tag with property descriptors for targeted invalidation.
644
+ *
645
+ * @example
646
+ * ```ts
647
+ * // PATCH — clears "todo-list" and "todo:id=42" on success
648
+ * endpoint.patch('/api/todos/:id')
649
+ * .clearsCacheTag('todo-list')
650
+ * .clearsCacheTag('todo', p => ({
651
+ * id: p.params.id
652
+ * }))
653
+ * ```
654
+ */
655
+ clearsCacheTag(name: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
656
+ clearsCacheTag(name: string, selector: (tree: CacheTagSelector<TParams, TBody, TQuery, THeaders>) => Record<string, unknown>): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse, TResponses>;
533
657
  }
534
658
  /**
535
659
  * Optional OpenAPI metadata fields accepted by `createEndpoint` / `createEndpoints`.
@@ -549,6 +673,12 @@ type ScopedEndpointFactoryMethods<TPrincipal, TRoles extends string = string> =
549
673
  delete<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
550
674
  head<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
551
675
  options<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
676
+ post<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams extends undefined ? {} : TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
677
+ put<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams extends undefined ? {} : TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
678
+ patch<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams extends undefined ? {} : TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
679
+ delete<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams extends undefined ? {} : TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
680
+ head<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams extends undefined ? {} : TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
681
+ options<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams extends undefined ? {} : TParams, undefined, {}, {}, {}, TPrincipal, TRoles, any, {}>;
552
682
  };
553
683
  export type ScopedEndpointFactory<TRoles extends string = string> = ScopedEndpointFactoryMethods<undefined, TRoles> & {
554
684
  /**
@@ -562,13 +692,13 @@ export type ScopedEndpointFactory<TRoles extends string = string> = ScopedEndpoi
562
692
  authorize(...roles: TRoles[]): ScopedEndpointFactoryMethods<unknown, TRoles>;
563
693
  };
564
694
  type EndpointFactory<TRoles extends string = string> = {
565
- get<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles, any, {}>;
566
- post<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles, any, {}>;
567
- put<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles, any, {}>;
568
- patch<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles, any, {}>;
569
- delete<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles, any, {}>;
570
- head<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles, any, {}>;
571
- options<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles, any, {}>;
695
+ get<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, any, TRoles, any, {}>;
696
+ post<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, any, TRoles, any, {}>;
697
+ put<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, any, TRoles, any, {}>;
698
+ patch<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, any, TRoles, any, {}>;
699
+ delete<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, any, TRoles, any, {}>;
700
+ head<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, any, TRoles, any, {}>;
701
+ options<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, any, TRoles, any, {}>;
572
702
  resource(basePath: string): ScopedEndpointFactory<TRoles>;
573
703
  subscription<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): SubscriptionBuilder<TParams, {}, {}, {}, undefined, TRoles>;
574
704
  };
package/dist/Server.d.ts CHANGED
@@ -46,6 +46,7 @@ export interface AuthorizationConfig {
46
46
  */
47
47
  export declare class ServerBuilder {
48
48
  #private;
49
+ constructor(options?: ServerOptions);
49
50
  /**
50
51
  * Configure the DI service collection.
51
52
  *
@@ -78,6 +79,8 @@ export declare class ServerBuilder {
78
79
  /**
79
80
  * Enable the `GET /health` endpoint that returns `{ ok: true }` (200).
80
81
  * Useful for load balancer and container readiness probes.
82
+ *
83
+ * The path is available as {@link HEALTHCHECK_PATH}.
81
84
  */
82
85
  withHealthcheck(): this;
83
86
  /**