@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.
Files changed (60) hide show
  1. package/README.md +104 -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/pipeline.d.ts.map +1 -1
  11. package/dist/app/refusal.d.ts +15 -0
  12. package/dist/app/refusal.d.ts.map +1 -0
  13. package/dist/app/send.d.ts +24 -1
  14. package/dist/app/send.d.ts.map +1 -1
  15. package/dist/app/socket.d.ts +1 -1
  16. package/dist/app/socket.d.ts.map +1 -1
  17. package/dist/app/types/index.d.ts +1 -0
  18. package/dist/app/types/index.d.ts.map +1 -1
  19. package/dist/app/types/refusal.d.ts +94 -0
  20. package/dist/app/types/refusal.d.ts.map +1 -0
  21. package/dist/app/types/route-table.d.ts +3 -2
  22. package/dist/app/types/route-table.d.ts.map +1 -1
  23. package/dist/app/types/schema.d.ts +7 -0
  24. package/dist/app/types/schema.d.ts.map +1 -1
  25. package/dist/app/types/valid-schema.d.ts +1 -1
  26. package/dist/app/types/valid-schema.d.ts.map +1 -1
  27. package/dist/errors/errors.d.ts +47 -1
  28. package/dist/errors/errors.d.ts.map +1 -1
  29. package/dist/index.d.ts +5 -3
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +413 -103
  32. package/dist/index.js.map +20 -13
  33. package/dist/reply/problem.d.ts +33 -0
  34. package/dist/reply/problem.d.ts.map +1 -0
  35. package/dist/reply/reply.d.ts.map +1 -1
  36. package/dist/request/limit.d.ts +13 -0
  37. package/dist/request/limit.d.ts.map +1 -0
  38. package/dist/request/read.d.ts +3 -2
  39. package/dist/request/read.d.ts.map +1 -1
  40. package/dist/sse/async-iterable.d.ts +6 -0
  41. package/dist/sse/async-iterable.d.ts.map +1 -0
  42. package/dist/sse/event-stream.d.ts +29 -6
  43. package/dist/sse/event-stream.d.ts.map +1 -1
  44. package/dist/sse/frame.d.ts +35 -0
  45. package/dist/sse/frame.d.ts.map +1 -0
  46. package/dist/sse/named-events.d.ts +70 -0
  47. package/dist/sse/named-events.d.ts.map +1 -0
  48. package/docs/README.md +3 -3
  49. package/docs/guide/groups-and-plugins.md +8 -1
  50. package/docs/guide/hooks.md +131 -4
  51. package/docs/guide/replies.md +47 -0
  52. package/docs/guide/routes.md +115 -1
  53. package/docs/guide/server-sent-events.md +177 -5
  54. package/docs/guide/serving.md +1 -1
  55. package/docs/guide/types.md +3 -1
  56. package/docs/guide/websockets.md +1 -1
  57. package/docs/guide/writing-a-plugin.md +1 -1
  58. package/docs/roadmap.md +35 -2
  59. package/docs/troubleshooting.md +331 -7
  60. package/package.json +1 -1
@@ -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`, `RoutingErrorBody`, `FileNotFoundBody`,
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. The response has already started with a 200, so the stream
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A zero-dependency, type-safe HTTP framework for Bun: routes validated with any Standard Schema, typed from the request to the client",
5
5
  "license": "MIT",
6
6
  "type": "module",