@alxia/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +103 -3
  2. package/dist/app/alxia.d.ts +53 -5
  3. package/dist/app/alxia.d.ts.map +1 -1
  4. package/dist/app/chain.d.ts +5 -8
  5. package/dist/app/chain.d.ts.map +1 -1
  6. package/dist/app/context.d.ts +12 -0
  7. package/dist/app/context.d.ts.map +1 -0
  8. package/dist/app/definition.d.ts +22 -1
  9. package/dist/app/definition.d.ts.map +1 -1
  10. package/dist/app/refusal.d.ts +15 -0
  11. package/dist/app/refusal.d.ts.map +1 -0
  12. package/dist/app/send.d.ts +8 -1
  13. package/dist/app/send.d.ts.map +1 -1
  14. package/dist/app/socket.d.ts +1 -1
  15. package/dist/app/socket.d.ts.map +1 -1
  16. package/dist/app/types/index.d.ts +1 -0
  17. package/dist/app/types/index.d.ts.map +1 -1
  18. package/dist/app/types/refusal.d.ts +94 -0
  19. package/dist/app/types/refusal.d.ts.map +1 -0
  20. package/dist/app/types/route-table.d.ts +3 -2
  21. package/dist/app/types/route-table.d.ts.map +1 -1
  22. package/dist/app/types/schema.d.ts +7 -0
  23. package/dist/app/types/schema.d.ts.map +1 -1
  24. package/dist/app/types/valid-schema.d.ts +1 -1
  25. package/dist/app/types/valid-schema.d.ts.map +1 -1
  26. package/dist/errors/errors.d.ts +47 -1
  27. package/dist/errors/errors.d.ts.map +1 -1
  28. package/dist/index.d.ts +5 -3
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +395 -97
  31. package/dist/index.js.map +20 -13
  32. package/dist/reply/problem.d.ts +33 -0
  33. package/dist/reply/problem.d.ts.map +1 -0
  34. package/dist/reply/reply.d.ts.map +1 -1
  35. package/dist/request/limit.d.ts +13 -0
  36. package/dist/request/limit.d.ts.map +1 -0
  37. package/dist/request/read.d.ts +3 -2
  38. package/dist/request/read.d.ts.map +1 -1
  39. package/dist/sse/async-iterable.d.ts +6 -0
  40. package/dist/sse/async-iterable.d.ts.map +1 -0
  41. package/dist/sse/event-stream.d.ts +29 -6
  42. package/dist/sse/event-stream.d.ts.map +1 -1
  43. package/dist/sse/frame.d.ts +35 -0
  44. package/dist/sse/frame.d.ts.map +1 -0
  45. package/dist/sse/named-events.d.ts +70 -0
  46. package/dist/sse/named-events.d.ts.map +1 -0
  47. package/docs/README.md +3 -3
  48. package/docs/guide/groups-and-plugins.md +8 -1
  49. package/docs/guide/hooks.md +128 -3
  50. package/docs/guide/replies.md +40 -0
  51. package/docs/guide/routes.md +115 -1
  52. package/docs/guide/server-sent-events.md +177 -5
  53. package/docs/guide/serving.md +1 -1
  54. package/docs/guide/types.md +3 -1
  55. package/docs/guide/websockets.md +1 -1
  56. package/docs/guide/writing-a-plugin.md +1 -1
  57. package/docs/roadmap.md +35 -2
  58. package/docs/troubleshooting.md +303 -6
  59. package/package.json +1 -1
@@ -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":"AACA,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"}
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"}
@@ -1,4 +1,4 @@
1
- import type { ValidationIssue } from '../errors/errors';
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,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAExD;;;;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;;;;GAIG;AACH,wBAAsB,QAAQ,CAC7B,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,SAAS,UAAU,EAAO,GACjC,OAAO,CAAC,QAAQ,CAAC,CAsDnB"}
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,6 @@
1
+ /**
2
+ * Whether a body is a stream of events: any async iterable but a
3
+ * `ReadableStream`, which is sent as bytes.
4
+ */
5
+ export declare function isAsyncIterable(value: unknown): value is AsyncIterable<unknown>;
6
+ //# sourceMappingURL=async-iterable.d.ts.map
@@ -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: each value the handler yields
13
- * is checked by `item`, and sent as its output.
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 isAsyncIterable(value: unknown): value is AsyncIterable<unknown>;
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 one
27
- * `data:` line of JSON, a comment while nothing is sent, and the iterator
28
- * closed when the client goes away.
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;AAEnC,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;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,IAAI,SAAS,gBAAgB,EACxD,IAAI,EAAE,IAAI,GACR,iBAAiB,CAAC,IAAI,CAAC,CAkBzB;AAqBD,wBAAgB,eAAe,CAC9B,KAAK,EAAE,OAAO,GACZ,KAAK,IAAI,aAAa,CAAC,OAAO,CAAC,CAOjC;AAED,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,gBAAgB,GACtB,MAAM,IAAI,iBAAiB,CAAC,gBAAgB,CAAC,CAE/C;AAED,8EAA8E;AAC9E,eAAO,MAAM,aAAa,OAAQ,CAAC;AAEnC;;;;GAIG;AACH,wBAAgB,aAAa,CAC5B,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,EAC9B,MAAM,CAAC,EAAE,WAAW,GAClB,cAAc,CAAC,UAAU,CAAC,CA2D5B"}
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 a redirect, or turning an error into a response |
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
 
@@ -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 }
@@ -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