tempest-react-sdk 0.45.1 → 0.46.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.
package/dist/http/api-client.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../utils/ids.cjs"),t=require("./build-url.cjs"),n=require("./errors.cjs"),r=require("./retry.cjs");var i=new Set([`GET`,`HEAD`,`OPTIONS`]),a=new Set([0,408,425,429]);function o(e,t){return!i.has(t)||!(e instanceof n.TempestApiError)?!1:a.has(e.status)||e.status>=500}function s(e){return e?e===!0?{}:e:null}function c(e){return typeof FormData<`u`&&e instanceof FormData}async function l(e,t){let r;try{r=await e.clone().json()}catch{try{r=await e.text()}catch{r=null}}return new n.TempestApiError(n.buildApiError(e.status,r,e.headers,t))}function u(n){let i=n.fetcher??globalThis.fetch.bind(globalThis);function a(){let e=n.getToken?.();return e?{Authorization:`Bearer ${e}`}:{}}async function u(e,r,o){let{body:s,params:l,headers:u,...d}=r,f=c(s),p={...f?{}:{"Content-Type":`application/json`},...o?{"X-Request-ID":o}:{},...n.headers,...a(),...u},m={...d,headers:p,credentials:n.withCredentials?`include`:d.credentials,body:s==null?void 0:f?s:JSON.stringify(s)};return i(t.buildApiUrl(n.baseURL,e,{prefix:n.prefix,params:l}),m)}async function d(t,r){let i=n.requestId?n.requestId():e.randomId(),a=await
|
|
1
|
+
const e=require("../utils/ids.cjs"),t=require("./build-url.cjs"),n=require("./errors.cjs"),r=require("./retry.cjs");var i=new Set([`GET`,`HEAD`,`OPTIONS`]),a=new Set([0,408,425,429]);function o(e,t){return!i.has(t)||!(e instanceof n.TempestApiError)?!1:a.has(e.status)||e.status>=500}function s(e){return e?e===!0?{}:e:null}function c(e){return typeof FormData<`u`&&e instanceof FormData}async function l(e,t){let r;try{r=await e.clone().json()}catch{try{r=await e.text()}catch{r=null}}return new n.TempestApiError(n.buildApiError(e.status,r,e.headers,t))}function u(n){let i=n.fetcher??globalThis.fetch.bind(globalThis);function a(){let e=n.getToken?.();return e?{Authorization:`Bearer ${e}`}:{}}async function u(e,r,o){let{body:s,params:l,headers:u,...d}=r,f=c(s),p={...f?{}:{"Content-Type":`application/json`},...o?{"X-Request-ID":o}:{},...n.headers,...a(),...u},m={...d,headers:p,credentials:n.withCredentials?`include`:d.credentials,body:s==null?void 0:f?s:JSON.stringify(s)};return i(t.buildApiUrl(n.baseURL,e,{prefix:n.prefix,params:l}),m)}async function d(e,t,r,i){let a=n.logger;if(!a)return u(e,t,r);let o=Date.now();try{let n=await u(e,t,r),s={requestId:r,status:n.status,ms:Date.now()-o},c=`${i} ${e} → ${n.status}`;return n.status>=400?a.warn(c,s):a.debug(c,s),n}catch(t){throw a.warn(`${i} ${e} → no response`,{requestId:r,ms:Date.now()-o,error:t}),t}}async function f(e,t){n.logger?.warn(`unauthorized — calling onUnauthorized`,{requestId:t,status:e.status}),await n.onUnauthorized?.(e)}async function p(t,r){let i=n.requestId?n.requestId():e.randomId(),a=(r.method??`GET`).toUpperCase(),o=await d(t,r,i,a);if(o.status===401){if(n.refresh){try{await n.refresh(),o=await d(t,r,i,a)}catch{throw await f(o,i),await l(o,i)}o.status===401&&await f(o,i)}else await f(o,i)}if(!o.ok)throw await l(o,i);if(o.status!==204)return(o.headers.get(`content-type`)??``).includes(`application/json`)?await o.json():await o.text()}async function m(e,t={}){let i=s(n.retry);if(!i)return p(e,t);let a=(t.method??`GET`).toUpperCase();return r.retry(()=>p(e,t),{...i,shouldRetry:i.shouldRetry??(e=>o(e,a))})}async function h(e,t,n=`POST`){return m(e,{method:n,body:t})}return{request:m,get:(e,t)=>m(e,{...t,method:`GET`}),post:(e,t)=>m(e,{...t,method:`POST`}),put:(e,t)=>m(e,{...t,method:`PUT`}),patch:(e,t)=>m(e,{...t,method:`PATCH`}),delete:(e,t)=>m(e,{...t,method:`DELETE`}),upload:h}}exports.createApiClient=u;
|
|
2
2
|
//# sourceMappingURL=api-client.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.cjs","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { buildApiError, TempestApiError } from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\nconst RETRIABLE_STATUSES: ReadonlySet<number> = new Set([0, 408, 425, 429]);\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return RETRIABLE_STATUSES.has(error.status) || error.status >= 500;\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n function authHeaders(): Record<string, string> {\n const token = config.getToken?.();\n return token ? { Authorization: `Bearer ${token}` } : {};\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId?: string,\n ): Promise<Response> {\n const { body, params, headers, ...rest } = options;\n const isForm = isFormData(body);\n\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(),\n ...(headers as Record<string, string> | undefined),\n };\n\n const init: RequestInit = {\n ...rest,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n return fetcher(buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }), init);\n }\n\n async function attempt<T>(path: string, options: RequestOptions): Promise<T> {\n const requestId = config.requestId ? config.requestId() : randomId();\n let response = await rawRequest(path, options, requestId);\n\n if (response.status === 401) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await rawRequest(path, options, requestId);\n } catch {\n await config.onUnauthorized?.(response);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await config.onUnauthorized?.(response);\n }\n } else {\n await config.onUnauthorized?.(response);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const retryOptions = resolveRetry(config.retry);\n if (!retryOptions) return attempt<T>(path, options);\n\n const method = (options.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, options), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n ): Promise<T> {\n return request<T>(path, { method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n upload,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"api-client.cjs","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { buildApiError, TempestApiError } from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\nconst RETRIABLE_STATUSES: ReadonlySet<number> = new Set([0, 408, 425, 429]);\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return RETRIABLE_STATUSES.has(error.status) || error.status >= 500;\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n function authHeaders(): Record<string, string> {\n const token = config.getToken?.();\n return token ? { Authorization: `Bearer ${token}` } : {};\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId?: string,\n ): Promise<Response> {\n const { body, params, headers, ...rest } = options;\n const isForm = isFormData(body);\n\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(),\n ...(headers as Record<string, string> | undefined),\n };\n\n const init: RequestInit = {\n ...rest,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n return fetcher(buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }), init);\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId);\n\n const startedAt = Date.now();\n try {\n const response = await rawRequest(path, options, requestId);\n const entry = { requestId, status: response.status, ms: Date.now() - startedAt };\n const line = `${method} ${path} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${method} ${path} → no response`, {\n requestId,\n ms: Date.now() - startedAt,\n error,\n });\n throw error;\n }\n }\n\n async function endSession(response: Response, requestId: string): Promise<void> {\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n await config.onUnauthorized?.(response);\n }\n\n async function attempt<T>(path: string, options: RequestOptions): Promise<T> {\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (options.method ?? \"GET\").toUpperCase();\n let response = await send(path, options, requestId, method);\n\n if (response.status === 401) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await send(path, options, requestId, method);\n } catch {\n await endSession(response, requestId);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await endSession(response, requestId);\n }\n } else {\n await endSession(response, requestId);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const retryOptions = resolveRetry(config.retry);\n if (!retryOptions) return attempt<T>(path, options);\n\n const method = (options.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, options), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n ): Promise<T> {\n return request<T>(path, { method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n upload,\n };\n}\n"],"mappings":"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"}
|
package/dist/http/api-client.js
CHANGED
|
@@ -59,59 +59,84 @@ function d(n) {
|
|
|
59
59
|
params: c
|
|
60
60
|
}), m);
|
|
61
61
|
}
|
|
62
|
-
async function d(t, r) {
|
|
63
|
-
let
|
|
64
|
-
if (a
|
|
62
|
+
async function d(e, t, r, i) {
|
|
63
|
+
let a = n.logger;
|
|
64
|
+
if (!a) return o(e, t, r);
|
|
65
|
+
let s = Date.now();
|
|
66
|
+
try {
|
|
67
|
+
let n = await o(e, t, r), c = {
|
|
68
|
+
requestId: r,
|
|
69
|
+
status: n.status,
|
|
70
|
+
ms: Date.now() - s
|
|
71
|
+
}, l = `${i} ${e} → ${n.status}`;
|
|
72
|
+
return n.status >= 400 ? a.warn(l, c) : a.debug(l, c), n;
|
|
73
|
+
} catch (t) {
|
|
74
|
+
throw a.warn(`${i} ${e} → no response`, {
|
|
75
|
+
requestId: r,
|
|
76
|
+
ms: Date.now() - s,
|
|
77
|
+
error: t
|
|
78
|
+
}), t;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function f(e, t) {
|
|
82
|
+
n.logger?.warn("unauthorized — calling onUnauthorized", {
|
|
83
|
+
requestId: t,
|
|
84
|
+
status: e.status
|
|
85
|
+
}), await n.onUnauthorized?.(e);
|
|
86
|
+
}
|
|
87
|
+
async function p(t, r) {
|
|
88
|
+
let i = n.requestId ? n.requestId() : e(), a = (r.method ?? "GET").toUpperCase(), o = await d(t, r, i, a);
|
|
89
|
+
if (o.status === 401) {
|
|
65
90
|
if (n.refresh) {
|
|
66
91
|
try {
|
|
67
|
-
await n.refresh(),
|
|
92
|
+
await n.refresh(), o = await d(t, r, i, a);
|
|
68
93
|
} catch {
|
|
69
|
-
throw await
|
|
94
|
+
throw await f(o, i), await u(o, i);
|
|
70
95
|
}
|
|
71
|
-
|
|
72
|
-
} else await
|
|
96
|
+
o.status === 401 && await f(o, i);
|
|
97
|
+
} else await f(o, i);
|
|
73
98
|
}
|
|
74
|
-
if (!
|
|
75
|
-
if (
|
|
99
|
+
if (!o.ok) throw await u(o, i);
|
|
100
|
+
if (o.status !== 204) return (o.headers.get("content-type") ?? "").includes("application/json") ? await o.json() : await o.text();
|
|
76
101
|
}
|
|
77
|
-
async function
|
|
102
|
+
async function m(e, t = {}) {
|
|
78
103
|
let r = c(n.retry);
|
|
79
|
-
if (!r) return
|
|
104
|
+
if (!r) return p(e, t);
|
|
80
105
|
let a = (t.method ?? "GET").toUpperCase();
|
|
81
|
-
return i(() =>
|
|
106
|
+
return i(() => p(e, t), {
|
|
82
107
|
...r,
|
|
83
108
|
shouldRetry: r.shouldRetry ?? ((e) => s(e, a))
|
|
84
109
|
});
|
|
85
110
|
}
|
|
86
|
-
async function
|
|
87
|
-
return
|
|
111
|
+
async function h(e, t, n = "POST") {
|
|
112
|
+
return m(e, {
|
|
88
113
|
method: n,
|
|
89
114
|
body: t
|
|
90
115
|
});
|
|
91
116
|
}
|
|
92
117
|
return {
|
|
93
|
-
request:
|
|
94
|
-
get: (e, t) =>
|
|
118
|
+
request: m,
|
|
119
|
+
get: (e, t) => m(e, {
|
|
95
120
|
...t,
|
|
96
121
|
method: "GET"
|
|
97
122
|
}),
|
|
98
|
-
post: (e, t) =>
|
|
123
|
+
post: (e, t) => m(e, {
|
|
99
124
|
...t,
|
|
100
125
|
method: "POST"
|
|
101
126
|
}),
|
|
102
|
-
put: (e, t) =>
|
|
127
|
+
put: (e, t) => m(e, {
|
|
103
128
|
...t,
|
|
104
129
|
method: "PUT"
|
|
105
130
|
}),
|
|
106
|
-
patch: (e, t) =>
|
|
131
|
+
patch: (e, t) => m(e, {
|
|
107
132
|
...t,
|
|
108
133
|
method: "PATCH"
|
|
109
134
|
}),
|
|
110
|
-
delete: (e, t) =>
|
|
135
|
+
delete: (e, t) => m(e, {
|
|
111
136
|
...t,
|
|
112
137
|
method: "DELETE"
|
|
113
138
|
}),
|
|
114
|
-
upload:
|
|
139
|
+
upload: h
|
|
115
140
|
};
|
|
116
141
|
}
|
|
117
142
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.js","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { buildApiError, TempestApiError } from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\nconst RETRIABLE_STATUSES: ReadonlySet<number> = new Set([0, 408, 425, 429]);\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return RETRIABLE_STATUSES.has(error.status) || error.status >= 500;\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n function authHeaders(): Record<string, string> {\n const token = config.getToken?.();\n return token ? { Authorization: `Bearer ${token}` } : {};\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId?: string,\n ): Promise<Response> {\n const { body, params, headers, ...rest } = options;\n const isForm = isFormData(body);\n\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(),\n ...(headers as Record<string, string> | undefined),\n };\n\n const init: RequestInit = {\n ...rest,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n return fetcher(buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }), init);\n }\n\n async function attempt<T>(path: string, options: RequestOptions): Promise<T> {\n const requestId = config.requestId ? config.requestId() : randomId();\n let response = await rawRequest(path, options, requestId);\n\n if (response.status === 401) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await rawRequest(path, options, requestId);\n } catch {\n await config.onUnauthorized?.(response);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await config.onUnauthorized?.(response);\n }\n } else {\n await config.onUnauthorized?.(response);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const retryOptions = resolveRetry(config.retry);\n if (!retryOptions) return attempt<T>(path, options);\n\n const method = (options.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, options), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n ): Promise<T> {\n return request<T>(path, { method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n upload,\n };\n}\n"],"mappings":";;;;;AAoBA,IAAM,oBAA0C,IAAI,IAAI;CAAC;CAAO;CAAQ;AAAS,CAAC,GAO5E,oBAA0C,IAAI,IAAI;CAAC;CAAG;CAAK;CAAK;AAAG,CAAC;AAc1E,SAAS,EAAmB,GAAgB,GAAyB;CAGjE,OAFI,CAAC,EAAmB,IAAI,CAAM,KAC9B,EAAE,aAAiB,KAAyB,KACzC,EAAmB,IAAI,EAAM,MAAM,KAAK,EAAM,UAAU;AACnE;AAQA,SAAS,EAAa,GAAiE;CAEnF,OADK,IACE,MAAW,KAAO,CAAC,IAAI,IADV;AAExB;AAEA,SAAS,EAAW,GAAiC;CACjD,OAAO,OAAO,WAAa,OAAe,aAAgB;AAC9D;AAEA,eAAe,EAAW,GAAoB,GAAkD;CAC5F,IAAI;CACJ,IAAI;EACA,IAAO,MAAM,EAAS,MAAM,CAAC,CAAC,KAAK;CACvC,QAAQ;EACJ,IAAI;GACA,IAAO,MAAM,EAAS,KAAK;EAC/B,QAAQ;GACJ,IAAO;EACX;CACJ;CACA,OAAO,IAAI,EACP,EAAc,EAAS,QAAQ,GAAM,EAAS,SAAS,CAAa,CACxE;AACJ;AA6BA,SAAgB,EAAgB,GAAoC;CAChE,IAAM,IAAU,EAAO,WAAW,WAAW,MAAM,KAAK,UAAU;CAElE,SAAS,IAAsC;EAC3C,IAAM,IAAQ,EAAO,WAAW;EAChC,OAAO,IAAQ,EAAE,eAAe,UAAU,IAAQ,IAAI,CAAC;CAC3D;CAEA,eAAe,EACX,GACA,GACA,GACiB;EACjB,IAAM,EAAE,SAAM,WAAQ,YAAS,GAAG,MAAS,GACrC,IAAS,EAAW,CAAI,GAExB,IAAuC;GACzC,GAAI,IAAS,CAAC,IAAI,EAAE,gBAAgB,mBAAmB;GACvD,GAAI,IAAY,EAAE,gBAAgB,EAAU,IAAI,CAAC;GACjD,GAAG,EAAO;GACV,GAAG,EAAY;GACf,GAAI;EACR,GAEM,IAAoB;GACtB,GAAG;GACH,SAAS;GACT,aAAa,EAAO,kBAAkB,YAAY,EAAK;GACvD,MACI,KAA+B,OACzB,KAAA,IACA,IACG,IACD,KAAK,UAAU,CAAI;EACnC;EAEA,OAAO,EAAQ,EAAY,EAAO,SAAS,GAAM;GAAE,QAAQ,EAAO;GAAQ;EAAO,CAAC,GAAG,CAAI;CAC7F;CAEA,eAAe,EAAW,GAAc,GAAqC;EACzE,IAAM,IAAY,EAAO,YAAY,EAAO,UAAU,IAAI,EAAS,GAC/D,IAAW,MAAM,EAAW,GAAM,GAAS,CAAS;EAExD,IAAI,EAAS,WAAW,KAAK;GACzB,IAAI,EAAO,SAAS;IAChB,IAAI;KAEA,AADA,MAAM,EAAO,QAAQ,GACrB,IAAW,MAAM,EAAW,GAAM,GAAS,CAAS;IACxD,QAAQ;KAEJ,MADA,MAAM,EAAO,iBAAiB,CAAQ,GAChC,MAAM,EAAW,GAAU,CAAS;IAC9C;IACA,AAAI,EAAS,WAAW,OACpB,MAAM,EAAO,iBAAiB,CAAQ;GAE9C,OACI,MAAM,EAAO,iBAAiB,CAAQ;EAE9C;EAEA,IAAI,CAAC,EAAS,IACV,MAAM,MAAM,EAAW,GAAU,CAAS;EAG1C,MAAS,WAAW,KAQxB,QAJoB,EAAS,QAAQ,IAAI,cAAc,KAAK,GAAA,CAC5C,SAAS,kBAAkB,IAC/B,MAAM,EAAS,KAAK,IAExB,MAAM,EAAS,KAAK;CAChC;CAEA,eAAe,EAAW,GAAc,IAA0B,CAAC,GAAe;EAC9E,IAAM,IAAe,EAAa,EAAO,KAAK;EAC9C,IAAI,CAAC,GAAc,OAAO,EAAW,GAAM,CAAO;EAElD,IAAM,KAAU,EAAQ,UAAU,MAAA,CAAO,YAAY;EACrD,OAAO,QAAuB,EAAW,GAAM,CAAO,GAAG;GACrD,GAAG;GACH,aACI,EAAa,iBAAiB,MAAmB,EAAmB,GAAO,CAAM;EACzF,CAAC;CACL;CAEA,eAAe,EACX,GACA,GACA,IAAmC,QACzB;EACV,OAAO,EAAW,GAAM;GAAE;GAAQ,MAAM;EAAS,CAAC;CACtD;CAEA,OAAO;EACH;EACA,MAAS,GAAc,MACnB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAM,CAAC;EAClD,OAAU,GAAc,MACpB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAO,CAAC;EACnD,MAAS,GAAc,MACnB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAM,CAAC;EAClD,QAAW,GAAc,MACrB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAQ,CAAC;EACpD,SAAY,GAAc,MACtB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAS,CAAC;EACrD;CACJ;AACJ"}
|
|
1
|
+
{"version":3,"file":"api-client.js","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { buildApiError, TempestApiError } from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\nconst RETRIABLE_STATUSES: ReadonlySet<number> = new Set([0, 408, 425, 429]);\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return RETRIABLE_STATUSES.has(error.status) || error.status >= 500;\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n function authHeaders(): Record<string, string> {\n const token = config.getToken?.();\n return token ? { Authorization: `Bearer ${token}` } : {};\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId?: string,\n ): Promise<Response> {\n const { body, params, headers, ...rest } = options;\n const isForm = isFormData(body);\n\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(),\n ...(headers as Record<string, string> | undefined),\n };\n\n const init: RequestInit = {\n ...rest,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n return fetcher(buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }), init);\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId);\n\n const startedAt = Date.now();\n try {\n const response = await rawRequest(path, options, requestId);\n const entry = { requestId, status: response.status, ms: Date.now() - startedAt };\n const line = `${method} ${path} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${method} ${path} → no response`, {\n requestId,\n ms: Date.now() - startedAt,\n error,\n });\n throw error;\n }\n }\n\n async function endSession(response: Response, requestId: string): Promise<void> {\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n await config.onUnauthorized?.(response);\n }\n\n async function attempt<T>(path: string, options: RequestOptions): Promise<T> {\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (options.method ?? \"GET\").toUpperCase();\n let response = await send(path, options, requestId, method);\n\n if (response.status === 401) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await send(path, options, requestId, method);\n } catch {\n await endSession(response, requestId);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await endSession(response, requestId);\n }\n } else {\n await endSession(response, requestId);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const retryOptions = resolveRetry(config.retry);\n if (!retryOptions) return attempt<T>(path, options);\n\n const method = (options.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, options), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n ): Promise<T> {\n return request<T>(path, { method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n upload,\n };\n}\n"],"mappings":";;;;;AAoBA,IAAM,oBAA0C,IAAI,IAAI;CAAC;CAAO;CAAQ;AAAS,CAAC,GAO5E,oBAA0C,IAAI,IAAI;CAAC;CAAG;CAAK;CAAK;AAAG,CAAC;AAc1E,SAAS,EAAmB,GAAgB,GAAyB;CAGjE,OAFI,CAAC,EAAmB,IAAI,CAAM,KAC9B,EAAE,aAAiB,KAAyB,KACzC,EAAmB,IAAI,EAAM,MAAM,KAAK,EAAM,UAAU;AACnE;AAQA,SAAS,EAAa,GAAiE;CAEnF,OADK,IACE,MAAW,KAAO,CAAC,IAAI,IADV;AAExB;AAEA,SAAS,EAAW,GAAiC;CACjD,OAAO,OAAO,WAAa,OAAe,aAAgB;AAC9D;AAEA,eAAe,EAAW,GAAoB,GAAkD;CAC5F,IAAI;CACJ,IAAI;EACA,IAAO,MAAM,EAAS,MAAM,CAAC,CAAC,KAAK;CACvC,QAAQ;EACJ,IAAI;GACA,IAAO,MAAM,EAAS,KAAK;EAC/B,QAAQ;GACJ,IAAO;EACX;CACJ;CACA,OAAO,IAAI,EACP,EAAc,EAAS,QAAQ,GAAM,EAAS,SAAS,CAAa,CACxE;AACJ;AAoCA,SAAgB,EAAgB,GAAoC;CAChE,IAAM,IAAU,EAAO,WAAW,WAAW,MAAM,KAAK,UAAU;CAElE,SAAS,IAAsC;EAC3C,IAAM,IAAQ,EAAO,WAAW;EAChC,OAAO,IAAQ,EAAE,eAAe,UAAU,IAAQ,IAAI,CAAC;CAC3D;CAEA,eAAe,EACX,GACA,GACA,GACiB;EACjB,IAAM,EAAE,SAAM,WAAQ,YAAS,GAAG,MAAS,GACrC,IAAS,EAAW,CAAI,GAExB,IAAuC;GACzC,GAAI,IAAS,CAAC,IAAI,EAAE,gBAAgB,mBAAmB;GACvD,GAAI,IAAY,EAAE,gBAAgB,EAAU,IAAI,CAAC;GACjD,GAAG,EAAO;GACV,GAAG,EAAY;GACf,GAAI;EACR,GAEM,IAAoB;GACtB,GAAG;GACH,SAAS;GACT,aAAa,EAAO,kBAAkB,YAAY,EAAK;GACvD,MACI,KAA+B,OACzB,KAAA,IACA,IACG,IACD,KAAK,UAAU,CAAI;EACnC;EAEA,OAAO,EAAQ,EAAY,EAAO,SAAS,GAAM;GAAE,QAAQ,EAAO;GAAQ;EAAO,CAAC,GAAG,CAAI;CAC7F;CAEA,eAAe,EACX,GACA,GACA,GACA,GACiB;EACjB,IAAM,IAAM,EAAO;EACnB,IAAI,CAAC,GAAK,OAAO,EAAW,GAAM,GAAS,CAAS;EAEpD,IAAM,IAAY,KAAK,IAAI;EAC3B,IAAI;GACA,IAAM,IAAW,MAAM,EAAW,GAAM,GAAS,CAAS,GACpD,IAAQ;IAAE;IAAW,QAAQ,EAAS;IAAQ,IAAI,KAAK,IAAI,IAAI;GAAU,GACzE,IAAO,GAAG,EAAO,GAAG,EAAK,KAAK,EAAS;GAG7C,OAFI,EAAS,UAAU,MAAK,EAAI,KAAK,GAAM,CAAK,IAC3C,EAAI,MAAM,GAAM,CAAK,GACnB;EACX,SAAS,GAAO;GAMZ,MALA,EAAI,KAAK,GAAG,EAAO,GAAG,EAAK,iBAAiB;IACxC;IACA,IAAI,KAAK,IAAI,IAAI;IACjB;GACJ,CAAC,GACK;EACV;CACJ;CAEA,eAAe,EAAW,GAAoB,GAAkC;EAK5E,AAJA,EAAO,QAAQ,KAAK,yCAAyC;GACzD;GACA,QAAQ,EAAS;EACrB,CAAC,GACD,MAAM,EAAO,iBAAiB,CAAQ;CAC1C;CAEA,eAAe,EAAW,GAAc,GAAqC;EACzE,IAAM,IAAY,EAAO,YAAY,EAAO,UAAU,IAAI,EAAS,GAC7D,KAAU,EAAQ,UAAU,MAAA,CAAO,YAAY,GACjD,IAAW,MAAM,EAAK,GAAM,GAAS,GAAW,CAAM;EAE1D,IAAI,EAAS,WAAW,KAAK;GACzB,IAAI,EAAO,SAAS;IAChB,IAAI;KAEA,AADA,MAAM,EAAO,QAAQ,GACrB,IAAW,MAAM,EAAK,GAAM,GAAS,GAAW,CAAM;IAC1D,QAAQ;KAEJ,MADA,MAAM,EAAW,GAAU,CAAS,GAC9B,MAAM,EAAW,GAAU,CAAS;IAC9C;IACA,AAAI,EAAS,WAAW,OACpB,MAAM,EAAW,GAAU,CAAS;GAE5C,OACI,MAAM,EAAW,GAAU,CAAS;EAE5C;EAEA,IAAI,CAAC,EAAS,IACV,MAAM,MAAM,EAAW,GAAU,CAAS;EAG1C,MAAS,WAAW,KAQxB,QAJoB,EAAS,QAAQ,IAAI,cAAc,KAAK,GAAA,CAC5C,SAAS,kBAAkB,IAC/B,MAAM,EAAS,KAAK,IAExB,MAAM,EAAS,KAAK;CAChC;CAEA,eAAe,EAAW,GAAc,IAA0B,CAAC,GAAe;EAC9E,IAAM,IAAe,EAAa,EAAO,KAAK;EAC9C,IAAI,CAAC,GAAc,OAAO,EAAW,GAAM,CAAO;EAElD,IAAM,KAAU,EAAQ,UAAU,MAAA,CAAO,YAAY;EACrD,OAAO,QAAuB,EAAW,GAAM,CAAO,GAAG;GACrD,GAAG;GACH,aACI,EAAa,iBAAiB,MAAmB,EAAmB,GAAO,CAAM;EACzF,CAAC;CACL;CAEA,eAAe,EACX,GACA,GACA,IAAmC,QACzB;EACV,OAAO,EAAW,GAAM;GAAE;GAAQ,MAAM;EAAS,CAAC;CACtD;CAEA,OAAO;EACH;EACA,MAAS,GAAc,MACnB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAM,CAAC;EAClD,OAAU,GAAc,MACpB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAO,CAAC;EACnD,MAAS,GAAc,MACnB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAM,CAAC;EAClD,QAAW,GAAc,MACrB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAQ,CAAC;EACpD,SAAY,GAAc,MACtB,EAAW,GAAM;GAAE,GAAG;GAAS,QAAQ;EAAS,CAAC;EACrD;CACJ;AACJ"}
|
|
@@ -600,6 +600,26 @@ export declare interface ApiClientConfig {
|
|
|
600
600
|
* after a refresh that resolved. Use it to end the session.
|
|
601
601
|
*/
|
|
602
602
|
onUnauthorized?: (response: Response) => void | Promise<void>;
|
|
603
|
+
/**
|
|
604
|
+
* Where the client reports each request it finished. Off when absent — the
|
|
605
|
+
* client writes to no console of its own.
|
|
606
|
+
*
|
|
607
|
+
* One entry per attempt (so a refresh replay and every retry show up), at
|
|
608
|
+
* `debug` under 400 and at `warn` from 400 up, carrying `requestId`,
|
|
609
|
+
* `status` and the elapsed `ms`. Firing `onUnauthorized` gets its own `warn`,
|
|
610
|
+
* which is what a session dying mid-session looks like in the log.
|
|
611
|
+
*
|
|
612
|
+
* Deliberately **not** a `debug: boolean`: the level lives in the logger you
|
|
613
|
+
* pass, so `createLogger({ level })` decides what survives, the sink decides
|
|
614
|
+
* where it goes (console in dev, Sentry in production, an array in a test),
|
|
615
|
+
* and one namespace per client keeps two clients apart.
|
|
616
|
+
*
|
|
617
|
+
* Never logs a body, a header, or the query string — a bearer token in
|
|
618
|
+
* `Authorization`, a password in a login payload and an `access_token` query
|
|
619
|
+
* param would all end up in whatever the sink writes to. What is logged is
|
|
620
|
+
* the method, the path as the call site wrote it, and the numbers.
|
|
621
|
+
*/
|
|
622
|
+
logger?: ApiClientLogger;
|
|
603
623
|
/**
|
|
604
624
|
* Optional refresh hook. When provided and the original request returns 401,
|
|
605
625
|
* the client awaits `refresh()` then retries the request once.
|
|
@@ -633,6 +653,13 @@ export declare interface ApiClientConfig {
|
|
|
633
653
|
fetcher?: typeof fetch;
|
|
634
654
|
}
|
|
635
655
|
|
|
656
|
+
/**
|
|
657
|
+
* The slice of {@link Logger} the client writes to: `debug` for a request that
|
|
658
|
+
* came back under 400, `warn` for everything else. Structural, so the SDK
|
|
659
|
+
* logger fits without adapting and so does any object with those two methods.
|
|
660
|
+
*/
|
|
661
|
+
export declare type ApiClientLogger = Pick<Logger, "debug" | "warn">;
|
|
662
|
+
|
|
636
663
|
export declare interface ApiError {
|
|
637
664
|
/** HTTP status code (0 for network failures). */
|
|
638
665
|
status: number;
|
|
@@ -2798,12 +2825,19 @@ export declare const CPFInput: ForwardRefExoticComponent<Omit<InputProps, "value
|
|
|
2798
2825
|
* **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}
|
|
2799
2826
|
* for the built-in policy; it never replays a write.
|
|
2800
2827
|
*
|
|
2828
|
+
* **Logging** is off unless you pass a `logger`. With one, every finished attempt
|
|
2829
|
+
* writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when
|
|
2830
|
+
* `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and
|
|
2831
|
+
* never a body, header or query string. The level and the destination belong to
|
|
2832
|
+
* the logger, not to a boolean here.
|
|
2833
|
+
*
|
|
2801
2834
|
* @example
|
|
2802
2835
|
* const api = createApiClient({
|
|
2803
2836
|
* baseURL: import.meta.env.VITE_API_URL,
|
|
2804
2837
|
* getToken: () => useAuthStore.getState().token,
|
|
2805
2838
|
* refresh,
|
|
2806
2839
|
* onUnauthorized: () => useAuthStore.getState().logout(),
|
|
2840
|
+
* logger: createLogger({ level: import.meta.env.DEV ? "debug" : "warn" }).child("http"),
|
|
2807
2841
|
* retry: true,
|
|
2808
2842
|
* });
|
|
2809
2843
|
*
|