snapreq 0.0.1

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.
@@ -0,0 +1,323 @@
1
+ // @ts-check
2
+
3
+ import {buildCapabilities} from "../capabilities.js"
4
+ import {SnapReqAbortError, SnapReqUnsupportedFeatureError} from "../errors.js"
5
+ import SnapReqHeaders from "../headers.js"
6
+ import SnapReqResponse from "../response.js"
7
+
8
+ /**
9
+ * Full-featured transport backed by Node's `http`/`https` modules. Supports
10
+ * Unix sockets, client TLS, keep-alive, request-body compression, response
11
+ * decompression and streaming. The `node:*` modules are loaded with a dynamic
12
+ * `import()` so this file never has to be bundled by web/Expo bundlers — the
13
+ * transport selector only imports it when running on Node.
14
+ */
15
+ export default class NodeTransport {
16
+ /** @returns {string} - Transport name. */
17
+ static get transportName() {
18
+ return "node"
19
+ }
20
+
21
+ /** @returns {boolean} - Whether this transport can run in the current environment. */
22
+ static isAvailable() {
23
+ return typeof process !== "undefined" && Boolean(process.versions?.node)
24
+ }
25
+
26
+ /**
27
+ * @param {object} [config] - Transport configuration.
28
+ * @param {string} [config.socketPath] - Unix domain socket path.
29
+ * @param {{ca?: string | Buffer, cert?: string | Buffer, key?: string | Buffer, rejectUnauthorized?: boolean}} [config.tls] - TLS material for HTTPS connections.
30
+ * @param {boolean} [config.keepAlive] - Reuse connections across requests. Defaults to true.
31
+ */
32
+ constructor({socketPath, tls, keepAlive = true} = {}) {
33
+ this.socketPath = socketPath
34
+ this.tls = tls
35
+ this.keepAlive = keepAlive
36
+ /** @type {{http: any, https: any, zlib: any, stream: any} | null} */
37
+ this._modules = null
38
+ /** @type {any} */
39
+ this._httpAgent = null
40
+ /** @type {any} */
41
+ this._httpsAgent = null
42
+ }
43
+
44
+ /** @returns {import("../capabilities.js").TransportCapabilities} - Supported capabilities. */
45
+ get capabilities() {
46
+ return buildCapabilities({
47
+ unixSocket: true,
48
+ tlsClientCert: true,
49
+ requestCompression: true,
50
+ responseStreaming: true,
51
+ requestStreaming: true,
52
+ keepAlive: true,
53
+ abort: true
54
+ })
55
+ }
56
+
57
+ /** @returns {Promise<{http: any, https: any, zlib: any, stream: any}>} - Lazily-loaded Node modules. */
58
+ async _load() {
59
+ if (!this._modules) {
60
+ const [http, https, zlib, stream] = await Promise.all([
61
+ import("node:http"),
62
+ import("node:https"),
63
+ import("node:zlib"),
64
+ import("node:stream")
65
+ ])
66
+
67
+ this._modules = {http, https, zlib, stream}
68
+ }
69
+
70
+ return this._modules
71
+ }
72
+
73
+ /**
74
+ * @param {boolean} useTls - Whether the request uses TLS.
75
+ * @returns {any} - The keep-alive agent for the protocol.
76
+ */
77
+ _agent(useTls) {
78
+ const {http, https} = /** @type {{http: any, https: any}} */ (this._modules)
79
+
80
+ if (useTls) {
81
+ this._httpsAgent ||= new https.Agent({
82
+ keepAlive: this.keepAlive,
83
+ ...(this.tls?.ca !== undefined ? {ca: this.tls.ca} : {}),
84
+ ...(this.tls?.cert !== undefined ? {cert: this.tls.cert} : {}),
85
+ ...(this.tls?.key !== undefined ? {key: this.tls.key} : {}),
86
+ ...(this.tls?.rejectUnauthorized !== undefined ? {rejectUnauthorized: this.tls.rejectUnauthorized} : {})
87
+ })
88
+
89
+ return this._httpsAgent
90
+ }
91
+
92
+ this._httpAgent ||= new http.Agent({keepAlive: this.keepAlive})
93
+
94
+ return this._httpAgent
95
+ }
96
+
97
+ /**
98
+ * Performs a single request and resolves once the response headers arrive,
99
+ * exposing the (decoded) body as a stream so callers can buffer or stream it.
100
+ * @param {import("../snap-req.js").NormalizedRequest} request - Normalized request.
101
+ * @returns {Promise<SnapReqResponse>} - The response.
102
+ */
103
+ async performRequest(request) {
104
+ const modules = await this._load()
105
+ const {zlib, stream} = modules
106
+ const parsedUrl = new URL(request.url)
107
+ const useTls = Boolean(this.tls) || parsedUrl.protocol === "https:"
108
+ const httpModule = useTls ? modules.https : modules.http
109
+ const headers = new SnapReqHeaders(request.headers)
110
+ const requestBody = this._prepareRequestBody(request, headers, {zlib, stream})
111
+
112
+ /** @type {Record<string, any>} */
113
+ const requestOptions = {
114
+ method: request.method,
115
+ path: `${parsedUrl.pathname}${parsedUrl.search}`,
116
+ headers: headers.toObject(),
117
+ agent: this._agent(useTls)
118
+ }
119
+
120
+ if (this.socketPath) {
121
+ requestOptions.socketPath = this.socketPath
122
+ } else {
123
+ requestOptions.hostname = parsedUrl.hostname
124
+ requestOptions.port = parsedUrl.port || (useTls ? 443 : 80)
125
+ }
126
+
127
+ return await new Promise((resolve, reject) => {
128
+ if (request.signal?.aborted) {
129
+ reject(new SnapReqAbortError())
130
+ return
131
+ }
132
+
133
+ let settled = false
134
+ const abort = () => req.destroy(new SnapReqAbortError())
135
+ const removeAbortListener = () => request.signal?.removeEventListener("abort", abort)
136
+
137
+ const req = httpModule.request(requestOptions, (res) => {
138
+ /** @type {import("node:stream").Readable} */
139
+ let responseStream
140
+
141
+ try {
142
+ responseStream = this._decodeResponseStream(res, zlib)
143
+ } catch (error) {
144
+ if (!settled) {
145
+ settled = true
146
+ removeAbortListener()
147
+ reject(error)
148
+ }
149
+
150
+ return
151
+ }
152
+
153
+ // Re-point the abort listener at the response stream so aborting after
154
+ // headers arrive tears down the body stream rather than the request.
155
+ removeAbortListener()
156
+ const abortStream = () => responseStream.destroy(new SnapReqAbortError())
157
+
158
+ request.signal?.addEventListener("abort", abortStream, {once: true})
159
+ responseStream.on("close", () => request.signal?.removeEventListener("abort", abortStream))
160
+
161
+ settled = true
162
+ resolve(new SnapReqResponse({
163
+ url: request.url,
164
+ method: request.method,
165
+ status: res.statusCode,
166
+ statusText: res.statusMessage || "",
167
+ headers: this._responseHeaders(res),
168
+ stream: responseStream,
169
+ nodeStream: responseStream
170
+ }))
171
+ })
172
+
173
+ request.signal?.addEventListener("abort", abort, {once: true})
174
+
175
+ req.on("error", (/** @type {unknown} */ error) => {
176
+ removeAbortListener()
177
+
178
+ if (!settled) {
179
+ settled = true
180
+ reject(error)
181
+ }
182
+ })
183
+
184
+ if (requestBody.stream) {
185
+ requestBody.stream.pipe(req)
186
+ } else {
187
+ if (requestBody.buffer) req.write(requestBody.buffer)
188
+
189
+ req.end()
190
+ }
191
+ })
192
+ }
193
+
194
+ /**
195
+ * @param {import("../snap-req.js").NormalizedRequest} request - Normalized request.
196
+ * @param {SnapReqHeaders} headers - Headers, mutated with Content-Length / Content-Encoding.
197
+ * @param {{zlib: any, stream: any}} modules - Node modules.
198
+ * @returns {{buffer: Buffer | null, stream: import("node:stream").Readable | null}} - Prepared body.
199
+ */
200
+ _prepareRequestBody(request, headers, {zlib, stream}) {
201
+ const compression = request.bodyCompression || "identity"
202
+ const body = request.body
203
+
204
+ if (body.kind === "none") return {buffer: null, stream: null}
205
+
206
+ /** @type {Buffer | null} */
207
+ let buffer = null
208
+ /** @type {import("node:stream").Readable | null} */
209
+ let bodyStream = null
210
+
211
+ if (body.kind === "stream") {
212
+ const value = /** @type {any} */ (body.value)
213
+
214
+ bodyStream = typeof value.pipe === "function" ? value : stream.Readable.from(value)
215
+ } else if (body.kind === "bytes") {
216
+ buffer = Buffer.from(/** @type {Uint8Array} */ (body.value))
217
+ } else {
218
+ buffer = Buffer.from(/** @type {string} */ (body.value))
219
+ }
220
+
221
+ if (compression === "identity") {
222
+ if (buffer) headers.set("Content-Length", String(buffer.length))
223
+
224
+ return {buffer, stream: bodyStream}
225
+ }
226
+
227
+ if (headers.has("content-encoding")) {
228
+ throw new SnapReqUnsupportedFeatureError({
229
+ feature: "bodyCompression",
230
+ transport: "node",
231
+ detail: "cannot combine bodyCompression with an explicit Content-Encoding header"
232
+ })
233
+ }
234
+
235
+ headers.set("Content-Encoding", compression)
236
+
237
+ const compressor = this._requestCompressor(compression, zlib)
238
+ const source = bodyStream || stream.Readable.from(/** @type {Buffer} */ (buffer))
239
+
240
+ source.on("error", (/** @type {Error} */ error) => compressor.destroy(error))
241
+ source.pipe(compressor)
242
+
243
+ return {buffer: null, stream: compressor}
244
+ }
245
+
246
+ /**
247
+ * @param {string} encoding - Compression encoding.
248
+ * @param {any} zlib - The zlib module.
249
+ * @returns {import("node:stream").Transform} - A compressor transform.
250
+ */
251
+ _requestCompressor(encoding, zlib) {
252
+ if (encoding === "gzip") return zlib.createGzip()
253
+ if (encoding === "deflate") return zlib.createDeflate()
254
+ if (encoding === "br") return zlib.createBrotliCompress()
255
+ if (encoding === "zstd" && typeof zlib.createZstdCompress === "function") return zlib.createZstdCompress()
256
+
257
+ throw new SnapReqUnsupportedFeatureError({feature: `bodyCompression "${encoding}"`, transport: "node"})
258
+ }
259
+
260
+ /**
261
+ * @param {import("node:http").IncomingMessage} response - The raw response.
262
+ * @param {any} zlib - The zlib module.
263
+ * @returns {import("node:stream").Readable} - The decoded response body stream.
264
+ */
265
+ _decodeResponseStream(response, zlib) {
266
+ const header = response.headers["content-encoding"]
267
+ const headerValue = Array.isArray(header) ? header.join(",") : header
268
+ const encodings = (headerValue || "")
269
+ .split(",")
270
+ .map((encoding) => encoding.trim().toLowerCase())
271
+ .filter((encoding) => encoding && encoding !== "identity")
272
+
273
+ /** @type {import("node:stream").Readable} */
274
+ let decoded = response
275
+
276
+ for (let index = encodings.length - 1; index >= 0; index -= 1) {
277
+ decoded = decoded.pipe(this._responseDecoder(encodings[index], zlib))
278
+ }
279
+
280
+ return decoded
281
+ }
282
+
283
+ /**
284
+ * @param {string} encoding - Content encoding.
285
+ * @param {any} zlib - The zlib module.
286
+ * @returns {import("node:stream").Transform} - A decompressor transform.
287
+ */
288
+ _responseDecoder(encoding, zlib) {
289
+ if (encoding === "gzip" || encoding === "x-gzip") return zlib.createGunzip()
290
+ if (encoding === "deflate") return zlib.createInflate()
291
+ if (encoding === "br") return zlib.createBrotliDecompress()
292
+ if (encoding === "zstd" && typeof zlib.createZstdDecompress === "function") return zlib.createZstdDecompress()
293
+
294
+ throw new SnapReqUnsupportedFeatureError({feature: `response content-encoding "${encoding}"`, transport: "node"})
295
+ }
296
+
297
+ /**
298
+ * @param {import("node:http").IncomingMessage} response - The raw response.
299
+ * @returns {SnapReqHeaders} - The response headers.
300
+ */
301
+ _responseHeaders(response) {
302
+ const headers = new SnapReqHeaders()
303
+
304
+ for (const [name, value] of Object.entries(response.headers)) {
305
+ if (value === undefined) continue
306
+
307
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value)
308
+ }
309
+
310
+ return headers
311
+ }
312
+
313
+ /**
314
+ * Destroys the keep-alive agents, closing all persistent connections.
315
+ * @returns {void}
316
+ */
317
+ close() {
318
+ this._httpAgent?.destroy()
319
+ this._httpsAgent?.destroy()
320
+ this._httpAgent = null
321
+ this._httpsAgent = null
322
+ }
323
+ }
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+
3
+ import {SnapReqError} from "../errors.js"
4
+ import FetchTransport from "./fetch-transport.js"
5
+ import XhrTransport from "./xhr-transport.js"
6
+
7
+ /**
8
+ * @typedef {"auto" | "node" | "fetch" | "xhr"} TransportName
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} Transport
13
+ * @property {import("../capabilities.js").TransportCapabilities} capabilities - Supported capabilities.
14
+ * @property {(request: import("../snap-req.js").NormalizedRequest) => Promise<import("../response.js").default>} performRequest - Perform a request.
15
+ * @property {() => void} [close] - Optional resource cleanup.
16
+ */
17
+
18
+ /**
19
+ * Detects the JavaScript runtime so `auto` can pick the right transport.
20
+ * @returns {"node" | "react-native" | "browser" | "unknown"} - Detected runtime.
21
+ */
22
+ export function detectRuntime() {
23
+ const navigatorRef = /** @type {any} */ (globalThis).navigator
24
+
25
+ if (navigatorRef && navigatorRef.product === "ReactNative") return "react-native"
26
+ if (typeof process !== "undefined" && Boolean(process.versions?.node)) return "node"
27
+ if (typeof window !== "undefined" && typeof document !== "undefined") return "browser"
28
+
29
+ return "unknown"
30
+ }
31
+
32
+ /**
33
+ * @param {object} config - Node transport configuration.
34
+ * @returns {Promise<Transport>} - A Node transport instance.
35
+ */
36
+ async function createNodeTransport(config) {
37
+ const {default: NodeTransport} = await import("./node-transport.js")
38
+
39
+ return new NodeTransport(config)
40
+ }
41
+
42
+ /**
43
+ * Resolves a transport for the requested preference. Returns the preference
44
+ * untouched when it is already a transport instance. The Node transport is
45
+ * imported dynamically so web/Expo bundlers never pull in `node:*` modules.
46
+ * @param {TransportName | Transport | undefined} preference - Requested transport.
47
+ * @param {object} nodeConfig - Configuration forwarded to the Node transport.
48
+ * @returns {Promise<Transport>} - The resolved transport.
49
+ */
50
+ export async function selectTransport(preference, nodeConfig) {
51
+ if (preference && typeof preference === "object" && typeof (/** @type {any} */ (preference).performRequest) === "function") {
52
+ return /** @type {Transport} */ (preference)
53
+ }
54
+
55
+ const choice = /** @type {TransportName} */ (preference || "auto")
56
+
57
+ if (choice === "node") return await createNodeTransport(nodeConfig)
58
+ if (choice === "fetch") return new FetchTransport()
59
+ if (choice === "xhr") return new XhrTransport()
60
+
61
+ if (choice !== "auto") {
62
+ throw new SnapReqError(`Unknown transport "${choice}". Use "auto", "node", "fetch", "xhr" or a transport instance.`)
63
+ }
64
+
65
+ const runtime = detectRuntime()
66
+
67
+ if (runtime === "node") return await createNodeTransport(nodeConfig)
68
+ if (FetchTransport.isAvailable()) return new FetchTransport()
69
+ if (XhrTransport.isAvailable()) return new XhrTransport()
70
+ if (typeof process !== "undefined" && Boolean(process.versions?.node)) return await createNodeTransport(nodeConfig)
71
+
72
+ throw new SnapReqError("No suitable transport found: this platform has no fetch, XMLHttpRequest or Node http support.")
73
+ }
@@ -0,0 +1,112 @@
1
+ // @ts-check
2
+
3
+ import {buildCapabilities} from "../capabilities.js"
4
+ import {SnapReqAbortError, SnapReqUnsupportedFeatureError} from "../errors.js"
5
+ import SnapReqHeaders from "../headers.js"
6
+ import SnapReqResponse from "../response.js"
7
+
8
+ /**
9
+ * Transport backed by `XMLHttpRequest`. A fallback for web environments that
10
+ * lack `fetch`. Buffers the whole response (no incremental streaming) and, like
11
+ * `fetch`, cannot do Unix sockets, client TLS or request-body compression.
12
+ */
13
+ export default class XhrTransport {
14
+ /** @returns {string} - Transport name. */
15
+ static get transportName() {
16
+ return "xhr"
17
+ }
18
+
19
+ /** @returns {boolean} - Whether this transport can run in the current environment. */
20
+ static isAvailable() {
21
+ return typeof XMLHttpRequest === "function"
22
+ }
23
+
24
+ /** @returns {import("../capabilities.js").TransportCapabilities} - Supported capabilities. */
25
+ get capabilities() {
26
+ return buildCapabilities({abort: true})
27
+ }
28
+
29
+ /**
30
+ * @param {import("../snap-req.js").NormalizedRequest} request - Normalized request.
31
+ * @returns {Promise<SnapReqResponse>} - The response.
32
+ */
33
+ performRequest(request) {
34
+ if (request.bodyCompression && request.bodyCompression !== "identity") {
35
+ throw new SnapReqUnsupportedFeatureError({feature: "request body compression", transport: "xhr"})
36
+ }
37
+
38
+ if (request.body.kind === "stream") {
39
+ throw new SnapReqUnsupportedFeatureError({feature: "streamed request bodies", transport: "xhr"})
40
+ }
41
+
42
+ return new Promise((resolve, reject) => {
43
+ if (request.signal?.aborted) {
44
+ reject(new SnapReqAbortError())
45
+ return
46
+ }
47
+
48
+ const xhr = new XMLHttpRequest()
49
+
50
+ xhr.open(request.method, request.url, true)
51
+ xhr.responseType = "arraybuffer"
52
+
53
+ if (request.credentials === "include") xhr.withCredentials = true
54
+
55
+ for (const [name, value] of request.headers.entries()) xhr.setRequestHeader(name, value)
56
+
57
+ const abort = () => xhr.abort()
58
+
59
+ request.signal?.addEventListener("abort", abort, {once: true})
60
+
61
+ const cleanup = () => request.signal?.removeEventListener("abort", abort)
62
+
63
+ xhr.onload = () => {
64
+ cleanup()
65
+ resolve(new SnapReqResponse({
66
+ url: request.url,
67
+ method: request.method,
68
+ status: xhr.status,
69
+ statusText: xhr.statusText,
70
+ headers: this._parseHeaders(xhr.getAllResponseHeaders()),
71
+ bytes: new Uint8Array(/** @type {ArrayBuffer} */ (xhr.response) || new ArrayBuffer(0))
72
+ }))
73
+ }
74
+
75
+ xhr.onerror = () => {
76
+ cleanup()
77
+ reject(new Error(`XMLHttpRequest failed for ${request.method} ${request.url}`))
78
+ }
79
+
80
+ xhr.onabort = () => {
81
+ cleanup()
82
+ reject(new SnapReqAbortError())
83
+ }
84
+
85
+ const body = request.body
86
+
87
+ if (body.kind === "none") {
88
+ xhr.send()
89
+ } else {
90
+ xhr.send(/** @type {any} */ (body.value))
91
+ }
92
+ })
93
+ }
94
+
95
+ /**
96
+ * @param {string} rawHeaders - Raw header block from `getAllResponseHeaders`.
97
+ * @returns {SnapReqHeaders} - The parsed response headers.
98
+ */
99
+ _parseHeaders(rawHeaders) {
100
+ const headers = new SnapReqHeaders()
101
+
102
+ for (const line of rawHeaders.trim().split(/[\r\n]+/)) {
103
+ const separatorIndex = line.indexOf(":")
104
+
105
+ if (separatorIndex === -1) continue
106
+
107
+ headers.set(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim())
108
+ }
109
+
110
+ return headers
111
+ }
112
+ }
@@ -0,0 +1,176 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Client-side handle for a channel subscription opened via
5
+ * `SnapReqWebSocketClient.subscribeChannel()`. Mirrors the server's
6
+ * subscription lifecycle — `subscribed` (resolves `ready`) / `onMessage` /
7
+ * `onClose`.
8
+ */
9
+ export default class SnapReqWebSocketChannel {
10
+ /**
11
+ * @param {object} args - Channel arguments.
12
+ * @param {import("./websocket-client.js").default} args.client - Owning client.
13
+ * @param {string} args.subscriptionId - Generated id unique within the session.
14
+ * @param {string} args.channelType - Name the server registered the channel under.
15
+ * @param {Record<string, any>} [args.params] - Opaque params forwarded to the server.
16
+ * @param {string} [args.lastEventId] - Resume replay from this event id.
17
+ * @param {(body: any) => void} [args.onMessage] - Fired on each `channel-message` from the server.
18
+ * @param {() => void} [args.onDisconnect] - Fired when the socket drops.
19
+ * @param {() => void} [args.onResume] - Fired when the session resumes after a drop.
20
+ * @param {(reason: string) => void} [args.onClose] - Fired exactly once when the subscription closes permanently.
21
+ */
22
+ constructor({client, subscriptionId, channelType, params, lastEventId, onMessage, onDisconnect, onResume, onClose}) {
23
+ this.client = client
24
+ this.subscriptionId = subscriptionId
25
+ this.channelType = channelType
26
+ this.params = params || {}
27
+ this.lastEventId = lastEventId
28
+ this._onMessage = onMessage
29
+ this._onDisconnect = onDisconnect
30
+ this._onResume = onResume
31
+ this._onClose = onClose
32
+ this._ready = false
33
+ this._resumeReadyOnResume = false
34
+ this._subscribed = false
35
+ this._subscribeSent = false
36
+ this._closed = false
37
+ }
38
+
39
+ /** @returns {Promise<void>} - Resolves once the subscription is acknowledged. */
40
+ _ensureReadyPromise() {
41
+ if (!this._readyPromise || !this._resolveReady || !this._rejectReady) {
42
+ /** @type {Promise<void>} */
43
+ this._readyPromise = new Promise((resolve, reject) => {
44
+ this._resolveReady = resolve
45
+ this._rejectReady = reject
46
+ })
47
+ }
48
+
49
+ return this._readyPromise
50
+ }
51
+
52
+ /** @returns {Promise<void>} - Resolves once the subscription is acknowledged. */
53
+ get ready() {
54
+ return this._ensureReadyPromise()
55
+ }
56
+
57
+ /** @returns {void} */
58
+ _resolveReadyState() {
59
+ this._ready = true
60
+ this._resolveReady?.()
61
+ this._resolveReady = null
62
+ this._rejectReady = null
63
+ }
64
+
65
+ /** @returns {void} */
66
+ _markNotReady() {
67
+ this._ready = false
68
+ }
69
+
70
+ /** @returns {void} */
71
+ _handleSubscribed() {
72
+ if (this._closed || this._subscribed) return
73
+ this._subscribed = true
74
+ this._resolveReadyState()
75
+ }
76
+
77
+ /** @returns {void} */
78
+ _markSubscribeSent() {
79
+ this._subscribeSent = true
80
+ }
81
+
82
+ /** @returns {boolean} - Whether the subscription still needs to be sent. */
83
+ _needsSubscribe() {
84
+ return !this._closed && !this._subscribeSent
85
+ }
86
+
87
+ /**
88
+ * @param {any} body - Message payload.
89
+ * @returns {void}
90
+ */
91
+ _handleMessage(body) {
92
+ if (this._closed) return
93
+ this._onMessage?.(body)
94
+ }
95
+
96
+ /** @returns {void} */
97
+ _handleDisconnected() {
98
+ if (this._closed) return
99
+ this._resumeReadyOnResume ||= this._subscribed
100
+ this._subscribed = false
101
+ this._markNotReady()
102
+ this._onDisconnect?.()
103
+ }
104
+
105
+ /** @returns {void} */
106
+ _handleResumed() {
107
+ if (this._closed) return
108
+ if (this._resumeReadyOnResume) {
109
+ this._subscribed = true
110
+ this._resolveReadyState()
111
+ }
112
+ this._resumeReadyOnResume = false
113
+ this._onResume?.()
114
+ }
115
+
116
+ /**
117
+ * @param {string} reason - Why the subscription closed.
118
+ * @returns {void}
119
+ */
120
+ _handleClosed(reason) {
121
+ if (this._closed) return
122
+ this._closed = true
123
+
124
+ try {
125
+ this._onClose?.(reason)
126
+ } finally {
127
+ this._resumeReadyOnResume = false
128
+ if (!this._ready) {
129
+ this._rejectReady?.(new Error(`Subscription closed before acknowledgement: ${reason}`))
130
+ }
131
+
132
+ this._resolveReady = null
133
+ this._rejectReady = null
134
+ }
135
+ }
136
+
137
+ /**
138
+ * @param {{timeoutMs?: number}} [params] - Options.
139
+ * @returns {Promise<void>} - Resolves once ready or rejects on timeout.
140
+ */
141
+ async waitForReady({timeoutMs = 5000} = {}) {
142
+ if (this._ready) return
143
+
144
+ const readyPromise = this._ensureReadyPromise()
145
+ const timeoutPromise = new Promise((_, reject) => {
146
+ setTimeout(() => reject(new Error(`Subscription not ready after ${timeoutMs}ms`)), timeoutMs)
147
+ })
148
+
149
+ await Promise.race([readyPromise, timeoutPromise])
150
+ }
151
+
152
+ /** @returns {void} */
153
+ close() {
154
+ if (this._closed) return
155
+
156
+ try {
157
+ if (this.client.isOpen()) {
158
+ this.client._sendMessage({type: "channel-unsubscribe", subscriptionId: this.subscriptionId})
159
+ }
160
+ } catch {
161
+ // Socket already gone; server will clean up on session teardown.
162
+ }
163
+
164
+ this.client._removeChannelSubscription(this.subscriptionId)
165
+ this._handleClosed("client_unsubscribe")
166
+ }
167
+
168
+ /** @returns {boolean} - Whether the subscription is closed. */
169
+ isClosed() { return this._closed }
170
+
171
+ /** @returns {boolean} - Whether the subscription is acknowledged and ready. */
172
+ isReady() { return this._ready }
173
+
174
+ /** @returns {boolean} - Whether the subscription is active. */
175
+ isSubscribed() { return this._subscribed && !this._closed }
176
+ }