accessio 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -21
- package/cjs/accessio.cjs +68 -84
- package/cjs/accessio.cjs.map +1 -1
- package/cjs/core/accessioError.cjs +49 -3
- package/cjs/core/accessioError.cjs.map +1 -1
- package/cjs/core/buildURL.cjs +10 -4
- package/cjs/core/buildURL.cjs.map +1 -1
- package/cjs/core/fetchAdapter.cjs +134 -111
- package/cjs/core/fetchAdapter.cjs.map +1 -1
- package/cjs/core/request.cjs +97 -24
- package/cjs/core/request.cjs.map +1 -1
- package/cjs/core/retry.cjs +25 -0
- package/cjs/core/retry.cjs.map +1 -1
- package/cjs/helpers/debug.cjs +7 -1
- package/cjs/helpers/debug.cjs.map +1 -1
- package/cjs/helpers/flattenHeaders.cjs +37 -0
- package/cjs/helpers/flattenHeaders.cjs.map +1 -1
- package/cjs/helpers/rateLimiter.cjs +11 -22
- package/cjs/helpers/rateLimiter.cjs.map +1 -1
- package/cjs/helpers/settle.cjs +1 -1
- package/cjs/helpers/settle.cjs.map +1 -1
- package/cjs/helpers/transformData.cjs +2 -2
- package/cjs/helpers/transformData.cjs.map +1 -1
- package/cjs/interceptors/interceptorManager.cjs +25 -18
- package/cjs/interceptors/interceptorManager.cjs.map +1 -1
- package/index.d.ts +89 -21
- package/package.json +2 -2
- package/src/accessio.ts +104 -98
- package/src/core/accessioError.ts +50 -1
- package/src/core/buildURL.ts +14 -4
- package/src/core/fetchAdapter.ts +166 -130
- package/src/core/request.ts +115 -28
- package/src/core/retry.ts +19 -1
- package/src/helpers/debug.ts +7 -2
- package/src/helpers/flattenHeaders.ts +30 -0
- package/src/helpers/rateLimiter.ts +11 -24
- package/src/helpers/settle.ts +1 -1
- package/src/helpers/transformData.ts +2 -1
- package/src/interceptors/interceptorManager.ts +26 -19
- package/src/types.ts +1 -0
package/src/core/fetchAdapter.ts
CHANGED
|
@@ -18,12 +18,20 @@ async function readResponseData(
|
|
|
18
18
|
default: {
|
|
19
19
|
const contentType = fetchResponse.headers.get('content-type') || '';
|
|
20
20
|
if (contentType.includes('application/json')) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
const text = await fetchResponse.text();
|
|
22
|
+
if (!text) return '';
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
throw new AccessioError(
|
|
27
|
+
`Failed to parse JSON response: ${(err as Error).message}. Raw body: ${
|
|
28
|
+
text.length > 500 ? `${text.slice(0, 500)}…` : text
|
|
29
|
+
}`,
|
|
30
|
+
AccessioError.ERR_BAD_RESPONSE,
|
|
31
|
+
config,
|
|
32
|
+
fetchResponse,
|
|
33
|
+
null,
|
|
34
|
+
);
|
|
27
35
|
}
|
|
28
36
|
}
|
|
29
37
|
return await fetchResponse.text();
|
|
@@ -31,17 +39,27 @@ async function readResponseData(
|
|
|
31
39
|
}
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
function assertValidURL(fullURL: string, config: AccessioRequestConfig): void {
|
|
43
|
+
if (!fullURL || !/^[a-z][a-z\d+\-.]*:/i.test(fullURL)) return;
|
|
44
|
+
try {
|
|
45
|
+
new URL(fullURL);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new AccessioError(
|
|
48
|
+
`Invalid URL: ${fullURL}`,
|
|
49
|
+
AccessioError.ERR_INVALID_URL,
|
|
50
|
+
config,
|
|
51
|
+
null,
|
|
52
|
+
null,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AbortWiring {
|
|
58
|
+
isTimedOut: () => boolean;
|
|
59
|
+
cleanup: () => void;
|
|
60
|
+
}
|
|
44
61
|
|
|
62
|
+
function setupAbort(config: AccessioRequestConfig, fetchOptions: RequestInit): AbortWiring {
|
|
45
63
|
if (
|
|
46
64
|
config.timeout !== undefined &&
|
|
47
65
|
(typeof config.timeout !== 'number' || isNaN(config.timeout) || config.timeout < 0)
|
|
@@ -56,84 +74,141 @@ export default async function fetchAdapter(
|
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
const timeoutValue = Number(config.timeout);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
new AccessioError(
|
|
66
|
-
`timeout of ${timeoutValue}ms exceeded`,
|
|
67
|
-
AccessioError.ETIMEDOUT,
|
|
68
|
-
config,
|
|
69
|
-
null,
|
|
70
|
-
null,
|
|
71
|
-
),
|
|
72
|
-
);
|
|
73
|
-
}, timeoutValue);
|
|
77
|
+
const hasTimeout = !isNaN(timeoutValue) && timeoutValue > 0;
|
|
78
|
+
|
|
79
|
+
if (!hasTimeout) {
|
|
80
|
+
if (config.signal) fetchOptions.signal = config.signal;
|
|
81
|
+
return { isTimedOut: () => false, cleanup: () => {} };
|
|
82
|
+
}
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
const abortController = new AbortController();
|
|
86
|
+
const timeoutId = setTimeout(() => {
|
|
87
|
+
timedOut = true;
|
|
88
|
+
abortController.abort(
|
|
89
|
+
new AccessioError(
|
|
90
|
+
`timeout of ${timeoutValue}ms exceeded`,
|
|
91
|
+
AccessioError.ETIMEDOUT,
|
|
92
|
+
config,
|
|
93
|
+
null,
|
|
94
|
+
null,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}, timeoutValue);
|
|
98
|
+
|
|
99
|
+
let onUserAbort: (() => void) | null = null;
|
|
100
|
+
|
|
101
|
+
if (config.signal) {
|
|
102
|
+
if (typeof AbortSignal.any === 'function') {
|
|
103
|
+
fetchOptions.signal = AbortSignal.any([config.signal, abortController.signal]);
|
|
104
|
+
} else {
|
|
105
|
+
if (config.signal.aborted) {
|
|
106
|
+
abortController.abort(config.signal.reason);
|
|
78
107
|
} else {
|
|
79
|
-
|
|
80
|
-
abortController.abort(config.signal
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!isTimedOut && abortController) {
|
|
84
|
-
abortController.abort(config.signal!.reason);
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
config.signal.addEventListener('abort', onUserAbort, {
|
|
88
|
-
once: true,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
fetchOptions.signal = abortController.signal;
|
|
108
|
+
onUserAbort = () => {
|
|
109
|
+
if (!timedOut) abortController.abort(config.signal!.reason);
|
|
110
|
+
};
|
|
111
|
+
config.signal.addEventListener('abort', onUserAbort, { once: true });
|
|
92
112
|
}
|
|
93
|
-
} else {
|
|
94
113
|
fetchOptions.signal = abortController.signal;
|
|
95
114
|
}
|
|
96
|
-
} else
|
|
97
|
-
fetchOptions.signal =
|
|
115
|
+
} else {
|
|
116
|
+
fetchOptions.signal = abortController.signal;
|
|
98
117
|
}
|
|
99
118
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
return {
|
|
120
|
+
isTimedOut: () => timedOut,
|
|
121
|
+
cleanup: () => {
|
|
122
|
+
clearTimeout(timeoutId);
|
|
123
|
+
if (onUserAbort && config.signal) {
|
|
124
|
+
config.signal.removeEventListener('abort', onUserAbort);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function wrapDownloadProgress(fetchResponse: Response, config: AccessioRequestConfig): Response {
|
|
131
|
+
if (!config.onDownloadProgress || !fetchResponse.body || config.responseType === 'stream') {
|
|
132
|
+
return fetchResponse;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const contentLength = fetchResponse.headers.get('content-length');
|
|
136
|
+
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
|
137
|
+
let loaded = 0;
|
|
138
|
+
|
|
139
|
+
const reader = fetchResponse.body.getReader();
|
|
140
|
+
const stream = new ReadableStream({
|
|
141
|
+
async start(controller) {
|
|
142
|
+
try {
|
|
143
|
+
while (true) {
|
|
144
|
+
const { done, value } = await reader.read();
|
|
145
|
+
if (done) {
|
|
146
|
+
controller.close();
|
|
147
|
+
break;
|
|
125
148
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
149
|
+
loaded += value.byteLength;
|
|
150
|
+
config.onDownloadProgress!({ loaded, total });
|
|
151
|
+
controller.enqueue(value);
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
controller.error(e);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
135
158
|
|
|
136
|
-
|
|
159
|
+
return new Response(stream, {
|
|
160
|
+
headers: fetchResponse.headers,
|
|
161
|
+
status: fetchResponse.status,
|
|
162
|
+
statusText: fetchResponse.statusText,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function classifyFetchError(
|
|
167
|
+
error: unknown,
|
|
168
|
+
config: AccessioRequestConfig,
|
|
169
|
+
isTimedOut: boolean,
|
|
170
|
+
): AccessioError {
|
|
171
|
+
if (error instanceof AccessioError) return error;
|
|
172
|
+
|
|
173
|
+
if (isTimedOut) {
|
|
174
|
+
return new AccessioError(
|
|
175
|
+
`timeout of ${config.timeout}ms exceeded`,
|
|
176
|
+
AccessioError.ETIMEDOUT,
|
|
177
|
+
config,
|
|
178
|
+
null,
|
|
179
|
+
null,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isAbort =
|
|
184
|
+
(error instanceof Error && error.name === 'AbortError') || !!config.signal?.aborted;
|
|
185
|
+
if (isAbort) {
|
|
186
|
+
return new AccessioError('Request aborted', AccessioError.ERR_CANCELED, config, null, null);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return AccessioError.from(
|
|
190
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
191
|
+
AccessioError.ERR_NETWORK,
|
|
192
|
+
config,
|
|
193
|
+
null,
|
|
194
|
+
null,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default async function fetchAdapter(
|
|
199
|
+
config: AccessioRequestConfig,
|
|
200
|
+
fullURL: string,
|
|
201
|
+
fetchOptions: RequestInit,
|
|
202
|
+
requestStartTime: number,
|
|
203
|
+
): Promise<AccessioResponse> {
|
|
204
|
+
assertValidURL(fullURL, config);
|
|
205
|
+
|
|
206
|
+
const abort = setupAbort(config, fetchOptions);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const fetchImpl = config.fetch || fetch;
|
|
210
|
+
const rawResponse = await fetchImpl(fullURL, fetchOptions);
|
|
211
|
+
const fetchResponse = wrapDownloadProgress(rawResponse, config);
|
|
137
212
|
|
|
138
213
|
const contentLength = fetchResponse.headers.get('content-length');
|
|
139
214
|
if (
|
|
@@ -150,6 +225,7 @@ export default async function fetchAdapter(
|
|
|
150
225
|
);
|
|
151
226
|
}
|
|
152
227
|
|
|
228
|
+
let responseData: unknown;
|
|
153
229
|
try {
|
|
154
230
|
responseData = await readResponseData(fetchResponse, config);
|
|
155
231
|
if (config.schema) {
|
|
@@ -160,6 +236,7 @@ export default async function fetchAdapter(
|
|
|
160
236
|
}
|
|
161
237
|
}
|
|
162
238
|
} catch (readError) {
|
|
239
|
+
if (readError instanceof AccessioError) throw readError;
|
|
163
240
|
throw AccessioError.from(
|
|
164
241
|
readError as Error,
|
|
165
242
|
AccessioError.ERR_BAD_RESPONSE,
|
|
@@ -169,59 +246,18 @@ export default async function fetchAdapter(
|
|
|
169
246
|
);
|
|
170
247
|
}
|
|
171
248
|
|
|
172
|
-
const responseHeaders = parseHeaders(fetchResponse.headers);
|
|
173
|
-
|
|
174
249
|
return {
|
|
175
250
|
data: responseData,
|
|
176
251
|
status: fetchResponse.status,
|
|
177
252
|
statusText: fetchResponse.statusText,
|
|
178
|
-
headers:
|
|
179
|
-
config
|
|
253
|
+
headers: parseHeaders(fetchResponse.headers),
|
|
254
|
+
config,
|
|
180
255
|
request: fetchResponse,
|
|
181
256
|
duration: Date.now() - requestStartTime,
|
|
182
257
|
};
|
|
183
258
|
} catch (error) {
|
|
184
|
-
|
|
185
|
-
throw error;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
189
|
-
if (isTimedOut) {
|
|
190
|
-
throw new AccessioError(
|
|
191
|
-
`timeout of ${config.timeout}ms exceeded`,
|
|
192
|
-
AccessioError.ETIMEDOUT,
|
|
193
|
-
config,
|
|
194
|
-
null,
|
|
195
|
-
null,
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
throw new AccessioError('Request aborted', AccessioError.ERR_CANCELED, config, null, null);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
error instanceof TypeError &&
|
|
203
|
-
(error.message.toLowerCase().includes('url') || error.message.toLowerCase().includes('fetch'))
|
|
204
|
-
) {
|
|
205
|
-
throw new AccessioError(
|
|
206
|
-
`Invalid URL: ${fullURL}`,
|
|
207
|
-
AccessioError.ERR_INVALID_URL,
|
|
208
|
-
config,
|
|
209
|
-
null,
|
|
210
|
-
null,
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
throw AccessioError.from(
|
|
215
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
216
|
-
AccessioError.ERR_NETWORK,
|
|
217
|
-
config,
|
|
218
|
-
null,
|
|
219
|
-
null,
|
|
220
|
-
);
|
|
259
|
+
throw classifyFetchError(error, config, abort.isTimedOut());
|
|
221
260
|
} finally {
|
|
222
|
-
|
|
223
|
-
if (config.signal && onUserAbort) {
|
|
224
|
-
config.signal.removeEventListener('abort', onUserAbort);
|
|
225
|
-
}
|
|
261
|
+
abort.cleanup();
|
|
226
262
|
}
|
|
227
263
|
}
|
package/src/core/request.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import buildURL from './buildURL';
|
|
2
|
-
import AccessioError from './accessioError';
|
|
2
|
+
import AccessioError, { redactConfig } from './accessioError';
|
|
3
|
+
import { ERR_BAD_OPTION } from '../constants/errorCodes';
|
|
3
4
|
import transformData from '../helpers/transformData';
|
|
4
5
|
import settle from '../helpers/settle';
|
|
5
6
|
import { flattenHeaders, removeContentType, buildFetchHeaders } from '../helpers/flattenHeaders';
|
|
@@ -9,6 +10,31 @@ import { defaultMemoryCache } from '../helpers/memoryCache';
|
|
|
9
10
|
import type { AccessioRequestConfig, AccessioResponse, TransformFunction } from '../types';
|
|
10
11
|
|
|
11
12
|
type HeadersConfig = Record<string, Record<string, string | string[]>>;
|
|
13
|
+
type FlatHeaders = Record<string, string | string[]>;
|
|
14
|
+
|
|
15
|
+
function lookupHeader(headers: FlatHeaders, name: string): string {
|
|
16
|
+
const target = name.toLowerCase();
|
|
17
|
+
for (const k of Object.keys(headers)) {
|
|
18
|
+
if (k.toLowerCase() === target) {
|
|
19
|
+
const v = headers[k];
|
|
20
|
+
return Array.isArray(v) ? v.join(',') : (v ?? '');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildCacheKey(
|
|
27
|
+
config: AccessioRequestConfig,
|
|
28
|
+
fullURL: string,
|
|
29
|
+
flatHeaders: FlatHeaders,
|
|
30
|
+
): string {
|
|
31
|
+
const method = (config.method || 'GET').toUpperCase();
|
|
32
|
+
const auth = lookupHeader(flatHeaders, 'authorization');
|
|
33
|
+
const accept = lookupHeader(flatHeaders, 'accept');
|
|
34
|
+
const withCreds = config.withCredentials ? '1' : '0';
|
|
35
|
+
const respType = config.responseType || 'json';
|
|
36
|
+
return `${method}:${fullURL}|a=${auth}|x=${accept}|c=${withCreds}|t=${respType}`;
|
|
37
|
+
}
|
|
12
38
|
|
|
13
39
|
function buildTransformArray(
|
|
14
40
|
transform: TransformFunction | TransformFunction[] | undefined,
|
|
@@ -18,7 +44,45 @@ function buildTransformArray(
|
|
|
18
44
|
return [transform];
|
|
19
45
|
}
|
|
20
46
|
|
|
47
|
+
const DEFAULT_ALLOWED_PROTOCOLS = ['http:', 'https:'];
|
|
48
|
+
|
|
49
|
+
function assertAllowedProtocol(fullURL: string, config: AccessioRequestConfig): void {
|
|
50
|
+
if (config.allowedProtocols === null) return;
|
|
51
|
+
const allowed = config.allowedProtocols ?? DEFAULT_ALLOWED_PROTOCOLS;
|
|
52
|
+
|
|
53
|
+
let scheme: string | null = null;
|
|
54
|
+
const match = /^([a-z][a-z\d+\-.]*):/i.exec(fullURL);
|
|
55
|
+
if (match) scheme = `${match[1].toLowerCase()}:`;
|
|
56
|
+
if (!scheme) return;
|
|
57
|
+
|
|
58
|
+
if (!allowed.includes(scheme)) {
|
|
59
|
+
throw new AccessioError(
|
|
60
|
+
`URL protocol "${scheme}" is not allowed. Allowed: ${allowed.join(', ')}. ` +
|
|
61
|
+
'Set config.allowedProtocols to extend, or null to disable the check.',
|
|
62
|
+
ERR_BAD_OPTION,
|
|
63
|
+
config,
|
|
64
|
+
null,
|
|
65
|
+
null,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
21
70
|
const activeRequests = new Map<string, Promise<AccessioResponse>>();
|
|
71
|
+
const MAX_ACTIVE_REQUESTS = 1024;
|
|
72
|
+
|
|
73
|
+
export function __activeRequestsSize(): number {
|
|
74
|
+
return activeRequests.size;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function trackActiveRequest(key: string, promise: Promise<AccessioResponse>): void {
|
|
78
|
+
activeRequests.set(key, promise);
|
|
79
|
+
// Evict the oldest entry if we've grown past the cap. Map preserves insertion order.
|
|
80
|
+
while (activeRequests.size > MAX_ACTIVE_REQUESTS) {
|
|
81
|
+
const oldest = activeRequests.keys().next().value;
|
|
82
|
+
if (oldest === undefined || oldest === key) break;
|
|
83
|
+
activeRequests.delete(oldest);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
22
86
|
|
|
23
87
|
export default async function dispatchRequest(
|
|
24
88
|
config: AccessioRequestConfig,
|
|
@@ -32,32 +96,42 @@ export default async function dispatchRequest(
|
|
|
32
96
|
config.paramsSerializer,
|
|
33
97
|
);
|
|
34
98
|
|
|
99
|
+
assertAllowedProtocol(fullURL, config);
|
|
100
|
+
|
|
35
101
|
if (config.hooks?.onBeforeRequest) {
|
|
36
102
|
await config.hooks.onBeforeRequest(config);
|
|
37
103
|
}
|
|
38
104
|
|
|
105
|
+
const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
|
|
106
|
+
setBasicAuth(config, flatHeaders);
|
|
107
|
+
|
|
39
108
|
const isGet = (config.method || 'GET').toUpperCase() === 'GET';
|
|
40
|
-
const cacheKey = isGet ?
|
|
109
|
+
const cacheKey = isGet ? buildCacheKey(config, fullURL, flatHeaders) : '';
|
|
41
110
|
|
|
42
111
|
if (isGet && config.cache) {
|
|
43
112
|
const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
|
|
44
113
|
const cached = await cacheProvider.get(cacheKey);
|
|
45
114
|
if (cached) {
|
|
115
|
+
const cachedView: AccessioResponse = {
|
|
116
|
+
...cached,
|
|
117
|
+
config: redactConfig(config) as typeof cached.config,
|
|
118
|
+
};
|
|
46
119
|
if (config.hooks?.onRequestResponse) {
|
|
47
|
-
await config.hooks.onRequestResponse(
|
|
120
|
+
await config.hooks.onRequestResponse(cachedView);
|
|
48
121
|
}
|
|
49
|
-
return
|
|
122
|
+
return cachedView;
|
|
50
123
|
}
|
|
51
124
|
}
|
|
52
125
|
|
|
53
126
|
if (isGet && config.dedupe) {
|
|
54
|
-
|
|
55
|
-
|
|
127
|
+
const inflight = activeRequests.get(cacheKey);
|
|
128
|
+
if (inflight) {
|
|
129
|
+
const shared = await inflight;
|
|
130
|
+
return finalizeResponse(shared, config);
|
|
56
131
|
}
|
|
57
132
|
}
|
|
58
133
|
|
|
59
|
-
const performRequest = async () => {
|
|
60
|
-
const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
|
|
134
|
+
const performRequest = async (): Promise<AccessioResponse> => {
|
|
61
135
|
const requestTransforms = buildTransformArray(config.transformRequest);
|
|
62
136
|
const requestData = await transformData(requestTransforms, config.data, flatHeaders, config);
|
|
63
137
|
|
|
@@ -69,8 +143,6 @@ export default async function dispatchRequest(
|
|
|
69
143
|
removeContentType(flatHeaders);
|
|
70
144
|
}
|
|
71
145
|
|
|
72
|
-
setBasicAuth(config, flatHeaders);
|
|
73
|
-
|
|
74
146
|
const fetchOptions: RequestInit = {
|
|
75
147
|
method: (config.method || 'GET').toUpperCase(),
|
|
76
148
|
headers: buildFetchHeaders(flatHeaders),
|
|
@@ -97,50 +169,55 @@ export default async function dispatchRequest(
|
|
|
97
169
|
}
|
|
98
170
|
|
|
99
171
|
const requestStartTime = Date.now();
|
|
100
|
-
|
|
101
172
|
const response = await fetchAdapter(config, fullURL, fetchOptions, requestStartTime);
|
|
102
173
|
|
|
103
174
|
const responseTransforms = buildTransformArray(config.transformResponse);
|
|
104
|
-
|
|
105
175
|
response.data = await transformData(
|
|
106
176
|
responseTransforms,
|
|
107
177
|
response.data,
|
|
108
178
|
response.headers,
|
|
109
179
|
config,
|
|
180
|
+
'response',
|
|
110
181
|
);
|
|
111
182
|
|
|
112
|
-
return
|
|
113
|
-
settle(
|
|
114
|
-
resolve as (value: AccessioResponse) => void,
|
|
115
|
-
reject as (reason: AccessioError) => void,
|
|
116
|
-
response,
|
|
117
|
-
config,
|
|
118
|
-
);
|
|
119
|
-
});
|
|
183
|
+
return response;
|
|
120
184
|
};
|
|
121
185
|
|
|
122
186
|
const promise = performRequest();
|
|
123
187
|
|
|
124
188
|
if (isGet && config.dedupe) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
activeRequests.
|
|
128
|
-
|
|
189
|
+
trackActiveRequest(cacheKey, promise);
|
|
190
|
+
const cleanup = () => {
|
|
191
|
+
if (activeRequests.get(cacheKey) === promise) {
|
|
192
|
+
activeRequests.delete(cacheKey);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
promise.then(cleanup, cleanup);
|
|
129
196
|
}
|
|
130
197
|
|
|
131
198
|
try {
|
|
132
|
-
const
|
|
199
|
+
const shared = await promise;
|
|
200
|
+
const response = finalizeResponse(shared, config);
|
|
133
201
|
|
|
134
202
|
if (isGet && config.cache) {
|
|
135
203
|
const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
|
|
136
|
-
await cacheProvider.set(cacheKey,
|
|
204
|
+
await cacheProvider.set(cacheKey, shared, config.cacheTTL);
|
|
137
205
|
}
|
|
138
206
|
|
|
207
|
+
const settled = await new Promise<AccessioResponse>((resolve, reject) => {
|
|
208
|
+
settle(
|
|
209
|
+
resolve as (value: AccessioResponse) => void,
|
|
210
|
+
reject as (reason: AccessioError) => void,
|
|
211
|
+
response,
|
|
212
|
+
config,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
139
216
|
if (config.hooks?.onRequestResponse) {
|
|
140
|
-
await config.hooks.onRequestResponse(
|
|
217
|
+
await config.hooks.onRequestResponse(settled);
|
|
141
218
|
}
|
|
142
219
|
|
|
143
|
-
return
|
|
220
|
+
return settled;
|
|
144
221
|
} catch (error) {
|
|
145
222
|
if (config.hooks?.onRequestError && error instanceof AccessioError) {
|
|
146
223
|
await config.hooks.onRequestError(error);
|
|
@@ -148,3 +225,13 @@ export default async function dispatchRequest(
|
|
|
148
225
|
throw error;
|
|
149
226
|
}
|
|
150
227
|
}
|
|
228
|
+
|
|
229
|
+
function finalizeResponse(
|
|
230
|
+
shared: AccessioResponse,
|
|
231
|
+
config: AccessioRequestConfig,
|
|
232
|
+
): AccessioResponse {
|
|
233
|
+
return {
|
|
234
|
+
...shared,
|
|
235
|
+
config: redactConfig(config) as typeof shared.config,
|
|
236
|
+
};
|
|
237
|
+
}
|
package/src/core/retry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ERR_CANCELED, ERR_NETWORK } from '../constants/errorCodes';
|
|
1
|
+
import { ERR_BAD_OPTION, ERR_CANCELED, ERR_NETWORK } from '../constants/errorCodes';
|
|
2
|
+
import AccessioErrorClass from './accessioError';
|
|
2
3
|
import type {
|
|
3
4
|
AccessioRequestConfig,
|
|
4
5
|
AccessioError,
|
|
@@ -6,6 +7,12 @@ import type {
|
|
|
6
7
|
OnRetryFunction,
|
|
7
8
|
} from '../types';
|
|
8
9
|
|
|
10
|
+
function isUnretriableBody(data: unknown): boolean {
|
|
11
|
+
if (data == null) return false;
|
|
12
|
+
if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) return true;
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
function defaultRetryCondition(error: any): boolean {
|
|
10
17
|
if (error.code === ERR_CANCELED) {
|
|
11
18
|
return false;
|
|
@@ -89,6 +96,17 @@ async function retryRequest(
|
|
|
89
96
|
throw error;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
if (isUnretriableBody(config.data)) {
|
|
100
|
+
throw new AccessioErrorClass(
|
|
101
|
+
'Request body is a ReadableStream and cannot be retried after consumption. ' +
|
|
102
|
+
'Buffer the stream upstream or set retry: 0 for this call.',
|
|
103
|
+
ERR_BAD_OPTION,
|
|
104
|
+
config,
|
|
105
|
+
null,
|
|
106
|
+
(error as AccessioError).response ?? null,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
let delay = calculateDelay(attempt, retryDelay);
|
|
93
111
|
|
|
94
112
|
if (config.retryOn429 && (error as any).response?.status === 429) {
|
package/src/helpers/debug.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AccessioRequestConfig, AccessioResponse } from '../types';
|
|
2
|
-
import AccessioError from '../core/accessioError';
|
|
2
|
+
import AccessioError, { redactBody } from '../core/accessioError';
|
|
3
3
|
|
|
4
4
|
function formatBytes(bytes: number): string {
|
|
5
5
|
if (bytes === 0) return '0 B';
|
|
@@ -35,7 +35,12 @@ export function logRequest(config: AccessioRequestConfig, fullUrl: string): void
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
if (config.data && typeof config.data === 'object') {
|
|
38
|
-
|
|
38
|
+
let preview: string;
|
|
39
|
+
try {
|
|
40
|
+
preview = JSON.stringify(redactBody(config.data));
|
|
41
|
+
} catch {
|
|
42
|
+
preview = '[unserializable body]';
|
|
43
|
+
}
|
|
39
44
|
const truncated = preview.length > 200 ? `${preview.substring(0, 200)}...` : preview;
|
|
40
45
|
parts.push(` Body: ${truncated}`);
|
|
41
46
|
}
|
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
import AccessioError from '../core/accessioError';
|
|
2
|
+
import { ERR_BAD_OPTION } from '../constants/errorCodes';
|
|
3
|
+
|
|
4
|
+
const HEADER_FORBIDDEN_CHAR = /[\r\n\0]/;
|
|
5
|
+
|
|
6
|
+
function assertSafeHeader(name: string, value: string | string[]): void {
|
|
7
|
+
if (typeof name !== 'string' || HEADER_FORBIDDEN_CHAR.test(name)) {
|
|
8
|
+
throw new AccessioError(
|
|
9
|
+
`Invalid header name "${String(name)}": CR, LF and NUL are not allowed`,
|
|
10
|
+
ERR_BAD_OPTION,
|
|
11
|
+
null,
|
|
12
|
+
null,
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
const values = Array.isArray(value) ? value : [value];
|
|
17
|
+
for (const v of values) {
|
|
18
|
+
if (typeof v === 'string' && HEADER_FORBIDDEN_CHAR.test(v)) {
|
|
19
|
+
throw new AccessioError(
|
|
20
|
+
`Invalid value for header "${name}": CR, LF and NUL are not allowed`,
|
|
21
|
+
ERR_BAD_OPTION,
|
|
22
|
+
null,
|
|
23
|
+
null,
|
|
24
|
+
null,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
1
30
|
const METHOD_KEYS = new Set<string>([
|
|
2
31
|
'common',
|
|
3
32
|
'delete',
|
|
@@ -47,6 +76,7 @@ export function removeContentType(headers: Record<string, string | string[]>): v
|
|
|
47
76
|
export function buildFetchHeaders(headers: Record<string, string | string[]>): Headers {
|
|
48
77
|
const fetchHeaders = new Headers();
|
|
49
78
|
for (const [key, value] of Object.entries(headers)) {
|
|
79
|
+
assertSafeHeader(key, value);
|
|
50
80
|
if (Array.isArray(value)) {
|
|
51
81
|
for (const v of value) {
|
|
52
82
|
fetchHeaders.append(key, v);
|