@nifrajs/client 4.0.2 → 5.1.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/src/client.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  type TransportCodec,
26
26
  type TransportCodecRegistry,
27
27
  } from "@nifrajs/core/transport-codec"
28
+ import { buildQuery } from "./query.ts"
28
29
  import { RESERVED_VERB_KEYS } from "./reserved.ts"
29
30
  import type { ApiError, Result } from "./result.ts"
30
31
  import { hasDotSegment } from "./sendable-path.ts"
@@ -78,7 +79,7 @@ export interface ClientRetryOptions {
78
79
 
79
80
  export interface ClientOptions {
80
81
  /**
81
- * Headers sent on every HTTP request (a per-call `headers` option is merged on top). A WebSocket
82
+ * Headers sent on every HTTP request (a per-call `headers` option is merged on top, names matched in any letter case). A WebSocket
82
83
  * handshake carries none of them, on any runtime: a browser cannot set them, and a socket that
83
84
  * authenticated under Bun but not in the browser would be worse than one that never did.
84
85
  */
@@ -211,6 +212,8 @@ interface CallOptions {
211
212
  readonly query?: Record<string, unknown>
212
213
  readonly headers?: Record<string, string>
213
214
  readonly signal?: AbortSignal
215
+ /** A DELETE's body; the body verbs take theirs positionally. */
216
+ readonly body?: unknown
214
217
  }
215
218
 
216
219
  /**
@@ -486,11 +489,24 @@ function createProxy(
486
489
  ? Object.values(first as Record<string, unknown>)[0]
487
490
  : first
488
491
  if (value === undefined || value === null) return createProxy(base, path, options, false)
489
- return createProxy(base, `${path}/${encodeURIComponent(String(value))}`, options, false)
492
+ return createProxy(base, `${path}/${encodeSegment(String(value))}`, options, false)
490
493
  },
491
494
  })
492
495
  }
493
496
 
497
+ /**
498
+ * A path segment, percent-encoded. A value with an unpaired surrogate has no UTF-8 spelling and
499
+ * `encodeURIComponent` throws on it; it becomes a `..` segment instead, which every call refuses as
500
+ * `invalid_path` before anything is sent, so the call still resolves to a Result.
501
+ */
502
+ function encodeSegment(value: string): string {
503
+ try {
504
+ return encodeURIComponent(value)
505
+ } catch {
506
+ return ".."
507
+ }
508
+ }
509
+
494
510
  /** Resolve one get-trap key on a proxy node - the (memoizable) body of the `get` trap above. */
495
511
  function resolveSegment(
496
512
  base: string,
@@ -567,6 +583,29 @@ function formBody(body: unknown): FormData | undefined {
567
583
  return form
568
584
  }
569
585
 
586
+ // Header names are case-insensitive: a plain object keyed `Authorization` and `authorization` would
587
+ // send both values joined into one header.
588
+ function assignHeaders(
589
+ target: Record<string, string>,
590
+ source: Record<string, string> | undefined,
591
+ ): Record<string, string> {
592
+ if (source === undefined) return target
593
+ for (const [name, value] of Object.entries(source)) {
594
+ deleteHeader(target, name)
595
+ target[name] = value
596
+ }
597
+ return target
598
+ }
599
+
600
+ function deleteHeader(headers: Record<string, string>, name: string): void {
601
+ const lower = name.toLowerCase()
602
+ for (const key of Object.keys(headers)) if (key.toLowerCase() === lower) delete headers[key]
603
+ }
604
+
605
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
606
+ return Object.keys(headers).some((key) => key.toLowerCase() === name)
607
+ }
608
+
570
609
  async function execute(
571
610
  base: string,
572
611
  path: string,
@@ -580,35 +619,38 @@ async function execute(
580
619
  return { ok: false, status: 0, data: null, error: { error: "invalid_path" } }
581
620
  }
582
621
  const isBodyVerb = BODY_VERBS.has(verb)
583
- const body = isBodyVerb ? args[0] : undefined
584
622
  const callOptions = (isBodyVerb ? args[1] : args[0]) as CallOptions | undefined
623
+ const body = isBodyVerb ? args[0] : verb === "delete" ? callOptions?.body : undefined
585
624
 
586
625
  let url = base + (path === "" ? "/" : path)
587
626
  const query = callOptions?.query ? buildQuery(callOptions.query) : ""
588
627
  if (query !== "") url += `?${query}`
589
628
 
590
629
  const method = verb.toUpperCase()
591
- const headers: Record<string, string> = { ...options.headers, ...callOptions?.headers }
592
- if (options.onRequest !== undefined) {
593
- const extra = await options.onRequest({ url, method, headers, body })
594
- if (extra !== undefined) Object.assign(headers, extra)
595
- }
596
- const init: RequestInit = { method, headers }
597
- if (body !== undefined) {
598
- const form = formBody(body)
630
+ const baseHeaders = assignHeaders(assignHeaders({}, options.headers), callOptions?.headers)
631
+ const init: RequestInit = { method }
632
+ const form = body === undefined ? undefined : formBody(body)
633
+ const codec = options.transport?.codec ?? plainJsonCodec
634
+ if (form !== undefined) init.body = form
635
+ else if (body !== undefined) init.body = codec.encode(body)
636
+ // Built for every attempt, so a retry carries what `onRequest` returns now (a refreshed token).
637
+ const headersFor = async (): Promise<Record<string, string>> => {
638
+ const headers = { ...baseHeaders }
639
+ if (options.onRequest !== undefined) {
640
+ const extra = await options.onRequest({ url, method, headers, body })
641
+ assignHeaders(headers, extra)
642
+ }
599
643
  if (form !== undefined) {
600
- init.body = form
601
644
  // The platform writes this header itself, boundary included; one set by hand would name a
602
645
  // boundary the body does not use and the server would refuse the request.
603
- for (const name of Object.keys(headers)) {
604
- if (name.toLowerCase() === "content-type") delete headers[name]
605
- }
606
- } else {
607
- const codec = options.transport?.codec ?? plainJsonCodec
608
- init.body = codec.encode(body)
609
- headers["content-type"] = codec.mediaType
610
- if (options.transport !== undefined) headers.accept ??= codec.mediaType
646
+ deleteHeader(headers, "content-type")
647
+ } else if (body !== undefined) {
648
+ // The codec labels the bytes it wrote; a type set by hand would name a different encoding.
649
+ assignHeaders(headers, { "content-type": codec.mediaType })
650
+ if (options.transport !== undefined && !hasHeader(headers, "accept"))
651
+ headers.accept = codec.mediaType
611
652
  }
653
+ return headers
612
654
  }
613
655
  const { signal, timeout } = buildSignal(callOptions?.signal, options.timeoutMs)
614
656
  if (signal !== undefined) init.signal = signal
@@ -631,8 +673,9 @@ async function execute(
631
673
  let response: Response
632
674
  let attempt = 0
633
675
  for (;;) {
676
+ const attemptInit = { ...init, headers: await headersFor() }
634
677
  try {
635
- response = await doFetch(url, init)
678
+ response = await doFetch(url, attemptInit)
636
679
  } catch (error) {
637
680
  // A contract violation is a test assertion (validateResponses), not a call outcome - let it
638
681
  // fail the test instead of degrading into a `Result` the test would happily branch on.
@@ -675,10 +718,13 @@ async function execute(
675
718
  if (isPayloadTooLarge(cause)) {
676
719
  return { ok: false, status: 0, data: null, error: { error: "response_too_large" } }
677
720
  }
678
- // A codec refusing the payload stays a throw. Anything else is the read itself failing - the
679
- // deadline or the caller's signal firing mid-body, or the connection dropping - which the fetch
680
- // above already reports as a Result.
681
- if (cause instanceof Error && cause.name === "TransportCodecError") throw cause
721
+ // A body its content type does not decode - a proxy's HTML error page labelled JSON, say - is
722
+ // no answer the caller can use either. Anything else is the read itself failing - the deadline
723
+ // or the caller's signal firing mid-body, or the connection dropping - which the fetch above
724
+ // already reports as a Result.
725
+ if (cause instanceof Error && cause.name === "TransportCodecError") {
726
+ return { ok: false, status: 0, data: null, error: { error: "malformed_response" } }
727
+ }
682
728
  const code = timeout?.aborted === true ? "timeout" : "network_error"
683
729
  return { ok: false, status: 0, data: null, error: { error: code } }
684
730
  }
@@ -694,6 +740,8 @@ async function execute(
694
740
 
695
741
  interface SubscribeCallOptions {
696
742
  readonly query?: Record<string, unknown>
743
+ /** The body of a POST stream (`app.sse()` with a body schema). */
744
+ readonly body?: unknown
697
745
  readonly headers?: Record<string, string>
698
746
  readonly signal?: AbortSignal
699
747
  readonly reconnect?: boolean | { baseDelayMs?: number; maxDelayMs?: number }
@@ -715,21 +763,23 @@ const MAX_RETRY_MS = 2_147_483_647
715
763
  * Incrementally parse a `text/event-stream` body, invoking `onFrame` per dispatched event.
716
764
  * Implements the SSE wire format: CRLF, CR, and LF all end a line, `data:` accumulates multi-line,
717
765
  * `id:`/`retry:` update stream state, `:` lines are comments, a blank line dispatches. An event
718
- * still being assembled may hold at most `maxEventLength` characters.
766
+ * still being assembled may hold at most `maxEventBytes` UTF-8 bytes (fields and normalized
767
+ * line separators included, the empty dispatch line excluded).
719
768
  */
720
769
  async function readSseStream(
721
770
  body: ReadableStream<Uint8Array>,
722
771
  onFrame: (frame: SseFrame) => void,
723
- maxEventLength: number,
772
+ maxEventBytes: number,
724
773
  ): Promise<void> {
725
774
  const reader = body.getReader()
726
775
  const decoder = new TextDecoder()
776
+ const encoder = new TextEncoder()
727
777
  const lineEnd = /\r\n?|\n/g
728
778
  // Only newly decoded text is scanned; the unfinished line is carried here, so a line that arrives
729
779
  // over many small reads costs its length once rather than once per read.
730
- let partial = ""
780
+ let partial: string[] = []
731
781
  let afterCr = false
732
- let held = 0
782
+ let eventBytes = 0
733
783
  let dataLines: string[] = []
734
784
  let frame: SseFrame = {}
735
785
 
@@ -739,7 +789,7 @@ async function readSseStream(
739
789
  onFrame(frame)
740
790
  }
741
791
  dataLines = []
742
- held = 0
792
+ eventBytes = 0
743
793
  frame = {}
744
794
  }
745
795
 
@@ -755,7 +805,6 @@ async function readSseStream(
755
805
  if (value.startsWith(" ")) value = value.slice(1)
756
806
  if (field === "data") {
757
807
  dataLines.push(value)
758
- held += value.length + 1
759
808
  } else if (field === "id") {
760
809
  // The grammar ignores an id with a NUL; it could never be sent back as `Last-Event-ID`.
761
810
  if (!value.includes("\0")) frame.id = value
@@ -771,13 +820,21 @@ async function readSseStream(
771
820
  let start = afterCr && text.charCodeAt(0) === 10 ? 1 : 0
772
821
  lineEnd.lastIndex = start
773
822
  for (let end = lineEnd.exec(text); end !== null; end = lineEnd.exec(text)) {
774
- handleLine(partial + text.slice(start, end.index))
775
- partial = ""
823
+ const piece = text.slice(start, end.index)
824
+ eventBytes += encoder.encode(piece).byteLength
825
+ // Check before dispatch or JSON parsing, including frames completed in this read.
826
+ if (piece !== "" || partial.length > 0) eventBytes += 1
827
+ if (eventBytes > maxEventBytes) throw new Error("sse_event_too_large")
828
+ partial.push(piece)
829
+ handleLine(partial.join(""))
830
+ partial = []
776
831
  start = lineEnd.lastIndex
777
832
  }
778
833
  afterCr = text.charCodeAt(text.length - 1) === 13
779
- partial += text.slice(start)
780
- if (partial.length + held > maxEventLength) throw new Error("sse_event_too_large")
834
+ const tail = text.slice(start)
835
+ eventBytes += encoder.encode(tail).byteLength
836
+ if (eventBytes > maxEventBytes) throw new Error("sse_event_too_large")
837
+ if (tail !== "") partial.push(tail)
781
838
  }
782
839
 
783
840
  try {
@@ -787,7 +844,7 @@ async function readSseStream(
787
844
  feed(decoder.decode(value, { stream: true }))
788
845
  }
789
846
  feed(decoder.decode())
790
- if (partial !== "") handleLine(partial)
847
+ if (partial.length > 0) handleLine(partial.join(""))
791
848
  dispatch() // an unterminated final frame still dispatches
792
849
  } catch (error) {
793
850
  // Stopping early must not leave the response open behind a released lock.
@@ -812,7 +869,9 @@ function lastEventIdHeader(id: string): string {
812
869
  * streams over the configured fetcher - network, an in-process bridge, or a test mock - with
813
870
  * EventSource semantics where they matter: auto-reconnect with backoff + jitter (honoring the
814
871
  * server's `retry:` hint after a stream it served), `Last-Event-ID` resumption, JSON-parsed typed
815
- * events. Never throws: failures reach `onError`; a terminal end reaches `onClose`.
872
+ * events. A `body` makes it a POST stream, encoded like a POST call's body; it is sent on every
873
+ * connect, so such a stream reconnects only when asked to. `onRequest` runs before each connect.
874
+ * Never throws: failures reach `onError`; a terminal end reaches `onClose`.
816
875
  */
817
876
  function subscribeSse(
818
877
  base: string,
@@ -826,7 +885,10 @@ function subscribeSse(
826
885
  let lastEventId: string | undefined
827
886
  let serverRetryMs: number | undefined
828
887
 
829
- const reconnectConfig = callOptions?.reconnect ?? true
888
+ const body = callOptions?.body
889
+ const method = body === undefined ? "GET" : "POST"
890
+ const codec = options.transport?.codec ?? plainJsonCodec
891
+ const reconnectConfig = callOptions?.reconnect ?? body === undefined
830
892
  const reconnectEnabled = reconnectConfig !== false
831
893
  const baseDelayMs =
832
894
  (typeof reconnectConfig === "object" ? reconnectConfig.baseDelayMs : undefined) ?? 1_000
@@ -862,16 +924,34 @@ function subscribeSse(
862
924
  close()
863
925
  return
864
926
  }
927
+ let form: FormData | undefined
928
+ let encoded: FormData | string | undefined
929
+ try {
930
+ form = body === undefined ? undefined : formBody(body)
931
+ encoded = form ?? (body === undefined ? undefined : codec.encode(body))
932
+ } catch (error) {
933
+ // A body that cannot be encoded sends nothing; reported after `subscribe()` returns.
934
+ await Promise.resolve()
935
+ if (closed) return
936
+ callOptions?.onError?.(error)
937
+ close()
938
+ return
939
+ }
865
940
  let attempt = 0
866
941
  while (!closed) {
867
942
  try {
868
- const headers: Record<string, string> = {
869
- ...options.headers,
870
- ...callOptions?.headers,
871
- accept: "text/event-stream",
872
- ...(lastEventId ? { "last-event-id": lastEventIdHeader(lastEventId) } : {}),
943
+ const headers = assignHeaders(assignHeaders({}, options.headers), callOptions?.headers)
944
+ if (options.onRequest !== undefined) {
945
+ assignHeaders(headers, await options.onRequest({ url, method, headers, body }))
873
946
  }
874
- const response = await doFetch(url, { headers, signal: controller.signal })
947
+ assignHeaders(headers, { accept: "text/event-stream" })
948
+ // The platform writes a form's content type, boundary included; the codec labels its bytes.
949
+ if (form !== undefined) deleteHeader(headers, "content-type")
950
+ else if (body !== undefined) assignHeaders(headers, { "content-type": codec.mediaType })
951
+ if (lastEventId) assignHeaders(headers, { "last-event-id": lastEventIdHeader(lastEventId) })
952
+ const init: RequestInit = { method, headers, signal: controller.signal }
953
+ if (encoded !== undefined) init.body = encoded
954
+ const response = await doFetch(url, init)
875
955
  if (!response.ok || response.body === null) {
876
956
  void response.body?.cancel().catch(() => {})
877
957
  throw new Error(`sse_http_${response.status}`)
@@ -926,19 +1006,6 @@ function subscribeSse(
926
1006
  return { close }
927
1007
  }
928
1008
 
929
- function buildQuery(query: Record<string, unknown>): string {
930
- const params = new URLSearchParams()
931
- for (const [key, value] of Object.entries(query)) {
932
- if (value === undefined) continue
933
- if (Array.isArray(value)) {
934
- for (const item of value) params.append(key, String(item))
935
- } else {
936
- params.set(key, String(value))
937
- }
938
- }
939
- return params.toString()
940
- }
941
-
942
1009
  // A registry is immutable once built, so a custom codec's registry is built once per client and
943
1010
  // reused - constructing one per response paid Map building and validation on every parse.
944
1011
  const CUSTOM_REGISTRIES = new WeakMap<TransportCodec, TransportCodecRegistry>()
@@ -968,8 +1035,21 @@ const TEXTUAL_TYPES = new Set([
968
1035
  "application/ecmascript",
969
1036
  "application/x-www-form-urlencoded",
970
1037
  "application/x-ndjson",
1038
+ "application/jsonl",
971
1039
  ])
972
1040
 
1041
+ const JSON_MEDIA = "application/json"
1042
+
1043
+ /** A content type the transport codecs decode. `application/json-patch+json` and `application/jsonl`
1044
+ * share the JSON prefix but are other formats, read like any other textual body. */
1045
+ function isTransportMedia(contentType: string): boolean {
1046
+ if (contentType.startsWith(JSON_MEDIA)) {
1047
+ const next = contentType[JSON_MEDIA.length]
1048
+ return next === undefined || next === ";" || next === " " || next === "\t"
1049
+ }
1050
+ return contentType.startsWith("application/vnd.nifra.")
1051
+ }
1052
+
973
1053
  /**
974
1054
  * True when a body can survive being decoded as UTF-8 text.
975
1055
  *
@@ -1023,10 +1103,7 @@ async function parseBody(response: Response, options: ClientOptions): Promise<un
1023
1103
  // is ~23x cheaper and the SAME cap is enforced on the result (identical error). A network
1024
1104
  // client is never marked and keeps the bounded-while-streaming read.
1025
1105
  const local = (options as { readonly [LOCAL_FETCH]?: true })[LOCAL_FETCH] === true
1026
- if (
1027
- contentType.startsWith("application/json") ||
1028
- contentType.startsWith("application/vnd.nifra.")
1029
- ) {
1106
+ if (isTransportMedia(contentType)) {
1030
1107
  if (local) {
1031
1108
  return decodeTransportText(
1032
1109
  await response.text(),
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export type {
27
27
  ActionArgs,
28
28
  ActionData,
29
29
  ApiProxy,
30
+ InputOf,
30
31
  LoaderArgs,
31
32
  LoaderData,
32
33
  LoaderResponseControls,
@@ -34,7 +35,9 @@ export type {
34
35
  Register,
35
36
  RegisteredBackend,
36
37
  RegisteredEnv,
38
+ RouteActionReturn,
37
39
  RouteLoaderArgs,
40
+ RouteLoaderReturn,
38
41
  SearchSchemaOf,
39
42
  } from "./routes.ts"
40
43
  export type {
package/src/query.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The query string every client transport sends: an array repeats its key once per item and an
3
+ * `undefined` value is left out, so a WebSocket handshake reads the same as an HTTP call.
4
+ */
5
+ export function buildQuery(query: Record<string, unknown>): string {
6
+ const params = new URLSearchParams()
7
+ for (const [key, value] of Object.entries(query)) {
8
+ if (value === undefined) continue
9
+ if (Array.isArray(value)) {
10
+ for (const item of value) params.append(key, String(item))
11
+ } else {
12
+ params.set(key, String(value))
13
+ }
14
+ }
15
+ return params.toString()
16
+ }
package/src/routes.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { ContractShape, RegistryFor } from "@nifrajs/core/contract"
2
- import type { CookieOptions, InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
2
+ import type {
3
+ CookieOptions,
4
+ InferInput,
5
+ InferOutput,
6
+ ResponseResult,
7
+ StandardSchemaV1,
8
+ } from "@nifrajs/core/server"
3
9
  import type { Treaty, TreatyFromRegistry } from "./treaty.ts"
4
10
 
5
11
  /**
@@ -102,6 +108,48 @@ export type OutputOf<Module, Name extends string> = Module extends { readonly [K
102
108
  : never
103
109
  : null
104
110
 
111
+ /**
112
+ * What a route module's output schema accepts from its loader or action: the input type of its `Name`
113
+ * export, or `null` when the module declares none. A `t.deferred(inner)` field accepts `defer(...)`.
114
+ */
115
+ export type InputOf<Module, Name extends string> = Module extends { readonly [K in Name]: infer S }
116
+ ? S extends StandardSchemaV1
117
+ ? InferInput<S>
118
+ : never
119
+ : null
120
+
121
+ type RouteControl = Response | ResponseResult | null | undefined
122
+
123
+ /**
124
+ * What a route module's `loader` may return: data its `loaderOutput` accepts, a `Response` or a
125
+ * `ResponseResult` such as `redirect(...)`, or nothing. With no `loaderOutput`, returning data is a
126
+ * type error, as it is a refusal at runtime.
127
+ */
128
+ export type RouteLoaderReturn<Module> =
129
+ | InputOf<Module, "loaderOutput">
130
+ | RouteControl
131
+ | Promise<InputOf<Module, "loaderOutput"> | RouteControl>
132
+
133
+ /**
134
+ * What a route module's `action` may return: as {@link RouteLoaderReturn} against `actionOutput`,
135
+ * plus that data wrapped in `revalidate(paths, data)`.
136
+ */
137
+ export type RouteActionReturn<Module> =
138
+ | InputOf<Module, "actionOutput">
139
+ | RouteRevalidate<InputOf<Module, "actionOutput">>
140
+ | RouteControl
141
+ | Promise<
142
+ | InputOf<Module, "actionOutput">
143
+ | RouteRevalidate<InputOf<Module, "actionOutput">>
144
+ | RouteControl
145
+ >
146
+
147
+ // Structural, so this stays decoupled from `@nifrajs/web`'s `revalidate()`.
148
+ type RouteRevalidate<Data> = {
149
+ readonly __nifraRevalidate: readonly string[]
150
+ readonly data: Data
151
+ }
152
+
105
153
  /** The `searchSchema` a route's frontend half declares, or `undefined`. */
106
154
  export type SearchSchemaOf<Module> = Module extends { readonly searchSchema: infer S }
107
155
  ? S
package/src/treaty.ts CHANGED
@@ -26,17 +26,26 @@ type CallOptions<I extends RouteInfo> = {
26
26
  * value instead of being treated like an omitted object property by the general `Jsonify` helper. */
27
27
  type JsonifyResponses<T> = T extends object ? { [K in keyof T]: Jsonify<T[K]> } : never
28
28
 
29
- type IsBodyVerb<M extends string> = M extends "POST" | "PUT" | "PATCH" ? true : false
29
+ /** Where a verb's body goes: slot 0 for POST/PUT/PATCH, `options.body` for DELETE, nowhere else. */
30
+ type BodySlot<M extends string> = M extends "POST" | "PUT" | "PATCH"
31
+ ? "positional"
32
+ : M extends "DELETE"
33
+ ? "option"
34
+ : "none"
30
35
 
31
36
  /**
32
37
  * A terminal method call. The shape is **verb-aware** so the runtime can read
33
38
  * args unambiguously with no route info: body-verbs (POST/PUT/PATCH) always take
34
39
  * the body positionally Eden-style (typed `undefined` when the route has no body
35
- * schema, so it's still slot 0); GET/DELETE/HEAD take only options. Returns a
40
+ * schema, so it's still slot 0); GET/DELETE/HEAD take only options, and a DELETE
41
+ * whose route declares a body schema requires it as `options.body`. Returns a
36
42
  * `Result` whose data is the handler's output as it arrives over the wire
37
43
  * (`Jsonify`).
38
44
  */
39
- type MethodCall<I extends RouteInfo, BodyVerb extends boolean> = BodyVerb extends true
45
+ type MethodCall<
46
+ I extends RouteInfo,
47
+ Slot extends "positional" | "option" | "none",
48
+ > = Slot extends "positional"
40
49
  ? [I["body"]] extends [never]
41
50
  ? (
42
51
  body?: undefined,
@@ -50,11 +59,23 @@ type MethodCall<I extends RouteInfo, BodyVerb extends boolean> = BodyVerb extend
50
59
  ) => Promise<
51
60
  Result<Jsonify<I["output"]>, Jsonify<I["errors"]>, JsonifyResponses<I["responses"]>>
52
61
  >
53
- : (
54
- options?: CallOptions<I>,
55
- ) => Promise<
56
- Result<Jsonify<I["output"]>, Jsonify<I["errors"]>, JsonifyResponses<I["responses"]>>
57
- >
62
+ : Slot extends "option"
63
+ ? [I["body"]] extends [never]
64
+ ? (
65
+ options?: CallOptions<I>,
66
+ ) => Promise<
67
+ Result<Jsonify<I["output"]>, Jsonify<I["errors"]>, JsonifyResponses<I["responses"]>>
68
+ >
69
+ : (
70
+ options: CallOptions<I> & { body: I["body"] },
71
+ ) => Promise<
72
+ Result<Jsonify<I["output"]>, Jsonify<I["errors"]>, JsonifyResponses<I["responses"]>>
73
+ >
74
+ : (
75
+ options?: CallOptions<I>,
76
+ ) => Promise<
77
+ Result<Jsonify<I["output"]>, Jsonify<I["errors"]>, JsonifyResponses<I["responses"]>>
78
+ >
58
79
 
59
80
  // --- typed SSE subscriptions ---
60
81
 
@@ -67,8 +88,9 @@ export interface SubscribeOptions<I extends RouteInfo> {
67
88
  /** Abort to close the subscription (same effect as calling `close()`). */
68
89
  signal?: AbortSignal
69
90
  /**
70
- * Reconnect after a dropped/errored stream (default true - EventSource semantics, so a proxy
71
- * closing an idle feed doesn't silently kill it). A FINITE stream should pass `false` so a clean
91
+ * Reconnect after a dropped/errored stream. Default `true` for a GET stream (EventSource semantics,
92
+ * so a proxy closing an idle feed doesn't silently kill it) and `false` for one that posts a body,
93
+ * since a reconnect sends the body again. A FINITE stream should pass `false` so a clean
72
94
  * server-side end completes the subscription instead of replaying it. Delays follow exponential
73
95
  * backoff with jitter, honoring the server's `retry:` hint when sent.
74
96
  */
@@ -89,12 +111,24 @@ type SubscribeCall<I extends RouteInfo> = (
89
111
  options?: SubscribeOptions<I>,
90
112
  ) => Subscription
91
113
 
92
- /** Routes declared via `app.sse()` grow a `.subscribe()` beside their verbs. */
93
- type SseMethods<MethodMap> = MethodMap extends { GET: infer G }
114
+ /** A POST stream's `.subscribe()`: the route's body is required and is posted on every connect. */
115
+ type SubscribeBodyCall<I extends RouteInfo> = (
116
+ onEvent: (event: Jsonify<SseOf<I>>) => void,
117
+ options: SubscribeOptions<I> & { body: I["body"] },
118
+ ) => Subscription
119
+
120
+ /** Routes declared via `app.sse()` grow a `.subscribe()` beside their verbs; one whose schema
121
+ * declares a body is a POST route, and its `.subscribe()` takes that body. */
122
+ type SseMethods<MethodMap> = (MethodMap extends { GET: infer G }
94
123
  ? [SseOf<G>] extends [never]
95
124
  ? unknown
96
125
  : { subscribe: SubscribeCall<G & RouteInfo> }
97
- : unknown
126
+ : unknown) &
127
+ (MethodMap extends { POST: infer P }
128
+ ? [SseOf<P>] extends [never]
129
+ ? unknown
130
+ : { subscribe: SubscribeBodyCall<P & RouteInfo> }
131
+ : unknown)
98
132
 
99
133
  // --- typed WebSocket handles ---
100
134
 
@@ -102,7 +136,12 @@ type SseMethods<MethodMap> = MethodMap extends { GET: infer G }
102
136
  type WsOf<I> = I extends { ws: infer W extends { in: unknown; out: unknown } } ? W : never
103
137
 
104
138
  export interface WsCallOptions {
105
- query?: Record<string, string | number | boolean>
139
+ /** Sent on the handshake URL as an HTTP call sends it: an array repeats its key once per item,
140
+ * and an `undefined` value is left out. */
141
+ query?: Record<
142
+ string,
143
+ string | number | boolean | readonly (string | number | boolean)[] | undefined
144
+ >
106
145
  /** WebSocket subprotocols. A browser handshake cannot carry custom headers - authenticate via
107
146
  * `upgrade()` reading cookies/query/subprotocol, not an Authorization header. */
108
147
  protocols?: string | string[]
@@ -146,7 +185,7 @@ type WsMethods<MethodMap> = MethodMap extends { WS: infer G }
146
185
  type Methods<MethodMap> = {
147
186
  [M in Exclude<keyof MethodMap, "WS"> as Lowercase<M & string>]: MethodCall<
148
187
  MethodMap[M] & RouteInfo,
149
- IsBodyVerb<M & string>
188
+ BodySlot<M & string>
150
189
  >
151
190
  } & SseMethods<MethodMap> &
152
191
  WsMethods<MethodMap>
package/src/ws.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  type TransportCodec,
15
15
  type TransportCodecRegistry,
16
16
  } from "@nifrajs/core/transport-codec"
17
+ import { buildQuery } from "./query.ts"
17
18
  import { hasDotSegment } from "./sendable-path.ts"
18
19
  import type { WsCallOptions, WsHandle } from "./treaty.ts"
19
20
 
@@ -33,11 +34,9 @@ interface WsRuntimeOptions extends WsCallOptions {
33
34
  }
34
35
 
35
36
  function toWsUrl(base: string, path: string, query: WsCallOptions["query"]): string {
36
- const url = new URL(base + (path === "" ? "/" : path))
37
+ const search = query === undefined ? "" : buildQuery(query)
38
+ const url = new URL(base + (path === "" ? "/" : path) + (search === "" ? "" : `?${search}`))
37
39
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
38
- for (const [key, value] of Object.entries(query ?? {})) {
39
- url.searchParams.set(key, String(value))
40
- }
41
40
  return url.toString()
42
41
  }
43
42