@alxia/core 0.1.0 → 0.2.1
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 +104 -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/pipeline.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 +24 -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 +413 -103
- 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 +131 -4
- package/docs/guide/replies.md +47 -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 +331 -7
- package/package.json +1 -1
package/docs/troubleshooting.md
CHANGED
|
@@ -20,6 +20,10 @@ 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)
|
|
25
|
+
- [`The inferred type of '…' cannot be named without a reference to '…' from '…/@alxia/core/dist/…'`](#the-inferred-type-of--cannot-be-named-without-a-reference-to--from-alxiacoredist)
|
|
26
|
+
- [`Property 'part' does not exist on type 'Refusal'`](#property-part-does-not-exist-on-type-refusal)
|
|
23
27
|
|
|
24
28
|
**Building the app**
|
|
25
29
|
|
|
@@ -36,12 +40,14 @@ a trap that prints nothing is headed by its symptom.
|
|
|
36
40
|
- [`GET /… is declared twice`](#get--is-declared-twice)
|
|
37
41
|
- [`GET /…: the handler is missing`](#get--the-handler-is-missing)
|
|
38
42
|
- [`group(): build is missing`](#group-build-is-missing)
|
|
43
|
+
- [`onRefusal(): the hook is missing`](#onrefusal-the-hook-is-missing)
|
|
39
44
|
- [`page(): /… is already served`](#page--is-already-served)
|
|
40
45
|
- [`GET /… is already served by a page`](#get--is-already-served-by-a-page)
|
|
41
46
|
|
|
42
47
|
**Responses**
|
|
43
48
|
|
|
44
49
|
- [`400 {"error":"validation","issues":[…]}`](#400-errorvalidationissues)
|
|
50
|
+
- [A route still answers `{"error":"validation"}` after `onRefusal`](#a-route-still-answers-errorvalidation-after-onrefusal)
|
|
45
51
|
- [`404 {"error":"not_found"}`](#404-errornot_found)
|
|
46
52
|
- [`405 {"error":"method_not_allowed"}`](#405-errormethod_not_allowed)
|
|
47
53
|
- [`426 {"error":"upgrade_required"}`](#426-errorupgrade_required)
|
|
@@ -57,7 +63,13 @@ a trap that prints nothing is headed by its symptom.
|
|
|
57
63
|
- [`ResponseValidationError: … the 200 reply does not match its schema`](#responsevalidationerror--the-200-reply-does-not-match-its-schema)
|
|
58
64
|
- [`ResponseValidationError: … declares no 201 reply`](#responsevalidationerror--declares-no-201-reply)
|
|
59
65
|
- [`TypeError: … the handler returned no reply. Return ctx.reply(status, body).`](#typeerror--the-handler-returned-no-reply-return-ctxreplystatus-body)
|
|
66
|
+
- [`TypeError: … the onRefusal hook returned neither a reply nor nothing.`](#typeerror--the-onrefusal-hook-returned-neither-a-reply-nor-nothing)
|
|
60
67
|
- [`TypeError: An event does not match its schema`](#typeerror-an-event-does-not-match-its-schema)
|
|
68
|
+
- [`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`
|
|
69
|
+
- [`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)
|
|
70
|
+
- [`TypeError: The event "…" is not declared: …`](#typeerror-the-event--is-not-declared-), and `An event of a named stream is an object { event, data }`
|
|
71
|
+
- [`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`
|
|
72
|
+
- [`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
73
|
|
|
62
74
|
**WebSockets**
|
|
63
75
|
|
|
@@ -123,7 +135,7 @@ error TS2561: Object literal may only specify known properties, but 'quey' does
|
|
|
123
135
|
```
|
|
124
136
|
|
|
125
137
|
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`.
|
|
138
|
+
`"quey" is not a part of a route: params, query, headers, cookies, body, response, bodyLimit or detail`.
|
|
127
139
|
A variable with no known key at all gives
|
|
128
140
|
`TS2559: Type '{ quey: … }' has no properties in common with type 'RouteSchema'`.
|
|
129
141
|
|
|
@@ -131,7 +143,7 @@ A variable with no known key at all gives
|
|
|
131
143
|
read the raw value.
|
|
132
144
|
|
|
133
145
|
**Fix:** use one of `params`, `query`, `headers`, `cookies`, `body`,
|
|
134
|
-
`response` or `detail`:
|
|
146
|
+
`response`, `bodyLimit` or `detail`:
|
|
135
147
|
|
|
136
148
|
```ts
|
|
137
149
|
app.get('/users', { query: z.object({ page: zq.int().optional() }) }, handler);
|
|
@@ -420,6 +432,81 @@ export const getPet = {
|
|
|
420
432
|
} as const;
|
|
421
433
|
```
|
|
422
434
|
|
|
435
|
+
### `Type 'Reply<500, …>' is not assignable to type 'MaybePromise<void | Reply<ClientErrorStatus, any> | undefined>'`
|
|
436
|
+
|
|
437
|
+
**When:** an `onRefusal` hook returns a reply whose status is not a client
|
|
438
|
+
error, such as a 500 or a 200.
|
|
439
|
+
|
|
440
|
+
```text
|
|
441
|
+
error TS2322: Type 'Reply<500, { readonly status: 500; }>' is not assignable to type 'MaybePromise<void | Reply<ClientErrorStatus, any> | undefined>'.
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
**Why:** a refused request is the client's error. A 5xx would tell a client
|
|
445
|
+
to retry a request that will be refused again, and a 2xx would say it
|
|
446
|
+
succeeded.
|
|
447
|
+
|
|
448
|
+
**Fix:** answer with a 4xx, such as 400 or 422:
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
app.onRefusal((refusal) => problem({ status: 422, detail: `the request is refused: ${refusal.kind}` }));
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### `Property 'part' does not exist on type 'Refusal'`
|
|
455
|
+
|
|
456
|
+
**When:** an `onRefusal` hook reads `part` or `issues` without checking
|
|
457
|
+
the refusal's `kind`, often by destructuring it:
|
|
458
|
+
|
|
459
|
+
```text
|
|
460
|
+
error TS2339: Property 'part' does not exist on type 'Refusal'.
|
|
461
|
+
Property 'part' does not exist on type 'BodyLimitRefusal'.
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
**Why:** a hook answers every kind of refusal. A `body_limit` refusal, a
|
|
465
|
+
body past the route's `bodyLimit`, has a `limit` and no `part` or
|
|
466
|
+
`issues`.
|
|
467
|
+
|
|
468
|
+
**Fix:** check `kind` first. Return nothing for a kind you leave to its
|
|
469
|
+
default:
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
app.onRefusal((refusal) =>
|
|
473
|
+
refusal.kind === 'validation' ? problem({ status: 400, detail: `the ${refusal.part} is invalid` }) : undefined,
|
|
474
|
+
);
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### `'500' does not exist in type 'RefusalResponses'`
|
|
478
|
+
|
|
479
|
+
**When:** the schemas given to `onRefusal` declare a status that is not a
|
|
480
|
+
client error.
|
|
481
|
+
|
|
482
|
+
```text
|
|
483
|
+
error TS2353: Object literal may only specify known properties, and '500' does not exist in type 'RefusalResponses'.
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
**Fix:** declare the 4xx the hook answers:
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
app.onRefusal({ response: { 400: Problem } }, (_, { reply }) =>
|
|
490
|
+
reply(400, { type: 'urn:example:invalid', status: 400, detail: 'invalid' }),
|
|
491
|
+
);
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### `The inferred type of '…' cannot be named without a reference to '…' from '…/@alxia/core/dist/…'`
|
|
495
|
+
|
|
496
|
+
**When:** `tsc` with `declaration: true` (a library, or a project with
|
|
497
|
+
`composite`), on an exported function or constant whose type is an app
|
|
498
|
+
inferred from its builders: TS2883, *"This is likely not portable. A type
|
|
499
|
+
annotation is necessary."*
|
|
500
|
+
|
|
501
|
+
**Why:** the declaration of that export must name every type the app's
|
|
502
|
+
type is made of, through `@alxia/core` itself. Before 0.2.1,
|
|
503
|
+
`RefusalsOf`, `RefusalOutcome` and `DeclaredRefusal` were not exported, so
|
|
504
|
+
an app with an `onRefusal` hook could not be named.
|
|
505
|
+
|
|
506
|
+
**Fix:** upgrade to `@alxia/core` 0.2.1 or later. A type core still fails
|
|
507
|
+
to export is a bug: report it with the code. Until then, annotate the
|
|
508
|
+
export or the hook's return type, e.g. `Reply<400 | 413, ProblemDetails>`, with `Reply` and `ProblemDetails` from `@alxia/core`.
|
|
509
|
+
|
|
423
510
|
## Building the app
|
|
424
511
|
|
|
425
512
|
These are `TypeError`s thrown when a route is declared, so the app fails at
|
|
@@ -627,6 +714,19 @@ third argument that is `undefined`.
|
|
|
627
714
|
app.get('/a', { query: Query }, ({ query, reply }) => reply(200, query));
|
|
628
715
|
```
|
|
629
716
|
|
|
717
|
+
### `POST /…: bodyLimit must be a whole number of bytes, 0 or more; got …`
|
|
718
|
+
|
|
719
|
+
**When:** a route's `bodyLimit` is negative, fractional, `NaN` or
|
|
720
|
+
`Infinity`. `bodyLimit()` throws the same message, prefixed
|
|
721
|
+
`bodyLimit():`.
|
|
722
|
+
|
|
723
|
+
**Fix:** give a byte count, or leave `bodyLimit` out for no limit beyond
|
|
724
|
+
the server's:
|
|
725
|
+
|
|
726
|
+
```ts
|
|
727
|
+
app.post('/upload', { bodyLimit: 25 * 1024 * 1024 }, handler);
|
|
728
|
+
```
|
|
729
|
+
|
|
630
730
|
### `group(): build is missing`
|
|
631
731
|
|
|
632
732
|
**When:** `group('/admin')` is called without its function.
|
|
@@ -637,6 +737,19 @@ app.get('/a', { query: Query }, ({ query, reply }) => reply(200, query));
|
|
|
637
737
|
app.group('/admin', (admin) => admin.derive(requireAdmin).get('/stats', stats));
|
|
638
738
|
```
|
|
639
739
|
|
|
740
|
+
### `onRefusal(): the hook is missing`
|
|
741
|
+
|
|
742
|
+
**When:** `onRefusal` is given its schemas but no hook. The types refuse
|
|
743
|
+
that, so this comes from JavaScript or a cast.
|
|
744
|
+
|
|
745
|
+
**Fix:** pass the hook after the schemas:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
app.onRefusal({ response: { 400: Problem } }, (_, { reply }) =>
|
|
749
|
+
reply(400, { type: 'urn:example:invalid', status: 400, detail: 'invalid' }),
|
|
750
|
+
);
|
|
751
|
+
```
|
|
752
|
+
|
|
640
753
|
### `page(): /… is already served`
|
|
641
754
|
|
|
642
755
|
**When:** `page(path, bundle)` names a path that another `page` or a
|
|
@@ -665,8 +778,8 @@ app.page('/dashboard', dashboard).get('/api/dashboard', ({ reply }) => reply(200
|
|
|
665
778
|
## Responses
|
|
666
779
|
|
|
667
780
|
The app answers these itself. Their bodies are the exported
|
|
668
|
-
`ValidationErrorBody`, `
|
|
669
|
-
`RangeNotSatisfiableBody` and `InternalErrorBody`.
|
|
781
|
+
`ValidationErrorBody`, `ContentTooLargeBody`, `RoutingErrorBody`,
|
|
782
|
+
`FileNotFoundBody`, `RangeNotSatisfiableBody` and `InternalErrorBody`.
|
|
670
783
|
|
|
671
784
|
### `400 {"error":"validation","issues":[…]}`
|
|
672
785
|
|
|
@@ -714,6 +827,86 @@ await fetch('/users', {
|
|
|
714
827
|
[`@alxia/client`](https://www.npmjs.com/package/@alxia/client) sets the
|
|
715
828
|
`content-type` for you.
|
|
716
829
|
|
|
830
|
+
To answer it in another format, such as an RFC 9457 problem, declare
|
|
831
|
+
[`onRefusal`](guide/hooks.md#onrefusal) before the routes.
|
|
832
|
+
|
|
833
|
+
### A route still answers `{"error":"validation"}` after `onRefusal`
|
|
834
|
+
|
|
835
|
+
**When:** an app declares `onRefusal`, and a refused request to one of its
|
|
836
|
+
routes still gets the default 400.
|
|
837
|
+
|
|
838
|
+
**Why**, by what you find:
|
|
839
|
+
|
|
840
|
+
- The route is declared **before** the hook. A route hook applies to the
|
|
841
|
+
routes declared after it, never before: move the hook up the chain.
|
|
842
|
+
- The hook is declared inside a `group`. A group's hooks stay inside it:
|
|
843
|
+
declare the hook on the app, before the group.
|
|
844
|
+
- The hook returned nothing, `undefined`, for this refusal. Nothing means
|
|
845
|
+
the default: return a reply for every refusal you want answered.
|
|
846
|
+
|
|
847
|
+
**Fix:** declare the hook first, and return a reply:
|
|
848
|
+
|
|
849
|
+
```ts
|
|
850
|
+
const app = alxia()
|
|
851
|
+
.onRefusal((refusal) =>
|
|
852
|
+
refusal.kind === 'validation' ? problem({ status: 400, detail: `the ${refusal.part} is invalid` }) : undefined,
|
|
853
|
+
)
|
|
854
|
+
.post('/users', { body: NewUser }, handler);
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
A body past the route's `bodyLimit` still gets the default
|
|
858
|
+
`413 {"error":"content_too_large","limit":…}` from that hook, which returns nothing
|
|
859
|
+
for a `body_limit`. Return a reply for it too to answer it in your format
|
|
860
|
+
([`413`](#413-errorcontent_too_largelimit)).
|
|
861
|
+
|
|
862
|
+
### `413 {"error":"content_too_large","limit":…}`
|
|
863
|
+
|
|
864
|
+
**When:** a request's body is larger than its route's `bodyLimit`: the
|
|
865
|
+
route's own, or the one a `bodyLimit(bytes)` before it set. Either its
|
|
866
|
+
`Content-Length` says so, and the body is not read, or the bytes counted as
|
|
867
|
+
it was read passed the limit:
|
|
868
|
+
|
|
869
|
+
```json
|
|
870
|
+
{ "error": "content_too_large", "limit": 65536 }
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
**Why:** `limit` is the route's limit, in bytes. The count covers every
|
|
874
|
+
reader of the body: the JSON, form and text parsers, a `parser` of the
|
|
875
|
+
app's, and a handler reading `ctx.request.body`. A body of exactly `limit`
|
|
876
|
+
bytes is accepted.
|
|
877
|
+
|
|
878
|
+
**Fix:** send a smaller body, or raise the limit for that route alone. A
|
|
879
|
+
route's own `bodyLimit` wins over the default:
|
|
880
|
+
|
|
881
|
+
```ts
|
|
882
|
+
app
|
|
883
|
+
.bodyLimit(64 * 1024)
|
|
884
|
+
.post('/attachments', { bodyLimit: 25 * 1024 * 1024 }, async ({ request, reply }) => {
|
|
885
|
+
await Bun.write('attachment.bin', new Response(request.body));
|
|
886
|
+
return reply(204);
|
|
887
|
+
});
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
To answer in another format, such as an RFC 9457 problem, return it from
|
|
891
|
+
an [`onRefusal`](guide/hooks.md#onrefusal) hook declared before the route,
|
|
892
|
+
for the refusal of kind `body_limit`. The `onError` hooks never see it:
|
|
893
|
+
|
|
894
|
+
```ts
|
|
895
|
+
import { problem } from '@alxia/core';
|
|
896
|
+
|
|
897
|
+
app
|
|
898
|
+
.onRefusal((refusal) =>
|
|
899
|
+
refusal.kind === 'body_limit'
|
|
900
|
+
? problem({ type: 'urn:ietf:params:jmap:error:limit', status: 413, limit: 'maxSizeRequest' })
|
|
901
|
+
: undefined,
|
|
902
|
+
)
|
|
903
|
+
.post('/api', { body: z.unknown(), bodyLimit: 10_000_000 }, handler);
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
A 413 with no JSON body comes from Bun itself. The body passed `listen`'s
|
|
907
|
+
`maxRequestBodySize`, which applies to every route, before any route's
|
|
908
|
+
`bodyLimit`.
|
|
909
|
+
|
|
717
910
|
### `404 {"error":"not_found"}`
|
|
718
911
|
|
|
719
912
|
**When:** a request matches no route, or a `static` or `file` route finds
|
|
@@ -802,6 +995,14 @@ The next section lists the messages it prints. An error thrown in a route
|
|
|
802
995
|
first goes through that route's `onError` hooks. An `HttpError` is answered
|
|
803
996
|
with its own status and body.
|
|
804
997
|
|
|
998
|
+
A request that fails because its client hung up (its `request.signal`
|
|
999
|
+
aborted, and the error is the `AbortError` a body read then throws) is
|
|
1000
|
+
not an error of the app: nothing is printed, no `onError` hook runs, and
|
|
1001
|
+
an `onResponse` hook, a logger's, sees a `499` with no body. Any other
|
|
1002
|
+
error is printed and answered 500, a bug thrown after the client left
|
|
1003
|
+
included. A handler that ignores the abort and replies gets its own
|
|
1004
|
+
status.
|
|
1005
|
+
|
|
805
1006
|
**Fix:** read the server log for the real error. To answer a known failure
|
|
806
1007
|
with a status of your own, return a declared reply, or turn the error into
|
|
807
1008
|
one with `onError`:
|
|
@@ -817,6 +1018,28 @@ app
|
|
|
817
1018
|
Prefer a declared `reply` to `throw new HttpError(…)`: a thrown status is
|
|
818
1019
|
not in the route's type, so a typed client does not expect it.
|
|
819
1020
|
|
|
1021
|
+
### A plugin's route reads a body past the app's `bodyLimit()`
|
|
1022
|
+
|
|
1023
|
+
**When:** an app calls `bodyLimit(bytes)` and then `use(plugin)` with an
|
|
1024
|
+
app plugin, and a route of that plugin accepts a larger body.
|
|
1025
|
+
|
|
1026
|
+
**Why:** an app's `bodyLimit()` reaches the routes declared on it and in
|
|
1027
|
+
its groups, never a plugin's. A plugin's route keeps the limit it was
|
|
1028
|
+
declared with, and its type with it. A plugin route with no `onRefusal` of
|
|
1029
|
+
its own is still answered by the app's hook. A function plugin that declares its routes on the app is
|
|
1030
|
+
bounded like any of them.
|
|
1031
|
+
|
|
1032
|
+
**Fix:** give the plugin's route a `bodyLimit` of its own:
|
|
1033
|
+
|
|
1034
|
+
```ts
|
|
1035
|
+
const uploads = alxia().post('/upload', { bodyLimit: 25 * 1024 * 1024 }, handler);
|
|
1036
|
+
const app = alxia().bodyLimit(64 * 1024).use(uploads);
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
A `bodyLimit()` the plugin calls instead also applies to the app's routes
|
|
1040
|
+
declared after `use`, as its hooks do. Call the app's own after `use` to
|
|
1041
|
+
keep it.
|
|
1042
|
+
|
|
820
1043
|
## Routing
|
|
821
1044
|
|
|
822
1045
|
A trap that prints nothing: the answer comes from another route than the
|
|
@@ -847,7 +1070,8 @@ app
|
|
|
847
1070
|
## Server log
|
|
848
1071
|
|
|
849
1072
|
Each of these is printed by `console.error`, and the request is answered
|
|
850
|
-
`500 {"error":"internal"}`.
|
|
1073
|
+
`500 {"error":"internal"}`. A client that hung up mid-request prints
|
|
1074
|
+
nothing: see [`500 {"error":"internal"}`](#500-errorinternal).
|
|
851
1075
|
|
|
852
1076
|
### `ResponseValidationError: … the 200 reply does not match its schema`
|
|
853
1077
|
|
|
@@ -861,6 +1085,9 @@ at runtime. Data from a database, `JSON.parse` or `any` gets past the types.
|
|
|
861
1085
|
**Why:** what leaves the server is the schema's output. A body the schema
|
|
862
1086
|
refuses is never sent, so the client never reads an undeclared shape.
|
|
863
1087
|
|
|
1088
|
+
An `onRefusal` hook given schemas is checked the same way: the message
|
|
1089
|
+
names the route that was refused and the status the hook replied with.
|
|
1090
|
+
|
|
864
1091
|
**Fix:** map the data to the schema before replying. To skip the check in
|
|
865
1092
|
a hot path you trust, turn it off for the app; the declared status is still
|
|
866
1093
|
enforced:
|
|
@@ -878,7 +1105,9 @@ ResponseValidationError: GET /u declares no 201 reply
|
|
|
878
1105
|
**When:** a route with `response` schemas replies with a status it did not
|
|
879
1106
|
declare. The types refuse that, so this comes from JavaScript or a cast.
|
|
880
1107
|
Redirects (3xx without a body) are exempt, and so is a reply returned by
|
|
881
|
-
`onError` or a `derive`.
|
|
1108
|
+
`onError` or a `derive`. An `onRefusal` hook given schemas that replies
|
|
1109
|
+
with a status they do not declare fails the same way, naming the refused
|
|
1110
|
+
route: declare the status in the hook's `response`.
|
|
882
1111
|
|
|
883
1112
|
**Fix:** declare the status in `response`:
|
|
884
1113
|
|
|
@@ -901,6 +1130,20 @@ app.get('/users', async ({ reply }) => {
|
|
|
901
1130
|
});
|
|
902
1131
|
```
|
|
903
1132
|
|
|
1133
|
+
### `TypeError: … the onRefusal hook returned neither a reply nor nothing.`
|
|
1134
|
+
|
|
1135
|
+
**When:** an `onRefusal` hook returns something that is neither a `Reply`
|
|
1136
|
+
nor `undefined`, such as a plain object or a `Response`. TypeScript
|
|
1137
|
+
refuses it, so this comes from JavaScript or a cast.
|
|
1138
|
+
|
|
1139
|
+
**Fix:** return `reply(…)` or `problem(…)`, or nothing for the default:
|
|
1140
|
+
|
|
1141
|
+
```ts
|
|
1142
|
+
app.onRefusal((refusal) =>
|
|
1143
|
+
refusal.kind === 'validation' && refusal.part === 'body' ? problem({ status: 400, detail: 'bad body' }) : undefined,
|
|
1144
|
+
);
|
|
1145
|
+
```
|
|
1146
|
+
|
|
904
1147
|
### `TypeError: An event does not match its schema`
|
|
905
1148
|
|
|
906
1149
|
```text
|
|
@@ -908,7 +1151,8 @@ TypeError: An event does not match its schema: n: Invalid input: expected number
|
|
|
908
1151
|
```
|
|
909
1152
|
|
|
910
1153
|
**When:** a value yielded by an `eventStream` reply is refused by the
|
|
911
|
-
event's schema.
|
|
1154
|
+
event's schema. On a named stream, the path starts with the event's name:
|
|
1155
|
+
`ping.interval: …`. The response has already started with a 200, so the stream
|
|
912
1156
|
is cut, and the client reads an error mid-stream instead of a 500.
|
|
913
1157
|
|
|
914
1158
|
**Fix:** yield values the event's schema accepts, mapping them inside the
|
|
@@ -920,6 +1164,86 @@ reply(200, (async function* () {
|
|
|
920
1164
|
})());
|
|
921
1165
|
```
|
|
922
1166
|
|
|
1167
|
+
### `TypeError: An event id must not hold a line break or a NUL`
|
|
1168
|
+
|
|
1169
|
+
```text
|
|
1170
|
+
TypeError: An event id must not hold a line break or a NUL: "1\ndata: forged"
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
**When:** an event yielded on a named `eventStream({ … })` has an `id`
|
|
1174
|
+
holding a CR, an LF or a NUL (`An event id must be a string` when it is
|
|
1175
|
+
not a string at all). Its type is `string`, so this comes from data
|
|
1176
|
+
that reached the id unchecked: a client's input, a database row.
|
|
1177
|
+
|
|
1178
|
+
**Why:** a line break would end the `id:` line and start a field the
|
|
1179
|
+
handler never yielded, and an `EventSource` ignores an id holding a NUL. The
|
|
1180
|
+
stream ends before the event is written; the events already sent stay sent.
|
|
1181
|
+
|
|
1182
|
+
**Fix:** make the id from something without line breaks — a counter, a
|
|
1183
|
+
state string you issue — or encode it:
|
|
1184
|
+
|
|
1185
|
+
```ts
|
|
1186
|
+
yield Push.event('state', change, { id: encodeURIComponent(state) });
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
### `TypeError: An event retry must be a whole number of milliseconds, 0 or more`
|
|
1190
|
+
|
|
1191
|
+
**When:** an event's `retry` is a fraction, negative, `NaN` or not a number.
|
|
1192
|
+
|
|
1193
|
+
**Why:** an `EventSource` reads `retry:` as ASCII digits only, and ignores
|
|
1194
|
+
anything else; the stream ends rather than send a field no client reads.
|
|
1195
|
+
|
|
1196
|
+
**Fix:** round it:
|
|
1197
|
+
|
|
1198
|
+
```ts
|
|
1199
|
+
yield Push.event('ping', { interval: 30 }, { retry: Math.round(seconds * 1000) });
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
### `TypeError: The event "…" is not declared: …`
|
|
1203
|
+
|
|
1204
|
+
**When:** a value yielded on a named stream has an `event` that is not one
|
|
1205
|
+
of the names its `eventStream({ … })` declares. The types refuse it, so this
|
|
1206
|
+
comes from a cast or from JavaScript. `TypeError: An event of a named stream
|
|
1207
|
+
is an object { event, data }` is its sibling, for a value that has no
|
|
1208
|
+
`event` at all.
|
|
1209
|
+
|
|
1210
|
+
**Fix:** declare the event, or yield one that is:
|
|
1211
|
+
|
|
1212
|
+
```ts
|
|
1213
|
+
const Push = eventStream({ state: StateChange, ping: Ping });
|
|
1214
|
+
yield Push.event('ping', { interval: 30 });
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
### `TypeError: An event name must not hold a line break or a NUL`
|
|
1218
|
+
|
|
1219
|
+
**When:** `eventStream({ … })` is given a name holding a CR, an LF or a
|
|
1220
|
+
NUL, or an empty one (`An event name must not be empty`). It throws when the
|
|
1221
|
+
app is built, as do `A named event stream declares at least one event` and
|
|
1222
|
+
`The event "…" is not a Standard Schema`.
|
|
1223
|
+
|
|
1224
|
+
**Fix:** name each event with a single line, and give each a schema:
|
|
1225
|
+
|
|
1226
|
+
```ts
|
|
1227
|
+
const Push = eventStream({ state: StateChange, ping: Ping });
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
### `Type 'string' is not assignable to type '"ping"'` on a named stream
|
|
1231
|
+
|
|
1232
|
+
**When:** a generator handed to `reply(200, …)` yields a plain
|
|
1233
|
+
`{ event: 'ping', data }` object: TypeScript widens its `event` to `string`,
|
|
1234
|
+
which no declared name is.
|
|
1235
|
+
|
|
1236
|
+
**Fix:** build the event with the stream's `event`, which keeps the name,
|
|
1237
|
+
or annotate the generator:
|
|
1238
|
+
|
|
1239
|
+
```ts
|
|
1240
|
+
yield Push.event('ping', { interval: 30 });
|
|
1241
|
+
// or
|
|
1242
|
+
async function* pings(): AsyncGenerator<EventInput<typeof Push>> {
|
|
1243
|
+
yield { event: 'ping', data: { interval: 30 } };
|
|
1244
|
+
}
|
|
1245
|
+
```
|
|
1246
|
+
|
|
923
1247
|
## WebSockets
|
|
924
1248
|
|
|
925
1249
|
### `{"error":"validation", … "code":"invalid_json","message":"The message is not valid JSON"}`
|
package/package.json
CHANGED