@nifrajs/client 1.12.0 → 2.0.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/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,154 @@
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 type { WsCallOptions, WsHandle } from "./treaty.ts"
10
+
11
+ /** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
12
+ export const NO_SOCKET = Symbol.for("@nifrajs/client/no-socket")
13
+
14
+ interface WsRuntimeOptions extends WsCallOptions {
15
+ headers?: Record<string, string> | undefined
16
+ }
17
+
18
+ function toWsUrl(base: string, path: string, query: WsCallOptions["query"]): string {
19
+ const url = new URL(base + (path === "" ? "/" : path))
20
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
21
+ for (const [key, value] of Object.entries(query ?? {})) {
22
+ url.searchParams.set(key, String(value))
23
+ }
24
+ return url.toString()
25
+ }
26
+
27
+ export function openWebSocket(
28
+ base: string,
29
+ path: string,
30
+ callOptions: WsRuntimeOptions | undefined,
31
+ noSocket: boolean,
32
+ ): WsHandle<unknown, unknown> {
33
+ if (noSocket) {
34
+ throw new Error(
35
+ "the in-process client cannot open WebSockets (app.fetch has no socket) - listen() the app and connect with client(baseUrl)",
36
+ )
37
+ }
38
+ const WebSocketImpl = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket
39
+ if (WebSocketImpl === undefined) {
40
+ throw new Error(
41
+ "no global WebSocket in this runtime - .ws() needs one (browser, Bun, Node 22+)",
42
+ )
43
+ }
44
+
45
+ const url = toWsUrl(base, path, callOptions?.query)
46
+ const socket =
47
+ callOptions?.protocols !== undefined
48
+ ? new WebSocketImpl(url, callOptions.protocols)
49
+ : new WebSocketImpl(url)
50
+
51
+ const sendQueue: string[] = []
52
+ const listeners = new Set<(message: unknown) => void>()
53
+
54
+ const opened = new Promise<void>((resolve, reject) => {
55
+ socket.addEventListener("open", () => resolve(), { once: true })
56
+ socket.addEventListener(
57
+ "error",
58
+ (event) => {
59
+ callOptions?.onError?.(event)
60
+ reject(new Error("websocket_connect_failed"))
61
+ },
62
+ { once: true },
63
+ )
64
+ })
65
+ // The rejection is surfaced via `opened` (and `onError`) only for those who look - an unconsumed
66
+ // `opened` on a failed connect must not crash the process with an unhandled rejection.
67
+ opened.catch(() => {})
68
+
69
+ socket.addEventListener("open", () => {
70
+ for (const frame of sendQueue) socket.send(frame)
71
+ sendQueue.length = 0
72
+ })
73
+ socket.addEventListener("message", (event: MessageEvent) => {
74
+ if (typeof event.data !== "string") return // binary frames live off the typed contract
75
+ let parsed: unknown
76
+ try {
77
+ parsed = JSON.parse(event.data)
78
+ } catch {
79
+ return // a non-JSON text frame is outside the contract too
80
+ }
81
+ for (const listener of listeners) listener(parsed)
82
+ })
83
+
84
+ if (callOptions?.signal !== undefined) {
85
+ if (callOptions.signal.aborted) socket.close()
86
+ else callOptions.signal.addEventListener("abort", () => socket.close(), { once: true })
87
+ }
88
+
89
+ const onMessage = (callback: (message: unknown) => void): (() => void) => {
90
+ listeners.add(callback)
91
+ return () => listeners.delete(callback)
92
+ }
93
+
94
+ return {
95
+ raw: socket,
96
+ opened,
97
+ send(message: unknown): void {
98
+ const frame = JSON.stringify(message)
99
+ if (socket.readyState === WebSocketImpl.OPEN) socket.send(frame)
100
+ else sendQueue.push(frame)
101
+ },
102
+ close(code?: number, reason?: string): void {
103
+ socket.close(code, reason)
104
+ },
105
+ onMessage,
106
+ messages(options?: { signal?: AbortSignal }): AsyncIterableIterator<unknown> {
107
+ const buffered: unknown[] = []
108
+ let pending: ((result: IteratorResult<unknown>) => void) | undefined
109
+ let done = false
110
+
111
+ const push = (message: unknown): void => {
112
+ if (pending !== undefined) {
113
+ const resolve = pending
114
+ pending = undefined
115
+ resolve({ value: message, done: false })
116
+ } else {
117
+ buffered.push(message)
118
+ }
119
+ }
120
+ const end = (): void => {
121
+ if (done) return
122
+ done = true
123
+ unsubscribe()
124
+ pending?.({ value: undefined, done: true })
125
+ pending = undefined
126
+ }
127
+
128
+ const unsubscribe = onMessage(push)
129
+ socket.addEventListener("close", end, { once: true })
130
+ socket.addEventListener("error", end, { once: true })
131
+ options?.signal?.addEventListener("abort", end, { once: true })
132
+
133
+ const iterator: AsyncIterableIterator<unknown> = {
134
+ [Symbol.asyncIterator]() {
135
+ return this
136
+ },
137
+ next(): Promise<IteratorResult<unknown>> {
138
+ if (buffered.length > 0) {
139
+ return Promise.resolve({ value: buffered.shift(), done: false })
140
+ }
141
+ if (done) return Promise.resolve({ value: undefined, done: true })
142
+ return new Promise((resolve) => {
143
+ pending = resolve
144
+ })
145
+ },
146
+ return(): Promise<IteratorResult<unknown>> {
147
+ end()
148
+ return Promise.resolve({ value: undefined, done: true })
149
+ },
150
+ }
151
+ return iterator
152
+ },
153
+ }
154
+ }