accessio 1.3.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.
@@ -25,7 +25,7 @@ async function readResponseData(
25
25
  } catch (err) {
26
26
  throw new AccessioError(
27
27
  `Failed to parse JSON response: ${(err as Error).message}. Raw body: ${
28
- text.length > 500 ? text.slice(0, 500) + '…' : text
28
+ text.length > 500 ? `${text.slice(0, 500)}…` : text
29
29
  }`,
30
30
  AccessioError.ERR_BAD_RESPONSE,
31
31
  config,
@@ -39,17 +39,27 @@ async function readResponseData(
39
39
  }
40
40
  }
41
41
 
42
- export default async function fetchAdapter(
43
- config: AccessioRequestConfig,
44
- fullURL: string,
45
- fetchOptions: RequestInit,
46
- requestStartTime: number,
47
- ): Promise<AccessioResponse> {
48
- let abortController: AbortController | null = null;
49
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
50
- let isTimedOut = false;
51
- let onUserAbort: (() => void) | null = null;
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
+ }
52
56
 
57
+ interface AbortWiring {
58
+ isTimedOut: () => boolean;
59
+ cleanup: () => void;
60
+ }
61
+
62
+ function setupAbort(config: AccessioRequestConfig, fetchOptions: RequestInit): AbortWiring {
53
63
  if (
54
64
  config.timeout !== undefined &&
55
65
  (typeof config.timeout !== 'number' || isNaN(config.timeout) || config.timeout < 0)
@@ -64,84 +74,141 @@ export default async function fetchAdapter(
64
74
  }
65
75
 
66
76
  const timeoutValue = Number(config.timeout);
67
- if (!isNaN(timeoutValue) && timeoutValue > 0) {
68
- abortController = new AbortController();
69
-
70
- timeoutId = setTimeout(() => {
71
- isTimedOut = true;
72
- abortController!.abort(
73
- new AccessioError(
74
- `timeout of ${timeoutValue}ms exceeded`,
75
- AccessioError.ETIMEDOUT,
76
- config,
77
- null,
78
- null,
79
- ),
80
- );
81
- }, timeoutValue);
77
+ const hasTimeout = !isNaN(timeoutValue) && timeoutValue > 0;
82
78
 
83
- if (config.signal) {
84
- if (typeof AbortSignal.any === 'function') {
85
- fetchOptions.signal = AbortSignal.any([config.signal, abortController.signal]);
79
+ if (!hasTimeout) {
80
+ if (config.signal) fetchOptions.signal = config.signal;
81
+ return { isTimedOut: () => false, cleanup: () => {} };
82
+ }
83
+
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);
86
107
  } else {
87
- if (config.signal.aborted) {
88
- abortController.abort(config.signal.reason);
89
- } else {
90
- onUserAbort = () => {
91
- if (!isTimedOut && abortController) {
92
- abortController.abort(config.signal!.reason);
93
- }
94
- };
95
- config.signal.addEventListener('abort', onUserAbort, {
96
- once: true,
97
- });
98
- }
99
- fetchOptions.signal = abortController.signal;
108
+ onUserAbort = () => {
109
+ if (!timedOut) abortController.abort(config.signal!.reason);
110
+ };
111
+ config.signal.addEventListener('abort', onUserAbort, { once: true });
100
112
  }
101
- } else {
102
113
  fetchOptions.signal = abortController.signal;
103
114
  }
104
- } else if (config.signal) {
105
- fetchOptions.signal = config.signal;
115
+ } else {
116
+ fetchOptions.signal = abortController.signal;
106
117
  }
107
118
 
108
- try {
109
- const fetchImpl = config.fetch || fetch;
110
- let fetchResponse = await fetchImpl(fullURL, fetchOptions);
111
-
112
- if (config.onDownloadProgress && fetchResponse.body && config.responseType !== 'stream') {
113
- const contentLength = fetchResponse.headers.get('content-length');
114
- const total = contentLength ? parseInt(contentLength, 10) : 0;
115
- let loaded = 0;
116
-
117
- const reader = fetchResponse.body.getReader();
118
- const stream = new ReadableStream({
119
- async start(controller) {
120
- try {
121
- while (true) {
122
- const { done, value } = await reader.read();
123
- if (done) {
124
- controller.close();
125
- break;
126
- }
127
- loaded += value.byteLength;
128
- config.onDownloadProgress!({ loaded, total });
129
- controller.enqueue(value);
130
- }
131
- } catch (e) {
132
- controller.error(e);
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;
133
148
  }
134
- },
135
- });
136
-
137
- fetchResponse = new Response(stream, {
138
- headers: fetchResponse.headers,
139
- status: fetchResponse.status,
140
- statusText: fetchResponse.statusText,
141
- });
142
- }
149
+ loaded += value.byteLength;
150
+ config.onDownloadProgress!({ loaded, total });
151
+ controller.enqueue(value);
152
+ }
153
+ } catch (e) {
154
+ controller.error(e);
155
+ }
156
+ },
157
+ });
143
158
 
