@api-wrappers/api-core 0.0.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/README.md +463 -0
- package/dist/index.cjs +994 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +755 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +755 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +973 -0
- package/dist/index.mjs.map +1 -0
- package/docs/README.md +42 -0
- package/docs/getting-started.md +93 -0
- package/docs/guides/built-in-plugins.md +119 -0
- package/docs/guides/error-handling.md +72 -0
- package/docs/guides/graphql.md +81 -0
- package/docs/guides/plugins.md +122 -0
- package/docs/guides/rest-requests.md +113 -0
- package/docs/guides/testing.md +88 -0
- package/docs/reference/client.md +53 -0
- package/docs/reference/configuration.md +83 -0
- package/docs/reference/exports.md +74 -0
- package/package.json +54 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/typeof.js
|
|
3
|
+
function _typeof(o) {
|
|
4
|
+
"@babel/helpers - typeof";
|
|
5
|
+
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
6
|
+
return typeof o;
|
|
7
|
+
} : function(o) {
|
|
8
|
+
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
9
|
+
}, _typeof(o);
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/toPrimitive.js
|
|
13
|
+
function toPrimitive(t, r) {
|
|
14
|
+
if ("object" != _typeof(t) || !t) return t;
|
|
15
|
+
var e = t[Symbol.toPrimitive];
|
|
16
|
+
if (void 0 !== e) {
|
|
17
|
+
var i = e.call(t, r || "default");
|
|
18
|
+
if ("object" != _typeof(i)) return i;
|
|
19
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
20
|
+
}
|
|
21
|
+
return ("string" === r ? String : Number)(t);
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/toPropertyKey.js
|
|
25
|
+
function toPropertyKey(t) {
|
|
26
|
+
var i = toPrimitive(t, "string");
|
|
27
|
+
return "symbol" == _typeof(i) ? i : i + "";
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/defineProperty.js
|
|
31
|
+
function _defineProperty(e, r, t) {
|
|
32
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
33
|
+
value: t,
|
|
34
|
+
enumerable: !0,
|
|
35
|
+
configurable: !0,
|
|
36
|
+
writable: !0
|
|
37
|
+
}) : e[r] = t, e;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/errors/ApiError.ts
|
|
41
|
+
var ApiError = class extends Error {
|
|
42
|
+
constructor(message, status, responseBody, cause) {
|
|
43
|
+
super(message);
|
|
44
|
+
_defineProperty(this, "status", void 0);
|
|
45
|
+
_defineProperty(this, "responseBody", void 0);
|
|
46
|
+
_defineProperty(this, "cause", void 0);
|
|
47
|
+
this.name = "ApiError";
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.responseBody = responseBody;
|
|
50
|
+
this.cause = cause;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/errors/RateLimitError.ts
|
|
55
|
+
var RateLimitError = class extends ApiError {
|
|
56
|
+
constructor(retryAfterMs, responseBody, cause) {
|
|
57
|
+
super("Rate limit exceeded", 429, responseBody, cause);
|
|
58
|
+
_defineProperty(this, "retryAfterMs", void 0);
|
|
59
|
+
this.name = "RateLimitError";
|
|
60
|
+
this.retryAfterMs = retryAfterMs;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/graphql/GraphQLRequestError.ts
|
|
65
|
+
/**
|
|
66
|
+
* Thrown when a GraphQL server returns a well-formed HTTP 200 response that
|
|
67
|
+
* contains a non-empty `errors` array.
|
|
68
|
+
*
|
|
69
|
+
* Extends {@link ApiError} so that code catching `ApiError` also catches
|
|
70
|
+
* GraphQL-level failures. Callers that need to inspect the individual error
|
|
71
|
+
* objects can narrow with `instanceof GraphQLRequestError` and read
|
|
72
|
+
* `graphqlErrors`.
|
|
73
|
+
*
|
|
74
|
+
* When the server returns both `data` and `errors` (partial result), the
|
|
75
|
+
* partial data is available on `partialData` but the error is still thrown —
|
|
76
|
+
* callers must explicitly opt in to consuming partial results.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* import { GraphQLRequestError } from "@api-wrappers/api-core";
|
|
81
|
+
*
|
|
82
|
+
* try {
|
|
83
|
+
* const data = await client.graphql<MyQuery>("/graphql", { query: QUERY });
|
|
84
|
+
* } catch (err) {
|
|
85
|
+
* if (err instanceof GraphQLRequestError) {
|
|
86
|
+
* for (const e of err.graphqlErrors) {
|
|
87
|
+
* console.error(e.message, e.path);
|
|
88
|
+
* }
|
|
89
|
+
* }
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
var GraphQLRequestError = class extends ApiError {
|
|
94
|
+
constructor(errors, partialData, cause) {
|
|
95
|
+
const message = errors.map((e) => e.message).join("; ");
|
|
96
|
+
super(`GraphQL errors: ${message}`, 200, { errors }, cause);
|
|
97
|
+
_defineProperty(this, "graphqlErrors", void 0);
|
|
98
|
+
_defineProperty(this, "partialData", void 0);
|
|
99
|
+
this.name = "GraphQLRequestError";
|
|
100
|
+
this.graphqlErrors = errors;
|
|
101
|
+
this.partialData = partialData;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/plugin/PluginManager.ts
|
|
106
|
+
var PluginManager = class {
|
|
107
|
+
/**
|
|
108
|
+
* @param logger - Logger used when an `onError` handler itself throws.
|
|
109
|
+
* Defaults to `console`. Pass a no-op object to silence all output.
|
|
110
|
+
*/
|
|
111
|
+
constructor(logger = console) {
|
|
112
|
+
_defineProperty(this, "plugins", []);
|
|
113
|
+
_defineProperty(this, "logger", void 0);
|
|
114
|
+
this.logger = logger;
|
|
115
|
+
}
|
|
116
|
+
register(plugin) {
|
|
117
|
+
if (plugin.enabled === false) return;
|
|
118
|
+
this.plugins.push(plugin);
|
|
119
|
+
this.plugins.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
|
120
|
+
}
|
|
121
|
+
getAll() {
|
|
122
|
+
return this.plugins;
|
|
123
|
+
}
|
|
124
|
+
async setup(client) {
|
|
125
|
+
for (const plugin of this.plugins) await plugin.setup?.(client);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Runs `beforeRequest` in ascending priority order (lowest first).
|
|
129
|
+
* Each plugin may return a mutated context.
|
|
130
|
+
*/
|
|
131
|
+
async beforeRequest(ctx) {
|
|
132
|
+
let current = ctx;
|
|
133
|
+
for (const plugin of this.plugins) try {
|
|
134
|
+
const result = await plugin.beforeRequest?.(current);
|
|
135
|
+
if (result != null) current = result;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw wrapPluginError(plugin.name, "beforeRequest", err, current);
|
|
138
|
+
}
|
|
139
|
+
return current;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Runs `afterResponse` in descending priority order (highest first).
|
|
143
|
+
* Each plugin may return a mutated context.
|
|
144
|
+
*/
|
|
145
|
+
async afterResponse(ctx) {
|
|
146
|
+
let current = ctx;
|
|
147
|
+
for (const plugin of [...this.plugins].reverse()) try {
|
|
148
|
+
const result = await plugin.afterResponse?.(current);
|
|
149
|
+
if (result != null) current = result;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw wrapPluginError(plugin.name, "afterResponse", err);
|
|
152
|
+
}
|
|
153
|
+
return current;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Runs `onError` on all plugins in registration order. A plugin throwing
|
|
157
|
+
* here is caught and logged via the configured logger but does not
|
|
158
|
+
* interrupt other `onError` handlers.
|
|
159
|
+
*/
|
|
160
|
+
async onError(error, ctx) {
|
|
161
|
+
for (const plugin of this.plugins) try {
|
|
162
|
+
await plugin.onError?.(error, ctx);
|
|
163
|
+
} catch (inner) {
|
|
164
|
+
this.logger.error(`[PluginManager] Plugin "${plugin.name}" threw inside onError:`, inner);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async dispose() {
|
|
168
|
+
for (const plugin of [...this.plugins].reverse()) await plugin.dispose?.();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
function getPluginErrorContext(error) {
|
|
172
|
+
if (!error || typeof error !== "object") return void 0;
|
|
173
|
+
return error.requestContext;
|
|
174
|
+
}
|
|
175
|
+
function wrapPluginError(name, hook, cause, requestContext) {
|
|
176
|
+
const message = `Plugin "${name}" threw during "${hook}"`;
|
|
177
|
+
const err = new Error(message, { cause });
|
|
178
|
+
err.name = "PluginError";
|
|
179
|
+
err.requestContext = requestContext;
|
|
180
|
+
return err;
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/errors/TimeoutError.ts
|
|
184
|
+
var TimeoutError = class extends Error {
|
|
185
|
+
constructor(message = "Request timed out", cause) {
|
|
186
|
+
super(message);
|
|
187
|
+
_defineProperty(this, "cause", void 0);
|
|
188
|
+
this.name = "TimeoutError";
|
|
189
|
+
this.cause = cause;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/utils/buildUrl.ts
|
|
194
|
+
/**
|
|
195
|
+
* Appends a query string to a URL. Skips nullish values and repeats keys for
|
|
196
|
+
* array values so APIs like TMDB can accept `with_genres=1&with_genres=2`.
|
|
197
|
+
*/
|
|
198
|
+
function buildUrl(base, query) {
|
|
199
|
+
if (!query || Object.keys(query).length === 0) return base;
|
|
200
|
+
const params = new URLSearchParams();
|
|
201
|
+
for (const [key, value] of Object.entries(query)) {
|
|
202
|
+
if (value === void 0 || value === null) continue;
|
|
203
|
+
if (Array.isArray(value)) {
|
|
204
|
+
for (const item of value) if (item !== void 0 && item !== null) params.append(key, String(item));
|
|
205
|
+
} else params.append(key, String(value));
|
|
206
|
+
}
|
|
207
|
+
const qs = params.toString();
|
|
208
|
+
if (!qs) return base;
|
|
209
|
+
return `${base}${base.includes("?") ? base.endsWith("?") || base.endsWith("&") ? "" : "&" : "?"}${qs}`;
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/utils/isPlainObject.ts
|
|
213
|
+
function isPlainObject(value) {
|
|
214
|
+
if (typeof value !== "object" || value === null) return false;
|
|
215
|
+
const proto = Object.getPrototypeOf(value);
|
|
216
|
+
return proto === Object.prototype || proto === null;
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/transport/fetchTransport.ts
|
|
220
|
+
/**
|
|
221
|
+
* Creates a {@link Transport} backed by the provided `fetch` function.
|
|
222
|
+
* Use this when you need a polyfill or a custom fetch interceptor:
|
|
223
|
+
*
|
|
224
|
+
* ```ts
|
|
225
|
+
* import nodeFetch from "node-fetch";
|
|
226
|
+
* createClient({ fetch: nodeFetch as typeof globalThis.fetch });
|
|
227
|
+
* // — or set it directly on the transport:
|
|
228
|
+
* const transport = createFetchTransport(nodeFetch as typeof globalThis.fetch);
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
function createFetchTransport(fetchFn = globalThis.fetch) {
|
|
232
|
+
return { async execute(ctx) {
|
|
233
|
+
const url = buildUrl(ctx.url, ctx.query);
|
|
234
|
+
const init = {
|
|
235
|
+
method: ctx.method,
|
|
236
|
+
headers: ctx.headers
|
|
237
|
+
};
|
|
238
|
+
if (ctx.body !== void 0 && ctx.method !== "GET" && ctx.method !== "HEAD") init.body = serializeRequestBody(ctx.body, ctx.headers);
|
|
239
|
+
if (ctx.timeoutMs !== void 0 || ctx.signal) {
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
let timedOut = false;
|
|
242
|
+
const abortFromParent = () => controller.abort(ctx.signal?.reason);
|
|
243
|
+
const timer = ctx.timeoutMs !== void 0 ? setTimeout(() => {
|
|
244
|
+
timedOut = true;
|
|
245
|
+
controller.abort();
|
|
246
|
+
}, ctx.timeoutMs) : void 0;
|
|
247
|
+
if (ctx.signal) if (ctx.signal.aborted) controller.abort(ctx.signal.reason);
|
|
248
|
+
else ctx.signal.addEventListener("abort", abortFromParent, { once: true });
|
|
249
|
+
try {
|
|
250
|
+
return await fetchFn(url, {
|
|
251
|
+
...init,
|
|
252
|
+
signal: controller.signal
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (timedOut && err instanceof Error && err.name === "AbortError") throw new TimeoutError(`Request timed out after ${ctx.timeoutMs}ms`, err);
|
|
256
|
+
throw err;
|
|
257
|
+
} finally {
|
|
258
|
+
if (timer) clearTimeout(timer);
|
|
259
|
+
ctx.signal?.removeEventListener("abort", abortFromParent);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return fetchFn(url, init);
|
|
263
|
+
} };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Default {@link Transport} backed by the global `fetch` API.
|
|
267
|
+
*
|
|
268
|
+
* Behaviour:
|
|
269
|
+
* - Builds the final URL from `ctx.url` + `ctx.query` via {@link buildUrl}.
|
|
270
|
+
* - Serialises `ctx.body` to JSON for non-GET/HEAD requests.
|
|
271
|
+
* - Wires an `AbortController` when `ctx.timeoutMs` is set; throws
|
|
272
|
+
* {@link TimeoutError} on abort.
|
|
273
|
+
*
|
|
274
|
+
* Replace this with a custom {@link Transport} in tests, or provide a custom
|
|
275
|
+
* `fetch` function via {@link ClientConfig.fetch}.
|
|
276
|
+
*/
|
|
277
|
+
const fetchTransport = createFetchTransport();
|
|
278
|
+
function serializeRequestBody(body, headers) {
|
|
279
|
+
if (isBodyInit(body)) return body;
|
|
280
|
+
const contentType = headers["content-type"] ?? "";
|
|
281
|
+
if (isPlainObject(body) || Array.isArray(body) || contentType.includes("json")) return JSON.stringify(body);
|
|
282
|
+
return String(body);
|
|
283
|
+
}
|
|
284
|
+
function isBodyInit(body) {
|
|
285
|
+
if (typeof body === "string") return true;
|
|
286
|
+
if (body instanceof ArrayBuffer) return true;
|
|
287
|
+
if (ArrayBuffer.isView(body)) return true;
|
|
288
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) return true;
|
|
289
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) return true;
|
|
290
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return true;
|
|
291
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return true;
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/utils/mergeHeaders.ts
|
|
296
|
+
/**
|
|
297
|
+
* Merges header objects left to right. Keys are normalized to
|
|
298
|
+
* lowercase so merging is case-insensitive. Later sources win.
|
|
299
|
+
*/
|
|
300
|
+
function mergeHeaders(...sources) {
|
|
301
|
+
const result = {};
|
|
302
|
+
for (const source of sources) {
|
|
303
|
+
if (!source) continue;
|
|
304
|
+
for (const [key, value] of Object.entries(source)) result[key.toLowerCase()] = value;
|
|
305
|
+
}
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/utils/resolveUrl.ts
|
|
310
|
+
/**
|
|
311
|
+
* Joins a client base URL and request path without requiring callers to keep
|
|
312
|
+
* slashes perfectly aligned. Absolute request URLs are returned unchanged.
|
|
313
|
+
*/
|
|
314
|
+
function resolveUrl(baseUrl, path) {
|
|
315
|
+
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(path)) return path;
|
|
316
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
317
|
+
const next = path.replace(/^\/+/, "");
|
|
318
|
+
return next ? `${base}/${next}` : base;
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/utils/sleep.ts
|
|
322
|
+
function sleep(ms) {
|
|
323
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/client/BaseHttpClient.ts
|
|
327
|
+
const DEFAULT_RETRIABLE_STATUS_CODES = [
|
|
328
|
+
429,
|
|
329
|
+
500,
|
|
330
|
+
502,
|
|
331
|
+
503,
|
|
332
|
+
504
|
|
333
|
+
];
|
|
334
|
+
/**
|
|
335
|
+
* Core HTTP client. Manages the plugin lifecycle, retry loop, and transport
|
|
336
|
+
* dispatch for all requests.
|
|
337
|
+
*
|
|
338
|
+
* Plugins are initialised lazily on the first call to {@link request} (or any
|
|
339
|
+
* convenience method). Call {@link dispose} when the client is no longer
|
|
340
|
+
* needed so plugins can release timers, connections, or cache handles.
|
|
341
|
+
*
|
|
342
|
+
* Extend this class to add domain-specific methods while keeping the plugin
|
|
343
|
+
* and transport infrastructure intact.
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```ts
|
|
347
|
+
* // Prefer createClient() in application code:
|
|
348
|
+
* const client = createClient({ baseUrl: "https://api.example.com/v1" });
|
|
349
|
+
*
|
|
350
|
+
* // Or subclass for wrapper packages:
|
|
351
|
+
* class MyApiClient extends BaseHttpClient {
|
|
352
|
+
* getUser(id: string) { return this.get<User>(`/users/${id}`); }
|
|
353
|
+
* }
|
|
354
|
+
* ```
|
|
355
|
+
*/
|
|
356
|
+
var BaseHttpClient = class {
|
|
357
|
+
constructor(config) {
|
|
358
|
+
_defineProperty(this, "config", void 0);
|
|
359
|
+
_defineProperty(this, "pluginManager", void 0);
|
|
360
|
+
_defineProperty(this, "initialized", false);
|
|
361
|
+
_defineProperty(this, "initPromise", void 0);
|
|
362
|
+
this.config = config;
|
|
363
|
+
this.pluginManager = new PluginManager(config.logger);
|
|
364
|
+
for (const plugin of config.plugins ?? []) this.pluginManager.register(plugin);
|
|
365
|
+
}
|
|
366
|
+
/** Initializes all plugins. Called lazily on first request. */
|
|
367
|
+
async init() {
|
|
368
|
+
if (this.initialized) return;
|
|
369
|
+
this.initPromise ?? (this.initPromise = this.pluginManager.setup(this).then(() => {
|
|
370
|
+
this.initialized = true;
|
|
371
|
+
}).catch((err) => {
|
|
372
|
+
this.initPromise = void 0;
|
|
373
|
+
throw err;
|
|
374
|
+
}));
|
|
375
|
+
await this.initPromise;
|
|
376
|
+
}
|
|
377
|
+
/** Disposes all plugins. Call when the client is no longer needed. */
|
|
378
|
+
async dispose() {
|
|
379
|
+
await this.pluginManager.dispose();
|
|
380
|
+
this.initialized = false;
|
|
381
|
+
this.initPromise = void 0;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Executes an HTTP request through the full plugin pipeline.
|
|
385
|
+
*
|
|
386
|
+
* Lifecycle per attempt:
|
|
387
|
+
* 1. Build `RequestContext` with merged headers, query, and retry state.
|
|
388
|
+
* 2. Run `beforeRequest` hooks (ascending priority). A plugin may set
|
|
389
|
+
* `ctx.syntheticResponse` to skip the transport entirely (e.g. cache hit).
|
|
390
|
+
* 3. Merge any `retry.*` meta written by {@link createRetryPlugin}.
|
|
391
|
+
* 4. Call transport (skipped when `syntheticResponse` is set).
|
|
392
|
+
* 5. Parse the response body (JSON or text).
|
|
393
|
+
* 6. Run `afterResponse` hooks (descending priority).
|
|
394
|
+
* 7. Retry on retriable status codes; throw on terminal failures.
|
|
395
|
+
*
|
|
396
|
+
* @param path - Path appended to `ClientConfig.baseUrl`. Should start with `/`.
|
|
397
|
+
* @param options - Per-request overrides for method, headers, body, query, etc.
|
|
398
|
+
* @returns The parsed response body cast to `T`.
|
|
399
|
+
* @throws {@link ApiError} for non-2xx responses.
|
|
400
|
+
* @throws {@link RateLimitError} for 429 responses.
|
|
401
|
+
* @throws {@link TimeoutError} when `timeoutMs` is exceeded.
|
|
402
|
+
*/
|
|
403
|
+
async request(path, options = {}) {
|
|
404
|
+
return (await this.requestWithResponse(path, options)).data;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Executes a request and returns the parsed body plus the final response
|
|
408
|
+
* context. Use this in wrappers that need response headers, status, or
|
|
409
|
+
* plugin metadata while keeping the same error/retry behaviour as
|
|
410
|
+
* {@link request}.
|
|
411
|
+
*/
|
|
412
|
+
async requestWithResponse(path, options = {}) {
|
|
413
|
+
await this.init();
|
|
414
|
+
const transport = this.config.transport ?? (this.config.fetch ? createFetchTransport(this.config.fetch) : fetchTransport);
|
|
415
|
+
const retryCfg = this.config.retry;
|
|
416
|
+
let maxAttempts = retryCfg?.maxAttempts ?? 1;
|
|
417
|
+
let baseDelay = retryCfg?.delayMs ?? 500;
|
|
418
|
+
let jitter = retryCfg?.jitter ?? true;
|
|
419
|
+
let retriableCodes = retryCfg?.retriableStatusCodes ?? DEFAULT_RETRIABLE_STATUS_CODES;
|
|
420
|
+
let lastError;
|
|
421
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
422
|
+
const baseCtx = {
|
|
423
|
+
url: resolveUrl(this.config.baseUrl, path),
|
|
424
|
+
method: options.method ?? "GET",
|
|
425
|
+
headers: mergeHeaders({ "content-type": "application/json" }, this.config.defaultHeaders, options.headers),
|
|
426
|
+
body: options.body,
|
|
427
|
+
query: options.query,
|
|
428
|
+
signal: options.signal,
|
|
429
|
+
meta: {},
|
|
430
|
+
cacheKey: options.cacheKey,
|
|
431
|
+
tags: options.tags,
|
|
432
|
+
retryCount: maxAttempts - 1 - attempt,
|
|
433
|
+
attempt,
|
|
434
|
+
timeoutMs: options.timeoutMs ?? this.config.timeoutMs
|
|
435
|
+
};
|
|
436
|
+
let ctx;
|
|
437
|
+
try {
|
|
438
|
+
ctx = await this.pluginManager.beforeRequest(baseCtx);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
await this.pluginManager.onError(err, getPluginErrorContext(err) ?? baseCtx);
|
|
441
|
+
throw err;
|
|
442
|
+
}
|
|
443
|
+
if (ctx.meta["retry.maxAttempts"] !== void 0) maxAttempts = ctx.meta["retry.maxAttempts"];
|
|
444
|
+
if (ctx.meta["retry.delayMs"] !== void 0) baseDelay = ctx.meta["retry.delayMs"];
|
|
445
|
+
if (ctx.meta["retry.jitter"] !== void 0) jitter = ctx.meta["retry.jitter"];
|
|
446
|
+
if (ctx.meta["retry.retriableStatusCodes"] !== void 0) retriableCodes = ctx.meta["retry.retriableStatusCodes"];
|
|
447
|
+
let rawResponse;
|
|
448
|
+
if (ctx.syntheticResponse) rawResponse = ctx.syntheticResponse;
|
|
449
|
+
else try {
|
|
450
|
+
rawResponse = await transport.execute(ctx);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
await this.pluginManager.onError(err, ctx);
|
|
453
|
+
lastError = err;
|
|
454
|
+
if (attempt < maxAttempts - 1) {
|
|
455
|
+
await this.waitForRetry(attempt, baseDelay, jitter);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
const parsedBody = await parseBody(rawResponse);
|
|
461
|
+
let resCtx = {
|
|
462
|
+
request: ctx,
|
|
463
|
+
response: rawResponse,
|
|
464
|
+
parsedBody,
|
|
465
|
+
meta: {}
|
|
466
|
+
};
|
|
467
|
+
try {
|
|
468
|
+
resCtx = await this.pluginManager.afterResponse(resCtx);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
await this.pluginManager.onError(err, ctx);
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
if (!rawResponse.ok) {
|
|
474
|
+
if (retriableCodes.includes(rawResponse.status) && attempt < maxAttempts - 1) {
|
|
475
|
+
if (rawResponse.status === 429) {
|
|
476
|
+
const wait = readRetryAfterMs(rawResponse);
|
|
477
|
+
await this.waitForRetry(attempt, wait ?? baseDelay, false);
|
|
478
|
+
} else await this.waitForRetry(attempt, baseDelay, jitter);
|
|
479
|
+
lastError = normalizeHttpError(rawResponse, resCtx.parsedBody);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const err = normalizeHttpError(rawResponse, resCtx.parsedBody);
|
|
483
|
+
await this.pluginManager.onError(err, ctx);
|
|
484
|
+
throw err;
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
data: resCtx.parsedBody,
|
|
488
|
+
response: resCtx.response,
|
|
489
|
+
request: resCtx.request,
|
|
490
|
+
meta: resCtx.meta
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
throw lastError;
|
|
494
|
+
}
|
|
495
|
+
/** Sends a GET request. The response body is not cached unless a cache plugin is registered. */
|
|
496
|
+
get(path, options) {
|
|
497
|
+
return this.request(path, {
|
|
498
|
+
...options,
|
|
499
|
+
method: "GET"
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
post(path, body, options) {
|
|
503
|
+
return this.request(path, {
|
|
504
|
+
...options,
|
|
505
|
+
method: "POST",
|
|
506
|
+
body
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
put(path, body, options) {
|
|
510
|
+
return this.request(path, {
|
|
511
|
+
...options,
|
|
512
|
+
method: "PUT",
|
|
513
|
+
body
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
patch(path, body, options) {
|
|
517
|
+
return this.request(path, {
|
|
518
|
+
...options,
|
|
519
|
+
method: "PATCH",
|
|
520
|
+
body
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
delete(path, options) {
|
|
524
|
+
return this.request(path, {
|
|
525
|
+
...options,
|
|
526
|
+
method: "DELETE"
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
head(path, options) {
|
|
530
|
+
return this.request(path, {
|
|
531
|
+
...options,
|
|
532
|
+
method: "HEAD"
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
options(path, options) {
|
|
536
|
+
return this.request(path, {
|
|
537
|
+
...options,
|
|
538
|
+
method: "OPTIONS"
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Executes a GraphQL query or mutation against a single endpoint path.
|
|
543
|
+
*
|
|
544
|
+
* The request is a `POST` with `content-type: application/json` carrying
|
|
545
|
+
* `{ query, variables?, operationName? }` as the body. It flows through
|
|
546
|
+
* the full plugin lifecycle (beforeRequest → transport → afterResponse →
|
|
547
|
+
* onError) and respects all retry configuration, exactly like REST calls.
|
|
548
|
+
*
|
|
549
|
+
* **Error handling:**
|
|
550
|
+
* - HTTP-level failures (429, 500, timeout) throw the same error classes
|
|
551
|
+
* as REST requests (`RateLimitError`, `ApiError`, `TimeoutError`).
|
|
552
|
+
* - A successful HTTP 200 that contains a non-empty `errors` array throws
|
|
553
|
+
* {@link GraphQLRequestError}, which extends `ApiError`.
|
|
554
|
+
*
|
|
555
|
+
* **Caching:**
|
|
556
|
+
* The cache plugin skips `POST` requests by default. Pass an explicit
|
|
557
|
+
* `cacheKey` in options to opt a specific operation into caching.
|
|
558
|
+
*
|
|
559
|
+
* @typeParam TData - Shape of the `data` field in the GraphQL response.
|
|
560
|
+
* @typeParam TVariables - Shape of the `variables` object. Defaults to
|
|
561
|
+
* `Record<string, unknown>`.
|
|
562
|
+
* @param path - Endpoint path, e.g. `"/graphql"`. Appended to `baseUrl`.
|
|
563
|
+
* @param options - Query document, variables, and optional per-request overrides.
|
|
564
|
+
* @returns The `data` field from the GraphQL response envelope.
|
|
565
|
+
* @throws {@link GraphQLRequestError} when `response.errors` is non-empty.
|
|
566
|
+
* @throws {@link ApiError} / {@link RateLimitError} / {@link TimeoutError} on
|
|
567
|
+
* HTTP-level failures.
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* ```ts
|
|
571
|
+
* const data = await client.graphql<GetUserQuery, GetUserQueryVariables>(
|
|
572
|
+
* "/graphql",
|
|
573
|
+
* { query: GET_USER, variables: { id: "123" } },
|
|
574
|
+
* );
|
|
575
|
+
* ```
|
|
576
|
+
*/
|
|
577
|
+
async graphql(path, options) {
|
|
578
|
+
const { query, variables, operationName, headers, timeoutMs, cacheKey, tags } = options;
|
|
579
|
+
const envelope = await this.request(path, {
|
|
580
|
+
method: "POST",
|
|
581
|
+
body: {
|
|
582
|
+
query,
|
|
583
|
+
...variables !== void 0 && { variables },
|
|
584
|
+
...operationName !== void 0 && { operationName }
|
|
585
|
+
},
|
|
586
|
+
headers,
|
|
587
|
+
timeoutMs,
|
|
588
|
+
cacheKey,
|
|
589
|
+
tags
|
|
590
|
+
});
|
|
591
|
+
if (envelope.errors && envelope.errors.length > 0) throw new GraphQLRequestError(envelope.errors, envelope.data);
|
|
592
|
+
return envelope.data;
|
|
593
|
+
}
|
|
594
|
+
async waitForRetry(attempt, baseDelay, useJitter) {
|
|
595
|
+
const exponential = baseDelay * 2 ** attempt;
|
|
596
|
+
const ms = useJitter ? exponential * (.5 + Math.random() * .5) : exponential;
|
|
597
|
+
await sleep(Math.round(ms));
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
async function parseBody(response) {
|
|
601
|
+
if (response.status === 204 || response.status === 205) return void 0;
|
|
602
|
+
if (response.headers.get("content-length") === "0") return void 0;
|
|
603
|
+
const text = await response.text();
|
|
604
|
+
if (!text) return void 0;
|
|
605
|
+
if ((response.headers.get("content-type") ?? "").includes("application/json")) return JSON.parse(text);
|
|
606
|
+
return text;
|
|
607
|
+
}
|
|
608
|
+
function normalizeHttpError(response, body) {
|
|
609
|
+
if (response.status === 429) return new RateLimitError(readRetryAfterMs(response), body);
|
|
610
|
+
return new ApiError(`Request failed with status ${response.status}`, response.status, body);
|
|
611
|
+
}
|
|
612
|
+
function readRetryAfterMs(response) {
|
|
613
|
+
const raw = response.headers.get("retry-after");
|
|
614
|
+
if (!raw) return void 0;
|
|
615
|
+
const seconds = Number(raw);
|
|
616
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
617
|
+
const date = Date.parse(raw);
|
|
618
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
619
|
+
}
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region src/client/createClient.ts
|
|
622
|
+
/**
|
|
623
|
+
* Factory function that creates a {@link BaseHttpClient} from the given
|
|
624
|
+
* config. Prefer this over `new BaseHttpClient(config)` in application code
|
|
625
|
+
* so that the concrete class stays an implementation detail.
|
|
626
|
+
*
|
|
627
|
+
* @example
|
|
628
|
+
* ```ts
|
|
629
|
+
* const client = createClient({
|
|
630
|
+
* baseUrl: "https://api.example.com/v1",
|
|
631
|
+
* defaultHeaders: { "x-api-key": "secret" },
|
|
632
|
+
* retry: { maxAttempts: 3, delayMs: 300 },
|
|
633
|
+
* plugins: [createLoggerPlugin(), createCachePlugin({ ttlMs: 60_000 })],
|
|
634
|
+
* });
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
function createClient(config) {
|
|
638
|
+
return new BaseHttpClient(config);
|
|
639
|
+
}
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/plugins/auth/authPlugin.ts
|
|
642
|
+
/**
|
|
643
|
+
* Adds an auth token header before each request. The token can be static or
|
|
644
|
+
* loaded asynchronously per request, which covers wrappers with refreshable
|
|
645
|
+
* access tokens.
|
|
646
|
+
*/
|
|
647
|
+
function createAuthPlugin(input) {
|
|
648
|
+
const options = normalizeOptions(input);
|
|
649
|
+
const headerName = (options.headerName ?? "authorization").toLowerCase();
|
|
650
|
+
const scheme = options.scheme === void 0 ? "Bearer" : options.scheme;
|
|
651
|
+
return {
|
|
652
|
+
name: "auth",
|
|
653
|
+
priority: 2,
|
|
654
|
+
async beforeRequest(ctx) {
|
|
655
|
+
const token = await options.getToken();
|
|
656
|
+
if (!token) return ctx;
|
|
657
|
+
return {
|
|
658
|
+
...ctx,
|
|
659
|
+
headers: {
|
|
660
|
+
...ctx.headers,
|
|
661
|
+
[headerName]: scheme ? `${scheme} ${token}` : token
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function normalizeOptions(input) {
|
|
668
|
+
if (typeof input === "string") return { getToken: () => input };
|
|
669
|
+
if (typeof input === "function") return { getToken: input };
|
|
670
|
+
return input;
|
|
671
|
+
}
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/plugins/cache/memoryStore.ts
|
|
674
|
+
var MemoryStore = class {
|
|
675
|
+
constructor() {
|
|
676
|
+
_defineProperty(this, "store", /* @__PURE__ */ new Map());
|
|
677
|
+
}
|
|
678
|
+
get(key) {
|
|
679
|
+
const entry = this.store.get(key);
|
|
680
|
+
if (!entry) return void 0;
|
|
681
|
+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
|
|
682
|
+
this.store.delete(key);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
return entry.value;
|
|
686
|
+
}
|
|
687
|
+
set(key, value, ttlMs) {
|
|
688
|
+
this.store.set(key, {
|
|
689
|
+
value,
|
|
690
|
+
expiresAt: ttlMs != null ? Date.now() + ttlMs : null
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
delete(key) {
|
|
694
|
+
this.store.delete(key);
|
|
695
|
+
}
|
|
696
|
+
clear() {
|
|
697
|
+
this.store.clear();
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/plugins/cache/cachePlugin.ts
|
|
702
|
+
const DEFAULT_CACHEABLE_METHODS = ["GET"];
|
|
703
|
+
const CACHE_HIT_META_KEY = "cache.hit";
|
|
704
|
+
function createCachePlugin(options = {}) {
|
|
705
|
+
const store = options.store ?? new MemoryStore();
|
|
706
|
+
const ttlMs = options.ttlMs;
|
|
707
|
+
const methods = options.methods ?? [...DEFAULT_CACHEABLE_METHODS];
|
|
708
|
+
const generateKey = options.generateKey ?? defaultCacheKey;
|
|
709
|
+
const tagIndex = /* @__PURE__ */ new Map();
|
|
710
|
+
return {
|
|
711
|
+
name: "cache",
|
|
712
|
+
priority: 20,
|
|
713
|
+
async beforeRequest(ctx) {
|
|
714
|
+
if (!methods.includes(ctx.method)) return ctx;
|
|
715
|
+
const key = ctx.cacheKey ?? generateKey(ctx);
|
|
716
|
+
const cached = await store.get(key);
|
|
717
|
+
if (cached !== void 0) {
|
|
718
|
+
const syntheticResponse = new Response(JSON.stringify(cached), {
|
|
719
|
+
status: 200,
|
|
720
|
+
headers: { "content-type": "application/json" }
|
|
721
|
+
});
|
|
722
|
+
return {
|
|
723
|
+
...ctx,
|
|
724
|
+
meta: {
|
|
725
|
+
...ctx.meta,
|
|
726
|
+
[CACHE_HIT_META_KEY]: {
|
|
727
|
+
key,
|
|
728
|
+
data: cached
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
syntheticResponse
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
...ctx,
|
|
736
|
+
meta: {
|
|
737
|
+
...ctx.meta,
|
|
738
|
+
"cache.key": key
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
},
|
|
742
|
+
async afterResponse(ctx) {
|
|
743
|
+
const hit = ctx.request.meta[CACHE_HIT_META_KEY];
|
|
744
|
+
if (hit) return {
|
|
745
|
+
...ctx,
|
|
746
|
+
parsedBody: hit.data,
|
|
747
|
+
meta: {
|
|
748
|
+
...ctx.meta,
|
|
749
|
+
"cache.served": true
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
const key = ctx.request.meta["cache.key"];
|
|
753
|
+
if (key && methods.includes(ctx.request.method) && ctx.response.ok) {
|
|
754
|
+
await store.set(key, ctx.parsedBody, ttlMs);
|
|
755
|
+
ctx.meta["cache.stored"] = true;
|
|
756
|
+
for (const tag of ctx.request.tags ?? []) {
|
|
757
|
+
if (!tagIndex.has(tag)) tagIndex.set(tag, /* @__PURE__ */ new Set());
|
|
758
|
+
tagIndex.get(tag)?.add(key);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return ctx;
|
|
762
|
+
},
|
|
763
|
+
async invalidate(key) {
|
|
764
|
+
await store.delete(key);
|
|
765
|
+
for (const keys of tagIndex.values()) keys.delete(key);
|
|
766
|
+
},
|
|
767
|
+
async invalidateByTag(tag) {
|
|
768
|
+
const keys = tagIndex.get(tag);
|
|
769
|
+
if (!keys || keys.size === 0) return;
|
|
770
|
+
for (const key of keys) {
|
|
771
|
+
await store.delete(key);
|
|
772
|
+
for (const otherKeys of tagIndex.values()) otherKeys.delete(key);
|
|
773
|
+
}
|
|
774
|
+
tagIndex.delete(tag);
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
function defaultCacheKey(ctx) {
|
|
779
|
+
const queryStr = ctx.query ? new URLSearchParams(Object.fromEntries(Object.entries(ctx.query).filter(([, v]) => v !== void 0).map(([k, v]) => [k, String(v)]))).toString() : "";
|
|
780
|
+
return `${ctx.method}:${ctx.url}${queryStr ? `?${queryStr}` : ""}`;
|
|
781
|
+
}
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region src/plugins/logger/loggerPlugin.ts
|
|
784
|
+
/**
|
|
785
|
+
* Creates a plugin that logs request start, response status, and errors.
|
|
786
|
+
*
|
|
787
|
+
* Log lines are prefixed with `[api-core]` and include the HTTP method, URL,
|
|
788
|
+
* attempt number (on `beforeRequest`), and status code (on `afterResponse`).
|
|
789
|
+
*
|
|
790
|
+
* Priority `10` means it runs _after_ auth or header-mutation plugins
|
|
791
|
+
* (priority < 10) so the logged URL and headers reflect the final request,
|
|
792
|
+
* but _before_ the cache plugin (priority `20`) so cache hits are still
|
|
793
|
+
* visible in the log.
|
|
794
|
+
*
|
|
795
|
+
* @example
|
|
796
|
+
* ```ts
|
|
797
|
+
* createClient({
|
|
798
|
+
* baseUrl: "https://api.example.com",
|
|
799
|
+
* plugins: [createLoggerPlugin({ logRequest: true, logResponse: true })],
|
|
800
|
+
* });
|
|
801
|
+
* ```
|
|
802
|
+
*/
|
|
803
|
+
function createLoggerPlugin(options = {}) {
|
|
804
|
+
const { logRequest = true, logResponse = true, logError = true, logger = console } = options;
|
|
805
|
+
return {
|
|
806
|
+
name: "logger",
|
|
807
|
+
priority: 10,
|
|
808
|
+
beforeRequest(ctx) {
|
|
809
|
+
if (logRequest) logger.info(`[api-core] --> ${ctx.method} ${ctx.url}`, {
|
|
810
|
+
attempt: ctx.attempt,
|
|
811
|
+
body: ctx.body
|
|
812
|
+
});
|
|
813
|
+
return ctx;
|
|
814
|
+
},
|
|
815
|
+
afterResponse(ctx) {
|
|
816
|
+
if (logResponse) logger.info(`[api-core] <-- ${ctx.response.status} ${ctx.request.method} ${ctx.request.url}`);
|
|
817
|
+
return ctx;
|
|
818
|
+
},
|
|
819
|
+
onError(error, ctx) {
|
|
820
|
+
if (logError) logger.error(`[api-core] ERR ${ctx.method} ${ctx.url}`, error);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/plugins/rateLimit/rateLimitPlugin.ts
|
|
826
|
+
const RELEASE_META_KEY = "rateLimit.release";
|
|
827
|
+
/**
|
|
828
|
+
* Throttles request starts before they reach the transport. Supports
|
|
829
|
+
* concurrency, minimum spacing, and fixed-window request budgets.
|
|
830
|
+
*/
|
|
831
|
+
function createRateLimitPlugin(options = {}) {
|
|
832
|
+
const maxConcurrent = options.maxConcurrent ?? Number.POSITIVE_INFINITY;
|
|
833
|
+
const minTimeMs = options.minTimeMs ?? 0;
|
|
834
|
+
const maxRequestsPerInterval = options.maxRequestsPerInterval;
|
|
835
|
+
const intervalMs = options.intervalMs;
|
|
836
|
+
if (maxConcurrent <= 0) throw new Error("maxConcurrent must be greater than 0");
|
|
837
|
+
if (minTimeMs < 0) throw new Error("minTimeMs must be greater than or equal to 0");
|
|
838
|
+
if ((maxRequestsPerInterval !== void 0 || intervalMs !== void 0) && (!maxRequestsPerInterval || maxRequestsPerInterval <= 0 || !intervalMs || intervalMs <= 0)) throw new Error("maxRequestsPerInterval and intervalMs must both be greater than 0");
|
|
839
|
+
const queue = [];
|
|
840
|
+
const starts = [];
|
|
841
|
+
let active = 0;
|
|
842
|
+
let lastStartAt = 0;
|
|
843
|
+
let timer;
|
|
844
|
+
const processQueue = () => {
|
|
845
|
+
if (timer) {
|
|
846
|
+
clearTimeout(timer);
|
|
847
|
+
timer = void 0;
|
|
848
|
+
}
|
|
849
|
+
while (queue.length > 0) {
|
|
850
|
+
const now = Date.now();
|
|
851
|
+
pruneStarts(now);
|
|
852
|
+
if (active >= maxConcurrent) return;
|
|
853
|
+
const waitMs = getWaitMs(now);
|
|
854
|
+
if (waitMs > 0) {
|
|
855
|
+
timer = setTimeout(processQueue, waitMs);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const item = queue.shift();
|
|
859
|
+
if (!item) return;
|
|
860
|
+
active++;
|
|
861
|
+
lastStartAt = now;
|
|
862
|
+
starts.push(now);
|
|
863
|
+
let released = false;
|
|
864
|
+
item.resolve(() => {
|
|
865
|
+
if (released) return;
|
|
866
|
+
released = true;
|
|
867
|
+
active--;
|
|
868
|
+
processQueue();
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
const acquire = () => new Promise((resolve) => {
|
|
873
|
+
queue.push({ resolve });
|
|
874
|
+
processQueue();
|
|
875
|
+
});
|
|
876
|
+
const release = (ctx) => {
|
|
877
|
+
const releaseFn = ctx.meta[RELEASE_META_KEY];
|
|
878
|
+
if (!releaseFn) return;
|
|
879
|
+
delete ctx.meta[RELEASE_META_KEY];
|
|
880
|
+
releaseFn();
|
|
881
|
+
};
|
|
882
|
+
const pruneStarts = (now) => {
|
|
883
|
+
if (!intervalMs) return;
|
|
884
|
+
while (starts.length > 0 && now - (starts[0] ?? 0) >= intervalMs) starts.shift();
|
|
885
|
+
};
|
|
886
|
+
const getWaitMs = (now) => {
|
|
887
|
+
const spacingWait = Math.max(0, lastStartAt + minTimeMs - now);
|
|
888
|
+
if (!maxRequestsPerInterval || !intervalMs) return spacingWait;
|
|
889
|
+
if (starts.length < maxRequestsPerInterval) return spacingWait;
|
|
890
|
+
const oldest = starts[0] ?? now;
|
|
891
|
+
const intervalWait = Math.max(0, oldest + intervalMs - now);
|
|
892
|
+
return Math.max(spacingWait, intervalWait);
|
|
893
|
+
};
|
|
894
|
+
return {
|
|
895
|
+
name: "rate-limit",
|
|
896
|
+
priority: 1,
|
|
897
|
+
async beforeRequest(ctx) {
|
|
898
|
+
const releaseFn = await acquire();
|
|
899
|
+
return {
|
|
900
|
+
...ctx,
|
|
901
|
+
meta: {
|
|
902
|
+
...ctx.meta,
|
|
903
|
+
[RELEASE_META_KEY]: releaseFn
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
},
|
|
907
|
+
afterResponse(ctx) {
|
|
908
|
+
release(ctx.request);
|
|
909
|
+
return ctx;
|
|
910
|
+
},
|
|
911
|
+
onError(_error, ctx) {
|
|
912
|
+
release(ctx);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/plugins/retry/retryPlugin.ts
|
|
918
|
+
/**
|
|
919
|
+
* Writes retry configuration into request context meta so the
|
|
920
|
+
* BaseHttpClient retry loop can read it. Use this when you need
|
|
921
|
+
* per-request retry overrides rather than global ClientConfig.retry.
|
|
922
|
+
*/
|
|
923
|
+
function createRetryPlugin(options = {}) {
|
|
924
|
+
return {
|
|
925
|
+
name: "retry",
|
|
926
|
+
priority: 5,
|
|
927
|
+
beforeRequest(ctx) {
|
|
928
|
+
if (options.maxAttempts !== void 0) ctx.meta["retry.maxAttempts"] = options.maxAttempts;
|
|
929
|
+
if (options.delayMs !== void 0) ctx.meta["retry.delayMs"] = options.delayMs;
|
|
930
|
+
if (options.jitter !== void 0) ctx.meta["retry.jitter"] = options.jitter;
|
|
931
|
+
if (options.retriableStatusCodes !== void 0) ctx.meta["retry.retriableStatusCodes"] = options.retriableStatusCodes;
|
|
932
|
+
return ctx;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/plugins/timeout/timeoutPlugin.ts
|
|
938
|
+
/**
|
|
939
|
+
* Sets `ctx.timeoutMs` on every request so all requests made by this client
|
|
940
|
+
* abort after the configured duration. The actual abort and
|
|
941
|
+
* {@link TimeoutError} are handled by {@link fetchTransport}.
|
|
942
|
+
*
|
|
943
|
+
* Priority `1` ensures the timeout is stamped before any other plugin (e.g.
|
|
944
|
+
* logger, cache) runs — plugins that read `ctx.timeoutMs` will always see it.
|
|
945
|
+
* Use a `beforeRequest` hook with a lower priority to override per-request.
|
|
946
|
+
*
|
|
947
|
+
* Prefer `ClientConfig.timeoutMs` for a static global timeout. Use this
|
|
948
|
+
* plugin when you need to set or change the timeout through the plugin
|
|
949
|
+
* pipeline (e.g. from environment config loaded asynchronously in `setup`).
|
|
950
|
+
*
|
|
951
|
+
* @example
|
|
952
|
+
* ```ts
|
|
953
|
+
* createClient({
|
|
954
|
+
* baseUrl: "https://api.example.com",
|
|
955
|
+
* plugins: [createTimeoutPlugin({ timeoutMs: 5_000 })],
|
|
956
|
+
* });
|
|
957
|
+
* ```
|
|
958
|
+
*/
|
|
959
|
+
function createTimeoutPlugin(options) {
|
|
960
|
+
return {
|
|
961
|
+
name: "timeout",
|
|
962
|
+
priority: 1,
|
|
963
|
+
beforeRequest(ctx) {
|
|
964
|
+
return {
|
|
965
|
+
...ctx,
|
|
966
|
+
timeoutMs: options.timeoutMs
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
//#endregion
|
|
972
|
+
exports.ApiError = ApiError;
|
|
973
|
+
exports.BaseHttpClient = BaseHttpClient;
|
|
974
|
+
exports.GraphQLRequestError = GraphQLRequestError;
|
|
975
|
+
exports.MemoryStore = MemoryStore;
|
|
976
|
+
exports.PluginManager = PluginManager;
|
|
977
|
+
exports.RateLimitError = RateLimitError;
|
|
978
|
+
exports.TimeoutError = TimeoutError;
|
|
979
|
+
exports.buildUrl = buildUrl;
|
|
980
|
+
exports.createAuthPlugin = createAuthPlugin;
|
|
981
|
+
exports.createCachePlugin = createCachePlugin;
|
|
982
|
+
exports.createClient = createClient;
|
|
983
|
+
exports.createFetchTransport = createFetchTransport;
|
|
984
|
+
exports.createLoggerPlugin = createLoggerPlugin;
|
|
985
|
+
exports.createRateLimitPlugin = createRateLimitPlugin;
|
|
986
|
+
exports.createRetryPlugin = createRetryPlugin;
|
|
987
|
+
exports.createTimeoutPlugin = createTimeoutPlugin;
|
|
988
|
+
exports.fetchTransport = fetchTransport;
|
|
989
|
+
exports.isPlainObject = isPlainObject;
|
|
990
|
+
exports.mergeHeaders = mergeHeaders;
|
|
991
|
+
exports.resolveUrl = resolveUrl;
|
|
992
|
+
exports.sleep = sleep;
|
|
993
|
+
|
|
994
|
+
//# sourceMappingURL=index.cjs.map
|