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