liaise 0.0.0 → 5.0.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/CHANGELOG.md +961 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +925 -0
- package/README.md +1392 -4
- package/dist/built-in-middleware.d.ts +232 -0
- package/dist/built-in-middleware.js +127 -0
- package/dist/create-api.d.ts +120 -0
- package/dist/create-api.js +370 -0
- package/dist/define-request.d.ts +251 -0
- package/dist/define-request.js +4 -0
- package/dist/graphql.d.ts +30 -0
- package/dist/graphql.js +272 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/middleware.d.ts +77 -0
- package/dist/middleware.js +12 -0
- package/dist/paginate.d.ts +71 -0
- package/dist/paginate.js +15 -0
- package/dist/request.d.ts +136 -0
- package/dist/request.js +12 -0
- package/dist/result.d.ts +178 -0
- package/dist/result.js +20 -0
- package/dist/testing.d.ts +51 -0
- package/dist/testing.js +135 -0
- package/dist/types.d.ts +751 -0
- package/dist/types.js +1 -0
- package/dist/utils/abort-kind.d.ts +58 -0
- package/dist/utils/abort-kind.js +28 -0
- package/dist/utils/any-signal.d.ts +18 -0
- package/dist/utils/any-signal.js +29 -0
- package/dist/utils/backstop.d.ts +49 -0
- package/dist/utils/backstop.js +80 -0
- package/dist/utils/budget.d.ts +53 -0
- package/dist/utils/budget.js +20 -0
- package/dist/utils/cache.d.ts +54 -0
- package/dist/utils/cache.js +37 -0
- package/dist/utils/dedupe.d.ts +90 -0
- package/dist/utils/dedupe.js +20 -0
- package/dist/utils/headers.d.ts +1 -0
- package/dist/utils/headers.js +19 -0
- package/dist/utils/path-params.d.ts +89 -0
- package/dist/utils/path-params.js +80 -0
- package/dist/utils/serialize.d.ts +48 -0
- package/dist/utils/serialize.js +21 -0
- package/dist/utils/share.d.ts +49 -0
- package/dist/utils/share.js +48 -0
- package/dist/utils/special-body.d.ts +18 -0
- package/dist/utils/special-body.js +7 -0
- package/dist/utils/stable-key.d.ts +55 -0
- package/dist/utils/stable-key.js +111 -0
- package/dist/utils/timeout.d.ts +27 -0
- package/dist/utils/timeout.js +7 -0
- package/dist/utils/validate.d.ts +32 -0
- package/dist/utils/validate.js +6 -0
- package/package.json +67 -5
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { Middleware, RetryOptions } from './types.js';
|
|
2
|
+
export type { RetryOptions, RetryInfo } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a middleware that retries failed requests with a real backoff
|
|
5
|
+
* policy: exponential (or linear, or custom) delay curves, full jitter,
|
|
6
|
+
* `Retry-After` support, a configurable retry predicate, and an observational
|
|
7
|
+
* `onRetry` hook.
|
|
8
|
+
*
|
|
9
|
+
* **How it works:**
|
|
10
|
+
*
|
|
11
|
+
* When the downstream chain (via `next()`) returns a result that `retryOn`
|
|
12
|
+
* accepts, this middleware waits out a delay and calls `next()` again —
|
|
13
|
+
* re-executing every middleware below it in the onion plus the core fetch.
|
|
14
|
+
* It keeps retrying until either the predicate rejects the result, or `max`
|
|
15
|
+
* attempts have been exhausted.
|
|
16
|
+
*
|
|
17
|
+
* **Delay:**
|
|
18
|
+
*
|
|
19
|
+
* The base delay comes from the configured curve (`baseDelay * 2^(attempt-1)`
|
|
20
|
+
* for `'exponential'`, `baseDelay * attempt` for `'linear'`, or a custom
|
|
21
|
+
* function of the attempt number), capped by `maxDelay`. Full jitter then
|
|
22
|
+
* applies: the actual delay is `Math.random() * computed`, per AWS's
|
|
23
|
+
* recommendation for de-synchronising a thundering herd. A `Retry-After`
|
|
24
|
+
* response header — when present and `respectRetryAfter` is not disabled —
|
|
25
|
+
* replaces the computed delay outright (still capped by `maxDelay`) and is
|
|
26
|
+
* honoured as-is, without jitter: a server telling you exactly when to come
|
|
27
|
+
* back should not be randomised.
|
|
28
|
+
*
|
|
29
|
+
* **Abortable sleep:**
|
|
30
|
+
*
|
|
31
|
+
* The backoff sleep watches `ctx.request.signal`, so a whole-operation
|
|
32
|
+
* `timeout` cannot be outlived by a long delay: the sleep resolves (rather
|
|
33
|
+
* than rejects) as soon as the signal aborts, and the loop proceeds straight
|
|
34
|
+
* to `next()`. With an already-aborted signal, the core fetch rejects
|
|
35
|
+
* immediately (no network call), and its existing abort classification does
|
|
36
|
+
* the rest — the result comes back with `kind: 'timeout'` for a deadline,
|
|
37
|
+
* `kind: 'abort'` for a cancellation or a dedupe supersede — instead of this
|
|
38
|
+
* middleware reporting a stale HTTP result for a request that was actually
|
|
39
|
+
* cancelled or timed out. The loop then exits on its own, since an abort is
|
|
40
|
+
* status 0 and the default `retryOn` only matches `status >= 500`.
|
|
41
|
+
*
|
|
42
|
+
* **What it does NOT retry by default:**
|
|
43
|
+
*
|
|
44
|
+
* - 4xx errors — caused by the request itself, not transient server issues.
|
|
45
|
+
* - 429 and network errors (status 0) — deliberately excluded from the
|
|
46
|
+
* default so upgrading doesn't change behaviour under you; pass a custom
|
|
47
|
+
* `retryOn` to opt in.
|
|
48
|
+
*
|
|
49
|
+
* **Retry count semantics:**
|
|
50
|
+
*
|
|
51
|
+
* `max` is the number of ADDITIONAL attempts after the initial one. So
|
|
52
|
+
* `retryMiddleware(2)` (or `{ max: 2 }`) means: 1 initial attempt + up to 2
|
|
53
|
+
* retries = 3 total calls to `next()` in the worst case.
|
|
54
|
+
*
|
|
55
|
+
* **Middleware position matters:**
|
|
56
|
+
*
|
|
57
|
+
* Because `next()` re-executes everything downstream, placing retry
|
|
58
|
+
* middleware BEFORE auth middleware means auth headers will be re-injected
|
|
59
|
+
* on each retry (good). Placing it AFTER means the same headers are reused
|
|
60
|
+
* (usually fine, but stale tokens won't be refreshed).
|
|
61
|
+
*
|
|
62
|
+
* @param options - Either a number (shorthand for `{ max: number }`, kept for
|
|
63
|
+
* backwards compatibility) or a {@link RetryOptions} object. Defaults to 3.
|
|
64
|
+
* @returns A Middleware function that can be passed to `createApi` or
|
|
65
|
+
* individual `Request` configs.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* // Retry up to 2 times on server errors (3 total attempts), numeric shorthand
|
|
70
|
+
* const api = createApi({
|
|
71
|
+
* baseUrl: '/api',
|
|
72
|
+
* requests: { getItems },
|
|
73
|
+
* middleware: [retryMiddleware(2)],
|
|
74
|
+
* })
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* // Full policy: linear backoff, a higher cap, and progress reporting
|
|
80
|
+
* const api = createApi({
|
|
81
|
+
* baseUrl: '/api',
|
|
82
|
+
* requests: { getItems },
|
|
83
|
+
* middleware: [retryMiddleware({
|
|
84
|
+
* max: 5,
|
|
85
|
+
* delay: 'linear',
|
|
86
|
+
* baseDelay: 200,
|
|
87
|
+
* maxDelay: 10_000,
|
|
88
|
+
* onRetry: ({ attempt, max, delay }) => console.log(`retry ${attempt}/${max} in ${delay}ms`),
|
|
89
|
+
* })],
|
|
90
|
+
* })
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export declare function retryMiddleware(options?: number | RetryOptions): Middleware;
|
|
94
|
+
/**
|
|
95
|
+
* Middleware that logs the lifecycle of each API request to the console.
|
|
96
|
+
*
|
|
97
|
+
* **What it logs:**
|
|
98
|
+
*
|
|
99
|
+
* 1. A "request start" line when the request begins, showing the HTTP method,
|
|
100
|
+
* the request name (e.g., 'getUser'), and the full URL.
|
|
101
|
+
*
|
|
102
|
+
* 2. A "request complete" line when the response arrives, showing:
|
|
103
|
+
* - The request name
|
|
104
|
+
* - Whether it succeeded ("OK") or failed ("ERROR" + status code)
|
|
105
|
+
* - The elapsed time in milliseconds
|
|
106
|
+
*
|
|
107
|
+
* **Timing:**
|
|
108
|
+
*
|
|
109
|
+
* Uses `Date.now()` instead of `performance.now()` for maximum runtime
|
|
110
|
+
* compatibility. `performance.now()` is not available in all environments
|
|
111
|
+
* (e.g., some edge runtimes, older Node.js versions), while `Date.now()`
|
|
112
|
+
* works everywhere. The millisecond precision of `Date.now()` is more than
|
|
113
|
+
* sufficient for HTTP request timing.
|
|
114
|
+
*
|
|
115
|
+
* **Output format examples:**
|
|
116
|
+
*
|
|
117
|
+
* ```
|
|
118
|
+
* [liaise] → GET getItems /api/items
|
|
119
|
+
* [liaise] ← getItems OK (142ms)
|
|
120
|
+
*
|
|
121
|
+
* [liaise] → POST createUser /api/users
|
|
122
|
+
* [liaise] ← createUser ERROR 422 (89ms)
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* **Usage note:**
|
|
126
|
+
*
|
|
127
|
+
* This middleware is intended for development and debugging. In production,
|
|
128
|
+
* you may want to replace it with a custom middleware that sends telemetry
|
|
129
|
+
* to your observability platform instead of logging to the console.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* import { logMiddleware } from 'liaise/middleware'
|
|
134
|
+
*
|
|
135
|
+
* const api = createApi({
|
|
136
|
+
* baseUrl: '/api',
|
|
137
|
+
* requests: { getItems, createUser },
|
|
138
|
+
* middleware: [logMiddleware],
|
|
139
|
+
* })
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export declare const logMiddleware: Middleware;
|
|
143
|
+
export type CacheMiddleware = Middleware & {
|
|
144
|
+
clear(): void;
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Creates a middleware that caches successful responses in memory, keyed by
|
|
148
|
+
* request name and params. Identical calls within the TTL window are served
|
|
149
|
+
* from cache without hitting the network.
|
|
150
|
+
*
|
|
151
|
+
* **Cache key:**
|
|
152
|
+
*
|
|
153
|
+
* The key is `ctx.requestName` plus `stableKey(ctx.request.params)` — a
|
|
154
|
+
* content-based key (see `src/utils/stable-key.ts`): object keys sorted,
|
|
155
|
+
* `undefined` members dropped, `Date` by its ISO string, `Map`, `Set` and
|
|
156
|
+
* typed arrays by their entries. It is derived from the original params
|
|
157
|
+
* object, not the processed URL.
|
|
158
|
+
*
|
|
159
|
+
* A call whose params cannot be keyed soundly — a BigInt, an `ArrayBuffer`,
|
|
160
|
+
* `Blob`, `FormData` or `URLSearchParams`, a circular structure, or an object
|
|
161
|
+
* with no enumerable state, at any depth — is never cached and never served
|
|
162
|
+
* from cache. Declining is always safe; serving one caller the response to a
|
|
163
|
+
* different payload never is. A raw string keys fine and is cached normally.
|
|
164
|
+
*
|
|
165
|
+
* **What is cached:**
|
|
166
|
+
*
|
|
167
|
+
* Only successful results are stored. If the response has an error (4xx, 5xx,
|
|
168
|
+
* network error, or GraphQL error), the result is not cached and the next call
|
|
169
|
+
* will hit the network again.
|
|
170
|
+
*
|
|
171
|
+
* The full `Result` object is cached, including `response` (headers, status)
|
|
172
|
+
* and `retry`. Calling `retry()` on a cached result re-enters the middleware
|
|
173
|
+
* chain — if the TTL is still valid it returns the cached value; if expired,
|
|
174
|
+
* it makes a fresh network call. To force a network call on a specific
|
|
175
|
+
* invocation, use `skipMiddleware: [myCache]` in the call options.
|
|
176
|
+
*
|
|
177
|
+
* **Isolation:**
|
|
178
|
+
*
|
|
179
|
+
* Each call to `cacheMiddleware()` creates an independent store. Two separate
|
|
180
|
+
* instances on two different endpoints never share entries, regardless of
|
|
181
|
+
* request name or params shape.
|
|
182
|
+
*
|
|
183
|
+
* **Eviction:**
|
|
184
|
+
*
|
|
185
|
+
* When the store reaches `maxSize`, the oldest entry by insertion time is
|
|
186
|
+
* evicted before the new one is added. Expired entries are removed on access
|
|
187
|
+
* rather than on a background timer.
|
|
188
|
+
*
|
|
189
|
+
* **Debugging:**
|
|
190
|
+
*
|
|
191
|
+
* Set `debug: true` to log cache hits and misses to the console:
|
|
192
|
+
* ```
|
|
193
|
+
* [liaise cache] HIT getUser {"id":"42"}
|
|
194
|
+
* [liaise cache] MISS getUser {"id":"42"}
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* @param options.ttl - Time-to-live in milliseconds. Defaults to 5 minutes.
|
|
198
|
+
* @param options.maxSize - Maximum number of entries. Defaults to 50.
|
|
199
|
+
* @param options.debug - Log hits and misses to console. Defaults to false.
|
|
200
|
+
* @returns A middleware function with an attached `clear()` method.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* import { cacheMiddleware } from 'liaise/middleware'
|
|
205
|
+
*
|
|
206
|
+
* const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })
|
|
207
|
+
*
|
|
208
|
+
* const getUser = new Request<{ id: string }, User>({
|
|
209
|
+
* method: 'GET',
|
|
210
|
+
* path: '/users/:id',
|
|
211
|
+
* middleware: [getUserCache],
|
|
212
|
+
* })
|
|
213
|
+
*
|
|
214
|
+
* // Force a network call for a single invocation:
|
|
215
|
+
* const { data } = await api.getUser({ id: '42' }, { skipMiddleware: [getUserCache] })
|
|
216
|
+
* ```
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```ts
|
|
220
|
+
* // Clear all cached entries on logout so the next user gets fresh data:
|
|
221
|
+
* const getUserCache = cacheMiddleware({ ttl: 5 * 60_000 })
|
|
222
|
+
*
|
|
223
|
+
* function onLogout() {
|
|
224
|
+
* getUserCache.clear()
|
|
225
|
+
* }
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export declare function cacheMiddleware(options?: {
|
|
229
|
+
ttl?: number;
|
|
230
|
+
maxSize?: number;
|
|
231
|
+
debug?: boolean;
|
|
232
|
+
}): CacheMiddleware;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { CacheStore } from './utils/cache.js';
|
|
2
|
+
import { stableKey } from './utils/stable-key.js';
|
|
3
|
+
function sleep(ms, signal) {
|
|
4
|
+
return new Promise(resolve => {
|
|
5
|
+
if (signal?.aborted)
|
|
6
|
+
return resolve();
|
|
7
|
+
const cleanup = () => { signal?.removeEventListener('abort', onAbort); };
|
|
8
|
+
const onAbort = () => { clearTimeout(timer); cleanup(); resolve(); };
|
|
9
|
+
const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
10
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function parseRetryAfter(value) {
|
|
14
|
+
if (!value)
|
|
15
|
+
return null;
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
if (!trimmed)
|
|
18
|
+
return null;
|
|
19
|
+
const seconds = Number(trimmed);
|
|
20
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
21
|
+
return seconds * 1000;
|
|
22
|
+
const when = Date.parse(trimmed);
|
|
23
|
+
if (Number.isNaN(when))
|
|
24
|
+
return null;
|
|
25
|
+
return Math.max(0, when - Date.now());
|
|
26
|
+
}
|
|
27
|
+
export function retryMiddleware(options = 3) {
|
|
28
|
+
const o = typeof options === 'number' ? { max: options } : options;
|
|
29
|
+
const max = o.max ?? 3;
|
|
30
|
+
const curve = o.delay ?? 'exponential';
|
|
31
|
+
const baseDelay = typeof o.baseDelay === 'number' && !Number.isNaN(o.baseDelay) ? o.baseDelay : 250;
|
|
32
|
+
const maxDelay = typeof o.maxDelay === 'number' && !Number.isNaN(o.maxDelay) ? o.maxDelay : 30000;
|
|
33
|
+
const jitter = o.jitter ?? true;
|
|
34
|
+
const respectRetryAfter = o.respectRetryAfter ?? true;
|
|
35
|
+
const retryOn = o.retryOn ?? ((r) => (r.error?.status ?? 0) >= 500);
|
|
36
|
+
const shouldRetry = (r, attempt) => {
|
|
37
|
+
try {
|
|
38
|
+
return retryOn(r, attempt);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const computeDelay = (attempt) => {
|
|
45
|
+
const fallback = baseDelay * 2 ** (attempt - 1);
|
|
46
|
+
if (typeof curve === 'function') {
|
|
47
|
+
let computed;
|
|
48
|
+
try {
|
|
49
|
+
computed = curve(attempt);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isFinite(computed))
|
|
55
|
+
return fallback;
|
|
56
|
+
return Math.max(0, computed);
|
|
57
|
+
}
|
|
58
|
+
return curve === 'linear' ? baseDelay * attempt : fallback;
|
|
59
|
+
};
|
|
60
|
+
return async (ctx, next) => {
|
|
61
|
+
let result = await next();
|
|
62
|
+
let attempt = 0;
|
|
63
|
+
while (shouldRetry(result, attempt + 1)) {
|
|
64
|
+
if (attempt >= max)
|
|
65
|
+
break;
|
|
66
|
+
attempt++;
|
|
67
|
+
const header = respectRetryAfter ? parseRetryAfter(result.response?.headers.get('retry-after') ?? null) : null;
|
|
68
|
+
let delay = Math.min(header ?? computeDelay(attempt), maxDelay);
|
|
69
|
+
if (!Number.isFinite(delay) || delay < 0)
|
|
70
|
+
delay = 0;
|
|
71
|
+
if (header === null && jitter)
|
|
72
|
+
delay = Math.random() * delay;
|
|
73
|
+
if (o.onRetry) {
|
|
74
|
+
const info = { attempt, max, delay, result };
|
|
75
|
+
try {
|
|
76
|
+
o.onRetry(info);
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
}
|
|
80
|
+
await sleep(delay, ctx.request.signal);
|
|
81
|
+
result = await next();
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export const logMiddleware = async (ctx, next) => {
|
|
87
|
+
const start = Date.now();
|
|
88
|
+
console.log(`[liaise] → ${ctx.request.method} ${ctx.requestName} ${ctx.request.url}`);
|
|
89
|
+
const result = await next();
|
|
90
|
+
const duration = Date.now() - start;
|
|
91
|
+
if (result.error) {
|
|
92
|
+
console.log(`[liaise] ← ${ctx.requestName} ERROR ${result.error.status} (${duration}ms)`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.log(`[liaise] ← ${ctx.requestName} OK (${duration}ms)`);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
export function cacheMiddleware(options) {
|
|
100
|
+
const store = new CacheStore({
|
|
101
|
+
ttl: options?.ttl ?? 5 * 60000,
|
|
102
|
+
maxSize: options?.maxSize ?? 50,
|
|
103
|
+
});
|
|
104
|
+
const debug = options?.debug ?? false;
|
|
105
|
+
const mw = async (ctx, next) => {
|
|
106
|
+
const paramsStr = stableKey(ctx.request.params);
|
|
107
|
+
if (paramsStr === null)
|
|
108
|
+
return next();
|
|
109
|
+
const key = `${ctx.requestName}|${paramsStr}`;
|
|
110
|
+
const cached = store.get(key);
|
|
111
|
+
if (cached !== null) {
|
|
112
|
+
if (debug)
|
|
113
|
+
console.log(`[liaise cache] HIT ${ctx.requestName} ${paramsStr}`);
|
|
114
|
+
return cached;
|
|
115
|
+
}
|
|
116
|
+
if (debug)
|
|
117
|
+
console.log(`[liaise cache] MISS ${ctx.requestName} ${paramsStr}`);
|
|
118
|
+
const result = await next();
|
|
119
|
+
if (!result.error) {
|
|
120
|
+
store.set(key, result);
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
};
|
|
124
|
+
const fn = mw;
|
|
125
|
+
fn.clear = () => store.clear();
|
|
126
|
+
return fn;
|
|
127
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Request } from './request.js';
|
|
2
|
+
import type { ApiConfig, CallOptions, Result } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Extracts the TParams type from a Request instance.
|
|
5
|
+
*
|
|
6
|
+
* Given `Request<{ id: string }, User>`, this resolves to `{ id: string }`.
|
|
7
|
+
* Used internally by the Api mapped type to infer method parameter types.
|
|
8
|
+
*
|
|
9
|
+
* @typeParam R - A Request instance (or anything — returns `never` for non-Request types).
|
|
10
|
+
*/
|
|
11
|
+
type ExtractParams<R> = R extends Request<infer P, any> ? P : never;
|
|
12
|
+
/**
|
|
13
|
+
* Extracts the TResponse type from a Request instance.
|
|
14
|
+
*
|
|
15
|
+
* Given `Request<{ id: string }, User>`, this resolves to `User`.
|
|
16
|
+
* Used internally by the Api mapped type to infer method return types.
|
|
17
|
+
*
|
|
18
|
+
* @typeParam R - A Request instance (or anything — returns `never` for non-Request types).
|
|
19
|
+
*/
|
|
20
|
+
type ExtractResponse<R> = R extends Request<any, infer Res> ? Res : never;
|
|
21
|
+
/**
|
|
22
|
+
* Defines the signature of a generated API method.
|
|
23
|
+
*
|
|
24
|
+
* The key trick here is the conditional type: when TParams is
|
|
25
|
+
* `Record<string, never>` (an empty object — meaning the endpoint takes no
|
|
26
|
+
* params), the `params` argument becomes optional. This allows callers to
|
|
27
|
+
* write `api.health()` instead of `api.health({})`.
|
|
28
|
+
*
|
|
29
|
+
* The condition `Record<string, never> extends TParams` works because:
|
|
30
|
+
* - When TParams IS Record<string, never>, the condition is true → optional params
|
|
31
|
+
* - When TParams has required keys (e.g., { id: string }), Record<string, never>
|
|
32
|
+
* does NOT extend it → required params
|
|
33
|
+
*
|
|
34
|
+
* @typeParam TParams - The params type for this endpoint.
|
|
35
|
+
* @typeParam TResponse - The response type for this endpoint.
|
|
36
|
+
*/
|
|
37
|
+
type ApiMethod<TParams extends object, TResponse> = Record<string, never> extends TParams ? (params?: TParams, options?: CallOptions) => Promise<Result<TResponse>> : (params: TParams, options?: CallOptions) => Promise<Result<TResponse>>;
|
|
38
|
+
/**
|
|
39
|
+
* The typed API object returned by createApi.
|
|
40
|
+
*
|
|
41
|
+
* This is a mapped type that transforms a record of Request instances into
|
|
42
|
+
* a record of callable methods. Each key from the `requests` config becomes
|
|
43
|
+
* a method with fully typed params and response.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* // Given:
|
|
48
|
+
* const requests = {
|
|
49
|
+
* getUser: new Request<{ id: string }, User>({ ... }),
|
|
50
|
+
* listUsers: new Request<Record<string, never>, User[]>({ ... }),
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* // Api<typeof requests> resolves to:
|
|
54
|
+
* {
|
|
55
|
+
* getUser: (params: { id: string }, options?: CallOptions) => Promise<Result<User>>
|
|
56
|
+
* listUsers: (params?: Record<string, never>, options?: CallOptions) => Promise<Result<User[]>>
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @typeParam TRequests - The record of Request instances from the config.
|
|
61
|
+
*/
|
|
62
|
+
type Api<TRequests extends Record<string, Request<any, any>>> = {
|
|
63
|
+
[K in keyof TRequests]: ApiMethod<ExtractParams<TRequests[K]>, ExtractResponse<TRequests[K]>>;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Creates a typed API client from a set of Request definitions.
|
|
67
|
+
*
|
|
68
|
+
* This is the primary entry point of the liaise library. It takes a configuration
|
|
69
|
+
* object containing a base URL, request definitions, optional global middleware,
|
|
70
|
+
* default headers, and an error callback, and returns an object where each request
|
|
71
|
+
* key becomes a callable, fully-typed method.
|
|
72
|
+
*
|
|
73
|
+
* **How it works internally:**
|
|
74
|
+
*
|
|
75
|
+
* For each Request in the `requests` record, createApi generates a method that:
|
|
76
|
+
* 1. Builds the URL from baseUrl + path template + params (path param substitution)
|
|
77
|
+
* 2. Merges headers from three layers (global < per-request < per-call)
|
|
78
|
+
* 3. Serializes the body (JSON for plain objects, passthrough for FormData/Blob/etc.)
|
|
79
|
+
* 4. Computes the effective abort signal (includes dedupe tracking if enabled)
|
|
80
|
+
* 5. Composes the middleware chain (global → per-request → per-call, minus skipped)
|
|
81
|
+
* 6. Executes the chain, with the core fetch as the innermost layer
|
|
82
|
+
* 7. Fires the onError callback if the final result has an error
|
|
83
|
+
*
|
|
84
|
+
* **Dedupe integration:**
|
|
85
|
+
*
|
|
86
|
+
* A single DedupeTracker instance is created per createApi call. When a Request
|
|
87
|
+
* has `dedupe: true`, the abort signal is routed through the tracker before being
|
|
88
|
+
* passed to fetch. This means that firing a new request for the same endpoint
|
|
89
|
+
* automatically cancels any previous in-flight request — perfect for
|
|
90
|
+
* search-as-you-type, paginated lists, or rapidly changing filters.
|
|
91
|
+
*
|
|
92
|
+
* **Error handling philosophy:**
|
|
93
|
+
*
|
|
94
|
+
* The library never throws — every outcome is expressed as a Result<T>.
|
|
95
|
+
* - HTTP errors (4xx, 5xx) → Result with error, response, and retry
|
|
96
|
+
* - Network errors → Result with error (status 0), null response, and retry
|
|
97
|
+
* - Synchronous errors (e.g., TypeError from query string serialization) → same
|
|
98
|
+
*
|
|
99
|
+
* @typeParam TRequests - Record of Request instances. Keys become method names,
|
|
100
|
+
* and the Request's TParams/TResponse generics become the method's signature.
|
|
101
|
+
*
|
|
102
|
+
* @param config - API configuration with baseUrl, requests, middleware, headers, onError.
|
|
103
|
+
* @returns A typed object where each request key is a callable method.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* const api = createApi({
|
|
108
|
+
* baseUrl: '/api',
|
|
109
|
+
* requests: { getUser, listUsers, createUser },
|
|
110
|
+
* middleware: [authMiddleware, logMiddleware],
|
|
111
|
+
* headers: { 'X-App-Version': '2.0.0' },
|
|
112
|
+
* onError: (error) => Sentry.captureException(error),
|
|
113
|
+
* })
|
|
114
|
+
*
|
|
115
|
+
* // Fully typed: params and response inferred from Request generics
|
|
116
|
+
* const { data, error, retry } = await api.getUser({ id: '42' })
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export declare function createApi<TRequests extends Record<string, Request<any, any>>>(config: ApiConfig<TRequests>): Api<TRequests>;
|
|
120
|
+
export {};
|