@monarcode/effetch 0.0.0-stage → 0.1.0-alpha.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 +51 -0
- package/LICENSE +21 -0
- package/README.md +613 -2
- package/dist/Client.d.ts +367 -0
- package/dist/Client.js +110 -0
- package/dist/Errors.d.ts +224 -0
- package/dist/Errors.js +151 -0
- package/dist/Options.d.ts +462 -0
- package/dist/Path.d.ts +72 -0
- package/dist/Plugin.d.ts +553 -0
- package/dist/Plugin.js +7 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +5 -0
- package/dist/internal/body.js +70 -0
- package/dist/internal/object.js +6 -0
- package/dist/internal/path.js +23 -0
- package/dist/internal/pipeline.js +29 -0
- package/dist/internal/plugins.js +92 -0
- package/dist/internal/query.js +22 -0
- package/dist/internal/request.js +33 -0
- package/dist/internal/resolve.js +23 -0
- package/dist/internal/response.js +73 -0
- package/dist/internal/url.js +24 -0
- package/dist/plugins/bearer.d.ts +152 -0
- package/dist/plugins/bearer.js +63 -0
- package/dist/plugins/index.d.ts +32 -0
- package/dist/plugins/index.js +6 -0
- package/dist/plugins/logger.d.ts +66 -0
- package/dist/plugins/logger.js +67 -0
- package/dist/plugins/retry.d.ts +94 -0
- package/dist/plugins/retry.js +74 -0
- package/dist/plugins/timeout.d.ts +108 -0
- package/dist/plugins/timeout.js +84 -0
- package/package.json +90 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@monarcode/effetch`, newest first.
|
|
4
|
+
|
|
5
|
+
## 0.1.0-alpha.0
|
|
6
|
+
|
|
7
|
+
First preview of effetch: typed, schema-driven HTTP calls on Effect v4's
|
|
8
|
+
`HttpClient`.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
#### Client
|
|
13
|
+
|
|
14
|
+
- `make` builds a client from any `HttpClient`, with a `baseUrl` (a string, or
|
|
15
|
+
an Effect such as a `Config`, run once), default `headers`, a default
|
|
16
|
+
`error` schema and `plugins`. Calls need no services apart from those of
|
|
17
|
+
their own schemas.
|
|
18
|
+
- `get`, `head` and `options` (no body), `post`, `put`, `patch` and `del`, and
|
|
19
|
+
`request(method, …)` for any method. `httpClient` exposes the configured
|
|
20
|
+
`HttpClient` for anything else, such as streaming.
|
|
21
|
+
- Path parameters are inferred from the path: `"/users/:id"` requires
|
|
22
|
+
`params: { id }`, and values are URI-encoded.
|
|
23
|
+
- `query` takes a plain object: arrays repeat the key, `null` and `undefined`
|
|
24
|
+
are dropped.
|
|
25
|
+
- `body` is sent by its type (`FormData`, `URLSearchParams`, `Blob`, bytes,
|
|
26
|
+
text, or JSON), or encoded with an `input` schema.
|
|
27
|
+
- `output` decodes the 2xx JSON body with a schema and sends
|
|
28
|
+
`Accept: application/json`. Without it, calls return the raw response.
|
|
29
|
+
|
|
30
|
+
#### Errors
|
|
31
|
+
|
|
32
|
+
- `NetworkError`, `HttpError` and `DecodeError`, with `FetchError` and
|
|
33
|
+
`isFetchError`. `HttpError.body` is decoded with the call's or the client's
|
|
34
|
+
`error` schema, and `HttpError.With<B>` types it.
|
|
35
|
+
- Error URLs never include the query string, and response headers such as
|
|
36
|
+
`set-cookie` print as `<redacted>`.
|
|
37
|
+
|
|
38
|
+
#### Plugins
|
|
39
|
+
|
|
40
|
+
- `plugin` defines a plugin with `onRequest`, `onResponse`, `onError` and
|
|
41
|
+
`wrap` hooks. Its errors, services and options are inferred, and calls can
|
|
42
|
+
skip it or override its options with `plugins: { name: … }`.
|
|
43
|
+
- Built-in plugins in `@monarcode/effetch/plugins`: `bearer` (tokens from a
|
|
44
|
+
string, a sync or async function, or an Effect), `logger`, `retry` (network
|
|
45
|
+
errors and transient statuses, idempotent methods only, with jittered
|
|
46
|
+
backoff) and `timeout` (with `TimeoutError`).
|
|
47
|
+
|
|
48
|
+
#### Types and docs
|
|
49
|
+
|
|
50
|
+
- Every export is documented with an example in the shipped `.d.ts`.
|
|
51
|
+
TypeScript 5.4 or newer is required.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MONARCH
|
|
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
CHANGED
|
@@ -1,3 +1,614 @@
|
|
|
1
|
-
|
|
1
|
+
<h1>
|
|
2
|
+
<img
|
|
3
|
+
src="https://github.com/monarcode/effetch/raw/main/banner.png"
|
|
4
|
+
alt="effetch: schema-driven HTTP calls on Effect v4's HttpClient, typed end to end"
|
|
5
|
+
width="100%"
|
|
6
|
+
/>
|
|
7
|
+
</h1>
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/@monarcode/effetch)
|
|
10
|
+
[](https://github.com/monarcode/effetch/actions/workflows/ci.yml)
|
|
11
|
+
|
|
12
|
+
Typed, schema-driven HTTP calls on [Effect](https://effect.website) v4's
|
|
13
|
+
`HttpClient`: path parameters checked against the path, object queries,
|
|
14
|
+
decoded responses, typed error bodies and plugins.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import * as Schema from "effect/Schema";
|
|
18
|
+
|
|
19
|
+
import type * as Fetch from "@monarcode/effetch";
|
|
20
|
+
|
|
21
|
+
declare const api: Fetch.Client;
|
|
22
|
+
|
|
23
|
+
const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
|
|
24
|
+
|
|
25
|
+
const todo = api.get("/todos/:id", { params: { id: 1 }, output: Todo });
|
|
26
|
+
// Effect<{ readonly id: number; readonly title: string },
|
|
27
|
+
// NetworkError | HttpError | DecodeError>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> **Alpha.** effetch is in early alpha (`0.1.0-alpha.N`). The API may change
|
|
31
|
+
> between releases.
|
|
32
|
+
|
|
33
|
+
## Contents
|
|
34
|
+
|
|
35
|
+
- [Why](#why)
|
|
36
|
+
- [Install](#install)
|
|
37
|
+
- [Quick start](#quick-start)
|
|
38
|
+
- [Making a client](#making-a-client)
|
|
39
|
+
- [Calling](#calling)
|
|
40
|
+
- [Errors](#errors)
|
|
41
|
+
- [Plugins](#plugins)
|
|
42
|
+
- [Testing](#testing)
|
|
43
|
+
- [Browsers](#browsers)
|
|
44
|
+
- [React Native](#react-native)
|
|
45
|
+
- [Limitations](#limitations)
|
|
46
|
+
- [API](#api)
|
|
47
|
+
- [Development](#development)
|
|
48
|
+
|
|
49
|
+
## Why
|
|
50
|
+
|
|
51
|
+
Effect gives you two ways to call HTTP APIs:
|
|
52
|
+
|
|
53
|
+
- `HttpClient` is low level. You build each request, check the status and
|
|
54
|
+
decode the body yourself.
|
|
55
|
+
- `HttpApi` and `HttpApiClient` are contract first. You describe the whole
|
|
56
|
+
API up front and share it with the server, which is ideal when you own
|
|
57
|
+
both sides.
|
|
58
|
+
|
|
59
|
+
effetch sits in between, for calling backends you don't own, one call at a
|
|
60
|
+
time. You get:
|
|
61
|
+
|
|
62
|
+
- path parameters inferred from the path: `"/users/:id"` requires
|
|
63
|
+
`params: { id }`;
|
|
64
|
+
- queries from plain objects, with arrays and `undefined` handled;
|
|
65
|
+
- responses decoded with a `Schema`, and error bodies decoded with another;
|
|
66
|
+
- three tagged errors to `catchTag` on, plus your plugins' own;
|
|
67
|
+
- Effect-native plugins for auth, retries, timeouts and logging, with typed
|
|
68
|
+
per-call overrides.
|
|
69
|
+
|
|
70
|
+
It has no platform-specific code, and targets browsers, React Native,
|
|
71
|
+
Node 20+, Bun and Deno.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pnpm add @monarcode/effetch effect
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With Expo:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx expo install effect
|
|
83
|
+
pnpm add @monarcode/effetch
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `effect` 4.x is a peer dependency.
|
|
87
|
+
- The package is ESM only.
|
|
88
|
+
- TypeScript 5.4 or newer. With `skipLibCheck: false`, Effect's own
|
|
89
|
+
declarations need TypeScript 5.7 or newer.
|
|
90
|
+
|
|
91
|
+
## Quick start
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import * as Effect from "effect/Effect";
|
|
95
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
96
|
+
import * as Schema from "effect/Schema";
|
|
97
|
+
|
|
98
|
+
import * as Fetch from "@monarcode/effetch";
|
|
99
|
+
|
|
100
|
+
const Todo = Schema.Struct({
|
|
101
|
+
id: Schema.Number,
|
|
102
|
+
title: Schema.String,
|
|
103
|
+
completed: Schema.Boolean,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const program = Effect.gen(function* () {
|
|
107
|
+
const api = yield* Fetch.make({
|
|
108
|
+
baseUrl: "https://jsonplaceholder.typicode.com",
|
|
109
|
+
});
|
|
110
|
+
const todo = yield* api.get("/todos/:id", {
|
|
111
|
+
params: { id: 1 },
|
|
112
|
+
output: Todo,
|
|
113
|
+
});
|
|
114
|
+
console.log(todo.title);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)));
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`Fetch.make` is an Effect that needs an `HttpClient`. Here
|
|
121
|
+
`FetchHttpClient.layer` provides one built on the global `fetch`. Any other
|
|
122
|
+
`HttpClient` works too, such as Node's or a mock in tests.
|
|
123
|
+
|
|
124
|
+
## Making a client
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import * as Config from "effect/Config";
|
|
128
|
+
import * as Context from "effect/Context";
|
|
129
|
+
import * as Effect from "effect/Effect";
|
|
130
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
131
|
+
import * as Layer from "effect/Layer";
|
|
132
|
+
import * as Schema from "effect/Schema";
|
|
133
|
+
|
|
134
|
+
import * as Fetch from "@monarcode/effetch";
|
|
135
|
+
import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins";
|
|
136
|
+
|
|
137
|
+
const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String });
|
|
138
|
+
|
|
139
|
+
const session: { accessToken?: string } = {};
|
|
140
|
+
|
|
141
|
+
const make = Fetch.make({
|
|
142
|
+
baseUrl: Config.String("API_URL"),
|
|
143
|
+
headers: { "x-client": "web" },
|
|
144
|
+
error: ApiError,
|
|
145
|
+
plugins: [
|
|
146
|
+
logger(),
|
|
147
|
+
retry(),
|
|
148
|
+
timeout({ duration: "10 seconds" }),
|
|
149
|
+
bearer({ token: () => session.accessToken }),
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export class Api extends Context.Service<Api, Effect.Success<typeof make>>()(
|
|
154
|
+
"Api",
|
|
155
|
+
) {}
|
|
156
|
+
|
|
157
|
+
export const ApiLive = Layer.effect(Api)(make).pipe(
|
|
158
|
+
Layer.provide(FetchHttpClient.layer),
|
|
159
|
+
);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| Option | Description |
|
|
163
|
+
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
164
|
+
| `baseUrl` | Prepended to every path that isn't an absolute `http(s)://` URL. A string, or an Effect run once when the client is made (a `Config` works). It can't contain a query string or a fragment. |
|
|
165
|
+
| `headers` | Sent with every request. A call's header of the same name wins. `content-type` and `content-length` are ignored here: they come from each body. |
|
|
166
|
+
| `error` | The default schema for non-2xx response bodies (see [Errors](#errors)). |
|
|
167
|
+
| `plugins` | See [Plugins](#plugins). The first one is the outermost. |
|
|
168
|
+
|
|
169
|
+
The client holds on to the `HttpClient`, the `baseUrl` and the services its
|
|
170
|
+
plugins need. Its calls need nothing from the environment, apart from the
|
|
171
|
+
services of their own schemas.
|
|
172
|
+
|
|
173
|
+
## Calling
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import type * as HttpMethod from "effect/http/HttpMethod";
|
|
177
|
+
import * as Schema from "effect/Schema";
|
|
178
|
+
|
|
179
|
+
import type * as Fetch from "@monarcode/effetch";
|
|
180
|
+
|
|
181
|
+
declare const api: Fetch.Client;
|
|
182
|
+
|
|
183
|
+
const Todo = Schema.Struct({
|
|
184
|
+
id: Schema.Number,
|
|
185
|
+
title: Schema.String,
|
|
186
|
+
done: Schema.Boolean,
|
|
187
|
+
due: Schema.Date,
|
|
188
|
+
});
|
|
189
|
+
const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date });
|
|
190
|
+
|
|
191
|
+
export const list = api.get("/todos", {
|
|
192
|
+
query: { page: 1, tags: ["work", "home"], search: undefined },
|
|
193
|
+
output: Schema.Array(Todo),
|
|
194
|
+
});
|
|
195
|
+
// GET /todos?page=1&tags=work&tags=home
|
|
196
|
+
|
|
197
|
+
export const create = api.post("/todos", {
|
|
198
|
+
input: NewTodo,
|
|
199
|
+
body: { title: "Write docs", due: new Date() },
|
|
200
|
+
output: Todo,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
export const complete = api.patch("/todos/:id", {
|
|
204
|
+
params: { id: 1 },
|
|
205
|
+
body: { done: true },
|
|
206
|
+
output: Todo,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
export const remove = api.del("/todos/:id", { params: { id: 1 } });
|
|
210
|
+
// No output: succeeds with the raw HttpClientResponse.
|
|
211
|
+
|
|
212
|
+
export const upload = (file: Blob) => {
|
|
213
|
+
const form = new FormData();
|
|
214
|
+
form.append("file", file);
|
|
215
|
+
return api.post("/uploads", { body: form });
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export const send = (method: HttpMethod.HttpMethod, path: string) =>
|
|
219
|
+
api.request(method, path);
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Methods: `get`, `head` and `options` take no body. `post`, `put`, `patch`,
|
|
223
|
+
`del` (because `delete` is a reserved word) and `request(method, path,
|
|
224
|
+
options)` do.
|
|
225
|
+
|
|
226
|
+
| Option | Description |
|
|
227
|
+
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
228
|
+
| `params` | Values for the path's `:params`. Required, with exactly those names, when the path has any; not allowed otherwise. Values are URI-encoded. |
|
|
229
|
+
| `query` | An object of query parameters. Arrays repeat the key, and `null` and `undefined` are dropped. |
|
|
230
|
+
| `headers` | Headers for this call. They win over the client's and over the body's `content-type`. |
|
|
231
|
+
| `body` | Sent by its type: `HttpBody`, string, `ArrayBuffer` and typed arrays, `Blob`/`File`, `FormData` and `URLSearchParams` as they are, anything else as JSON. |
|
|
232
|
+
| `input` | A schema that `body` is encoded with before it's sent as JSON (a `Date` becomes an ISO string). `body` is then typed by the schema. |
|
|
233
|
+
| `output` | A schema the 2xx JSON body is decoded with. The call then sends `Accept: application/json` (unless you set `accept`). Without it, the call succeeds with the raw `HttpClientResponse`. |
|
|
234
|
+
| `error` | A schema for non-2xx bodies, replacing the client's. |
|
|
235
|
+
| `plugins` | Per-call plugin overrides (see [Per-call overrides](#per-call-overrides)). |
|
|
236
|
+
|
|
237
|
+
An empty 2xx body (204, `HEAD`) decodes as JSON `null`: use `Schema.Void` for
|
|
238
|
+
endpoints that return nothing.
|
|
239
|
+
|
|
240
|
+
`client.httpClient` is the configured `HttpClient` underneath: base URL,
|
|
241
|
+
default headers and plugins, but no status check and no decoding. Use it for
|
|
242
|
+
anything the methods don't cover, such as streaming a response.
|
|
243
|
+
|
|
244
|
+
## Errors
|
|
245
|
+
|
|
246
|
+
| Error | When | Fields |
|
|
247
|
+
| ----------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
248
|
+
| `NetworkError` | No response: offline, DNS failure, connection refused, CORS rejection. | `method`, `url`, `cause` |
|
|
249
|
+
| `HttpError` | A response outside 200–299 (3xx included). | `method`, `url`, `status`, `headers`, `text`, `body` |
|
|
250
|
+
| `DecodeError` | A 2xx body that isn't JSON or doesn't match `output`. Only on calls with `output`. | `method`, `url`, `status`, `issue`, `message` |
|
|
251
|
+
| `TimeoutError` (plugin) | The `timeout` plugin's limit passed before the response arrived. | `method`, `url`, `duration` |
|
|
252
|
+
|
|
253
|
+
- `url` never includes the query string, so tokens in queries stay out of
|
|
254
|
+
logs.
|
|
255
|
+
- `HttpError.headers` are Effect `Headers`: `authorization`, `set-cookie` and
|
|
256
|
+
other sensitive headers print as `<redacted>`.
|
|
257
|
+
- `HttpError.text` is the raw body. `body` is the body decoded with the
|
|
258
|
+
`error` schema, `undefined` if it didn't match, or the parsed JSON when
|
|
259
|
+
there's no schema. Reading it never fails the call.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import * as Effect from "effect/Effect";
|
|
263
|
+
import * as Schema from "effect/Schema";
|
|
264
|
+
|
|
265
|
+
import type * as Fetch from "@monarcode/effetch";
|
|
266
|
+
|
|
267
|
+
declare const api: Fetch.Client;
|
|
268
|
+
|
|
269
|
+
const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
|
|
270
|
+
const ApiError = Schema.Struct({ code: Schema.String });
|
|
271
|
+
|
|
272
|
+
export const todo = (id: number) =>
|
|
273
|
+
api.get("/todos/:id", { params: { id }, output: Todo, error: ApiError }).pipe(
|
|
274
|
+
Effect.catchTag("HttpError", (error) =>
|
|
275
|
+
error.status === 404
|
|
276
|
+
? Effect.succeed(undefined)
|
|
277
|
+
: Effect.fail(error.body?.code ?? `HTTP ${error.status}`),
|
|
278
|
+
),
|
|
279
|
+
Effect.catchTag("NetworkError", () => Effect.fail("You're offline")),
|
|
280
|
+
Effect.catchTag("DecodeError", (error) => Effect.fail(error.message)),
|
|
281
|
+
);
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`Fetch.FetchError` is the union of the three, `Fetch.isFetchError` tells them
|
|
285
|
+
apart from other values, and `Fetch.HttpError.With<B>` is an `HttpError`
|
|
286
|
+
whose `body` is typed as `B`.
|
|
287
|
+
|
|
288
|
+
Mistakes in the calling code are defects (the Effect dies) rather than typed
|
|
289
|
+
errors: a missing path parameter, a body that can't be encoded, a body on a
|
|
290
|
+
`GET` or `HEAD` request, a `baseUrl` with a query string, two plugins with
|
|
291
|
+
the same name, and a relative URL without a `baseUrl` outside browsers.
|
|
292
|
+
|
|
293
|
+
## Plugins
|
|
294
|
+
|
|
295
|
+
### Built-in plugins
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import * as Effect from "effect/Effect";
|
|
299
|
+
|
|
300
|
+
import * as Fetch from "@monarcode/effetch";
|
|
301
|
+
import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins";
|
|
302
|
+
|
|
303
|
+
declare const readToken: () => Promise<string | null>;
|
|
304
|
+
|
|
305
|
+
export const make = Fetch.make({
|
|
306
|
+
baseUrl: "https://api.example.com",
|
|
307
|
+
plugins: [
|
|
308
|
+
logger({ level: "Info" }),
|
|
309
|
+
retry({ times: 2 }),
|
|
310
|
+
timeout({ duration: "10 seconds" }),
|
|
311
|
+
bearer({ token: () => readToken() }),
|
|
312
|
+
],
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
export const withEffectToken = bearer({ token: Effect.succeed("token") });
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
| Plugin | What it does | Per call |
|
|
319
|
+
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
|
320
|
+
| `bearer({ token, scheme? })` | Sends `Authorization: Bearer <token>`. The token is a string, a function of the request (sync or async), or an Effect, read for every request. `null`, `undefined` and `""` send no header, and an existing `Authorization` header is kept. | `false` |
|
|
321
|
+
| `logger({ level? })` | Logs one entry per call at Debug (or Info): `GET <url> 200 (35ms)`, or `failed` with the cause. Never logs headers or query strings. | `false` |
|
|
322
|
+
| `retry({ times?, schedule?, methods?, statuses? })` | Retries network errors and 408, 429, 500, 502, 503 and 504, 2 times by default, with jittered exponential backoff from 200ms. Only idempotent methods (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) by default, so a `POST` is never sent twice. When retries run out, the last response is returned. | `false`, `{ times }` |
|
|
323
|
+
| `timeout({ duration })` | Fails with `TimeoutError` and aborts the request when the response headers don't arrive in time. Downloading the body isn't covered. | `false`, `{ duration }` |
|
|
324
|
+
|
|
325
|
+
- **Order.** The first plugin is the outermost. The order above is the one
|
|
326
|
+
to use: `logger` sees the whole call, and each retry gets its own timeout
|
|
327
|
+
and asks for the token again. A timed-out attempt isn't retried.
|
|
328
|
+
- **Token errors.** A token function that throws, or whose promise rejects,
|
|
329
|
+
makes the call die. Pass an Effect to fail with a typed error instead: its
|
|
330
|
+
error and services become the client's, and `make` asks for the services.
|
|
331
|
+
|
|
332
|
+
### Per-call overrides
|
|
333
|
+
|
|
334
|
+
Any plugin can be skipped for one call with `false`, and plugins with options
|
|
335
|
+
take a partial of them. Names and option types are checked:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
import * as Effect from "effect/Effect";
|
|
339
|
+
|
|
340
|
+
import * as Fetch from "@monarcode/effetch";
|
|
341
|
+
import { bearer, retry, timeout } from "@monarcode/effetch/plugins";
|
|
342
|
+
|
|
343
|
+
export const program = Effect.gen(function* () {
|
|
344
|
+
const api = yield* Fetch.make({
|
|
345
|
+
baseUrl: "https://api.example.com",
|
|
346
|
+
plugins: [
|
|
347
|
+
retry(),
|
|
348
|
+
timeout({ duration: "10 seconds" }),
|
|
349
|
+
bearer({ token: "token" }),
|
|
350
|
+
],
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
yield* api.post("/auth/login", {
|
|
354
|
+
body: { user: "ada" },
|
|
355
|
+
plugins: { bearer: false },
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
yield* api.get("/reports/export", {
|
|
359
|
+
plugins: { timeout: { duration: "2 minutes" }, retry: { times: 0 } },
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Writing a plugin
|
|
365
|
+
|
|
366
|
+
`Fetch.plugin` defines a plugin and infers its name, errors, services and
|
|
367
|
+
options from the hooks:
|
|
368
|
+
|
|
369
|
+
| Hook | Runs |
|
|
370
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
371
|
+
| `onRequest(request, ctx)` | Before the request is sent, in array order. Returns the request to send, or fails. It sees the full URL and the client's headers. |
|
|
372
|
+
| `onResponse(response, ctx)` | On every response, non-2xx included, in reverse order. Returns the response, or fails. The status check and decoding come after all plugins. |
|
|
373
|
+
| `onError(error, ctx)` | After a call fails or dies, in array order. It can't change the outcome. |
|
|
374
|
+
| `wrap(client, options)` | Wraps the plugins after it and the network, for retries, timeouts and the like. It can add errors but can't remove them. |
|
|
375
|
+
|
|
376
|
+
`ctx.options` is the plugin's `defaults` merged with the call's override, so
|
|
377
|
+
a plugin needs `defaults` to have options. `ctx.request` is the request: in
|
|
378
|
+
`onResponse`, the one this plugin passed on; in `onError`, the one the call
|
|
379
|
+
built.
|
|
380
|
+
|
|
381
|
+
The classic example turns a `401` into a typed `SessionExpired`, after
|
|
382
|
+
signing out:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import * as Context from "effect/Context";
|
|
386
|
+
import * as Data from "effect/Data";
|
|
387
|
+
import * as Effect from "effect/Effect";
|
|
388
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
389
|
+
|
|
390
|
+
import * as Fetch from "@monarcode/effetch";
|
|
391
|
+
|
|
392
|
+
export class SessionExpired extends Data.TaggedError("SessionExpired")<{}> {}
|
|
393
|
+
|
|
394
|
+
export class Session extends Context.Service<
|
|
395
|
+
Session,
|
|
396
|
+
{
|
|
397
|
+
readonly accessToken: Effect.Effect<string | undefined>;
|
|
398
|
+
readonly signOut: Effect.Effect<void>;
|
|
399
|
+
}
|
|
400
|
+
>()("Session") {}
|
|
401
|
+
|
|
402
|
+
export const auth = Fetch.plugin({
|
|
403
|
+
name: "auth",
|
|
404
|
+
onRequest: (request) =>
|
|
405
|
+
Effect.gen(function* () {
|
|
406
|
+
const session = yield* Session;
|
|
407
|
+
const token = yield* session.accessToken;
|
|
408
|
+
return token === undefined
|
|
409
|
+
? request
|
|
410
|
+
: HttpClientRequest.bearerToken(request, token);
|
|
411
|
+
}),
|
|
412
|
+
onResponse: (response) =>
|
|
413
|
+
Effect.gen(function* () {
|
|
414
|
+
if (response.status !== 401) {
|
|
415
|
+
return response;
|
|
416
|
+
}
|
|
417
|
+
const session = yield* Session;
|
|
418
|
+
yield* session.signOut;
|
|
419
|
+
return yield* new SessionExpired();
|
|
420
|
+
}),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// make now needs Session; calls fail with SessionExpired too.
|
|
424
|
+
export const make = Fetch.make({
|
|
425
|
+
baseUrl: "https://api.example.com",
|
|
426
|
+
plugins: [auth],
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
A `wrap` that adds its own error, with an option calls can override:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import * as Data from "effect/Data";
|
|
434
|
+
import * as Effect from "effect/Effect";
|
|
435
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
436
|
+
|
|
437
|
+
import * as Fetch from "@monarcode/effetch";
|
|
438
|
+
|
|
439
|
+
export class TooSlow extends Data.TaggedError("TooSlow")<{
|
|
440
|
+
readonly url: string;
|
|
441
|
+
}> {}
|
|
442
|
+
|
|
443
|
+
export const deadline = Fetch.plugin({
|
|
444
|
+
name: "deadline",
|
|
445
|
+
defaults: { millis: 5000 },
|
|
446
|
+
wrap: (client, options) =>
|
|
447
|
+
HttpClient.transform(client, (response, request) =>
|
|
448
|
+
Effect.gen(function* () {
|
|
449
|
+
const { millis } = yield* options;
|
|
450
|
+
return yield* Effect.timeoutOrElse(response, {
|
|
451
|
+
duration: millis,
|
|
452
|
+
orElse: () => Effect.fail(new TooSlow({ url: request.url })),
|
|
453
|
+
});
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- Services a plugin needs are required by `make`, once. Calls don't need
|
|
460
|
+
them. Services provided around a call win over those `make` saw.
|
|
461
|
+
- Plugin names must be unique.
|
|
462
|
+
- Write `wrap` bodies with `Effect.gen`. A ternary that returns two different
|
|
463
|
+
Effects can't be typed.
|
|
464
|
+
- Pass plugin lists inline or let TypeScript infer them. Annotating a list as
|
|
465
|
+
`Fetch.Plugin.Any[]` erases the plugins' types.
|
|
466
|
+
|
|
467
|
+
[`examples/`](examples) has complete programs with built-in and custom
|
|
468
|
+
plugins.
|
|
469
|
+
|
|
470
|
+
## Testing
|
|
471
|
+
|
|
472
|
+
`make` needs an `HttpClient`, so tests give it a fake one:
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import * as Effect from "effect/Effect";
|
|
476
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
477
|
+
import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
478
|
+
import * as Layer from "effect/Layer";
|
|
479
|
+
import * as Schema from "effect/Schema";
|
|
480
|
+
|
|
481
|
+
import * as Fetch from "@monarcode/effetch";
|
|
482
|
+
|
|
483
|
+
const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
|
|
484
|
+
|
|
485
|
+
const sent: Array<string> = [];
|
|
486
|
+
|
|
487
|
+
const TestHttpClient = Layer.succeed(
|
|
488
|
+
HttpClient.HttpClient,
|
|
489
|
+
HttpClient.make((request, url) => {
|
|
490
|
+
sent.push(`${request.method} ${url.href}`);
|
|
491
|
+
return Effect.succeed(
|
|
492
|
+
HttpClientResponse.fromWeb(
|
|
493
|
+
request,
|
|
494
|
+
Response.json({ id: 1, title: "Write docs" }),
|
|
495
|
+
),
|
|
496
|
+
);
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const program = Effect.gen(function* () {
|
|
501
|
+
const api = yield* Fetch.make({ baseUrl: "https://api.test" });
|
|
502
|
+
return yield* api.get("/todos/:id", { params: { id: 1 }, output: Todo });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const todo = await Effect.runPromise(
|
|
506
|
+
program.pipe(Effect.provide(TestHttpClient)),
|
|
507
|
+
);
|
|
508
|
+
// todo: { id: 1, title: "Write docs" }; sent: ["GET https://api.test/todos/1"]
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
## Browsers
|
|
512
|
+
|
|
513
|
+
Effect's `HttpClient` adds `traceparent` and `b3` tracing headers to every
|
|
514
|
+
request. Cross-origin calls to APIs that don't allow those headers then fail
|
|
515
|
+
the CORS preflight. Turn propagation off where the client is made, and it
|
|
516
|
+
applies to all its calls:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
import * as Context from "effect/Context";
|
|
520
|
+
import * as Effect from "effect/Effect";
|
|
521
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
522
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
523
|
+
import * as Layer from "effect/Layer";
|
|
524
|
+
|
|
525
|
+
import * as Fetch from "@monarcode/effetch";
|
|
526
|
+
|
|
527
|
+
const make = Fetch.make({ baseUrl: "https://api.example.com" });
|
|
528
|
+
|
|
529
|
+
export class Api extends Context.Service<Api, Effect.Success<typeof make>>()(
|
|
530
|
+
"Api",
|
|
531
|
+
) {}
|
|
532
|
+
|
|
533
|
+
export const ApiLive = Layer.effect(Api)(
|
|
534
|
+
make.pipe(Effect.provideService(HttpClient.TracerPropagationEnabled, false)),
|
|
535
|
+
).pipe(Layer.provide(FetchHttpClient.layer));
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
In a browser, a relative path without a `baseUrl` is resolved against the
|
|
539
|
+
page's URL.
|
|
540
|
+
|
|
541
|
+
## React Native
|
|
542
|
+
|
|
543
|
+
- effetch doesn't use `URL` itself. Effect's `HttpClient` does, including
|
|
544
|
+
`searchParams`. Expo provides a standard `URL`. In a React Native app
|
|
545
|
+
without Expo, install a polyfill such as `react-native-url-polyfill`.
|
|
546
|
+
- effetch and Effect use package `exports` (`effect/http/HttpClient`,
|
|
547
|
+
`@monarcode/effetch/plugins`). Metro resolves them by default from React
|
|
548
|
+
Native 0.79 (Expo SDK 53). On older versions, set
|
|
549
|
+
`resolver.unstable_enablePackageExports = true` in `metro.config.js`.
|
|
550
|
+
- `FetchHttpClient.layer` uses the global `fetch`.
|
|
551
|
+
|
|
552
|
+
## Limitations
|
|
553
|
+
|
|
554
|
+
- A path parameter is a whole segment: `/files/:name.json` has one parameter
|
|
555
|
+
called `name.json`.
|
|
556
|
+
- No query schemas or optional path parameters yet, and no endpoint
|
|
557
|
+
definitions: each call passes its own schemas.
|
|
558
|
+
- Paths shouldn't contain `?` or `#`. Pass query parameters with `query`.
|
|
559
|
+
- `timeout` and the logged duration cover the time until the response
|
|
560
|
+
headers arrive, not downloading the body.
|
|
561
|
+
- Two plugins with the same name are only caught when the client is made.
|
|
562
|
+
|
|
563
|
+
## API
|
|
564
|
+
|
|
565
|
+
`@monarcode/effetch`:
|
|
566
|
+
|
|
567
|
+
- `make`, `ClientConfig`, `Client`: making a client and its methods.
|
|
568
|
+
- `NetworkError`, `HttpError` (and `HttpError.With`), `DecodeError`,
|
|
569
|
+
`FetchError`, `isFetchError`: [errors](#errors).
|
|
570
|
+
- `plugin`, `Plugin` (and the `Plugin.*` helper types), `PluginContext`:
|
|
571
|
+
[writing plugins](#writing-a-plugin).
|
|
572
|
+
- Option and result types, for wrapping the client in your own functions:
|
|
573
|
+
`RequestOptions`, `NoBodyRequestOptions`, `BaseOptions`, `BodyOptions`,
|
|
574
|
+
`ParamsOption`, `QueryInput`, `QueryValue`, `AnySchema`, `Args`,
|
|
575
|
+
`Success`, `CallError`, `CallServices`, `CallEffect`, `NoBodyMethod`,
|
|
576
|
+
`BodyMethod`, `RequestMethod`, `PathParamNames`, `PathParams`,
|
|
577
|
+
`PathParamValue`.
|
|
578
|
+
|
|
579
|
+
`@monarcode/effetch/plugins`:
|
|
580
|
+
|
|
581
|
+
- `bearer`, `BearerOptions`, `BearerPlugin`, `TokenSource`, `TokenValue`.
|
|
582
|
+
- `logger`, `LoggerOptions`, `LoggerPlugin`.
|
|
583
|
+
- `retry`, `RetryOptions`, `RetryPlugin`.
|
|
584
|
+
- `timeout`, `TimeoutOptions`, `TimeoutPlugin`, `TimeoutError`.
|
|
585
|
+
|
|
586
|
+
Every export is documented, with an example, in the type declarations your
|
|
587
|
+
editor shows.
|
|
588
|
+
|
|
589
|
+
## Development
|
|
590
|
+
|
|
591
|
+
You need Node 24 and pnpm 10.12.1 (`corepack enable` picks the pinned
|
|
592
|
+
version).
|
|
593
|
+
|
|
594
|
+
```bash
|
|
595
|
+
pnpm install
|
|
596
|
+
pnpm run check
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
| Script | What it does |
|
|
600
|
+
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
601
|
+
| `pnpm run build` | Builds `dist/` (ESM and `.d.ts`) with tsdown. |
|
|
602
|
+
| `pnpm test` | Runs the tests and type tests with Vitest. |
|
|
603
|
+
| `pnpm run typecheck` | Typechecks `src`, `test` and `examples`. |
|
|
604
|
+
| `pnpm run lint` | Lints with oxlint (`lint:effect` runs the Effect language service). |
|
|
605
|
+
| `pnpm run format` | Formats with oxfmt (`format:check` only checks). |
|
|
606
|
+
| `pnpm run check:docs` | Checks every public export has a description, `@example` and `@since`, and compiles every example in the JSDoc and this README. |
|
|
607
|
+
| `pnpm run smoke` | Packs the package, installs it in a fresh project, typechecks and runs a consumer, and checks go-to-definition, hovers and completions. |
|
|
608
|
+
| `pnpm run check` | All of the above, plus publint and attw. CI runs it on every push to `main` and every pull request. |
|
|
609
|
+
|
|
610
|
+
The pre-commit hook runs `lint` and `format:check`.
|
|
611
|
+
|
|
612
|
+
## License
|
|
613
|
+
|
|
614
|
+
[MIT](LICENSE)
|