apexa 0.0.0-stage → 0.1.1
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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/dist/index.d.ts +149 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +691 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/docs/API.md +207 -0
- package/package.json +52 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apexa — configurable, strongly typed HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
/* -------------------------------------------------------------------------- */
|
|
7
|
+
/* Defaults */
|
|
8
|
+
/* -------------------------------------------------------------------------- */
|
|
9
|
+
const DEFAULT_TIMEOUT = 15_000;
|
|
10
|
+
const DEFAULT_MINIMUM_TIME = 0;
|
|
11
|
+
const DEFAULT_CREDENTIALS = "same-origin";
|
|
12
|
+
const DEFAULT_RETRY = {
|
|
13
|
+
enabled: false,
|
|
14
|
+
retries: 2,
|
|
15
|
+
delay: 300,
|
|
16
|
+
maxDelay: 5_000,
|
|
17
|
+
backoff: 2,
|
|
18
|
+
statusCodes: [408, 425, 429, 500, 502, 503, 504],
|
|
19
|
+
retryNetworkErrors: true,
|
|
20
|
+
};
|
|
21
|
+
const DEFAULT_CSRF = {
|
|
22
|
+
enabled: false,
|
|
23
|
+
cookieName: "csrf-token",
|
|
24
|
+
headerName: "X-CSRF-Token",
|
|
25
|
+
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
26
|
+
required: true,
|
|
27
|
+
};
|
|
28
|
+
const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
|
|
29
|
+
const RETRY_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
30
|
+
/* -------------------------------------------------------------------------- */
|
|
31
|
+
/* Errors */
|
|
32
|
+
/* -------------------------------------------------------------------------- */
|
|
33
|
+
/**
|
|
34
|
+
* Standardized error thrown by Apexa.
|
|
35
|
+
*
|
|
36
|
+
* Use `code` for programmatic handling and `response` to inspect an HTTP
|
|
37
|
+
* response when one was received.
|
|
38
|
+
*/
|
|
39
|
+
export class ApexaError extends Error {
|
|
40
|
+
/** Stable error code. */
|
|
41
|
+
code;
|
|
42
|
+
/** Request configuration, when available. */
|
|
43
|
+
config;
|
|
44
|
+
/** Parsed response, when available. */
|
|
45
|
+
response;
|
|
46
|
+
/**
|
|
47
|
+
* Create an Apexa error.
|
|
48
|
+
*
|
|
49
|
+
* @param message - Human-readable error message.
|
|
50
|
+
* @param options - Error code and optional diagnostic context.
|
|
51
|
+
*/
|
|
52
|
+
constructor(message, options) {
|
|
53
|
+
super(message, { cause: options.cause });
|
|
54
|
+
this.name = "ApexaError";
|
|
55
|
+
this.code = options.code;
|
|
56
|
+
if (options.config !== undefined)
|
|
57
|
+
this.config = options.config;
|
|
58
|
+
if (options.response !== undefined)
|
|
59
|
+
this.response = options.response;
|
|
60
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Alias for applications that prefer the name `ApiError`. */
|
|
64
|
+
export { ApexaError as ApiError };
|
|
65
|
+
/**
|
|
66
|
+
* Manages registration, removal, and snapshotting of interceptors.
|
|
67
|
+
*
|
|
68
|
+
* Interceptor identifiers remain stable until removed. Removing an interceptor
|
|
69
|
+
* does not alter requests that have already taken their snapshot.
|
|
70
|
+
*/
|
|
71
|
+
class InterceptorManager {
|
|
72
|
+
handlers = [];
|
|
73
|
+
nextId = 0;
|
|
74
|
+
/**
|
|
75
|
+
* Register a fulfilled and/or rejected callback.
|
|
76
|
+
*
|
|
77
|
+
* @returns An identifier for later removal with `eject`.
|
|
78
|
+
*/
|
|
79
|
+
use(fulfilled, rejected) {
|
|
80
|
+
const id = this.nextId++;
|
|
81
|
+
this.handlers.push({
|
|
82
|
+
id,
|
|
83
|
+
...(fulfilled !== undefined ? { fulfilled } : {}),
|
|
84
|
+
...(rejected !== undefined ? { rejected } : {}),
|
|
85
|
+
});
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
/** Remove a registered interceptor. Unknown IDs are ignored. */
|
|
89
|
+
eject(id) {
|
|
90
|
+
this.handlers = this.handlers.filter((handler) => handler.id !== id);
|
|
91
|
+
}
|
|
92
|
+
/** Remove all interceptors from this manager. */
|
|
93
|
+
clear() {
|
|
94
|
+
this.handlers = [];
|
|
95
|
+
}
|
|
96
|
+
/** Return a stable snapshot of the current handlers. */
|
|
97
|
+
snapshot() {
|
|
98
|
+
return [...this.handlers];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Public request and response interceptor registry.
|
|
103
|
+
*
|
|
104
|
+
* Request interceptors execute in reverse registration order.
|
|
105
|
+
* Response interceptors execute in registration order.
|
|
106
|
+
*/
|
|
107
|
+
export class InterceptorRegistry {
|
|
108
|
+
/** Request interceptors. */
|
|
109
|
+
request = new InterceptorManager();
|
|
110
|
+
/** Response interceptors. */
|
|
111
|
+
response = new InterceptorManager();
|
|
112
|
+
}
|
|
113
|
+
/* -------------------------------------------------------------------------- */
|
|
114
|
+
/* Configuration and URL helpers */
|
|
115
|
+
/* -------------------------------------------------------------------------- */
|
|
116
|
+
function normalizeBasePath(value) {
|
|
117
|
+
if (!value?.trim())
|
|
118
|
+
return "";
|
|
119
|
+
const path = value.trim().replace(/^\/+|\/+$/g, "");
|
|
120
|
+
return path ? `/${path}` : "";
|
|
121
|
+
}
|
|
122
|
+
function isAbsoluteUrl(value) {
|
|
123
|
+
return /^[a-z][a-z\d+.-]*:\/\//i.test(value);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a request path against the effective base URL and base path.
|
|
127
|
+
* An absolute request URL bypasses both base settings.
|
|
128
|
+
*/
|
|
129
|
+
function resolveUrl(path, baseUrl, basePath = "") {
|
|
130
|
+
if (isAbsoluteUrl(path))
|
|
131
|
+
return path;
|
|
132
|
+
const cleanPath = path.trim().replace(/^\/+/, "");
|
|
133
|
+
const normalizedBasePath = normalizeBasePath(basePath).replace(/^\/+/, "");
|
|
134
|
+
const relativePath = [normalizedBasePath, cleanPath]
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join("/");
|
|
137
|
+
if (baseUrl === undefined || baseUrl.trim() === "") {
|
|
138
|
+
return `/${relativePath}`;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const base = new URL(baseUrl);
|
|
142
|
+
const baseUrlPath = base.pathname.replace(/\/+$/, "").replace(/^\/+/, "");
|
|
143
|
+
const fullPath = [baseUrlPath, relativePath].filter(Boolean).join("/");
|
|
144
|
+
return new URL(`/${fullPath}`, base.origin).toString();
|
|
145
|
+
}
|
|
146
|
+
catch (cause) {
|
|
147
|
+
throw new ApexaError("Invalid base URL.", {
|
|
148
|
+
code: "ERR_CONFIG",
|
|
149
|
+
cause,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function serializeQuery(query) {
|
|
154
|
+
if (!query)
|
|
155
|
+
return "";
|
|
156
|
+
const params = new URLSearchParams();
|
|
157
|
+
for (const [key, value] of Object.entries(query)) {
|
|
158
|
+
if (value === undefined || value === null)
|
|
159
|
+
continue;
|
|
160
|
+
if (value instanceof Date) {
|
|
161
|
+
params.append(key, value.toISOString());
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(value)) {
|
|
165
|
+
for (const item of value) {
|
|
166
|
+
params.append(key, item instanceof Date ? item.toISOString() : String(item));
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
params.append(key, String(value));
|
|
171
|
+
}
|
|
172
|
+
return params.toString();
|
|
173
|
+
}
|
|
174
|
+
function appendQuery(url, query) {
|
|
175
|
+
const serialized = serializeQuery(query);
|
|
176
|
+
if (!serialized)
|
|
177
|
+
return url;
|
|
178
|
+
const hashIndex = url.indexOf("#");
|
|
179
|
+
const hash = hashIndex >= 0 ? url.slice(hashIndex) : "";
|
|
180
|
+
const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
|
181
|
+
return `${withoutHash}${withoutHash.includes("?") ? "&" : "?"}${serialized}${hash}`;
|
|
182
|
+
}
|
|
183
|
+
function mergeHeaders(...sources) {
|
|
184
|
+
const result = new Headers();
|
|
185
|
+
for (const source of sources) {
|
|
186
|
+
if (source === undefined)
|
|
187
|
+
continue;
|
|
188
|
+
new Headers(source).forEach((value, key) => {
|
|
189
|
+
result.set(key, value);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
function mergeRetry(defaults, override) {
|
|
195
|
+
return {
|
|
196
|
+
...DEFAULT_RETRY,
|
|
197
|
+
...defaults,
|
|
198
|
+
...override,
|
|
199
|
+
statusCodes: override?.statusCodes ??
|
|
200
|
+
defaults?.statusCodes ??
|
|
201
|
+
DEFAULT_RETRY.statusCodes,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function mergeCsrf(defaults, override) {
|
|
205
|
+
return {
|
|
206
|
+
...DEFAULT_CSRF,
|
|
207
|
+
...defaults,
|
|
208
|
+
...override,
|
|
209
|
+
methods: override?.methods ?? defaults?.methods ?? DEFAULT_CSRF.methods,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function validateNonNegative(name, value) {
|
|
213
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
214
|
+
throw new ApexaError(`"${name}" must be a finite, non-negative number.`, {
|
|
215
|
+
code: "ERR_CONFIG",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function isPlainObject(value) {
|
|
220
|
+
if (value === null || typeof value !== "object")
|
|
221
|
+
return false;
|
|
222
|
+
const prototype = Object.getPrototypeOf(value);
|
|
223
|
+
return prototype === Object.prototype || prototype === null;
|
|
224
|
+
}
|
|
225
|
+
function prepareBody(body, headers) {
|
|
226
|
+
if (body === undefined || body === null)
|
|
227
|
+
return body;
|
|
228
|
+
if (isPlainObject(body) || Array.isArray(body)) {
|
|
229
|
+
if (!headers.has("Content-Type")) {
|
|
230
|
+
headers.set("Content-Type", "application/json");
|
|
231
|
+
}
|
|
232
|
+
return JSON.stringify(body);
|
|
233
|
+
}
|
|
234
|
+
return body;
|
|
235
|
+
}
|
|
236
|
+
function readCookie(name) {
|
|
237
|
+
if (typeof document === "undefined")
|
|
238
|
+
return undefined;
|
|
239
|
+
const prefix = `${encodeURIComponent(name)}=`;
|
|
240
|
+
const cookie = document.cookie
|
|
241
|
+
.split(";")
|
|
242
|
+
.map((item) => item.trim())
|
|
243
|
+
.find((item) => item.startsWith(prefix));
|
|
244
|
+
if (!cookie)
|
|
245
|
+
return undefined;
|
|
246
|
+
const value = cookie.slice(prefix.length);
|
|
247
|
+
try {
|
|
248
|
+
return decodeURIComponent(value);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function isAbortError(error) {
|
|
255
|
+
return (typeof error === "object" &&
|
|
256
|
+
error !== null &&
|
|
257
|
+
"name" in error &&
|
|
258
|
+
error.name === "AbortError");
|
|
259
|
+
}
|
|
260
|
+
function sleep(milliseconds) {
|
|
261
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
262
|
+
}
|
|
263
|
+
/* -------------------------------------------------------------------------- */
|
|
264
|
+
/* Response parsing */
|
|
265
|
+
/* -------------------------------------------------------------------------- */
|
|
266
|
+
/**
|
|
267
|
+
* Default response interpreter.
|
|
268
|
+
*
|
|
269
|
+
* JSON content types are parsed as JSON. Other content types are returned
|
|
270
|
+
* as text. Empty and bodyless responses return `undefined`.
|
|
271
|
+
*/
|
|
272
|
+
export const defaultInterpreter = async (response) => {
|
|
273
|
+
if (response.status === 204 ||
|
|
274
|
+
response.status === 205 ||
|
|
275
|
+
response.status === 304) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
279
|
+
const text = await response.text();
|
|
280
|
+
if (text.length === 0)
|
|
281
|
+
return undefined;
|
|
282
|
+
if (contentType.includes("application/json") ||
|
|
283
|
+
contentType.includes("+json")) {
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(text);
|
|
286
|
+
}
|
|
287
|
+
catch (cause) {
|
|
288
|
+
throw new ApexaError("The server returned invalid JSON.", {
|
|
289
|
+
code: "ERR_PARSE",
|
|
290
|
+
cause,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return text;
|
|
295
|
+
};
|
|
296
|
+
function toApiResponse(data, response, config) {
|
|
297
|
+
return {
|
|
298
|
+
data,
|
|
299
|
+
status: response.status,
|
|
300
|
+
statusText: response.statusText,
|
|
301
|
+
headers: new Headers(response.headers),
|
|
302
|
+
url: response.url || config.url,
|
|
303
|
+
config,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/* -------------------------------------------------------------------------- */
|
|
307
|
+
/* Default Fetch adapter */
|
|
308
|
+
/* -------------------------------------------------------------------------- */
|
|
309
|
+
/**
|
|
310
|
+
* Default Fetch-based transport adapter.
|
|
311
|
+
*
|
|
312
|
+
* It sends the normalized request configuration and returns the native
|
|
313
|
+
* `Response`. Response parsing and HTTP status handling are performed by Apexa.
|
|
314
|
+
*/
|
|
315
|
+
export const fetchAdapter = async (config) => {
|
|
316
|
+
if (config.method === "CONNECT" || config.method === "TRACE") {
|
|
317
|
+
throw new ApexaError(`Fetch does not support the ${config.method} method.`, { code: "ERR_CONFIG", config });
|
|
318
|
+
}
|
|
319
|
+
const headers = new Headers(config.headers);
|
|
320
|
+
const body = prepareBody(config.body, headers);
|
|
321
|
+
if (BODYLESS_METHODS.has(config.method) && body != null) {
|
|
322
|
+
throw new ApexaError(`${config.method} requests cannot include a body.`, {
|
|
323
|
+
code: "ERR_CONFIG",
|
|
324
|
+
config,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const init = {
|
|
328
|
+
method: config.method,
|
|
329
|
+
headers,
|
|
330
|
+
signal: config.signal,
|
|
331
|
+
credentials: config.credentials,
|
|
332
|
+
...(body !== undefined ? { body } : {}),
|
|
333
|
+
...(config.cache !== undefined ? { cache: config.cache } : {}),
|
|
334
|
+
...(config.redirect !== undefined ? { redirect: config.redirect } : {}),
|
|
335
|
+
...(config.referrer !== undefined ? { referrer: config.referrer } : {}),
|
|
336
|
+
...(config.referrerPolicy !== undefined
|
|
337
|
+
? { referrerPolicy: config.referrerPolicy }
|
|
338
|
+
: {}),
|
|
339
|
+
...(config.integrity !== undefined ? { integrity: config.integrity } : {}),
|
|
340
|
+
...(config.keepalive !== undefined ? { keepalive: config.keepalive } : {}),
|
|
341
|
+
...(config.mode !== undefined ? { mode: config.mode } : {}),
|
|
342
|
+
};
|
|
343
|
+
return fetch(config.url, init);
|
|
344
|
+
};
|
|
345
|
+
/* -------------------------------------------------------------------------- */
|
|
346
|
+
/* Apexa client */
|
|
347
|
+
/* -------------------------------------------------------------------------- */
|
|
348
|
+
/**
|
|
349
|
+
* Configurable HTTP client with typed response methods and interceptors.
|
|
350
|
+
*
|
|
351
|
+
* Instance defaults can be overridden on each request, including `baseUrl`
|
|
352
|
+
* and `basePath`.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* ```ts
|
|
356
|
+
* const api = new Apexa({
|
|
357
|
+
* baseUrl: "https://api.example.com",
|
|
358
|
+
* basePath: "/api/v1",
|
|
359
|
+
* timeout: 10_000,
|
|
360
|
+
* });
|
|
361
|
+
*
|
|
362
|
+
* const response = await api.get<User>("/users/123");
|
|
363
|
+
* console.log(response.data);
|
|
364
|
+
*
|
|
365
|
+
* // Override both URL settings for one request.
|
|
366
|
+
* const other = await api.get<User>("/users/123", {
|
|
367
|
+
* baseUrl: "https://other.example.com",
|
|
368
|
+
* basePath: "/v2",
|
|
369
|
+
* });
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
export class Apexa {
|
|
373
|
+
/** Request and response interceptors. */
|
|
374
|
+
interceptors = new InterceptorRegistry();
|
|
375
|
+
defaults;
|
|
376
|
+
/**
|
|
377
|
+
* Create an Apexa instance.
|
|
378
|
+
*
|
|
379
|
+
* @param config - Default options used by requests.
|
|
380
|
+
*/
|
|
381
|
+
constructor(config = {}) {
|
|
382
|
+
this.defaults = { ...config };
|
|
383
|
+
validateNonNegative("timeout", config.timeout ?? DEFAULT_TIMEOUT);
|
|
384
|
+
validateNonNegative("minimumTime", config.minimumTime ?? DEFAULT_MINIMUM_TIME);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Execute a request using the configured request pipeline.
|
|
388
|
+
*
|
|
389
|
+
* @typeParam T - Expected response data type.
|
|
390
|
+
* @param url - Absolute URL or relative endpoint path.
|
|
391
|
+
* @param options - Per-request overrides.
|
|
392
|
+
* @returns Parsed response and request metadata.
|
|
393
|
+
*/
|
|
394
|
+
async request(url, options = {}) {
|
|
395
|
+
const startedAt = Date.now();
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
const externalSignal = options.signal;
|
|
398
|
+
let timedOut = false;
|
|
399
|
+
let timeoutId;
|
|
400
|
+
let config;
|
|
401
|
+
let parsedResponse;
|
|
402
|
+
const abortFromExternal = () => {
|
|
403
|
+
controller.abort(externalSignal?.reason);
|
|
404
|
+
};
|
|
405
|
+
if (externalSignal?.aborted) {
|
|
406
|
+
abortFromExternal();
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
externalSignal?.addEventListener("abort", abortFromExternal, {
|
|
410
|
+
once: true,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const timeout = options.timeout ?? this.defaults.timeout ?? DEFAULT_TIMEOUT;
|
|
414
|
+
try {
|
|
415
|
+
validateNonNegative("timeout", timeout);
|
|
416
|
+
validateNonNegative("minimumTime", options.minimumTime ??
|
|
417
|
+
this.defaults.minimumTime ??
|
|
418
|
+
DEFAULT_MINIMUM_TIME);
|
|
419
|
+
if (timeout > 0) {
|
|
420
|
+
timeoutId = setTimeout(() => {
|
|
421
|
+
timedOut = true;
|
|
422
|
+
controller.abort(new DOMException("The request timed out.", "TimeoutError"));
|
|
423
|
+
}, timeout);
|
|
424
|
+
}
|
|
425
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
426
|
+
const baseUrl = options.baseUrl ?? this.defaults.baseUrl;
|
|
427
|
+
const basePath = normalizeBasePath(options.basePath ?? this.defaults.basePath);
|
|
428
|
+
const resolvedUrl = appendQuery(resolveUrl(url, baseUrl, basePath), options.query);
|
|
429
|
+
const headers = mergeHeaders(this.defaults.headers, options.headers);
|
|
430
|
+
const metadata = {
|
|
431
|
+
...this.defaults.metadata,
|
|
432
|
+
...options.metadata,
|
|
433
|
+
};
|
|
434
|
+
const retry = mergeRetry(this.defaults.retry, options.retry);
|
|
435
|
+
const csrf = mergeCsrf(this.defaults.csrf, options.csrf);
|
|
436
|
+
const rawConfig = {
|
|
437
|
+
url: resolvedUrl,
|
|
438
|
+
method,
|
|
439
|
+
headers,
|
|
440
|
+
signal: controller.signal,
|
|
441
|
+
basePath,
|
|
442
|
+
credentials: options.credentials ??
|
|
443
|
+
this.defaults.credentials ??
|
|
444
|
+
DEFAULT_CREDENTIALS,
|
|
445
|
+
timeout,
|
|
446
|
+
minimumTime: options.minimumTime ??
|
|
447
|
+
this.defaults.minimumTime ??
|
|
448
|
+
DEFAULT_MINIMUM_TIME,
|
|
449
|
+
metadata,
|
|
450
|
+
retry,
|
|
451
|
+
csrf,
|
|
452
|
+
...(options.body !== undefined ? { body: options.body } : {}),
|
|
453
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
454
|
+
...((options.cache ?? this.defaults.cache) !== undefined
|
|
455
|
+
? { cache: options.cache ?? this.defaults.cache }
|
|
456
|
+
: {}),
|
|
457
|
+
...((options.redirect ?? this.defaults.redirect) !== undefined
|
|
458
|
+
? { redirect: options.redirect ?? this.defaults.redirect }
|
|
459
|
+
: {}),
|
|
460
|
+
...((options.referrer ?? this.defaults.referrer) !== undefined
|
|
461
|
+
? { referrer: options.referrer ?? this.defaults.referrer }
|
|
462
|
+
: {}),
|
|
463
|
+
...((options.referrerPolicy ?? this.defaults.referrerPolicy) !==
|
|
464
|
+
undefined
|
|
465
|
+
? {
|
|
466
|
+
referrerPolicy: options.referrerPolicy ?? this.defaults.referrerPolicy,
|
|
467
|
+
}
|
|
468
|
+
: {}),
|
|
469
|
+
...((options.integrity ?? this.defaults.integrity) !== undefined
|
|
470
|
+
? { integrity: options.integrity ?? this.defaults.integrity }
|
|
471
|
+
: {}),
|
|
472
|
+
...((options.keepalive ?? this.defaults.keepalive) !== undefined
|
|
473
|
+
? { keepalive: options.keepalive ?? this.defaults.keepalive }
|
|
474
|
+
: {}),
|
|
475
|
+
...((options.mode ?? this.defaults.mode) !== undefined
|
|
476
|
+
? { mode: options.mode ?? this.defaults.mode }
|
|
477
|
+
: {}),
|
|
478
|
+
};
|
|
479
|
+
config = rawConfig;
|
|
480
|
+
// Request interceptors use a Promise chain so rejection callbacks
|
|
481
|
+
// can recover from a preceding request-interceptor failure.
|
|
482
|
+
let requestChain = Promise.resolve(config);
|
|
483
|
+
const requestHandlers = this.interceptors.request.snapshot();
|
|
484
|
+
for (let index = requestHandlers.length - 1; index >= 0; index -= 1) {
|
|
485
|
+
const handler = requestHandlers[index];
|
|
486
|
+
if (!handler)
|
|
487
|
+
continue;
|
|
488
|
+
requestChain = requestChain.then(handler.fulfilled
|
|
489
|
+
? (value) => handler.fulfilled?.(value)
|
|
490
|
+
: undefined, handler.rejected ? (error) => handler.rejected?.(error) : undefined);
|
|
491
|
+
}
|
|
492
|
+
config = (await requestChain);
|
|
493
|
+
if (!config || typeof config.url !== "string") {
|
|
494
|
+
throw new ApexaError("A request interceptor returned an invalid configuration.", { code: "ERR_INTERCEPTOR" });
|
|
495
|
+
}
|
|
496
|
+
await this.applyCsrf(config);
|
|
497
|
+
const adapter = options.adapter ?? this.defaults.adapter ?? fetchAdapter;
|
|
498
|
+
const response = await this.performWithRetry(config, adapter);
|
|
499
|
+
const interpreter = options.interpreter ?? this.defaults.interpreter ?? defaultInterpreter;
|
|
500
|
+
let data;
|
|
501
|
+
try {
|
|
502
|
+
data = await interpreter(response, config);
|
|
503
|
+
}
|
|
504
|
+
catch (cause) {
|
|
505
|
+
if (cause instanceof ApexaError && cause.code === "ERR_PARSE") {
|
|
506
|
+
throw new ApexaError(cause.message, {
|
|
507
|
+
code: "ERR_PARSE",
|
|
508
|
+
config,
|
|
509
|
+
cause,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
throw new ApexaError("Failed to parse the server response.", {
|
|
513
|
+
code: "ERR_PARSE",
|
|
514
|
+
config,
|
|
515
|
+
cause,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
parsedResponse = toApiResponse(data, response, config);
|
|
519
|
+
if (!response.ok) {
|
|
520
|
+
throw new ApexaError(`Request failed with HTTP status ${response.status}.`, {
|
|
521
|
+
code: "ERR_HTTP",
|
|
522
|
+
config,
|
|
523
|
+
response: parsedResponse,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const elapsed = Date.now() - startedAt;
|
|
527
|
+
const remaining = config.minimumTime - elapsed;
|
|
528
|
+
if (remaining > 0)
|
|
529
|
+
await sleep(remaining);
|
|
530
|
+
}
|
|
531
|
+
catch (originalError) {
|
|
532
|
+
let error = originalError;
|
|
533
|
+
if (originalError instanceof ApexaError) {
|
|
534
|
+
error = originalError;
|
|
535
|
+
}
|
|
536
|
+
else if (timedOut) {
|
|
537
|
+
error = new ApexaError("The request timed out.", {
|
|
538
|
+
code: "ERR_TIMEOUT",
|
|
539
|
+
...(config !== undefined ? { config } : {}),
|
|
540
|
+
cause: originalError,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
else if (externalSignal?.aborted ||
|
|
544
|
+
isAbortError(originalError) ||
|
|
545
|
+
controller.signal.aborted) {
|
|
546
|
+
error = new ApexaError("The request was aborted.", {
|
|
547
|
+
code: "ERR_ABORTED",
|
|
548
|
+
...(config !== undefined ? { config } : {}),
|
|
549
|
+
cause: originalError,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
else if (config === undefined) {
|
|
553
|
+
error = new ApexaError("Request configuration failed.", {
|
|
554
|
+
code: "ERR_CONFIG",
|
|
555
|
+
cause: originalError,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
error = new ApexaError("The request pipeline failed.", {
|
|
560
|
+
code: "ERR_UNKNOWN",
|
|
561
|
+
config,
|
|
562
|
+
cause: originalError,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return (await this.runResponseRejectionChain(error));
|
|
566
|
+
}
|
|
567
|
+
finally {
|
|
568
|
+
if (timeoutId !== undefined)
|
|
569
|
+
clearTimeout(timeoutId);
|
|
570
|
+
externalSignal?.removeEventListener("abort", abortFromExternal);
|
|
571
|
+
}
|
|
572
|
+
// Keep response fulfillment outside the catch block so an exception
|
|
573
|
+
// thrown by a fulfilled response interceptor is not processed twice.
|
|
574
|
+
return (await this.runResponseFulfillmentChain(parsedResponse));
|
|
575
|
+
}
|
|
576
|
+
/** Execute a GET request. */
|
|
577
|
+
get(url, options = {}) {
|
|
578
|
+
return this.request(url, { ...options, method: "GET" });
|
|
579
|
+
}
|
|
580
|
+
/** Execute a DELETE request. */
|
|
581
|
+
delete(url, options = {}) {
|
|
582
|
+
return this.request(url, { ...options, method: "DELETE" });
|
|
583
|
+
}
|
|
584
|
+
/** Execute a HEAD request. */
|
|
585
|
+
head(url, options = {}) {
|
|
586
|
+
return this.request(url, { ...options, method: "HEAD" });
|
|
587
|
+
}
|
|
588
|
+
/** Execute an OPTIONS request. */
|
|
589
|
+
options(url, options = {}) {
|
|
590
|
+
return this.request(url, { ...options, method: "OPTIONS" });
|
|
591
|
+
}
|
|
592
|
+
/** Execute a POST request. */
|
|
593
|
+
post(url, body, options = {}) {
|
|
594
|
+
return this.request(url, {
|
|
595
|
+
...options,
|
|
596
|
+
method: "POST",
|
|
597
|
+
...(body !== undefined ? { body } : {}),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/** Execute a PUT request. */
|
|
601
|
+
put(url, body, options = {}) {
|
|
602
|
+
return this.request(url, {
|
|
603
|
+
...options,
|
|
604
|
+
method: "PUT",
|
|
605
|
+
...(body !== undefined ? { body } : {}),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
/** Execute a PATCH request. */
|
|
609
|
+
patch(url, body, options = {}) {
|
|
610
|
+
return this.request(url, {
|
|
611
|
+
...options,
|
|
612
|
+
method: "PATCH",
|
|
613
|
+
...(body !== undefined ? { body } : {}),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
async applyCsrf(config) {
|
|
617
|
+
const csrf = config.csrf;
|
|
618
|
+
if (!csrf.enabled || !csrf.methods?.includes(config.method))
|
|
619
|
+
return;
|
|
620
|
+
let token;
|
|
621
|
+
if (csrf.tokenProvider) {
|
|
622
|
+
token = await csrf.tokenProvider();
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
token = readCookie(csrf.cookieName ?? DEFAULT_CSRF.cookieName);
|
|
626
|
+
}
|
|
627
|
+
if (token) {
|
|
628
|
+
config.headers.set(csrf.headerName ?? DEFAULT_CSRF.headerName, token);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (csrf.required !== false) {
|
|
632
|
+
throw new ApexaError("CSRF protection is enabled, but no token could be resolved.", { code: "ERR_CSRF", config });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async performWithRetry(config, adapter) {
|
|
636
|
+
const retry = config.retry;
|
|
637
|
+
const maxRetries = retry.enabled === true ? Math.max(0, retry.retries ?? 0) : 0;
|
|
638
|
+
const delay = Math.max(0, retry.delay ?? 0);
|
|
639
|
+
const maxDelay = Math.max(delay, retry.maxDelay ?? delay);
|
|
640
|
+
const backoff = Math.max(1, retry.backoff ?? 1);
|
|
641
|
+
const statuses = new Set(retry.statusCodes ?? DEFAULT_RETRY.statusCodes);
|
|
642
|
+
// Do not automatically retry unsafe methods, even if retries are enabled.
|
|
643
|
+
const retryMethodAllowed = RETRY_SAFE_METHODS.has(config.method);
|
|
644
|
+
for (let attempt = 0;; attempt += 1) {
|
|
645
|
+
try {
|
|
646
|
+
const response = await adapter(config);
|
|
647
|
+
if (retryMethodAllowed &&
|
|
648
|
+
attempt < maxRetries &&
|
|
649
|
+
statuses.has(response.status)) {
|
|
650
|
+
const retryAfter = response.headers.get("retry-after");
|
|
651
|
+
const retryAfterMs = retryAfter && /^\d+$/.test(retryAfter)
|
|
652
|
+
? Number(retryAfter) * 1_000
|
|
653
|
+
: 0;
|
|
654
|
+
await response.body?.cancel().catch(() => undefined);
|
|
655
|
+
const backoffMs = Math.min(maxDelay, delay * backoff ** attempt);
|
|
656
|
+
await sleep(Math.min(maxDelay, Math.max(backoffMs, retryAfterMs)));
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
return response;
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
if (!retryMethodAllowed ||
|
|
663
|
+
attempt >= maxRetries ||
|
|
664
|
+
retry.retryNetworkErrors === false ||
|
|
665
|
+
config.signal.aborted) {
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
await sleep(Math.min(maxDelay, delay * backoff ** attempt));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async runResponseFulfillmentChain(initialResponse) {
|
|
673
|
+
let chain = Promise.resolve(initialResponse);
|
|
674
|
+
for (const handler of this.interceptors.response.snapshot()) {
|
|
675
|
+
chain = chain.then(handler.fulfilled
|
|
676
|
+
? (value) => handler.fulfilled?.(value)
|
|
677
|
+
: undefined);
|
|
678
|
+
}
|
|
679
|
+
return (await chain);
|
|
680
|
+
}
|
|
681
|
+
async runResponseRejectionChain(initialError) {
|
|
682
|
+
let chain = Promise.reject(initialError);
|
|
683
|
+
for (const handler of this.interceptors.response.snapshot()) {
|
|
684
|
+
chain = chain.then(undefined, handler.rejected ? (error) => handler.rejected?.(error) : undefined);
|
|
685
|
+
}
|
|
686
|
+
return chain;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** Default export for convenient imports. */
|
|
690
|
+
export default Apexa;
|
|
691
|
+
//# sourceMappingURL=index.js.map
|