@alxia/core 0.1.0 → 0.2.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.
Files changed (59) hide show
  1. package/README.md +103 -3
  2. package/dist/app/alxia.d.ts +53 -5
  3. package/dist/app/alxia.d.ts.map +1 -1
  4. package/dist/app/chain.d.ts +5 -8
  5. package/dist/app/chain.d.ts.map +1 -1
  6. package/dist/app/context.d.ts +12 -0
  7. package/dist/app/context.d.ts.map +1 -0
  8. package/dist/app/definition.d.ts +22 -1
  9. package/dist/app/definition.d.ts.map +1 -1
  10. package/dist/app/refusal.d.ts +15 -0
  11. package/dist/app/refusal.d.ts.map +1 -0
  12. package/dist/app/send.d.ts +8 -1
  13. package/dist/app/send.d.ts.map +1 -1
  14. package/dist/app/socket.d.ts +1 -1
  15. package/dist/app/socket.d.ts.map +1 -1
  16. package/dist/app/types/index.d.ts +1 -0
  17. package/dist/app/types/index.d.ts.map +1 -1
  18. package/dist/app/types/refusal.d.ts +94 -0
  19. package/dist/app/types/refusal.d.ts.map +1 -0
  20. package/dist/app/types/route-table.d.ts +3 -2
  21. package/dist/app/types/route-table.d.ts.map +1 -1
  22. package/dist/app/types/schema.d.ts +7 -0
  23. package/dist/app/types/schema.d.ts.map +1 -1
  24. package/dist/app/types/valid-schema.d.ts +1 -1
  25. package/dist/app/types/valid-schema.d.ts.map +1 -1
  26. package/dist/errors/errors.d.ts +47 -1
  27. package/dist/errors/errors.d.ts.map +1 -1
  28. package/dist/index.d.ts +5 -3
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +395 -97
  31. package/dist/index.js.map +20 -13
  32. package/dist/reply/problem.d.ts +33 -0
  33. package/dist/reply/problem.d.ts.map +1 -0
  34. package/dist/reply/reply.d.ts.map +1 -1
  35. package/dist/request/limit.d.ts +13 -0
  36. package/dist/request/limit.d.ts.map +1 -0
  37. package/dist/request/read.d.ts +3 -2
  38. package/dist/request/read.d.ts.map +1 -1
  39. package/dist/sse/async-iterable.d.ts +6 -0
  40. package/dist/sse/async-iterable.d.ts.map +1 -0
  41. package/dist/sse/event-stream.d.ts +29 -6
  42. package/dist/sse/event-stream.d.ts.map +1 -1
  43. package/dist/sse/frame.d.ts +35 -0
  44. package/dist/sse/frame.d.ts.map +1 -0
  45. package/dist/sse/named-events.d.ts +70 -0
  46. package/dist/sse/named-events.d.ts.map +1 -0
  47. package/docs/README.md +3 -3
  48. package/docs/guide/groups-and-plugins.md +8 -1
  49. package/docs/guide/hooks.md +128 -3
  50. package/docs/guide/replies.md +40 -0
  51. package/docs/guide/routes.md +115 -1
  52. package/docs/guide/server-sent-events.md +177 -5
  53. package/docs/guide/serving.md +1 -1
  54. package/docs/guide/types.md +3 -1
  55. package/docs/guide/websockets.md +1 -1
  56. package/docs/guide/writing-a-plugin.md +1 -1
  57. package/docs/roadmap.md +35 -2
  58. package/docs/troubleshooting.md +303 -6
  59. package/package.json +1 -1
@@ -243,6 +243,7 @@ Every part is optional, and each one is any Standard Schema.
243
243
  | `cookies` | the `Cookie` header, by name | `Readonly<Record<string, string>>` |
244
244
  | `body` | the body, parsed by its `content-type` | `undefined`: read `ctx.request` yourself |
245
245
  | `response` | the body of each status the route may answer | any status, any body ([Replies](replies.md)) |
