@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 +28 -0
- package/README.md +457 -24
- package/dist/CacheTag.d.ts +1 -17
- package/dist/Cors.d.ts +28 -0
- package/dist/Endpoint.d.ts +33 -15
- package/dist/EndpointOptions.d.ts +31 -0
- package/dist/ErrorMap.d.ts +90 -0
- package/dist/Implementation.d.ts +193 -0
- package/dist/RequestContext.d.ts +2 -2
- package/dist/Server.d.ts +17 -9
- package/dist/VirtualHttp.d.ts +2 -0
- package/dist/cacheKey.d.ts +17 -0
- package/dist/chunk-UZECVBRI.js +2 -0
- package/dist/chunk-UZECVBRI.js.map +1 -0
- package/dist/contract.d.ts +4 -2
- package/dist/contract.js +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/middlewares/Idempotency.d.ts +20 -41
- package/dist/middlewares/ResponseCache.d.ts +9 -2
- package/dist/middlewares/ResponseSnapshot.d.ts +9 -0
- package/dist/multipart.d.ts +9 -0
- package/dist/types.d.ts +12 -1
- package/dist/upload.d.ts +16 -0
- package/package.json +10 -7
- package/dist/chunk-PLSVKHPB.js +0 -2
- package/dist/chunk-PLSVKHPB.js.map +0 -1
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
|
[](https://github.com/cleverbrush/framework/actions/workflows/ci.yml)
|
|
4
4
|
[](../../LICENSE)
|
|
5
|
+
<!-- coverage-badge-start -->
|
|
6
|
+

|
|
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 })) //
|
|
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
|
-
|
|
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
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 |
|
|
242
|
-
|
|
243
|
-
| `maxFileSize` |
|
|
244
|
-
| `
|
|
245
|
-
| `
|
|
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).
|
package/dist/CacheTag.d.ts
CHANGED
|
@@ -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 {};
|