@rebilly/api 0.0.0 → 0.1.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.
@@ -0,0 +1,15 @@
1
+ import { RetryConfig, RetryContext } from './types.ts';
2
+ /**
3
+ * The default retry predicate: idempotent methods — or any request carrying an
4
+ * `Idempotency-Key` header, which makes re-sending safe — on a transport error or a
5
+ * transient status. A custom `retryOn` fully replaces this (no method check kept).
6
+ */
7
+ export declare function defaultRetryOn(ctx: RetryContext): boolean;
8
+ /**
9
+ * The delay before the next attempt: a `Retry-After` header (seconds or HTTP-date)
10
+ * wins; otherwise fixed/exponential backoff over `retryDelay`, with full jitter
11
+ * unless `jitter === false`.
12
+ */
13
+ export declare function retryDelay(retry: RetryConfig, attempt: number, retryAfter: string | null): number;
14
+ /** Abort-aware sleep: resolves after `ms`, rejects with the abort reason immediately on abort. */
15
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import { abortError } from "./errors.js";
2
+ //#region src/runtime/retry.ts
3
+ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set([
4
+ "GET",
5
+ "HEAD",
6
+ "PUT",
7
+ "DELETE",
8
+ "OPTIONS"
9
+ ]);
10
+ var TRANSIENT_STATUS = /* @__PURE__ */ new Set([
11
+ 408,
12
+ 429,
13
+ 500,
14
+ 502,
15
+ 503,
16
+ 504
17
+ ]);
18
+ /**
19
+ * The default retry predicate: idempotent methods — or any request carrying an
20
+ * `Idempotency-Key` header, which makes re-sending safe — on a transport error or a
21
+ * transient status. A custom `retryOn` fully replaces this (no method check kept).
22
+ */
23
+ function defaultRetryOn(ctx) {
24
+ if (!(IDEMPOTENT_METHODS.has(ctx.request.method.toUpperCase()) || "Idempotency-Key" in ctx.request.headers || "idempotency-key" in ctx.request.headers)) return false;
25
+ return ctx.response === void 0 || TRANSIENT_STATUS.has(ctx.response.status);
26
+ }
27
+ /**
28
+ * The delay before the next attempt: a `Retry-After` header (seconds or HTTP-date)
29
+ * wins; otherwise fixed/exponential backoff over `retryDelay`, with full jitter
30
+ * unless `jitter === false`.
31
+ */
32
+ function retryDelay(retry, attempt, retryAfter) {
33
+ if (retryAfter) {
34
+ const seconds = Number(retryAfter);
35
+ if (!Number.isNaN(seconds)) return seconds * 1e3;
36
+ const when = Date.parse(retryAfter);
37
+ if (!Number.isNaN(when)) return Math.max(0, when - Date.now());
38
+ }
39
+ const base = retry.retryDelay ?? 1e3;
40
+ const raw = retry.retryStrategy === "fixed" ? base : base * Math.pow(2, attempt - 1);
41
+ return retry.jitter === false ? raw : Math.random() * raw;
42
+ }
43
+ /** Abort-aware sleep: resolves after `ms`, rejects with the abort reason immediately on abort. */
44
+ function sleep(ms, signal) {
45
+ return new Promise((resolve, reject) => {
46
+ if (signal?.aborted) {
47
+ reject(abortError(signal));
48
+ return;
49
+ }
50
+ const onAbort = () => {
51
+ clearTimeout(timer);
52
+ reject(abortError(signal));
53
+ };
54
+ const timer = setTimeout(() => {
55
+ if (signal) signal.removeEventListener("abort", onAbort);
56
+ resolve();
57
+ }, ms);
58
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
59
+ });
60
+ }
61
+ //#endregion
62
+ export { defaultRetryOn, retryDelay, sleep };
@@ -0,0 +1,35 @@
1
+ import { ClientConfig, Middleware, OperationContext, RequestContext, RequestOptions } from './types.ts';
2
+ /**
3
+ * Optional behaviors the send core can use but never statically imports — wired by
4
+ * `createClient` (the same seam the future inline-mode assembler relies on).
5
+ */
6
+ export type SendCapabilities = {
7
+ /** Serialize a typed multipart body (a plain object) to FormData. */
8
+ serializeMultipart?: (body: Record<string, unknown>) => FormData;
9
+ };
10
+ /**
11
+ * Normalize a caller's `HeadersInit` (plain record, `Headers` instance, or entry pairs)
12
+ * to a plain record — spreading a `Headers` or an array contributes no entries.
13
+ */
14
+ export declare function toHeaderRecord(headers: HeadersInit | undefined): Record<string, string>;
15
+ /**
16
+ * The effective middleware chain for a request: the single `onRequest`/`onResponse`/
17
+ * `onError` config hooks as one implicit first middleware, then `config.middleware`.
18
+ */
19
+ export declare function middlewareChain(config: ClientConfig): Middleware[];
20
+ /**
21
+ * The fetch core shared by every operation: default + config + per-call headers, the
22
+ * `onRequest` chain (BEFORE body serialization, so mutations are sent), body
23
+ * serialization (JSON, or FormData via the multipart capability; a stream passes
24
+ * through in one attempt), the retry loop
25
+ * (idempotent-only defaults, `Retry-After`, abandoned-body drain), and the reverse
26
+ * `onResponse` onion, then the `onSettled` chain once with the final outcome. Returns the
27
+ * final response plus the request context.
28
+ */
29
+ export declare function send(config: ClientConfig, op: OperationContext, url: string, init: RequestOptions, body: unknown | undefined, bodySpec: {
30
+ contentType: string;
31
+ multipart?: boolean;
32
+ } | undefined, caps: SendCapabilities, accept?: string): Promise<{
33
+ response: Response;
34
+ context: RequestContext;
35
+ }>;
@@ -0,0 +1,224 @@
1
+ import { TimeoutError, abortError } from "./errors.js";
2
+ import { defaultRetryOn, retryDelay, sleep } from "./retry.js";
3
+ //#region src/runtime/send.ts
4
+ /**
5
+ * Normalize a caller's `HeadersInit` (plain record, `Headers` instance, or entry pairs)
6
+ * to a plain record — spreading a `Headers` or an array contributes no entries.
7
+ */
8
+ function toHeaderRecord(headers) {
9
+ if (headers === void 0) return {};
10
+ if (headers instanceof Headers) {
11
+ const record = {};
12
+ headers.forEach((value, key) => {
13
+ record[key] = value;
14
+ });
15
+ return record;
16
+ }
17
+ if (Array.isArray(headers)) return Object.fromEntries(headers);
18
+ return headers;
19
+ }
20
+ /**
21
+ * The effective middleware chain for a request: the single `onRequest`/`onResponse`/
22
+ * `onError` config hooks as one implicit first middleware, then `config.middleware`.
23
+ */
24
+ function middlewareChain(config) {
25
+ return [...config.onRequest || config.onResponse || config.onError ? [{
26
+ onRequest: config.onRequest,
27
+ onResponse: config.onResponse,
28
+ onError: config.onError
29
+ }] : [], ...config.middleware ?? []];
30
+ }
31
+ /**
32
+ * `AbortSignal.timeout()` where the platform has it, else an `AbortController` armed by
33
+ * `setTimeout`: Chrome before 103, Safari before 16, and Firefox before 100 lack the
34
+ * static, and every request with a timeout would throw before `fetch` runs.
35
+ */
36
+ function timeoutSignal(ms) {
37
+ if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
38
+ const controller = new AbortController();
39
+ setTimeout(() => {
40
+ controller.abort(new DOMException(`Timed out after ${ms} ms`, "TimeoutError"));
41
+ }, ms);
42
+ return controller.signal;
43
+ }
44
+ /**
45
+ * `AbortSignal.any()` where the platform has it, else a controller that follows whichever
46
+ * signal aborts first: Chrome before 116, Safari before 17.4, and Firefox before 124 lack
47
+ * the static. The listeners come off once one signal fires, so a caller's long-lived
48
+ * signal does not keep one per request.
49
+ */
50
+ function anySignal(signals) {
51
+ if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);
52
+ const controller = new AbortController();
53
+ const listeners = /* @__PURE__ */ new Map();
54
+ const abort = (reason) => {
55
+ for (const [signal, listener] of listeners) signal.removeEventListener("abort", listener);
56
+ controller.abort(reason);
57
+ };
58
+ for (const signal of signals) {
59
+ const listener = () => abort(signal.reason);
60
+ listeners.set(signal, listener);
61
+ signal.addEventListener("abort", listener);
62
+ }
63
+ return controller.signal;
64
+ }
65
+ /**
66
+ * The fetch core shared by every operation: default + config + per-call headers, the
67
+ * `onRequest` chain (BEFORE body serialization, so mutations are sent), body
68
+ * serialization (JSON, or FormData via the multipart capability; a stream passes
69
+ * through in one attempt), the retry loop
70
+ * (idempotent-only defaults, `Retry-After`, abandoned-body drain), and the reverse
71
+ * `onResponse` onion, then the `onSettled` chain once with the final outcome. Returns the
72
+ * final response plus the request context.
73
+ */
74
+ async function send(config, op, url, init, body, bodySpec, caps, accept = "application/json") {
75
+ const { retry: callRetry, timeout: callTimeout, idempotencyKey: callKey, ...fetchInit } = init;
76
+ const retry = {
77
+ ...config.retry,
78
+ ...callRetry
79
+ };
80
+ const timeout = callTimeout ?? config.timeout;
81
+ const idempotency = callKey ?? config.idempotencyKey;
82
+ const headers = {
83
+ Accept: accept,
84
+ ...typeof config.headers === "function" ? await config.headers() : config.headers,
85
+ ...toHeaderRecord(fetchInit.headers)
86
+ };
87
+ const method = (fetchInit.method ?? "GET").toUpperCase();
88
+ if (idempotency !== void 0 && idempotency !== false && (method === "POST" || method === "PATCH") && !("Idempotency-Key" in headers) && !("idempotency-key" in headers)) headers["Idempotency-Key"] = typeof idempotency === "string" ? idempotency : typeof idempotency === "function" ? idempotency() : crypto.randomUUID();
89
+ if (typeof config.clientHeader === "string" && typeof document === "undefined" && !("X-Redocly-Client" in headers) && !("x-redocly-client" in headers)) headers["X-Redocly-Client"] = config.clientHeader;
90
+ const context = {
91
+ url,
92
+ method: fetchInit.method ?? "GET",
93
+ headers,
94
+ body,
95
+ operation: op
96
+ };
97
+ const middleware = middlewareChain(config);
98
+ for (const mw of middleware) if (mw.onRequest) await mw.onRequest(context);
99
+ let payload;
100
+ let isStream = false;
101
+ if (context.body !== void 0) {
102
+ const value = context.body;
103
+ const isBinary = value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
104
+ const isFormData = typeof FormData !== "undefined" && value instanceof FormData;
105
+ const isURLSearchParams = value instanceof URLSearchParams;
106
+ isStream = typeof ReadableStream !== "undefined" && value instanceof ReadableStream || value !== null && typeof value[Symbol.asyncIterator] === "function";
107
+ const hasContentType = "Content-Type" in context.headers || "content-type" in context.headers;
108
+ if (isFormData || isURLSearchParams || isBinary || isStream || typeof value === "string") {
109
+ payload = value;
110
+ const declared = bodySpec?.contentType;
111
+ if (!isFormData && !isURLSearchParams && !hasContentType && declared !== void 0 && !declared.toLowerCase().includes("multipart")) context.headers["Content-Type"] = declared;
112
+ } else if (bodySpec?.multipart === true) {
113
+ if (!caps.serializeMultipart) throw new Error("Multipart capability not wired: cannot serialize the request body");
114
+ payload = caps.serializeMultipart(value);
115
+ } else {
116
+ payload = JSON.stringify(value);
117
+ if (!hasContentType) context.headers["Content-Type"] = bodySpec?.contentType ?? "application/json";
118
+ }
119
+ }
120
+ const doFetch = config.fetch ?? fetch;
121
+ const maxAttempts = isStream ? 1 : 1 + (retry.retries ?? 0);
122
+ const retryOn = retry.retryOn ?? defaultRetryOn;
123
+ const signal = fetchInit.signal ?? void 0;
124
+ const startedAt = Date.now();
125
+ let attempt = 0;
126
+ let finalResponse;
127
+ let lastResponse;
128
+ try {
129
+ while (true) {
130
+ if (signal?.aborted) throw abortError(signal);
131
+ attempt++;
132
+ const budget = timeout ? timeoutSignal(timeout) : void 0;
133
+ const attemptSignal = budget && signal ? anySignal([signal, budget]) : budget ?? signal;
134
+ let response;
135
+ try {
136
+ response = await doFetch(context.url, {
137
+ ...fetchInit,
138
+ ...isStream ? { duplex: "half" } : {},
139
+ signal: attemptSignal,
140
+ method: context.method,
141
+ headers: context.headers,
142
+ body: payload
143
+ });
144
+ } catch (error) {
145
+ const timedOut = timeout !== void 0 && budget?.aborted && !signal?.aborted;
146
+ if (attempt < maxAttempts && !signal?.aborted && await retryOn({
147
+ attempt,
148
+ request: context,
149
+ error
150
+ })) {
151
+ await sleep(retryDelay(retry, attempt, null), signal);
152
+ continue;
153
+ }
154
+ if (timedOut) throw new TimeoutError(op.id, timeout, attempt);
155
+ throw error;
156
+ }
157
+ lastResponse = response;
158
+ for (let i = middleware.length - 1; i >= 0; i--) {
159
+ const onResponse = middleware[i].onResponse;
160
+ if (onResponse) {
161
+ const replaced = await onResponse(response, context);
162
+ if (replaced && replaced !== response) {
163
+ await response.body?.cancel().catch(() => void 0);
164
+ response = replaced;
165
+ lastResponse = replaced;
166
+ }
167
+ }
168
+ }
169
+ if (!response.ok && attempt < maxAttempts && !signal?.aborted && await retryOn({
170
+ attempt,
171
+ request: context,
172
+ response
173
+ })) {
174
+ const retryAfter = response.headers.get("retry-after");
175
+ await response.body?.cancel().catch(() => void 0);
176
+ lastResponse = void 0;
177
+ await sleep(retryDelay(retry, attempt, retryAfter), signal);
178
+ continue;
179
+ }
180
+ finalResponse = response;
181
+ break;
182
+ }
183
+ } catch (error) {
184
+ const settled = {
185
+ response: lastResponse,
186
+ error,
187
+ attempts: attempt,
188
+ durationMs: Date.now() - startedAt
189
+ };
190
+ for (const mw of middleware) {
191
+ if (!mw.onSettled) continue;
192
+ try {
193
+ await mw.onSettled(context, settled);
194
+ } catch (hookError) {
195
+ globalThis.console?.warn("onSettled hook failed", hookError);
196
+ }
197
+ }
198
+ throw error;
199
+ }
200
+ const settled = {
201
+ response: finalResponse,
202
+ attempts: attempt,
203
+ durationMs: Date.now() - startedAt
204
+ };
205
+ const hookErrors = [];
206
+ for (const mw of middleware) {
207
+ if (!mw.onSettled) continue;
208
+ try {
209
+ await mw.onSettled(context, settled);
210
+ } catch (hookError) {
211
+ hookErrors.push(hookError);
212
+ }
213
+ }
214
+ if (hookErrors.length > 0) {
215
+ await finalResponse.body?.cancel().catch(() => void 0);
216
+ throw hookErrors[0];
217
+ }
218
+ return {
219
+ response: finalResponse,
220
+ context
221
+ };
222
+ }
223
+ //#endregion
224
+ export { middlewareChain, send, toHeaderRecord };