@cleverbrush/server 4.4.3 → 5.0.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/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (C) 2024 by Andrew Zolotuhkin <andrew_zol@cleverbrush.com>
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice,
9
+ this list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its contributors
16
+ may be used to endorse or promote products derived from this software
17
+ without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -2,15 +2,33 @@
2
2
 
3
3
  [![CI](https://github.com/cleverbrush/framework/actions/workflows/ci.yml/badge.svg)](https://github.com/cleverbrush/framework/actions/workflows/ci.yml)
4
4
  [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](../../LICENSE)
5
+ <!-- coverage-badge-start -->
6
+ ![Unit coverage](https://img.shields.io/badge/unit_coverage-92%25-brightgreen)
7
+ <!-- coverage-badge-end -->
5
8
 
6
9
  A schema-first HTTP server framework for Node.js. Combines [`@cleverbrush/schema`](../schema) for request validation, [`@cleverbrush/di`](../di) for dependency injection, and [`@cleverbrush/auth`](../auth) for authentication — all wired together through a fluent builder API.
7
10
 
11
+ ## Structured validation paths for forms
12
+
13
+ Request validation retains its existing 400 Problem Details envelope and
14
+ `errors: [{ pointer, detail }]` extension. Nested arrays now expose indexed paths
15
+ such as `/body/addresses/0/city` alongside existing aggregate errors. Property
16
+ names use JSON Pointer escaping (`~0` for `~`, `~1` for `/`), including query and
17
+ header names. These paths describe the request, not a particular UI.
18
+
19
+ The optional client `decodeValidationIssues(error, { source: 'body' })` adapter
20
+ produces serializable form-relative issues; see the
21
+ [multi-file consumer example](../react-form/README.md#server-validation-issues).
22
+ Application-owned business errors need explicit structured paths to appear beside
23
+ fields; the framework never derives a field name from an exception's message.
24
+
8
25
  ## Features
9
26
 
10
27
  - **Fluent endpoint builder** — `endpoint.get('/users').body(schema).query(schema).authorize()` with fully typed handler context.
11
28
  - **Action results** — `ActionResult.ok()`, `.created()`, `.noContent()`, `.redirect()`, `.file()`, `.stream()`, `.raw()`, `.status()` — no manual `res.write()` / `res.end()` unless you explicitly opt in.
12
29
  - **Content negotiation** — pluggable `ContentTypeHandler` registry; JSON and `application/x-www-form-urlencoded` registered by default; honours the `Accept` request header.
13
30
  - **Middleware pipeline** — `server.use(middleware)` for global middleware; per-endpoint middleware via `handle(ep, handler, { middlewares })`.
31
+ - **Opt-in CORS** — `server.useCors()` handles route-aware preflights before authentication, with explicit origins or asynchronous origin predicates.
14
32
  - **DI integration** — `endpoint.inject({ db: IDbContext })` resolves services per-request from a `@cleverbrush/di` container.
15
33
  - **Authentication & authorization** — `server.useAuthentication()` / `server.useAuthorization()` wired to `@cleverbrush/auth` schemes and policies.
16
34
  - **RFC 9457 Problem Details** — validation errors and `HttpError` subclasses are serialized as `application/problem+json`.
@@ -20,6 +38,313 @@ A schema-first HTTP server framework for Node.js. Combines [`@cleverbrush/schema
20
38
  - **Health check** — optional `/health` endpoint via `server.withHealthcheck()`.
21
39
  - **WebSocket subscriptions** — `endpoint.subscription('/ws/path')` with typed incoming/outgoing schemas, `tracked()` events, and async generator handlers.
22
40
  - **Contract composition** — `mergeContracts`, `pickGroups`, and `omitGroups` enable audience-scoped bundles: ship only the endpoints each consumer needs.
41
+ - **Modular implementations** — `implement(api)` derives server-configured scopes, keeps separate handler files strongly typed, and checks full contract coverage at final registration.
42
+ - **Typed error policies** — `errorMap()` and `withErrors()` translate known handler exceptions without repeated catch blocks or widening endpoint responses.
43
+
44
+ ## CORS
45
+
46
+ Enable CORS with an explicit server-wide policy:
47
+
48
+ ```ts
49
+ import { createServer } from '@cleverbrush/server';
50
+
51
+ const server = createServer().useCors({
52
+ origin: ['https://app.example.com', 'http://localhost:5173'],
53
+ methods: ['GET', 'POST', 'PATCH', 'DELETE'],
54
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
55
+ exposedHeaders: ['WWW-Authenticate', 'X-Request-Id'],
56
+ credentials: true,
57
+ maxAgeSeconds: 600
58
+ });
59
+ ```
60
+
61
+ `ServerCorsOptions` is exported from `@cleverbrush/server`. The policy is
62
+ validated and copied before listening. Origins are exact serialized URL origins
63
+ (scheme, host and optional port, without a path or trailing slash). The special
64
+ origin string `null` can be included explicitly for opaque browser origins.
65
+
66
+ | Option | Behavior / default |
67
+ | --- | --- |
68
+ | `origin` | Required: an exact origin, readonly origin list, `'*'`, or `(origin: string) => boolean \| Promise<boolean>`. Empty lists deny all origins. |
69
+ | `methods` | Optional preflight allowlist, intersected with the registered routes. By default, any method registered for the requested URL may be preflighted. |
70
+ | `allowedHeaders` | Explicit preflight request-header names; case-insensitive, default `[]`. Include `Authorization` or custom auth headers when needed. |
71
+ | `exposedHeaders` | Additional response-header names browsers may read; default `[]`. |
72
+ | `credentials` | Default `false`. With `true`, an exact accepted origin is returned. `origin: '*'` with credentials is rejected at startup. |
73
+ | `maxAgeSeconds` | Non-negative integer browser preflight cache duration, default `0`. |
74
+
75
+ Method and header lists use explicit names; wildcard entries are not supported.
76
+ `origin: '*'` explicitly permits all valid origins, including opaque `null`
77
+ origins, without credential support. CORS is disabled until `useCors` is called.
78
+
79
+ For domains determined at request time, use a predicate backed by application
80
+ configuration or a domain registry:
81
+
82
+ ```ts
83
+ server.useCors({
84
+ origin: async origin => tenantDomains.isAllowed(origin),
85
+ allowedHeaders: ['Content-Type', 'Authorization']
86
+ });
87
+ ```
88
+
89
+ The predicate is awaited once per physical HTTP request carrying a valid `Origin`,
90
+ including preflights; it is not called for requests without `Origin`. Results are
91
+ not cached by the server. Browser preflight caching follows `maxAgeSeconds`.
92
+ Returning `false` rejects the request with `403` before authentication or handlers.
93
+ Throwing or rejecting returns a generic `500` without exposing the callback error.
94
+ Calling `useCors` again replaces the policy for subsequently started servers.
95
+
96
+ ### Execution order and responses
97
+
98
+ CORS runs before routing, body parsing, DI scopes and ordinary middleware,
99
+ regardless of where `useCors` appears in the builder chain. An `OPTIONS` request
100
+ with both `Origin` and `Access-Control-Request-Method` is a preflight. Accepted
101
+ preflights return an empty `204` without running authentication or handlers.
102
+ Only registered HTTP routes and enabled health/batch endpoints are eligible.
103
+ Malformed preflights return `400`, unknown routes `404`, unsupported route methods
104
+ `405`, and policy denials `403`. Denied preflights have no CORS permission headers.
105
+ Ordinary `OPTIONS` requests still use registered handlers and normal routing.
106
+
107
+ Actual requests from accepted origins follow the existing middleware and
108
+ authentication pipeline. CORS headers accompany successful and error responses,
109
+ including authentication challenges, validation failures and routing errors.
110
+ Disallowed or malformed origins receive `403` before handlers run. Requests
111
+ without `Origin` retain normal processing and receive no CORS permission headers.
112
+ Authentication remains responsible for resource access; the method/header lists
113
+ govern browser preflight permission, not ordinary HTTP routing.
114
+
115
+ When enabled, the CORS stage owns its six standard response headers and finalizes
116
+ them as headers are sent, including raw/streamed responses and cache/idempotency
117
+ replays. Configure CORS through this API rather than writing competing CORS headers
118
+ in middleware. Existing `Vary` values are preserved and merged with `Origin`, plus
119
+ the requested method/header fields on preflights. Cached CORS permissions are not
120
+ reused for another origin. Virtual batch subrequests retain their usual auth
121
+ pipeline; CORS applies to the outer HTTP request. WebSocket upgrades are outside
122
+ this policy. Ordinary middleware, including middleware-based tracing, does not run
123
+ for CORS short-circuits.
124
+
125
+ ## Large APIs and shared error handling
126
+
127
+ `implement` adds server-only configuration and complete handler registration to
128
+ an existing shared API contract. It does not replace endpoint builders, change
129
+ the wire contract, or require business logic inside one fluent expression.
130
+ `errorMap` and `withErrors` also work independently of this registration API.
131
+
132
+ ### A complete multi-file example
133
+
134
+ The application functions and service registration below are application-owned.
135
+ The contract in this example has exactly two operations, so its root is complete.
136
+
137
+ #### Shared contract
138
+
139
+ ```ts
140
+ // contracts.ts — browser-safe; no server configuration or handler imports
141
+ import { defineApi, endpoint, route } from '@cleverbrush/server/contract';
142
+ import { array, number, object, string } from '@cleverbrush/schema';
143
+
144
+ export const Item = object({ id: number(), title: string() });
145
+ const Message = object({ message: string() });
146
+ export const Principal = object({ userId: string() });
147
+ const resource = endpoint.resource('/items').authorize(Principal);
148
+
149
+ export const api = defineApi({
150
+ items: {
151
+ list: resource.get().responses({ 200: array(Item) }),
152
+ remove: resource.delete(
153
+ route({ id: number().coerce() })`/${p => p.id}`
154
+ ).responses({ 204: null, 404: Message })
155
+ }
156
+ });
157
+ ```
158
+
159
+ #### Server configuration without handlers
160
+
161
+ ```ts
162
+ // features/items/scope.ts
163
+ import { implement } from '@cleverbrush/server';
164
+ import { api } from '../../contracts.js';
165
+ import { DbToken } from '../../di/tokens.js';
166
+
167
+ export const items = implement(api).group('items', {
168
+ inject: { db: DbToken },
169
+ tags: ['items'],
170
+ operations: {
171
+ list: { summary: 'List items', operationId: 'listItems' },
172
+ remove: { summary: 'Remove an item', operationId: 'removeItem' }
173
+ }
174
+ });
175
+ ```
176
+
177
+ `items.endpoints` contains immutable, server-configured builders. Export the
178
+ scope constant and reference it with `typeof`; do not annotate it with a broad
179
+ base type that erases operation names or service types.
180
+
181
+ #### Separate handlers
182
+
183
+ ```ts
184
+ // features/items/handlers/list.ts
185
+ import type { Handler } from '@cleverbrush/server';
186
+ import type { items } from '../scope.js';
187
+ import { listItems } from '../../../application/items.js';
188
+
189
+ export const list: Handler<typeof items.endpoints.list> = (
190
+ { principal }, { db }
191
+ ) => listItems(db, principal.userId);
192
+ ```
193
+
194
+ ```ts
195
+ // features/items/handlers/remove.ts
196
+ import { ActionResult, type Handler } from '@cleverbrush/server';
197
+ import type { items } from '../scope.js';
198
+ import { removeItem } from '../../../application/items.js';
199
+
200
+ export const remove: Handler<typeof items.endpoints.remove> = async (
201
+ { params, principal }, { db }
202
+ ) => {
203
+ await removeItem(db, principal.userId, params.id);
204
+ return ActionResult.noContent();
205
+ };
206
+ ```
207
+
208
+ The application function can throw a domain exception. Handlers do not need a
209
+ repeated catch block. Requests, principals, injected services, and responses are
210
+ derived at the handler's declaration site, not inferred retrospectively from a
211
+ later registration call. Type-only scope imports avoid runtime import cycles.
212
+
213
+ #### Application-owned policy and feature registration
214
+
215
+ ```ts
216
+ // features/items/errors.ts
217
+ import { ActionResult, errorMap } from '@cleverbrush/server';
218
+ import { MissingItemError } from '../../application/errors.js';
219
+
220
+ export const itemErrors = errorMap().on(MissingItemError, () =>
221
+ ActionResult.notFound({ message: 'Item not found' })
222
+ );
223
+ ```
224
+
225
+ ```ts
226
+ // features/items/index.ts
227
+ import { items } from './scope.js';
228
+ import { itemErrors } from './errors.js';
229
+ import { list } from './handlers/list.js';
230
+ import { remove } from './handlers/remove.js';
231
+
232
+ export const itemsModule = items.withHandlers({
233
+ list,
234
+ remove: { handler: remove, errors: itemErrors }
235
+ });
236
+ ```
237
+
238
+ Attaching this policy to `list` is a type error: `list` does not declare 404.
239
+ Declaring 204 on `remove` does not permit arbitrary other statuses or bodies.
240
+
241
+ ```ts
242
+ // server.ts — after the application's ordinary server/DI setup
243
+ import { implement } from '@cleverbrush/server';
244
+ import { api } from './contracts.js';
245
+ import { itemsModule } from './features/items/index.js';
246
+
247
+ server.handleAll(implement(api).use(itemsModule).complete());
248
+ ```
249
+
250
+ `complete()` fails at compile time if an operation is missing, and checks again
251
+ at runtime for JavaScript callers and erased types. The output is the existing
252
+ `HandlerMapping`. Authentication, validation, caching, batching, middleware,
253
+ serialization, logging, and OpenAPI generation use their existing paths.
254
+
255
+ ### Split large features and compose contract slices
256
+
257
+ ```ts
258
+ export const reads = items.pick('list').withHandlers({ list });
259
+ export const writes = items.pick('remove').withHandlers({
260
+ remove: { handler: remove, errors: itemErrors }
261
+ });
262
+ export const feature = implement(api).use(reads, writes);
263
+
264
+ // Partial roots can be exported and composed; finalize only at the full root.
265
+ server.handleAll(implement(api).use(feature).complete());
266
+ ```
267
+
268
+ Other groups are separate feature modules added to the same `use(...)` call.
269
+ Pass modules directly or in a literal tuple, not an array widened to a generic
270
+ module type. Duplicate bindings are rejected across modules and calls. Source
271
+ endpoint identity is checked at runtime as well: independently recreated
272
+ endpoints are not interchangeable just because their TypeScript shapes match.
273
+ `pickGroups`, `omitGroups`, and `mergeContracts` preserve endpoint references,
274
+ so intentional contract slices can be implemented and recomposed.
275
+
276
+ Subscriptions use the same scopes, `SubscriptionHandler<typeof scope.endpoints.x>`,
277
+ and existing subscription handler descriptors. They count toward completeness.
278
+ HTTP error policies are not accepted on subscription handlers and do not change
279
+ the WebSocket error protocol.
280
+
281
+ ### Configuration rules
282
+
283
+ - `inject` merges contract bindings, then group bindings, then operation bindings.
284
+ A same-name binding is replaced by the later value in both the type and runtime
285
+ service map. Other dependencies remain available. Endpoint `.inject()` itself
286
+ retains its existing replacement behavior.
287
+ - An operation's `authorize` schema takes precedence over a group's schema;
288
+ existing endpoint roles are retained. Supplying authorization explicitly makes
289
+ even a previously public operation authenticated. Omit it when authorization
290
+ already lives entirely in the shared contract. There is no implicit auth default.
291
+ - Operation `tags` replace group tags; group tags replace existing tags. Other
292
+ metadata changes only when supplied: `summary`, `description`, `operationId`,
293
+ and `deprecated: true`. Deprecation is additive and cannot be cleared here.
294
+ - Request/response schemas, cache definitions, upload settings, links, examples,
295
+ and other endpoint metadata remain on the shared endpoint. Scopes do not edit
296
+ the wire contract or silently add error responses.
297
+ - Handler descriptors support existing per-operation `middlewares`. Configuration
298
+ scopes do not register anything until explicitly bound and composed.
299
+
300
+ ### Error policy behavior and limits
301
+
302
+ Policies are immutable. Each `.on(ErrorClass, translator)` returns a new policy.
303
+ Rules match by `instanceof` in declaration order; put subclasses before base
304
+ classes. Both handlers and translators may be synchronous or asynchronous.
305
+
306
+ Translators return explicit JSON or bodyless `ActionResult` values. Every result
307
+ must fit the target endpoint's `.responses()` status/body map. Policies cannot
308
+ use raw/file/stream results to escape that check, and endpoints without explicit
309
+ responses cannot attach a policy. TypeScript escape hatches (`any`, assertions,
310
+ or deliberately erased annotations) still bypass static guarantees; this is not
311
+ a replacement for runtime response validation.
312
+
313
+ Only exceptions from the handler invocation are translated. Authentication,
314
+ request validation, DI resolution, middleware, and response serialization are
315
+ outside the wrapper. Unknown thrown values are rethrown unchanged. If a translator
316
+ fails, its failure propagates without recursively applying the policy again.
317
+ Existing centralized handling still logs unexpected failures and sends safe 500
318
+ Problem Details. There is no automatic catch-all or exposure of `Error.message`.
319
+
320
+ Application authors decide which errors are safe to map and which details can
321
+ be returned. Framework has no knowledge of domain ownership or privacy rules.
322
+
323
+ ### Incremental adoption
324
+
325
+ The old `handle` and `mapHandlers` APIs remain unchanged. Adopt just error policies
326
+ without migrating registration:
327
+
328
+ ```ts
329
+ import { withErrors } from '@cleverbrush/server';
330
+
331
+ server.handle(
332
+ RemoveItemEndpoint,
333
+ withErrors(RemoveItemEndpoint, itemErrors, removeHandler)
334
+ );
335
+ ```
336
+
337
+ The returned function can also be placed in an existing handler map. To migrate
338
+ registration, move server-only metadata/DI into scopes, type the existing handler
339
+ files against `scope.endpoints`, bind feature modules, and finalize at the root.
340
+ Keep the shared contract in `/contract` imports; implementation facilities and
341
+ policies are server-entry-point exports only.
342
+
343
+ The repository tests include strict type assertions against emitted package
344
+ declarations and a generated 1,000-operation consumer with one handler per file.
345
+ The generated consumer verifies cross-file inference and records compiler
346
+ diagnostics, memory, and timings for both existing and modular registration
347
+ without using environment-dependent timing thresholds.
23
348
 
24
349
  ## Installation
25
350
 
@@ -147,7 +472,7 @@ const UpdateTodo = endpoint
147
472
  .patch('/api/todos/:id')
148
473
  .body(UpdateTodoBody)
149
474
  .clearsCacheTag('todo-list') // clears the collection cache
150
- .clearsCacheTag('todo', p => ({ id: p.params.id })) // clears specific entity
475
+ .clearsCacheTag('todo', p => ({ id: p.params.id })) // entity label + external computed key
151
476
  .returns(TodoSchema);
152
477
  ```
153
478
 
@@ -160,6 +485,18 @@ const UpdateTodo = endpoint
160
485
  - **Immutability** — both methods return a new builder; the original is
161
486
  unchanged.
162
487
 
488
+ `computeCacheKey` (also exported from the browser-safe `@cleverbrush/server/contract`)
489
+ and the client's `computeCacheTagKey` use the same deterministic `ct2:` encoding.
490
+ It distinguishes types, normalizes property order and preserves date milliseconds.
491
+ `cacheResponse()` invalidates only after successful mutations and prevents older
492
+ in-flight reads from refilling any invalidated alias. In-memory invalidation retains
493
+ tag-name-prefix coverage; it is not limited to the mutation's selected entity ID.
494
+
495
+ **Breaking:** computed keys changed, even for property-free tags. Upgrade external
496
+ writers/invalidators together and retire old entries. Literal base labels and TTLs
497
+ are unchanged. Endpoint response identity and auth/tenant isolation remain the
498
+ consumer's responsibility. See the [migration guide](../../docs/cache-form-migration.md#cache-key-migration-breaking).
499
+
163
500
  ## Registering and Handling Endpoints
164
501
 
165
502
  ```ts
@@ -215,34 +552,54 @@ validated against the endpoint body schema. Repeated fields become arrays:
215
552
 
216
553
  ## File Upload
217
554
 
218
- Accept file uploads via `multipart/form-data` by chaining `.upload()` on an endpoint:
555
+ Declare uploaded fields with schema builders. Import contracts through the
556
+ browser-safe entry point when sharing them with a client:
219
557
 
220
558
  ```ts
221
- import { endpoint } from '@cleverbrush/server';
222
- import { object, string } from '@cleverbrush/schema';
223
-
224
- const UploadAvatar = endpoint
225
- .post('/api/avatar')
226
- .upload({ maxFileSize: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] })
227
- .body(object({ description: string().optional() }))
228
- .authorize(UserPrincipal);
229
-
230
- const handler: Handler<typeof UploadAvatar> = async ({ body, files }) => {
231
- const avatar = files['avatar'];
232
- // avatar: FilePart { filename, mimeType, buffer, size }
233
- return ActionResult.created({ name: avatar.filename });
234
- };
235
- ```
236
-
237
- 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`.
559
+ import { endpoint, file } from '@cleverbrush/server/contract';
560
+ import { array, object, string } from '@cleverbrush/schema';
561
+
562
+ export const UploadAssets = endpoint.post('/api/assets')
563
+ .upload(object({
564
+ images: array(file()).minLength(1).maxLength(3),
565
+ cover: file().optional()
566
+ }), { allowedMimeTypes: ['image/*'] })
567
+ .body(object({ description: string().optional() }));
568
+ ```
569
+
570
+ Handlers receive `files.images: FilePart[]` in request order and an optional
571
+ `files.cover: FilePart`. Text fields are validated through `.body()`. Omit
572
+ `.body()` for a file-only endpoint. Text and file field names must be distinct.
573
+ A missing required array becomes `[]`; use `.minLength(1)` to require a file.
574
+ Absent optional file fields are omitted.
575
+
576
+ Typed upload endpoints reject malformed or invalid requests with `400` Problem
577
+ Details before calling the handler. Resource limits return `413`, including
578
+ oversized files, text fields, field names, request bodies and part counts.
579
+ Truncated content is never passed to handlers. Files are buffered in memory
580
+ within these limits; authorization runs before parsing. MIME allowlists compare
581
+ the declared multipart MIME type; they do not inspect file contents.
582
+
583
+ The options-only `.upload(options?)` overload accepts one `FilePart` per field.
584
+ It reports MIME exclusions through `rejectedFiles` (including `fieldName`) and
585
+ rejects duplicate file fields and limit violations.
238
586
 
239
587
  ### Options
240
588
 
241
- | Option | Type | Default | Description |
242
- |--------|------|---------|-------------|
243
- | `maxFileSize` | `number` | 10 MB | Maximum file size per file in bytes |
244
- | `allowedMimeTypes` | `string[]` | all | MIME type allowlist (supports `image/*` glob) |
245
- | `maxFileCount` | `number` | 10 | Maximum number of files per request |
589
+ | Option | Default | Meaning |
590
+ | --- | --- | --- |
591
+ | `maxFileSize` | 10 MiB | Bytes per file |
592
+ | `maxFileCount` | 10 | Files per request, including rejected files |
593
+ | `allowedMimeTypes` | All | Declared MIME types; supports patterns such as `image/*` |
594
+ | `maxFieldSize` | 1 MiB | Bytes per text field |
595
+ | `maxFieldCount` | 100 | Text fields per request |
596
+ | `maxFieldNameSize` | 100 | UTF-8 bytes per field name |
597
+ | `maxPartCount` | File-count limit + field-count limit | Total file and text parts |
598
+
599
+ Limits must be positive safe integers. The server's `maxBodySize` (default 5 MiB)
600
+ also limits the **entire multipart request**, including boundaries and headers.
601
+ Configure it large enough for the permitted file collection. It applies even
602
+ when the request has no `Content-Length`.
246
603
 
247
604
  ### FilePart type
248
605
 
@@ -341,6 +698,9 @@ server.handle(GetUser, ({ params }) => {
341
698
 
342
699
  ## WebSocket Subscriptions
343
700
 
701
+ Middleware that rejects a subscription closes its WebSocket with code `1008`.
702
+ Closing or disconnecting aborts the handler signal and disposes its DI scope.
703
+
344
704
  Define real-time endpoints using `endpoint.subscription()`:
345
705
 
346
706
  ```ts
@@ -557,6 +917,79 @@ apps/
557
917
  backend/ // imports fullApi — handles all routes
558
918
  ```
559
919
 
920
+ ## Response replay and caching
921
+
922
+ Declare mutation replay in the shared contract with `.idempotent()`. Bind an
923
+ explicit scope on the server; registration fails at startup when it is missing.
924
+ The optional `prepare` callback receives validated request data and injected
925
+ services and returns the request passed to scope resolution and the handler.
926
+ Use it to authorize resource access and resolve request defaults before replay.
927
+
928
+ ```ts
929
+ const createItem = endpoint.post('/items')
930
+ .idempotent()
931
+ .body(CreateItemSchema)
932
+ .authorize(UserPrincipal)
933
+ .inject({ db: DbToken })
934
+ .responses({ 201: ItemSchema, 403: MessageSchema });
935
+
936
+ server.handle(createItem, createItemHandler, {
937
+ prepare: async (request, { db }) => {
938
+ const workspace = await requireWorkspaceAccess(db, request.principal,
939
+ request.body.workspaceId);
940
+ return { ...request, body: { ...request.body, workspaceId: workspace.id } };
941
+ },
942
+ idempotency: {
943
+ scope: ({ principal, body }) => [principal.userId, body.workspaceId]
944
+ },
945
+ errors: itemErrors
946
+ });
947
+ ```
948
+
949
+ The same options work in `mapHandlers` and `implement(api).group(...).withHandlers`.
950
+ Authentication, validation, preparation and scope resolution run before each
951
+ replay, including batch subrequests. Preparation/scope exceptions use the bound
952
+ error policy without reserving a key. Handler exceptions translated by that
953
+ policy become replayable responses. Validation, DI and serialization errors
954
+ retain their normal Framework handling; standalone `withErrors` still wraps
955
+ only its handler.
956
+
957
+ `X-Idempotency-Key` is optional, case-insensitive, and must contain 1–256
958
+ characters when present. It is transport metadata, so no `.headers()` schema is
959
+ needed. OpenAPI includes this header and the 400/409/503 Problem Details responses.
960
+ When enabling cross-origin browser access, explicitly include this header in
961
+ your CORS `allowedHeaders`. Each server endpoint retains its own bounded store
962
+ with the defaults below; `idempotency` options can override its limits.
963
+
964
+ ### Low-level replay and caching
965
+
966
+
967
+ `idempotency({ scope })` coalesces concurrent mutations and replays their completed
968
+ responses within one middleware instance. Install it after authentication and
969
+ authorization. Derive `scope(ctx)` from verified identity/tenant context; returning
970
+ `undefined` bypasses replay. A constant scope is appropriate only for intentionally
971
+ public operations. Keys also include method and full URL. Reusing a key asserts
972
+ identical input; request bodies are not fingerprinted.
973
+
974
+ Defaults: `ttl: 86400000`, `maxEntries: 1000`, `maxResponseBytes: 65536`.
975
+ Capacity exhaustion returns 503 before execution. Oversized or incomplete responses
976
+ reserve the key; retries return 409 so the caller can verify the outcome. This is
977
+ process-local replay, not durable exactly-once execution: crashes, expiry or thrown
978
+ handler errors can permit a later execution. Use database deduplication for durable
979
+ business guarantees.
980
+
981
+ `cacheResponse()` retains at most 1000 keys and 65,536-byte complete response bodies
982
+ by default (`maxEntries`, `maxResponseBytes`); oldest keys are evicted at capacity.
983
+ It bypasses `Set-Cookie` and `Cache-Control: private`/`no-store` responses.
984
+ `defaultTtl` and `ttlByTag` are finite, non-negative milliseconds; retention uses
985
+ the longest TTL among an endpoint's tags (all zero disables retention).
986
+ Tags must distinguish endpoint shapes, verified identities,
987
+ tenants and all representation inputs. Both middleware capture `write()`/`end()`
988
+ chunks and restore response hooks on failure.
989
+
990
+ See [security guidance](../../SECURITY.md) and the
991
+ [v4.x-to-v5 migration guide](../../docs/MIGRATION-v5.md).
992
+
560
993
  ## License
561
994
 
562
995
  BSD-3-Clause — see [LICENSE](../../LICENSE).
@@ -57,20 +57,4 @@ export declare function createCacheTagTree(schemas: {
57
57
  * @throws If any value is not a valid property descriptor.
58
58
  */
59
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;
60
+ export { computeCacheKey } from './cacheKey.js';
package/dist/Cors.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /** Server-wide CORS policy, enabled explicitly with ServerBuilder.useCors(). */
3
+ export interface ServerCorsOptions {
4
+ /** Exact serialized origins, '*', or a predicate evaluated once per HTTP request with Origin. */
5
+ origin: string | readonly string[] | ((origin: string) => boolean | Promise<boolean>);
6
+ /** Optional preflight allowlist, restricted to registered routes. No wildcards. */
7
+ methods?: readonly string[];
8
+ /** Permitted preflight header names (case-insensitive). Defaults to none; no wildcards. */
9
+ allowedHeaders?: readonly string[];
10
+ /** Additional response headers browsers may read. Defaults to none; no wildcards. */
11
+ exposedHeaders?: readonly string[];
12
+ /** Allow browser credentials. Defaults to false; incompatible with origin: '*'. */
13
+ credentials?: boolean;
14
+ /** Browser preflight cache duration in seconds. Defaults to zero. */
15
+ maxAgeSeconds?: number;
16
+ }
17
+ type RouteStatus = {
18
+ status: 200 | 400 | 404 | 405;
19
+ allowedMethods?: readonly string[];
20
+ };
21
+ /** @internal Compiled CORS policy; deliberately separate from route middleware. */
22
+ export declare class CorsPolicy {
23
+ #private;
24
+ constructor(options: ServerCorsOptions);
25
+ /** Returns true when CORS has ended the response, false to run the usual pipeline. */
26
+ handle(req: IncomingMessage, res: ServerResponse, matchRoute: (method: string, path: string) => RouteStatus): Promise<boolean>;
27
+ }
28
+ export {};