snapreq 0.0.3 → 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 +12 -0
- package/package.json +49 -16
- 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/types/errors.d.ts +17 -0
- package/types/response.d.ts +20 -1
- package/types/snap-req.d.ts +71 -1
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ new SnapReq({
|
|
|
63
63
|
headers, // default headers — object or a factory `() => ({...})` for dynamic auth
|
|
64
64
|
retry, // default retry policy (see below)
|
|
65
65
|
throwOnError, // throw SnapReqHttpError on non-2xx (default false)
|
|
66
|
+
timeoutMs, // default request/body timeout in milliseconds; per-request 0 disables it
|
|
66
67
|
credentials, // fetch credentials mode: "omit" | "same-origin" | "include"
|
|
67
68
|
transport, // "auto" (default) | "node" | "fetch" | "xhr" | a transport instance
|
|
68
69
|
|
|
@@ -73,6 +74,17 @@ new SnapReq({
|
|
|
73
74
|
})
|
|
74
75
|
```
|
|
75
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
|
+
|
|
76
88
|
### Retry
|
|
77
89
|
|
|
78
90
|
Retries transient network errors and retryable HTTP statuses (502/503/504 by default). Only applies to buffered requests — never to streamed bodies.
|
package/package.json
CHANGED
|
@@ -1,22 +1,55 @@
|
|
|
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
6
|
"main": "./src/snap-req.js",
|
|
7
7
|
"types": "./types/snap-req.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"./
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"./
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
}
|
|
20
53
|
},
|
|
21
54
|
"files": [
|
|
22
55
|
"src/**",
|
|
@@ -43,12 +76,12 @@
|
|
|
43
76
|
"author": "kasper@diestoeckels.de",
|
|
44
77
|
"license": "ISC",
|
|
45
78
|
"devDependencies": {
|
|
46
|
-
"@eslint/js": "^
|
|
47
|
-
"@types/node": "^
|
|
79
|
+
"@eslint/js": "^10.0.1",
|
|
80
|
+
"@types/node": "^25.9.1",
|
|
48
81
|
"@types/ws": "^8.18.1",
|
|
49
82
|
"eslint": "^10.3.0",
|
|
50
|
-
"eslint-plugin-jsdoc": "^
|
|
51
|
-
"globals": "^
|
|
83
|
+
"eslint-plugin-jsdoc": "^63.0.0",
|
|
84
|
+
"globals": "^17.6.0",
|
|
52
85
|
"release-patch": "^1.0.0",
|
|
53
86
|
"typescript": "^6.0.3",
|
|
54
87
|
"ws": "^8.21.0"
|
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
|
|
package/types/errors.d.ts
CHANGED
|
@@ -62,3 +62,20 @@ export class SnapReqAbortError extends SnapReqError {
|
|
|
62
62
|
/** @param {string} [message] - Human readable description. */
|
|
63
63
|
constructor(message?: string);
|
|
64
64
|
}
|
|
65
|
+
/** Thrown when a request exceeds its configured timeout. */
|
|
66
|
+
export class SnapReqTimeoutError extends SnapReqError {
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} options - Error metadata.
|
|
69
|
+
* @param {string} options.method - HTTP method used for the request.
|
|
70
|
+
* @param {string} options.url - Fully resolved request URL.
|
|
71
|
+
* @param {number} options.timeoutMs - Timeout in milliseconds.
|
|
72
|
+
*/
|
|
73
|
+
constructor({ method, url, timeoutMs }: {
|
|
74
|
+
method: string;
|
|
75
|
+
url: string;
|
|
76
|
+
timeoutMs: number;
|
|
77
|
+
});
|
|
78
|
+
method: string;
|
|
79
|
+
url: string;
|
|
80
|
+
timeoutMs: number;
|
|
81
|
+
}
|
package/types/response.d.ts
CHANGED
|
@@ -15,8 +15,10 @@ export default class SnapReqResponse {
|
|
|
15
15
|
* @param {Uint8Array} [options.bytes] - Fully-read body, when the transport already buffered it.
|
|
16
16
|
* @param {AsyncIterable<Uint8Array>} [options.stream] - Streamed body, when the transport supports streaming.
|
|
17
17
|
* @param {import("node:stream").Readable} [options.nodeStream] - Raw Node stream, when available, for advanced consumers.
|
|
18
|
+
* @param {() => void} [options.onBodyDone] - Callback fired when body reading finishes or fails.
|
|
19
|
+
* @param {(error: unknown) => unknown} [options.mapBodyError] - Maps body read errors before rethrowing.
|
|
18
20
|
*/
|
|
19
|
-
constructor({ url, method, status, statusText, headers, bytes, stream, nodeStream }: {
|
|
21
|
+
constructor({ url, method, status, statusText, headers, bytes, stream, nodeStream, onBodyDone, mapBodyError }: {
|
|
20
22
|
url: string;
|
|
21
23
|
method: string;
|
|
22
24
|
status: number;
|
|
@@ -25,6 +27,8 @@ export default class SnapReqResponse {
|
|
|
25
27
|
bytes?: Uint8Array;
|
|
26
28
|
stream?: AsyncIterable<Uint8Array>;
|
|
27
29
|
nodeStream?: import("node:stream").Readable;
|
|
30
|
+
onBodyDone?: () => void;
|
|
31
|
+
mapBodyError?: (error: unknown) => unknown;
|
|
28
32
|
});
|
|
29
33
|
url: string;
|
|
30
34
|
method: string;
|
|
@@ -38,6 +42,9 @@ export default class SnapReqResponse {
|
|
|
38
42
|
/** @type {import("node:stream").Readable | undefined} */
|
|
39
43
|
nodeStream: import("node:stream").Readable | undefined;
|
|
40
44
|
_streamConsumed: boolean;
|
|
45
|
+
_bodyDone: boolean;
|
|
46
|
+
_onBodyDone: () => void;
|
|
47
|
+
_mapBodyError: (error: unknown) => unknown;
|
|
41
48
|
/** @returns {boolean} - Whether the status is in the 2xx range. */
|
|
42
49
|
get ok(): boolean;
|
|
43
50
|
/**
|
|
@@ -70,5 +77,17 @@ export default class SnapReqResponse {
|
|
|
70
77
|
* @returns {Promise<any>} - The parsed JSON body.
|
|
71
78
|
*/
|
|
72
79
|
json(): Promise<any>;
|
|
80
|
+
/**
|
|
81
|
+
* @param {AsyncIterable<Uint8Array>} source - Source response stream.
|
|
82
|
+
* @returns {AsyncIterable<Uint8Array>} - Stream with cleanup/error mapping.
|
|
83
|
+
*/
|
|
84
|
+
_wrappedStream(source: AsyncIterable<Uint8Array>): AsyncIterable<Uint8Array>;
|
|
85
|
+
/**
|
|
86
|
+
* @param {unknown} error - Body read error.
|
|
87
|
+
* @returns {unknown} - Error to rethrow.
|
|
88
|
+
*/
|
|
89
|
+
_mappedBodyError(error: unknown): unknown;
|
|
90
|
+
/** @returns {void} */
|
|
91
|
+
_finishBody(): void;
|
|
73
92
|
}
|
|
74
93
|
import SnapReqHeaders from "./headers.js";
|
package/types/snap-req.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @property {import("./request.js").NormalizedBody} body - Normalized request body.
|
|
10
10
|
* @property {CompressionEncoding} bodyCompression - Request body compression.
|
|
11
11
|
* @property {AbortSignal} [signal] - Abort signal.
|
|
12
|
+
* @property {number} [timeoutMs] - Request timeout in milliseconds.
|
|
12
13
|
* @property {string} [credentials] - Fetch credentials mode ("omit" | "same-origin" | "include").
|
|
13
14
|
*/
|
|
14
15
|
/**
|
|
@@ -21,10 +22,18 @@
|
|
|
21
22
|
* @property {any} [body] - Request body: string, object (JSON), Uint8Array/ArrayBuffer, or a stream/async-iterable.
|
|
22
23
|
* @property {CompressionEncoding} [bodyCompression] - Compress the request body (Node transport only).
|
|
23
24
|
* @property {AbortSignal} [signal] - Abort signal for the request.
|
|
25
|
+
* @property {number} [timeoutMs] - Request timeout in milliseconds. Set to `0` to disable a client default.
|
|
24
26
|
* @property {string} [credentials] - Fetch credentials mode.
|
|
25
27
|
* @property {boolean | import("./retry.js").RetryOptions} [retry] - Retry transient failures.
|
|
26
28
|
* @property {boolean} [throwOnError] - Throw `SnapReqHttpError` on non-2xx responses.
|
|
27
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} RequestTimeout
|
|
32
|
+
* @property {AbortSignal | undefined} signal - Signal to use for the request.
|
|
33
|
+
* @property {() => void} clear - Clears timeout resources.
|
|
34
|
+
* @property {(response: import("./response.js").default, request: NormalizedRequest) => import("./response.js").default} response - Attaches timeout handling to a response.
|
|
35
|
+
* @property {(error: unknown, request: NormalizedRequest) => unknown} error - Maps a thrown error.
|
|
36
|
+
*/
|
|
28
37
|
/**
|
|
29
38
|
* A cross-platform HTTP client with one API across Node, web, Expo and React
|
|
30
39
|
* Native. The right transport is chosen at runtime; features a platform cannot
|
|
@@ -41,10 +50,11 @@ export default class SnapReq {
|
|
|
41
50
|
* @param {Record<string, string | number> | (() => Record<string, string | number>)} [config.headers] - Default headers (object or factory).
|
|
42
51
|
* @param {boolean | import("./retry.js").RetryOptions} [config.retry] - Default retry policy.
|
|
43
52
|
* @param {boolean} [config.throwOnError] - Throw `SnapReqHttpError` on non-2xx responses by default. Defaults to false.
|
|
53
|
+
* @param {number} [config.timeoutMs] - Default request timeout in milliseconds. Set per-request `timeoutMs: 0` to disable.
|
|
44
54
|
* @param {string} [config.credentials] - Default fetch credentials mode.
|
|
45
55
|
* @param {import("./transports/select.js").TransportName | import("./transports/select.js").Transport} [config.transport] - Transport preference or instance. Defaults to "auto".
|
|
46
56
|
*/
|
|
47
|
-
constructor({ baseUrl, socketPath, tls, keepAlive, headers, retry, throwOnError, credentials, transport }?: {
|
|
57
|
+
constructor({ baseUrl, socketPath, tls, keepAlive, headers, retry, throwOnError, timeoutMs, credentials, transport }?: {
|
|
48
58
|
baseUrl?: string;
|
|
49
59
|
socketPath?: string;
|
|
50
60
|
tls?: {
|
|
@@ -57,6 +67,7 @@ export default class SnapReq {
|
|
|
57
67
|
headers?: Record<string, string | number> | (() => Record<string, string | number>);
|
|
58
68
|
retry?: boolean | import("./retry.js").RetryOptions;
|
|
59
69
|
throwOnError?: boolean;
|
|
70
|
+
timeoutMs?: number;
|
|
60
71
|
credentials?: string;
|
|
61
72
|
transport?: import("./transports/select.js").TransportName | import("./transports/select.js").Transport;
|
|
62
73
|
});
|
|
@@ -64,6 +75,7 @@ export default class SnapReq {
|
|
|
64
75
|
defaultHeaders: Record<string, string | number> | (() => Record<string, string | number>);
|
|
65
76
|
defaultRetry: boolean | import("./retry.js").RetryOptions;
|
|
66
77
|
throwOnError: boolean;
|
|
78
|
+
timeoutMs: number;
|
|
67
79
|
credentials: string;
|
|
68
80
|
_transportPreference: import("./transports/select.js").TransportName | import("./transports/select.js").Transport;
|
|
69
81
|
_nodeConfig: {
|
|
@@ -91,6 +103,32 @@ export default class SnapReq {
|
|
|
91
103
|
* @returns {NormalizedRequest} - The normalized request.
|
|
92
104
|
*/
|
|
93
105
|
_normalize(options: RequestOptions): NormalizedRequest;
|
|
106
|
+
/**
|
|
107
|
+
* @param {RequestOptions} options - Request options.
|
|
108
|
+
* @returns {RequestTimeout} - Timeout handling for one request attempt.
|
|
109
|
+
*/
|
|
110
|
+
_requestTimeout(options: RequestOptions): RequestTimeout;
|
|
111
|
+
/**
|
|
112
|
+
* @param {AbortSignal | undefined} callerSignal - Caller-supplied signal.
|
|
113
|
+
* @param {AbortSignal} timeoutSignal - Timeout signal.
|
|
114
|
+
* @returns {{signal: AbortSignal, clear: () => void}} - Signal that aborts when either source aborts.
|
|
115
|
+
*/
|
|
116
|
+
_composeSignal(callerSignal: AbortSignal | undefined, timeoutSignal: AbortSignal): {
|
|
117
|
+
signal: AbortSignal;
|
|
118
|
+
clear: () => void;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* @param {(() => void) | undefined} existing - Existing body-done callback.
|
|
122
|
+
* @param {() => void} next - Callback to add.
|
|
123
|
+
* @returns {() => void} - Combined callback.
|
|
124
|
+
*/
|
|
125
|
+
_chainBodyDone(existing: (() => void) | undefined, next: () => void): () => void;
|
|
126
|
+
/**
|
|
127
|
+
* @param {((error: unknown) => unknown) | undefined} existing - Existing error mapper.
|
|
128
|
+
* @param {(error: unknown) => unknown} next - Mapper to add.
|
|
129
|
+
* @returns {(error: unknown) => unknown} - Combined mapper.
|
|
130
|
+
*/
|
|
131
|
+
_chainBodyError(existing: ((error: unknown) => unknown) | undefined, next: (error: unknown) => unknown): (error: unknown) => unknown;
|
|
94
132
|
/**
|
|
95
133
|
* Performs a request and buffers nothing eagerly — read the body via the
|
|
96
134
|
* returned response (`json()`, `text()`, `bytes()`). Retries transient
|
|
@@ -107,6 +145,12 @@ export default class SnapReq {
|
|
|
107
145
|
* @returns {Promise<import("./response.js").default>} - The streaming response.
|
|
108
146
|
*/
|
|
109
147
|
requestStream(options: RequestOptions): Promise<import("./response.js").default>;
|
|
148
|
+
/**
|
|
149
|
+
* @param {RequestOptions} options - Request options.
|
|
150
|
+
* @param {(request: NormalizedRequest) => Promise<import("./response.js").default>} performRequest - Transport request runner.
|
|
151
|
+
* @returns {Promise<import("./response.js").default>} - Response with timeout handling attached.
|
|
152
|
+
*/
|
|
153
|
+
_requestWithTimeout(options: RequestOptions, performRequest: (request: NormalizedRequest) => Promise<import("./response.js").default>): Promise<import("./response.js").default>;
|
|
110
154
|
/**
|
|
111
155
|
* @param {string} path - Request path or absolute URL.
|
|
112
156
|
* @param {RequestOptions} [options] - Request options.
|
|
@@ -178,6 +222,10 @@ export type NormalizedRequest = {
|
|
|
178
222
|
* - Abort signal.
|
|
179
223
|
*/
|
|
180
224
|
signal?: AbortSignal;
|
|
225
|
+
/**
|
|
226
|
+
* - Request timeout in milliseconds.
|
|
227
|
+
*/
|
|
228
|
+
timeoutMs?: number;
|
|
181
229
|
/**
|
|
182
230
|
* - Fetch credentials mode ("omit" | "same-origin" | "include").
|
|
183
231
|
*/
|
|
@@ -216,6 +264,10 @@ export type RequestOptions = {
|
|
|
216
264
|
* - Abort signal for the request.
|
|
217
265
|
*/
|
|
218
266
|
signal?: AbortSignal;
|
|
267
|
+
/**
|
|
268
|
+
* - Request timeout in milliseconds. Set to `0` to disable a client default.
|
|
269
|
+
*/
|
|
270
|
+
timeoutMs?: number;
|
|
219
271
|
/**
|
|
220
272
|
* - Fetch credentials mode.
|
|
221
273
|
*/
|
|
@@ -229,5 +281,23 @@ export type RequestOptions = {
|
|
|
229
281
|
*/
|
|
230
282
|
throwOnError?: boolean;
|
|
231
283
|
};
|
|
284
|
+
export type RequestTimeout = {
|
|
285
|
+
/**
|
|
286
|
+
* - Signal to use for the request.
|
|
287
|
+
*/
|
|
288
|
+
signal: AbortSignal | undefined;
|
|
289
|
+
/**
|
|
290
|
+
* - Clears timeout resources.
|
|
291
|
+
*/
|
|
292
|
+
clear: () => void;
|
|
293
|
+
/**
|
|
294
|
+
* - Attaches timeout handling to a response.
|
|
295
|
+
*/
|
|
296
|
+
response: (response: import("./response.js").default, request: NormalizedRequest) => import("./response.js").default;
|
|
297
|
+
/**
|
|
298
|
+
* - Maps a thrown error.
|
|
299
|
+
*/
|
|
300
|
+
error: (error: unknown, request: NormalizedRequest) => unknown;
|
|
301
|
+
};
|
|
232
302
|
import { SnapReqHttpError } from "./errors.js";
|
|
233
303
|
import SnapReqHeaders from "./headers.js";
|