@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/README.md +5 -2
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +142 -67
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query.d.ts +6 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +20 -0
- package/dist/query.js.map +1 -0
- package/dist/routes.d.ts +25 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js.map +1 -1
- package/dist/treaty.d.ts +26 -10
- package/dist/treaty.d.ts.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +3 -4
- package/dist/ws.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +138 -61
- package/src/index.ts +3 -0
- package/src/query.ts +16 -0
- package/src/routes.ts +49 -1
- package/src/treaty.ts +54 -15
- package/src/ws.ts +3 -4
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}/${
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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,
|
|
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
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
71
|
-
* closing an idle feed doesn't silently kill it)
|
|
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
|
-
/**
|
|
93
|
-
type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|