@nifrajs/client 1.13.0 → 2.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/index.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  */
7
7
  export {
8
8
  type ClientOptions,
9
+ type ClientRetryOptions,
9
10
  client,
10
11
  type FetchFn,
11
12
  type InProcessClient,
13
+ type InProcessClientOptions,
12
14
  inProcessClient,
13
15
  testClient,
14
16
  } from "./client.ts"
@@ -21,4 +23,7 @@ export type {
21
23
  Subscription,
22
24
  Treaty,
23
25
  TreatyFromRegistry,
26
+ WsCallOptions,
27
+ WsHandle,
24
28
  } from "./treaty.ts"
29
+ export { ResponseContractViolation } from "./validate-responses.ts"
package/src/result.ts CHANGED
@@ -8,18 +8,96 @@ export interface ApiError {
8
8
  }
9
9
 
10
10
  /**
11
- * The outcome of a client call. The client never throws — inspect `ok` to branch.
12
- * `data` is the parsed response body, **typed by `ok`**: on success it's the route's
13
- * response type; on failure it's the parsed error body, typed from the route's
14
- * `errors` contract (`unknown` when the route declares none, `null` on a transport
15
- * error with no response body). `error` is always the server's normalized
16
- * `{ error, issues? }` summary on failure (or a transport error), `null` on success.
11
+ * The failure statuses a call can carry (the IANA registry's 3xx/4xx/5xx set). Finite on purpose:
12
+ * the undeclared-status fallback arm of {@link Result} is `Exclude<ErrorStatus, declared>` - with a
13
+ * bare `number` there instead, `status === 404` could not narrow `data` to the declared 404 body,
14
+ * because 404 would still inhabit the fallback arm.
17
15
  */
18
- export type Result<Data, ErrData = unknown> =
16
+ export type ErrorStatus =
17
+ | 300
18
+ | 301
19
+ | 302
20
+ | 303
21
+ | 304
22
+ | 305
23
+ | 306
24
+ | 307
25
+ | 308
26
+ | 400
27
+ | 401
28
+ | 402
29
+ | 403
30
+ | 404
31
+ | 405
32
+ | 406
33
+ | 407
34
+ | 408
35
+ | 409
36
+ | 410
37
+ | 411
38
+ | 412
39
+ | 413
40
+ | 414
41
+ | 415
42
+ | 416
43
+ | 417
44
+ | 418
45
+ | 421
46
+ | 422
47
+ | 423
48
+ | 424
49
+ | 425
50
+ | 426
51
+ | 428
52
+ | 429
53
+ | 431
54
+ | 451
55
+ | 500
56
+ | 501
57
+ | 502
58
+ | 503
59
+ | 504
60
+ | 505
61
+ | 506
62
+ | 507
63
+ | 508
64
+ | 510
65
+ | 511
66
+
67
+ /** One failure arm per declared error status: `status` is the literal, `data` its declared body. */
68
+ type DeclaredFailures<Errors extends Record<number, unknown>> = {
69
+ [K in Extract<keyof Errors, number>]: {
70
+ readonly ok: false
71
+ readonly status: K
72
+ readonly data: Errors[K]
73
+ readonly error: ApiError
74
+ }
75
+ }[Extract<keyof Errors, number>]
76
+
77
+ /**
78
+ * The outcome of a client call. The client never throws - inspect `ok` to branch.
79
+ *
80
+ * On success, `data` is the route's response type. On failure the union is DISCRIMINATED BY
81
+ * `status` when the route declares an `errors` record: `status === 404` narrows `data` to the
82
+ * declared 404 body; every undeclared status (plus `0` for a transport error with no response)
83
+ * falls into a fallback arm whose `data` is `unknown`. A route with no `errors` contract keeps a
84
+ * single failure arm with `data: Errors` (`unknown`). `error` is always the server's normalized
85
+ * `{ error, issues? }` summary on failure, `null` on success.
86
+ */
87
+ export type Result<Data, Errors = unknown> =
19
88
  | { readonly ok: true; readonly status: number; readonly data: Data; readonly error: null }
