@zerotal/arch 1.7.3 → 1.7.4

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.
@@ -1,118 +0,0 @@
1
- ---
2
- title: Client File Transfers
3
- description: Uploading and downloading binary payloads.
4
- ---
5
-
6
- # File uploads & downloads
7
-
8
- The client encodes plain objects as JSON, which is the right default until the
9
- payload is binary. Recognising the difference is the whole of file handling here:
10
- on the way out, a body the platform already knows how to encode is passed through
11
- untouched; on the way back, you name the shape you expect.
12
-
13
- ## Uploading
14
-
15
- A body that is `FormData`, `Blob`, `File`, `URLSearchParams`, `ArrayBuffer`, or a
16
- string is sent as-is with no JSON encoding, which lets `fetch` set the correct
17
- `Content-Type` — including the multipart boundary, a value that cannot be written
18
- by hand:
19
-
20
- ```ts
21
- // in any frontend module
22
- const form = new FormData();
23
- form.append("avatar", file);
24
- await api.post("/api/avatars", form);
25
- ```
26
-
27
- | Body | Sent as |
28
- | ----------------- | ------------------------------------------- |
29
- | Plain object | JSON, with `Content-Type: application/json` |
30
- | `FormData` | Multipart, boundary set by the platform |
31
- | `File` / `Blob` | Raw bytes |
32
- | `URLSearchParams` | Form-encoded |
33
- | `ArrayBuffer` | Raw bytes |
34
- | String | Sent verbatim |
35
-
36
- Do not set `Content-Type` yourself for a `FormData` upload. A hand-written header
37
- has no boundary parameter, and the server then fails to parse a body that is
38
- otherwise perfectly formed — a confusing failure worth avoiding by simply leaving
39
- the header alone.
40
-
41
- To send a file alongside ordinary fields, put everything in the `FormData`;
42
- mixing a JSON body and a file in one request is not possible:
43
-
44
- ```ts
45
- const form = new FormData();
46
- form.append("title", "Quarterly report");
47
- form.append("document", file);
48
- await api.post("/api/reports", form);
49
- ```
50
-
51
- ## Downloading
52
-
53
- Responses are parsed as JSON unless you say otherwise. `responseType` names the
54
- shape you want:
55
-
56
- | `responseType` | Returns | Reach for it when |
57
- | --------------- | -------------------- | ------------------------------------ |
58
- | `"auto"` | Parsed JSON, or text | The default — JSON with a safety net |
59
- | `"json"` | Parsed JSON, or text | The body is known to be JSON |
60
- | `"text"` | `string` | CSV, XML, plain text |
61
- | `"blob"` | `Blob` | Saving or displaying a file |
62
- | `"arrayBuffer"` | `ArrayBuffer` | Reading bytes directly |
63
-
64
- ```ts
65
- // in any frontend module
66
- const pdf = await api.get("/api/report", undefined, { responseType: "blob" });
67
- ```
68
-
69
- `"auto"` and `"json"` fall back to the raw text when the body will not parse, so a
70
- misconfigured endpoint returning an HTML error page surfaces that page rather than
71
- a parse exception.
72
-
73
- A `204 No Content` response — or any response with `Content-Length: 0` — resolves
74
- to `undefined` rather than throwing, so a `DELETE` needs no special handling:
75
-
76
- ```ts
77
- await api.delete("/api/avatars/1"); // → undefined
78
- ```
79
-
80
- Handing a downloaded blob to the browser takes one more step, since the client
81
- returns the data rather than saving it:
82
-
83
- ```ts
84
- const blob = await api.get("/api/report", undefined, { responseType: "blob" });
85
-
86
- const url = URL.createObjectURL(blob);
87
- const a = Object.assign(document.createElement("a"), { href: url, download: "report.pdf" });
88
- a.click();
89
- URL.revokeObjectURL(url);
90
- ```
91
-
92
- ## Reading response metadata
93
-
94
- Binary endpoints often carry the interesting information in headers. A per-request
95
- `meta` callback reads them without installing a global interceptor:
96
-
97
- ```ts
98
- // in any frontend module
99
- let total: string | null = null;
100
- const users = await api.get("/api/users", undefined, {
101
- meta: (m) => (total = m.headers.get("X-Total")),
102
- });
103
- ```
104
-
105
- This is the way to reach pagination totals, rate-limit counters, and `ETag` values
106
- while still receiving the parsed body as the return value.
107
-
108
- ## Query serialization
109
-
110
- Query objects serialize arrays and nested objects with bracket notation:
111
- `{ ids: [1, 2], filter: { status: "open" } }` becomes
112
- `?ids[]=1&ids[]=2&filter[status]=open`.
113
-
114
- ## Next steps
115
-
116
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
117
- - [Making requests](/docs/client/requests) — the full request surface.
118
- - [Error handling](/docs/client/errors) — what a failed transfer throws.
@@ -1,58 +0,0 @@
1
- ---
2
- title: Client References
3
- description: Every ApiClient method, config key, and error type in one table.
4
- ---
5
-
6
- # References
7
-
8
- ## `createApiClient<Routes>(config)`
9
-
10
- Returns an `ApiClient<Routes>` bound to your route map. The `config` is an
11
- `ApiClientConfig` (see the [Configuration](/docs/client#configuration) table for the common
12
- fields, plus `onError`, `onResponse`, `onRequest`, `onUnauthorized`, `onForbidden`,
13
- and `circuitBreaker`).
14
-
15
- ## ApiClient methods
16
-
17
- | Method | Signature | Description |
18
- | ---------- | -------------------------------------------------------------------- | ---------------------------------- |
19
- | `get` | `get(path, params?, options?: GetOptions): Promise<Response>` | Typed GET; path params then query. |
20
- | `post` | `post(path, body?, options?: MutationOptions): Promise<Response>` | Typed POST with body. |
21
- | `put` | `put(path, body?, options?: MutationOptions): Promise<Response>` | Typed PUT with body. |
22
- | `patch` | `patch(path, body?, options?: MutationOptions): Promise<Response>` | Typed PATCH with body. |
23
- | `delete` | `delete(path, params?, options?: RequestOptions): Promise<Response>` | Typed DELETE; path params only. |
24
- | `setToken` | `setToken(token: TokenSource \| null): void` | Update or clear the bearer token. |
25
-
26
- ## ApiClientError
27
-
28
- | Member | Signature | Description |
29
- | -------------- | ----------------------- | -------------------------------------- |
30
- | `status` | `number` | HTTP status code. |
31
- | `statusText` | `string` | HTTP status text. |
32
- | `body` | `string` | Raw response text. |
33
- | `headers` | `Headers \| undefined` | Response headers, when available. |
34
- | `retryAfterMs` | `get(): number \| null` | Parsed `Retry-After` in ms, or `null`. |
35
-
36
- ## ValidationError extends ApiClientError
37
-
38
- | Member | Signature | Description |
39
- | ------------------- | ------------------------------------------- | ---------------------------------- |
40
- | `errors` | `Record<string, string[]>` | Field → messages map. |
41
- | `validationMessage` | `string` | Top-level `message` from the body. |
42
- | `has` | `has(field: string): boolean` | Whether a field has any error. |
43
- | `first` | `first(field: string): string \| undefined` | First message for a field. |
44
- | `all` | `all(): Record<string, string[]>` | The full field-error map. |
45
- | `fields` | `fields(): string[]` | Names of every failed field. |
46
-
47
- ## CircuitBreaker
48
-
49
- | Member | Signature | Description |
50
- | ---------- | ------------------------------------------- | --------------------------------------- |
51
- | `call` | `call<T>(fn: () => Promise<T>): Promise<T>` | Run `fn` under the breaker. |
52
- | `state` | `get(): CircuitState` | `'closed'`, `'open'`, or `'half-open'`. |
53
- | `failures` | `get(): number` | Current consecutive failure count. |
54
- | `reset` | `reset(): void` | Manually return to the closed state. |
55
-
56
- ## Next steps
57
-
58
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
@@ -1,131 +0,0 @@
1
- ---
2
- title: Client Requests
3
- description: The typed route map, making requests, and shaping them with interceptors.
4
- ---
5
-
6
- # Route map
7
-
8
- Define your API surface once. Keys are `'METHOD /path'` strings — path params are
9
- `{braces}` style. All fields are optional.
10
-
11
- ```ts
12
- // app/api/api-types.ts
13
- export interface Routes {
14
- "GET /api/users": {
15
- query: { page?: number; perPage?: number; search?: string };
16
- response: { data: UserResource[]; total: number };
17
- };
18
- "GET /api/users/{id}": {
19
- params: { id: number };
20
- response: UserResource;
21
- };
22
- "POST /api/users": {
23
- body: { name: string; email: string; password: string };
24
- response: UserResource;
25
- };
26
- "PUT /api/users/{id}": {
27
- params: { id: number };
28
- body: { name?: string; email?: string };
29
- response: UserResource;
30
- };
31
- "DELETE /api/users/{id}": {
32
- params: { id: number };
33
- response: void;
34
- };
35
- }
36
- ```
37
-
38
- ## Making requests
39
-
40
- ```ts
41
- // in any frontend module
42
- // GET with path params
43
- const user = await api.get("/api/users/{id}", { id: 42 });
44
- // ^? UserResource — inferred from the route map
45
-
46
- // GET with query string
47
- const list = await api.get("/api/users", undefined, {
48
- query: { page: 2, perPage: 25, search: "alice" },
49
- });
50
- // ^? { data: UserResource[]; total: number }
51
-
52
- // POST with body
53
- const created = await api.post("/api/users", {
54
- name: "Alice",
55
- email: "alice@example.com",
56
- password: "hunter2",
57
- });
58
- // ^? UserResource
59
-
60
- // PUT
61
- await api.put("/api/users/{id}", { name: "Alice Smith" }, { params: { id: 42 } });
62
-
63
- // PATCH
64
- await api.patch("/api/users/{id}", { email: "new@example.com" }, { params: { id: 42 } });
65
-
66
- // DELETE
67
- await api.delete("/api/users/{id}", { id: 42 });
68
- ```
69
-
70
- All methods accept an optional `options` argument for per-request headers and
71
- extra `fetch` init fields (`signal`, `credentials`, etc.):
72
-
73
- ```ts
74
- // in any frontend module
75
- const ctrl = new AbortController();
76
-
77
- await api.get("/api/users", undefined, {
78
- headers: { "X-Trace-Id": requestId },
79
- init: { signal: ctrl.signal },
80
- });
81
- ```
82
-
83
- ## Request interceptors
84
-
85
- Run one or more async functions before every outgoing request. Each interceptor
86
- receives the current `RequestConfig` and must return it (or a new one). Useful for
87
- attaching authorization headers from a reactive store without coupling the store
88
- to the client's constructor.
89
-
90
- ```ts
91
- // app/api/client.ts
92
- const api = createApiClient<Routes>({
93
- baseUrl: "https://api.example.com",
94
-
95
- // Single interceptor
96
- onRequest: async (config) => ({
97
- ...config,
98
- headers: {
99
- ...config.headers,
100
- Authorization: `Bearer ${await tokenStore.get()}`,
101
- },
102
- }),
103
- });
104
- ```
105
-
106
- Multiple interceptors execute in declaration order:
107
-
108
- ```ts
109
- // app/api/client.ts
110
- const api = createApiClient<Routes>({
111
- baseUrl: "https://api.example.com",
112
- onRequest: [addAuthHeader, addRequestId, logOutgoing],
113
- });
114
- ```
115
-
116
- A symmetric `onResponse` runs after every successful (2xx) response — receiving a
117
- `ResponseContext` of `{ status, headers, data, request }` — to unwrap envelopes or log.
118
-
119
- ### RequestConfig shape
120
-
121
- | Field | Type | Description |
122
- | --------- | ------------------------ | ------------------------------------------------------------ |
123
- | `method` | `string` | HTTP verb — `'GET'`, `'POST'`, … |
124
- | `url` | `string` | Full resolved URL (base + path + query string) |
125
- | `headers` | `Record<string, string>` | Merged headers — add/override here |
126
- | `body` | `BodyInit \| undefined` | Serialised body (JSON string, `FormData`, …), or `undefined` |
127
-
128
- ## Next steps
129
-
130
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
131
- - [Reference](/docs/client/references) — the full API surface in one table.
@@ -1,141 +0,0 @@
1
- ---
2
- title: Client Resilience
3
- description: Timeouts, retries, and the circuit breaker that spares a failing upstream.
4
- ---
5
-
6
- # Timeouts & retries
7
-
8
- Set a default `timeout` (ms) and/or a `retry` policy on the client, and override either per request:
9
-
10
- ```ts
11
- // app/api/client.ts
12
- const api = createApiClient<Routes>({
13
- timeout: 10_000,
14
- retry: 2, // or { attempts, methods, statuses, backoff, respectRetryAfter }
15
- });
16
-
17
- await api.get("/api/users", undefined, { timeout: 2_000, retry: false });
18
- ```
19
-
20
- Retries apply to **idempotent** methods by default (GET/PUT/DELETE/HEAD/OPTIONS) and trigger on
21
- network errors, request timeouts, and `408 / 425 / 429 / 5xx` responses, using exponential backoff
22
- with jitter and honoring any `Retry-After` header. A timeout aborts via `AbortSignal`; pass your
23
- own `signal` to cancel manually (it's combined with the timeout).
24
-
25
- > **Warning** — A caller-cancelled request (`AbortError`) is never retried, but a request that
26
- > times out (`TimeoutError`) is treated as transient and retried per your policy.
27
-
28
- ## Circuit breaker
29
-
30
- Attach a circuit breaker to stop hammering a struggling upstream service and fail
31
- fast instead. When the circuit is open, requests throw `CircuitBreakerOpenError`
32
- immediately — no network call is made.
33
-
34
- ```ts
35
- // app/api/client.ts
36
- import { createApiClient } from "@zerotal/client";
37
-
38
- // Option 1 — options object (creates a dedicated breaker)
39
- const api = createApiClient<Routes>({
40
- baseUrl: "https://api.example.com",
41
- circuitBreaker: {
42
- threshold: 5, // open after 5 consecutive failures
43
- resetTimeout: 30_000, // attempt recovery after 30 s
44
- },
45
- });
46
-
47
- // Option 2 — shared instance (multiple clients trip the same circuit)
48
- import { CircuitBreaker } from "@zerotal/client";
49
-
50
- const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 10_000 });
51
-
52
- const usersApi = createApiClient<Routes>({ baseUrl: "…", circuitBreaker: breaker });
53
- const postsApi = createApiClient<Routes>({ baseUrl: "…", circuitBreaker: breaker });
54
- ```
55
-
56
- ### Which option should I use?
57
-
58
- - **Options object** — one client talks to one upstream. Each client gets its own dedicated breaker.
59
- - **Shared `CircuitBreaker` instance** — several clients hit the _same_ upstream and should trip together as a unit.
60
-
61
- ```ts
62
- // in any frontend module
63
- import { CircuitBreakerOpenError } from "@zerotal/client";
64
-
65
- try {
66
- await api.get("/api/users");
67
- } catch (err) {
68
- if (err instanceof CircuitBreakerOpenError) {
69
- // Return cached data, show degraded UI, etc.
70
- }
71
- }
72
- ```
73
-
74
- ### Failure classification
75
-
76
- By default:
77
-
78
- - **5xx responses** → counted as failures (upstream is unhealthy)
79
- - **4xx responses** → not counted (client mistakes, not upstream outages)
80
- - **Network / DNS / timeout errors** → counted as failures
81
-
82
- Override with a custom predicate:
83
-
84
- ```ts
85
- // in any frontend module
86
- new CircuitBreaker({
87
- isFailure: (err) => {
88
- if (err instanceof ApiClientError) return err.status >= 500;
89
- return true; // any non-ApiClientError (network failure) counts
90
- },
91
- });
92
- ```
93
-
94
- ## Standalone CircuitBreaker
95
-
96
- `CircuitBreaker` can wrap any async operation — not just HTTP requests.
97
-
98
- ```ts
99
- // in any module
100
- import { CircuitBreaker, CircuitBreakerOpenError } from "@zerotal/client";
101
-
102
- const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 15_000 });
103
-
104
- async function fetchPricing() {
105
- return breaker.call(async () => {
106
- const res = await fetch("https://pricing.internal/v1/rates");
107
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
108
- return res.json();
109
- });
110
- }
111
- ```
112
-
113
- ### State machine
114
-
115
- | State | Behaviour |
116
- | ----------- | ------------------------------------------------------- |
117
- | `closed` | Requests pass through; consecutive failures are counted |
118
- | `open` | All calls immediately throw `CircuitBreakerOpenError` |
119
- | `half-open` | One probe request is allowed through to test recovery |
120
-
121
- Transitions:
122
-
123
- - **closed → open** when failure count reaches `threshold`
124
- - **open → half-open** after `resetTimeout` ms
125
- - **half-open → closed** on a successful probe
126
- - **half-open → open** on a failed probe (timer resets)
127
-
128
- While in `half-open`, concurrent calls that arrive before the probe completes also
129
- throw `CircuitBreakerOpenError` — only one probe goes through at a time.
130
-
131
- ```ts
132
- // in any module
133
- breaker.state; // 'closed' | 'open' | 'half-open'
134
- breaker.failures; // current consecutive failure count
135
- breaker.reset(); // manually reset to closed (e.g. after an admin action)
136
- ```
137
-
138
- ## Next steps
139
-
140
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
141
- - [Reference](/docs/client/references) — the full API surface in one table.
@@ -1,146 +0,0 @@
1
- ---
2
- title: Testing the Client
3
- description: Stub the global fetch, cover the failure paths, and drive the circuit breaker.
4
- ---
5
-
6
- # Testing
7
-
8
- Set your suite up once as described in [Testing](/docs/testing). `ApiClient`
9
- calls the global `fetch`, so a test controls it by replacing that — there is no
10
- separate fake to install.
11
-
12
- ```typescript
13
- // tests/services/BillingClient.test.ts
14
- import { test, expect, afterEach } from "bun:test";
15
- import { ApiClient } from "@zerotal/client";
16
-
17
- const realFetch = globalThis.fetch;
18
- afterEach(() => {
19
- globalThis.fetch = realFetch;
20
- });
21
-
22
- test("maps a customer payload onto our shape", async () => {
23
- globalThis.fetch = async () =>
24
- new Response(JSON.stringify({ id: "cus_1", email: "jane@example.com" }), {
25
- status: 200,
26
- headers: { "Content-Type": "application/json" },
27
- });
28
-
29
- const client = new ApiClient({ baseUrl: "https://api.example.com" });
30
- const customer = await client.get("/customers/cus_1");
31
-
32
- expect(customer.email).toBe("jane@example.com");
33
- });
34
- ```
35
-
36
- **Restore `fetch` in `afterEach`, not at the end of the test.** A test that
37
- throws before its cleanup line leaves the stub installed, and every later test in
38
- the file talks to it instead of the network — producing failures that point at
39
- innocent code.
40
-
41
- ## Asserting on the request
42
-
43
- Stubbing the response proves your code reads the answer correctly. It says nothing
44
- about whether you asked the right question — a client that sends the wrong URL,
45
- loses a query parameter, or drops the auth header passes every response-shaped test
46
- and still fails in production. Capture what `fetch` received and assert on it:
47
-
48
- ```typescript
49
- // tests/services/BillingClient.test.ts
50
- test("sends the query and the auth header", async () => {
51
- let url: string | undefined;
52
- let init: RequestInit | undefined;
53
-
54
- globalThis.fetch = async (u, i) => {
55
- url = String(u);
56
- init = i;
57
- return new Response("[]", { status: 200 });
58
- };
59
-
60
- const client = new ApiClient({
61
- baseUrl: "https://api.example.com",
62
- token: "tok_123",
63
- });
64
- await client.get("/customers", { status: "active" });
65
-
66
- expect(url).toBe("https://api.example.com/customers?status=active");
67
- expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer tok_123");
68
- });
69
- ```
70
-
71
- Wrap the headers in `new Headers(...)` before reading them. Casing is normalised
72
- that way, so the assertion holds whether the value arrived as a plain object or as
73
- a `Headers` instance from a per-request override.
74
-
75
- Uploads deserve one such test each. Asserting that `init.body` is the `FormData`
76
- you passed — rather than a JSON string — is what pins down that the body was not
77
- re-encoded on its way out.
78
-
79
- **Test the failure paths, because they are the ones production hits.** A 500, a
80
- timeout, and a malformed body all reach your code differently:
81
-
82
- ```typescript
83
- // tests/services/BillingClient.test.ts
84
- test("a 500 surfaces as ApiClientError", async () => {
85
- globalThis.fetch = async () => new Response("upstream boom", { status: 500 });
86
-
87
- await expect(client.get("/customers/cus_1")).rejects.toBeInstanceOf(ApiClientError);
88
- });
89
- ```
90
-
91
- A network failure is a different case again, and the stub models it by rejecting
92
- instead of resolving — which is how you reach the branch that never receives a
93
- status at all:
94
-
95
- ```typescript
96
- globalThis.fetch = async () => {
97
- throw new TypeError("Failed to fetch");
98
- };
99
-
100
- await expect(client.get("/customers")).rejects.not.toBeInstanceOf(ApiClientError);
101
- ```
102
-
103
- ## Retries and the circuit breaker
104
-
105
- A retry policy is invisible from the outside — the caller sees one resolved promise
106
- whether it took one attempt or four. Counting calls is what makes the behaviour
107
- observable:
108
-
109
- ```typescript
110
- // tests/services/BillingClient.test.ts
111
- let calls = 0;
112
- globalThis.fetch = async () => {
113
- calls++;
114
- return calls < 3 ? new Response("", { status: 503 }) : new Response("{}", { status: 200 });
115
- };
116
-
117
- await client.get("/customers");
118
- expect(calls).toBe(3); // two failures, then success
119
- ```
120
-
121
- **A circuit breaker is a state machine**, so drive it with repeated failures and
122
- assert it opens — then that it refuses without calling `fetch` at all:
123
-
124
- ```typescript
125
- // tests/services/BillingClient.test.ts
126
- let calls = 0;
127
- globalThis.fetch = async () => {
128
- calls++;
129
- return new Response("", { status: 500 });
130
- };
131
-
132
- for (let i = 0; i < threshold; i++) await client.get("/x").catch(() => {});
133
- const before = calls;
134
-
135
- await expect(client.get("/x")).rejects.toBeInstanceOf(CircuitBreakerOpenError);
136
- expect(calls).toBe(before); // the open circuit short-circuits, no request made
137
- ```
138
-
139
- That last assertion is the point of a breaker — without it you have tested that
140
- an error is thrown, not that the upstream was spared.
141
-
142
- ## Next steps
143
-
144
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
145
- - [Resilience](/docs/client/resilience) — the retry and breaker policies under test.
146
- - [Error handling](/docs/client/errors) — the errors these tests assert on.