@nifrajs/client 3.5.0 → 4.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/README.md +11 -2
- package/dist/client.d.ts +17 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +288 -67
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/routes.d.ts +66 -4
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js.map +1 -1
- package/dist/sendable-path.d.ts +2 -0
- package/dist/sendable-path.d.ts.map +1 -0
- package/dist/sendable-path.js +14 -0
- package/dist/sendable-path.js.map +1 -0
- package/dist/treaty.d.ts +35 -5
- package/dist/treaty.d.ts.map +1 -1
- package/dist/validate-responses.d.ts +4 -2
- package/dist/validate-responses.d.ts.map +1 -1
- package/dist/validate-responses.js +2 -2
- package/dist/validate-responses.js.map +1 -1
- package/dist/ws.d.ts +0 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +32 -6
- package/dist/ws.js.map +1 -1
- package/package.json +5 -2
- package/src/client.ts +352 -83
- package/src/index.ts +15 -1
- package/src/routes.ts +78 -4
- package/src/sendable-path.ts +14 -0
- package/src/treaty.ts +77 -15
- package/src/validate-responses.ts +11 -4
- package/src/ws.ts +35 -6
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
type InProcessClient,
|
|
13
13
|
type InProcessClientOptions,
|
|
14
14
|
inProcessClient,
|
|
15
|
+
type TestClientOptions,
|
|
15
16
|
testClient,
|
|
16
17
|
} from "./client.ts"
|
|
17
18
|
export type { Jsonify } from "./jsonify.ts"
|
|
@@ -22,7 +23,20 @@ export {
|
|
|
22
23
|
reservedKeyFor,
|
|
23
24
|
} from "./reserved.ts"
|
|
24
25
|
export type { ApiError, Result } from "./result.ts"
|
|
25
|
-
export type {
|
|
26
|
+
export type {
|
|
27
|
+
ActionArgs,
|
|
28
|
+
ActionData,
|
|
29
|
+
ApiProxy,
|
|
30
|
+
LoaderArgs,
|
|
31
|
+
LoaderData,
|
|
32
|
+
LoaderResponseControls,
|
|
33
|
+
OutputOf,
|
|
34
|
+
Register,
|
|
35
|
+
RegisteredBackend,
|
|
36
|
+
RegisteredEnv,
|
|
37
|
+
RouteLoaderArgs,
|
|
38
|
+
SearchSchemaOf,
|
|
39
|
+
} from "./routes.ts"
|
|
26
40
|
export type {
|
|
27
41
|
RegistryOf,
|
|
28
42
|
SubscribeOptions,
|
package/src/routes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ContractShape, RegistryFor } from "@nifrajs/core/contract"
|
|
2
|
-
import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
|
|
2
|
+
import type { CookieOptions, InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
|
|
3
3
|
import type { Treaty, TreatyFromRegistry } from "./treaty.ts"
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -11,6 +11,31 @@ export type ApiProxy<Api> = Api extends ContractShape
|
|
|
11
11
|
? TreatyFromRegistry<RegistryFor<Api>>
|
|
12
12
|
: Treaty<Api>
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Response controls a loader or action reaches as `ctx.set` - the page counterpart of a route
|
|
16
|
+
* handler's `c.set`. Write before the loader or action returns; a write from a deferred promise that
|
|
17
|
+
* settles later throws.
|
|
18
|
+
*/
|
|
19
|
+
export interface LoaderResponseControls {
|
|
20
|
+
/**
|
|
21
|
+
* Headers for the rendered document (`cache-control`, `x-robots-tag`, `link`, ...). A layout's
|
|
22
|
+
* headers are applied first, then the page's, then the action's, so the most specific writer wins a
|
|
23
|
+
* name. They are not applied to a redirect, a status page, an error page, or a navigation data
|
|
24
|
+
* response. `content-type`, `set-cookie`, `location`, transport headers, and the `x-nifra-` prefix
|
|
25
|
+
* are refused.
|
|
26
|
+
*/
|
|
27
|
+
readonly headers: Record<string, string>
|
|
28
|
+
/**
|
|
29
|
+
* Queue a `Set-Cookie`, with the same secure defaults as `c.set.cookie`
|
|
30
|
+
* (`HttpOnly; Secure; SameSite=Lax; Path=/`). The cookie rides every outcome - the document, a
|
|
31
|
+
* navigation data response, a redirect, a status or error page - and makes the response
|
|
32
|
+
* `cache-control: private, no-store`.
|
|
33
|
+
*/
|
|
34
|
+
cookie(name: string, value: string, options?: CookieOptions): void
|
|
35
|
+
/** Queue a cookie deletion. Match the `path`/`domain` the cookie was set with. */
|
|
36
|
+
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
/**
|
|
15
40
|
* Context a route `loader` receives: the route params, the request, a typed in-process `api` (an
|
|
16
41
|
* {@link ApiProxy} for the app contract `Api`), and the platform `env`. Pair with `inProcessClient`.
|
|
@@ -41,8 +66,53 @@ export interface LoaderArgs<Api, Env = unknown, Search = undefined> {
|
|
|
41
66
|
? InferOutput<Search>
|
|
42
67
|
: never
|
|
43
68
|
: Record<string, unknown>
|
|
69
|
+
/** Response headers and cookies for this page request: `ctx.set.headers["cache-control"] = ...`,
|
|
70
|
+
* `ctx.set.cookie("theme", "dark")`. */
|
|
71
|
+
readonly set: LoaderResponseControls
|
|
44
72
|
}
|
|
45
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The app's registered types. `nifra types` writes `.nifra/types/register.d.ts`, which fills it in
|
|
76
|
+
* from `backend/app.ts`, so a route's generated `Route.LoaderArgs` types `api` without the route
|
|
77
|
+
* importing the backend:
|
|
78
|
+
*
|
|
79
|
+
* declare module "@nifrajs/client" { interface Register { backend: typeof backend } }
|
|
80
|
+
*
|
|
81
|
+
* `env` may be registered the same way, for the platform bindings `ctx.env` carries.
|
|
82
|
+
*/
|
|
83
|
+
// biome-ignore lint/suspicious/noEmptyInterface: filled in by declaration merging
|
|
84
|
+
export interface Register {}
|
|
85
|
+
|
|
86
|
+
/** The registered backend (`Register["backend"]`), or `unknown` before one is registered. */
|
|
87
|
+
export type RegisteredBackend = Register extends { readonly backend: infer Backend }
|
|
88
|
+
? Backend
|
|
89
|
+
: unknown
|
|
90
|
+
|
|
91
|
+
/** The registered platform bindings (`Register["env"]`), or `unknown`. */
|
|
92
|
+
export type RegisteredEnv = Register extends { readonly env: infer Env } ? Env : unknown
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What a route module's output schema lets through to the browser: the output type of its `Name`
|
|
96
|
+
* export (`loaderOutput`, `actionOutput`), or `null` when the module declares none - a loader without
|
|
97
|
+
* one sends no data.
|
|
98
|
+
*/
|
|
99
|
+
export type OutputOf<Module, Name extends string> = Module extends { readonly [K in Name]: infer S }
|
|
100
|
+
? S extends StandardSchemaV1
|
|
101
|
+
? InferOutput<S>
|
|
102
|
+
: never
|
|
103
|
+
: null
|
|
104
|
+
|
|
105
|
+
/** The `searchSchema` a route's frontend half declares, or `undefined`. */
|
|
106
|
+
export type SearchSchemaOf<Module> = Module extends { readonly searchSchema: infer S }
|
|
107
|
+
? S
|
|
108
|
+
: undefined
|
|
109
|
+
|
|
110
|
+
/** A route's loader or action context: its own `params`, and `api` typed by the registered backend. */
|
|
111
|
+
export type RouteLoaderArgs<Params, Search = undefined> = Omit<
|
|
112
|
+
LoaderArgs<RegisteredBackend, RegisteredEnv, Search>,
|
|
113
|
+
"params"
|
|
114
|
+
> & { readonly params: Params }
|
|
115
|
+
|
|
46
116
|
/** The (awaited) return of a `loader`, for typing a page component's `data` prop. */
|
|
47
117
|
export type LoaderData<L> = L extends (...args: never[]) => infer R ? Awaited<R> : never
|
|
48
118
|
|
|
@@ -61,9 +131,13 @@ export type ActionArgs<Api, Env = unknown, Search = undefined> = LoaderArgs<Api,
|
|
|
61
131
|
* stays decoupled from `@nifrajs/web`) and unwrapped to its inner `data` - what the component receives.
|
|
62
132
|
*/
|
|
63
133
|
export type ActionData<A> = A extends (...args: never[]) => infer R
|
|
64
|
-
?
|
|
65
|
-
|
|
66
|
-
|
|
134
|
+
? // `infer D` makes the check distribute, so an action mixing `revalidate(...)` and plain returns
|
|
135
|
+
// unwraps each branch.
|
|
136
|
+
Awaited<R> extends infer D
|
|
137
|
+
? D extends { readonly __nifraRevalidate: readonly string[]; readonly data: infer W }
|
|
138
|
+
? Exclude<W, Response>
|
|
139
|
+
: Exclude<D, Response>
|
|
140
|
+
: never
|
|
67
141
|
: never
|
|
68
142
|
|
|
69
143
|
// Why a type annotation, not a `createRoutes()` factory: a module-level factory call defeats
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a request path has a `.` or `..` segment.
|
|
3
|
+
*
|
|
4
|
+
* Such a path cannot be sent as written. URL parsing removes those segments before the request
|
|
5
|
+
* leaves - the percent-encoded spellings (`%2e`, `%2E`) included - so the server would be asked for
|
|
6
|
+
* a different path than the call names: `/users/../posts` arrives as `/posts`. No encoding survives
|
|
7
|
+
* that step, so the client refuses the call instead of sending it somewhere else. A backslash counts
|
|
8
|
+
* as a separator because an `http(s)` URL reads it as one.
|
|
9
|
+
*/
|
|
10
|
+
const DOT_SEGMENT = /(?:^|[/\\])(?:\.|%2e){1,2}(?:[/\\]|$)/i
|
|
11
|
+
|
|
12
|
+
export function hasDotSegment(path: string): boolean {
|
|
13
|
+
return DOT_SEGMENT.test(path)
|
|
14
|
+
}
|
package/src/treaty.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RouteInfo, Server } from "@nifrajs/core/server"
|
|
1
|
+
import type { RequestPath, RouteInfo, Server } from "@nifrajs/core/server"
|
|
2
2
|
import type { Jsonify } from "./jsonify.ts"
|
|
3
3
|
import type { Result } from "./result.ts"
|
|
4
4
|
|
|
@@ -199,12 +199,34 @@ type NextSegs<R, Prefix extends string> = {
|
|
|
199
199
|
[P in Sub<R, Prefix> & string]: NextSeg<Prefix, P>
|
|
200
200
|
}[Sub<R, Prefix> & string]
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
/**
|
|
203
|
+
* What a request carries in place of one route segment: a static segment is itself, `:id` and
|
|
204
|
+
* `*path` are any `string`, and a segment that is part literal, part parameter keeps its literal
|
|
205
|
+
* text - `:name.json` is `` `${string}.json` ``. Read with the router's own rules, so a name ends
|
|
206
|
+
* where the router ends it and a constraint is not part of the text.
|
|
207
|
+
*/
|
|
208
|
+
type SegText<Seg extends string> = RequestPath<`/${Seg}`> extends `/${infer Text}` ? Text : never
|
|
209
|
+
|
|
210
|
+
// A segment is static when its text is the segment, a param when its text is any string (a whole
|
|
211
|
+
// `:name`, with or without a constraint, or a wildcard), and mixed otherwise.
|
|
212
|
+
type SegKind<Seg extends string> = string extends Seg
|
|
213
|
+
? "static"
|
|
214
|
+
: SegText<Seg> extends Seg
|
|
215
|
+
? "static"
|
|
216
|
+
: string extends SegText<Seg>
|
|
217
|
+
? "param"
|
|
218
|
+
: "mixed"
|
|
219
|
+
|
|
220
|
+
// The root's empty segment (``) is never a child.
|
|
221
|
+
type SegsOf<Seg extends string, Kind extends string> = Seg extends ""
|
|
222
|
+
? never
|
|
223
|
+
: SegKind<Seg> extends Kind
|
|
224
|
+
? Seg
|
|
225
|
+
: never
|
|
226
|
+
|
|
227
|
+
type StaticSegs<R, Prefix extends string> = SegsOf<NextSegs<R, Prefix>, "static">
|
|
228
|
+
type ParamSeg<R, Prefix extends string> = SegsOf<NextSegs<R, Prefix>, "param">
|
|
229
|
+
type MixedSegs<R, Prefix extends string> = SegsOf<NextSegs<R, Prefix>, "mixed">
|
|
208
230
|
|
|
209
231
|
// `unknown` (not `{}`/`never`) is the intersection identity, so empty branches
|
|
210
232
|
// don't poison the node - and we never `Prettify` the node (it would strip the
|
|
@@ -217,15 +239,35 @@ type StaticChildren<R, Prefix extends string> = {
|
|
|
217
239
|
: TreatyNode<R, `${Prefix}/${Seg}`>
|
|
218
240
|
}
|
|
219
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The call for one param segment. A constraint is not part of the name: `:id{[0-9]+}` is called
|
|
244
|
+
* with `{ id }`, and the node below is the route as it was registered.
|
|
245
|
+
*/
|
|
246
|
+
type ParamCall<
|
|
247
|
+
R,
|
|
248
|
+
Prefix extends string,
|
|
249
|
+
Seg extends string,
|
|
250
|
+
> = Seg extends `:${infer Name}{${string}}`
|
|
251
|
+
? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/${Seg}`>
|
|
252
|
+
: Seg extends `:${infer Name}`
|
|
253
|
+
? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/${Seg}`>
|
|
254
|
+
: Seg extends `*${infer Name}`
|
|
255
|
+
? (params: Record<Name extends "" ? "*" : Name, string>) => TreatyNode<R, `${Prefix}/${Seg}`>
|
|
256
|
+
: never
|
|
257
|
+
|
|
258
|
+
/** One callable carrying every signature in a union of functions. */
|
|
259
|
+
type EveryCall<Calls> = (Calls extends unknown ? (call: Calls) => void : never) extends (
|
|
260
|
+
call: infer All,
|
|
261
|
+
) => void
|
|
262
|
+
? All
|
|
263
|
+
: never
|
|
264
|
+
|
|
265
|
+
// One position can hold several param routes (`:id{[0-9]+}` beside `:slug`, or a param beside a
|
|
266
|
+
// wildcard). Each gets its own signature, picked by the name in the argument - so routes that share
|
|
267
|
+
// a position should not share a name, or the first one's types answer for both.
|
|
220
268
|
type ParamChild<R, Prefix extends string> = [ParamSeg<R, Prefix>] extends [never]
|
|
221
269
|
? unknown
|
|
222
|
-
:
|
|
223
|
-
? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/:${Name}`>
|
|
224
|
-
: ParamSeg<R, Prefix> extends `*${infer Name}`
|
|
225
|
-
? (
|
|
226
|
-
params: Record<Name extends "" ? "*" : Name, string>,
|
|
227
|
-
) => TreatyNode<R, `${Prefix}/*${Name}`>
|
|
228
|
-
: unknown
|
|
270
|
+
: EveryCall<ParamCall<R, Prefix, ParamSeg<R, Prefix>>>
|
|
229
271
|
|
|
230
272
|
/**
|
|
231
273
|
* The call spelling for a static child segment: `api("users").get()` is `api.users.get()`.
|
|
@@ -247,10 +289,30 @@ type SegmentCall<R, Prefix extends string> = [StaticSegs<R, Prefix>] extends [ne
|
|
|
247
289
|
? unknown
|
|
248
290
|
: <S extends StaticSegs<R, Prefix> & string>(segment: S) => TreatyNode<R, `${Prefix}/${S}`>
|
|
249
291
|
|
|
292
|
+
type MixedCall<R, Prefix extends string, Seg extends string> = Seg extends unknown
|
|
293
|
+
? (segment: SegText<Seg>) => TreatyNode<R, `${Prefix}/${Seg}`>
|
|
294
|
+
: never
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The call for a segment that is part literal, part parameter. It has no single param to name, so
|
|
298
|
+
* it takes the segment as it appears in the request: `/files/:name.json` is
|
|
299
|
+
* `api.files("report.json")`, `/v:major.:minor` is `api("v1.2")`. The argument's type is the
|
|
300
|
+
* segment's literal text around any string, so `api.files("report.txt")` does not compile.
|
|
301
|
+
*
|
|
302
|
+
* Placed last in the node intersection. A segment the registry declares as static resolves to
|
|
303
|
+
* `SegmentCall` first - `api.files("index.json")` is the static `/files/index.json` when there is
|
|
304
|
+
* one, which is also the route the server picks. Two mixed segments at one position that accept the
|
|
305
|
+
* same text resolve to whichever was registered first.
|
|
306
|
+
*/
|
|
307
|
+
type MixedChild<R, Prefix extends string> = [MixedSegs<R, Prefix>] extends [never]
|
|
308
|
+
? unknown
|
|
309
|
+
: EveryCall<MixedCall<R, Prefix, MixedSegs<R, Prefix>>>
|
|
310
|
+
|
|
250
311
|
type TreatyNode<R, Prefix extends string> = MethodsAt<R, Prefix> &
|
|
251
312
|
StaticChildren<R, Prefix> &
|
|
252
313
|
ParamChild<R, Prefix> &
|
|
253
|
-
SegmentCall<R, Prefix>
|
|
314
|
+
SegmentCall<R, Prefix> &
|
|
315
|
+
MixedChild<R, Prefix>
|
|
254
316
|
|
|
255
317
|
// The root path "/" is reached as `api.index.get()` (Eden convention).
|
|
256
318
|
type RootIndex<R> = "/" extends keyof R ? { readonly index: Methods<R["/"]> } : unknown
|
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
|
|
16
16
|
import { type Method, Router } from "@nifrajs/core/router"
|
|
17
17
|
import { formatStandardIssues } from "@nifrajs/core/schema"
|
|
18
|
-
import type {
|
|
18
|
+
import type { Platform } from "@nifrajs/core/server"
|
|
19
|
+
|
|
20
|
+
/** The in-process bridge: the client's `fetch(url, init)` shape, plus the calling request's platform. */
|
|
21
|
+
export type PlatformFetchFn = (
|
|
22
|
+
input: string,
|
|
23
|
+
init?: RequestInit,
|
|
24
|
+
platform?: Platform,
|
|
25
|
+
) => Promise<Response>
|
|
19
26
|
|
|
20
27
|
/** The slice of a Standard Schema this wrapper runs. */
|
|
21
28
|
interface SchemaLike {
|
|
@@ -66,7 +73,7 @@ export class ResponseContractViolation extends Error {
|
|
|
66
73
|
* Wrap the in-process bridge so every response is checked against its route's declared contract.
|
|
67
74
|
* Throws at wrap time when the app cannot enumerate routes - a misconfiguration, not a soft skip.
|
|
68
75
|
*/
|
|
69
|
-
export function withResponseValidation(app: unknown, bridge:
|
|
76
|
+
export function withResponseValidation(app: unknown, bridge: PlatformFetchFn): PlatformFetchFn {
|
|
70
77
|
const routesOf = (app as Partial<RouteIntrospectable>).routes
|
|
71
78
|
if (typeof routesOf !== "function") {
|
|
72
79
|
throw new Error(
|
|
@@ -79,8 +86,8 @@ export function withResponseValidation(app: unknown, bridge: FetchFn): FetchFn {
|
|
|
79
86
|
router.add(route.method as Method, route.path, route.schema)
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
return async (url, init) => {
|
|
83
|
-
const response = await bridge(url, init)
|
|
89
|
+
return async (url, init, platform) => {
|
|
90
|
+
const response = await bridge(url, init, platform)
|
|
84
91
|
const method = (init?.method ?? "GET").toUpperCase()
|
|
85
92
|
const status = response.status
|
|
86
93
|
if (method === "HEAD" || status === 204 || status === 205) return response
|
package/src/ws.ts
CHANGED
|
@@ -14,13 +14,17 @@ import {
|
|
|
14
14
|
type TransportCodec,
|
|
15
15
|
type TransportCodecRegistry,
|
|
16
16
|
} from "@nifrajs/core/transport-codec"
|
|
17
|
+
import { hasDotSegment } from "./sendable-path.ts"
|
|
17
18
|
import type { WsCallOptions, WsHandle } from "./treaty.ts"
|
|
18
19
|
|
|
19
20
|
/** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
|
|
20
21
|
export const NO_SOCKET = Symbol.for("@nifrajs/client/no-socket")
|
|
21
22
|
|
|
23
|
+
// `readyState` values, fixed by the WebSocket standard (an implementation's statics may be absent).
|
|
24
|
+
const CONNECTING = 0
|
|
25
|
+
const CLOSED = 3
|
|
26
|
+
|
|
22
27
|
interface WsRuntimeOptions extends WsCallOptions {
|
|
23
|
-
headers?: Record<string, string> | undefined
|
|
24
28
|
transport?: {
|
|
25
29
|
readonly codec: TransportCodec
|
|
26
30
|
readonly registry?: TransportCodecRegistry
|
|
@@ -55,6 +59,12 @@ export function openWebSocket(
|
|
|
55
59
|
)
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
if (hasDotSegment(path)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'cannot open a WebSocket at a path with a "." or ".." segment - URL parsing removes that segment, so the socket would reach a different route',
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
const url = toWsUrl(base, path, callOptions?.query)
|
|
59
69
|
const socket =
|
|
60
70
|
callOptions?.protocols !== undefined
|
|
@@ -89,6 +99,10 @@ export function openWebSocket(
|
|
|
89
99
|
for (const frame of sendQueue) socket.send(frame)
|
|
90
100
|
sendQueue.length = 0
|
|
91
101
|
})
|
|
102
|
+
// A socket that closed before it opened will never send what was queued for it.
|
|
103
|
+
socket.addEventListener("close", () => {
|
|
104
|
+
sendQueue.length = 0
|
|
105
|
+
})
|
|
92
106
|
socket.addEventListener("message", (event: MessageEvent) => {
|
|
93
107
|
if (typeof event.data !== "string") return // binary frames live off the typed contract
|
|
94
108
|
let parsed: unknown
|
|
@@ -105,9 +119,16 @@ export function openWebSocket(
|
|
|
105
119
|
for (const listener of listeners) listener(parsed)
|
|
106
120
|
})
|
|
107
121
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
const signal = callOptions?.signal
|
|
123
|
+
if (signal !== undefined) {
|
|
124
|
+
if (signal.aborted) socket.close()
|
|
125
|
+
else {
|
|
126
|
+
const abort = (): void => socket.close()
|
|
127
|
+
signal.addEventListener("abort", abort, { once: true })
|
|
128
|
+
socket.addEventListener("close", () => signal.removeEventListener("abort", abort), {
|
|
129
|
+
once: true,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
111
132
|
}
|
|
112
133
|
|
|
113
134
|
const onMessage = (callback: (message: unknown) => void): (() => void) => {
|
|
@@ -124,7 +145,9 @@ export function openWebSocket(
|
|
|
124
145
|
? JSON.stringify(message)
|
|
125
146
|
: encodeTransportFrame(message, transport.codec)
|
|
126
147
|
if (socket.readyState === WebSocketImpl.OPEN) socket.send(frame)
|
|
127
|
-
|
|
148
|
+
// Queued only while connecting: a closing or closed socket drops the frame, as
|
|
149
|
+
// `WebSocket.send` itself does, rather than holding it in a queue nothing will flush.
|
|
150
|
+
else if (socket.readyState === CONNECTING) sendQueue.push(frame)
|
|
128
151
|
},
|
|
129
152
|
close(code?: number, reason?: string): void {
|
|
130
153
|
socket.close(code, reason)
|
|
@@ -134,6 +157,7 @@ export function openWebSocket(
|
|
|
134
157
|
const buffered: unknown[] = []
|
|
135
158
|
let pending: ((result: IteratorResult<unknown>) => void) | undefined
|
|
136
159
|
let done = false
|
|
160
|
+
const signal = options?.signal
|
|
137
161
|
|
|
138
162
|
const push = (message: unknown): void => {
|
|
139
163
|
if (pending !== undefined) {
|
|
@@ -148,6 +172,9 @@ export function openWebSocket(
|
|
|
148
172
|
if (done) return
|
|
149
173
|
done = true
|
|
150
174
|
unsubscribe()
|
|
175
|
+
socket.removeEventListener("close", end)
|
|
176
|
+
socket.removeEventListener("error", end)
|
|
177
|
+
signal?.removeEventListener("abort", end)
|
|
151
178
|
pending?.({ value: undefined, done: true })
|
|
152
179
|
pending = undefined
|
|
153
180
|
}
|
|
@@ -155,7 +182,9 @@ export function openWebSocket(
|
|
|
155
182
|
const unsubscribe = onMessage(push)
|
|
156
183
|
socket.addEventListener("close", end, { once: true })
|
|
157
184
|
socket.addEventListener("error", end, { once: true })
|
|
158
|
-
|
|
185
|
+
signal?.addEventListener("abort", end, { once: true })
|
|
186
|
+
// Already over: no close or abort event is coming to end the iteration.
|
|
187
|
+
if (socket.readyState === CLOSED || signal?.aborted === true) end()
|
|
159
188
|
|
|
160
189
|
const iterator: AsyncIterableIterator<unknown> = {
|
|
161
190
|
[Symbol.asyncIterator]() {
|