@zerotal/arch 1.7.2 → 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.
- package/api-surface.md +4 -4
- package/docs/about.md +4 -4
- package/docs/changelog.md +122 -6
- package/docs/client/index.md +277 -70
- package/docs/contributing.md +20 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +4 -8
- package/docs/flow/decorators.md +17 -13
- package/docs/flow/events.md +3 -3
- package/docs/flow/icons.md +199 -0
- package/docs/flow/index.md +11 -7
- package/docs/flow/layouts.md +1 -1
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +6 -6
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +14 -2
- package/docs/getting-started.md +9 -9
- package/docs/i18n.md +3 -3
- package/docs/inertia/index.md +6 -1
- package/docs/inertia/props.md +1 -1
- package/docs/inertia/rendering.md +79 -0
- package/docs/routing.md +11 -0
- package/docs/support-policy.md +28 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
- package/docs/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/api-surface.md
CHANGED
|
@@ -185,11 +185,11 @@ interface ToolContext = {
|
|
|
185
185
|
root: string
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
type BlockOutcome = {
|
|
188
|
+
type BlockOutcome = { status: 'created'; text: string } | { status: 'updated'; text: string } | { status: 'unchanged'; text: string } | { status: 'conflict'; reason: string }
|
|
189
189
|
|
|
190
|
-
type ConfigOutcome = {
|
|
190
|
+
type ConfigOutcome = { status: 'created' | 'updated' | 'unchanged'; text: string } | { status: 'conflict'; reason: string }
|
|
191
191
|
|
|
192
|
-
type ProbeResult = {
|
|
192
|
+
type ProbeResult = { ok: true; data: unknown } | { ok: false; message: string }
|
|
193
193
|
|
|
194
194
|
type ProbeTopic = 'doctor' | 'routes' | 'schema' | 'app-info'
|
|
195
195
|
|
|
@@ -272,7 +272,7 @@ interface ToolOutcome = {
|
|
|
272
272
|
text: string
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
type DecodedFrame = {
|
|
275
|
+
type DecodedFrame = { ok: true; request: JsonRpcRequest } | { ok: false; failure: JsonRpcFailure }
|
|
276
276
|
|
|
277
277
|
type Era = 'modern' | 'legacy'
|
|
278
278
|
|
package/docs/about.md
CHANGED
|
@@ -43,7 +43,7 @@ a theme.
|
|
|
43
43
|
|
|
44
44
|
### 1. Bun-native, source-only
|
|
45
45
|
|
|
46
|
-
Zerotal runs **only on Bun** (≥ 1.
|
|
46
|
+
Zerotal runs **only on Bun** (≥ 1.3.14) and leans on Bun's APIs throughout — `Bun.sql`
|
|
47
47
|
for the database, `Bun.CryptoHasher` for hashing, `Bun.build` for bundling. Because
|
|
48
48
|
Bun runs and type-strips TypeScript natively, packages ship as **`.ts` source with no
|
|
49
49
|
compiled `dist/`**. You always read real source and get accurate types; there's
|
|
@@ -660,9 +660,9 @@ The [README](../README.md) has a package-by-package table with links.
|
|
|
660
660
|
scaffolding, migrations, the dev server, the worker, and tests all run through it.
|
|
661
661
|
- **No build, ever.** `bun run dev` / `bun test` / `bun run typecheck`. No compile
|
|
662
662
|
step to remember.
|
|
663
|
-
- **
|
|
664
|
-
|
|
665
|
-
|
|
663
|
+
- **Starters are the fastest way in.** `bun create zerotal my-app` scaffolds a working
|
|
664
|
+
app from one of six starters — `api`, `admin`, `flow`, `react`, `vue`, or `minimal`.
|
|
665
|
+
Read the generated code alongside the docs.
|
|
666
666
|
- **Conventions are documented, not magic.** When something "just works" (a model you
|
|
667
667
|
never registered, a policy suddenly enforced), [Conventions](/docs/conventions)
|
|
668
668
|
explains exactly what the framework discovered and why.
|
package/docs/changelog.md
CHANGED
|
@@ -17,12 +17,128 @@ summary across the suite.
|
|
|
17
17
|
Each version lists changes under three headings:
|
|
18
18
|
|
|
19
19
|
- **Added** — new features and APIs (safe to adopt incrementally).
|
|
20
|
-
- **Changed** — behavior changes; **breaking** ones are called out explicitly
|
|
21
|
-
|
|
20
|
+
- **Changed** — behavior changes; **breaking** ones are called out explicitly, in
|
|
21
|
+
bold, as **BREAKING**.
|
|
22
22
|
- **Fixed** — bug fixes.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
Breaking changes belong in major releases, and while the 1.x line is young they may
|
|
25
|
+
also land in a minor or a patch — always labelled, always with migration steps. Read
|
|
26
|
+
the section for every version you cross and apply its migration notes, not only the
|
|
27
|
+
majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
|
|
28
|
+
when that carve-out ends.
|
|
29
|
+
|
|
30
|
+
## 1.7.4 — 2026-08-21
|
|
31
|
+
|
|
32
|
+
A debug panel that was reaching production, a column type MySQL would not index, and
|
|
33
|
+
2,060 icons.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **DevTools no longer appears on a production page.** The provider is gated on the
|
|
38
|
+
environment, so in production its routes are absent — and the browser client took that as
|
|
39
|
+
permission to start anyway and "connect to nothing". It did not: it mounted the panel first
|
|
40
|
+
and discovered the absence afterwards, so an app calling `DevTools.start()` unconditionally
|
|
41
|
+
served a floating DevTools bar to every visitor, its tabs reading
|
|
42
|
+
`Could not read the map — HTTP 404`.
|
|
43
|
+
|
|
44
|
+
`start()` now probes for the routes and builds nothing unless they answer — no shell, no
|
|
45
|
+
shadow root, no `EventSource`, no listeners. Any failure (404, offline, CSP) is read as
|
|
46
|
+
absent. **If your app calls `DevTools.start()`, take this release.**
|
|
47
|
+
|
|
48
|
+
- **A string column could not carry an index on MySQL.** `table.string()` compiled to `TEXT`
|
|
49
|
+
on every engine and discarded its `length`, and MySQL refuses to key a TEXT column without
|
|
50
|
+
a prefix length — so `table.string("email").unique()` failed at `CREATE TABLE`. MySQL now
|
|
51
|
+
gets `VARCHAR(length)`; SQLite and PostgreSQL keep `TEXT`. `char()` had the same bug and the
|
|
52
|
+
same fix. Found by the new MySQL suite on its first run against a real server.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **`<Icon name="inbox" />` — 2,060 icons, bundled, typed by name.** The set ships inside
|
|
57
|
+
`@zerotal/flow-ui`, so there is nothing to install and no generator to run: a fresh app gets
|
|
58
|
+
autocomplete over every name and a compile error on a typo. Rendered on the server as inline
|
|
59
|
+
SVG, so there is no icon font, no sprite, no request per glyph, and nothing for a strict CSP
|
|
60
|
+
to block. Four icons are drawn for sign-in flows the set has no name for — `passkey`,
|
|
61
|
+
`two-factor`, `otp`, `magic-link` — and three brand marks ship for the social-login providers
|
|
62
|
+
`@zerotal/auth` supports. See [Icons](/docs/flow/icons).
|
|
63
|
+
|
|
64
|
+
- **The ORM suite runs against MySQL 8 in CI, and the job blocks merges.** The same smoke
|
|
65
|
+
suite that covers PostgreSQL — schema DDL and `ALTER`, identity columns, CRUD, type
|
|
66
|
+
round-trips, unique and NOT NULL enforcement, row locks, transaction rollback. MySQL moves
|
|
67
|
+
from _experimental_ to _supported, hardening_; see the
|
|
68
|
+
[Support Policy](/docs/support-policy).
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- **The starters link by route name.** Every hard-coded `href="/about"` in the React and Vue
|
|
73
|
+
templates now goes through `route()`, and the templates ship the generated route table so a
|
|
74
|
+
freshly scaffolded app type-checks before its first `zt dev`.
|
|
75
|
+
|
|
76
|
+
### Documented
|
|
77
|
+
|
|
78
|
+
- **The HTTP client guide is one page.** Eight pages became one, written from where the
|
|
79
|
+
package is used — your app calling somebody else's service — with straight URLs instead of
|
|
80
|
+
a route map threaded through every example. See [HTTP Client](/docs/client).
|
|
81
|
+
|
|
82
|
+
- **`route()` in Inertia**, for links and for form submissions, including the one thing Inertia
|
|
83
|
+
adds: a page renders in two processes, so `defineRoutes()` has to run in the SSR entry too.
|
|
84
|
+
See [Building URLs](/docs/inertia/rendering#building-urls-with-route).
|
|
85
|
+
|
|
86
|
+
- **Every package changelog has the release headings it was missing.** `[Unreleased]` had
|
|
87
|
+
accumulated four releases of shipped work — `@zerotal/flow-ui`'s newest heading read
|
|
88
|
+
`[1.5.0]` while 1.7.3 was on npm. Cutting a release now moves them.
|
|
89
|
+
|
|
90
|
+
## 1.7.3 — 2026-08-20
|
|
91
|
+
|
|
92
|
+
Two fields that accepted input and threw it away, and a CI job that was testing nothing.
|
|
93
|
+
|
|
94
|
+
### Fixed
|
|
95
|
+
|
|
96
|
+
- **A boolean column could not hold a boolean on PostgreSQL.** `table.boolean()` compiled to
|
|
97
|
+
`INTEGER` on every engine — right for SQLite, which has no boolean type, and rejected by
|
|
98
|
+
PostgreSQL, which has a real one:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
column "active" is of type integer but expression is of type boolean (42804)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Every insert of `true` failed, and so did every `where("active", true)`. The storage type
|
|
105
|
+
now comes from the dialect, as the auto-increment column already did. SQLite and MySQL are
|
|
106
|
+
unchanged — MySQL's `BOOLEAN` is a synonym for `TINYINT(1)` and `INTEGER` takes 0/1 either
|
|
107
|
+
way, so there was nothing broken there to fix.
|
|
108
|
+
|
|
109
|
+
**Existing PostgreSQL tables keep their integer columns.** New tables get `BOOLEAN`; a table
|
|
110
|
+
already created needs an `ALTER` if you want the column converted:
|
|
111
|
+
|
|
112
|
+
```sql
|
|
113
|
+
ALTER TABLE posts ALTER COLUMN active TYPE boolean USING active <> 0;
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **A bound password field discarded every keystroke.** Flow's client-writable set was
|
|
117
|
+
`fillable` minus `hidden`, which conflates two allow-lists answering different questions:
|
|
118
|
+
`fillable` governs what may be _written_, `hidden` governs what may be _shown_. A password
|
|
119
|
+
is in both, so subtracting made it unwritable — `<input type="password"
|
|
120
|
+
value={this.user.password} blur />` accepted typing and dropped it on arrival.
|
|
121
|
+
|
|
122
|
+
`hidden` is no longer subtracted. It is still never sent: the stored hash does not leave the
|
|
123
|
+
server and the field arrives empty. A hidden value **the client supplied** survives until
|
|
124
|
+
save; one **the server produced** is never echoed back, and a half-typed one is stripped
|
|
125
|
+
from the durable snapshot before it is persisted.
|
|
126
|
+
|
|
127
|
+
### Changed
|
|
128
|
+
|
|
129
|
+
- **The PostgreSQL CI job blocks merges.** It had been running the ORM suite beside a Postgres
|
|
130
|
+
container without connecting to it, so it reported success without testing anything. A smoke
|
|
131
|
+
suite now exercises schema DDL, identity columns, CRUD, type round-trips, row locks and
|
|
132
|
+
transaction rollback against a real PostgreSQL 16, and a failure fails the build. The
|
|
133
|
+
boolean defect above is what it found on its first real run.
|
|
134
|
+
|
|
135
|
+
### Documented
|
|
136
|
+
|
|
137
|
+
- **Flow pages take their model from the route, not from a query.** The docs opened every
|
|
138
|
+
model example by fetching the record in `onMount()`, which predates a route being able to
|
|
139
|
+
hand a component the record. `models.md` leads with the bound form; `lifecycle.md` no longer
|
|
140
|
+
presents the old id-plus-`onHydrate`-re-query as the correct pattern. The old shape still
|
|
141
|
+
works — it is simply two fields and a query doing what one field now does.
|
|
26
142
|
|
|
27
143
|
## 1.7.2 — 2026-08-18
|
|
28
144
|
|
|
@@ -50,7 +166,7 @@ saying so.
|
|
|
50
166
|
- **Flow bundles the socket client into its runtime.** A page that declares a `socket:`
|
|
51
167
|
listener is live with no script of your own. Flow apps own no bundle entry, so the contract
|
|
52
168
|
used to be "publish `window.Socket` yourself" — and when you didn't, the listeners were
|
|
53
|
-
|
|
169
|
+
_silently inert_: no error, no warning, no subscription, so a live feature with no script
|
|
54
170
|
looked exactly like a live feature that was never written. An app that needs a configured
|
|
55
171
|
client still assigns `window.Socket` before the runtime loads and that one is used as-is; a
|
|
56
172
|
page with no listeners opens no connection at all.
|
|
@@ -60,7 +176,7 @@ saying so.
|
|
|
60
176
|
- **A patch no longer writes back into a file input.** A file input's `value` belongs to the
|
|
61
177
|
user agent, and assigning anything but `""` throws `InvalidStateError`. The write was legal
|
|
62
178
|
while the bound property was empty and threw on the very patch carrying an upload's result
|
|
63
|
-
— and the throw escaped the frame handler, so the DOM never updated
|
|
179
|
+
— and the throw escaped the frame handler, so the DOM never updated _and_ the action's ack
|
|
64
180
|
never resolved. Since frames are chained per component, every later action queued behind a
|
|
65
181
|
promise that would never settle: the page rendered correctly and ignored every click for
|
|
66
182
|
the rest of its life.
|
package/docs/client/index.md
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
---
|
|
2
|
-
title:
|
|
3
|
-
description: Call
|
|
2
|
+
title: HTTP Client
|
|
3
|
+
description: Call another service from your app — requests, auth, timeouts, retries, errors, and a circuit breaker.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# HTTP Client
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Your application calling somebody else's: charging a card, sending a message,
|
|
9
|
+
looking up an address. `@zerotal/client` is the outbound HTTP client — a thin,
|
|
10
|
+
expressive wrapper over `fetch` that adds what every real integration ends up
|
|
11
|
+
needing anyway and that is tedious to get right by hand.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
```ts
|
|
14
|
+
import { Client } from "@zerotal/client";
|
|
15
|
+
|
|
16
|
+
const charge = await Client.post("https://api.stripe.com/v1/charges", {
|
|
17
|
+
amount: 2000,
|
|
18
|
+
currency: "usd",
|
|
19
|
+
source: token,
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Timeouts, retries with backoff, bearer tokens, a circuit breaker for the upstream
|
|
24
|
+
that has started failing, and errors you can branch on.
|
|
25
|
+
|
|
26
|
+
> **For requests coming _into_ your app**, see [Routing](/docs/routing). This page is
|
|
27
|
+
> only about requests going out.
|
|
16
28
|
|
|
17
|
-
##
|
|
29
|
+
## Install and register
|
|
18
30
|
|
|
19
31
|
```bash
|
|
20
|
-
# in your project root
|
|
21
32
|
bun add @zerotal/client
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
config-driven client from the container (typical for server-side and console code).
|
|
27
|
-
|
|
28
|
-
## Register the provider
|
|
29
|
-
|
|
30
|
-
Registering the provider is optional — it gives you one container-bound `client`
|
|
31
|
-
built from `config/client.ts`. Add `ClientProvider` to the providers array in
|
|
32
|
-
`bootstrap/providers.ts`:
|
|
35
|
+
Add `ClientProvider` to `bootstrap/providers.ts` to get one shared, configured
|
|
36
|
+
client from the container:
|
|
33
37
|
|
|
34
38
|
```ts
|
|
35
39
|
// bootstrap/providers.ts
|
|
@@ -43,26 +47,68 @@ const providers = [
|
|
|
43
47
|
export default providers;
|
|
44
48
|
```
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
It binds a client under the `client` key in the `web`, `console`, `worker`, `test`
|
|
51
|
+
and `repl` environments — so a queued job calling a gateway uses the same client a
|
|
52
|
+
controller does — and exposes it as the `Client` facade.
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
- `onBooted` — pre-resolves that async singleton so the `Client` facade can be accessed synchronously after boot.
|
|
54
|
+
## Making requests
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
Five methods, each taking a URL. Pass an absolute URL, or set a `baseUrl` in config
|
|
57
|
+
and pass a path:
|
|
53
58
|
|
|
54
59
|
```ts
|
|
55
|
-
// in a controller or service
|
|
56
60
|
import { Client } from "@zerotal/client";
|
|
57
61
|
|
|
58
|
-
|
|
62
|
+
await Client.get("https://api.example.com/v1/invoices");
|
|
63
|
+
await Client.post("https://api.example.com/v1/invoices", { customer: "cus_123" });
|
|
64
|
+
await Client.put("https://api.example.com/v1/invoices/in_9", { memo: "Paid" });
|
|
65
|
+
await Client.patch("https://api.example.com/v1/invoices/in_9", { memo: "Paid" });
|
|
66
|
+
await Client.delete("https://api.example.com/v1/invoices/in_9");
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The response body is decoded for you — JSON when the response is JSON, text
|
|
70
|
+
otherwise. A 2xx returns; anything else throws, which [Errors](#errors) covers.
|
|
71
|
+
|
|
72
|
+
### Query parameters
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
await Client.get("https://api.example.com/v1/charges", undefined, {
|
|
76
|
+
query: { limit: 25, status: "succeeded", created: { gte: 1_700_000_000 } },
|
|
77
|
+
});
|
|
59
78
|
```
|
|
60
79
|
|
|
61
|
-
|
|
80
|
+
Nested objects and arrays are serialized with bracket notation, so
|
|
81
|
+
`{ created: { gte: 1 } }` becomes `created[gte]=1`.
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
### Request bodies
|
|
84
|
+
|
|
85
|
+
A plain object or array is JSON-encoded with the matching `Content-Type`. Anything
|
|
86
|
+
`fetch` understands natively — `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`,
|
|
87
|
+
a raw string — passes straight through, so the runtime sets the header itself
|
|
88
|
+
(including multipart boundaries):
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// Form-encoded, which several gateways still require
|
|
92
|
+
await Client.post(
|
|
93
|
+
"https://api.example.com/v1/charges",
|
|
94
|
+
new URLSearchParams({ amount: "2000", currency: "usd" }),
|
|
95
|
+
);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Headers
|
|
99
|
+
|
|
100
|
+
Per request, merged over the client's defaults:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
await Client.post("https://api.example.com/v1/messages", payload, {
|
|
104
|
+
headers: { "Idempotency-Key": crypto.randomUUID() },
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Authentication
|
|
109
|
+
|
|
110
|
+
Most APIs want a bearer token. Set it once and every request carries
|
|
111
|
+
`Authorization: Bearer …`:
|
|
66
112
|
|
|
67
113
|
```ts
|
|
68
114
|
// config/client.ts
|
|
@@ -70,59 +116,220 @@ import { ClientConfig } from "@zerotal/client";
|
|
|
70
116
|
import { env } from "zerotal";
|
|
71
117
|
|
|
72
118
|
export default ClientConfig({
|
|
73
|
-
baseUrl:
|
|
119
|
+
baseUrl: "https://api.example.com",
|
|
120
|
+
token: env("PAYMENTS_API_KEY", ""),
|
|
74
121
|
headers: { Accept: "application/json" },
|
|
75
122
|
});
|
|
76
123
|
```
|
|
77
124
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
| `baseUrl` | no | `""` | Base URL prepended to every request path. |
|
|
81
|
-
| `headers` | no | `{}` | Default headers sent with every request. |
|
|
82
|
-
| `token` | no | — | Bearer token (string or resolver) attached as `Authorization`. |
|
|
83
|
-
| `withCredentials` | no | `false` | Send cookies (`credentials: 'include'`); also turns CSRF on. |
|
|
84
|
-
| `csrf` | no | — | CSRF cookie/header names, or a boolean to force on/off. |
|
|
85
|
-
| `timeout` | no | — | Default per-request timeout in ms (`0`/omitted = none). |
|
|
86
|
-
| `retry` | no | — | Default retry policy (number of attempts or `RetryOptions`). |
|
|
125
|
+
A `token` may also be a function, including an async one, which is how credentials
|
|
126
|
+
that expire are handled — it is resolved per request:
|
|
87
127
|
|
|
88
|
-
|
|
89
|
-
|
|
128
|
+
```ts
|
|
129
|
+
export default ClientConfig({
|
|
130
|
+
token: async () => await currentAccessToken(),
|
|
131
|
+
});
|
|
132
|
+
```
|
|
90
133
|
|
|
91
|
-
|
|
134
|
+
Change it at runtime with `setToken(token)`, or pass `null` to clear it. Its type is
|
|
135
|
+
`TokenSource`.
|
|
92
136
|
|
|
93
|
-
|
|
94
|
-
`createApiClient<Routes>()`:
|
|
137
|
+
For an API that wants something other than a bearer token, set the header directly:
|
|
95
138
|
|
|
96
139
|
```ts
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
140
|
+
export default ClientConfig({
|
|
141
|
+
headers: { "X-Api-Key": env("PARTNER_API_KEY", "") },
|
|
142
|
+
});
|
|
143
|
+
```
|
|
101
144
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
145
|
+
### Refreshing on a 401
|
|
146
|
+
|
|
147
|
+
`onUnauthorized` receives the error and a `retry` function, so a token can be
|
|
148
|
+
refreshed and the original request replayed once:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
export default ClientConfig({
|
|
152
|
+
onUnauthorized: async (error, retry) => retry({ Authorization: `Bearer ${await refresh()}` }),
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Timeouts
|
|
157
|
+
|
|
158
|
+
There is no timeout by default, because the right one depends on the upstream. Set a
|
|
159
|
+
default and override per request:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
export default ClientConfig({ timeout: 10_000 });
|
|
163
|
+
|
|
164
|
+
// This one is slow and we accept that
|
|
165
|
+
await Client.post("https://api.example.com/v1/reports", body, { timeout: 60_000 });
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`timeout: 0` disables it. Pass your own `signal` to cancel for any other reason; it
|
|
169
|
+
is combined with the timeout rather than replacing it.
|
|
170
|
+
|
|
171
|
+
## Retries
|
|
172
|
+
|
|
173
|
+
A network blip or a `503` is worth trying again; a `422` never is. `retry` retries
|
|
174
|
+
idempotent requests on network errors, 5xx and 429 with exponential backoff, and
|
|
175
|
+
honours a `Retry-After` header when the server sends one:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
export default ClientConfig({ retry: 2 });
|
|
179
|
+
|
|
180
|
+
await Client.post("https://api.example.com/v1/charges", body, { retry: false });
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Turning retries **off** for a charge is the deliberate part: retrying a payment you
|
|
184
|
+
cannot prove failed is how a customer gets billed twice. Send an idempotency key if
|
|
185
|
+
you want both. Pass `RetryOptions` instead of a number to tune the delays and which
|
|
186
|
+
statuses qualify.
|
|
187
|
+
|
|
188
|
+
## Errors
|
|
189
|
+
|
|
190
|
+
A non-2xx throws `ApiClientError`, carrying what you need to decide what happened:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { ApiClientError } from "@zerotal/client";
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await Client.post("https://api.example.com/v1/charges", body);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof ApiClientError) {
|
|
199
|
+
error.status; // 402
|
|
200
|
+
error.body; // raw response text
|
|
201
|
+
error.headers; // Headers, when available
|
|
202
|
+
error.retryAfterMs; // parsed Retry-After, or null
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
A `422` carrying a field-error body throws `ValidationError`, a subclass that reads
|
|
209
|
+
those fields for you — `has(field)`, `first(field)`, `all()`, `fields()`, and
|
|
210
|
+
`validationMessage` for the top-level message.
|
|
211
|
+
|
|
212
|
+
`onError` fires for every non-2xx before the throw and `onForbidden` for a 403 —
|
|
213
|
+
both good places to log or alert without wrapping each call site.
|
|
214
|
+
|
|
215
|
+
## Circuit breaker
|
|
216
|
+
|
|
217
|
+
When an upstream is down, continuing to call it wastes your own capacity and slows
|
|
218
|
+
everything queued behind it. A `CircuitBreaker` stops after a threshold of
|
|
219
|
+
consecutive failures, fails fast for a cooldown, then lets a single request through
|
|
220
|
+
to test the water:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
export default ClientConfig({
|
|
224
|
+
circuitBreaker: { threshold: 5, cooldownMs: 30_000 },
|
|
105
225
|
});
|
|
106
226
|
```
|
|
107
227
|
|
|
108
|
-
|
|
109
|
-
|
|
228
|
+
While it is open, calls throw `CircuitBreakerOpenError` **without** a request being
|
|
229
|
+
made — catch that specifically to fall back to a cached answer or a queued retry.
|
|
230
|
+
`state` reports the `CircuitState` (`closed`, `open`, `half-open`), `failures` the
|
|
231
|
+
current count, and `reset()` closes it by hand.
|
|
232
|
+
|
|
233
|
+
Pass a shared `CircuitBreaker` instance instead of options when several clients talk
|
|
234
|
+
to the same upstream and should trip together.
|
|
235
|
+
|
|
236
|
+
## Files
|
|
237
|
+
|
|
238
|
+
Upload with `FormData`; download by asking for the body you want:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const form = new FormData();
|
|
242
|
+
form.append("file", Bun.file("./invoice.pdf"));
|
|
243
|
+
await Client.post("https://api.example.com/v1/documents", form);
|
|
244
|
+
|
|
245
|
+
const pdf = await Client.get("https://api.example.com/v1/documents/doc_1", undefined, {
|
|
246
|
+
responseType: "blob",
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`responseType` takes `auto` (the default), `json`, `text`, `blob` or `arrayBuffer`.
|
|
251
|
+
|
|
252
|
+
## Interceptors
|
|
253
|
+
|
|
254
|
+
`onRequest` runs before every request and can rewrite the outgoing `RequestConfig`;
|
|
255
|
+
`onResponse` runs after every 2xx with the `ResponseContext`. Both take one function
|
|
256
|
+
or an array:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
export default ClientConfig({
|
|
260
|
+
onRequest: (config) => {
|
|
261
|
+
config.headers["X-Request-Id"] = crypto.randomUUID();
|
|
262
|
+
return config;
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
To read the status or headers of a single response without a global interceptor,
|
|
268
|
+
pass `meta` in the request options.
|
|
269
|
+
|
|
270
|
+
## Testing
|
|
271
|
+
|
|
272
|
+
The client calls `fetch`, so a test replaces `fetch`:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { test, expect } from "bun:test";
|
|
276
|
+
|
|
277
|
+
test("charges the card", async () => {
|
|
278
|
+
globalThis.fetch = async () => Response.json({ id: "ch_1", paid: true });
|
|
279
|
+
|
|
280
|
+
const charge = await Client.post("https://api.example.com/v1/charges", { amount: 2000 });
|
|
281
|
+
expect(charge.paid).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Return a non-2xx `Response` to cover the failure paths, and throw from the stub to
|
|
286
|
+
exercise retries and the breaker. For requests your app _receives_ in tests, see
|
|
287
|
+
[Testing](/docs/testing).
|
|
288
|
+
|
|
289
|
+
## Configuration reference
|
|
290
|
+
|
|
291
|
+
Every field of `ClientConfigShape` (which extends `ApiClientConfig`) is optional:
|
|
292
|
+
|
|
293
|
+
| Field | Description |
|
|
294
|
+
| ----------------- | --------------------------------------------------------------------------- |
|
|
295
|
+
| `baseUrl` | Prepended to every request path. Omit it and pass absolute URLs. |
|
|
296
|
+
| `headers` | Default headers for every request. |
|
|
297
|
+
| `token` | Bearer token — a string, or a (possibly async) resolver called per request. |
|
|
298
|
+
| `timeout` | Default per-request timeout in ms. `0` disables. |
|
|
299
|
+
| `retry` | Default retry policy — a count, or `RetryOptions`. |
|
|
300
|
+
| `circuitBreaker` | A `CircuitBreaker` instance to share, or options for a dedicated one. |
|
|
301
|
+
| `withCredentials` | Send cookies (`credentials: 'include'`), and turn CSRF on. |
|
|
302
|
+
| `csrf` | CSRF cookie/header names, or a boolean. |
|
|
303
|
+
| `onRequest` | Interceptor(s) run before every request. |
|
|
304
|
+
| `onResponse` | Interceptor(s) run after every 2xx. |
|
|
305
|
+
| `onError` | Called for every non-2xx before the error is thrown. |
|
|
306
|
+
| `onUnauthorized` | Called on 401, with a `retry` function. |
|
|
307
|
+
| `onForbidden` | Called on 403. |
|
|
308
|
+
|
|
309
|
+
Per-request options — `RequestOptions`, plus `query` on `GetOptions` and
|
|
310
|
+
`params`/`query` on `MutationOptions` — override the client's defaults: `headers`,
|
|
311
|
+
`timeout`, `retry`, `responseType`, `signal`, `init` and `meta`.
|
|
312
|
+
|
|
313
|
+
## Calling your own API
|
|
314
|
+
|
|
315
|
+
Everything above treats a URL as a string, which is the right trade for a service
|
|
316
|
+
you hit a handful of endpoints on. Nobody should describe someone else's API in
|
|
317
|
+
types to send three requests to it.
|
|
318
|
+
|
|
319
|
+
If you are calling **your own** API repeatedly, `createApiClient<Routes>(config)`
|
|
320
|
+
binds a route map, so paths, params, query and response types are inferred and a
|
|
321
|
+
renamed endpoint becomes a build error. It returns the same `ApiClient` with a
|
|
322
|
+
narrower type; the `Client` facade above is bound to the base map and answers
|
|
323
|
+
`unknown`. The route-map types are listed in the [API reference](/docs/api).
|
|
110
324
|
|
|
111
|
-
##
|
|
325
|
+
## Realtime
|
|
112
326
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
| [Authentication](/docs/client/auth) | Bearer tokens, CSRF, and refreshing credentials on a 401. |
|
|
117
|
-
| [Error Handling](/docs/client/errors) | What a failed request throws, and how to tell the failure modes apart. |
|
|
118
|
-
| [Resilience](/docs/client/resilience) | Timeouts, retries, and the circuit breaker that spares a failing upstream. |
|
|
119
|
-
| [File Transfers](/docs/client/files) | Uploading and downloading binary payloads. |
|
|
120
|
-
| [Testing the Client](/docs/client/testing) | Stub the global fetch, cover the failure paths, and drive the circuit breaker. |
|
|
121
|
-
| [References](/docs/client/references) | Every ApiClient method, config key, and error type in one table. |
|
|
327
|
+
This package also ships `Socket`, `Channel` and `PresenceChannel` — a WebSocket
|
|
328
|
+
client speaking Zerotal's broadcast protocol. Different job, documented where it is
|
|
329
|
+
used: [Broadcasting → Client](/docs/broadcasting/client).
|
|
122
330
|
|
|
123
331
|
## Next steps
|
|
124
332
|
|
|
125
|
-
- [Rate Limiting](/docs/rate-limiting) — throttle
|
|
126
|
-
- [
|
|
127
|
-
- [
|
|
128
|
-
- [Telemetry](/docs/telemetry) — observe request failures and circuit state.
|
|
333
|
+
- [Rate Limiting](/docs/rate-limiting) — throttle what you send and what you accept.
|
|
334
|
+
- [Telemetry](/docs/telemetry) — trace outbound calls and watch circuit state.
|
|
335
|
+
- [Queues](/docs/queue) — where a slow or flaky integration usually belongs.
|
package/docs/contributing.md
CHANGED
|
@@ -8,7 +8,7 @@ description: Get the Zerotal monorepo running locally and pass the checks your c
|
|
|
8
8
|
Zerotal is a Bun-native monorepo of composable packages. This guide covers getting the
|
|
9
9
|
repo running locally, the project layout, and the checks your change needs to pass.
|
|
10
10
|
|
|
11
|
-
> **Warning** — Bun ≥ 1.
|
|
11
|
+
> **Warning** — Bun ≥ 1.3.14 is required. Node.js is not supported; Zerotal uses `Bun.sql`, `Bun.CryptoHasher`, `Bun.build`, and other Bun-native APIs throughout.
|
|
12
12
|
|
|
13
13
|
## Getting set up
|
|
14
14
|
|
|
@@ -32,7 +32,7 @@ packages/ # the framework — one directory per @zerotal/* package
|
|
|
32
32
|
auth/ cache/ queue/ … # feature packages
|
|
33
33
|
testing/ # factories, fakes, test app harness
|
|
34
34
|
create-zerotal/ # the `bun create zerotal` scaffolder
|
|
35
|
-
apps/ #
|
|
35
|
+
apps/ # applications in this workspace
|
|
36
36
|
docs/ # this documentation site
|
|
37
37
|
docs/ # the markdown documentation (what you're reading)
|
|
38
38
|
```
|
|
@@ -149,6 +149,24 @@ that keeps it complete.
|
|
|
149
149
|
The same rule governs a page's own length: a section that has grown into a second
|
|
150
150
|
subject is a sign it wants to be its own page, or to move to the one that owns it.
|
|
151
151
|
|
|
152
|
+
## Changelogs
|
|
153
|
+
|
|
154
|
+
Each package keeps its own `CHANGELOG.md`. Add what you changed under
|
|
155
|
+
`## [Unreleased]` in the package you changed — that is where in-flight work
|
|
156
|
+
belongs, and the entry travels with the code rather than being reconstructed later.
|
|
157
|
+
|
|
158
|
+
Cutting a release moves those entries under the version, which is a step and not an
|
|
159
|
+
automatic consequence:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
bun run changelog:release 1.7.4
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Skipping it is how `[Unreleased]` came to hold four releases of shipped work —
|
|
166
|
+
`@zerotal/flow-ui`'s newest heading read `[1.5.0]` while 1.7.3 was on npm. Someone
|
|
167
|
+
upgrading reads "Unreleased" and concludes none of it is in the version they are
|
|
168
|
+
taking, when all of it is.
|
|
169
|
+
|
|
152
170
|
## Pull requests
|
|
153
171
|
|
|
154
172
|
- Keep PRs focused — one logical change per PR.
|