snapreq 0.0.1 → 0.0.4

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 CHANGED
@@ -15,6 +15,19 @@ The same code runs everywhere. Where a platform cannot provide a feature (Unix s
15
15
  npm install snapreq
16
16
  ```
17
17
 
18
+ ## Imports
19
+
20
+ snapreq has **no barrel entry point** — you import each piece from its own subpath so a bundler (Metro/Expo, webpack, …) only pulls in what you use, and the HTTP client never drags the WebSocket client into your bundle:
21
+
22
+ ```js
23
+ import SnapReq from "snapreq" // the HTTP client
24
+ import SnapReqWebSocketClient from "snapreq/websocket"
25
+ import {SnapReqHttpError, SnapReqUnsupportedFeatureError} from "snapreq/errors"
26
+ import {defaultRetryableError} from "snapreq/retry"
27
+ import SnapReqResponse from "snapreq/response"
28
+ import SnapReqHeaders from "snapreq/headers"
29
+ ```
30
+
18
31
  ## HTTP
19
32
 
20
33
  ```js
@@ -50,6 +63,7 @@ new SnapReq({
50
63
  headers, // default headers — object or a factory `() => ({...})` for dynamic auth
51
64
  retry, // default retry policy (see below)
52
65
  throwOnError, // throw SnapReqHttpError on non-2xx (default false)
66
+ timeoutMs, // default request/body timeout in milliseconds; per-request 0 disables it
53
67
  credentials, // fetch credentials mode: "omit" | "same-origin" | "include"
54
68
  transport, // "auto" (default) | "node" | "fetch" | "xhr" | a transport instance
55
69
 
@@ -60,6 +74,17 @@ new SnapReq({
60
74
  })
61
75
  ```
62
76
 
77
+ ### Timeouts
78
+
79
+ Set `timeoutMs` on the client or a single request to abort stalled requests. The timeout covers the response headers and body reads through `json()`, `text()`, `bytes()`, `buffer()`, or `stream()`. A timed-out request rejects with `SnapReqTimeoutError`.
80
+
81
+ ```js
82
+ const client = new SnapReq({baseUrl: "https://api.example.com", timeoutMs: 120000})
83
+
84
+ await client.get("/slow", {timeoutMs: 5000})
85
+ await client.get("/long-running", {timeoutMs: 0}) // disable the client default for this call
86
+ ```
87
+
63
88
  ### Retry
64
89
 
65
90
  Retries transient network errors and retryable HTTP statuses (502/503/504 by default). Only applies to buffered requests — never to streamed bodies.
@@ -69,7 +94,7 @@ await client.get("/flaky", {retry: true})
69
94
  await client.get("/flaky", {retry: {tries: 5, waitMs: 200, retryableStatuses: [503]}})
70
95
 
71
96
  // Compose extra rules on top of the default network classifier:
72
- import {defaultRetryableError} from "snapreq"
97
+ import {defaultRetryableError} from "snapreq/retry"
73
98
 
74
99
  await client.get("/x", {retry: {shouldRetry: (error) => defaultRetryableError(error) || isMyCase(error)}})
75
100
  ```
@@ -118,7 +143,7 @@ const caps = await client.capabilities()
118
143
  A WebSocket client over `globalThis.WebSocket` with auto-reconnect, session resumption, request/response calls, channel subscriptions and 1:1 connections.
119
144
 
120
145
  ```js
121
- import {SnapReqWebSocketClient} from "snapreq"
146
+ import SnapReqWebSocketClient from "snapreq/websocket"
122
147
 
123
148
  const client = new SnapReqWebSocketClient({url: "wss://example.com/websocket"})
124
149
 
@@ -137,7 +162,7 @@ await connection.ready
137
162
  connection.sendMessage({text: "hi"})
138
163
  ```
139
164
 
140
- Optional adapters: `networkMonitor` (gate reconnects on online state) and `sessionStore` (persist the session id across reloads).
165
+ Optional adapters: `networkMonitor` (gate reconnects on online state), `sessionStore` (persist the session id across reloads) and `deserialize` (a `(value) => value` transform applied inside `response.json()` so an app can re-hydrate its own wire format).
141
166
 
142
167
  ## License
143
168
 
package/package.json CHANGED
@@ -1,17 +1,66 @@
1
1
  {
2
2
  "name": "snapreq",
3
- "version": "0.0.1",
3
+ "version": "0.0.4",
4
4
  "description": "Cross-platform HTTP and WebSocket client with one API across Node, web, Expo and React Native",
5
5
  "type": "module",
6
- "main": "src/index.js",
6
+ "main": "./src/snap-req.js",
7
+ "types": "./types/snap-req.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.js"
9
+ ".": {
10
+ "types": "./types/snap-req.d.ts",
11
+ "default": "./src/snap-req.js"
12
+ },
13
+ "./websocket": {
14
+ "types": "./types/websocket/websocket-client.d.ts",
15
+ "default": "./src/websocket/websocket-client.js"
16
+ },
17
+ "./errors": {
18
+ "types": "./types/errors.d.ts",
19
+ "default": "./src/errors.js"
20
+ },
21
+ "./headers": {
22
+ "types": "./types/headers.d.ts",
23
+ "default": "./src/headers.js"
24
+ },
25
+ "./response": {
26
+ "types": "./types/response.d.ts",
27
+ "default": "./src/response.js"
28
+ },
29
+ "./retry": {
30
+ "types": "./types/retry.d.ts",
31
+ "default": "./src/retry.js"
32
+ },
33
+ "./capabilities": {
34
+ "types": "./types/capabilities.d.ts",
35
+ "default": "./src/capabilities.js"
36
+ },
37
+ "./transports/select": {
38
+ "types": "./types/transports/select.d.ts",
39
+ "default": "./src/transports/select.js"
40
+ },
41
+ "./transports/fetch": {
42
+ "types": "./types/transports/fetch-transport.d.ts",
43
+ "default": "./src/transports/fetch-transport.js"
44
+ },
45
+ "./transports/xhr": {
46
+ "types": "./types/transports/xhr-transport.d.ts",
47
+ "default": "./src/transports/xhr-transport.js"
48
+ },
49
+ "./*": {
50
+ "types": "./types/*.d.ts",
51
+ "default": "./src/*.js"
52
+ }
9
53
  },
10
54
  "files": [
11
- "src/**"
55
+ "src/**",
56
+ "types/**"
12
57
  ],
13
58
  "scripts": {
59
+ "all-checks": "npm run typecheck && npm run lint && npm run test",
60
+ "build:types": "tsc -p tsconfig.build.json",
14
61
  "lint": "eslint src spec",
62
+ "prepublishOnly": "npm run build:types",
63
+ "release:patch": "release-patch",
15
64
  "test": "node --test \"spec/**/*-spec.js\"",
16
65
  "typecheck": "tsc --noEmit"
17
66
  },
@@ -27,12 +76,13 @@
27
76
  "author": "kasper@diestoeckels.de",
28
77
  "license": "ISC",
29
78
  "devDependencies": {
30
- "@eslint/js": "^9.0.0",
31
- "@types/node": "^22.0.0",
79
+ "@eslint/js": "^10.0.1",
80
+ "@types/node": "^25.9.1",
32
81
  "@types/ws": "^8.18.1",
33
82
  "eslint": "^10.3.0",
34
- "eslint-plugin-jsdoc": "^62.9.0",
35
- "globals": "^16.0.0",
83
+ "eslint-plugin-jsdoc": "^63.0.0",
84
+ "globals": "^17.6.0",
85
+ "release-patch": "^1.0.0",
36
86
  "typescript": "^6.0.3",
37
87
  "ws": "^8.21.0"
38
88
  }
package/src/errors.js CHANGED
@@ -67,3 +67,20 @@ export class SnapReqAbortError extends SnapReqError {
67
67
  this.name = "SnapReqAbortError"
68
68
  }
69
69
  }
70
+
71
+ /** Thrown when a request exceeds its configured timeout. */
72
+ export class SnapReqTimeoutError extends SnapReqError {
73
+ /**
74
+ * @param {object} options - Error metadata.
75
+ * @param {string} options.method - HTTP method used for the request.
76
+ * @param {string} options.url - Fully resolved request URL.
77
+ * @param {number} options.timeoutMs - Timeout in milliseconds.
78
+ */
79
+ constructor({method, url, timeoutMs}) {
80
+ super(`Request timed out after ${timeoutMs}ms: ${method} ${url}`)
81
+ this.name = "SnapReqTimeoutError"
82
+ this.method = method
83
+ this.url = url
84
+ this.timeoutMs = timeoutMs
85
+ }
86
+ }
package/src/response.js CHANGED
@@ -40,8 +40,10 @@ export default class SnapReqResponse {
40
40
  * @param {Uint8Array} [options.bytes] - Fully-read body, when the transport already buffered it.
41
41
  * @param {AsyncIterable<Uint8Array>} [options.stream] - Streamed body, when the transport supports streaming.
42
42
  * @param {import("node:stream").Readable} [options.nodeStream] - Raw Node stream, when available, for advanced consumers.
43
+ * @param {() => void} [options.onBodyDone] - Callback fired when body reading finishes or fails.
44
+ * @param {(error: unknown) => unknown} [options.mapBodyError] - Maps body read errors before rethrowing.
43
45
  */
44
- constructor({url, method, status, statusText = "", headers, bytes, stream, nodeStream}) {
46
+ constructor({url, method, status, statusText = "", headers, bytes, stream, nodeStream, onBodyDone, mapBodyError}) {
45
47
  this.url = url
46
48
  this.method = method
47
49
  this.status = status
@@ -54,6 +56,11 @@ export default class SnapReqResponse {
54
56
  /** @type {import("node:stream").Readable | undefined} */
55
57
  this.nodeStream = nodeStream
56
58
  this._streamConsumed = false
59
+ this._bodyDone = false
60
+ this._onBodyDone = onBodyDone
61
+ this._mapBodyError = mapBodyError
62
+
63
+ if (bytes !== undefined) this._finishBody()
57
64
  }
58
65
 
59
66
  /** @returns {boolean} - Whether the status is in the 2xx range. */
@@ -77,7 +84,7 @@ export default class SnapReqResponse {
77
84
 
78
85
  this._streamConsumed = true
79
86
 
80
- return this._stream
87
+ return this._wrappedStream(this._stream)
81
88
  }
82
89
 
83
90
  /** @returns {boolean} - Whether the body is available as a stream that has not been read yet. */
@@ -94,20 +101,15 @@ export default class SnapReqResponse {
94
101
 
95
102
  if (!this._stream) {
96
103
  this._bytes = new Uint8Array(0)
104
+ this._finishBody()
97
105
 
98
106
  return this._bytes
99
107
  }
100
108
 
101
- if (this._streamConsumed) {
102
- throw new Error("Cannot buffer this response: its stream was already consumed via stream().")
103
- }
104
-
105
- this._streamConsumed = true
106
-
107
109
  /** @type {Uint8Array[]} */
108
110
  const chunks = []
109
111
 
110
- for await (const chunk of this._stream) {
112
+ for await (const chunk of this.stream()) {
111
113
  chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk))
112
114
  }
113
115
 
@@ -153,4 +155,43 @@ export default class SnapReqResponse {
153
155
 
154
156
  return JSON.parse(text)
155
157
  }
158
+
159
+ /**
160
+ * @param {AsyncIterable<Uint8Array>} source - Source response stream.
161
+ * @returns {AsyncIterable<Uint8Array>} - Stream with cleanup/error mapping.
162
+ */
163
+ _wrappedStream(source) {
164
+ const response = this
165
+
166
+ return (async function* () {
167
+ try {
168
+ for await (const chunk of source) {
169
+ yield chunk
170
+ }
171
+ } catch (error) {
172
+ throw response._mappedBodyError(error)
173
+ } finally {
174
+ response._finishBody()
175
+ }
176
+ })()
177
+ }
178
+
179
+ /**
180
+ * @param {unknown} error - Body read error.
181
+ * @returns {unknown} - Error to rethrow.
182
+ */
183
+ _mappedBodyError(error) {
184
+ if (this._mapBodyError) return this._mapBodyError(error)
185
+
186
+ return error
187
+ }
188
+
189
+ /** @returns {void} */
190
+ _finishBody() {
191
+ if (this._bodyDone) return
192
+
193
+ this._bodyDone = true
194
+
195
+ if (this._onBodyDone) this._onBodyDone()
196
+ }
156
197
  }
package/src/retry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import {SnapReqHttpError} from "./errors.js"
3
+ import {SnapReqHttpError, SnapReqTimeoutError} from "./errors.js"
4
4
 
5
5
  const RETRYABLE_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENOENT", "ETIMEDOUT", "EPIPE"])
6
6
  const DEFAULT_RETRYABLE_STATUSES = [502, 503, 504]
@@ -28,6 +28,8 @@ const DEFAULT_RETRYABLE_STATUSES = [502, 503, 504]
28
28
  * @returns {boolean} - Whether the error is a transient network failure.
29
29
  */
30
30
  export function defaultRetryableError(error) {
31
+ if (error instanceof SnapReqTimeoutError) return true
32
+
31
33
  if (!error || typeof error !== "object") return false
32
34
 
33
35
  if ("code" in error && typeof error.code === "string" && RETRYABLE_ERROR_CODES.has(error.code)) {
package/src/snap-req.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import {SnapReqHttpError, SnapReqUnsupportedFeatureError} from "./errors.js"
3
+ import {SnapReqHttpError, SnapReqTimeoutError, SnapReqUnsupportedFeatureError} from "./errors.js"
4
4
  import SnapReqHeaders from "./headers.js"
5
5
  import {buildUrl, normalizeBody} from "./request.js"
6
6
  import {normalizeRetryOptions, runWithRetry} from "./retry.js"
@@ -18,6 +18,7 @@ import {selectTransport} from "./transports/select.js"
18
18
  * @property {import("./request.js").NormalizedBody} body - Normalized request body.
19
19
  * @property {CompressionEncoding} bodyCompression - Request body compression.
20
20
  * @property {AbortSignal} [signal] - Abort signal.
21
+ * @property {number} [timeoutMs] - Request timeout in milliseconds.
21
22
  * @property {string} [credentials] - Fetch credentials mode ("omit" | "same-origin" | "include").
22
23
  */
23
24
 
@@ -31,11 +32,20 @@ import {selectTransport} from "./transports/select.js"
31
32
  * @property {any} [body] - Request body: string, object (JSON), Uint8Array/ArrayBuffer, or a stream/async-iterable.
32
33
  * @property {CompressionEncoding} [bodyCompression] - Compress the request body (Node transport only).
33
34
  * @property {AbortSignal} [signal] - Abort signal for the request.
35
+ * @property {number} [timeoutMs] - Request timeout in milliseconds. Set to `0` to disable a client default.
34
36
  * @property {string} [credentials] - Fetch credentials mode.
35
37
  * @property {boolean | import("./retry.js").RetryOptions} [retry] - Retry transient failures.
36
38
  * @property {boolean} [throwOnError] - Throw `SnapReqHttpError` on non-2xx responses.
37
39
  */
38
40
 
41
+ /**
42
+ * @typedef {object} RequestTimeout
43
+ * @property {AbortSignal | undefined} signal - Signal to use for the request.
44
+ * @property {() => void} clear - Clears timeout resources.
45
+ * @property {(response: import("./response.js").default, request: NormalizedRequest) => import("./response.js").default} response - Attaches timeout handling to a response.
46
+ * @property {(error: unknown, request: NormalizedRequest) => unknown} error - Maps a thrown error.
47
+ */
48
+
39
49
  /**
40
50
  * A cross-platform HTTP client with one API across Node, web, Expo and React
41
51
  * Native. The right transport is chosen at runtime; features a platform cannot
@@ -52,14 +62,16 @@ export default class SnapReq {
52
62
  * @param {Record<string, string | number> | (() => Record<string, string | number>)} [config.headers] - Default headers (object or factory).
53
63
  * @param {boolean | import("./retry.js").RetryOptions} [config.retry] - Default retry policy.
54
64
  * @param {boolean} [config.throwOnError] - Throw `SnapReqHttpError` on non-2xx responses by default. Defaults to false.
65
+ * @param {number} [config.timeoutMs] - Default request timeout in milliseconds. Set per-request `timeoutMs: 0` to disable.
55
66
  * @param {string} [config.credentials] - Default fetch credentials mode.
56
67
  * @param {import("./transports/select.js").TransportName | import("./transports/select.js").Transport} [config.transport] - Transport preference or instance. Defaults to "auto".
57
68
  */
58
- constructor({baseUrl, socketPath, tls, keepAlive = true, headers, retry, throwOnError = false, credentials, transport = "auto"} = {}) {
69
+ constructor({baseUrl, socketPath, tls, keepAlive = true, headers, retry, throwOnError = false, timeoutMs, credentials, transport = "auto"} = {}) {
59
70
  this.baseUrl = baseUrl
60
71
  this.defaultHeaders = headers
61
72
  this.defaultRetry = retry
62
73
  this.throwOnError = throwOnError
74
+ this.timeoutMs = timeoutMs
63
75
  this.credentials = credentials
64
76
  this._transportPreference = transport
65
77
  this._nodeConfig = {socketPath, tls, keepAlive}
@@ -110,10 +122,120 @@ export default class SnapReq {
110
122
  body,
111
123
  bodyCompression: options.bodyCompression || "identity",
112
124
  signal: options.signal,
125
+ timeoutMs: options.timeoutMs ?? this.timeoutMs,
113
126
  credentials: options.credentials ?? this.credentials
114
127
  }
115
128
  }
116
129
 
130
+ /**
131
+ * @param {RequestOptions} options - Request options.
132
+ * @returns {RequestTimeout} - Timeout handling for one request attempt.
133
+ */
134
+ _requestTimeout(options) {
135
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs
136
+
137
+ if (!timeoutMs || timeoutMs <= 0) {
138
+ return {
139
+ signal: options.signal,
140
+ clear: () => {},
141
+ response: (response) => response,
142
+ error: (error) => error
143
+ }
144
+ }
145
+
146
+ const timeoutController = new AbortController()
147
+ const composedSignal = this._composeSignal(options.signal, timeoutController.signal)
148
+ let timedOut = false
149
+ const timer = setTimeout(() => {
150
+ timedOut = true
151
+ timeoutController.abort()
152
+ }, timeoutMs)
153
+
154
+ if (typeof timer.unref === "function") timer.unref()
155
+
156
+ const clear = () => {
157
+ clearTimeout(timer)
158
+ composedSignal.clear()
159
+ }
160
+ const toError = (error, request) => {
161
+ if (timedOut) {
162
+ return new SnapReqTimeoutError({
163
+ method: request.method,
164
+ url: request.url,
165
+ timeoutMs
166
+ })
167
+ }
168
+
169
+ return error
170
+ }
171
+
172
+ return {
173
+ signal: composedSignal.signal,
174
+ clear,
175
+ response: (response, request) => {
176
+ if (response._bodyDone) {
177
+ clear()
178
+
179
+ return response
180
+ }
181
+
182
+ response._onBodyDone = this._chainBodyDone(response._onBodyDone, clear)
183
+ response._mapBodyError = this._chainBodyError(response._mapBodyError, (error) => toError(error, request))
184
+
185
+ return response
186
+ },
187
+ error: toError
188
+ }
189
+ }
190
+
191
+ /**
192
+ * @param {AbortSignal | undefined} callerSignal - Caller-supplied signal.
193
+ * @param {AbortSignal} timeoutSignal - Timeout signal.
194
+ * @returns {{signal: AbortSignal, clear: () => void}} - Signal that aborts when either source aborts.
195
+ */
196
+ _composeSignal(callerSignal, timeoutSignal) {
197
+ if (!callerSignal) return {signal: timeoutSignal, clear: () => {}}
198
+
199
+ const controller = new AbortController()
200
+ const abort = () => controller.abort()
201
+
202
+ if (callerSignal.aborted || timeoutSignal.aborted) {
203
+ controller.abort()
204
+ } else {
205
+ callerSignal.addEventListener("abort", abort, {once: true})
206
+ timeoutSignal.addEventListener("abort", abort, {once: true})
207
+ }
208
+
209
+ return {
210
+ signal: controller.signal,
211
+ clear: () => {
212
+ callerSignal.removeEventListener("abort", abort)
213
+ timeoutSignal.removeEventListener("abort", abort)
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * @param {(() => void) | undefined} existing - Existing body-done callback.
220
+ * @param {() => void} next - Callback to add.
221
+ * @returns {() => void} - Combined callback.
222
+ */
223
+ _chainBodyDone(existing, next) {
224
+ return () => {
225
+ if (existing) existing()
226
+ next()
227
+ }
228
+ }
229
+
230
+ /**
231
+ * @param {((error: unknown) => unknown) | undefined} existing - Existing error mapper.
232
+ * @param {(error: unknown) => unknown} next - Mapper to add.
233
+ * @returns {(error: unknown) => unknown} - Combined mapper.
234
+ */
235
+ _chainBodyError(existing, next) {
236
+ return (error) => next(existing ? existing(error) : error)
237
+ }
238
+
117
239
  /**
118
240
  * Performs a request and buffers nothing eagerly — read the body via the
119
241
  * returned response (`json()`, `text()`, `bytes()`). Retries transient
@@ -122,16 +244,15 @@ export default class SnapReq {
122
244
  * @returns {Promise<import("./response.js").default>} - The response.
123
245
  */
124
246
  async request(options) {
125
- const normalized = this._normalize(options)
126
247
  const transport = await this._resolveTransport()
127
248
  const throwOnError = options.throwOnError ?? this.throwOnError
128
249
  const retry = normalizeRetryOptions(options.retry ?? this.defaultRetry)
129
- const canRetry = retry && normalized.body.kind !== "stream"
130
-
131
- const attempt = () => transport.performRequest(normalized)
250
+ const body = normalizeBody(options.body, new SnapReqHeaders(options.headers))
251
+ const canRetry = retry && body.kind !== "stream"
252
+ const attempt = async () => this._requestWithTimeout(options, (request) => transport.performRequest(request))
132
253
  const response = canRetry ? await runWithRetry(attempt, /** @type {any} */ (retry)) : await attempt()
133
254
 
134
- if (throwOnError && !response.ok) throw await this._httpError(response, normalized)
255
+ if (throwOnError && !response.ok) throw await this._httpError(response, this._normalize(options))
135
256
 
136
257
  return response
137
258
  }
@@ -144,7 +265,6 @@ export default class SnapReq {
144
265
  * @returns {Promise<import("./response.js").default>} - The streaming response.
145
266
  */
146
267
  async requestStream(options) {
147
- const normalized = this._normalize(options)
148
268
  const transport = await this._resolveTransport()
149
269
 
150
270
  if (!transport.capabilities.responseStreaming) {
@@ -154,15 +274,35 @@ export default class SnapReq {
154
274
  })
155
275
  }
156
276
 
157
- const response = await transport.performRequest(normalized)
277
+ const response = await this._requestWithTimeout(options, (request) => transport.performRequest(request))
158
278
 
159
279
  if ((options.throwOnError ?? this.throwOnError) && !response.ok) {
160
- throw await this._httpError(response, normalized)
280
+ throw await this._httpError(response, this._normalize(options))
161
281
  }
162
282
 
163
283
  return response
164
284
  }
165
285
 
286
+ /**
287
+ * @param {RequestOptions} options - Request options.
288
+ * @param {(request: NormalizedRequest) => Promise<import("./response.js").default>} performRequest - Transport request runner.
289
+ * @returns {Promise<import("./response.js").default>} - Response with timeout handling attached.
290
+ */
291
+ async _requestWithTimeout(options, performRequest) {
292
+ const timeout = this._requestTimeout(options)
293
+ const normalized = this._normalize({...options, signal: timeout.signal})
294
+
295
+ try {
296
+ const response = await performRequest(normalized)
297
+
298
+ return timeout.response(response, normalized)
299
+ } catch (error) {
300
+ timeout.clear()
301
+
302
+ throw timeout.error(error, normalized)
303
+ }
304
+ }
305
+
166
306
  /**
167
307
  * @param {string} path - Request path or absolute URL.
168
308
  * @param {RequestOptions} [options] - Request options.
@@ -221,7 +361,9 @@ export default class SnapReq {
221
361
 
222
362
  try {
223
363
  responseText = await response.text()
224
- } catch {
364
+ } catch (error) {
365
+ if (error instanceof SnapReqTimeoutError) throw error
366
+
225
367
  // Body unavailable (already streamed or read error) — fall back to status text.
226
368
  }
227
369
 
@@ -31,11 +31,14 @@ export default class SnapReqWebSocketClient {
31
31
  * @param {{getIsOnline?: () => boolean | Promise<boolean>, subscribe?: (callback: (isOnline: boolean) => void) => (() => void) | {remove: () => void}}} [args.networkMonitor] - Optional online-state adapter. When provided, auto-reconnect can wait for the network to report online before reconnecting, and open sockets are closed when the monitor reports offline.
32
32
  * @param {number[]} [args.reconnectDelays] - Backoff delays in ms (default: [1000, 2000, 4000, 8000, 15000]).
33
33
  * @param {{get: () => string | null | undefined | Promise<string | null | undefined>, set: (sessionId: string) => void | Promise<void>, clear: () => void | Promise<void>}} [args.sessionStore] - Optional sessionId persistence hook surviving reloads (localStorage, a cookie, SQLite, etc.).
34
+ * @param {(value: any) => any} [args.deserialize] - Optional transform applied to a response body inside `response.json()`. Lets an app re-hydrate its own wire format. Defaults to identity.
34
35
  */
35
- constructor({autoReconnect = true, debug = false, networkMonitor, reconnectDelays, sessionStore, url} = /** @type {any} */ ({})) {
36
+ constructor({autoReconnect = true, debug = false, deserialize, networkMonitor, reconnectDelays, sessionStore, url} = /** @type {any} */ ({})) {
36
37
  if (!globalThis.WebSocket) throw new Error("WebSocket global is not available")
37
38
  if (!url) throw new Error("SnapReqWebSocketClient requires a url")
38
39
 
40
+ /** @type {(value: any) => any} */
41
+ this._deserialize = deserialize || ((value) => value)
39
42
  /** @type {boolean} */
40
43
  this.autoReconnect = autoReconnect
41
44
  this.debug = debug
@@ -637,7 +640,7 @@ export default class SnapReqWebSocketClient {
637
640
 
638
641
  if (pending) {
639
642
  this.pendingRequests.delete(id)
640
- pending.resolve(new SnapReqWebSocketResponse(message))
643
+ pending.resolve(new SnapReqWebSocketResponse(message, this._deserialize))
641
644
  } else {
642
645
  this._debug(`No pending request for response id ${id}`)
643
646
  }
@@ -1006,8 +1009,9 @@ export default class SnapReqWebSocketClient {
1006
1009
  export class SnapReqWebSocketResponse {
1007
1010
  /**
1008
1011
  * @param {object} message - The response message.
1012
+ * @param {(value: any) => any} [deserialize] - Transform applied to the parsed body in `json()`. Defaults to identity.
1009
1013
  */
1010
- constructor(message) {
1014
+ constructor(message, deserialize) {
1011
1015
  const responseMessage = /** @type {{body?: any, headers?: Record<string, any>, id?: string | number | null, statusCode?: number, statusMessage?: string, type?: string}} */ (message)
1012
1016
 
1013
1017
  this.body = responseMessage.body
@@ -1016,14 +1020,15 @@ export class SnapReqWebSocketResponse {
1016
1020
  this.statusCode = responseMessage.statusCode || 200
1017
1021
  this.statusMessage = responseMessage.statusMessage || "OK"
1018
1022
  this.type = responseMessage.type
1023
+ this._deserialize = deserialize || ((value) => value)
1019
1024
  }
1020
1025
 
1021
- /** @returns {any} - The parsed JSON body. */
1026
+ /** @returns {any} - The parsed (and optionally deserialized) JSON body. */
1022
1027
  json() {
1023
1028
  if (typeof this.body !== "string") {
1024
1029
  throw new Error("Response body is not a string")
1025
1030
  }
1026
1031
 
1027
- return JSON.parse(this.body)
1032
+ return this._deserialize(JSON.parse(this.body))
1028
1033
  }
1029
1034
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @typedef {object} TransportCapabilities
3
+ * @property {boolean} unixSocket - Connect over a Unix domain socket.
4
+ * @property {boolean} tlsClientCert - Present a client certificate / custom CA for TLS.
5
+ * @property {boolean} requestCompression - Compress the request body (gzip/deflate/br/zstd).
6
+ * @property {boolean} responseStreaming - Expose the response body as a stream before it is fully read.
7
+ * @property {boolean} requestStreaming - Send a streamed (async-iterable) request body.
8
+ * @property {boolean} keepAlive - Reuse connections across requests (HTTP keep-alive).
9
+ * @property {boolean} abort - Cancel an in-flight request via an `AbortSignal`.
10
+ */
11
+ /**
12
+ * Builds a fully-populated capability object so callers can read every flag
13
+ * without undefined checks.
14
+ * @param {Partial<TransportCapabilities>} overrides - Capabilities the transport supports.
15
+ * @returns {TransportCapabilities} - Complete capability flags.
16
+ */
17
+ export function buildCapabilities(overrides: Partial<TransportCapabilities>): TransportCapabilities;
18
+ export type TransportCapabilities = {
19
+ /**
20
+ * - Connect over a Unix domain socket.
21
+ */
22
+ unixSocket: boolean;
23
+ /**
24
+ * - Present a client certificate / custom CA for TLS.
25
+ */
26
+ tlsClientCert: boolean;
27
+ /**
28
+ * - Compress the request body (gzip/deflate/br/zstd).
29
+ */
30
+ requestCompression: boolean;
31
+ /**
32
+ * - Expose the response body as a stream before it is fully read.
33
+ */
34
+ responseStreaming: boolean;
35
+ /**
36
+ * - Send a streamed (async-iterable) request body.
37
+ */
38
+ requestStreaming: boolean;
39
+ /**
40
+ * - Reuse connections across requests (HTTP keep-alive).
41
+ */
42
+ keepAlive: boolean;
43
+ /**
44
+ * - Cancel an in-flight request via an `AbortSignal`.
45
+ */
46
+ abort: boolean;
47
+ };