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 +28 -3
- package/package.json +58 -8
- package/src/errors.js +17 -0
- package/src/response.js +50 -9
- package/src/retry.js +3 -1
- package/src/snap-req.js +153 -11
- package/src/websocket/websocket-client.js +10 -5
- package/types/capabilities.d.ts +47 -0
- package/types/errors.d.ts +81 -0
- package/types/headers.d.ts +50 -0
- package/types/request.d.ts +35 -0
- package/types/response.d.ts +93 -0
- package/types/retry.d.ts +73 -0
- package/types/snap-req.d.ts +303 -0
- package/types/transports/fetch-transport.d.ts +35 -0
- package/types/transports/node-transport.d.ts +112 -0
- package/types/transports/select.d.ts +38 -0
- package/types/transports/xhr-transport.d.ts +25 -0
- package/types/websocket/websocket-channel.d.ts +92 -0
- package/types/websocket/websocket-client.d.ts +364 -0
- package/types/websocket/websocket-connection.d.ts +94 -0
- package/src/index.js +0 -22
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
|
|
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)
|
|
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.
|
|
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/
|
|
6
|
+
"main": "./src/snap-req.js",
|
|
7
|
+
"types": "./types/snap-req.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
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": "^
|
|
31
|
-
"@types/node": "^
|
|
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": "^
|
|
35
|
-
"globals": "^
|
|
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.
|
|
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
|
|
130
|
-
|
|
131
|
-
const attempt = () => transport.performRequest(
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
+
};
|