@bquery/bquery 1.7.0 → 1.8.2
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 +760 -716
- package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
- package/dist/a11y-DVBCy09c.js.map +1 -0
- package/dist/a11y.es.mjs +1 -1
- package/dist/component/library.d.ts.map +1 -1
- package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
- package/dist/component-L3-JfOFz.js.map +1 -0
- package/dist/component.es.mjs +1 -1
- package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
- package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
- package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
- package/dist/constraints-D5RHQLmP.js.map +1 -0
- package/dist/core/collection.d.ts +86 -0
- package/dist/core/collection.d.ts.map +1 -1
- package/dist/core/element.d.ts +28 -0
- package/dist/core/element.d.ts.map +1 -1
- package/dist/core/shared.d.ts +6 -0
- package/dist/core/shared.d.ts.map +1 -1
- package/dist/core-DdtZHzsS.js +168 -0
- package/dist/core-DdtZHzsS.js.map +1 -0
- package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
- package/dist/core-EMYSLzaT.js.map +1 -0
- package/dist/core.es.mjs +48 -47
- package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
- package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
- package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
- package/dist/devtools-BhB2iDPT.js.map +1 -0
- package/dist/devtools.es.mjs +1 -1
- package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
- package/dist/dnd-NwZBYh4l.js.map +1 -0
- package/dist/dnd.es.mjs +1 -1
- package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
- package/dist/env-CTdvLaH2.js.map +1 -0
- package/dist/forms/create-form.d.ts.map +1 -1
- package/dist/forms/index.d.ts +3 -2
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/types.d.ts +46 -0
- package/dist/forms/types.d.ts.map +1 -1
- package/dist/forms/use-field.d.ts +34 -0
- package/dist/forms/use-field.d.ts.map +1 -0
- package/dist/forms/validators.d.ts +25 -0
- package/dist/forms/validators.d.ts.map +1 -1
- package/dist/forms-UcRHsYxC.js +227 -0
- package/dist/forms-UcRHsYxC.js.map +1 -0
- package/dist/forms.es.mjs +14 -12
- package/dist/full.d.ts +17 -26
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +206 -181
- package/dist/full.iife.js +33 -33
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +33 -33
- package/dist/full.umd.js.map +1 -1
- package/dist/function-Cybd57JV.js +33 -0
- package/dist/function-Cybd57JV.js.map +1 -0
- package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
- package/dist/i18n-kuF6Ekj6.js.map +1 -0
- package/dist/i18n.es.mjs +1 -1
- package/dist/index.es.mjs +251 -228
- package/dist/media/breakpoints.d.ts.map +1 -1
- package/dist/media/types.d.ts +2 -2
- package/dist/media/types.d.ts.map +1 -1
- package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
- package/dist/media-i-fB5WxI.js.map +1 -0
- package/dist/media.es.mjs +1 -1
- package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
- package/dist/motion-BJsAuULb.js.map +1 -0
- package/dist/motion.es.mjs +1 -1
- package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
- package/dist/mount-B4Y8bk8Z.js.map +1 -0
- package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
- package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
- package/dist/platform.es.mjs +2 -2
- package/dist/plugin/registry.d.ts.map +1 -1
- package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
- package/dist/plugin-C2WuC8SF.js.map +1 -0
- package/dist/plugin.es.mjs +1 -1
- package/dist/reactive/async-data.d.ts +28 -3
- package/dist/reactive/async-data.d.ts.map +1 -1
- package/dist/reactive/computed.d.ts +3 -0
- package/dist/reactive/computed.d.ts.map +1 -1
- package/dist/reactive/effect.d.ts +3 -0
- package/dist/reactive/effect.d.ts.map +1 -1
- package/dist/reactive/http.d.ts +194 -0
- package/dist/reactive/http.d.ts.map +1 -0
- package/dist/reactive/index.d.ts +2 -2
- package/dist/reactive/index.d.ts.map +1 -1
- package/dist/reactive/pagination.d.ts +126 -0
- package/dist/reactive/pagination.d.ts.map +1 -0
- package/dist/reactive/polling.d.ts +55 -0
- package/dist/reactive/polling.d.ts.map +1 -0
- package/dist/reactive/readonly.d.ts +20 -1
- package/dist/reactive/readonly.d.ts.map +1 -1
- package/dist/reactive/rest.d.ts +293 -0
- package/dist/reactive/rest.d.ts.map +1 -0
- package/dist/reactive/scope.d.ts +140 -0
- package/dist/reactive/scope.d.ts.map +1 -0
- package/dist/reactive/signal.d.ts +16 -2
- package/dist/reactive/signal.d.ts.map +1 -1
- package/dist/reactive/to-value.d.ts +57 -0
- package/dist/reactive/to-value.d.ts.map +1 -0
- package/dist/reactive/websocket.d.ts +285 -0
- package/dist/reactive/websocket.d.ts.map +1 -0
- package/dist/reactive-DwkhUJfP.js +1148 -0
- package/dist/reactive-DwkhUJfP.js.map +1 -0
- package/dist/reactive.es.mjs +38 -19
- package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
- package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
- package/dist/router/constraints.d.ts.map +1 -1
- package/dist/router/index.d.ts +1 -1
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/state.d.ts +25 -2
- package/dist/router/state.d.ts.map +1 -1
- package/dist/router-CQikC9Ed.js +492 -0
- package/dist/router-CQikC9Ed.js.map +1 -0
- package/dist/router.es.mjs +9 -8
- package/dist/ssr/hydrate.d.ts.map +1 -1
- package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
- package/dist/ssr-_dAcGdzu.js.map +1 -0
- package/dist/ssr.es.mjs +1 -1
- package/dist/store/persisted.d.ts.map +1 -1
- package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
- package/dist/store-Cb3gPRve.js.map +1 -0
- package/dist/store.es.mjs +2 -2
- package/dist/storybook.es.mjs.map +1 -1
- package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
- package/dist/testing-C5Sjfsna.js.map +1 -0
- package/dist/testing.es.mjs +1 -1
- package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
- package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
- package/dist/untrack-D0fnO5k2.js +36 -0
- package/dist/untrack-D0fnO5k2.js.map +1 -0
- package/dist/view/custom-directives.d.ts.map +1 -1
- package/dist/view.es.mjs +4 -4
- package/package.json +178 -177
- package/src/a11y/announce.ts +131 -131
- package/src/a11y/audit.ts +314 -314
- package/src/a11y/index.ts +68 -68
- package/src/a11y/media-preferences.ts +255 -255
- package/src/a11y/roving-tab-index.ts +164 -164
- package/src/a11y/skip-link.ts +255 -255
- package/src/a11y/trap-focus.ts +184 -184
- package/src/a11y/types.ts +183 -183
- package/src/component/component.ts +599 -599
- package/src/component/html.ts +153 -153
- package/src/component/index.ts +52 -52
- package/src/component/library.ts +540 -542
- package/src/component/scope.ts +212 -212
- package/src/component/types.ts +310 -310
- package/src/core/collection.ts +876 -707
- package/src/core/element.ts +1015 -981
- package/src/core/env.ts +60 -60
- package/src/core/index.ts +49 -49
- package/src/core/shared.ts +77 -62
- package/src/core/utils/index.ts +148 -148
- package/src/devtools/devtools.ts +410 -410
- package/src/devtools/index.ts +48 -48
- package/src/devtools/types.ts +104 -104
- package/src/dnd/draggable.ts +296 -296
- package/src/dnd/droppable.ts +228 -228
- package/src/dnd/index.ts +62 -62
- package/src/dnd/sortable.ts +307 -307
- package/src/dnd/types.ts +293 -293
- package/src/forms/create-form.ts +320 -278
- package/src/forms/index.ts +70 -65
- package/src/forms/types.ts +203 -154
- package/src/forms/use-field.ts +231 -0
- package/src/forms/validators.ts +294 -265
- package/src/full.ts +554 -480
- package/src/i18n/formatting.ts +67 -67
- package/src/i18n/i18n.ts +200 -200
- package/src/i18n/index.ts +67 -67
- package/src/i18n/translate.ts +182 -182
- package/src/i18n/types.ts +171 -171
- package/src/index.ts +108 -108
- package/src/media/battery.ts +116 -116
- package/src/media/breakpoints.ts +129 -131
- package/src/media/clipboard.ts +80 -80
- package/src/media/device-sensors.ts +158 -158
- package/src/media/geolocation.ts +119 -119
- package/src/media/index.ts +76 -76
- package/src/media/media-query.ts +92 -92
- package/src/media/network.ts +115 -115
- package/src/media/types.ts +177 -177
- package/src/media/viewport.ts +84 -84
- package/src/motion/index.ts +57 -57
- package/src/motion/morph.ts +151 -151
- package/src/motion/parallax.ts +120 -120
- package/src/motion/reduced-motion.ts +66 -66
- package/src/motion/types.ts +271 -271
- package/src/motion/typewriter.ts +164 -164
- package/src/plugin/index.ts +37 -37
- package/src/plugin/registry.ts +284 -269
- package/src/plugin/types.ts +137 -137
- package/src/reactive/async-data.ts +250 -29
- package/src/reactive/computed.ts +144 -130
- package/src/reactive/effect.ts +29 -6
- package/src/reactive/http.ts +790 -0
- package/src/reactive/index.ts +60 -0
- package/src/reactive/pagination.ts +317 -0
- package/src/reactive/polling.ts +179 -0
- package/src/reactive/readonly.ts +52 -8
- package/src/reactive/rest.ts +859 -0
- package/src/reactive/scope.ts +276 -0
- package/src/reactive/signal.ts +61 -1
- package/src/reactive/to-value.ts +71 -0
- package/src/reactive/websocket.ts +849 -0
- package/src/router/bq-link.ts +279 -279
- package/src/router/constraints.ts +204 -201
- package/src/router/index.ts +49 -49
- package/src/router/match.ts +312 -312
- package/src/router/path-pattern.ts +52 -52
- package/src/router/query.ts +38 -38
- package/src/router/router.ts +421 -402
- package/src/router/state.ts +51 -3
- package/src/router/types.ts +139 -139
- package/src/router/use-route.ts +68 -68
- package/src/router/utils.ts +157 -157
- package/src/security/index.ts +12 -12
- package/src/ssr/hydrate.ts +84 -82
- package/src/ssr/index.ts +70 -70
- package/src/ssr/render.ts +508 -508
- package/src/ssr/serialize.ts +296 -296
- package/src/ssr/types.ts +81 -81
- package/src/store/create-store.ts +467 -467
- package/src/store/index.ts +27 -27
- package/src/store/persisted.ts +245 -249
- package/src/store/types.ts +247 -247
- package/src/store/utils.ts +135 -135
- package/src/storybook/index.ts +480 -480
- package/src/testing/index.ts +42 -42
- package/src/testing/testing.ts +593 -593
- package/src/testing/types.ts +170 -170
- package/src/view/custom-directives.ts +28 -30
- package/src/view/evaluate.ts +292 -292
- package/src/view/process.ts +108 -108
- package/dist/a11y-C5QOVvRn.js.map +0 -1
- package/dist/component-CuuTijA6.js.map +0 -1
- package/dist/constraints-3lV9yyBw.js.map +0 -1
- package/dist/core-Cjl7GUu8.js.map +0 -1
- package/dist/core-DnlyjbF2.js +0 -112
- package/dist/core-DnlyjbF2.js.map +0 -1
- package/dist/custom-directives-7wAShnnd.js.map +0 -1
- package/dist/devtools-D2fQLhDN.js.map +0 -1
- package/dist/dnd-B8EgyzaI.js.map +0 -1
- package/dist/env-NeVmr4Gf.js.map +0 -1
- package/dist/forms-C3yovgH9.js +0 -141
- package/dist/forms-C3yovgH9.js.map +0 -1
- package/dist/i18n-BnnhTFOS.js.map +0 -1
- package/dist/media-Di2Ta22s.js.map +0 -1
- package/dist/motion-qPj_TYGv.js.map +0 -1
- package/dist/mount-SM07RUa6.js.map +0 -1
- package/dist/plugin-cPoOHFLY.js.map +0 -1
- package/dist/reactive-Cfv0RK6x.js +0 -233
- package/dist/reactive-Cfv0RK6x.js.map +0 -1
- package/dist/router-BrthaP_z.js +0 -473
- package/dist/router-BrthaP_z.js.map +0 -1
- package/dist/ssr-B2qd_WBB.js.map +0 -1
- package/dist/store-DWpyH6p5.js.map +0 -1
- package/dist/testing-CsqjNUyy.js.map +0 -1
- package/dist/untrack-DJVQQ2WM.js +0 -33
- package/dist/untrack-DJVQQ2WM.js.map +0 -1
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imperative HTTP client with Axios-like API, interceptors, retry, timeout,
|
|
3
|
+
* cancellation, and progress tracking — built on the native Fetch API.
|
|
4
|
+
*
|
|
5
|
+
* @module bquery/reactive
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { merge, isPlainObject } from '../core/utils/object';
|
|
9
|
+
import { getBqueryConfig, type BqueryFetchParseAs } from '../platform/config';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Configuration for automatic request retries. */
|
|
16
|
+
export interface RetryConfig {
|
|
17
|
+
/** Maximum number of retry attempts (default: 3). */
|
|
18
|
+
count: number;
|
|
19
|
+
/** Delay in ms between retries, or a function receiving the attempt index. */
|
|
20
|
+
delay?: number | ((attempt: number) => number);
|
|
21
|
+
/** Predicate deciding whether to retry a given error. Defaults to network / 5xx errors. */
|
|
22
|
+
retryOn?: (error: HttpError, attempt: number) => boolean;
|
|
23
|
+
/** Called before each retry attempt with the error and 1-indexed attempt number. */
|
|
24
|
+
onRetry?: (error: HttpError, attempt: number) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Progress information emitted during upload or download. */
|
|
28
|
+
export interface HttpProgressEvent {
|
|
29
|
+
/** Bytes transferred so far. */
|
|
30
|
+
loaded: number;
|
|
31
|
+
/** Total bytes if known, otherwise 0. */
|
|
32
|
+
total: number;
|
|
33
|
+
/** Percentage between 0 and 100, or `undefined` when total is unknown. */
|
|
34
|
+
percent: number | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Full request configuration accepted by the HTTP client. */
|
|
38
|
+
export interface HttpRequestConfig extends Omit<RequestInit, 'body' | 'headers' | 'signal'> {
|
|
39
|
+
/** Request URL (resolved against `baseUrl`). */
|
|
40
|
+
url?: string;
|
|
41
|
+
/** Base URL prepended to relative request URLs. */
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
/** Request headers. */
|
|
44
|
+
headers?: HeadersInit;
|
|
45
|
+
/** Query parameters appended to the URL. */
|
|
46
|
+
query?: Record<string, unknown>;
|
|
47
|
+
/** Request body — plain objects/arrays are JSON-serialised automatically. */
|
|
48
|
+
body?: BodyInit | Record<string, unknown> | unknown[] | null;
|
|
49
|
+
/** Request timeout in milliseconds. 0 means no timeout (default). */
|
|
50
|
+
timeout?: number;
|
|
51
|
+
/** Response parsing strategy. */
|
|
52
|
+
parseAs?: BqueryFetchParseAs;
|
|
53
|
+
/** Custom status validation. Returns `true` for acceptable statuses. Default: `status >= 200 && status < 300`. */
|
|
54
|
+
validateStatus?: (status: number) => boolean;
|
|
55
|
+
/** Custom fetch implementation for testing or adapters. */
|
|
56
|
+
fetcher?: typeof fetch;
|
|
57
|
+
/** External `AbortSignal` for request cancellation. */
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
/** Retry configuration. Pass a number for simple retry count, or a `RetryConfig` object. */
|
|
60
|
+
retry?: number | RetryConfig;
|
|
61
|
+
/** Called repeatedly as response body chunks arrive. */
|
|
62
|
+
onDownloadProgress?: (event: HttpProgressEvent) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Structured HTTP response returned by every client method. */
|
|
66
|
+
export interface HttpResponse<T = unknown> {
|
|
67
|
+
/** Parsed response data. */
|
|
68
|
+
data: T;
|
|
69
|
+
/** HTTP status code. */
|
|
70
|
+
status: number;
|
|
71
|
+
/** HTTP status text. */
|
|
72
|
+
statusText: string;
|
|
73
|
+
/** Response headers. */
|
|
74
|
+
headers: Headers;
|
|
75
|
+
/** Resolved request configuration used for this call. */
|
|
76
|
+
config: HttpRequestConfig;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Error subclass thrown on failed HTTP requests with rich metadata. */
|
|
80
|
+
export class HttpError extends Error {
|
|
81
|
+
/** HTTP response (available when the server replied). */
|
|
82
|
+
response?: HttpResponse;
|
|
83
|
+
/** Resolved request configuration. */
|
|
84
|
+
config: HttpRequestConfig;
|
|
85
|
+
/** Original error code string (e.g. `'TIMEOUT'`, `'ABORT'`, `'NETWORK'`). */
|
|
86
|
+
code: string;
|
|
87
|
+
|
|
88
|
+
constructor(message: string, config: HttpRequestConfig, code: string, response?: HttpResponse) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = 'HttpError';
|
|
91
|
+
this.config = config;
|
|
92
|
+
this.code = code;
|
|
93
|
+
this.response = response;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Interceptors
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Single interceptor handler pair. */
|
|
102
|
+
export interface Interceptor<T> {
|
|
103
|
+
fulfilled?: (value: T) => T | Promise<T>;
|
|
104
|
+
rejected?: (error: unknown) => unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Manager for adding and removing interceptors. */
|
|
108
|
+
export interface InterceptorManager<T> {
|
|
109
|
+
/** Register an interceptor. Returns a numeric id for later removal via `eject()`. */
|
|
110
|
+
use(fulfilled?: (value: T) => T | Promise<T>, rejected?: (error: unknown) => unknown): number;
|
|
111
|
+
/** Remove a previously registered interceptor by id. */
|
|
112
|
+
eject(id: number): void;
|
|
113
|
+
/** Remove all interceptors. */
|
|
114
|
+
clear(): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @internal */
|
|
118
|
+
interface InterceptorEntry<T> {
|
|
119
|
+
id: number;
|
|
120
|
+
fulfilled?: (value: T) => T | Promise<T>;
|
|
121
|
+
rejected?: (error: unknown) => unknown;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @internal */
|
|
125
|
+
function createInterceptorManager<T>(): InterceptorManager<T> & {
|
|
126
|
+
/** @internal */ forEach(fn: (entry: InterceptorEntry<T>) => void): void;
|
|
127
|
+
} {
|
|
128
|
+
const entries: Array<InterceptorEntry<T> | null> = [];
|
|
129
|
+
let nextId = 0;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
use(fulfilled, rejected) {
|
|
133
|
+
const id = nextId++;
|
|
134
|
+
entries.push({ id, fulfilled, rejected });
|
|
135
|
+
return id;
|
|
136
|
+
},
|
|
137
|
+
eject(id) {
|
|
138
|
+
const index = entries.findIndex((e) => e?.id === id);
|
|
139
|
+
if (index !== -1) entries[index] = null;
|
|
140
|
+
},
|
|
141
|
+
clear() {
|
|
142
|
+
entries.length = 0;
|
|
143
|
+
},
|
|
144
|
+
forEach(fn) {
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (entry) fn(entry);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// HttpClient interface
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** Imperative HTTP client with interceptors and convenience method shortcuts. */
|
|
157
|
+
export interface HttpClient {
|
|
158
|
+
/** Send a request using the provided configuration. */
|
|
159
|
+
request<T = unknown>(config: HttpRequestConfig): Promise<HttpResponse<T>>;
|
|
160
|
+
/** Send a GET request. */
|
|
161
|
+
get<T = unknown>(url: string, config?: HttpRequestConfig): Promise<HttpResponse<T>>;
|
|
162
|
+
/** Send a POST request. */
|
|
163
|
+
post<T = unknown>(
|
|
164
|
+
url: string,
|
|
165
|
+
body?: HttpRequestConfig['body'],
|
|
166
|
+
config?: HttpRequestConfig
|
|
167
|
+
): Promise<HttpResponse<T>>;
|
|
168
|
+
/** Send a PUT request. */
|
|
169
|
+
put<T = unknown>(
|
|
170
|
+
url: string,
|
|
171
|
+
body?: HttpRequestConfig['body'],
|
|
172
|
+
config?: HttpRequestConfig
|
|
173
|
+
): Promise<HttpResponse<T>>;
|
|
174
|
+
/** Send a PATCH request. */
|
|
175
|
+
patch<T = unknown>(
|
|
176
|
+
url: string,
|
|
177
|
+
body?: HttpRequestConfig['body'],
|
|
178
|
+
config?: HttpRequestConfig
|
|
179
|
+
): Promise<HttpResponse<T>>;
|
|
180
|
+
/** Send a DELETE request. */
|
|
181
|
+
delete<T = unknown>(url: string, config?: HttpRequestConfig): Promise<HttpResponse<T>>;
|
|
182
|
+
/** Send a HEAD request. */
|
|
183
|
+
head<T = unknown>(url: string, config?: HttpRequestConfig): Promise<HttpResponse<T>>;
|
|
184
|
+
/** Send an OPTIONS request. */
|
|
185
|
+
options<T = unknown>(url: string, config?: HttpRequestConfig): Promise<HttpResponse<T>>;
|
|
186
|
+
/** Request and response interceptors. */
|
|
187
|
+
interceptors: {
|
|
188
|
+
request: InterceptorManager<HttpRequestConfig>;
|
|
189
|
+
response: InterceptorManager<HttpResponse>;
|
|
190
|
+
};
|
|
191
|
+
/** The merged default configuration used by this client. */
|
|
192
|
+
defaults: HttpRequestConfig;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Internal helpers
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
const DEFAULT_VALIDATE_STATUS = (status: number): boolean => status >= 200 && status < 300;
|
|
200
|
+
|
|
201
|
+
const DEFAULT_RETRY_ON = (error: HttpError): boolean => {
|
|
202
|
+
if (error.code === 'PARSE') return false;
|
|
203
|
+
if (error.code === 'TIMEOUT' || error.code === 'NETWORK') return true;
|
|
204
|
+
const status = error.response?.status;
|
|
205
|
+
return status !== undefined && status >= 500;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** @internal */
|
|
209
|
+
const normalizeRetry = (retry: HttpRequestConfig['retry']): RetryConfig | undefined => {
|
|
210
|
+
if (retry == null) return undefined;
|
|
211
|
+
if (typeof retry === 'number') return { count: retry };
|
|
212
|
+
return retry;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** @internal */
|
|
216
|
+
const resolveRetryDelay = (delay: RetryConfig['delay'], attempt: number): number => {
|
|
217
|
+
if (delay == null) return Math.min(1000 * 2 ** attempt, 30_000);
|
|
218
|
+
if (typeof delay === 'number') return delay;
|
|
219
|
+
return delay(attempt);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** @internal */
|
|
223
|
+
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
224
|
+
new Promise<void>((resolve, reject) => {
|
|
225
|
+
if (signal?.aborted) {
|
|
226
|
+
reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
230
|
+
const onAbort = (): void => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
signal?.removeEventListener('abort', onAbort);
|
|
233
|
+
reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
|
|
234
|
+
};
|
|
235
|
+
timer = setTimeout(() => {
|
|
236
|
+
signal?.removeEventListener('abort', onAbort);
|
|
237
|
+
resolve();
|
|
238
|
+
}, ms);
|
|
239
|
+
if (signal) {
|
|
240
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/** @internal */
|
|
245
|
+
const toHeaders = (...sources: Array<HeadersInit | undefined>): Headers => {
|
|
246
|
+
const headers = new Headers();
|
|
247
|
+
for (const source of sources) {
|
|
248
|
+
if (!source) continue;
|
|
249
|
+
new Headers(source).forEach((value, key) => {
|
|
250
|
+
headers.set(key, value);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return headers;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** @internal */
|
|
257
|
+
const isBodyLike = (value: unknown): value is BodyInit => {
|
|
258
|
+
if (typeof value === 'string') return true;
|
|
259
|
+
if (value instanceof Blob || value instanceof FormData || value instanceof URLSearchParams) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) return true;
|
|
263
|
+
if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) return true;
|
|
264
|
+
return typeof value === 'object' && value !== null && ArrayBuffer.isView(value);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/** @internal */
|
|
268
|
+
const serializeBody = (
|
|
269
|
+
body: HttpRequestConfig['body'],
|
|
270
|
+
headers: Headers
|
|
271
|
+
): BodyInit | null | undefined => {
|
|
272
|
+
if (body == null) return body;
|
|
273
|
+
if (isBodyLike(body)) return body;
|
|
274
|
+
if (!headers.has('content-type')) {
|
|
275
|
+
headers.set('content-type', 'application/json');
|
|
276
|
+
}
|
|
277
|
+
return JSON.stringify(body);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** @internal */
|
|
281
|
+
const appendQuery = (url: URL, query: Record<string, unknown>): void => {
|
|
282
|
+
for (const [key, value] of Object.entries(query)) {
|
|
283
|
+
if (value == null) continue;
|
|
284
|
+
if (Array.isArray(value)) {
|
|
285
|
+
for (const item of value) {
|
|
286
|
+
if (item != null) url.searchParams.append(key, String(item));
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
url.searchParams.set(key, String(value));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/** @internal */
|
|
295
|
+
const buildUrl = (url: string, baseUrl?: string): URL => {
|
|
296
|
+
const runtimeBase =
|
|
297
|
+
typeof window !== 'undefined' && /^https?:/i.test(window.location.href)
|
|
298
|
+
? window.location.href
|
|
299
|
+
: 'http://localhost';
|
|
300
|
+
const base = baseUrl ? new URL(baseUrl, runtimeBase).toString() : runtimeBase;
|
|
301
|
+
return new URL(url, base);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** @internal */
|
|
305
|
+
const parseResponseBody = async <T>(
|
|
306
|
+
response: Response,
|
|
307
|
+
parseAs: BqueryFetchParseAs,
|
|
308
|
+
config: HttpRequestConfig
|
|
309
|
+
): Promise<T> => {
|
|
310
|
+
if (parseAs === 'response') return response as T;
|
|
311
|
+
if (parseAs === 'text') return (await response.text()) as T;
|
|
312
|
+
if (parseAs === 'blob') return (await response.blob()) as T;
|
|
313
|
+
if (parseAs === 'arrayBuffer') return (await response.arrayBuffer()) as T;
|
|
314
|
+
if (parseAs === 'formData') return (await response.formData()) as T;
|
|
315
|
+
|
|
316
|
+
const text = await response.text();
|
|
317
|
+
if (!text) return undefined as T;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
return JSON.parse(text) as T;
|
|
321
|
+
} catch (parseError) {
|
|
322
|
+
const detail = response.url ? ` for ${response.url}` : '';
|
|
323
|
+
throw new HttpError(
|
|
324
|
+
`Failed to parse JSON response${detail} (status ${response.status}): ${parseError instanceof Error ? parseError.message : String(parseError)}`,
|
|
325
|
+
config,
|
|
326
|
+
'PARSE',
|
|
327
|
+
{
|
|
328
|
+
data: text,
|
|
329
|
+
status: response.status,
|
|
330
|
+
statusText: response.statusText,
|
|
331
|
+
headers: response.headers,
|
|
332
|
+
config,
|
|
333
|
+
}
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/** @internal – wrap a response body stream to report download progress. */
|
|
339
|
+
const wrapDownloadStream = (
|
|
340
|
+
response: Response,
|
|
341
|
+
onProgress: (event: HttpProgressEvent) => void
|
|
342
|
+
): Response => {
|
|
343
|
+
const body = response.body;
|
|
344
|
+
if (!body) return response;
|
|
345
|
+
|
|
346
|
+
const total = parseInt(response.headers.get('content-length') ?? '0', 10) || 0;
|
|
347
|
+
let loaded = 0;
|
|
348
|
+
|
|
349
|
+
const reader = body.getReader();
|
|
350
|
+
const stream = new ReadableStream({
|
|
351
|
+
async pull(controller) {
|
|
352
|
+
const { done, value } = await reader.read();
|
|
353
|
+
if (done) {
|
|
354
|
+
controller.close();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
loaded += value.byteLength;
|
|
358
|
+
onProgress({
|
|
359
|
+
loaded,
|
|
360
|
+
total,
|
|
361
|
+
percent: total > 0 ? Math.round((loaded / total) * 100) : undefined,
|
|
362
|
+
});
|
|
363
|
+
controller.enqueue(value);
|
|
364
|
+
},
|
|
365
|
+
cancel(reason) {
|
|
366
|
+
reader.cancel(reason);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return new Response(stream, {
|
|
371
|
+
status: response.status,
|
|
372
|
+
statusText: response.statusText,
|
|
373
|
+
headers: response.headers,
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Core request execution
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/** @internal Execute a single HTTP request (no retry/interceptor logic). */
|
|
382
|
+
const executeRequest = async <T>(config: HttpRequestConfig): Promise<HttpResponse<T>> => {
|
|
383
|
+
const fetchConfig = getBqueryConfig().fetch;
|
|
384
|
+
const parseAs = config.parseAs ?? fetchConfig?.parseAs ?? 'json';
|
|
385
|
+
const fetcher = config.fetcher ?? fetch;
|
|
386
|
+
const validateStatus = config.validateStatus ?? DEFAULT_VALIDATE_STATUS;
|
|
387
|
+
|
|
388
|
+
const urlString = config.url ?? '/';
|
|
389
|
+
const url = buildUrl(urlString, config.baseUrl ?? fetchConfig?.baseUrl);
|
|
390
|
+
|
|
391
|
+
if (config.query) {
|
|
392
|
+
appendQuery(url, config.query);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const headers = toHeaders(fetchConfig?.headers, config.headers);
|
|
396
|
+
const serializedBody = serializeBody(config.body, headers);
|
|
397
|
+
|
|
398
|
+
// Build RequestInit, omitting non-standard keys
|
|
399
|
+
const requestInit: RequestInit = {};
|
|
400
|
+
if (config.method) requestInit.method = config.method.toUpperCase();
|
|
401
|
+
requestInit.headers = headers;
|
|
402
|
+
if (serializedBody != null) requestInit.body = serializedBody;
|
|
403
|
+
if (config.cache) requestInit.cache = config.cache;
|
|
404
|
+
if (config.credentials) requestInit.credentials = config.credentials;
|
|
405
|
+
if (config.integrity) requestInit.integrity = config.integrity;
|
|
406
|
+
if (config.keepalive !== undefined) requestInit.keepalive = config.keepalive;
|
|
407
|
+
if (config.mode) requestInit.mode = config.mode;
|
|
408
|
+
if (config.redirect) requestInit.redirect = config.redirect;
|
|
409
|
+
if (config.referrer) requestInit.referrer = config.referrer;
|
|
410
|
+
if (config.referrerPolicy) requestInit.referrerPolicy = config.referrerPolicy;
|
|
411
|
+
|
|
412
|
+
// Abort / timeout
|
|
413
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
414
|
+
let mergedSignal: AbortSignal | undefined = config.signal;
|
|
415
|
+
let externalAbortHandler: (() => void) | undefined;
|
|
416
|
+
|
|
417
|
+
if (config.timeout && config.timeout > 0) {
|
|
418
|
+
const controller = new AbortController();
|
|
419
|
+
|
|
420
|
+
if (config.signal) {
|
|
421
|
+
// Compose: abort when *either* the external signal or the timeout fires
|
|
422
|
+
externalAbortHandler = () => controller.abort(config.signal?.reason);
|
|
423
|
+
config.signal.addEventListener('abort', externalAbortHandler, { once: true });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
timeoutId = setTimeout(() => {
|
|
427
|
+
controller.abort(new DOMException('Request timeout', 'TimeoutError'));
|
|
428
|
+
}, config.timeout);
|
|
429
|
+
|
|
430
|
+
mergedSignal = controller.signal;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (mergedSignal) requestInit.signal = mergedSignal;
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
let response = await fetcher(url.toString(), requestInit);
|
|
437
|
+
|
|
438
|
+
if (config.onDownloadProgress) {
|
|
439
|
+
response = wrapDownloadStream(response, config.onDownloadProgress);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!validateStatus(response.status)) {
|
|
443
|
+
throw new HttpError(
|
|
444
|
+
`Request failed with status ${response.status}`,
|
|
445
|
+
config,
|
|
446
|
+
'ERR_BAD_RESPONSE',
|
|
447
|
+
{
|
|
448
|
+
data: undefined,
|
|
449
|
+
status: response.status,
|
|
450
|
+
statusText: response.statusText,
|
|
451
|
+
headers: response.headers,
|
|
452
|
+
config,
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const data = await parseResponseBody<T>(response, parseAs, config);
|
|
458
|
+
|
|
459
|
+
const httpResponse: HttpResponse<T> = {
|
|
460
|
+
data,
|
|
461
|
+
status: response.status,
|
|
462
|
+
statusText: response.statusText,
|
|
463
|
+
headers: response.headers,
|
|
464
|
+
config,
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
return httpResponse;
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error instanceof HttpError) throw error;
|
|
470
|
+
|
|
471
|
+
if (error instanceof DOMException) {
|
|
472
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
|
|
473
|
+
const isTimeout = error.name === 'TimeoutError' || error.message === 'Request timeout';
|
|
474
|
+
throw new HttpError(
|
|
475
|
+
isTimeout ? `Request timeout of ${config.timeout}ms exceeded` : 'Request aborted',
|
|
476
|
+
config,
|
|
477
|
+
isTimeout ? 'TIMEOUT' : 'ABORT'
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
throw new HttpError(error instanceof Error ? error.message : String(error), config, 'NETWORK');
|
|
483
|
+
} finally {
|
|
484
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
485
|
+
if (config.signal && externalAbortHandler) {
|
|
486
|
+
config.signal.removeEventListener('abort', externalAbortHandler);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Factory
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Create a preconfigured HTTP client instance with interceptors.
|
|
497
|
+
*
|
|
498
|
+
* @param defaults - Default request configuration merged into every request
|
|
499
|
+
* @returns An `HttpClient` with `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.head()`, `.options()`
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* ```ts
|
|
503
|
+
* import { createHttp } from '@bquery/bquery/reactive';
|
|
504
|
+
*
|
|
505
|
+
* const api = createHttp({
|
|
506
|
+
* baseUrl: 'https://api.example.com',
|
|
507
|
+
* headers: { authorization: 'Bearer token' },
|
|
508
|
+
* timeout: 10_000,
|
|
509
|
+
* });
|
|
510
|
+
*
|
|
511
|
+
* api.interceptors.request.use((config) => {
|
|
512
|
+
* config.headers = { ...Object.fromEntries(new Headers(config.headers)), 'x-req-id': crypto.randomUUID() };
|
|
513
|
+
* return config;
|
|
514
|
+
* });
|
|
515
|
+
*
|
|
516
|
+
* const { data } = await api.get<User[]>('/users');
|
|
517
|
+
* ```
|
|
518
|
+
*/
|
|
519
|
+
export function createHttp(defaults: HttpRequestConfig = {}): HttpClient {
|
|
520
|
+
const requestInterceptors = createInterceptorManager<HttpRequestConfig>();
|
|
521
|
+
const responseInterceptors = createInterceptorManager<HttpResponse>();
|
|
522
|
+
|
|
523
|
+
const mergeConfig = (perCall: HttpRequestConfig): HttpRequestConfig => {
|
|
524
|
+
const mergedQuery = merge({}, defaults.query ?? {}, perCall.query ?? {}) as Record<
|
|
525
|
+
string,
|
|
526
|
+
unknown
|
|
527
|
+
>;
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
...defaults,
|
|
531
|
+
...perCall,
|
|
532
|
+
headers: toHeaders(defaults.headers, perCall.headers),
|
|
533
|
+
query: Object.keys(mergedQuery).length > 0 ? mergedQuery : undefined,
|
|
534
|
+
};
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const dispatchRequest = async <T>(config: HttpRequestConfig): Promise<HttpResponse<T>> => {
|
|
538
|
+
// Run request interceptors
|
|
539
|
+
let resolvedConfig = config;
|
|
540
|
+
const requestChain: Array<InterceptorEntry<HttpRequestConfig>> = [];
|
|
541
|
+
requestInterceptors.forEach((entry) => requestChain.push(entry));
|
|
542
|
+
|
|
543
|
+
for (const { fulfilled, rejected } of requestChain) {
|
|
544
|
+
try {
|
|
545
|
+
if (fulfilled) {
|
|
546
|
+
resolvedConfig = await fulfilled(resolvedConfig);
|
|
547
|
+
}
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (rejected) {
|
|
550
|
+
const result = await rejected(err);
|
|
551
|
+
if (isPlainObject(result)) {
|
|
552
|
+
resolvedConfig = result as unknown as HttpRequestConfig;
|
|
553
|
+
} else {
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
throw err;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Execute with retry
|
|
563
|
+
const retryConfig = normalizeRetry(resolvedConfig.retry);
|
|
564
|
+
let lastError: HttpError | undefined;
|
|
565
|
+
|
|
566
|
+
const maxAttempts = (retryConfig?.count ?? 0) + 1;
|
|
567
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
568
|
+
try {
|
|
569
|
+
let response = await executeRequest<T>(resolvedConfig);
|
|
570
|
+
|
|
571
|
+
// Run response interceptors
|
|
572
|
+
const responseChain: Array<InterceptorEntry<HttpResponse>> = [];
|
|
573
|
+
responseInterceptors.forEach((entry) => responseChain.push(entry));
|
|
574
|
+
|
|
575
|
+
for (const { fulfilled, rejected } of responseChain) {
|
|
576
|
+
try {
|
|
577
|
+
if (fulfilled) {
|
|
578
|
+
response = (await fulfilled(response as HttpResponse)) as HttpResponse<T>;
|
|
579
|
+
}
|
|
580
|
+
} catch (err) {
|
|
581
|
+
if (rejected) {
|
|
582
|
+
const result = await rejected(err);
|
|
583
|
+
if (result && typeof result === 'object' && 'data' in result) {
|
|
584
|
+
response = result as HttpResponse<T>;
|
|
585
|
+
} else {
|
|
586
|
+
throw err;
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
throw err;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return response;
|
|
595
|
+
} catch (error) {
|
|
596
|
+
const httpError =
|
|
597
|
+
error instanceof HttpError
|
|
598
|
+
? error
|
|
599
|
+
: new HttpError(
|
|
600
|
+
error instanceof Error ? error.message : String(error),
|
|
601
|
+
resolvedConfig,
|
|
602
|
+
'NETWORK'
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
lastError = httpError;
|
|
606
|
+
|
|
607
|
+
const shouldRetry = retryConfig
|
|
608
|
+
? (retryConfig.retryOn ?? DEFAULT_RETRY_ON)(httpError, attempt)
|
|
609
|
+
: false;
|
|
610
|
+
|
|
611
|
+
if (!shouldRetry || attempt >= maxAttempts - 1) {
|
|
612
|
+
// Run response error interceptors before throwing
|
|
613
|
+
const responseChain: Array<InterceptorEntry<HttpResponse>> = [];
|
|
614
|
+
responseInterceptors.forEach((entry) => responseChain.push(entry));
|
|
615
|
+
|
|
616
|
+
let finalError: unknown = httpError;
|
|
617
|
+
for (const { rejected } of responseChain) {
|
|
618
|
+
if (rejected) {
|
|
619
|
+
try {
|
|
620
|
+
const result = await rejected(finalError);
|
|
621
|
+
if (result && typeof result === 'object' && 'data' in result) {
|
|
622
|
+
return result as HttpResponse<T>;
|
|
623
|
+
}
|
|
624
|
+
if (result != null) {
|
|
625
|
+
finalError = result;
|
|
626
|
+
}
|
|
627
|
+
} catch (innerErr) {
|
|
628
|
+
if (innerErr != null) {
|
|
629
|
+
finalError = innerErr;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (!(finalError instanceof Error)) {
|
|
636
|
+
finalError = httpError;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
throw finalError;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const retryDelay = retryConfig ? resolveRetryDelay(retryConfig.delay, attempt) : 0;
|
|
643
|
+
retryConfig?.onRetry?.(httpError, attempt + 1);
|
|
644
|
+
await sleep(retryDelay, resolvedConfig.signal);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
throw lastError!;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const request = <T>(config: HttpRequestConfig): Promise<HttpResponse<T>> =>
|
|
652
|
+
dispatchRequest<T>(mergeConfig(config));
|
|
653
|
+
|
|
654
|
+
const client: HttpClient = {
|
|
655
|
+
request,
|
|
656
|
+
get: <T>(url: string, config: HttpRequestConfig = {}) =>
|
|
657
|
+
request<T>({ ...config, url, method: 'GET' }),
|
|
658
|
+
post: <T>(url: string, body?: HttpRequestConfig['body'], config: HttpRequestConfig = {}) =>
|
|
659
|
+
request<T>({ ...config, url, method: 'POST', body }),
|
|
660
|
+
put: <T>(url: string, body?: HttpRequestConfig['body'], config: HttpRequestConfig = {}) =>
|
|
661
|
+
request<T>({ ...config, url, method: 'PUT', body }),
|
|
662
|
+
patch: <T>(url: string, body?: HttpRequestConfig['body'], config: HttpRequestConfig = {}) =>
|
|
663
|
+
request<T>({ ...config, url, method: 'PATCH', body }),
|
|
664
|
+
delete: <T>(url: string, config: HttpRequestConfig = {}) =>
|
|
665
|
+
request<T>({ ...config, url, method: 'DELETE' }),
|
|
666
|
+
head: <T>(url: string, config: HttpRequestConfig = {}) =>
|
|
667
|
+
request<T>({ ...config, url, method: 'HEAD' }),
|
|
668
|
+
options: <T>(url: string, config: HttpRequestConfig = {}) =>
|
|
669
|
+
request<T>({ ...config, url, method: 'OPTIONS' }),
|
|
670
|
+
interceptors: {
|
|
671
|
+
request: requestInterceptors,
|
|
672
|
+
response: responseInterceptors,
|
|
673
|
+
},
|
|
674
|
+
defaults,
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
return client;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Default HTTP client instance using global bQuery fetch config.
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* ```ts
|
|
685
|
+
* import { http } from '@bquery/bquery/reactive';
|
|
686
|
+
*
|
|
687
|
+
* const { data } = await http.get<User[]>('/api/users');
|
|
688
|
+
* const { data: created } = await http.post('/api/users', { name: 'Ada' });
|
|
689
|
+
* ```
|
|
690
|
+
*/
|
|
691
|
+
export const http: HttpClient = createHttp();
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Request Queue
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
/** Options for `createRequestQueue()`. */
|
|
698
|
+
export interface RequestQueueOptions {
|
|
699
|
+
/** Maximum number of concurrent in-flight requests (default: 6). */
|
|
700
|
+
concurrency?: number;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** A queued request entry. */
|
|
704
|
+
interface QueueEntry<T = unknown> {
|
|
705
|
+
execute: () => Promise<HttpResponse<T>>;
|
|
706
|
+
resolve: (value: HttpResponse<T>) => void;
|
|
707
|
+
reject: (reason: unknown) => void;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Return value of `createRequestQueue()`. */
|
|
711
|
+
export interface RequestQueue {
|
|
712
|
+
/** Enqueue a request. Returns a promise that resolves when the request completes. */
|
|
713
|
+
add: <T = unknown>(execute: () => Promise<HttpResponse<T>>) => Promise<HttpResponse<T>>;
|
|
714
|
+
/** Number of requests currently being processed. */
|
|
715
|
+
readonly pending: number;
|
|
716
|
+
/** Number of requests waiting in the queue. */
|
|
717
|
+
readonly size: number;
|
|
718
|
+
/** Remove all pending (not yet started) requests from the queue. Their promises will reject. */
|
|
719
|
+
clear: () => void;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Create a request queue with a concurrency limit.
|
|
724
|
+
*
|
|
725
|
+
* Useful for rate-limiting parallel HTTP requests (e.g. browser connection limits,
|
|
726
|
+
* API throttling) while maintaining a simple promise-based interface.
|
|
727
|
+
*
|
|
728
|
+
* @param options - Queue configuration
|
|
729
|
+
* @returns A `RequestQueue` with `.add()`, `.clear()`, `.pending`, and `.size`
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* ```ts
|
|
733
|
+
* import { createRequestQueue, createHttp } from '@bquery/bquery/reactive';
|
|
734
|
+
*
|
|
735
|
+
* const api = createHttp({ baseUrl: 'https://api.example.com' });
|
|
736
|
+
* const queue = createRequestQueue({ concurrency: 3 });
|
|
737
|
+
*
|
|
738
|
+
* // These will run at most 3 at a time
|
|
739
|
+
* const results = await Promise.all(
|
|
740
|
+
* ids.map(id => queue.add(() => api.get(`/items/${id}`)))
|
|
741
|
+
* );
|
|
742
|
+
* ```
|
|
743
|
+
*/
|
|
744
|
+
export function createRequestQueue(options: RequestQueueOptions = {}): RequestQueue {
|
|
745
|
+
const { concurrency = 6 } = options;
|
|
746
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
747
|
+
throw new Error('Request queue concurrency must be a positive integer');
|
|
748
|
+
}
|
|
749
|
+
const queue: Array<QueueEntry> = [];
|
|
750
|
+
let running = 0;
|
|
751
|
+
|
|
752
|
+
const drain = (): void => {
|
|
753
|
+
while (running < concurrency && queue.length > 0) {
|
|
754
|
+
const entry = queue.shift()!;
|
|
755
|
+
running++;
|
|
756
|
+
Promise.resolve()
|
|
757
|
+
.then(entry.execute)
|
|
758
|
+
.then(entry.resolve, entry.reject)
|
|
759
|
+
.finally(() => {
|
|
760
|
+
running--;
|
|
761
|
+
drain();
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
add<T = unknown>(execute: () => Promise<HttpResponse<T>>): Promise<HttpResponse<T>> {
|
|
768
|
+
return new Promise<HttpResponse<T>>((resolve, reject) => {
|
|
769
|
+
queue.push({
|
|
770
|
+
execute: execute as () => Promise<HttpResponse>,
|
|
771
|
+
resolve: resolve as (value: HttpResponse) => void,
|
|
772
|
+
reject,
|
|
773
|
+
});
|
|
774
|
+
drain();
|
|
775
|
+
});
|
|
776
|
+
},
|
|
777
|
+
get pending() {
|
|
778
|
+
return running;
|
|
779
|
+
},
|
|
780
|
+
get size() {
|
|
781
|
+
return queue.length;
|
|
782
|
+
},
|
|
783
|
+
clear() {
|
|
784
|
+
const cleared = queue.splice(0);
|
|
785
|
+
for (const entry of cleared) {
|
|
786
|
+
entry.reject(new Error('Request queue cleared'));
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|