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.
@@ -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({ resolve, reject });\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();\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,UAAyB;AAChC,QAAI,WAAW;AACb,aAAO,QAAQ,OAAO,IAAI,MAAM,4CAA4C,CAAC;AAAA,IAC/E;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,YAAM,KAAK,EAAE,SAAS,OAAO,CAAC;AAAA,IAChC,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;AACtB,MAAI;AACF,WAAO,MAAM,WAAW,MAAM;AAAA,EAChC,UAAE;AACA,YAAQ,QAAQ;AAAA,EAClB;AACF;AAEA,IAAO,sBAAQ;","names":[]}
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' && !(obj instanceof File) && !(obj instanceof Blob)) {\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,WAAW,OAAO,QAAQ,YAAY,EAAE,eAAe,SAAS,EAAE,eAAe,OAAO;AACtF,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":[]}
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.4.0",
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
- "@vitest/coverage-v8": "^3.1.0",
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": "^3.1.0"
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
- const decoder = new TextDecoder();
265
- let buffer = '';
264
+ try {
265
+ const decoder = new TextDecoder();
266
+ let buffer = '';
266
267
 
267
- while (true) {
268
- const { done, value } = await reader.read();
269
- if (done) break;
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 (line.trim().startsWith('{') || line.trim().startsWith('[')) {
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 items = Array.isArray(response.data) ? response.data : response.data.data;
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 = response.data.next || response.data.links?.next || null;
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
 
@@ -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(/(?::([a-zA-Z0-9_]+))|(?:{([a-zA-Z0-9_]+)})/g, (match, p1, p2) => {
86
- const key = p1 || p2;
87
- if (
88
- key &&
89
- Object.prototype.hasOwnProperty.call(unusedParams, key) &&
90
- unusedParams[key] !== undefined
91
- ) {
92
- const val = unusedParams[key];
93
- delete unusedParams[key];
94
- return encodeURIComponent(String(val));
95
- }
96
- return match;
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
 
@@ -154,6 +154,9 @@ function wrapDownloadProgress(fetchResponse: Response, config: AccessioRequestCo
154
154
  controller.error(e);
155
155
  }
156
156
  },
157
+ cancel(reason) {
158
+ reader.cancel(reason).catch(() => {});
159
+ },
157
160
  });
158
161
 
159
162
  return new Response(stream, {
@@ -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 = {},
@@ -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
- return `${method}:${fullURL}|a=${auth}|x=${accept}|c=${withCreds}|t=${respType}`;
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
- const shared = await inflight;
130
- return finalizeResponse(shared, config);
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
- return Math.round(exponentialDelay + jitter);
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 isLastAttempt = attempt >= actualMaxRetries;
93
- const shouldRetry = !isLastAttempt && retryCondition(error as AccessioError);
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
- fetchHeaders.append(key, v);
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 expiry = ttl ? Date.now() + ttl : null;
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
- queue.push({ resolve, reject });
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 (typeof obj === 'object' && !(obj instanceof File) && !(obj instanceof Blob)) {
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;