144
- let responseData: unknown;
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);
145
212
 
146
213
  const contentLength = fetchResponse.headers.get('content-length');
147
214
  if (
@@ -158,6 +225,7 @@ export default async function fetchAdapter(
158
225
  );
159
226
  }
160
227
 
228
+ let responseData: unknown;
161
229
  try {
162
230
  responseData = await readResponseData(fetchResponse, config);
163
231
  if (config.schema) {
@@ -178,59 +246,18 @@ export default async function fetchAdapter(
178
246
  );
179
247
  }
180
248
 
181
- const responseHeaders = parseHeaders(fetchResponse.headers);
182
-
183
249
  return {
184
250
  data: responseData,
185
251
  status: fetchResponse.status,
186
252
  statusText: fetchResponse.statusText,
187
- headers: responseHeaders,
188
- config: config,
253
+ headers: parseHeaders(fetchResponse.headers),
254
+ config,
189
255
  request: fetchResponse,
190
256
  duration: Date.now() - requestStartTime,
191
257
  };
192
258
  } catch (error) {
193
- if (error instanceof AccessioError) {
194
- throw error;
195
- }
196
-
197
- if (error instanceof Error && error.name === 'AbortError') {
198
- if (isTimedOut) {
199
- throw new AccessioError(
200
- `timeout of ${config.timeout}ms exceeded`,
201
- AccessioError.ETIMEDOUT,
202
- config,
203
- null,
204
- null,
205
- );
206
- }
207
- throw new AccessioError('Request aborted', AccessioError.ERR_CANCELED, config, null, null);
208
- }
209
-
210
- if (
211
- error instanceof TypeError &&
212
- (error.message.toLowerCase().includes('url') || error.message.toLowerCase().includes('fetch'))
213
- ) {
214
- throw new AccessioError(
215
- `Invalid URL: ${fullURL}`,
216
- AccessioError.ERR_INVALID_URL,
217
- config,
218
- null,
219
- null,
220
- );
221
- }
222
-
223
- throw AccessioError.from(
224
- error instanceof Error ? error : new Error(String(error)),
225
- AccessioError.ERR_NETWORK,
226
- config,
227
- null,
228
- null,
229
- );
259
+ throw classifyFetchError(error, config, abort.isTimedOut());
230
260
  } finally {
231
- if (timeoutId) clearTimeout(timeoutId);
232
- if (config.signal && onUserAbort) {
233
- config.signal.removeEventListener('abort', onUserAbort);
234
- }
261
+ abort.cleanup();
235
262
  }
236
263
  }
@@ -10,6 +10,31 @@ import { defaultMemoryCache } from '../helpers/memoryCache';
10
10
  import type { AccessioRequestConfig, AccessioResponse, TransformFunction } from '../types';
11
11
 
12
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
+ }
13
38
 
14
39
  function buildTransformArray(
15
40
  transform: TransformFunction | TransformFunction[] | undefined,
@@ -27,7 +52,7 @@ function assertAllowedProtocol(fullURL: string, config: AccessioRequestConfig):
27
52
 
28
53
  let scheme: string | null = null;
29
54
  const match = /^([a-z][a-z\d+\-.]*):/i.exec(fullURL);
30
- if (match) scheme = match[1].toLowerCase() + ':';
55
+ if (match) scheme = `${match[1].toLowerCase()}:`;
31
56
  if (!scheme) return;
32
57
 
33
58
  if (!allowed.includes(scheme)) {
@@ -43,6 +68,21 @@ function assertAllowedProtocol(fullURL: string, config: AccessioRequestConfig):
43
68
  }
44
69
 
45
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
+ }
46
86
 
