@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.
- package/README.md +103 -3
- package/dist/app/alxia.d.ts +53 -5
- package/dist/app/alxia.d.ts.map +1 -1
- package/dist/app/chain.d.ts +5 -8
- package/dist/app/chain.d.ts.map +1 -1
- package/dist/app/context.d.ts +12 -0
- package/dist/app/context.d.ts.map +1 -0
- package/dist/app/definition.d.ts +22 -1
- package/dist/app/definition.d.ts.map +1 -1
- package/dist/app/refusal.d.ts +15 -0
- package/dist/app/refusal.d.ts.map +1 -0
- package/dist/app/send.d.ts +8 -1
- package/dist/app/send.d.ts.map +1 -1
- package/dist/app/socket.d.ts +1 -1
- package/dist/app/socket.d.ts.map +1 -1
- package/dist/app/types/index.d.ts +1 -0
- package/dist/app/types/index.d.ts.map +1 -1
- package/dist/app/types/refusal.d.ts +94 -0
- package/dist/app/types/refusal.d.ts.map +1 -0
- package/dist/app/types/route-table.d.ts +3 -2
- package/dist/app/types/route-table.d.ts.map +1 -1
- package/dist/app/types/schema.d.ts +7 -0
- package/dist/app/types/schema.d.ts.map +1 -1
- package/dist/app/types/valid-schema.d.ts +1 -1
- package/dist/app/types/valid-schema.d.ts.map +1 -1
- package/dist/errors/errors.d.ts +47 -1
- package/dist/errors/errors.d.ts.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +395 -97
- package/dist/index.js.map +20 -13
- package/dist/reply/problem.d.ts +33 -0
- package/dist/reply/problem.d.ts.map +1 -0
- package/dist/reply/reply.d.ts.map +1 -1
- package/dist/request/limit.d.ts +13 -0
- package/dist/request/limit.d.ts.map +1 -0
- package/dist/request/read.d.ts +3 -2
- package/dist/request/read.d.ts.map +1 -1
- package/dist/sse/async-iterable.d.ts +6 -0
- package/dist/sse/async-iterable.d.ts.map +1 -0
- package/dist/sse/event-stream.d.ts +29 -6
- package/dist/sse/event-stream.d.ts.map +1 -1
- package/dist/sse/frame.d.ts +35 -0
- package/dist/sse/frame.d.ts.map +1 -0
- package/dist/sse/named-events.d.ts +70 -0
- package/dist/sse/named-events.d.ts.map +1 -0
- package/docs/README.md +3 -3
- package/docs/guide/groups-and-plugins.md +8 -1
- package/docs/guide/hooks.md +128 -3
- package/docs/guide/replies.md +40 -0
- package/docs/guide/routes.md +115 -1
- package/docs/guide/server-sent-events.md +177 -5
- package/docs/guide/serving.md +1 -1
- package/docs/guide/types.md +3 -1
- package/docs/guide/websockets.md +1 -1
- package/docs/guide/writing-a-plugin.md +1 -1
- package/docs/roadmap.md +35 -2
- package/docs/troubleshooting.md +303 -6
- package/package.json +1 -1
package/docs/guide/routes.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
package/docs/guide/serving.md
CHANGED
|
@@ -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
|
|
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
|
package/docs/guide/types.md
CHANGED
|
@@ -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
|
package/docs/guide/websockets.md
CHANGED
|
@@ -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 |
|
|
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
|
-
|
|
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
|
-
|
|
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`,
|