accessio 1.5.0 → 1.7.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.
@@ -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
 
@@ -39,7 +39,10 @@ export function defaultTransformRequest(
39
39
  return data;
40
40
  }
41
41
 
42
- export function defaultTransformResponse(data: unknown): unknown {
42
+ export function defaultTransformResponse(data: unknown, headers?: any, config?: any): unknown {
43
+ if (config && config.responseType === 'text') {
44
+ return data;
45
+ }
43
46
  if (typeof data === 'string') {
44
47
  try {
45
48
  return JSON.parse(data);
@@ -49,17 +49,31 @@ export function flattenHeaders(
49
49
  const merged: Record<string, string | string[]> = {};
50
50
  const methodLower = (method || 'get').toLowerCase();
51
51
 
52
+ const setHeader = (target: Record<string, string | string[]>, key: string, value: any) => {
53
+ const keyLower = key.toLowerCase();
54
+ for (const existingKey of Object.keys(target)) {
55
+ if (existingKey.toLowerCase() === keyLower) {
56
+ delete target[existingKey];
57
+ }
58
+ }
59
+ target[key] = value;
60
+ };
61
+
52
62
  if (headers['common']) {
53
- Object.assign(merged, headers['common']);
63
+ Object.entries(headers['common']).forEach(([k, v]) => {
64
+ setHeader(merged, k, v);
65
+ });
54
66
  }
55
67
 
56
68
  if (headers[methodLower]) {
57
- Object.assign(merged, headers[methodLower]);
69
+ Object.entries(headers[methodLower]).forEach(([k, v]) => {
70
+ setHeader(merged, k, v);
71
+ });
58
72
  }
59
73
 
60
74
  for (const key in headers) {
61
75
  if (Object.prototype.hasOwnProperty.call(headers, key) && !METHOD_KEYS.has(key)) {
62
- merged[key] = headers[key] as unknown as string | string[];
76
+ setHeader(merged, key, headers[key]);
63
77
  }
64
78
  }
65
79
 
@@ -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,33 @@ 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
+ if (this.cache.has(key)) {
25
+ const expiry = ttl ? now + ttl : null;
26
+ this.cache.set(key, { value, expiry });
27
+ return;
28
+ }
29
+
30
+ // Proactively evict up to 5 of the oldest items to prevent memory build-up without O(N) cost.
31
+ // Map entries are ordered by insertion, so the oldest are checked first.
32
+ let count = 0;
33
+ for (const [k, item] of this.cache.entries()) {
34
+ if (count++ >= 5) break;
35
+ if (item.expiry && now > item.expiry) {
36
+ this.cache.delete(k);
37
+ }
38
+ }
39
+
40
+ // Evict the oldest item if we are still at limit
41
+ if (this.cache.size >= this.maxItems) {
42
+ const oldest = this.cache.keys().next().value;
43
+ if (oldest !== undefined) {
44
+ this.cache.delete(oldest);
45
+ }
46
+ }
47
+
48
+ const expiry = ttl ? now + ttl : null;
18
49
  this.cache.set(key, { value, expiry });
19
50
  }
20
51
 
@@ -28,3 +59,4 @@ class MemoryCache implements CacheProvider {
28
59
  }
29
60
 
30
61
  export const defaultMemoryCache = new MemoryCache();
62
+ export { MemoryCache };
@@ -3,16 +3,19 @@ export default function parseHeaders(headers: any): Record<string, string | stri
3
3
 
4
4
  if (!headers) return parsed;
5
5
 
6
- const addHeader = (key: string, value: string) => {
6
+ const addHeader = (key: string, value: string | string[]) => {
7
7
  const k = key.toLowerCase();
8
- if (parsed[k]) {
9
- if (Array.isArray(parsed[k])) {
10
- (parsed[k] as string[]).push(value);
8
+ const values = Array.isArray(value) ? value : [value];
9
+ for (const val of values) {
10
+ if (parsed[k]) {
11
+ if (Array.isArray(parsed[k])) {
12
+ (parsed[k] as string[]).push(val);
13
+ } else {
14
+ parsed[k] = [parsed[k] as string, val];
15
+ }
11
16
  } else {
12
- parsed[k] = [parsed[k] as string, value];
17
+ parsed[k] = val;
13
18
  }
14
- } else {
15
- parsed[k] = value;
16
19
  }
17
20
  };
18
21
 
@@ -17,7 +17,7 @@ export default async function transformData(
17
17
  for (const transform of transforms) {
18
18
  if (typeof transform === 'function') {
19
19
  try {
20
- result = await transform(result, headers);
20
+ result = await transform(result, headers, config);
21
21
  } catch (err) {
22
22
  throw AccessioError.from(
23
23
  err instanceof Error ? err : new Error(String(err)),
package/src/types.ts CHANGED
@@ -14,6 +14,7 @@ export type ParamsSerializer = (params: Record<string, unknown>) => string;
14
14
  export type TransformFunction = (
15
15
  data: unknown,
16
16
  headers: Record<string, string | string[]>,
17
+ config?: any,
17
18
  ) => unknown | Promise<unknown>;
18
19
 
19
20
  export type RetryConditionFunction = (error: AccessioError) => boolean;
@@ -91,6 +92,11 @@ export interface AccessioRequestConfig {
91
92
  dedupe?: boolean;
92
93
  cache?: boolean | CacheProvider;
93
94
  cacheTTL?: number;
95
+ cacheKeySerializer?: (
96
+ config: AccessioRequestConfig,
97
+ fullURL: string,
98
+ headers: Record<string, string | string[]>,
99
+ ) => string;
94
100
  onDownloadProgress?: (progressEvent: { loaded: number; total: number }) => void;
95
101
  hooks?: AccessioHooks;
96
102
  schema?: SchemaValidator;