@zerotal/client 1.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 +18 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/package.json +52 -0
- package/src/ApiClient.ts +423 -0
- package/src/CircuitBreaker.ts +170 -0
- package/src/Socket.ts +531 -0
- package/src/augment.ts +8 -0
- package/src/config.ts +22 -0
- package/src/errors.ts +136 -0
- package/src/http.ts +192 -0
- package/src/index.ts +68 -0
- package/src/provider/ClientProvider.ts +26 -0
- package/src/types.ts +112 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog — @zerotal/client
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `stable`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] — 2026-08-05
|
|
12
|
+
|
|
13
|
+
_First public release._
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Moved service provider to `src/provider/`.
|
|
18
|
+
- Provider now declares `static provides`/`environments`; config factory takes `Partial<>`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# @zerotal/client
|
|
2
|
+
|
|
3
|
+
> Type-safe HTTP API client bound to a route map, with a built-in circuit breaker.
|
|
4
|
+
|
|
5
|
+
A fetch-based HTTP client that infers request bodies, path params, query params, and response shapes directly from a TypeScript route map — no casting. Includes request/response interceptors, 401 token-refresh handling, and a circuit breaker that fails fast when an upstream service is struggling.
|
|
6
|
+
|
|
7
|
+
> **`@zerotal/client` vs core's `Http` facade** — they solve different problems, despite both speaking HTTP:
|
|
8
|
+
>
|
|
9
|
+
> - Use **`@zerotal/client`** (`ApiClient`) for the **frontend/SPA** consuming _your own_ typed API — it's route-map-typed and ships a familiar realtime `Socket`.
|
|
10
|
+
> - Use core's **`Http`** facade (`@zerotal/core`) for **server-to-server** outgoing requests — fluent, with test fakes.
|
|
11
|
+
|
|
12
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add @zerotal/client
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
Register the provider in `bootstrap/providers.ts`:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { ClientProvider } from "@zerotal/client";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Define your API surface as a route map, then create a client bound to it:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// api-types.ts
|
|
34
|
+
export interface Routes {
|
|
35
|
+
"GET /api/users": {
|
|
36
|
+
query: { page?: number; search?: string };
|
|
37
|
+
response: { data: UserResource[]; total: number };
|
|
38
|
+
};
|
|
39
|
+
"GET /api/users/{id}": { params: { id: number }; response: UserResource };
|
|
40
|
+
"POST /api/users": { body: { name: string; email: string }; response: UserResource };
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { createApiClient } from "@zerotal/client";
|
|
46
|
+
import type { Routes } from "./api-types.ts";
|
|
47
|
+
|
|
48
|
+
export const api = createApiClient<Routes>({
|
|
49
|
+
baseUrl: "https://api.example.com",
|
|
50
|
+
headers: { Accept: "application/json" },
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Request methods are fully typed against the route map:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const user = await api.get("/api/users/{id}", { id: 42 }); // -> UserResource
|
|
58
|
+
const list = await api.get("/api/users", undefined, { query: { page: 2, search: "alice" } });
|
|
59
|
+
const created = await api.post("/api/users", { name: "Alice", email: "alice@example.com" });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Attach interceptors and 401 refresh handling at construction:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const api = createApiClient<Routes>({
|
|
66
|
+
baseUrl: "https://api.example.com",
|
|
67
|
+
onRequest: async (config) => ({
|
|
68
|
+
...config,
|
|
69
|
+
headers: { ...config.headers, Authorization: `Bearer ${await tokenStore.get()}` },
|
|
70
|
+
}),
|
|
71
|
+
onUnauthorized: async (err, retry) => {
|
|
72
|
+
const newToken = await authStore.refresh();
|
|
73
|
+
return retry({ Authorization: `Bearer ${newToken}` }); // limited to one attempt
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add a circuit breaker to fail fast against an unhealthy upstream:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { CircuitBreaker, CircuitBreakerOpenError } from "@zerotal/client";
|
|
82
|
+
|
|
83
|
+
const api = createApiClient<Routes>({
|
|
84
|
+
baseUrl: "https://api.example.com",
|
|
85
|
+
circuitBreaker: { threshold: 5, resetTimeout: 30_000 },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// CircuitBreaker also wraps any async operation directly:
|
|
89
|
+
const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 15_000 });
|
|
90
|
+
const rates = await breaker.call(() =>
|
|
91
|
+
fetch("https://pricing.internal/rates").then((r) => r.json()),
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Non-2xx responses throw `ApiClientError` (with `status`, `statusText`, `body`); a `422` throws a typed `ValidationError` (`.errors`, `.has()`, `.first()`); an open circuit throws `CircuitBreakerOpenError`.
|
|
96
|
+
|
|
97
|
+
The client also handles **auth & CSRF** (`token`/`setToken`, `withCredentials`, `XSRF-TOKEN` → `X-XSRF-TOKEN`), **timeouts & retries** (`timeout`, `retry` with backoff + `Retry-After`), **file uploads/downloads** (`FormData`/`Blob` bodies, `responseType: "blob"`), nested query serialization (`ids[]=`, `filter[x]=`), and a per-request `meta` callback for response headers. See [the docs](../../docs/client/index.md).
|
|
98
|
+
|
|
99
|
+
### Realtime: `Socket`
|
|
100
|
+
|
|
101
|
+
A small, dependency-free WebSocket client for [`@zerotal/broadcasting`](../broadcasting)'s native
|
|
102
|
+
`ws` / `redis` drivers, with a familiar realtime-client API (and a drop-in for `window.Echo`). No
|
|
103
|
+
external realtime client required.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { Socket } from "@zerotal/client";
|
|
107
|
+
|
|
108
|
+
const socket = new Socket(); // ws(s)://<host>/app/ws
|
|
109
|
+
|
|
110
|
+
socket.channel("posts").listen("PostPublished", (e) => render(e));
|
|
111
|
+
socket.private(`orders.${id}`).listen("OrderUpdated", (e) => update(e.order));
|
|
112
|
+
socket
|
|
113
|
+
.presence(`chat.${roomId}`)
|
|
114
|
+
.here((members) => setOnline(members))
|
|
115
|
+
.joining((m) => addOnline(m))
|
|
116
|
+
.leaving((m) => removeOnline(m))
|
|
117
|
+
.listen("Message", (e) => append(e));
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Private/presence channels are authorized with a per-subscription HMAC signature (Pusher-style):
|
|
121
|
+
the client fetches it from `authEndpoint` (default `/broadcasting/auth`, signed server-side with
|
|
122
|
+
`APP_KEY`) and re-fetches on reconnect. Pass `auth.headers` for CSRF, or `authEndpoint: false` for
|
|
123
|
+
connection-level auth. Auto-reconnects and re-subscribes; observe state with
|
|
124
|
+
`socket.on("connected"|"disconnected"|…, cb)`; use `socket.socketId()` as the `X-Socket-ID` header
|
|
125
|
+
so server `toOthers()` broadcasts skip this client. See
|
|
126
|
+
[Broadcasting › Client-side](../../docs/broadcasting/client.md).
|
|
127
|
+
|
|
128
|
+
## Exports
|
|
129
|
+
|
|
130
|
+
- `createApiClient<Routes>(config)` — factory returning a type-safe `ApiClient`.
|
|
131
|
+
- `ApiClient` — the client class (`get`, `post`, `put`, `patch`, `delete`, `setToken`).
|
|
132
|
+
- `ApiClientError` — thrown on non-2xx responses (`status`, `statusText`, `body`, `retryAfterMs`).
|
|
133
|
+
- `ValidationError` — thrown on `422`; exposes `.errors`, `.has()`, `.first()`, `.fields()`, `.validationMessage`.
|
|
134
|
+
- `CircuitBreaker` — standalone breaker with `.call()`, `.state`, `.failures`, `.reset()`.
|
|
135
|
+
- `CircuitBreakerOpenError` — thrown immediately while the circuit is open.
|
|
136
|
+
- `Socket` — realtime broadcasting client (`channel`, `private`, `presence`/`join`, `leave`, `socketId`, `on`).
|
|
137
|
+
- `Channel` / `PresenceChannel` — channel objects (`listen`, `stopListening`, `subscribed`, `error`; presence adds `here`/`joining`/`leaving`).
|
|
138
|
+
- `ClientProvider` / `Client` — service provider and facade.
|
|
139
|
+
- `ClientConfig` — config factory.
|
|
140
|
+
- Types: `ApiRouteMap`, `RouteShape`, `HttpMethod`, `PathParams`, `ParamRecord`, `PathsFor`, `ResponseOf`, `BodyOf`, `QueryOf`, `ApiClientConfig`, `RequestConfig`, `RequestInterceptor`, `ResponseContext`, `ResponseInterceptor`, `ResponseMeta`, `RequestOptions`, `GetOptions`, `MutationOptions`, `TokenSource`, `RetryOptions`, `CircuitBreakerOptions`, `CircuitState`, `ClientConfigShape`, `SocketOptions`, `SocketState`, `SocketLike`, `PresenceMember`.
|
|
141
|
+
|
|
142
|
+
## Documentation
|
|
143
|
+
|
|
144
|
+
- [API Client](../../docs/client/index.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "stable",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./Socket": "./src/Socket.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/**/*.test.tsx",
|
|
19
|
+
"!src/**/*.spec.ts",
|
|
20
|
+
"!src/**/__fixtures__/**"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.14"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zerotal/core": "1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"typescript": "^5.8.0"
|
|
37
|
+
},
|
|
38
|
+
"description": "Typed HTTP client helpers for Zerotal applications.",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"zerotal",
|
|
41
|
+
"bun",
|
|
42
|
+
"typescript",
|
|
43
|
+
"framework"
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
48
|
+
"directory": "packages/client"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/client#readme",
|
|
51
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
52
|
+
}
|
package/src/ApiClient.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import type { ApiRouteMap, ParamRecord, PathsFor, ResponseOf, BodyOf, QueryOf } from "./types.ts";
|
|
2
|
+
import { ApiClientError, makeApiError } from "./errors.ts";
|
|
3
|
+
import {
|
|
4
|
+
interpolatePath,
|
|
5
|
+
buildUrl,
|
|
6
|
+
serializeBody,
|
|
7
|
+
timeoutSignal,
|
|
8
|
+
readCookie,
|
|
9
|
+
resolveRetry,
|
|
10
|
+
withRetry,
|
|
11
|
+
type RetryOptions,
|
|
12
|
+
} from "./http.ts";
|
|
13
|
+
import { CircuitBreaker } from "./CircuitBreaker.ts";
|
|
14
|
+
import type { CircuitBreakerOptions } from "./CircuitBreaker.ts";
|
|
15
|
+
|
|
16
|
+
// Re-export the error vocabulary so existing `from "./ApiClient.ts"` imports keep working.
|
|
17
|
+
export { ApiClientError, ValidationError } from "./errors.ts";
|
|
18
|
+
export type { RetryOptions } from "./http.ts";
|
|
19
|
+
|
|
20
|
+
// ── Interceptor types ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The mutable request configuration passed through `onRequest` interceptors.
|
|
24
|
+
* Modify and return the object (or a new one) to alter the outgoing request.
|
|
25
|
+
*/
|
|
26
|
+
export interface RequestConfig {
|
|
27
|
+
method: string;
|
|
28
|
+
url: string;
|
|
29
|
+
/** Merged headers — modify here to add/override per-request headers. */
|
|
30
|
+
headers: Record<string, string>;
|
|
31
|
+
/** The request body (serialized): JSON string, `FormData`, `Blob`, … or `undefined`. */
|
|
32
|
+
body: BodyInit | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Called before every outgoing request. Must return the (possibly mutated) `RequestConfig`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* onRequest: async (cfg) => ({
|
|
40
|
+
* ...cfg,
|
|
41
|
+
* headers: { ...cfg.headers, Authorization: `Bearer ${await tokenStore.get()}` },
|
|
42
|
+
* })
|
|
43
|
+
*/
|
|
44
|
+
export type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
|
|
45
|
+
|
|
46
|
+
/** The context passed to `onResponse` interceptors after a successful (2xx) response. */
|
|
47
|
+
export interface ResponseContext {
|
|
48
|
+
/** HTTP status code of the response. */
|
|
49
|
+
status: number;
|
|
50
|
+
/** Response headers. */
|
|
51
|
+
headers: Headers;
|
|
52
|
+
/** Parsed response body (JSON-decoded, text, blob/arrayBuffer per `responseType`, or `undefined`). */
|
|
53
|
+
data: unknown;
|
|
54
|
+
/** The request config after all `onRequest` interceptors have run. */
|
|
55
|
+
request: RequestConfig;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called after every successful (2xx) response, before the parsed body is returned. Must return
|
|
60
|
+
* the (possibly mutated) `ResponseContext`. Useful for unwrapping API envelopes, logging, etc.
|
|
61
|
+
*/
|
|
62
|
+
export type ResponseInterceptor = (
|
|
63
|
+
ctx: ResponseContext,
|
|
64
|
+
) => ResponseContext | Promise<ResponseContext>;
|
|
65
|
+
|
|
66
|
+
/** Lightweight response metadata handed to a per-request `meta` callback. */
|
|
67
|
+
export interface ResponseMeta {
|
|
68
|
+
status: number;
|
|
69
|
+
headers: Headers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Request options ───────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export interface RequestOptions {
|
|
75
|
+
/** Extra headers merged on top of the client's default headers. */
|
|
76
|
+
headers?: Record<string, string>;
|
|
77
|
+
/** Additional `RequestInit` fields (e.g. `credentials`, `mode`, `cache`). */
|
|
78
|
+
init?: Omit<RequestInit, "method" | "headers" | "body">;
|
|
79
|
+
/** Abort signal for cancellation (combined with any `timeout`). */
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
/** Per-request timeout in ms (overrides the client default; `0` disables). */
|
|
82
|
+
timeout?: number;
|
|
83
|
+
/** Per-request retry policy (overrides the client default; `false` disables). */
|
|
84
|
+
retry?: number | RetryOptions | false;
|
|
85
|
+
/** How to read the response body. Default `"auto"` (JSON with text fallback). */
|
|
86
|
+
responseType?: "auto" | "json" | "text" | "blob" | "arrayBuffer";
|
|
87
|
+
/** Read response metadata (status, headers) without a global interceptor. */
|
|
88
|
+
meta?: (meta: ResponseMeta) => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface GetOptions<Q> extends RequestOptions {
|
|
92
|
+
/** Query-string parameters (nested arrays/objects are serialized with bracket notation). */
|
|
93
|
+
query?: Q;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MutationOptions<Q> extends RequestOptions {
|
|
97
|
+
/** URL path parameters for routes like `/api/users/{id}/action`. */
|
|
98
|
+
params?: Record<string, string | number>;
|
|
99
|
+
/** Query-string parameters. */
|
|
100
|
+
query?: Q;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Client config ─────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** A bearer token, or a (possibly async) function resolving one per request. */
|
|
106
|
+
export type TokenSource =
|
|
107
|
+
string | (() => string | null | undefined | Promise<string | null | undefined>);
|
|
108
|
+
|
|
109
|
+
export interface ApiClientConfig {
|
|
110
|
+
/** Base URL prepended to every request path, e.g. `'https://api.example.com'`. */
|
|
111
|
+
baseUrl?: string;
|
|
112
|
+
/** Default headers sent with every request (e.g. `Authorization`). */
|
|
113
|
+
headers?: Record<string, string>;
|
|
114
|
+
/**
|
|
115
|
+
* Bearer token attached as `Authorization: Bearer <token>` when no `Authorization` header is
|
|
116
|
+
* already present. May be a string or a (possibly async) resolver. Update at runtime with
|
|
117
|
+
* {@link ApiClient.setToken}.
|
|
118
|
+
*/
|
|
119
|
+
token?: TokenSource;
|
|
120
|
+
/** Send credentials (cookies) with every request — sets `credentials: 'include'`. */
|
|
121
|
+
withCredentials?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Attach the CSRF/XSRF token to mutating requests: reads the `XSRF-TOKEN` cookie and sends it as
|
|
124
|
+
* `X-XSRF-TOKEN` (the SPA/session flow). Defaults to `true` when `withCredentials` is set.
|
|
125
|
+
* Customize the cookie/header names with an object.
|
|
126
|
+
*/
|
|
127
|
+
csrf?: boolean | { cookie?: string; header?: string };
|
|
128
|
+
/** Default per-request timeout in ms (`0`/omitted = no timeout). */
|
|
129
|
+
timeout?: number;
|
|
130
|
+
/**
|
|
131
|
+
* Default retry policy. `2` retries idempotent requests on network errors / 5xx / 429 with
|
|
132
|
+
* exponential backoff (honoring `Retry-After`); pass {@link RetryOptions} to tune, or omit to
|
|
133
|
+
* disable. Per-request `retry` overrides this.
|
|
134
|
+
*/
|
|
135
|
+
retry?: number | RetryOptions;
|
|
136
|
+
/** Called for every non-2xx response before the error is thrown. */
|
|
137
|
+
onError?: (error: ApiClientError) => void | Promise<void>;
|
|
138
|
+
/** Called when a request receives `403 Forbidden`. */
|
|
139
|
+
onForbidden?: (error: ApiClientError) => void | Promise<void>;
|
|
140
|
+
/** One or more request interceptors executed in order before every fetch. */
|
|
141
|
+
onRequest?: RequestInterceptor | RequestInterceptor[];
|
|
142
|
+
/** One or more response interceptors executed in order after every successful (2xx) response. */
|
|
143
|
+
onResponse?: ResponseInterceptor | ResponseInterceptor[];
|
|
144
|
+
/**
|
|
145
|
+
* Called when a request receives `401 Unauthorized`. Receives the error and a `retry` function
|
|
146
|
+
* (optionally with header overrides) to re-execute the request once.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* onUnauthorized: async (err, retry) => retry({ Authorization: `Bearer ${await refresh()}` }),
|
|
150
|
+
*/
|
|
151
|
+
onUnauthorized?: (
|
|
152
|
+
error: ApiClientError,
|
|
153
|
+
retry: (headerOverrides?: Record<string, string>) => Promise<unknown>,
|
|
154
|
+
) => Promise<unknown> | void;
|
|
155
|
+
/** Attach a circuit breaker (instance to share, or options to create a dedicated one). */
|
|
156
|
+
circuitBreaker?: CircuitBreaker | CircuitBreakerOptions;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── ApiClient<Routes> ───────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Type-safe HTTP client bound to a route map.
|
|
163
|
+
*
|
|
164
|
+
* Do not instantiate directly — use `createApiClient<Routes>(config)`.
|
|
165
|
+
*/
|
|
166
|
+
export class ApiClient<Routes extends ApiRouteMap> {
|
|
167
|
+
private readonly _baseUrl: string;
|
|
168
|
+
private readonly _headers: Record<string, string>;
|
|
169
|
+
private _token: TokenSource | undefined;
|
|
170
|
+
private readonly _withCredentials: boolean;
|
|
171
|
+
private readonly _csrf: { cookie: string; header: string } | undefined;
|
|
172
|
+
private readonly _timeout: number | undefined;
|
|
173
|
+
private readonly _retry: number | RetryOptions | undefined;
|
|
174
|
+
private readonly _onError: ((e: ApiClientError) => void | Promise<void>) | undefined;
|
|
175
|
+
private readonly _onForbidden: ((e: ApiClientError) => void | Promise<void>) | undefined;
|
|
176
|
+
private readonly _interceptors: RequestInterceptor[];
|
|
177
|
+
private readonly _responseInterceptors: ResponseInterceptor[];
|
|
178
|
+
private readonly _onUnauthorized: ApiClientConfig["onUnauthorized"];
|
|
179
|
+
private readonly _breaker: CircuitBreaker | undefined;
|
|
180
|
+
|
|
181
|
+
constructor(config: ApiClientConfig = {}) {
|
|
182
|
+
this._baseUrl = config.baseUrl ?? "";
|
|
183
|
+
this._headers = config.headers ?? {};
|
|
184
|
+
this._token = config.token;
|
|
185
|
+
this._withCredentials = config.withCredentials ?? false;
|
|
186
|
+
this._timeout = config.timeout;
|
|
187
|
+
this._retry = config.retry;
|
|
188
|
+
this._onError = config.onError;
|
|
189
|
+
this._onForbidden = config.onForbidden;
|
|
190
|
+
this._onUnauthorized = config.onUnauthorized;
|
|
191
|
+
this._interceptors = _asArray(config.onRequest);
|
|
192
|
+
this._responseInterceptors = _asArray(config.onResponse);
|
|
193
|
+
|
|
194
|
+
const csrf = config.csrf ?? (this._withCredentials ? true : false);
|
|
195
|
+
this._csrf = csrf
|
|
196
|
+
? {
|
|
197
|
+
cookie: (typeof csrf === "object" && csrf.cookie) || "XSRF-TOKEN",
|
|
198
|
+
header: (typeof csrf === "object" && csrf.header) || "X-XSRF-TOKEN",
|
|
199
|
+
}
|
|
200
|
+
: undefined;
|
|
201
|
+
|
|
202
|
+
this._breaker =
|
|
203
|
+
config.circuitBreaker instanceof CircuitBreaker
|
|
204
|
+
? config.circuitBreaker
|
|
205
|
+
: config.circuitBreaker
|
|
206
|
+
? new CircuitBreaker(config.circuitBreaker as CircuitBreakerOptions)
|
|
207
|
+
: undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Update (or clear) the bearer token used for subsequent requests. */
|
|
211
|
+
setToken(token: TokenSource | null): void {
|
|
212
|
+
this._token = token ?? undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── GET ─────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Send a typed GET request.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* const user = await api.get('/api/users/{id}', { id: 1 }); // ^? UserResource
|
|
222
|
+
* const list = await api.get('/api/users', undefined, { query: { page: 2 } });
|
|
223
|
+
*/
|
|
224
|
+
async get<Path extends PathsFor<Routes, "GET">>(
|
|
225
|
+
path: Path,
|
|
226
|
+
params?: ParamRecord<Path & string>,
|
|
227
|
+
options?: GetOptions<QueryOf<Routes, "GET", Path & string>>,
|
|
228
|
+
): Promise<ResponseOf<Routes, "GET", Path & string>> {
|
|
229
|
+
const url = buildUrl(
|
|
230
|
+
this._baseUrl,
|
|
231
|
+
interpolatePath(path as string, params as Record<string, string | number> | undefined),
|
|
232
|
+
options?.query as Record<string, unknown> | undefined,
|
|
233
|
+
);
|
|
234
|
+
return this._send("GET", url, undefined, options);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── POST ────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Send a typed POST request. The body may be a plain object (JSON), `FormData`, `Blob`,
|
|
241
|
+
* `URLSearchParams`, or a string.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* await api.post('/api/users', { name: 'Alice', email: 'alice@example.com' });
|
|
245
|
+
* await api.post('/api/avatars', formData); // multipart upload
|
|
246
|
+
*/
|
|
247
|
+
async post<Path extends PathsFor<Routes, "POST">>(
|
|
248
|
+
path: Path,
|
|
249
|
+
body?: BodyOf<Routes, "POST", Path & string>,
|
|
250
|
+
options?: MutationOptions<QueryOf<Routes, "POST", Path & string>>,
|
|
251
|
+
): Promise<ResponseOf<Routes, "POST", Path & string>> {
|
|
252
|
+
return this._send("POST", this._mutationUrl(path, options), body, options);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── PUT ─────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
async put<Path extends PathsFor<Routes, "PUT">>(
|
|
258
|
+
path: Path,
|
|
259
|
+
body?: BodyOf<Routes, "PUT", Path & string>,
|
|
260
|
+
options?: MutationOptions<QueryOf<Routes, "PUT", Path & string>>,
|
|
261
|
+
): Promise<ResponseOf<Routes, "PUT", Path & string>> {
|
|
262
|
+
return this._send("PUT", this._mutationUrl(path, options), body, options);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── PATCH ───────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
async patch<Path extends PathsFor<Routes, "PATCH">>(
|
|
268
|
+
path: Path,
|
|
269
|
+
body?: BodyOf<Routes, "PATCH", Path & string>,
|
|
270
|
+
options?: MutationOptions<QueryOf<Routes, "PATCH", Path & string>>,
|
|
271
|
+
): Promise<ResponseOf<Routes, "PATCH", Path & string>> {
|
|
272
|
+
return this._send("PATCH", this._mutationUrl(path, options), body, options);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── DELETE ──────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
async delete<Path extends PathsFor<Routes, "DELETE">>(
|
|
278
|
+
path: Path,
|
|
279
|
+
params?: ParamRecord<Path & string>,
|
|
280
|
+
options?: RequestOptions,
|
|
281
|
+
): Promise<ResponseOf<Routes, "DELETE", Path & string>> {
|
|
282
|
+
const url = buildUrl(
|
|
283
|
+
this._baseUrl,
|
|
284
|
+
interpolatePath(path as string, params as Record<string, string | number> | undefined),
|
|
285
|
+
);
|
|
286
|
+
return this._send("DELETE", url, undefined, options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Internals ─────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
private _mutationUrl(path: string, options?: MutationOptions<unknown>): string {
|
|
292
|
+
return buildUrl(
|
|
293
|
+
this._baseUrl,
|
|
294
|
+
interpolatePath(path, options?.params),
|
|
295
|
+
options?.query as Record<string, unknown> | undefined,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Merge default + token + CSRF + per-request headers. */
|
|
300
|
+
private async _buildHeaders(
|
|
301
|
+
method: string,
|
|
302
|
+
perRequest: Record<string, string> | undefined,
|
|
303
|
+
): Promise<Record<string, string>> {
|
|
304
|
+
const headers: Record<string, string> = { ...this._headers, ...perRequest };
|
|
305
|
+
|
|
306
|
+
if (this._token !== undefined && !_hasHeader(headers, "authorization")) {
|
|
307
|
+
const tok = typeof this._token === "function" ? await this._token() : this._token;
|
|
308
|
+
if (tok) headers["Authorization"] = `Bearer ${tok}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (this._csrf && _isMutating(method) && !_hasHeader(headers, this._csrf.header)) {
|
|
312
|
+
const value = readCookie(this._csrf.cookie);
|
|
313
|
+
if (value) headers[this._csrf.header] = value;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return headers;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async _parseResponse(
|
|
320
|
+
res: Response,
|
|
321
|
+
type: RequestOptions["responseType"],
|
|
322
|
+
): Promise<unknown> {
|
|
323
|
+
if (type === "blob") return res.blob();
|
|
324
|
+
if (type === "arrayBuffer") return res.arrayBuffer();
|
|
325
|
+
if (res.status === 204 || res.headers.get("content-length") === "0") return undefined;
|
|
326
|
+
if (type === "text") return res.text();
|
|
327
|
+
const text = await res.text();
|
|
328
|
+
if (!text) return undefined;
|
|
329
|
+
try {
|
|
330
|
+
return JSON.parse(text);
|
|
331
|
+
} catch {
|
|
332
|
+
return text; // "auto" / "json": fall back to raw text for non-JSON bodies
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async _send<T>(
|
|
337
|
+
method: string,
|
|
338
|
+
url: string,
|
|
339
|
+
body: unknown,
|
|
340
|
+
options?: RequestOptions,
|
|
341
|
+
_isRetry = false,
|
|
342
|
+
): Promise<T> {
|
|
343
|
+
const headers = await this._buildHeaders(method, options?.headers);
|
|
344
|
+
const { body: serialized, contentType } = serializeBody(body);
|
|
345
|
+
if (contentType && !_hasHeader(headers, "content-type")) headers["Content-Type"] = contentType;
|
|
346
|
+
|
|
347
|
+
let config: RequestConfig = { method, url, headers, body: serialized };
|
|
348
|
+
for (const interceptor of this._interceptors) config = await interceptor(config);
|
|
349
|
+
|
|
350
|
+
const timeout = options?.timeout ?? this._timeout;
|
|
351
|
+
const policy = resolveRetry(options?.retry ?? this._retry, method);
|
|
352
|
+
const userSignal = options?.signal ?? options?.init?.signal ?? undefined;
|
|
353
|
+
const credentials =
|
|
354
|
+
options?.init?.credentials ?? (this._withCredentials ? "include" : undefined);
|
|
355
|
+
|
|
356
|
+
const attempt = async (): Promise<Response> => {
|
|
357
|
+
const signal = timeoutSignal(timeout, userSignal);
|
|
358
|
+
const init: RequestInit = { method: config.method, headers: config.headers };
|
|
359
|
+
if (config.body !== undefined) init.body = config.body;
|
|
360
|
+
Object.assign(init, options?.init);
|
|
361
|
+
if (signal) init.signal = signal;
|
|
362
|
+
if (credentials) init.credentials = credentials;
|
|
363
|
+
|
|
364
|
+
const raw = await fetch(config.url, init);
|
|
365
|
+
if (!raw.ok) {
|
|
366
|
+
const text = await raw.text().catch(() => "");
|
|
367
|
+
throw makeApiError(raw.status, raw.statusText, text, raw.headers);
|
|
368
|
+
}
|
|
369
|
+
return raw;
|
|
370
|
+
};
|
|
371
|
+
const runOnce = (): Promise<Response> =>
|
|
372
|
+
this._breaker ? this._breaker.call(attempt) : attempt();
|
|
373
|
+
|
|
374
|
+
let res: Response;
|
|
375
|
+
try {
|
|
376
|
+
res = await withRetry(runOnce, policy);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (err instanceof ApiClientError) {
|
|
379
|
+
// 401 → optional one-shot refresh + retry.
|
|
380
|
+
if (err.status === 401 && !_isRetry && this._onUnauthorized) {
|
|
381
|
+
const retry = (headerOverrides?: Record<string, string>): Promise<unknown> =>
|
|
382
|
+
this._send<unknown>(
|
|
383
|
+
method,
|
|
384
|
+
url,
|
|
385
|
+
body,
|
|
386
|
+
{ ...options, headers: { ...options?.headers, ...headerOverrides } },
|
|
387
|
+
true,
|
|
388
|
+
);
|
|
389
|
+
const result = await this._onUnauthorized(err, retry);
|
|
390
|
+
if (result !== undefined) return result as T;
|
|
391
|
+
}
|
|
392
|
+
if (err.status === 403) await this._onForbidden?.(err);
|
|
393
|
+
await this._onError?.(err);
|
|
394
|
+
}
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const data = await this._parseResponse(res, options?.responseType);
|
|
399
|
+
|
|
400
|
+
let ctx: ResponseContext = { status: res.status, headers: res.headers, data, request: config };
|
|
401
|
+
for (const interceptor of this._responseInterceptors) ctx = await interceptor(ctx);
|
|
402
|
+
|
|
403
|
+
options?.meta?.({ status: res.status, headers: res.headers });
|
|
404
|
+
|
|
405
|
+
return ctx.data as T;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
function _asArray<T>(v: T | T[] | undefined): T[] {
|
|
412
|
+
return v ? (Array.isArray(v) ? v : [v]) : [];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function _hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
416
|
+
const lower = name.toLowerCase();
|
|
417
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === lower);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function _isMutating(method: string): boolean {
|
|
421
|
+
const m = method.toUpperCase();
|
|
422
|
+
return m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE";
|
|
423
|
+
}
|