20
- | {
21
- readonly ok: false
22
- readonly status: number
23
- readonly data: ErrData
24
- readonly error: ApiError
25
- }
89
+ | ([Errors] extends [Record<number, unknown>]
90
+ ?
91
+ | DeclaredFailures<Errors>
92
+ | {
93
+ readonly ok: false
94
+ readonly status: Exclude<ErrorStatus, Extract<keyof Errors, number>> | 0
95
+ readonly data: unknown
96
+ readonly error: ApiError
97
+ }
98
+ : {
99
+ readonly ok: false
100
+ readonly status: number
101
+ readonly data: Errors
102
+ readonly error: ApiError
103
+ })
package/src/routes.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ContractShape, RegistryFor } from "@nifrajs/core"
1
+ import type { ContractShape, RegistryFor } from "@nifrajs/core/contract"
2
2
  import type { Treaty, TreatyFromRegistry } from "./treaty.ts"
3
3
 
4
4
  /**
package/src/treaty.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { RouteInfo, Server } from "@nifrajs/core"
1
+ import type { RouteInfo, Server } from "@nifrajs/core/server"
2
2
  import type { Jsonify } from "./jsonify.ts"
3
3
  import type { Result } from "./result.ts"
4
4
 
@@ -84,12 +84,60 @@ type SseMethods<MethodMap> = MethodMap extends { GET: infer G }
84
84
  : { subscribe: SubscribeCall<G & RouteInfo> }
85
85
  : unknown
86
86
 
87
+ // --- typed WebSocket handles ---
88
+
89
+ /** The WS frame contract of a route (from `app.ws()`'s schemas); `never` for ordinary routes. */
90
+ type WsOf<I> = I extends { ws: infer W extends { in: unknown; out: unknown } } ? W : never
91
+
92
+ export interface WsCallOptions {
93
+ query?: Record<string, string | number | boolean>
94
+ /** WebSocket subprotocols. A browser handshake cannot carry custom headers - authenticate via
95
+ * `upgrade()` reading cookies/query/subprotocol, not an Authorization header. */
96
+ protocols?: string | string[]
97
+ /** Abort to close the socket (same effect as calling `close()`). */
98
+ signal?: AbortSignal
99
+ /** Socket-level failures (connection refused, abnormal close). */
100
+ onError?: (error: unknown) => void
101
+ }
102
+
103
+ /**
104
+ * A live typed WebSocket connection to an `app.ws()` route. `send` accepts the route's
105
+ * `messageSchema` input type (validated server-side at the trust boundary); received frames are
106
+ * typed from its `sendSchema` and JSON-parsed. Binary frames are not part of the typed contract
107
+ * and are ignored by `messages()`/`onMessage` - use `raw` for them.
108
+ */
109
+ export interface WsHandle<In, Out> {
110
+ /** Send one typed frame (JSON-encoded). Queued until the socket opens, so it never throws. */
111
+ send(message: In): void
112
+ /** Async-iterate incoming typed frames. Ends when the socket closes (or `signal` aborts). */
113
+ messages(options?: { signal?: AbortSignal }): AsyncIterableIterator<Out>
114
+ /** Callback form of {@link messages}; returns an unsubscribe. */
115
+ onMessage(callback: (message: Out) => void): () => void
116
+ /** Resolves when the socket is open (rejects on a connect failure). */
117
+ readonly opened: Promise<void>
118
+ close(code?: number, reason?: string): void
119
+ /** The underlying socket, for anything off the typed contract (binary frames, bufferedAmount…). */
120
+ readonly raw: WebSocket
121
+ }
122
+
123
+ type WsCall<I extends RouteInfo> = (
124
+ options?: WsCallOptions,
125
+ ) => WsHandle<WsOf<I>["in"], Jsonify<WsOf<I>["out"]>>
126
+
127
+ /** Routes declared via `app.ws()` (pseudo-method `"WS"`) grow a `.ws()` handle. */
128
+ type WsMethods<MethodMap> = MethodMap extends { WS: infer G }
129
+ ? [WsOf<G>] extends [never]
130
+ ? unknown
131
+ : { ws: WsCall<G & RouteInfo> }
132
+ : unknown
133
+
87
134
  type Methods<MethodMap> = {
88
- [M in keyof MethodMap as Lowercase<M & string>]: MethodCall<
135
+ [M in Exclude<keyof MethodMap, "WS"> as Lowercase<M & string>]: MethodCall<
89
136
  MethodMap[M] & RouteInfo,
90
137
  IsBodyVerb<M & string>
91
138
  >
92
- } & SseMethods<MethodMap>
139
+ } & SseMethods<MethodMap> &
140
+ WsMethods<MethodMap>
93
141
 
