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.
Files changed (55) hide show
  1. package/CHANGELOG.md +961 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +925 -0
  4. package/README.md +1392 -4
  5. package/dist/built-in-middleware.d.ts +232 -0
  6. package/dist/built-in-middleware.js +127 -0
  7. package/dist/create-api.d.ts +120 -0
  8. package/dist/create-api.js +370 -0
  9. package/dist/define-request.d.ts +251 -0
  10. package/dist/define-request.js +4 -0
  11. package/dist/graphql.d.ts +30 -0
  12. package/dist/graphql.js +272 -0
  13. package/dist/index.d.ts +16 -0
  14. package/dist/index.js +6 -0
  15. package/dist/middleware.d.ts +77 -0
  16. package/dist/middleware.js +12 -0
  17. package/dist/paginate.d.ts +71 -0
  18. package/dist/paginate.js +15 -0
  19. package/dist/request.d.ts +136 -0
  20. package/dist/request.js +12 -0
  21. package/dist/result.d.ts +178 -0
  22. package/dist/result.js +20 -0
  23. package/dist/testing.d.ts +51 -0
  24. package/dist/testing.js +135 -0
  25. package/dist/types.d.ts +751 -0
  26. package/dist/types.js +1 -0
  27. package/dist/utils/abort-kind.d.ts +58 -0
  28. package/dist/utils/abort-kind.js +28 -0
  29. package/dist/utils/any-signal.d.ts +18 -0
  30. package/dist/utils/any-signal.js +29 -0
  31. package/dist/utils/backstop.d.ts +49 -0
  32. package/dist/utils/backstop.js +80 -0
  33. package/dist/utils/budget.d.ts +53 -0
  34. package/dist/utils/budget.js +20 -0
  35. package/dist/utils/cache.d.ts +54 -0
  36. package/dist/utils/cache.js +37 -0
  37. package/dist/utils/dedupe.d.ts +90 -0
  38. package/dist/utils/dedupe.js +20 -0
  39. package/dist/utils/headers.d.ts +1 -0
  40. package/dist/utils/headers.js +19 -0
  41. package/dist/utils/path-params.d.ts +89 -0
  42. package/dist/utils/path-params.js +80 -0
  43. package/dist/utils/serialize.d.ts +48 -0
  44. package/dist/utils/serialize.js +21 -0
  45. package/dist/utils/share.d.ts +49 -0
  46. package/dist/utils/share.js +48 -0
  47. package/dist/utils/special-body.d.ts +18 -0
  48. package/dist/utils/special-body.js +7 -0
  49. package/dist/utils/stable-key.d.ts +55 -0
  50. package/dist/utils/stable-key.js +111 -0
  51. package/dist/utils/timeout.d.ts +27 -0
  52. package/dist/utils/timeout.js +7 -0
  53. package/dist/utils/validate.d.ts +32 -0
  54. package/dist/utils/validate.js +6 -0
  55. 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 {};