47
87
  export default async function dispatchRequest(
48
88
  config: AccessioRequestConfig,
@@ -62,28 +102,36 @@ export default async function dispatchRequest(
62
102
  await config.hooks.onBeforeRequest(config);
63
103
  }
64
104
 
105
+ const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
106
+ setBasicAuth(config, flatHeaders);
107
+
65
108
  const isGet = (config.method || 'GET').toUpperCase() === 'GET';
66
- const cacheKey = isGet ? `GET:${fullURL}` : '';
109
+ const cacheKey = isGet ? buildCacheKey(config, fullURL, flatHeaders) : '';
67
110
 
68
111
  if (isGet && config.cache) {
69
112
  const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
70
113
  const cached = await cacheProvider.get(cacheKey);
71
114
  if (cached) {
115
+ const cachedView: AccessioResponse = {
116
+ ...cached,
117
+ config: redactConfig(config) as typeof cached.config,
118
+ };
72
119
  if (config.hooks?.onRequestResponse) {
73
- await config.hooks.onRequestResponse(cached);
120
+ await config.hooks.onRequestResponse(cachedView);
74
121
  }
75
- return cached;
122
+ return cachedView;
76
123
  }
77
124
  }
78
125
 
79
126
  if (isGet && config.dedupe) {
80
- if (activeRequests.has(cacheKey)) {
81
- return activeRequests.get(cacheKey)!;
127
+ const inflight = activeRequests.get(cacheKey);
128
+ if (inflight) {
129
+ const shared = await inflight;
130
+ return finalizeResponse(shared, config);
82
131
  }
83
132
  }
84
133
 
85
- const performRequest = async () => {
86
- const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
134
+ const performRequest = async (): Promise<AccessioResponse> => {
87
135
  const requestTransforms = buildTransformArray(config.transformRequest);
88
136
  const requestData = await transformData(requestTransforms, config.data, flatHeaders, config);
89
137
 
@@ -95,8 +143,6 @@ export default async function dispatchRequest(
95
143
  removeContentType(flatHeaders);
96
144
  }
97
145
 
98
- setBasicAuth(config, flatHeaders);
99
-
100
146
  const fetchOptions: RequestInit = {
101
147
  method: (config.method || 'GET').toUpperCase(),
102
148
  headers: buildFetchHeaders(flatHeaders),
@@ -123,12 +169,9 @@ export default async function dispatchRequest(
123
169
  }
124
170
 
125
171
  const requestStartTime = Date.now();
126
-
127
172
  const response = await fetchAdapter(config, fullURL, fetchOptions, requestStartTime);
128
- response.config = redactConfig(response.config) as typeof response.config;
129
173
 
130
174
  const responseTransforms = buildTransformArray(config.transformResponse);
131
-
132
175
  response.data = await transformData(
133
176
  responseTransforms,
134
177
  response.data,
@@ -137,39 +180,44 @@ export default async function dispatchRequest(
137
180
  'response',
138
181
  );
139
182
 
140
- return new Promise<AccessioResponse>((resolve, reject) => {
141
- settle(
142
- resolve as (value: AccessioResponse) => void,
143
- reject as (reason: AccessioError) => void,
144
- response,
145
- config,
146
- );
147
- });
183
+ return response;
148
184
  };
149
185
 
150
186
  const promise = performRequest();
151
187
 
152
188
  if (isGet && config.dedupe) {
153
- activeRequests.set(cacheKey, promise);
189
+ trackActiveRequest(cacheKey, promise);
154
190
  const cleanup = () => {
155
- activeRequests.delete(cacheKey);
191
+ if (activeRequests.get(cacheKey) === promise) {
192
+ activeRequests.delete(cacheKey);
193
+ }
156
194
  };
157
195
  promise.then(cleanup, cleanup);
158
196
  }
159
197
 
160
198
  try {
161
- const response = await promise;
199
+ const shared = await promise;
200
+ const response = finalizeResponse(shared, config);
162
201
 
163
202
  if (isGet && config.cache) {
164
203
  const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
165
- await cacheProvider.set(cacheKey, response, config.cacheTTL);
204
+ await cacheProvider.set(cacheKey, shared, config.cacheTTL);
166
205
  }
167
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
+
168
216
  if (config.hooks?.onRequestResponse) {
169
- await config.hooks.onRequestResponse(response);
217
+ await config.hooks.onRequestResponse(settled);
170
218
  }
171
219
 
172
- return response;
220
+ return settled;
173
221
  } catch (error) {
174
222
  if (config.hooks?.onRequestError && error instanceof AccessioError) {
175
223
  await config.hooks.onRequestError(error);
@@ -177,3 +225,13 @@ export default async function dispatchRequest(
177
225
  throw error;
178
226
  }
179
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
+ }
@@ -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
- const preview = JSON.stringify(config.data);
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
  }
@@ -9,7 +9,7 @@ export default function settle(
9
9
  ): void {
10
10
  const validateStatus = config.validateStatus;
11
11
 
12
- if (!response.status || !validateStatus || validateStatus(response.status)) {
12
+ if (!validateStatus || validateStatus(response.status)) {
13
13
  resolve(response);
14
14
  } else {
15
15
  const error = new AccessioError(
@@ -1,12 +1,12 @@
1
1
  import type { TransformFunction, InterceptorHandler, InterceptorOptions } from '../types';
2
2
 
3
3
  export class InterceptorManager {
4
- handlers: Array<InterceptorHandler | null>;
5
- private _activeCount: number;
4
+ private _handlers: Map<number, InterceptorHandler>;
5
+ private _nextId: number;
6
6
 
7
7
  constructor() {
8
- this.handlers = [];
9
- this._activeCount = 0;
8
+ this._handlers = new Map();
9
+ this._nextId = 0;
10
10
  }
11
11
 
12
12
  use(
@@ -14,39 +14,46 @@ export class InterceptorManager {
14
14
  rejected?: ((error: unknown) => unknown) | null,
15
15
  options: InterceptorOptions = {},
16
16
  ): number {
17
- this.handlers.push({
17
+ const id = this._nextId++;
18
+ this._handlers.set(id, {
18
19
  fulfilled: fulfilled || null,
19
20
  rejected: rejected || null,
20
21
  synchronous: options.synchronous || false,
21
22
  runWhen: options.runWhen || null,
22
23
  });
23
-
24
- this._activeCount++;
25
- return this.handlers.length - 1;
24
+ return id;
26
25
  }
27
26
 
28
27
  eject(id: number): void {
29
- if (this.handlers[id]) {
30
- this.handlers[id] = null;
31
- this._activeCount--;
32
- }
28
+ this._handlers.delete(id);
33
29
  }
34
30
 
35
31
  clear(): void {
36
- this.handlers = [];
37
- this._activeCount = 0;
32
+ this._handlers.clear();
38
33
  }
39
34
 
40
35
  forEach(fn: (handler: InterceptorHandler) => void): void {
41
- for (const handler of this.handlers) {
42
- if (handler !== null) {
43
- fn(handler);
44
- }
36
+ for (const handler of this._handlers.values()) {
37
+ fn(handler);
45
38
  }
46
39
  }
47
40
 
48
41
  get size(): number {
49
- return this._activeCount;
42
+ return this._handlers.size;
43
+ }
44
+
45
+ /**
46
+ * Snapshot view for backward-compat introspection. Slot index = interceptor ID;
47
+ * ejected IDs appear as `null`. Reading this builds a fresh array each time —
48
+ * prefer `forEach`/`size` in hot paths.
49
+ */
50
+ get handlers(): Array<InterceptorHandler | null> {
51
+ const max = this._nextId;
52
+ const out: Array<InterceptorHandler | null> = new Array(max);
53
+ for (let i = 0; i < max; i++) {
54
+ out[i] = this._handlers.get(i) ?? null;
55
+ }
56
+ return out;
50
57
  }
51
58
  }
52
59