@cleverbrush/server 4.5.0 → 5.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/LICENSE +28 -0
- package/README.md +486 -20
- package/dist/Cors.d.ts +28 -0
- package/dist/Endpoint.d.ts +32 -14
- package/dist/EndpointOptions.d.ts +31 -0
- package/dist/EndpointPath.d.ts +23 -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/chunk-YCSREVQU.js +2 -0
- package/dist/chunk-YCSREVQU.js.map +1 -0
- package/dist/contract.d.ts +4 -2
- package/dist/contract.js +1 -1
- package/dist/index.d.ts +7 -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 +7 -0
- 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/dist/withRequestSignal.d.ts +29 -0
- package/package.json +10 -7
- package/dist/chunk-L2OPVEOF.js +0 -2
- package/dist/chunk-L2OPVEOF.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
|
|
|
@@ -227,34 +552,54 @@ validated against the endpoint body schema. Repeated fields become arrays:
|
|
|
227
552
|
|
|
228
553
|
## File Upload
|
|
229
554
|
|
|
230
|
-
|
|
555
|
+
Declare uploaded fields with schema builders. Import contracts through the
|
|
556
|
+
browser-safe entry point when sharing them with a client:
|
|
231
557
|
|
|
232
558
|
```ts
|
|
233
|
-
import { endpoint } from '@cleverbrush/server';
|
|
234
|
-
import { object, string } from '@cleverbrush/schema';
|
|
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
|
+
```
|
|
235
569
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
241
575
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
248
582
|
|
|
249
|
-
The `
|
|
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.
|
|
250
586
|
|
|
251
587
|
### Options
|
|
252
588
|
|
|
253
|
-
| Option |
|
|
254
|
-
|
|
255
|
-
| `maxFileSize` |
|
|
256
|
-
| `
|
|
257
|
-
| `
|
|
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`.
|
|
258
603
|
|
|
259
604
|
### FilePart type
|
|
260
605
|
|
|
@@ -353,6 +698,9 @@ server.handle(GetUser, ({ params }) => {
|
|
|
353
698
|
|
|
354
699
|
## WebSocket Subscriptions
|
|
355
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
|
+
|
|
356
704
|
Define real-time endpoints using `endpoint.subscription()`:
|
|
357
705
|
|
|
358
706
|
```ts
|
|
@@ -569,6 +917,124 @@ apps/
|
|
|
569
917
|
backend/ // imports fullApi — handles all routes
|
|
570
918
|
```
|
|
571
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
|
+
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
### Endpoint path helpers
|
|
996
|
+
|
|
997
|
+
`endpointPath(endpoint, params)` constructs an encoded pathname from a contract.
|
|
998
|
+
`matchEndpointPath(endpoint, pathname)` returns validated parameters or `undefined`.
|
|
999
|
+
Both are exported from `@cleverbrush/server/contract` for browser use and from the
|
|
1000
|
+
main package. Matching checks paths only, not methods or authorization. Origins,
|
|
1001
|
+
query strings and fragments are intentionally separate.
|
|
1002
|
+
|
|
1003
|
+
```ts
|
|
1004
|
+
import { endpointPath, matchEndpointPath } from '@cleverbrush/server/contract';
|
|
1005
|
+
|
|
1006
|
+
const path = endpointPath(api.files.content, { id: 'report 1' });
|
|
1007
|
+
const params = matchEndpointPath(api.files.content, path);
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
Parameters are URI-component encoded and decoded once. Invalid values throw
|
|
1011
|
+
during construction; mismatches, malformed encoding and invalid parameter values
|
|
1012
|
+
return `undefined` when matching. One trailing slash is accepted.
|
|
1013
|
+
|
|
572
1014
|
## License
|
|
573
1015
|
|
|
574
1016
|
BSD-3-Clause — see [LICENSE](../../LICENSE).
|
|
1017
|
+
|
|
1018
|
+
## Request cancellation and deadlines
|
|
1019
|
+
|
|
1020
|
+
Use `withRequestSignal(context, work, { timeoutMs, signal? })` for bounded,
|
|
1021
|
+
request-owned work. The timeout is an application policy, so it is required and
|
|
1022
|
+
must be a positive integer no greater than 2,147,483,647 milliseconds.
|
|
1023
|
+
|
|
1024
|
+
```ts
|
|
1025
|
+
import { withRequestSignal } from '@cleverbrush/server';
|
|
1026
|
+
|
|
1027
|
+
const result = await withRequestSignal(
|
|
1028
|
+
context,
|
|
1029
|
+
signal => reports.generate({ signal }),
|
|
1030
|
+
{ timeoutMs: 30_000, signal: shutdownSignal }
|
|
1031
|
+
);
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
The signal aborts on an unfinished response closing, request abort, deadline, or
|
|
1035
|
+
optional caller cancellation. A completed response closing normally does not
|
|
1036
|
+
abort it. Caller cancellation preserves its reason; disconnect and deadline use
|
|
1037
|
+
`AbortError` and `TimeoutError` DOMExceptions. Already-cancelled work never starts.
|
|
1038
|
+
Timers and listeners are released on success or failure. Cancellation is
|
|
1039
|
+
cooperative: the work must observe or forward its signal. This helper neither
|
|
1040
|
+
races arbitrary work nor changes HTTP error/status policies.
|
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 {};
|