@nifrajs/client 1.13.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/dist/client.d.ts +48 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +88 -18
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/result.d.ts +34 -10
- package/dist/result.d.ts.map +1 -1
- package/dist/routes.d.ts +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/treaty.d.ts +48 -3
- package/dist/treaty.d.ts.map +1 -1
- package/dist/validate-responses.d.ts +61 -0
- package/dist/validate-responses.d.ts.map +1 -0
- package/dist/validate-responses.js +82 -0
- package/dist/validate-responses.js.map +1 -0
- package/dist/ws.d.ts +16 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +133 -0
- package/dist/ws.js.map +1 -0
- package/package.json +2 -2
- package/src/client.ts +147 -18
- package/src/index.ts +5 -0
- package/src/result.ts +91 -13
- package/src/routes.ts +1 -1
- package/src/treaty.ts +51 -3
- package/src/validate-responses.ts +121 -0
- package/src/ws.ts +154 -0
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
|
+
}
|