94
142
  // --- path-tree construction over the registry ---
95
143
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Runtime response-contract assertion for the in-process test client
3
+ * (`testClient(app, { validateResponses: true })`).
4
+ *
5
+ * The `response` / `errors` schemas on a route are a compile-time contract on the handler; nothing
6
+ * re-checks the bytes that actually leave the app. This wrapper closes that gap in tests: every
7
+ * JSON response is validated against the route's declared schema for its status - `response` for
8
+ * 2xx, `errors[status]` for declared failures - and a mismatch THROWS (failing the test loudly)
9
+ * instead of letting a drifted payload pass. Responses with no declared schema for their status,
10
+ * non-JSON bodies (SSE, streams, HTML), and 204/205/HEAD are passed through untouched.
11
+ *
12
+ * Route lookup reuses the framework's own `Router` over `app.routes()`, so pattern matching can
13
+ * never drift from what the server itself does.
14
+ */
15
+
16
+ import { type Method, Router } from "@nifrajs/core/router"
17
+ import type { FetchFn } from "./client.ts"
18
+
19
+ /** The slice of a Standard Schema this wrapper runs. */
20
+ interface SchemaLike {
21
+ readonly "~standard": {
22
+ readonly validate: (value: unknown) =>
23
+ | { readonly issues?: ReadonlyArray<IssueLike> | undefined; readonly value?: unknown }
24
+ | Promise<{
25
+ readonly issues?: ReadonlyArray<IssueLike> | undefined
26
+ readonly value?: unknown
27
+ }>
28
+ }
29
+ }
30
+
31
+ interface IssueLike {
32
+ readonly message: string
33
+ readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined
34
+ }
35
+
36
+ interface RouteSchemaLike {
37
+ readonly response?: SchemaLike
38
+ readonly errors?: Readonly<Record<number, SchemaLike>>
39
+ }
40
+
41
+ interface RouteDescriptorLike {
42
+ readonly method: string
43
+ readonly path: string
44
+ readonly schema: RouteSchemaLike | undefined
45
+ }
46
+
47
+ /** What `validateResponses` needs from the app beyond `fetch`: the route table. */
48
+ export interface RouteIntrospectable {
49
+ routes(): ReadonlyArray<RouteDescriptorLike>
50
+ }
51
+
52
+ /**
53
+ * A response body that broke its route's declared contract. Thrown THROUGH the "never throws"
54
+ * client on purpose: this is a test assertion about the server's honesty, not a call outcome the
55
+ * caller should branch on - swallowing it into a `Result` would let the drift pass the test.
56
+ */
57
+ export class ResponseContractViolation extends Error {
58
+ constructor(message: string) {
59
+ super(message)
60
+ this.name = "ResponseContractViolation"
61
+ }
62
+ }
63
+
64
+ function formatIssues(issues: ReadonlyArray<IssueLike>): string {
65
+ return issues
66
+ .map((issue) => {
67
+ const path = (issue.path ?? [])
68
+ .map((seg) => String(typeof seg === "object" && seg !== null ? seg.key : seg))
69
+ .join(".")
70
+ return path === "" ? issue.message : `${path}: ${issue.message}`
71
+ })
72
+ .join("; ")
73
+ }
74
+
75
+ /**
76
+ * Wrap the in-process bridge so every response is checked against its route's declared contract.
77
+ * Throws at wrap time when the app cannot enumerate routes - a misconfiguration, not a soft skip.
78
+ */
79
+ export function withResponseValidation(app: unknown, bridge: FetchFn): FetchFn {
80
+ const routesOf = (app as Partial<RouteIntrospectable>).routes
81
+ if (typeof routesOf !== "function") {
82
+ throw new Error(
83
+ "validateResponses needs an app that exposes .routes() (a nifra server); this app does not",
84
+ )
85
+ }
86
+ // One router per client, built once: match by the server's own engine, payload = the schema.
87
+ const router = new Router<RouteSchemaLike | undefined>()
88
+ for (const route of routesOf.call(app)) {
89
+ router.add(route.method as Method, route.path, route.schema)
90
+ }
91
+
92
+ return async (url, init) => {
93
+ const response = await bridge(url, init)
94
+ const method = (init?.method ?? "GET").toUpperCase()
95
+ const status = response.status
96
+ if (method === "HEAD" || status === 204 || status === 205) return response
97
+
98
+ const match = router.find(method, new URL(url).pathname)
99
+ if (!match.found) return response
100
+ const schema = match.payload
101
+ const declared = status >= 200 && status < 300 ? schema?.response : schema?.errors?.[status]
102
+ if (declared === undefined) return response
103
+
104
+ const contentType = response.headers.get("content-type") ?? ""
105
+ if (!contentType.includes("application/json")) return response
106
+ // Clone so the caller still gets an unconsumed body; unparseable JSON is the transport's problem.
107
+ let body: unknown
108
+ try {
109
+ body = await response.clone().json()
110
+ } catch {
111
+ return response
112
+ }
113
+ const result = await declared["~standard"].validate(body)
114
+ if (result.issues !== undefined) {
115
+ throw new ResponseContractViolation(
116
+ `response contract violation: ${method} ${new URL(url).pathname} → ${status}: ${formatIssues(result.issues)}`,
117
+ )
118
+ }
119
+ return response
120
+ }
121
+ }
package/src/ws.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The typed client's WebSocket transport - the runtime behind `api.chat.ws()` for routes declared
3
+ * with `app.ws()`. Uses the platform's global `WebSocket` (browser, Bun, Node 22+, Deno, workerd),
4
+ * mapping the client's `http(s)` base to `ws(s)`. Frames are JSON on the typed surface: `send`
5
+ * encodes, `messages()`/`onMessage` parse text frames (binary frames bypass the typed contract -
6
+ * read them off `raw`). Sends before the socket opens are queued, so the handle never throws.
7
+ */
8
+
9
+ import {
10
+ createTransportCodecRegistry,
11
+ decodeTransportFrame,
12
+ encodeTransportFrame,
13
+ plainJsonCodec,
14
+ type TransportCodec,
15
+ type TransportCodecRegistry,
16
+ } from "@nifrajs/core/transport-codec"
17
+ import type { WsCallOptions, WsHandle } from "./treaty.ts"
18
+
19
+ /** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
20
+ export const NO_SOCKET = Symbol.for("@nifrajs/client/no-socket")
21
+
22
+ interface WsRuntimeOptions extends WsCallOptions {
23
+ headers?: Record<string, string> | undefined
24
+ transport?: {
25
+ readonly codec: TransportCodec
26
+ readonly registry?: TransportCodecRegistry
27
+ readonly maxBytes?: number
28
+ }
29
+ }
30
+
31
+ function toWsUrl(base: string, path: string, query: WsCallOptions["query"]): string {
32
+ const url = new URL(base + (path === "" ? "/" : path))
33
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
34
+ for (const [key, value] of Object.entries(query ?? {})) {
35
+ url.searchParams.set(key, String(value))
36
+ }
37
+ return url.toString()
38
+ }
39
+
40
+ export function openWebSocket(
41
+ base: string,
42
+ path: string,
43
+ callOptions: WsRuntimeOptions | undefined,
44
+ noSocket: boolean,
45
+ ): WsHandle<unknown, unknown> {
46
+ if (noSocket) {
47
+ throw new Error(
48
+ "the in-process client cannot open WebSockets (app.fetch has no socket) - listen() the app and connect with client(baseUrl)",
49
+ )
50
+ }
51
+ const WebSocketImpl = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket
52
+ if (WebSocketImpl === undefined) {
53
+ throw new Error(
54
+ "no global WebSocket in this runtime - .ws() needs one (browser, Bun, Node 22+)",
55
+ )
56
+ }
57
+
58
+ const url = toWsUrl(base, path, callOptions?.query)
59
+ const socket =
60
+ callOptions?.protocols !== undefined
61
+ ? new WebSocketImpl(url, callOptions.protocols)
62
+ : new WebSocketImpl(url)
63
+
64
+ const sendQueue: string[] = []
65
+ const listeners = new Set<(message: unknown) => void>()
66
+ const transport = callOptions?.transport
67
+ const registry =
68
+ transport?.registry ??
69
+ (transport === undefined || transport.codec === plainJsonCodec
70
+ ? createTransportCodecRegistry([plainJsonCodec])
71
+ : createTransportCodecRegistry([plainJsonCodec, transport.codec]))
72
+
73
+ const opened = new Promise<void>((resolve, reject) => {
74
+ socket.addEventListener("open", () => resolve(), { once: true })
75
+ socket.addEventListener(
76
+ "error",
77
+ (event) => {
78
+ callOptions?.onError?.(event)
79
+ reject(new Error("websocket_connect_failed"))
80
+ },
81
+ { once: true },
82
+ )
83
+ })
84
+ // The rejection is surfaced via `opened` (and `onError`) only for those who look - an unconsumed
85
+ // `opened` on a failed connect must not crash the process with an unhandled rejection.
86
+ opened.catch(() => {})
87
+
88
+ socket.addEventListener("open", () => {
89
+ for (const frame of sendQueue) socket.send(frame)
90
+ sendQueue.length = 0
91
+ })
92
+ socket.addEventListener("message", (event: MessageEvent) => {
93
+ if (typeof event.data !== "string") return // binary frames live off the typed contract
94
+ let parsed: unknown
95
+ try {
96
+ parsed =
97
+ transport === undefined
98
+ ? JSON.parse(event.data)
99
+ : decodeTransportFrame(event.data, registry, {
100
+ ...(transport.maxBytes === undefined ? {} : { maxBytes: transport.maxBytes }),
101
+ })
102
+ } catch {
103
+ return // a non-JSON text frame is outside the contract too
104
+ }
105
+ for (const listener of listeners) listener(parsed)
106
+ })
107
+
108
+ if (callOptions?.signal !== undefined) {
109
+ if (callOptions.signal.aborted) socket.close()
110
+ else callOptions.signal.addEventListener("abort", () => socket.close(), { once: true })
111
+ }
112
+
113
+ const onMessage = (callback: (message: unknown) => void): (() => void) => {
114
+ listeners.add(callback)
115
+ return () => listeners.delete(callback)
116
+ }
117
+
118
+ return {
119
+ raw: socket,
120
+ opened,
121
+ send(message: unknown): void {
122
+ const frame =
123
+ transport === undefined
124
+ ? JSON.stringify(message)
125
+ : encodeTransportFrame(message, transport.codec)
126
+ if (socket.readyState === WebSocketImpl.OPEN) socket.send(frame)
127
+ else sendQueue.push(frame)
128
+ },
129
+ close(code?: number, reason?: string): void {
130
+ socket.close(code, reason)
131
+ },
132
+ onMessage,
133
+ messages(options?: { signal?: AbortSignal }): AsyncIterableIterator<unknown> {
134
+ const buffered: unknown[] = []
135
+ let pending: ((result: IteratorResult<unknown>) => void) | undefined
136
+ let done = false
137
+
138
+ const push = (message: unknown): void => {
139
+ if (pending !== undefined) {
140
+ const resolve = pending
141
+ pending = undefined
142
+ resolve({ value: message, done: false })
143
+ } else {
144
+ buffered.push(message)
145
+ }
146
+ }
147
+ const end = (): void => {
148
+ if (done) return
149
+ done = true
150
+ unsubscribe()
151
+ pending?.({ value: undefined, done: true })
152
+ pending = undefined
153
+ }
154
+
155
+ const unsubscribe = onMessage(push)
156
+ socket.addEventListener("close", end, { once: true })
157
+ socket.addEventListener("error", end, { once: true })
158
+ options?.signal?.addEventListener("abort", end, { once: true })
159
+
160
+ const iterator: AsyncIterableIterator<unknown> = {
161
+ [Symbol.asyncIterator]() {
162
+ return this
163
+ },
164
+ next(): Promise<IteratorResult<unknown>> {
165
+ if (buffered.length > 0) {
166
+ return Promise.resolve({ value: buffered.shift(), done: false })
167
+ }
168
+ if (done) return Promise.resolve({ value: undefined, done: true })
169
+ return new Promise((resolve) => {
170
+ pending = resolve
171
+ })
172
+ },
173
+ return(): Promise<IteratorResult<unknown>> {
174
+ end()
175
+ return Promise.resolve({ value: undefined, done: true })
176
+ },
177
+ }
178
+ return iterator
179
+ },
180
+ }
181
+ }