@monarcode/effetch 0.0.0-stage → 0.1.0-alpha.1

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/README.md CHANGED
@@ -1,3 +1,614 @@
1
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
9
+ [![npm](https://img.shields.io/npm/v/@monarcode/effetch)](https://www.npmjs.com/package/@monarcode/effetch)
10
+ [![CI](https://github.com/monarcode/effetch/actions/workflows/ci.yml/badge.svg)](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)