246
+ | `bodyLimit` | not a schema: the most bytes the body may hold, a 413 past it ([Body size](#body-size-bodylimit)) | no limit beyond `listen`'s `maxRequestBodySize` |
246
247
  | `detail` | nothing at runtime: what [`@alxia/openapi`](https://www.npmjs.com/package/@alxia/openapi) says of the route | — |
247
248
 
248
249
  The handler reads each part as its schema's **output**: a schema that
@@ -321,7 +322,115 @@ const app = alxia()
321
322
  ```
322
323
 
323
324
  A parser that throws is a 400 with the code `unreadable_body` and the
324
- error's message.
325
+ error's message. A parser that reads past the route's
326
+ [`bodyLimit`](#body-size-bodylimit) gets a 413.
327
+
328
+ ### Body size: `bodyLimit`
329
+
330
+ `listen`'s `maxRequestBodySize` caps every body the server takes. A route can
331
+ cap its own body below that limit:
332
+
333
+ | Where | Applies to |
334
+ | --- | --- |
335
+ | `bodyLimit` in a route's schema | that route, whatever `bodyLimit()` was called before it |
336
+ | `app.bodyLimit(bytes)` | every route declared after it on the app, a group's included |
337
+ | `group.bodyLimit(bytes)` inside a group | the group's routes declared after it, never the app's |
338
+ | a plugin's routes, given to `use` | their own limit only: the app's `bodyLimit()` never reaches them. A `bodyLimit()` the plugin calls applies to the app's routes declared after `use`, as its hooks do |
339
+
340
+ ```ts
341
+ import { alxia } from '@alxia/core';
342
+ import { z } from 'zod';
343
+
344
+ const Note = z.object({ text: z.string() });
345
+
346
+ const app = alxia()
347
+ .bodyLimit(64 * 1024) // the app's default: 64 KiB
348
+ .post('/notes', { body: Note }, ({ body, reply }) => reply(201, body))
349
+ .group('/import', (bulk) =>
350
+ bulk
351
+ .bodyLimit(4 * 1024 * 1024) // this group only: 4 MiB
352
+ .post('/notes', { body: z.array(Note) }, ({ body, reply }) => reply(200, body.length)),
353
+ )
354
+ .post(
355
+ '/upload',
356
+ { bodyLimit: 25 * 1024 * 1024 }, // this route only: 25 MiB
357
+ async ({ request, reply }) => {
358
+ let bytes = 0;
359
+ for await (const chunk of request.body ?? []) bytes += chunk.byteLength;
360
+ return reply(200, bytes);
361
+ },
362
+ );
363
+ ```
364
+
365
+ The limit is a whole number of bytes, 0 or more. Any other value throws a
366
+ `TypeError` when the route is declared:
367
+ `POST /upload: bodyLimit must be a whole number of bytes, 0 or more; got -1`.
368
+
369
+ How a body is held to it, without reading it whole:
370
+
371
+ 1. **`Content-Length` first.** A declared length over the limit is refused
372
+ without reading a byte.
373
+ 2. **Then a count.** Without a `Content-Length`, as with a chunked upload,
374
+ or with a false one, the bytes are counted as they arrive. The read fails
375
+ at the first chunk that passes the limit and the rest is never pulled. A
376
+ body of exactly `bodyLimit` bytes is read.
377
+
378
+ The count covers every reader of the body. That means the built-in JSON,
379
+ form and text parsers, a [`parser`](#body-parsers) of the app's, a route
380
+ hook (`derive`, `wrap`), and a handler that reads `ctx.request.body` as a stream, as `/upload` does
381
+ above. That handler sees a stream like any other, and the stream fails once
382
+ the count passes the limit. Measured on a 25 MiB limit through `listen`,
383
+ with 256 MiB offered: the handler read 25 MiB, the client had sent about
384
+ 25.3 MiB when the request was refused, and the process grew by about 2 MiB.
385
+ [`body-limit.spec.ts`](https://github.com/softistx/alxia/blob/develop/packages/core/src/app/body-limit.spec.ts)
386
+ repeats that run: it holds the read and the growth under the limit, and the
387
+ bytes sent within 8 MiB of it.
388
+
389
+ A global hook, `onRequest` or `around`, runs before the route is known, so
390
+ it reads the body whole. If one has read it, the route's limit is skipped:
391
+ cap such a hook with `maxRequestBodySize`.
392
+
393
+ Past the limit the request is answered with a 413:
394
+
395
+ ```json
396
+ { "error": "content_too_large", "limit": 65536 }
397
+ ```
398
+
399
+ Its body is the exported `ContentTooLargeBody`. The 413 is in the type of
400
+ every route under a limit, so a typed client reads it, and
401
+ [`@alxia/openapi`](https://www.npmjs.com/package/@alxia/openapi) documents
402
+ it. A route with no limit has no default 413 in its type, and reads its
403
+ body as it always has.
404
+
405
+ What the read throws is a `ContentTooLargeError`, an `HttpError` with the
406
+ route's `limit`. The route answers it as a refusal, as it answers a 400:
407
+ the [`onRefusal`](hooks.md#onrefusal) hook in force reads
408
+ `{ kind: 'body_limit', limit }` and may answer in another format, such as
409
+ an RFC 9457 problem. The `onError` hooks never see it.
410
+
411
+ ```ts
412
+ import { alxia, problem } from '@alxia/core';
413
+ import { z } from 'zod';
414
+
415
+ const api = alxia()
416
+ .onRefusal((refusal) =>
417
+ refusal.kind === 'body_limit'
418
+ ? problem({ type: 'urn:ietf:params:jmap:error:limit', status: 413, limit: 'maxSizeRequest' })
419
+ : undefined,
420
+ )
421
+ .post('/api', { body: z.unknown(), bodyLimit: 10_000_000 }, ({ reply }) => reply(200, 'ok'));
422
+ // a body past 10 MB → 413, application/problem+json:
423
+ // { "type": "urn:ietf:params:jmap:error:limit", "status": 413, "limit": "maxSizeRequest" }
424
+ ```
425
+
426
+ The hook's replies then take the default 413's place in the type of every
427
+ route under a limit. A hook that returns nothing for a `body_limit` sends
428
+ the default 413, which stays in the type beside them
429
+ ([Hooks](hooks.md#onrefusal)).
430
+
431
+ A handler that streams its own response while it reads the body may have
432
+ sent its headers before the count passes the limit. In that case its
433
+ response stream fails instead of answering a 413.
325
434
 
326
435
  ## The 400
327
436
 
@@ -356,6 +465,11 @@ Two codes are the framework's: `invalid_json` (the body is not JSON) and
356
465
  `unreadable_body` (a parser threw). The 400 is in the type of every route
357
466
  that validates part of its request, so a client reads it.
358
467
 
468
+ The 400 is the default. [`onRefusal`](hooks.md#onrefusal) answers a refused
469
+ request in your own format for the routes declared after it, such as an RFC
470
+ 9457 problem sent as `application/problem+json`. Its reply then takes the
471
+ 400's place in those routes' types.
472
+
359
473
  ## What the types refuse
360
474
 
361
475
  The schema argument is checked against the path and against
@@ -2,7 +2,8 @@
2
2
 
3
3
  This page covers streaming events to a client: a handler replies with an
4
4
  async iterable, each value is one event, and the client reads the same
5
- values back as an async iterable.
5
+ values back as an async iterable. A stream may also name its events —
6
+ `event: state`, `event: ping` — each with a schema of its own.
6
7
 
7
8
  ```ts
8
9
  import { alxia, eventStream } from '@alxia/core';
@@ -43,12 +44,164 @@ The handler replies with an async iterable of what `item` accepts; each
43
44
  value is validated and sent as `item`'s **output**, so an unknown key the
44
45
  schema strips never leaves the server, as for any reply.
45
46
 
46
- `isEventStreamSchema(schema)` tells whether a schema is one `eventStream`
47
- made: what a plugin documenting the app — an OpenAPI generator — reads.
47
+ `isEventStreamSchema(schema)` tells whether a schema is one
48
+ `eventStream(schema)` made: what a plugin documenting the app — an OpenAPI generator — reads.
49
+
50
+ ## Named events
51
+
52
+ ```ts
53
+ function eventStream<Events extends EventSchemas>( // Readonly<Record<string, StandardSchemaV1>>
54
+ events: Events,
55
+ ): NamedEventStreamSchema<Events>;
56
+ ```
57
+
58
+ Given a schema per event name, the stream sends each event with its
59
+ `event:` line, as a protocol that tells its events apart by name expects —
60
+ JMAP's push, for one, sends `state` and `ping`:
61
+
62
+ ```ts
63
+ import { alxia, eventStream } from '@alxia/core';
64
+ import { z } from 'zod';
65
+
66
+ const StateChange = z.object({
67
+ '@type': z.literal('StateChange'),
68
+ changed: z.record(z.string(), z.record(z.string(), z.string())),
69
+ });
70
+ const Ping = z.object({ interval: z.number().int() });
71
+
72
+ const Push = eventStream({ state: StateChange, ping: Ping });
73
+
74
+ const app = alxia().get('/push', { response: { 200: Push } }, ({ reply }) =>
75
+ reply(
76
+ 200,
77
+ (async function* () {
78
+ yield Push.event('ping', { interval: 30 });
79
+ yield Push.event(
80
+ 'state',
81
+ { '@type': 'StateChange', changed: { a1: { Email: 's42' } } },
82
+ { id: 's42' },
83
+ );
84
+ })(),
85
+ ),
86
+ );
87
+ ```
88
+
89
+ ```text
90
+ event: ping
91
+ data: {"interval":30}
92
+
93
+ event: state
94
+ id: s42
95
+ data: {"@type":"StateChange","changed":{"a1":{"Email":"s42"}}}
96
+
97
+ ```
98
+
99
+ - **What the handler yields** is `{ event, data, id?, retry? }`: `event`
100
+ one of the declared names, `data` what that name's schema accepts. Any
101
+ other name, or data of another event, is a compile error. The data is
102
+ validated and sent as its schema's output, as for an unnamed stream.
103
+ - **`Push.event(name, data, fields?)`** builds one, typed by the schema of
104
+ its name. A plain `{ event: 'ping', data }` object yielded from an
105
+ `async function*` passed to `reply(200, …)` widens `event` to `string`,
106
+ which the stream's type refuses; `Push.event` keeps the literal. A
107
+ generator annotated with the union takes plain objects too:
108
+
109
+ ```ts
110
+ import type { EventInput } from '@alxia/core';
111
+
112
+ async function* pings(): AsyncGenerator<EventInput<typeof Push>> {
113
+ yield { event: 'ping', data: { interval: 30 } };
114
+ }
115
+ ```
116
+
117
+ - **`id`** is the event's id, which an `EventSource` sends back as
118
+ `Last-Event-ID` when it reconnects. **`retry`**, a whole number of
119
+ milliseconds, tells an `EventSource` how long to wait before reconnecting.
120
+ Both are left out unless given.
121
+ - **The client** reads `{ event, data, id? }`, a union discriminated by
122
+ `event`: see [Reading it](#reading-it).
123
+
124
+ ### What is refused
125
+
126
+ A field holding a line break would write a frame the handler never
127
+ yielded — `id: 1\ndata: forged` is two lines. So:
128
+
129
+ | What | When | Result |
130
+ | --- | --- | --- |
131
+ | an event name that is empty, or holds a CR, an LF or a NUL | `eventStream({ … })` | a `TypeError`, when the app is built |
132
+ | no event at all, or a value that is not a Standard Schema | `eventStream({ … })` | a `TypeError`, when the app is built |
133
+ | an `id` that is not a string, or holds a CR, an LF or a NUL | the event is yielded | the stream ends with an error, before the event is written |
134
+ | a `retry` that is not a whole number, 0 or more | the event is yielded | the stream ends with an error, before the event is written |
135
+ | an undeclared `event`, or a value that is not `{ event, data }` | the event is yielded | the stream ends with an error, before the event is written |
136
+
137
+ The types refuse each of the last three; they come from a cast or from
138
+ JavaScript. These checks run even with `validateResponses: false`, which
139
+ skips only the data's schema. Data is never a risk: it is JSON, whose line
140
+ breaks are escaped, so a string holding one stays on a single `data:` line.
141
+
142
+ ### Pings, the end of the stream, and a client that leaves
143
+
144
+ A push stream usually waits on two things at once: what it pushes, and a
145
+ timer that pings. The handler reads `request.signal`, aborted when the
146
+ client leaves, to stop waiting at once; its `finally` releases the timer
147
+ and the subscription. Returning ends the stream — what a client's
148
+ `closeafter=state` asks for:
149
+
150
+ ```ts
151
+ app.get(
152
+ '/events',
153
+ {
154
+ query: z.object({ closeafter: z.enum(['state', 'no']).default('no') }),
155
+ response: { 200: Push },
156
+ },
157
+ ({ query, request, reply }) =>
158
+ reply(
159
+ 200,
160
+ (async function* () {
161
+ const queue: EventInput<typeof Push>[] = [];
162
+ let wake = () => {};
163
+ const timer = setInterval(() => {
164
+ queue.push(Push.event('ping', { interval: 30 }));
165
+ wake();
166
+ }, 30_000);
167
+ const subscription = changes.subscribe((change) => {
168
+ queue.push(Push.event('state', change));
169
+ wake();
170
+ });
171
+ request.signal.addEventListener('abort', () => wake());
172
+ try {
173
+ while (!request.signal.aborted) {
174
+ const next = queue.shift();
175
+ if (next === undefined) {
176
+ await new Promise<void>((resolve) => {
177
+ wake = resolve;
178
+ });
179
+ continue;
180
+ }
181
+ yield next;
182
+ if (next.event === 'state' && query.closeafter === 'state') return;
183
+ }
184
+ } finally {
185
+ clearInterval(timer); // runs when the client leaves, or the stream ends
186
+ subscription.close();
187
+ }
188
+ })(),
189
+ ),
190
+ );
191
+ ```
192
+
193
+ Without the signal, a generator waiting on a promise is closed only when it
194
+ next yields: its `finally` would wait for the next ping.
195
+
196
+ `isNamedEventStreamSchema(schema)` tells whether a schema is a named
197
+ stream, and `schema['~events']` holds its schemas by name: what an OpenAPI
198
+ generator reads. `isEventStreamSchema` stays true of the unnamed form only.
48
199
 
49
200
  ## What is sent
50
201
 
51
- - Each value is one event: a `data:` line of JSON, then a blank line.
202
+ - Each value is one event: a `data:` line of JSON, then a blank line. On
203
+ a named stream, its `event:` line comes first, then its `id:` and
204
+ `retry:` lines when it has them.
52
205
  - The response has `content-type: text/event-stream`,
53
206
  `cache-control: no-cache` and `x-accel-buffering: no`, so a proxy such as
54
207
  nginx does not buffer it.
@@ -82,6 +235,7 @@ app.get('/orders/:id/status', { response: { 200: eventStream(Status) } }, ({ par
82
235
  | --- | --- |
83
236
  | the reply is not an async iterable (the types refuse it; a cast gets past them) | `500`, before the stream starts; a `ResponseValidationError` naming `An event stream replies with an async iterable` is logged |
84
237
  | an event its schema refuses | the stream is ended with an error: `An event does not match its schema: …` is logged; the events already sent stay sent |
238
+ | a named event whose fields would write another frame | the stream is ended with an error, which is logged: see [What is refused](#what-is-refused) |
85
239
  | the generator throws | the stream is ended with an error, which is logged |
86
240
 
87
241
  The status and headers are gone once the first event is sent, so an
@@ -127,7 +281,22 @@ if (ticks.status === 200) {
127
281
  }
128
282
  ```
129
283
 
130
- Any `EventSource` reads it too: each `data` is the JSON of one event.
284
+ On a named stream, each one is `{ event, data, id? }`, a union
285
+ discriminated by `event`, its `data` typed by that name's schema:
286
+
287
+ ```ts
288
+ const push = await api.get('/push');
289
+ if (push.status === 200) {
290
+ for await (const item of push.data) {
291
+ if (item.event === 'state') console.log(item.data.changed, item.id);
292
+ else console.log('ping every', item.data.interval);
293
+ }
294
+ }
295
+ ```
296
+
297
+ Any `EventSource` reads it too: each `data` is the JSON of one event, and a
298
+ named event is dispatched under its name
299
+ (`source.addEventListener('state', …)`).
131
300
 
132
301
  In a test, the body is the text of the events:
133
302
 
@@ -151,3 +320,6 @@ expect(await response.text()).toBe('data: {"n":1}\n\ndata: {"n":2}\n\n');
151
320
 
152
321
  - [Replies](replies.md#how-a-body-is-sent): how every other body is sent.
153
322
  - [WebSockets](websockets.md): when the client sends too.
323
+ - [`@alxia/compress`](https://www.npmjs.com/package/@alxia/compress) leaves
324
+ an event stream alone by default; opted in with `compressible`, it
325
+ flushes each event as it is sent.
@@ -33,7 +33,7 @@ a `Bun.serve` given [`websocket`](#websocket).
33
33
  | `hostname` | `string` | Bun's | the interface to listen on |
34
34
  | `development` | `boolean` | Bun's | Bun's development mode, which hot-reloads `page` bundles |
35
35
  | `idleTimeout` | `number` | Bun's | seconds before an idle connection is closed |
36
- | `maxRequestBodySize` | `number` | Bun's | the largest body accepted, in bytes |
36
+ | `maxRequestBodySize` | `number` | Bun's | the largest body the server accepts, in bytes; a route's [`bodyLimit`](routes.md#body-size-bodylimit) caps its own below it |
37
37
  | `tls` | `Bun.TLSOptions` | none | serve HTTPS |
38
38
 
39
39
  ```ts
@@ -83,7 +83,9 @@ interface Outcome<Status extends number = number, Data = unknown> {
83
83
  | each reply the handler can return | it has none |
84
84
  | a redirect the handler returns | always |
85
85
  | each reply a `derive`, `wrap` or `onError` before the route can return | always |
86
- | `400`, `ValidationErrorBody` | the route validates a part of its request |
86
+ | `400`, `ValidationErrorBody` | the route validates a part of its request, and no `onRefusal` hook is declared before it |
87
+ | each reply the `onRefusal` hook before the route can return, in place of the 400 and the 413 | the route validates a part of its request, or has a `bodyLimit`; the default of each kind too when the hook may return nothing |
88
+ | `413`, `ContentTooLargeBody` | the route has a `bodyLimit` of its own, or a `bodyLimit()` was called before it ([Routes](routes.md#body-size-bodylimit)), and no `onRefusal` hook is declared before it, or one that may return nothing |
87
89
  | `500`, `InternalErrorBody` | always |
88
90
 
89
91
  ```ts
@@ -46,7 +46,7 @@ The schema is required; `{}` validates nothing.
46
46
 
47
47
  | Part | Checks | Refused |
48
48
  | --- | --- | --- |
49
- | `params`, `query`, `headers`, `cookies` | the upgrade request, as a route's | a 400 with every issue, and no socket |
49
+ | `params`, `query`, `headers`, `cookies` | the upgrade request, as a route's | the 400 with every issue, or the reply of the `onRefusal` hook in force ([Hooks](hooks.md#onrefusal)), and no socket |
50
50
  | `message` | each message the client sends, parsed as JSON | answered on the socket with a `ValidationErrorBody`; the socket stays open, the handler is not called |
51
51
  | `send` | each message the server sends | the message is not sent: `send` rejects with a `ResponseValidationError`, which, in a handler, closes the socket with `1011` |
52
52
  | `detail` | nothing at runtime: what OpenAPI says of it | — |
@@ -17,7 +17,7 @@ hooks — are in [Groups and plugins](groups-and-plugins.md#plugins).
17
17
  An app is a plugin. What it declares is mounted on the app that uses it,
18
18
  and its types come with it:
19
19
 
20
- - its route hooks (`derive`, `decorate`, `wrap`, `onError`) apply to the
20
+ - its route hooks (`derive`, `decorate`, `wrap`, `onError`, `onRefusal`) apply to the
21
21
  routes declared after `use`, and what they add is typed on them;
22
22
  - its routes are mounted under the app's prefix, behind the hooks declared
23
23
  before `use`;
package/docs/roadmap.md CHANGED
@@ -11,11 +11,15 @@ Nothing scheduled yet.
11
11
 
12
12
  ## Next
13
13
 
14
- Nothing scheduled yet.
14
+ - **A body too large through `onRefusal`.** A request whose body is over a
15
+ limit becomes a refusal of its own kind, `body_limit`, so the hook that
16
+ shapes the 400 can shape the 413 too, as an RFC 9457 problem with its
17
+ `limit`.
15
18
 
16
19
  ## Later
17
20
 
18
- Nothing scheduled yet.
21
+ - **Comments on a stream.** A handler yielding a comment line of its own
22
+ (`: …`), beside the keep-alive the stream already sends while idle.
19
23
 
20
24
  ## Not planned
21
25
 
@@ -39,6 +43,35 @@ Nothing scheduled yet.
39
43
 
40
44
  ## Shipped
41
45
 
46
+ ### Next release
47
+
48
+ - **Refusals in your format.** `onRefusal(hook)` answers a request the
49
+ route's schemas refuse with your own reply instead of
50
+ `400 { error: 'validation', issues }`, for the routes declared after it.
51
+ The hook reads the part that failed and every issue, and may answer 400
52
+ or another 4xx. Its reply takes the 400's place in each route's type, so
53
+ the client reads it. Given schemas, `@alxia/openapi` documents it under
54
+ its content type. A group's hook stays in the group, and the default is
55
+ unchanged.
56
+ - **RFC 9457 problems.** `problem({ type, status, detail, … })` is a reply
57
+ sent as `application/problem+json`, with its extension members typed:
58
+ the error format of JMAP and other APIs built on problem details.
59
+ - **Named server-sent events.** `eventStream({ state: State, ping: Ping })`
60
+ maps each event name to the schema of its data: the handler yields only
61
+ declared events, each sent with its `event:` line and, when given, its
62
+ `id:` and `retry:`; the client reads a union discriminated by `event`. A
63
+ line break in an id, or a retry that is not a whole number, is refused
64
+ before it can write a frame the handler never yielded.
65
+ - **A body size per route.** `bodyLimit` on a route, or `bodyLimit(bytes)`
66
+ for the app or a group, caps its request body below the server's
67
+ `maxRequestBodySize`. A body over the limit is refused with a typed 413,
68
+ which `@alxia/openapi` documents. A `Content-Length` over the limit is
69
+ refused unread, and a chunked upload is cut off as soon as it passes the
70
+ limit, never buffered whole. This holds for JSON, forms, text, custom
71
+ parsers and a handler reading the raw stream. The refusal reaches
72
+ `onRefusal` as `{ kind: 'body_limit', limit }`, so a JMAP server answers
73
+ it with its own problem.
74
+
42
75
  ### 0.1.0
43
76
 
44
77
  - **Typed routes on Bun.** `alxia()` declares `get`, `post`, `put`, `patch`,