@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/dist/client.d.ts +58 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +117 -22
- 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 +22 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +146 -0
- package/dist/ws.js.map +1 -0
- package/package.json +2 -2
- package/src/client.ts +192 -22
- 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 +181 -0
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
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
|
+
}
|