@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/troubleshooting.md
CHANGED
|
@@ -20,6 +20,8 @@ a trap that prints nothing is headed by its symptom.
|
|
|
20
20
|
- [`the plugin's … reads its context as any: annotate what it reads, or leave it unannotated`](#the-plugins--reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated)
|
|
21
21
|
- [`… is not assignable to type 'ProvidedBy<C, …>'`](#-is-not-assignable-to-type-providedbyc-)
|
|
22
22
|
- [`route() needs one method: declare the operation as const`](#route-needs-one-method-declare-the-operation-as-const)
|
|
23
|
+
- [`Type 'Reply<500, …>' is not assignable to type 'MaybePromise<void | Reply<ClientErrorStatus, any> | undefined>'`](#type-reply500--is-not-assignable-to-type-maybepromisevoid--replyclienterrorstatus-any--undefined)
|
|
24
|
+
- [`'500' does not exist in type 'RefusalResponses'`](#500-does-not-exist-in-type-refusalresponses)
|
|
23
25
|
|
|
24
26
|
**Building the app**
|
|
25
27
|
|
|
@@ -36,12 +38,14 @@ a trap that prints nothing is headed by its symptom.
|
|
|
36
38
|
- [`GET /… is declared twice`](#get--is-declared-twice)
|
|
37
39
|
- [`GET /…: the handler is missing`](#get--the-handler-is-missing)
|
|
38
40
|
- [`group(): build is missing`](#group-build-is-missing)
|
|
41
|
+
- [`onRefusal(): the hook is missing`](#onrefusal-the-hook-is-missing)
|
|
39
42
|
- [`page(): /… is already served`](#page--is-already-served)
|
|
40
43
|
- [`GET /… is already served by a page`](#get--is-already-served-by-a-page)
|
|
41
44
|
|
|
42
45
|
**Responses**
|
|
43
46
|
|
|
44
47
|
- [`400 {"error":"validation","issues":[…]}`](#400-errorvalidationissues)
|
|
48
|
+
- [A route still answers `{"error":"validation"}` after `onRefusal`](#a-route-still-answers-errorvalidation-after-onrefusal)
|
|
45
49
|
- [`404 {"error":"not_found"}`](#404-errornot_found)
|
|
46
50
|
- [`405 {"error":"method_not_allowed"}`](#405-errormethod_not_allowed)
|
|
47
51
|
- [`426 {"error":"upgrade_required"}`](#426-errorupgrade_required)
|
|
@@ -57,7 +61,13 @@ a trap that prints nothing is headed by its symptom.
|
|
|
57
61
|
- [`ResponseValidationError: … the 200 reply does not match its schema`](#responsevalidationerror--the-200-reply-does-not-match-its-schema)
|
|
58
62
|
- [`ResponseValidationError: … declares no 201 reply`](#responsevalidationerror--declares-no-201-reply)
|
|
59
63
|
- [`TypeError: … the handler returned no reply. Return ctx.reply(status, body).`](#typeerror--the-handler-returned-no-reply-return-ctxreplystatus-body)
|
|
64
|
+
- [`TypeError: … the onRefusal hook returned neither a reply nor nothing.`](#typeerror--the-onrefusal-hook-returned-neither-a-reply-nor-nothing)
|
|
60
65
|
- [`TypeError: An event does not match its schema`](#typeerror-an-event-does-not-match-its-schema)
|
|
66
|
+
- [`TypeError: An event id must not hold a line break or a NUL`](#typeerror-an-event-id-must-not-hold-a-line-break-or-a-nul), and `An event id must be a string`
|
|
67
|
+
- [`TypeError: An event retry must be a whole number of milliseconds, 0 or more`](#typeerror-an-event-retry-must-be-a-whole-number-of-milliseconds-0-or-more)
|
|
68
|
+
- [`TypeError: The event "…" is not declared: …`](#typeerror-the-event--is-not-declared-), and `An event of a named stream is an object { event, data }`
|
|
69
|
+
- [`TypeError: An event name must not hold a line break or a NUL`](#typeerror-an-event-name-must-not-hold-a-line-break-or-a-nul), and `An event name must not be empty`, `A named event stream declares at least one event`, `The event "…" is not a Standard Schema`
|
|
70
|
+
- [`Type 'string' is not assignable to type '"ping"'` on a named stream](#type-string-is-not-assignable-to-type-ping-on-a-named-stream)
|
|
61
71
|
|
|
62
72
|
**WebSockets**
|
|
63
73
|
|
|
@@ -123,7 +133,7 @@ error TS2561: Object literal may only specify known properties, but 'quey' does
|
|
|
123
133
|
```
|
|
124
134
|
|
|
125
135
|
When the schema object is a variable, the message names the key instead:
|
|
126
|
-
`"quey" is not a part of a route: params, query, headers, cookies, body, response or detail`.
|
|
136
|
+
`"quey" is not a part of a route: params, query, headers, cookies, body, response, bodyLimit or detail`.
|
|
127
137
|
A variable with no known key at all gives
|
|
128
138
|
`TS2559: Type '{ quey: … }' has no properties in common with type 'RouteSchema'`.
|
|
129
139
|
|
|
@@ -131,7 +141,7 @@ A variable with no known key at all gives
|
|
|
131
141
|
read the raw value.
|
|
132
142
|
|
|
133
143
|
**Fix:** use one of `params`, `query`, `headers`, `cookies`, `body`,
|
|
134
|
-
`response` or `detail`:
|
|
144
|
+
`response`, `bodyLimit` or `detail`:
|
|
135
145
|
|
|
136
146
|
```ts
|
|
137
147
|
app.get('/users', { query: z.object({ page: zq.int().optional() }) }, handler);
|
|
@@ -420,6 +430,65 @@ export const getPet = {
|
|
|
420
430
|
} as const;
|
|
421
431
|
```
|
|
422
432
|
|
|
433
|
+
### `Type 'Reply<500, …>' is not assignable to type 'MaybePromise<void | Reply<ClientErrorStatus, any> | undefined>'`
|
|
434
|
+
|
|
435
|
+
**When:** an `onRefusal` hook returns a reply whose status is not a client
|
|
436
|
+
error, such as a 500 or a 200.
|
|
437
|
+
|
|
438
|
+
```text
|
|
439
|
+
error TS2322: Type 'Reply<500, { readonly status: 500; }>' is not assignable to type 'MaybePromise<void | Reply<ClientErrorStatus, any> | undefined>'.
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
**Why:** a refused request is the client's error. A 5xx would tell a client
|
|
443
|
+
to retry a request that will be refused again, and a 2xx would say it
|
|
444
|
+
succeeded.
|
|
445
|
+
|
|
446
|
+
**Fix:** answer with a 4xx, such as 400 or 422:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
app.onRefusal((refusal) => problem({ status: 422, detail: `the request is refused: ${refusal.kind}` }));
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### `Property 'part' does not exist on type 'Refusal'`
|
|
453
|
+
|
|
454
|
+
**When:** an `onRefusal` hook reads `part` or `issues` without checking
|
|
455
|
+
the refusal's `kind`, often by destructuring it:
|
|
456
|
+
|
|
457
|
+
```text
|
|
458
|
+
error TS2339: Property 'part' does not exist on type 'Refusal'.
|
|
459
|
+
Property 'part' does not exist on type 'BodyLimitRefusal'.
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**Why:** a hook answers every kind of refusal. A `body_limit` refusal, a
|
|
463
|
+
body past the route's `bodyLimit`, has a `limit` and no `part` or
|
|
464
|
+
`issues`.
|
|
465
|
+
|
|
466
|
+
**Fix:** check `kind` first. Return nothing for a kind you leave to its
|
|
467
|
+
default:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
app.onRefusal((refusal) =>
|
|
471
|
+
refusal.kind === 'validation' ? problem({ status: 400, detail: `the ${refusal.part} is invalid` }) : undefined,
|
|
472
|
+
);
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### `'500' does not exist in type 'RefusalResponses'`
|
|
476
|
+
|
|
477
|
+
**When:** the schemas given to `onRefusal` declare a status that is not a
|
|
478
|
+
client error.
|
|
479
|
+
|
|
480
|
+
```text
|
|
481
|
+
error TS2353: Object literal may only specify known properties, and '500' does not exist in type 'RefusalResponses'.
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**Fix:** declare the 4xx the hook answers:
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
app.onRefusal({ response: { 400: Problem } }, (_, { reply }) =>
|
|
488
|
+
reply(400, { type: 'urn:example:invalid', status: 400, detail: 'invalid' }),
|
|
489
|
+
);
|
|
490
|
+
```
|
|
491
|
+
|
|
423
492
|
## Building the app
|
|
424
493
|
|
|
425
494
|
These are `TypeError`s thrown when a route is declared, so the app fails at
|
|
@@ -627,6 +696,19 @@ third argument that is `undefined`.
|
|
|
627
696
|
app.get('/a', { query: Query }, ({ query, reply }) => reply(200, query));
|
|
628
697
|
```
|
|
629
698
|
|
|
699
|
+
### `POST /…: bodyLimit must be a whole number of bytes, 0 or more; got …`
|
|
700
|
+
|
|
701
|
+
**When:** a route's `bodyLimit` is negative, fractional, `NaN` or
|
|
702
|
+
`Infinity`. `bodyLimit()` throws the same message, prefixed
|
|
703
|
+
`bodyLimit():`.
|
|
704
|
+
|
|
705
|
+
**Fix:** give a byte count, or leave `bodyLimit` out for no limit beyond
|
|
706
|
+
the server's:
|
|
707
|
+
|
|
708
|
+
```ts
|
|
709
|
+
app.post('/upload', { bodyLimit: 25 * 1024 * 1024 }, handler);
|
|
710
|
+
```
|
|
711
|
+
|
|
630
712
|
### `group(): build is missing`
|
|
631
713
|
|
|
632
714
|
**When:** `group('/admin')` is called without its function.
|
|
@@ -637,6 +719,19 @@ app.get('/a', { query: Query }, ({ query, reply }) => reply(200, query));
|
|
|
637
719
|
app.group('/admin', (admin) => admin.derive(requireAdmin).get('/stats', stats));
|
|
638
720
|
```
|
|
639
721
|
|
|
722
|
+
### `onRefusal(): the hook is missing`
|
|
723
|
+
|
|
724
|
+
**When:** `onRefusal` is given its schemas but no hook. The types refuse
|
|
725
|
+
that, so this comes from JavaScript or a cast.
|
|
726
|
+
|
|
727
|
+
**Fix:** pass the hook after the schemas:
|
|
728
|
+
|
|
729
|
+
```ts
|
|
730
|
+
app.onRefusal({ response: { 400: Problem } }, (_, { reply }) =>
|
|
731
|
+
reply(400, { type: 'urn:example:invalid', status: 400, detail: 'invalid' }),
|
|
732
|
+
);
|
|
733
|
+
```
|
|
734
|
+
|
|
640
735
|
### `page(): /… is already served`
|
|
641
736
|
|
|
642
737
|
**When:** `page(path, bundle)` names a path that another `page` or a
|
|
@@ -665,8 +760,8 @@ app.page('/dashboard', dashboard).get('/api/dashboard', ({ reply }) => reply(200
|
|
|
665
760
|
## Responses
|
|
666
761
|
|
|
667
762
|
The app answers these itself. Their bodies are the exported
|
|
668
|
-
`ValidationErrorBody`, `
|
|
669
|
-
`RangeNotSatisfiableBody` and `InternalErrorBody`.
|
|
763
|
+
`ValidationErrorBody`, `ContentTooLargeBody`, `RoutingErrorBody`,
|
|
764
|
+
`FileNotFoundBody`, `RangeNotSatisfiableBody` and `InternalErrorBody`.
|
|
670
765
|
|
|
671
766
|
### `400 {"error":"validation","issues":[…]}`
|
|
672
767
|
|
|
@@ -714,6 +809,86 @@ await fetch('/users', {
|
|
|
714
809
|
[`@alxia/client`](https://www.npmjs.com/package/@alxia/client) sets the
|
|
715
810
|
`content-type` for you.
|
|
716
811
|
|
|
812
|
+
To answer it in another format, such as an RFC 9457 problem, declare
|
|
813
|
+
[`onRefusal`](guide/hooks.md#onrefusal) before the routes.
|
|
814
|
+
|
|
815
|
+
### A route still answers `{"error":"validation"}` after `onRefusal`
|
|
816
|
+
|
|
817
|
+
**When:** an app declares `onRefusal`, and a refused request to one of its
|
|
818
|
+
routes still gets the default 400.
|
|
819
|
+
|
|
820
|
+
**Why**, by what you find:
|
|
821
|
+
|
|
822
|
+
- The route is declared **before** the hook. A route hook applies to the
|
|
823
|
+
routes declared after it, never before: move the hook up the chain.
|
|
824
|
+
- The hook is declared inside a `group`. A group's hooks stay inside it:
|
|
825
|
+
declare the hook on the app, before the group.
|
|
826
|
+
- The hook returned nothing, `undefined`, for this refusal. Nothing means
|
|
827
|
+
the default: return a reply for every refusal you want answered.
|
|
828
|
+
|
|
829
|
+
**Fix:** declare the hook first, and return a reply:
|
|
830
|
+
|
|
831
|
+
```ts
|
|
832
|
+
const app = alxia()
|
|
833
|
+
.onRefusal((refusal) =>
|
|
834
|
+
refusal.kind === 'validation' ? problem({ status: 400, detail: `the ${refusal.part} is invalid` }) : undefined,
|
|
835
|
+
)
|
|
836
|
+
.post('/users', { body: NewUser }, handler);
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
A body past the route's `bodyLimit` still gets the default
|
|
840
|
+
`413 {"error":"content_too_large","limit":…}` from that hook, which returns nothing
|
|
841
|
+
for a `body_limit`. Return a reply for it too to answer it in your format
|
|
842
|
+
([`413`](#413-errorcontent_too_largelimit)).
|
|
843
|
+
|
|
844
|
+
### `413 {"error":"content_too_large","limit":…}`
|
|
845
|
+
|
|
846
|
+
**When:** a request's body is larger than its route's `bodyLimit`: the
|
|
847
|
+
route's own, or the one a `bodyLimit(bytes)` before it set. Either its
|
|
848
|
+
`Content-Length` says so, and the body is not read, or the bytes counted as
|
|
849
|
+
it was read passed the limit:
|
|
850
|
+
|
|
851
|
+
```json
|
|
852
|
+
{ "error": "content_too_large", "limit": 65536 }
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
**Why:** `limit` is the route's limit, in bytes. The count covers every
|
|
856
|
+
reader of the body: the JSON, form and text parsers, a `parser` of the
|
|
857
|
+
app's, and a handler reading `ctx.request.body`. A body of exactly `limit`
|
|
858
|
+
bytes is accepted.
|
|
859
|
+
|
|
860
|
+
**Fix:** send a smaller body, or raise the limit for that route alone. A
|
|
861
|
+
route's own `bodyLimit` wins over the default:
|
|
862
|
+
|
|
863
|
+
```ts
|
|
864
|
+
app
|
|
865
|
+
.bodyLimit(64 * 1024)
|
|
866
|
+
.post('/attachments', { bodyLimit: 25 * 1024 * 1024 }, async ({ request, reply }) => {
|
|
867
|
+
await Bun.write('attachment.bin', new Response(request.body));
|
|
868
|
+
return reply(204);
|
|
869
|
+
});
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
To answer in another format, such as an RFC 9457 problem, return it from
|
|
873
|
+
an [`onRefusal`](guide/hooks.md#onrefusal) hook declared before the route,
|
|
874
|
+
for the refusal of kind `body_limit`. The `onError` hooks never see it:
|
|
875
|
+
|
|
876
|
+
```ts
|
|
877
|
+
import { problem } from '@alxia/core';
|
|
878
|
+
|
|
879
|
+
app
|
|
880
|
+
.onRefusal((refusal) =>
|
|
881
|
+
refusal.kind === 'body_limit'
|
|
882
|
+
? problem({ type: 'urn:ietf:params:jmap:error:limit', status: 413, limit: 'maxSizeRequest' })
|
|
883
|
+
: undefined,
|
|
884
|
+
)
|
|
885
|
+
.post('/api', { body: z.unknown(), bodyLimit: 10_000_000 }, handler);
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
A 413 with no JSON body comes from Bun itself. The body passed `listen`'s
|
|
889
|
+
`maxRequestBodySize`, which applies to every route, before any route's
|
|
890
|
+
`bodyLimit`.
|
|
891
|
+
|
|
717
892
|
### `404 {"error":"not_found"}`
|
|
718
893
|
|
|
719
894
|
**When:** a request matches no route, or a `static` or `file` route finds
|
|
@@ -817,6 +992,28 @@ app
|
|
|
817
992
|
Prefer a declared `reply` to `throw new HttpError(…)`: a thrown status is
|
|
818
993
|
not in the route's type, so a typed client does not expect it.
|
|
819
994
|
|
|
995
|
+
### A plugin's route reads a body past the app's `bodyLimit()`
|
|
996
|
+
|
|
997
|
+
**When:** an app calls `bodyLimit(bytes)` and then `use(plugin)` with an
|
|
998
|
+
app plugin, and a route of that plugin accepts a larger body.
|
|
999
|
+
|
|
1000
|
+
**Why:** an app's `bodyLimit()` reaches the routes declared on it and in
|
|
1001
|
+
its groups, never a plugin's. A plugin's route keeps the limit it was
|
|
1002
|
+
declared with, and its type with it. A plugin route with no `onRefusal` of
|
|
1003
|
+
its own is still answered by the app's hook. A function plugin that declares its routes on the app is
|
|
1004
|
+
bounded like any of them.
|
|
1005
|
+
|
|
1006
|
+
**Fix:** give the plugin's route a `bodyLimit` of its own:
|
|
1007
|
+
|
|
1008
|
+
```ts
|
|
1009
|
+
const uploads = alxia().post('/upload', { bodyLimit: 25 * 1024 * 1024 }, handler);
|
|
1010
|
+
const app = alxia().bodyLimit(64 * 1024).use(uploads);
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
A `bodyLimit()` the plugin calls instead also applies to the app's routes
|
|
1014
|
+
declared after `use`, as its hooks do. Call the app's own after `use` to
|
|
1015
|
+
keep it.
|
|
1016
|
+
|
|
820
1017
|
## Routing
|
|
821
1018
|
|
|
822
1019
|
A trap that prints nothing: the answer comes from another route than the
|
|
@@ -861,6 +1058,9 @@ at runtime. Data from a database, `JSON.parse` or `any` gets past the types.
|
|
|
861
1058
|
**Why:** what leaves the server is the schema's output. A body the schema
|
|
862
1059
|
refuses is never sent, so the client never reads an undeclared shape.
|
|
863
1060
|
|
|
1061
|
+
An `onRefusal` hook given schemas is checked the same way: the message
|
|
1062
|
+
names the route that was refused and the status the hook replied with.
|
|
1063
|
+
|
|
864
1064
|
**Fix:** map the data to the schema before replying. To skip the check in
|
|
865
1065
|
a hot path you trust, turn it off for the app; the declared status is still
|
|
866
1066
|
enforced:
|
|
@@ -878,7 +1078,9 @@ ResponseValidationError: GET /u declares no 201 reply
|
|
|
878
1078
|
**When:** a route with `response` schemas replies with a status it did not
|
|
879
1079
|
declare. The types refuse that, so this comes from JavaScript or a cast.
|
|
880
1080
|
Redirects (3xx without a body) are exempt, and so is a reply returned by
|
|
881
|
-
`onError` or a `derive`.
|
|
1081
|
+
`onError` or a `derive`. An `onRefusal` hook given schemas that replies
|
|
1082
|
+
with a status they do not declare fails the same way, naming the refused
|
|
1083
|
+
route: declare the status in the hook's `response`.
|
|
882
1084
|
|
|
883
1085
|
**Fix:** declare the status in `response`:
|
|
884
1086
|
|
|
@@ -901,6 +1103,20 @@ app.get('/users', async ({ reply }) => {
|
|
|
901
1103
|
});
|
|
902
1104
|
```
|
|
903
1105
|
|
|
1106
|
+
### `TypeError: … the onRefusal hook returned neither a reply nor nothing.`
|
|
1107
|
+
|
|
1108
|
+
**When:** an `onRefusal` hook returns something that is neither a `Reply`
|
|
1109
|
+
nor `undefined`, such as a plain object or a `Response`. TypeScript
|
|
1110
|
+
refuses it, so this comes from JavaScript or a cast.
|
|
1111
|
+
|
|
1112
|
+
**Fix:** return `reply(…)` or `problem(…)`, or nothing for the default:
|
|
1113
|
+
|
|
1114
|
+
```ts
|
|
1115
|
+
app.onRefusal((refusal) =>
|
|
1116
|
+
refusal.kind === 'validation' && refusal.part === 'body' ? problem({ status: 400, detail: 'bad body' }) : undefined,
|
|
1117
|
+
);
|
|
1118
|
+
```
|
|
1119
|
+
|
|
904
1120
|
### `TypeError: An event does not match its schema`
|
|
905
1121
|
|
|
906
1122
|
```text
|
|
@@ -908,7 +1124,8 @@ TypeError: An event does not match its schema: n: Invalid input: expected number
|
|
|
908
1124
|
```
|
|
909
1125
|
|
|
910
1126
|
**When:** a value yielded by an `eventStream` reply is refused by the
|
|
911
|
-
event's schema.
|
|
1127
|
+
event's schema. On a named stream, the path starts with the event's name:
|
|
1128
|
+
`ping.interval: …`. The response has already started with a 200, so the stream
|
|
912
1129
|
is cut, and the client reads an error mid-stream instead of a 500.
|
|
913
1130
|
|
|
914
1131
|
**Fix:** yield values the event's schema accepts, mapping them inside the
|
|
@@ -920,6 +1137,86 @@ reply(200, (async function* () {
|
|
|
920
1137
|
})());
|
|
921
1138
|
```
|
|
922
1139
|
|
|
1140
|
+
### `TypeError: An event id must not hold a line break or a NUL`
|
|
1141
|
+
|
|
1142
|
+
```text
|
|
1143
|
+
TypeError: An event id must not hold a line break or a NUL: "1\ndata: forged"
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
**When:** an event yielded on a named `eventStream({ … })` has an `id`
|
|
1147
|
+
holding a CR, an LF or a NUL (`An event id must be a string` when it is
|
|
1148
|
+
not a string at all). Its type is `string`, so this comes from data
|
|
1149
|
+
that reached the id unchecked: a client's input, a database row.
|
|
1150
|
+
|
|
1151
|
+
**Why:** a line break would end the `id:` line and start a field the
|
|
1152
|
+
handler never yielded, and an `EventSource` ignores an id holding a NUL. The
|
|
1153
|
+
stream ends before the event is written; the events already sent stay sent.
|
|
1154
|
+
|
|
1155
|
+
**Fix:** make the id from something without line breaks — a counter, a
|
|
1156
|
+
state string you issue — or encode it:
|
|
1157
|
+
|
|
1158
|
+
```ts
|
|
1159
|
+
yield Push.event('state', change, { id: encodeURIComponent(state) });
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
### `TypeError: An event retry must be a whole number of milliseconds, 0 or more`
|
|
1163
|
+
|
|
1164
|
+
**When:** an event's `retry` is a fraction, negative, `NaN` or not a number.
|
|
1165
|
+
|
|
1166
|
+
**Why:** an `EventSource` reads `retry:` as ASCII digits only, and ignores
|
|
1167
|
+
anything else; the stream ends rather than send a field no client reads.
|
|
1168
|
+
|
|
1169
|
+
**Fix:** round it:
|
|
1170
|
+
|
|
1171
|
+
```ts
|
|
1172
|
+
yield Push.event('ping', { interval: 30 }, { retry: Math.round(seconds * 1000) });
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
### `TypeError: The event "…" is not declared: …`
|
|
1176
|
+
|
|
1177
|
+
**When:** a value yielded on a named stream has an `event` that is not one
|
|
1178
|
+
of the names its `eventStream({ … })` declares. The types refuse it, so this
|
|
1179
|
+
comes from a cast or from JavaScript. `TypeError: An event of a named stream
|
|
1180
|
+
is an object { event, data }` is its sibling, for a value that has no
|
|
1181
|
+
`event` at all.
|
|
1182
|
+
|
|
1183
|
+
**Fix:** declare the event, or yield one that is:
|
|
1184
|
+
|
|
1185
|
+
```ts
|
|
1186
|
+
const Push = eventStream({ state: StateChange, ping: Ping });
|
|
1187
|
+
yield Push.event('ping', { interval: 30 });
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
### `TypeError: An event name must not hold a line break or a NUL`
|
|
1191
|
+
|
|
1192
|
+
**When:** `eventStream({ … })` is given a name holding a CR, an LF or a
|
|
1193
|
+
NUL, or an empty one (`An event name must not be empty`). It throws when the
|
|
1194
|
+
app is built, as do `A named event stream declares at least one event` and
|
|
1195
|
+
`The event "…" is not a Standard Schema`.
|
|
1196
|
+
|
|
1197
|
+
**Fix:** name each event with a single line, and give each a schema:
|
|
1198
|
+
|
|
1199
|
+
```ts
|
|
1200
|
+
const Push = eventStream({ state: StateChange, ping: Ping });
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
### `Type 'string' is not assignable to type '"ping"'` on a named stream
|
|
1204
|
+
|
|
1205
|
+
**When:** a generator handed to `reply(200, …)` yields a plain
|
|
1206
|
+
`{ event: 'ping', data }` object: TypeScript widens its `event` to `string`,
|
|
1207
|
+
which no declared name is.
|
|
1208
|
+
|
|
1209
|
+
**Fix:** build the event with the stream's `event`, which keeps the name,
|
|
1210
|
+
or annotate the generator:
|
|
1211
|
+
|
|
1212
|
+
```ts
|
|
1213
|
+
yield Push.event('ping', { interval: 30 });
|
|
1214
|
+
// or
|
|
1215
|
+
async function* pings(): AsyncGenerator<EventInput<typeof Push>> {
|
|
1216
|
+
yield { event: 'ping', data: { interval: 30 } };
|
|
1217
|
+
}
|
|
1218
|
+
```
|
|
1219
|
+
|
|
923
1220
|
## WebSockets
|
|
924
1221
|
|
|
925
1222
|
### `{"error":"validation", … "code":"invalid_json","message":"The message is not valid JSON"}`
|
package/package.json
CHANGED