@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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Problem details for HTTP APIs (RFC 9457, which obsoletes RFC 7807): a
|
|
3
|
+
* JSON body sent as `application/problem+json`.
|
|
4
|
+
*/
|
|
5
|
+
import type { ClientErrorStatus, ServerErrorStatus } from '../types/status';
|
|
6
|
+
import { Reply, type ReplyInit } from './reply';
|
|
7
|
+
/**
|
|
8
|
+
* The members RFC 9457 defines. A problem may carry members of its own
|
|
9
|
+
* beside them, its extensions: `problem()` keeps their types.
|
|
10
|
+
*/
|
|
11
|
+
export interface ProblemDetails<Status extends ClientErrorStatus | ServerErrorStatus = ClientErrorStatus | ServerErrorStatus> {
|
|
12
|
+
/** A URI naming the problem type; absent, it is `about:blank`. */
|
|
13
|
+
readonly type?: string;
|
|
14
|
+
/** A short summary of the problem type, the same for every occurrence. */
|
|
15
|
+
readonly title?: string;
|
|
16
|
+
/** The status of the response, which `problem()` replies with. */
|
|
17
|
+
readonly status: Status;
|
|
18
|
+
/** What went wrong with this occurrence. */
|
|
19
|
+
readonly detail?: string;
|
|
20
|
+
/** A URI naming this occurrence. */
|
|
21
|
+
readonly instance?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A reply whose body is a problem, with `status` as its status and
|
|
25
|
+
* `content-type: application/problem+json` unless `init` sets one. Any
|
|
26
|
+
* other member is an extension, kept as given and in the body's type:
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* problem({ type: 'urn:ietf:params:jmap:error:limit', status: 413, limit: 'maxSizeRequest' });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function problem<const Body extends ProblemDetails>(body: Body, init?: ReplyInit): Reply<Body['status'], Body>;
|
|
33
|
+
//# sourceMappingURL=problem.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem.d.ts","sourceRoot":"","sources":["../../src/reply/problem.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAE,KAAK,EAAE,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AAEhD;;;GAGG;AACH,MAAM,WAAW,cAAc,CAC9B,MAAM,SAAS,iBAAiB,GAAG,iBAAiB,GACjD,iBAAiB,GACjB,iBAAiB;IAEpB,kEAAkE;IAClE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,4CAA4C;IAC5C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,KAAK,CAAC,IAAI,SAAS,cAAc,EACxD,IAAI,EAAE,IAAI,EACV,IAAI,CAAC,EAAE,SAAS,GACd,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,CAM7B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reply.d.ts","sourceRoot":"","sources":["../../src/reply/reply.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"reply.d.ts","sourceRoot":"","sources":["../../src/reply/reply.ts"],"names":[],"mappings":"AAEA,OAAO,EAAY,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE5D,MAAM,WAAW,SAAS;IACzB;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED;;;;;;;GAOG;AACH,qBAAa,KAAK,CAAC,MAAM,SAAS,MAAM,GAAG,MAAM,EAAE,IAAI,GAAG,OAAO;IAChE;;;;OAIG;IACH,SAAiB,QAAQ,EAAE,IAAI,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,WAAW,GAAG,SAAS,CAAC;gBAE9B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,SAAS;CAKxD;AAED,+CAA+C;AAC/C,MAAM,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;AAEvC;;;GAGG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;CAUZ,CAAC;AAEX,uEAAuE;AACvE,MAAM,MAAM,SAAS,GAAG,OAAO,SAAS,CAAC;AAEzC,yEAAyE;AACzE,MAAM,MAAM,aAAa,GAAG;IAC3B,QAAQ,EAAE,IAAI,IAAI,MAAM,SAAS,GAAG,IAAI,SAAS,WAAW,GACzD,CAAC,IAAI,CAAC,EAAE,SAAS,KAAK,KAAK,CAAC,GAAG,EAAE,SAAS,CAAC,GAC3C,CAAC,KAAK,CAAC,IAAI,GAAG,SAAS,EACvB,IAAI,CAAC,EAAE,IAAI,EACX,IAAI,CAAC,EAAE,SAAS,KACZ,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC;CACnC,GAAG;IACH,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,MAAM,SAAS,UAAU,EAC9C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,SAAS,KACZ,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3B,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAChC,KAAK,CAAC,MAAM,SAAS,UAAU,EAC/B,KAAK,CAAC,IAAI,GAAG,SAAS,EAEtB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,IAAI,EACX,IAAI,CAAC,EAAE,SAAS,KACZ,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,GACxB,aAAa,CAAC;AAkCf,eAAO,MAAM,WAAW,EAAE,iBAOzB,CAAC;AAUF;;;;GAIG;AACH,wBAAgB,UAAU,CACzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,OAAO,EAChB,MAAM,CAAC,EAAE,WAAW,GAClB,QAAQ,CAiCV"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request with its body bounded to `limit` bytes, for every reader:
|
|
3
|
+
* the body parsers, a hook, a handler reading `request.body` as a stream.
|
|
4
|
+
* A `Content-Length` over the limit fails the first read without reading a
|
|
5
|
+
* byte; otherwise the bytes are counted as they arrive, and the read fails
|
|
6
|
+
* with a `ContentTooLargeError` once they pass the limit, so a chunked
|
|
7
|
+
* upload is never buffered whole. A request without a body, or whose body
|
|
8
|
+
* a global hook has already read, is returned as it is.
|
|
9
|
+
*/
|
|
10
|
+
export declare function limitBody(request: Request, limit: number): Request;
|
|
11
|
+
/** Whether `limit` is a byte count a route can be given. */
|
|
12
|
+
export declare function checkLimit(limit: number, where: string): number;
|
|
13
|
+
//# sourceMappingURL=limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limit.d.ts","sourceRoot":"","sources":["../../src/request/limit.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAwBlE;AAED,4DAA4D;AAC5D,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAO/D"}
|
package/dist/request/read.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ValidationIssue } from '../errors/errors';
|
|
2
2
|
/**
|
|
3
3
|
* The query string as an object. A key given once is a string, a key given
|
|
4
4
|
* more than once an array of them, so `z.array(...)` reads `?tag=a&tag=b`;
|
|
@@ -25,7 +25,8 @@ export type ReadBody = {
|
|
|
25
25
|
/**
|
|
26
26
|
* The request body, read by its `content-type`: by a parser the app added,
|
|
27
27
|
* else JSON, a form (an object of its fields, a field given more than once
|
|
28
|
-
* an array), text, or the bytes.
|
|
28
|
+
* an array), text, or the bytes. A body past the route's `bodyLimit` throws
|
|
29
|
+
* a `ContentTooLargeError`, a custom parser's included.
|
|
29
30
|
*/
|
|
30
31
|
export declare function readBody(request: Request, parsers?: readonly BodyParser[]): Promise<ReadBody>;
|
|
31
32
|
//# sourceMappingURL=read.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../src/request/read.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../src/request/read.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE9E;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CASrE;AAED,0DAA0D;AAC1D,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIpE;AAED,wCAAwC;AACxC,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIpE;AAED,iEAAiE;AACjE,MAAM,WAAW,UAAU;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC;CAC9C;AAaD,MAAM,MAAM,QAAQ,GACjB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC9C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAA;CAAE,CAAC;AAE3D;;;;;GAKG;AACH,wBAAsB,QAAQ,CAC7B,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,SAAS,UAAU,EAAO,GACjC,OAAO,CAAC,QAAQ,CAAC,CAuDnB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"async-iterable.d.ts","sourceRoot":"","sources":["../../src/sse/async-iterable.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,eAAe,CAC9B,KAAK,EAAE,OAAO,GACZ,KAAK,IAAI,aAAa,CAAC,OAAO,CAAC,CAOjC"}
|
|
@@ -4,28 +4,51 @@
|
|
|
4
4
|
* The client reads the same values back as an async iterable.
|
|
5
5
|
*/
|
|
6
6
|
import { type InferInput, type InferOutput, type StandardSchemaV1 } from '../schema/standard-schema';
|
|
7
|
+
import { type EventSchemas, type NamedEventStreamSchema } from './named-events';
|
|
7
8
|
/** A response schema whose body is a stream of events, each one checked by `item`. */
|
|
8
9
|
export interface EventStreamSchema<Item extends StandardSchemaV1> extends StandardSchemaV1<AsyncIterable<InferInput<Item>>, AsyncIterable<InferOutput<Item>>> {
|
|
9
10
|
readonly '~eventStream': Item;
|
|
10
11
|
}
|
|
11
12
|
/**
|
|
12
|
-
* The schema of a reply that streams events
|
|
13
|
-
*
|
|
13
|
+
* The schema of a reply that streams events.
|
|
14
|
+
*
|
|
15
|
+
* Given one schema, each value the handler yields is checked by it and sent
|
|
16
|
+
* as its output, on `data:` lines alone:
|
|
14
17
|
*
|
|
15
18
|
* ```ts
|
|
16
19
|
* app.get('/ticks', { response: { 200: eventStream(Tick) } }, ({ reply }) =>
|
|
17
20
|
* reply(200, (async function* () { yield { n: 1 }; })()));
|
|
18
21
|
* ```
|
|
22
|
+
*
|
|
23
|
+
* Given a schema per event name, the handler yields `{ event, data, id?,
|
|
24
|
+
* retry? }`: only a declared name, with data its schema accepts. Each is
|
|
25
|
+
* sent with its `event:` line, and the client reads `{ event, data, id? }`:
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* const Push = eventStream({ state: StateChange, ping: Ping });
|
|
29
|
+
* app.get('/push', { response: { 200: Push } }, ({ reply }) =>
|
|
30
|
+
* reply(200, (async function* () { yield { event: 'ping', data: { interval: 30 } }; })()));
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* An event name that is empty or holds a line break throws a `TypeError`.
|
|
19
34
|
*/
|
|
20
35
|
export declare function eventStream<Item extends StandardSchemaV1>(item: Item): EventStreamSchema<Item>;
|
|
21
|
-
export declare function
|
|
36
|
+
export declare function eventStream<Events extends EventSchemas>(events: Events & Declarable<Events>): NamedEventStreamSchema<Events>;
|
|
37
|
+
/**
|
|
38
|
+
* `unknown` for a map of events the stream can write, `never` for one with
|
|
39
|
+
* no event or an empty name: a compile error, as it is a `TypeError` at run
|
|
40
|
+
* time.
|
|
41
|
+
*/
|
|
42
|
+
type Declarable<Events> = [keyof Events] extends [never] ? never : '' extends keyof Events ? never : unknown;
|
|
22
43
|
export declare function isEventStreamSchema(schema: StandardSchemaV1): schema is EventStreamSchema<StandardSchemaV1>;
|
|
23
44
|
/** How often a comment keeps an idle stream open: Bun closes a silent one. */
|
|
24
45
|
export declare const KEEP_ALIVE_MS = 8000;
|
|
25
46
|
/**
|
|
26
|
-
* The events of `values` as a `text/event-stream` body: each value as
|
|
27
|
-
* `data:`
|
|
28
|
-
*
|
|
47
|
+
* The events of `values` as a `text/event-stream` body: each value as
|
|
48
|
+
* `data:` lines of JSON, after its `event:`, `id:` and `retry:` lines on a
|
|
49
|
+
* named stream, a comment while nothing is sent, and the iterator closed
|
|
50
|
+
* when the client goes away.
|
|
29
51
|
*/
|
|
30
52
|
export declare function toEventStream(values: AsyncIterable<unknown>, signal?: AbortSignal): ReadableStream<Uint8Array>;
|
|
53
|
+
export {};
|
|
31
54
|
//# sourceMappingURL=event-stream.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"event-stream.d.ts","sourceRoot":"","sources":["../../src/sse/event-stream.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAEN,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"event-stream.d.ts","sourceRoot":"","sources":["../../src/sse/event-stream.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAEN,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,sBAAsB,EAE3B,MAAM,gBAAgB,CAAC;AAExB,sFAAsF;AACtF,MAAM,WAAW,iBAAiB,CAAC,IAAI,SAAS,gBAAgB,CAC/D,SAAQ,gBAAgB,CACvB,aAAa,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,EAC/B,aAAa,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAChC;IACD,QAAQ,CAAC,cAAc,EAAE,IAAI,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,WAAW,CAAC,IAAI,SAAS,gBAAgB,EACxD,IAAI,EAAE,IAAI,GACR,iBAAiB,CAAC,IAAI,CAAC,CAAC;AAC3B,wBAAgB,WAAW,CAAC,MAAM,SAAS,YAAY,EACtD,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,MAAM,CAAC,GACjC,sBAAsB,CAAC,MAAM,CAAC,CAAC;AAqClC;;;;GAIG;AACH,KAAK,UAAU,CAAC,MAAM,IAAI,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,GACrD,KAAK,GACL,EAAE,SAAS,MAAM,MAAM,GACtB,KAAK,GACL,OAAO,CAAC;AAUZ,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,gBAAgB,GACtB,MAAM,IAAI,iBAAiB,CAAC,gBAAgB,CAAC,CAE/C;AAED,8EAA8E;AAC9E,eAAO,MAAM,aAAa,OAAQ,CAAC;AAEnC;;;;;GAKG;AACH,wBAAgB,aAAa,CAC5B,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,EAC9B,MAAM,CAAC,EAAE,WAAW,GAClB,cAAc,CAAC,UAAU,CAAC,CAmD5B"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One event of a `text/event-stream` body as it is written: its `event:`,
|
|
3
|
+
* `id:` and `retry:` fields, then its data as JSON on `data:` lines.
|
|
4
|
+
*/
|
|
5
|
+
import type { ValidationIssue } from '../errors/errors';
|
|
6
|
+
/**
|
|
7
|
+
* An event with fields of its own, as a named stream's validator gives it
|
|
8
|
+
* to the writer. A plain value yielded by any other stream is sent as its
|
|
9
|
+
* `data` alone, so a `{ event }` object a handler yields without a named
|
|
10
|
+
* schema stays JSON.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Frame {
|
|
13
|
+
readonly event: string;
|
|
14
|
+
readonly data: unknown;
|
|
15
|
+
readonly id: string | undefined;
|
|
16
|
+
readonly retry: number | undefined;
|
|
17
|
+
constructor(event: string, data: unknown, id: string | undefined, retry: number | undefined);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `value` as the text of one event: a `Frame`'s fields, then its data, each
|
|
21
|
+
* line of it on a `data:` line of its own, and the blank line ending it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function frameText(value: unknown): string;
|
|
24
|
+
/**
|
|
25
|
+
* The error of an event its schema refuses, each issue at its path, under
|
|
26
|
+
* the event's name on a named stream.
|
|
27
|
+
*/
|
|
28
|
+
export declare function mismatch(issues: readonly ValidationIssue[], name?: string): TypeError;
|
|
29
|
+
/** Why `name` cannot be an event's name, or `undefined` when it can. */
|
|
30
|
+
export declare function refuseName(name: string): string | undefined;
|
|
31
|
+
/** Why `id` cannot be an event's id, or `undefined` when it can. */
|
|
32
|
+
export declare function refuseId(id: unknown): string | undefined;
|
|
33
|
+
/** Why `retry` cannot be an event's retry, or `undefined` when it can. */
|
|
34
|
+
export declare function refuseRetry(retry: unknown): string | undefined;
|
|
35
|
+
//# sourceMappingURL=frame.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frame.d.ts","sourceRoot":"","sources":["../../src/sse/frame.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAExD;;;;;GAKG;AACH,qBAAa,KAAK;IACjB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;gBAGlC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,OAAO,EACb,EAAE,EAAE,MAAM,GAAG,SAAS,EACtB,KAAK,EAAE,MAAM,GAAG,SAAS;CAO1B;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAYhD;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CACvB,MAAM,EAAE,SAAS,eAAe,EAAE,EAClC,IAAI,CAAC,EAAE,MAAM,GACX,SAAS,CASX;AAKD,wEAAwE;AACxE,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM3D;AAED,oEAAoE;AACpE,wBAAgB,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAOxD;AAED,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAM9D"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-sent events with names: `eventStream({ state: State, ping: Ping })`
|
|
3
|
+
* maps each event name to the schema of its data. The handler yields
|
|
4
|
+
* `{ event, data, id?, retry? }`, and the client reads `{ event, data, id? }`,
|
|
5
|
+
* a union discriminated by `event`.
|
|
6
|
+
*/
|
|
7
|
+
import { type InferInput, type InferOutput, type StandardSchemaV1 } from '../schema/standard-schema';
|
|
8
|
+
import { Frame } from './frame';
|
|
9
|
+
/** The schema of each event's data, by event name. */
|
|
10
|
+
export type EventSchemas = Readonly<Record<string, StandardSchemaV1>>;
|
|
11
|
+
/** The schemas by name of `Of`: itself, or those of the named stream it is. */
|
|
12
|
+
type SchemasOf<Of> = Of extends {
|
|
13
|
+
readonly '~events': infer Events;
|
|
14
|
+
} ? Events : Of;
|
|
15
|
+
/**
|
|
16
|
+
* What a handler yields on a named stream: one of the declared events, with
|
|
17
|
+
* data its schema accepts. `id` sets the client's last event id; `retry`, in
|
|
18
|
+
* milliseconds, how long an `EventSource` waits before it reconnects. `Of`
|
|
19
|
+
* is the stream's schema, `EventInput<typeof Push>`, or its schemas by name.
|
|
20
|
+
*/
|
|
21
|
+
export type EventInput<Of extends EventSchemas | AnyNamedEventStream> = {
|
|
22
|
+
[Name in keyof SchemasOf<Of> & string]: {
|
|
23
|
+
readonly event: Name;
|
|
24
|
+
readonly data: InferInput<Extract<SchemasOf<Of>[Name], StandardSchemaV1>>;
|
|
25
|
+
readonly id?: string;
|
|
26
|
+
readonly retry?: number;
|
|
27
|
+
};
|
|
28
|
+
}[keyof SchemasOf<Of> & string];
|
|
29
|
+
/** What the client reads of a named stream: the event, its data as its schema gives it back, and its id when it had one. */
|
|
30
|
+
export type EventOutput<Of extends EventSchemas | AnyNamedEventStream> = {
|
|
31
|
+
[Name in keyof SchemasOf<Of> & string]: {
|
|
32
|
+
readonly event: Name;
|
|
33
|
+
readonly data: InferOutput<Extract<SchemasOf<Of>[Name], StandardSchemaV1>>;
|
|
34
|
+
readonly id?: string;
|
|
35
|
+
};
|
|
36
|
+
}[keyof SchemasOf<Of> & string];
|
|
37
|
+
/** Any named stream, whatever its events. */
|
|
38
|
+
interface AnyNamedEventStream {
|
|
39
|
+
readonly '~events': EventSchemas;
|
|
40
|
+
}
|
|
41
|
+
/** The fields an event may carry besides its name and data. */
|
|
42
|
+
export interface EventFields {
|
|
43
|
+
readonly id?: string;
|
|
44
|
+
readonly retry?: number;
|
|
45
|
+
}
|
|
46
|
+
/** A response schema whose body is a stream of named events, each one's data checked by the schema of its name. */
|
|
47
|
+
export interface NamedEventStreamSchema<Events extends EventSchemas> extends StandardSchemaV1<AsyncIterable<EventInput<Events>>, AsyncIterable<EventOutput<Events>>> {
|
|
48
|
+
readonly '~events': Events;
|
|
49
|
+
/**
|
|
50
|
+
* One event to yield, typed by the schema of its name: its data is
|
|
51
|
+
* checked, and its literals kept, where a plain `{ event, data }` object
|
|
52
|
+
* yielded from a generator would widen `event` to `string`.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* yield Push.event('ping', { interval: 30 });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
event<Name extends keyof Events & string>(event: Name, data: InferInput<Events[Name]>, fields?: EventFields): EventInput<Pick<Events, Name>>;
|
|
59
|
+
}
|
|
60
|
+
export declare function namedEventStream<Events extends EventSchemas>(events: Events): NamedEventStreamSchema<Events>;
|
|
61
|
+
export declare function isNamedEventStreamSchema(schema: StandardSchemaV1): schema is NamedEventStreamSchema<EventSchemas>;
|
|
62
|
+
/**
|
|
63
|
+
* Each value of `values` as a `Frame`: its name declared, its id and retry
|
|
64
|
+
* safe to write, and, when `validate`, its data checked by its schema. The
|
|
65
|
+
* fields are checked even when responses are not validated: a line break in
|
|
66
|
+
* one would write a frame the handler never yielded.
|
|
67
|
+
*/
|
|
68
|
+
export declare function toFrames(events: EventSchemas, values: AsyncIterable<unknown>, validate: boolean): AsyncGenerator<Frame>;
|
|
69
|
+
export {};
|
|
70
|
+
//# sourceMappingURL=named-events.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"named-events.d.ts","sourceRoot":"","sources":["../../src/sse/named-events.ts"],"names":[],"mappings":"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"}
|
package/docs/README.md
CHANGED
|
@@ -9,9 +9,9 @@ a realistic example for each.
|
|
|
9
9
|
| Page | Read it when |
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| [Getting started](guide/getting-started.md) | writing a first app, and testing it without a server |
|
|
12
|
-
| [Routes and schemas](guide/routes.md) | declaring paths, validating params, query, headers, cookies or a body, or reading a 400 |
|
|
13
|
-
| [Replies](guide/replies.md) | answering with a status, a file, a header, a cookie or
|
|
14
|
-
| [Hooks](guide/hooks.md) | authenticating, adding to the context, timing or tracing a request, or editing every response |
|
|
12
|
+
| [Routes and schemas](guide/routes.md) | declaring paths, validating params, query, headers, cookies or a body, capping a body's size, or reading a 400 or a 413 |
|
|
13
|
+
| [Replies](guide/replies.md) | answering with a status, a file, a header, a cookie, a redirect or an RFC 9457 problem, or turning an error into a response |
|
|
14
|
+
| [Hooks](guide/hooks.md) | authenticating, adding to the context, answering a refused request in your own format, timing or tracing a request, or editing every response |
|
|
15
15
|
| [Groups and plugins](guide/groups-and-plugins.md) | scoping hooks to some routes, splitting the app across files, or reading what `use` mounts |
|
|
16
16
|
| [Writing a plugin](guide/writing-a-plugin.md) | writing a plugin: an app, a `Plugin` function, or a `definePlugin` that reads what an earlier plugin added |
|
|
17
17
|
| [Static files](guide/static-files.md) | serving a directory, one file, a single-page app, or a Bun HTML bundle |
|
|
@@ -73,9 +73,12 @@ A plugin is either an **app** or a **function**.
|
|
|
73
73
|
|
|
74
74
|
| | Adds | Type of the app after `use` |
|
|
75
75
|
| --- | --- | --- |
|
|
76
|
-
| an app, `use(otherApp)` | routes, route hooks, context, typed replies, global hooks | grows: its routes and context are added |
|
|
76
|
+
| an app, `use(otherApp)` | routes, route hooks, context, typed replies, global hooks, its [`bodyLimit()`](routes.md#body-size-bodylimit) | grows: its routes and context are added |
|
|
77
77
|
| a function, `use(plugin)` | global hooks | unchanged |
|
|
78
78
|
|
|
79
|
+
The app's own `bodyLimit()` does not reach an app plugin's routes: they
|
|
80
|
+
keep the limit they were declared with ([Routes](routes.md#body-size-bodylimit)).
|
|
81
|
+
|
|
79
82
|
### An app as a plugin
|
|
80
83
|
|
|
81
84
|
```ts
|
|
@@ -88,6 +91,10 @@ use(plugin: Alxia<PluginCtx, PluginRoutes, PluginPrefix, PluginShortcuts>): Alxi
|
|
|
88
91
|
- Its **route hooks** then apply to the routes declared on this app after
|
|
89
92
|
`use`: an `auth` plugin can be a `derive` and nothing else.
|
|
90
93
|
- Its **`onError` hooks** are tried before this app's, for its own routes.
|
|
94
|
+
- Its **`onRefusal` hook** answers its own routes' refused requests. Its
|
|
95
|
+
routes without one take this app's, declared before `use`. The plugin's
|
|
96
|
+
hook then replaces this app's for the routes declared after `use`
|
|
97
|
+
([Hooks](hooks.md#onrefusal)).
|
|
91
98
|
- Its **global hooks**, body parsers and [pages](static-files.md#bun-html-bundles)
|
|
92
99
|
become this app's.
|
|
93
100
|
|
package/docs/guide/hooks.md
CHANGED
|
@@ -23,7 +23,7 @@ const app = alxia()
|
|
|
23
23
|
|
|
24
24
|
| | Applies to | Declared with |
|
|
25
25
|
| --- | --- | --- |
|
|
26
|
-
| route hooks | the routes declared **after** them, in the same app or [group](groups-and-plugins.md#groups) | `decorate`, `derive`, `wrap`, `onError` |
|
|
26
|
+
| route hooks | the routes declared **after** them, in the same app or [group](groups-and-plugins.md#groups) | `decorate`, `derive`, `wrap`, `onError`, `onRefusal` |
|
|
27
27
|
| global hooks | every request to the app, wherever they are declared | `around`, `onRequest`, `onResponse`, `onStart`, `onStop`, `parser` |
|
|
28
28
|
|
|
29
29
|
The order of the chain is the order of the request, at runtime and in the
|
|
@@ -37,12 +37,17 @@ around (first declared outermost)
|
|
|
37
37
|
└─ onRequest hooks ← a Response here is sent as it is
|
|
38
38
|
└─ routing ← 404, 405, 426
|
|
39
39
|
└─ route hooks, in order: derive / decorate / wrap
|
|
40
|
-
└─ validation ← 400
|
|
40
|
+
└─ validation ← 400, or the onRefusal hook's reply
|
|
41
41
|
└─ handler
|
|
42
42
|
onError hooks ← for what any of the above threw
|
|
43
43
|
onResponse hooks ← every response, 404s included
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
A body read past its route's `bodyLimit` is a `body_limit` refusal,
|
|
47
|
+
answered by [`onRefusal`](#onrefusal) or with a 413, wherever it is read: a
|
|
48
|
+
route hook, validation or the handler ([Routes](routes.md#body-size-bodylimit)). A global hook reads it
|
|
49
|
+
unbounded, and the route's limit is then skipped.
|
|
50
|
+
|
|
46
51
|
Route hooks run **before validation**: they read `pathParams`, the path
|
|
47
52
|
parameters as they arrived, not `params`.
|
|
48
53
|
|
|
@@ -152,6 +157,126 @@ const app = alxia()
|
|
|
152
157
|
The context is `Partial<Ctx>`: the error may have been thrown before a
|
|
153
158
|
`derive` added its part.
|
|
154
159
|
|
|
160
|
+
## `onRefusal`
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
onRefusal<Result extends Reply<ClientErrorStatus, any> | undefined | void>(
|
|
164
|
+
hook: (refusal: Refusal, ctx: BaseContext & Ctx) => MaybePromise<Result>,
|
|
165
|
+
): Alxia<…>
|
|
166
|
+
onRefusal<Responses extends RefusalResponses, Result extends DeclaredReply<Responses> | undefined | void>(
|
|
167
|
+
schema: { response: Responses; contentType?: string },
|
|
168
|
+
hook: (refusal: Refusal, ctx: Omit<BaseContext, 'reply'> & Ctx & { reply: TypedReplyFunction<Responses> }) => MaybePromise<Result>,
|
|
169
|
+
): Alxia<…>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Answers a request that a route declared after it refuses before its
|
|
173
|
+
handler runs. There are two kinds of refusal, each with its default:
|
|
174
|
+
|
|
175
|
+
| `kind` | When | Default |
|
|
176
|
+
| --- | --- | --- |
|
|
177
|
+
| `validation` | the route's schemas refuse the request | `400 { "error": "validation", "issues": […] }` ([The 400](routes.md#the-400)) |
|
|
178
|
+
| `body_limit` | the body is larger than the route's [`bodyLimit`](routes.md#body-size-bodylimit) | `413 { "error": "content_too_large", "limit": … }` |
|
|
179
|
+
|
|
180
|
+
The hook reads the refusal and the context. It returns a reply with a 4xx
|
|
181
|
+
status, or nothing to send that kind's default.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
interface ValidationRefusal {
|
|
185
|
+
readonly kind: 'validation';
|
|
186
|
+
readonly part: 'params' | 'query' | 'headers' | 'cookies' | 'body'; // the first that failed
|
|
187
|
+
readonly issues: readonly ValidationIssue[]; // every one, each with its target
|
|
188
|
+
}
|
|
189
|
+
interface BodyLimitRefusal {
|
|
190
|
+
readonly kind: 'body_limit';
|
|
191
|
+
readonly limit: number; // the route's limit, in bytes
|
|
192
|
+
}
|
|
193
|
+
type Refusal = ValidationRefusal | BodyLimitRefusal; // told apart by `kind`
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Only a `validation` refusal has a `part` and `issues`: check `kind` before
|
|
197
|
+
reading them. A kind added later reaches every hook too, and a hook that
|
|
198
|
+
returns nothing for it sends its default.
|
|
199
|
+
|
|
200
|
+
An API whose errors are RFC 9457 problems, as JMAP's are, answers them
|
|
201
|
+
with [`problem`](replies.md#problem-details-problem):
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { alxia, problem, type Refusal } from '@alxia/core';
|
|
205
|
+
import { z } from 'zod';
|
|
206
|
+
|
|
207
|
+
const jmapProblem = (refusal: Refusal) =>
|
|
208
|
+
refusal.kind === 'body_limit'
|
|
209
|
+
? problem({ type: 'urn:ietf:params:jmap:error:limit', status: 413, limit: 'maxSizeRequest' })
|
|
210
|
+
: problem({
|
|
211
|
+
type: refusal.issues.some((issue) => issue.code === 'invalid_json')
|
|
212
|
+
? 'urn:ietf:params:jmap:error:notJSON'
|
|
213
|
+
: 'urn:ietf:params:jmap:error:notRequest',
|
|
214
|
+
status: 400,
|
|
215
|
+
detail: `the ${refusal.part} is invalid`,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const app = alxia()
|
|
219
|
+
.onRefusal(jmapProblem)
|
|
220
|
+
.post('/jmap', { body: z.object({ using: z.array(z.string()) }), bodyLimit: 10_000_000 }, ({ reply }) =>
|
|
221
|
+
reply(200, { methodResponses: [] }),
|
|
222
|
+
)
|
|
223
|
+
.get('/download/:blobId', { params: z.object({ blobId: z.string().min(1) }) }, ({ reply }) =>
|
|
224
|
+
reply(200, 'blob'),
|
|
225
|
+
);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
A body that is not JSON gets `notJSON`, and a body or a path parameter its
|
|
229
|
+
schema refuses gets `notRequest`. A body past 10 MB gets JMAP's `limit`
|
|
230
|
+
problem, whether its `Content-Length` says so or the bytes counted do. It
|
|
231
|
+
is sent with 413 here, the app's choice; RFC 8620's own example of that
|
|
232
|
+
problem answers 400. Each is sent as
|
|
233
|
+
`application/problem+json`, with its `detail` naming the part.
|
|
234
|
+
|
|
235
|
+
**Order is meaning.** The last `onRefusal` declared before a route is the
|
|
236
|
+
one in force. A route declared before any keeps the default, and a
|
|
237
|
+
[group](groups-and-plugins.md#groups)'s hook stays inside the group. A
|
|
238
|
+
plugin given to `use` keeps its own hook for its routes. Its routes without
|
|
239
|
+
one take the hook of the app using it, and the plugin's hook then applies to
|
|
240
|
+
the routes declared after `use`, as its `derive`s do.
|
|
241
|
+
|
|
242
|
+
**Typed.** The hook's reply replaces the default 400 in the type of every
|
|
243
|
+
route after it that validates part of its request, so
|
|
244
|
+
[`@alxia/client`](https://www.npmjs.com/package/@alxia/client) reads the
|
|
245
|
+
problem. A route under a `bodyLimit` may be refused too, and its type
|
|
246
|
+
gains the hook's replies in place of the default 413. A route that neither
|
|
247
|
+
validates nor has a `bodyLimit` is never refused, and its type gains
|
|
248
|
+
nothing. A hook that may return nothing keeps the default of each kind the
|
|
249
|
+
route may refuse with — the 400, the 413 — in the type beside its own
|
|
250
|
+
reply. A status other than 400 replaces it: a hook that answers 422 makes
|
|
251
|
+
the route's outcomes 422 and no 400. The hook's type does not say which
|
|
252
|
+
reply answers which kind, so every reply it may return is in the type of
|
|
253
|
+
every route it may refuse: the JMAP hook above puts its 413 in the type of
|
|
254
|
+
`/download/:blobId` too, though only `/jmap` has a limit.
|
|
255
|
+
|
|
256
|
+
**With schemas.** Given `{ response, contentType? }` first, the hook's
|
|
257
|
+
`reply` is typed by those schemas, as a route's is. Its reply is checked by
|
|
258
|
+
the schema of its status and sent as that schema's output, and a reply the
|
|
259
|
+
schema refuses is a 500, as a handler's is ([`validateResponses`](replies.md#validateresponses)).
|
|
260
|
+
`contentType` is set on the reply unless it sets its own.
|
|
261
|
+
[`@alxia/openapi`](https://www.npmjs.com/package/@alxia/openapi) documents
|
|
262
|
+
each declared status under that content type. Without schemas it documents
|
|
263
|
+
a `4XX` whose body it does not know.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const Problem = z.object({ type: z.string(), status: z.literal(400), detail: z.string() });
|
|
267
|
+
|
|
268
|
+
const documented = alxia()
|
|
269
|
+
.onRefusal({ response: { 400: Problem }, contentType: 'application/problem+json' }, (refusal, { reply }) =>
|
|
270
|
+
reply(400, { type: 'urn:ietf:params:jmap:error:notRequest', status: 400, detail: refusal.kind }),
|
|
271
|
+
)
|
|
272
|
+
.post('/jmap', { body: z.object({ using: z.array(z.string()) }) }, ({ reply }) => reply(200, 'ok'));
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
A hook that throws reaches the route's `onError` hooks, as a handler's
|
|
276
|
+
error does. A socket route's upgrade is refused through the same hook. A
|
|
277
|
+
message the socket refuses is answered on the socket, as before
|
|
278
|
+
([WebSockets](websockets.md)).
|
|
279
|
+
|
|
155
280
|
## Global hooks
|
|
156
281
|
|
|
157
282
|
### `around`
|
|
@@ -264,7 +389,7 @@ interface RequestContext { // around, onRequest, onResponse
|
|
|
264
389
|
readonly error: unknown; // once a route has failed
|
|
265
390
|
}
|
|
266
391
|
|
|
267
|
-
interface BaseContext extends RequestContext { // derive, wrap, onError, handlers
|
|
392
|
+
interface BaseContext extends RequestContext { // derive, wrap, onError, onRefusal, handlers
|
|
268
393
|
readonly route: string; // as declared: /users/:id
|
|
269
394
|
readonly pathParams: Readonly<Record<string, string>>;
|
|
270
395
|
readonly set: ResponseSettings; // { headers: Headers; cookies: Bun.CookieMap }
|
package/docs/guide/replies.md
CHANGED
|
@@ -258,11 +258,17 @@ const app = alxia()
|
|
|
258
258
|
```ts
|
|
259
259
|
class HttpError<Status extends number = number, Body = unknown> extends Error {
|
|
260
260
|
constructor(status: Status, body: Body, message?: string);
|
|
261
|
+
readonly name: string; // 'HttpError', or a subclass's own
|
|
261
262
|
readonly status: Status;
|
|
262
263
|
readonly body: Body;
|
|
263
264
|
}
|
|
264
265
|
```
|
|
265
266
|
|
|
267
|
+
Core throws one subclass of its own, `ContentTooLargeError`, for a body past
|
|
268
|
+
its route's `bodyLimit` ([Routes](routes.md#body-size-bodylimit)). The route
|
|
269
|
+
answers it as a refusal, through [`onRefusal`](hooks.md#onrefusal), before
|
|
270
|
+
any `onError` hook. Test for it with `instanceof`, not by `name`.
|
|
271
|
+
|
|
266
272
|
An `onError` reply is added to the type of the routes after it. A thrown
|
|
267
273
|
`HttpError` is not: the client reads it as a status the route never
|
|
268
274
|
declared.
|
|
@@ -270,6 +276,40 @@ declared.
|
|
|
270
276
|
Every route's type includes `500 { error: 'internal' }`
|
|
271
277
|
(`InternalErrorBody`), so a client always handles it.
|
|
272
278
|
|
|
279
|
+
## Problem details: `problem`
|
|
280
|
+
|
|
281
|
+
`problem(details, init?)` is a reply whose body is a problem as RFC 9457
|
|
282
|
+
(which obsoletes RFC 7807) defines it. Its status is the problem's
|
|
283
|
+
`status`, and its `content-type` is `application/problem+json` unless
|
|
284
|
+
`init` sets one. Every other member is an extension, kept as given and in
|
|
285
|
+
the body's type:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { alxia, problem } from '@alxia/core';
|
|
289
|
+
|
|
290
|
+
const app = alxia().post('/upload', ({ request }) =>
|
|
291
|
+
Number(request.headers.get('content-length')) > 50_000_000
|
|
292
|
+
? problem({ type: 'urn:ietf:params:jmap:error:limit', status: 413, limit: 'maxSizeRequest' })
|
|
293
|
+
: problem({ type: 'about:blank', status: 501, title: 'Not Implemented' }),
|
|
294
|
+
);
|
|
295
|
+
// the client reads 413 { type: 'urn:ietf:params:jmap:error:limit'; status: 413; limit: 'maxSizeRequest' }
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
interface ProblemDetails<Status extends ClientErrorStatus | ServerErrorStatus> {
|
|
300
|
+
readonly type?: string; // a URI; absent, it is about:blank
|
|
301
|
+
readonly title?: string;
|
|
302
|
+
readonly status: Status; // the response's status
|
|
303
|
+
readonly detail?: string;
|
|
304
|
+
readonly instance?: string;
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
On a route with `response` schemas, a problem is a reply like any other:
|
|
309
|
+
its status must be declared and its body accepted by that status's schema.
|
|
310
|
+
[`onRefusal`](hooks.md#onrefusal) answers a refused request with one.
|
|
311
|
+
`@alxia/client` reads `application/problem+json` as JSON.
|
|
312
|
+
|
|
273
313
|
## Types
|
|
274
314
|
|
|
275
315
|
```ts
|