accessio 1.4.0 → 1.6.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 +165 -207
- package/cjs/accessio.cjs +30 -13
- package/cjs/accessio.cjs.map +1 -1
- package/cjs/core/accessioError.cjs +28 -0
- package/cjs/core/accessioError.cjs.map +1 -1
- package/cjs/core/buildURL.cjs +11 -8
- package/cjs/core/buildURL.cjs.map +1 -1
- package/cjs/core/fetchAdapter.cjs +4 -0
- package/cjs/core/fetchAdapter.cjs.map +1 -1
- package/cjs/core/mergeConfig.cjs +1 -1
- package/cjs/core/mergeConfig.cjs.map +1 -1
- package/cjs/core/request.cjs +43 -15
- package/cjs/core/request.cjs.map +1 -1
- package/cjs/core/retry.cjs +8 -5
- package/cjs/core/retry.cjs.map +1 -1
- package/cjs/helpers/flattenHeaders.cjs +4 -1
- package/cjs/helpers/flattenHeaders.cjs.map +1 -1
- package/cjs/helpers/memoryCache.cjs +19 -1
- package/cjs/helpers/memoryCache.cjs.map +1 -1
- package/cjs/helpers/rateLimiter.cjs +31 -3
- package/cjs/helpers/rateLimiter.cjs.map +1 -1
- package/cjs/helpers/toFormData.cjs +1 -1
- package/cjs/helpers/toFormData.cjs.map +1 -1
- package/index.d.ts +7 -0
- package/package.json +5 -4
- package/src/accessio.ts +44 -15
- package/src/core/accessioError.ts +33 -2
- package/src/core/buildURL.ts +16 -13
- package/src/core/fetchAdapter.ts +3 -0
- package/src/core/mergeConfig.ts +1 -1
- package/src/core/request.ts +54 -16
- package/src/core/retry.ts +8 -5
- package/src/helpers/flattenHeaders.ts +4 -1
- package/src/helpers/memoryCache.ts +24 -1
- package/src/helpers/rateLimiter.ts +35 -3
- package/src/helpers/toFormData.ts +5 -1
- package/src/types.ts +7 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/helpers/rateLimiter.ts"],"sourcesContent":["import type { RateLimiter, AccessioRequestConfig, AccessioResponse } from '../types';\n\ninterface QueueItem {\n resolve: () => void;\n reject: (reason: Error) => void;\n}\n\nexport function createRateLimiter(\n maxConcurrent: number = Infinity,\n maxQueueSize: number = Infinity,\n): RateLimiter {\n if (maxConcurrent !== Infinity && (!Number.isInteger(maxConcurrent) || maxConcurrent < 1)) {\n throw new RangeError(\n `[Accessio] maxConcurrent must be a positive integer or Infinity, got: ${maxConcurrent}`,\n );\n }\n if (maxQueueSize !== Infinity && (!Number.isInteger(maxQueueSize) || maxQueueSize < 1)) {\n throw new RangeError(\n `[Accessio] maxQueueSize must be a positive integer or Infinity, got: ${maxQueueSize}`,\n );\n }\n let active = 0;\n let destroyed = false;\n const queue: QueueItem[] = [];\n\n function acquire(): Promise<void> {\n if (destroyed) {\n return Promise.reject(new Error('[Accessio] Rate limiter has been destroyed'));\n }\n\n if (active < maxConcurrent) {\n active++;\n return Promise.resolve();\n }\n\n if (queue.length >= maxQueueSize) {\n return Promise.reject(\n new Error(`[Accessio] Rate limiter queue size exceeded maxQueueSize (${maxQueueSize})`),\n );\n }\n\n return new Promise((resolve, reject) => {\n queue.push({
|
|
1
|
+
{"version":3,"sources":["../../src/helpers/rateLimiter.ts"],"sourcesContent":["import type { RateLimiter, AccessioRequestConfig, AccessioResponse } from '../types';\n\ninterface QueueItem {\n resolve: () => void;\n reject: (reason: Error) => void;\n}\n\nexport function createRateLimiter(\n maxConcurrent: number = Infinity,\n maxQueueSize: number = Infinity,\n): RateLimiter {\n if (maxConcurrent !== Infinity && (!Number.isInteger(maxConcurrent) || maxConcurrent < 1)) {\n throw new RangeError(\n `[Accessio] maxConcurrent must be a positive integer or Infinity, got: ${maxConcurrent}`,\n );\n }\n if (maxQueueSize !== Infinity && (!Number.isInteger(maxQueueSize) || maxQueueSize < 1)) {\n throw new RangeError(\n `[Accessio] maxQueueSize must be a positive integer or Infinity, got: ${maxQueueSize}`,\n );\n }\n let active = 0;\n let destroyed = false;\n const queue: QueueItem[] = [];\n\n function acquire(signal?: AbortSignal): Promise<void> {\n if (destroyed) {\n return Promise.reject(new Error('[Accessio] Rate limiter has been destroyed'));\n }\n\n if (signal?.aborted) {\n return Promise.reject(signal.reason || new Error('Request aborted'));\n }\n\n if (active < maxConcurrent) {\n active++;\n return Promise.resolve();\n }\n\n if (queue.length >= maxQueueSize) {\n return Promise.reject(\n new Error(`[Accessio] Rate limiter queue size exceeded maxQueueSize (${maxQueueSize})`),\n );\n }\n\n return new Promise((resolve, reject) => {\n let onAbort: (() => void) | undefined;\n\n const item = {\n resolve: () => {\n if (signal && onAbort) {\n signal.removeEventListener('abort', onAbort);\n }\n resolve();\n },\n reject: (err: Error) => {\n if (signal && onAbort) {\n signal.removeEventListener('abort', onAbort);\n }\n reject(err);\n },\n };\n\n queue.push(item);\n\n if (signal) {\n onAbort = () => {\n const index = queue.indexOf(item);\n if (index !== -1) {\n queue.splice(index, 1);\n }\n reject(signal.reason || new Error('Request aborted'));\n };\n signal.addEventListener('abort', onAbort, { once: true });\n }\n });\n }\n\n function release(): void {\n if (destroyed) return;\n if (active <= 0) return;\n\n const next = queue.shift();\n if (next) {\n next.resolve();\n return;\n }\n active--;\n }\n\n function destroy(): void {\n destroyed = true;\n const reason = new Error('[Accessio] Rate limiter destroyed — pending request cancelled');\n while (queue.length > 0) {\n queue.shift()!.reject(reason);\n }\n }\n\n return {\n acquire,\n release,\n destroy,\n get pending() {\n return queue.length;\n },\n get active() {\n return active;\n },\n get destroyed() {\n return destroyed;\n },\n };\n}\n\nexport async function rateLimitedRequest<T = unknown>(\n dispatchFn: (config: AccessioRequestConfig) => Promise<AccessioResponse<T>>,\n limiter: RateLimiter,\n config: AccessioRequestConfig,\n): Promise<AccessioResponse<T>> {\n await limiter.acquire(config.signal);\n try {\n return await dispatchFn(config);\n } finally {\n limiter.release();\n }\n}\n\nexport default createRateLimiter;\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOO,SAAS,kBACd,gBAAwB,UACxB,eAAuB,UACV;AACb,MAAI,kBAAkB,aAAa,CAAC,OAAO,UAAU,aAAa,KAAK,gBAAgB,IAAI;AACzF,UAAM,IAAI;AAAA,MACR,yEAAyE,aAAa;AAAA,IACxF;AAAA,EACF;AACA,MAAI,iBAAiB,aAAa,CAAC,OAAO,UAAU,YAAY,KAAK,eAAe,IAAI;AACtF,UAAM,IAAI;AAAA,MACR,wEAAwE,YAAY;AAAA,IACtF;AAAA,EACF;AACA,MAAI,SAAS;AACb,MAAI,YAAY;AAChB,QAAM,QAAqB,CAAC;AAE5B,WAAS,QAAQ,QAAqC;AACpD,QAAI,WAAW;AACb,aAAO,QAAQ,OAAO,IAAI,MAAM,4CAA4C,CAAC;AAAA,IAC/E;AAEA,QAAI,QAAQ,SAAS;AACnB,aAAO,QAAQ,OAAO,OAAO,UAAU,IAAI,MAAM,iBAAiB,CAAC;AAAA,IACrE;AAEA,QAAI,SAAS,eAAe;AAC1B;AACA,aAAO,QAAQ,QAAQ;AAAA,IACzB;AAEA,QAAI,MAAM,UAAU,cAAc;AAChC,aAAO,QAAQ;AAAA,QACb,IAAI,MAAM,6DAA6D,YAAY,GAAG;AAAA,MACxF;AAAA,IACF;AAEA,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAI;AAEJ,YAAM,OAAO;AAAA,QACX,SAAS,MAAM;AACb,cAAI,UAAU,SAAS;AACrB,mBAAO,oBAAoB,SAAS,OAAO;AAAA,UAC7C;AACA,kBAAQ;AAAA,QACV;AAAA,QACA,QAAQ,CAAC,QAAe;AACtB,cAAI,UAAU,SAAS;AACrB,mBAAO,oBAAoB,SAAS,OAAO;AAAA,UAC7C;AACA,iBAAO,GAAG;AAAA,QACZ;AAAA,MACF;AAEA,YAAM,KAAK,IAAI;AAEf,UAAI,QAAQ;AACV,kBAAU,MAAM;AACd,gBAAM,QAAQ,MAAM,QAAQ,IAAI;AAChC,cAAI,UAAU,IAAI;AAChB,kBAAM,OAAO,OAAO,CAAC;AAAA,UACvB;AACA,iBAAO,OAAO,UAAU,IAAI,MAAM,iBAAiB,CAAC;AAAA,QACtD;AACA,eAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;AAAA,MAC1D;AAAA,IACF,CAAC;AAAA,EACH;AAEA,WAAS,UAAgB;AACvB,QAAI,UAAW;AACf,QAAI,UAAU,EAAG;AAEjB,UAAM,OAAO,MAAM,MAAM;AACzB,QAAI,MAAM;AACR,WAAK,QAAQ;AACb;AAAA,IACF;AACA;AAAA,EACF;AAEA,WAAS,UAAgB;AACvB,gBAAY;AACZ,UAAM,SAAS,IAAI,MAAM,oEAA+D;AACxF,WAAO,MAAM,SAAS,GAAG;AACvB,YAAM,MAAM,EAAG,OAAO,MAAM;AAAA,IAC9B;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,IAAI,UAAU;AACZ,aAAO,MAAM;AAAA,IACf;AAAA,IACA,IAAI,SAAS;AACX,aAAO;AAAA,IACT;AAAA,IACA,IAAI,YAAY;AACd,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAEA,eAAsB,mBACpB,YACA,SACA,QAC8B;AAC9B,QAAM,QAAQ,QAAQ,OAAO,MAAM;AACnC,MAAI;AACF,WAAO,MAAM,WAAW,MAAM;AAAA,EAChC,UAAE;AACA,YAAQ,QAAQ;AAAA,EAClB;AACF;AAEA,IAAO,sBAAQ;","names":[]}
|
|
@@ -29,7 +29,7 @@ function toFormData(obj, form, namespace) {
|
|
|
29
29
|
}
|
|
30
30
|
if (obj instanceof Date) {
|
|
31
31
|
fd.append(namespace || "", obj.toISOString());
|
|
32
|
-
} else if (typeof obj === "object" && !(obj instanceof File) && !(obj instanceof Blob)) {
|
|
32
|
+
} else if (typeof obj === "object" && !(typeof File !== "undefined" && obj instanceof File) && !(typeof Blob !== "undefined" && obj instanceof Blob)) {
|
|
33
33
|
Object.keys(obj).forEach((key) => {
|
|
34
34
|
if (Array.isArray(obj)) {
|
|
35
35
|
formKey = namespace ? `${namespace}[${key}]` : key;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/helpers/toFormData.ts"],"sourcesContent":["export function toFormData(obj: any, form?: FormData, namespace?: string): FormData {\n const fd = form || new FormData();\n let formKey: string;\n\n if (obj === null || obj === undefined) {\n return fd;\n }\n\n if (obj instanceof Date) {\n fd.append(namespace || '', obj.toISOString());\n } else if (typeof obj === 'object' &&
|
|
1
|
+
{"version":3,"sources":["../../src/helpers/toFormData.ts"],"sourcesContent":["export function toFormData(obj: any, form?: FormData, namespace?: string): FormData {\n const fd = form || new FormData();\n let formKey: string;\n\n if (obj === null || obj === undefined) {\n return fd;\n }\n\n if (obj instanceof Date) {\n fd.append(namespace || '', obj.toISOString());\n } else if (\n typeof obj === 'object' &&\n !(typeof File !== 'undefined' && obj instanceof File) &&\n !(typeof Blob !== 'undefined' && obj instanceof Blob)\n ) {\n Object.keys(obj).forEach((key) => {\n if (Array.isArray(obj)) {\n formKey = namespace ? `${namespace}[${key}]` : key;\n } else {\n formKey = namespace ? `${namespace}.${key}` : key;\n }\n toFormData(obj[key], fd, formKey);\n });\n } else {\n fd.append(namespace || '', obj);\n }\n\n return fd;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAO,SAAS,WAAW,KAAU,MAAiB,WAA8B;AAClF,QAAM,KAAK,QAAQ,IAAI,SAAS;AAChC,MAAI;AAEJ,MAAI,QAAQ,QAAQ,QAAQ,QAAW;AACrC,WAAO;AAAA,EACT;AAEA,MAAI,eAAe,MAAM;AACvB,OAAG,OAAO,aAAa,IAAI,IAAI,YAAY,CAAC;AAAA,EAC9C,WACE,OAAO,QAAQ,YACf,EAAE,OAAO,SAAS,eAAe,eAAe,SAChD,EAAE,OAAO,SAAS,eAAe,eAAe,OAChD;AACA,WAAO,KAAK,GAAG,EAAE,QAAQ,CAAC,QAAQ;AAChC,UAAI,MAAM,QAAQ,GAAG,GAAG;AACtB,kBAAU,YAAY,GAAG,SAAS,IAAI,GAAG,MAAM;AAAA,MACjD,OAAO;AACL,kBAAU,YAAY,GAAG,SAAS,IAAI,GAAG,KAAK;AAAA,MAChD;AACA,iBAAW,IAAI,GAAG,GAAG,IAAI,OAAO;AAAA,IAClC,CAAC;AAAA,EACH,OAAO;AACL,OAAG,OAAO,aAAa,IAAI,GAAG;AAAA,EAChC;AAEA,SAAO;AACT;","names":[]}
|
package/index.d.ts
CHANGED
|
@@ -150,6 +150,13 @@ export interface AccessioRequestConfig {
|
|
|
150
150
|
/** TTL in ms for cached responses (when supported by the provider) */
|
|
151
151
|
cacheTTL?: number;
|
|
152
152
|
|
|
153
|
+
/** Custom function to serialize request properties into a deterministic cache key */
|
|
154
|
+
cacheKeySerializer?: (
|
|
155
|
+
config: AccessioRequestConfig,
|
|
156
|
+
fullURL: string,
|
|
157
|
+
headers: Record<string, HeaderValue>,
|
|
158
|
+
) => string;
|
|
159
|
+
|
|
153
160
|
// ── Response handling ──────────────────────────────
|
|
154
161
|
|
|
155
162
|
/** Maximum allowed response Content-Length in bytes */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "accessio",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Fast, flexible HTTP client — simple, modular, and dependency-free",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./cjs/index.cjs",
|
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
"node": ">=18.0.0"
|
|
97
97
|
},
|
|
98
98
|
"devDependencies": {
|
|
99
|
-
"@
|
|
99
|
+
"@types/node": "^25.9.2",
|
|
100
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
100
101
|
"eslint": "^9.0.0",
|
|
101
102
|
"eslint-config-prettier": "^10.1.8",
|
|
102
103
|
"jsdom": "^29.0.2",
|
|
@@ -104,6 +105,6 @@
|
|
|
104
105
|
"tsup": "^8.0.0",
|
|
105
106
|
"typescript": "^5.0.0",
|
|
106
107
|
"typescript-eslint": "^8.59.3",
|
|
107
|
-
"vitest": "^
|
|
108
|
+
"vitest": "^4.1.8"
|
|
108
109
|
}
|
|
109
|
-
}
|
|
110
|
+
}
|
package/src/accessio.ts
CHANGED
|
@@ -261,19 +261,13 @@ export class Accessio {
|
|
|
261
261
|
if (!response.data) return;
|
|
262
262
|
|
|
263
263
|
const reader = response.data.getReader();
|
|
264
|
-
|
|
265
|
-
|
|
264
|
+
try {
|
|
265
|
+
const decoder = new TextDecoder();
|
|
266
|
+
let buffer = '';
|
|
266
267
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
buffer += decoder.decode(value, { stream: true });
|
|
272
|
-
const lines = buffer.split('\n');
|
|
273
|
-
buffer = lines.pop() || '';
|
|
274
|
-
|
|
275
|
-
for (const line of lines) {
|
|
276
|
-
if (line.trim().startsWith('data:')) {
|
|
268
|
+
const processLine = function* (line: string) {
|
|
269
|
+
const trimmed = line.trim();
|
|
270
|
+
if (trimmed.startsWith('data:')) {
|
|
277
271
|
const dataStr = line.replace(/^data:\s*/, '');
|
|
278
272
|
if (dataStr === '[DONE]') return;
|
|
279
273
|
try {
|
|
@@ -281,14 +275,39 @@ export class Accessio {
|
|
|
281
275
|
} catch (e) {
|
|
282
276
|
yield dataStr as any;
|
|
283
277
|
}
|
|
284
|
-
} else if (
|
|
278
|
+
} else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
285
279
|
try {
|
|
286
280
|
yield JSON.parse(line);
|
|
287
281
|
} catch (e) {
|
|
288
282
|
// ignore partial json
|
|
289
283
|
}
|
|
290
284
|
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
while (true) {
|
|
288
|
+
const { done, value } = await reader.read();
|
|
289
|
+
if (done) break;
|
|
290
|
+
|
|
291
|
+
buffer += decoder.decode(value, { stream: true });
|
|
292
|
+
const lines = buffer.split('\n');
|
|
293
|
+
buffer = lines.pop() || '';
|
|
294
|
+
|
|
295
|
+
for (const line of lines) {
|
|
296
|
+
yield* processLine(line);
|
|
297
|
+
}
|
|
291
298
|
}
|
|
299
|
+
|
|
300
|
+
buffer += decoder.decode(new Uint8Array(), { stream: false });
|
|
301
|
+
if (buffer.trim()) {
|
|
302
|
+
yield* processLine(buffer);
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
try {
|
|
306
|
+
await reader.cancel();
|
|
307
|
+
} catch {
|
|
308
|
+
// ignore errors on cancel
|
|
309
|
+
}
|
|
310
|
+
reader.releaseLock();
|
|
292
311
|
}
|
|
293
312
|
}
|
|
294
313
|
|
|
@@ -302,14 +321,24 @@ export class Accessio {
|
|
|
302
321
|
while (nextUrl) {
|
|
303
322
|
const response: AccessioResponse<any> = await this.get(nextUrl, currentConfig);
|
|
304
323
|
|
|
305
|
-
const
|
|
324
|
+
const data = response.data;
|
|
325
|
+
const items = Array.isArray(data)
|
|
326
|
+
? data
|
|
327
|
+
: data && typeof data === 'object'
|
|
328
|
+
? (data as any).data
|
|
329
|
+
: null;
|
|
330
|
+
|
|
306
331
|
if (Array.isArray(items)) {
|
|
307
332
|
for (const item of items) {
|
|
308
333
|
yield item;
|
|
309
334
|
}
|
|
310
335
|
}
|
|
311
336
|
|
|
312
|
-
nextUrl =
|
|
337
|
+
nextUrl =
|
|
338
|
+
data && typeof data === 'object'
|
|
339
|
+
? (data as any).next || (data as any).links?.next || null
|
|
340
|
+
: null;
|
|
341
|
+
|
|
313
342
|
if (nextUrl) {
|
|
314
343
|
currentConfig = mergeConfig(currentConfig, { url: nextUrl, params: {} });
|
|
315
344
|
}
|
|
@@ -42,11 +42,42 @@ export function redactBody(value: unknown, seen?: WeakSet<object>): unknown {
|
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function redactParams(params: unknown): unknown {
|
|
46
|
+
if (!params || typeof params !== 'object') return params;
|
|
47
|
+
const out: Record<string, unknown> = {};
|
|
48
|
+
for (const key of Object.keys(params as Record<string, unknown>)) {
|
|
49
|
+
const value = (params as Record<string, unknown>)[key];
|
|
50
|
+
if (SENSITIVE_BODY_KEY.test(key)) {
|
|
51
|
+
out[key] = '[REDACTED]';
|
|
52
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
53
|
+
out[key] = redactParams(value);
|
|
54
|
+
} else {
|
|
55
|
+
out[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function redactURL(url: string | undefined): string | undefined {
|
|
62
|
+
if (!url) return url;
|
|
63
|
+
// Match inline credentials: http://user:pass@host
|
|
64
|
+
return url.replace(/^([a-z][a-z\d+\-.]*:\/\/)([^/]+)@/i, (match, protocol, userInfo) => {
|
|
65
|
+
const parts = userInfo.split(':');
|
|
66
|
+
if (parts.length > 1) {
|
|
67
|
+
return `${protocol}${parts[0]}:[REDACTED]@`;
|
|
68
|
+
}
|
|
69
|
+
return `${protocol}[REDACTED]@`;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
export function redactConfig(config: AccessioRequestConfig | null): AccessioRequestConfig | null {
|
|
46
74
|
if (!config) return config;
|
|
47
75
|
const clone = { ...config } as AccessioRequestConfig & { auth?: unknown };
|
|
48
76
|
if ('auth' in clone) delete clone.auth;
|
|
49
77
|
if (clone.headers) clone.headers = redactHeaders(clone.headers) as typeof clone.headers;
|
|
78
|
+
if (clone.params) clone.params = redactParams(clone.params) as typeof clone.params;
|
|
79
|
+
if (clone.url) clone.url = redactURL(clone.url);
|
|
80
|
+
if (clone._builtUrl) clone._builtUrl = redactURL(clone._builtUrl);
|
|
50
81
|
return clone;
|
|
51
82
|
}
|
|
52
83
|
|
|
@@ -86,8 +117,8 @@ export class AccessioError extends Error {
|
|
|
86
117
|
this.response = response ?? null;
|
|
87
118
|
this.isAccessioError = true;
|
|
88
119
|
|
|
89
|
-
if (Error.captureStackTrace) {
|
|
90
|
-
Error.captureStackTrace(this, AccessioError);
|
|
120
|
+
if ((Error as any).captureStackTrace) {
|
|
121
|
+
(Error as any).captureStackTrace(this, AccessioError);
|
|
91
122
|
}
|
|
92
123
|
}
|
|
93
124
|
|
package/src/core/buildURL.ts
CHANGED
|
@@ -82,19 +82,22 @@ export default function buildURL(
|
|
|
82
82
|
if (key === '__proto__' || key === 'prototype' || key === 'constructor') continue;
|
|
83
83
|
unusedParams[key] = (params as Record<string, unknown>)[key];
|
|
84
84
|
}
|
|
85
|
-
fullURL = fullURL.replace(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
key
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
85
|
+
fullURL = fullURL.replace(
|
|
86
|
+
/(?::([a-zA-Z_][a-zA-Z0-9_]*))|(?:{([a-zA-Z_][a-zA-Z0-9_]*)})/g,
|
|
87
|
+
(match, p1, p2) => {
|
|
88
|
+
const key = p1 || p2;
|
|
89
|
+
if (
|
|
90
|
+
key &&
|
|
91
|
+
Object.prototype.hasOwnProperty.call(unusedParams, key) &&
|
|
92
|
+
unusedParams[key] !== undefined
|
|
93
|
+
) {
|
|
94
|
+
const val = unusedParams[key];
|
|
95
|
+
delete unusedParams[key];
|
|
96
|
+
return encodeURIComponent(String(val));
|
|
97
|
+
}
|
|
98
|
+
return match;
|
|
99
|
+
},
|
|
100
|
+
);
|
|
98
101
|
finalParams = unusedParams;
|
|
99
102
|
}
|
|
100
103
|
|
package/src/core/fetchAdapter.ts
CHANGED
package/src/core/mergeConfig.ts
CHANGED
|
@@ -37,7 +37,7 @@ function deepMerge(...sources: any[]): Record<string, any> {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const requestOnlyKeys = new Set<string>(['url', 'data', 'signal']);
|
|
40
|
-
const deepMergeKeys = new Set<string>(['headers']);
|
|
40
|
+
const deepMergeKeys = new Set<string>(['headers', 'params', 'hooks']);
|
|
41
41
|
|
|
42
42
|
export default function mergeConfig(
|
|
43
43
|
config1: AccessioRequestConfig = {},
|
package/src/core/request.ts
CHANGED
|
@@ -12,28 +12,35 @@ import type { AccessioRequestConfig, AccessioResponse, TransformFunction } from
|
|
|
12
12
|
type HeadersConfig = Record<string, Record<string, string | string[]>>;
|
|
13
13
|
type FlatHeaders = Record<string, string | string[]>;
|
|
14
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
15
|
function buildCacheKey(
|
|
27
16
|
config: AccessioRequestConfig,
|
|
28
17
|
fullURL: string,
|
|
29
18
|
flatHeaders: FlatHeaders,
|
|
30
19
|
): string {
|
|
20
|
+
if (typeof config.cacheKeySerializer === 'function') {
|
|
21
|
+
return config.cacheKeySerializer(config, fullURL, flatHeaders);
|
|
22
|
+
}
|
|
31
23
|
const method = (config.method || 'GET').toUpperCase();
|
|
32
|
-
const auth = lookupHeader(flatHeaders, 'authorization');
|
|
33
|
-
const accept = lookupHeader(flatHeaders, 'accept');
|
|
34
24
|
const withCreds = config.withCredentials ? '1' : '0';
|
|
35
25
|
const respType = config.responseType || 'json';
|
|
36
|
-
|
|
26
|
+
|
|
27
|
+
// Sort and serialize headers dynamically to prevent collisions,
|
|
28
|
+
// excluding environment-specific transient headers.
|
|
29
|
+
const serializedHeaders = Object.keys(flatHeaders)
|
|
30
|
+
.sort()
|
|
31
|
+
.filter(
|
|
32
|
+
(k) =>
|
|
33
|
+
!['user-agent', 'connection', 'host', 'content-length', 'accept-encoding'].includes(
|
|
34
|
+
k.toLowerCase(),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
.map((k) => {
|
|
38
|
+
const val = flatHeaders[k];
|
|
39
|
+
return `${k.toLowerCase()}=${Array.isArray(val) ? val.join(',') : val}`;
|
|
40
|
+
})
|
|
41
|
+
.join('&');
|
|
42
|
+
|
|
43
|
+
return `${method}:${fullURL}|h:${serializedHeaders}|c=${withCreds}|t=${respType}`;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
function buildTransformArray(
|
|
@@ -126,8 +133,39 @@ export default async function dispatchRequest(
|
|
|
126
133
|
if (isGet && config.dedupe) {
|
|
127
134
|
const inflight = activeRequests.get(cacheKey);
|
|
128
135
|
if (inflight) {
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
try {
|
|
137
|
+
const shared = await inflight;
|
|
138
|
+
const response = finalizeResponse(shared, config);
|
|
139
|
+
const settled = await new Promise<AccessioResponse>((resolve, reject) => {
|
|
140
|
+
settle(
|
|
141
|
+
resolve as (value: AccessioResponse) => void,
|
|
142
|
+
reject as (reason: AccessioError) => void,
|
|
143
|
+
response,
|
|
144
|
+
config,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (config.hooks?.onRequestResponse) {
|
|
149
|
+
await config.hooks.onRequestResponse(settled);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return settled;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
let finalError = error;
|
|
155
|
+
if (error instanceof AccessioError) {
|
|
156
|
+
finalError = AccessioError.from(
|
|
157
|
+
error,
|
|
158
|
+
error.code || 'ERR_DEDUPE',
|
|
159
|
+
config,
|
|
160
|
+
error.request,
|
|
161
|
+
error.response,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (config.hooks?.onRequestError && finalError instanceof AccessioError) {
|
|
165
|
+
await config.hooks.onRequestError(finalError);
|
|
166
|
+
}
|
|
167
|
+
throw finalError;
|
|
168
|
+
}
|
|
131
169
|
}
|
|
132
170
|
}
|
|
133
171
|
|
package/src/core/retry.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
function isUnretriableBody(data: unknown): boolean {
|
|
11
11
|
if (data == null) return false;
|
|
12
12
|
if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) return true;
|
|
13
|
+
if (data && typeof (data as any).pipe === 'function') return true;
|
|
13
14
|
return false;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -33,10 +34,11 @@ function defaultRetryCondition(error: any): boolean {
|
|
|
33
34
|
return false;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
function calculateDelay(attempt: number, baseDelay: number): number {
|
|
37
|
+
function calculateDelay(attempt: number, baseDelay: number, maxDelay: number = 30000): number {
|
|
37
38
|
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
38
39
|
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
|
|
39
|
-
|
|
40
|
+
const calculated = Math.round(exponentialDelay + jitter);
|
|
41
|
+
return Math.min(calculated, maxDelay);
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
function sleep(ms: number, options?: { signal?: AbortSignal }): Promise<void> {
|
|
@@ -89,8 +91,9 @@ async function retryRequest(
|
|
|
89
91
|
} catch (error) {
|
|
90
92
|
lastError = error;
|
|
91
93
|
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
+
const is429 = (error as any).response?.status === 429;
|
|
95
|
+
const attemptLimit = is429 && config.retryOn429 ? Math.max(maxRetries, 3) : maxRetries;
|
|
96
|
+
const shouldRetry = attempt < attemptLimit && retryCondition(error as AccessioError);
|
|
94
97
|
|
|
95
98
|
if (!shouldRetry) {
|
|
96
99
|
throw error;
|
|
@@ -107,7 +110,7 @@ async function retryRequest(
|
|
|
107
110
|
);
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
let delay = calculateDelay(attempt, retryDelay);
|
|
113
|
+
let delay = calculateDelay(attempt, retryDelay, config.maxRetryDelay ?? 30000);
|
|
111
114
|
|
|
112
115
|
if (config.retryOn429 && (error as any).response?.status === 429) {
|
|
113
116
|
const headers = (error as any).response?.headers;
|
|
@@ -76,10 +76,13 @@ export function removeContentType(headers: Record<string, string | string[]>): v
|
|
|
76
76
|
export function buildFetchHeaders(headers: Record<string, string | string[]>): Headers {
|
|
77
77
|
const fetchHeaders = new Headers();
|
|
78
78
|
for (const [key, value] of Object.entries(headers)) {
|
|
79
|
+
if (value === undefined || value === null) continue;
|
|
79
80
|
assertSafeHeader(key, value);
|
|
80
81
|
if (Array.isArray(value)) {
|
|
81
82
|
for (const v of value) {
|
|
82
|
-
|
|
83
|
+
if (v !== undefined && v !== null) {
|
|
84
|
+
fetchHeaders.append(key, v);
|
|
85
|
+
}
|
|
83
86
|
}
|
|
84
87
|
} else {
|
|
85
88
|
fetchHeaders.set(key, value);
|
|
@@ -2,6 +2,11 @@ import type { CacheProvider } from '../types';
|
|
|
2
2
|
|
|
3
3
|
class MemoryCache implements CacheProvider {
|
|
4
4
|
private cache = new Map<string, { value: any; expiry: number | null }>();
|
|
5
|
+
private maxItems: number;
|
|
6
|
+
|
|
7
|
+
constructor(maxItems: number = 1000) {
|
|
8
|
+
this.maxItems = maxItems;
|
|
9
|
+
}
|
|
5
10
|
|
|
6
11
|
get(key: string) {
|
|
7
12
|
const item = this.cache.get(key);
|
|
@@ -14,7 +19,24 @@ class MemoryCache implements CacheProvider {
|
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
set(key: string, value: any, ttl?: number) {
|
|
17
|
-
const
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
|
|
24
|
+
// Proactively evict all expired items first
|
|
25
|
+
for (const [k, item] of this.cache.entries()) {
|
|
26
|
+
if (item.expiry && now > item.expiry) {
|
|
27
|
+
this.cache.delete(k);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Evict oldest item if we are still at limit
|
|
32
|
+
if (this.cache.size >= this.maxItems) {
|
|
33
|
+
const oldest = this.cache.keys().next().value;
|
|
34
|
+
if (oldest !== undefined) {
|
|
35
|
+
this.cache.delete(oldest);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const expiry = ttl ? now + ttl : null;
|
|
18
40
|
this.cache.set(key, { value, expiry });
|
|
19
41
|
}
|
|
20
42
|
|
|
@@ -28,3 +50,4 @@ class MemoryCache implements CacheProvider {
|
|
|
28
50
|
}
|
|
29
51
|
|
|
30
52
|
export const defaultMemoryCache = new MemoryCache();
|
|
53
|
+
export { MemoryCache };
|
|
@@ -23,11 +23,15 @@ export function createRateLimiter(
|
|
|
23
23
|
let destroyed = false;
|
|
24
24
|
const queue: QueueItem[] = [];
|
|
25
25
|
|
|
26
|
-
function acquire(): Promise<void> {
|
|
26
|
+
function acquire(signal?: AbortSignal): Promise<void> {
|
|
27
27
|
if (destroyed) {
|
|
28
28
|
return Promise.reject(new Error('[Accessio] Rate limiter has been destroyed'));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
if (signal?.aborted) {
|
|
32
|
+
return Promise.reject(signal.reason || new Error('Request aborted'));
|
|
33
|
+
}
|
|
34
|
+
|
|
31
35
|
if (active < maxConcurrent) {
|
|
32
36
|
active++;
|
|
33
37
|
return Promise.resolve();
|
|
@@ -40,7 +44,35 @@ export function createRateLimiter(
|
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
return new Promise((resolve, reject) => {
|
|
43
|
-
|
|
47
|
+
let onAbort: (() => void) | undefined;
|
|
48
|
+
|
|
49
|
+
const item = {
|
|
50
|
+
resolve: () => {
|
|
51
|
+
if (signal && onAbort) {
|
|
52
|
+
signal.removeEventListener('abort', onAbort);
|
|
53
|
+
}
|
|
54
|
+
resolve();
|
|
55
|
+
},
|
|
56
|
+
reject: (err: Error) => {
|
|
57
|
+
if (signal && onAbort) {
|
|
58
|
+
signal.removeEventListener('abort', onAbort);
|
|
59
|
+
}
|
|
60
|
+
reject(err);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
queue.push(item);
|
|
65
|
+
|
|
66
|
+
if (signal) {
|
|
67
|
+
onAbort = () => {
|
|
68
|
+
const index = queue.indexOf(item);
|
|
69
|
+
if (index !== -1) {
|
|
70
|
+
queue.splice(index, 1);
|
|
71
|
+
}
|
|
72
|
+
reject(signal.reason || new Error('Request aborted'));
|
|
73
|
+
};
|
|
74
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
75
|
+
}
|
|
44
76
|
});
|
|
45
77
|
}
|
|
46
78
|
|
|
@@ -85,7 +117,7 @@ export async function rateLimitedRequest<T = unknown>(
|
|
|
85
117
|
limiter: RateLimiter,
|
|
86
118
|
config: AccessioRequestConfig,
|
|
87
119
|
): Promise<AccessioResponse<T>> {
|
|
88
|
-
await limiter.acquire();
|
|
120
|
+
await limiter.acquire(config.signal);
|
|
89
121
|
try {
|
|
90
122
|
return await dispatchFn(config);
|
|
91
123
|
} finally {
|
|
@@ -8,7 +8,11 @@ export function toFormData(obj: any, form?: FormData, namespace?: string): FormD
|
|
|
8
8
|
|
|
9
9
|
if (obj instanceof Date) {
|
|
10
10
|
fd.append(namespace || '', obj.toISOString());
|
|
11
|
-
} else if (
|
|
11
|
+
} else if (
|
|
12
|
+
typeof obj === 'object' &&
|
|
13
|
+
!(typeof File !== 'undefined' && obj instanceof File) &&
|
|
14
|
+
!(typeof Blob !== 'undefined' && obj instanceof Blob)
|
|
15
|
+
) {
|
|
12
16
|
Object.keys(obj).forEach((key) => {
|
|
13
17
|
if (Array.isArray(obj)) {
|
|
14
18
|
formKey = namespace ? `${namespace}[${key}]` : key;
|
package/src/types.ts
CHANGED
|
@@ -91,11 +91,17 @@ export interface AccessioRequestConfig {
|
|
|
91
91
|
dedupe?: boolean;
|
|
92
92
|
cache?: boolean | CacheProvider;
|
|
93
93
|
cacheTTL?: number;
|
|
94
|
+
cacheKeySerializer?: (
|
|
95
|
+
config: AccessioRequestConfig,
|
|
96
|
+
fullURL: string,
|
|
97
|
+
headers: Record<string, string | string[]>,
|
|
98
|
+
) => string;
|
|
94
99
|
onDownloadProgress?: (progressEvent: { loaded: number; total: number }) => void;
|
|
95
100
|
hooks?: AccessioHooks;
|
|
96
101
|
schema?: SchemaValidator;
|
|
97
102
|
fetch?: typeof fetch;
|
|
98
103
|
retryOn429?: boolean;
|
|
104
|
+
maxRetryDelay?: number;
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
export interface AccessioResponse<T = unknown> {
|
|
@@ -120,7 +126,7 @@ export interface AccessioError extends Error {
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
export interface RateLimiter {
|
|
123
|
-
acquire: () => Promise<void>;
|
|
129
|
+
acquire: (signal?: AbortSignal) => Promise<void>;
|
|
124
130
|
release: () => void;
|
|
125
131
|
destroy: () => void;
|
|
126
132
|
pending: number;
|