@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.
@@ -0,0 +1,23 @@
1
+ import { hasOwn } from "./object.js";
2
+
3
+ //#region src/internal/path.ts
4
+ /**
5
+ * Replaces each `:name` segment of `path` with the URI-encoded value of
6
+ * `params[name]`. Splits on `/` exactly like `PathParamNames`, so the
7
+ * runtime and the types agree.
8
+ *
9
+ * Only own properties of `params` count, so `:constructor` or `:toString`
10
+ * never pick up inherited members.
11
+ *
12
+ * Throws if a parameter is missing. Callers run it inside an Effect, where
13
+ * the throw becomes a defect.
14
+ */
15
+ const interpolatePath = (path, params) => path.split("/").map((segment) => {
16
+ if (segment.length < 2 || !segment.startsWith(":")) return segment;
17
+ const name = segment.slice(1);
18
+ if (params === void 0 || !hasOwn(params, name)) throw new Error(`Missing path parameter "${name}" for path "${path}"`);
19
+ return encodeURIComponent(String(params[name]));
20
+ }).join("/");
21
+
22
+ //#endregion
23
+ export { interpolatePath };
@@ -0,0 +1,29 @@
1
+ import { NetworkError } from "../Errors.js";
2
+ import { errorUrl, joinUrl } from "./url.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Headers from "effect/http/Headers";
5
+ import * as HttpClient from "effect/http/HttpClient";
6
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
7
+
8
+ //#region src/internal/pipeline.ts
9
+ const mapClientError = (error) => {
10
+ const { reason } = error;
11
+ switch (reason._tag) {
12
+ case "TransportError": return Effect.fail(new NetworkError({
13
+ method: reason.request.method,
14
+ url: errorUrl(reason.request.url),
15
+ cause: reason.cause
16
+ }));
17
+ case "StatusCodeError": return Effect.succeed(reason.response);
18
+ default: return Effect.die(error);
19
+ }
20
+ };
21
+ const mapClientErrors = (client) => HttpClient.transformResponse(client, Effect.catch(mapClientError));
22
+ const bodyHeaders = ["content-type", "content-length"];
23
+ const makeApplyDefaults = (baseUrl, headers) => {
24
+ const defaults = Headers.removeMany(headers, bodyHeaders);
25
+ return (request) => HttpClientRequest.setHeaders(HttpClientRequest.setUrl(request, joinUrl(baseUrl, request.url)), Headers.merge(defaults, request.headers));
26
+ };
27
+
28
+ //#endregion
29
+ export { makeApplyDefaults, mapClientErrors };
@@ -0,0 +1,92 @@
1
+ import { hasOwn } from "./object.js";
2
+ import * as Effect from "effect/Effect";
3
+ import * as HttpClient from "effect/http/HttpClient";
4
+ import * as Cause from "effect/Cause";
5
+ import * as Context from "effect/Context";
6
+
7
+ //#region src/internal/plugins.ts
8
+ const noOverrides = {};
9
+ /**
10
+ * The `plugins` option of the call being run. Provided around each call;
11
+ * anything else (such as `client.httpClient`) sees no overrides.
12
+ */
13
+ const CurrentOverrides = Context.Reference("@monarcode/effetch/CurrentOverrides", { defaultValue: () => noOverrides });
14
+ const provideOverrides = (overrides) => Effect.provideService(CurrentOverrides, overrides ?? noOverrides);
15
+ const overrideOf = (overrides, name) => hasOwn(overrides, name) ? overrides[name] : void 0;
16
+ const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
17
+ /**
18
+ * `{ ...defaults, ...override }`, leaving out the override's `undefined`
19
+ * values so they keep their default. Only object options can be
20
+ * overridden; anything else keeps `defaults`.
21
+ */
22
+ const mergeOptions = (defaults, override) => isRecord(defaults) && isRecord(override) ? {
23
+ ...defaults,
24
+ ...Object.fromEntries(Object.entries(override).filter(([, value]) => value !== void 0))
25
+ } : defaults;
26
+ const fromExecute = (execute) => HttpClient.makeWith(Effect.flatMap(execute), Effect.succeed);
27
+ const executeOf = (client) => (request) => client.execute(request);
28
+ /**
29
+ * One plugin around `inner`: its `wrap` encloses its `onRequest` and
30
+ * `onResponse`, and a call that sets the plugin to `false` goes straight to
31
+ * `inner`.
32
+ *
33
+ * Each layer is a plain `execute` function rather than an `HttpClient`
34
+ * split into Effect's `preprocess` and `postprocess`, so a `wrap` that
35
+ * re-runs its client (a retry) re-runs only what is inside it.
36
+ */
37
+ const layer = (plugin, inner) => {
38
+ const { onRequest, onResponse, wrap } = plugin;
39
+ if (onRequest === void 0 && onResponse === void 0 && wrap === void 0) return inner;
40
+ const options = Effect.map(Effect.service(CurrentOverrides), (overrides) => mergeOptions(plugin.defaults, overrideOf(overrides, plugin.name)));
41
+ const hooked = onRequest === void 0 && onResponse === void 0 ? inner : (request) => Effect.gen(function* () {
42
+ const current = yield* options;
43
+ const sent = onRequest === void 0 ? request : yield* onRequest(request, {
44
+ request,
45
+ options: current
46
+ });
47
+ const response = yield* inner(sent);
48
+ return onResponse === void 0 ? response : yield* onResponse(response, {
49
+ request: sent,
50
+ options: current
51
+ });
52
+ });
53
+ const wrapped = wrap === void 0 ? hooked : executeOf(wrap(fromExecute(hooked), options));
54
+ return (request) => Effect.flatMap(Effect.service(CurrentOverrides), (overrides) => overrideOf(overrides, plugin.name) === false ? inner(request) : wrapped(request));
55
+ };
56
+ /**
57
+ * Runs `effect` with the services captured by `make` filling in whatever the
58
+ * calling fiber doesn't have. The caller's own services and references win,
59
+ * as with Effect's `HttpClient.layerMergedContext`.
60
+ */
61
+ const withContext = (context) => (effect) => Effect.updateContext(effect, (input) => Context.merge(context, input));
62
+ /**
63
+ * The plugins around `client`, `plugins[0]` outermost, run with the captured
64
+ * `context`.
65
+ */
66
+ const applyPlugins = (client, plugins, context) => {
67
+ const execute = plugins.reduceRight((inner, plugin) => layer(plugin, inner), executeOf(client));
68
+ return fromExecute((request) => withContext(context)(execute(request)));
69
+ };
70
+ /**
71
+ * Runs the `onError` hooks, in array order, for a call that failed or died
72
+ * after `request` was built. Interruption isn't a failure, so it runs none.
73
+ * Plugins skipped by the call are left out. A hook that dies is logged at
74
+ * Debug level and never changes the call's outcome.
75
+ */
76
+ const runErrorHooks = (plugins, context, request, cause) => {
77
+ if (!Cause.hasFails(cause) && !Cause.hasDies(cause)) return Effect.void;
78
+ const error = Cause.squash(cause);
79
+ const hooks = Effect.flatMap(Effect.service(CurrentOverrides), (overrides) => Effect.forEach(plugins, (plugin) => {
80
+ const override = overrideOf(overrides, plugin.name);
81
+ if (plugin.onError === void 0 || override === false) return Effect.void;
82
+ const options = mergeOptions(plugin.defaults, override);
83
+ return Effect.catchCause(plugin.onError(error, {
84
+ request,
85
+ options
86
+ }), (failure) => Effect.logDebug(`effetch: the onError hook of plugin "${plugin.name}" failed`, failure));
87
+ }, { discard: true }));
88
+ return withContext(context)(hooks);
89
+ };
90
+
91
+ //#endregion
92
+ export { CurrentOverrides, applyPlugins, mergeOptions, provideOverrides, runErrorHooks, withContext };
@@ -0,0 +1,22 @@
1
+ import * as UrlParams from "effect/http/UrlParams";
2
+
3
+ //#region src/internal/query.ts
4
+ /**
5
+ * Converts the `query` option into `UrlParams`, in key order. Arrays repeat
6
+ * the key; `null` and `undefined` values and array items are dropped.
7
+ *
8
+ * Deliberately not `UrlParams.fromInput(query)`: that sends a `null` value
9
+ * as the string `"null"` and keeps `null` array items.
10
+ */
11
+ const toUrlParams = (query) => {
12
+ const entries = [];
13
+ if (query !== void 0) {
14
+ for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) {
15
+ for (const item of value) if (item !== null && item !== void 0) entries.push([key, String(item)]);
16
+ } else if (value !== null && value !== void 0) entries.push([key, String(value)]);
17
+ }
18
+ return UrlParams.make(entries);
19
+ };
20
+
21
+ //#endregion
22
+ export { toUrlParams };
@@ -0,0 +1,33 @@
1
+ import { provideOverrides, runErrorHooks } from "./plugins.js";
2
+ import { encodeBody } from "./body.js";
3
+ import { interpolatePath } from "./path.js";
4
+ import { toUrlParams } from "./query.js";
5
+ import { checkStatus, decodeOutput } from "./response.js";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Headers from "effect/http/Headers";
8
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
9
+
10
+ //#region src/internal/request.ts
11
+ const buildRequest = (state, method, path, options) => Effect.gen(function* () {
12
+ if (options.body !== void 0 && (method === "GET" || method === "HEAD")) return yield* Effect.die(/* @__PURE__ */ new Error(`A ${method} request can't have a body`));
13
+ let request = HttpClientRequest.make(method)(interpolatePath(path, options.params), { urlParams: toUrlParams(options.query) });
14
+ const body = yield* encodeBody(options.body, options.input);
15
+ if (body !== void 0) request = HttpClientRequest.setBody(request, body);
16
+ if (options.headers !== void 0) request = HttpClientRequest.setHeaders(request, options.headers);
17
+ request = state.applyDefaults(request);
18
+ if (options.output !== void 0 && !Headers.has(request.headers, "accept")) request = HttpClientRequest.acceptJson(request);
19
+ return request;
20
+ });
21
+ const execute = (state, request, options) => Effect.gen(function* () {
22
+ const response = yield* state.chain.execute(request);
23
+ const checked = yield* checkStatus(response, options.error ?? state.errorSchema);
24
+ return options.output === void 0 ? checked : yield* decodeOutput(checked, options.output);
25
+ });
26
+ const send = (state, method, path, options) => Effect.gen(function* () {
27
+ const request = yield* buildRequest(state, method, path, options);
28
+ return yield* Effect.tapCause(execute(state, request, options), (cause) => runErrorHooks(state.plugins, state.context, request, cause));
29
+ }).pipe(provideOverrides(options.plugins));
30
+ const makeCall = (state) => (method) => (path, options = {}) => send(state, method, path, options);
31
+
32
+ //#endregion
33
+ export { makeCall };
@@ -0,0 +1,23 @@
1
+ import * as Effect from "effect/Effect";
2
+
3
+ //#region src/internal/resolve.ts
4
+ const isThenable = (u) => typeof u === "object" && u !== null && typeof u.then === "function";
5
+ /**
6
+ * Resolves a bearer token source for `request`, in the calling fiber. The
7
+ * forms are told apart in this order: a string, an Effect (which may also
8
+ * be a function, like a `Context.Service` class), then a function.
9
+ *
10
+ * A function that throws or returns a rejected promise is a defect.
11
+ * Interrupting the Effect stops waiting on a pending promise.
12
+ */
13
+ const resolveTokenSource = (source, request) => {
14
+ if (typeof source === "string") return Effect.succeed(source);
15
+ if (Effect.isEffect(source)) return source;
16
+ return Effect.suspend(() => {
17
+ const value = source(request);
18
+ return isThenable(value) ? Effect.promise(() => value) : Effect.succeed(value);
19
+ });
20
+ };
21
+
22
+ //#endregion
23
+ export { resolveTokenSource };
@@ -0,0 +1,73 @@
1
+ import { DecodeError, HttpError } from "../Errors.js";
2
+ import { errorUrl } from "./url.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Schema from "effect/Schema";
5
+
6
+ //#region src/internal/response.ts
7
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
8
+ /**
9
+ * `undefined` for an empty or whitespace-only body, else the parsed JSON.
10
+ * Throws on invalid JSON. `JSON.parse` never returns `undefined`, so an
11
+ * empty body and a JSON `null` stay distinguishable here.
12
+ */
13
+ const parseBody = (text) => text.trim() === "" ? void 0 : JSON.parse(text);
14
+ const decodeJson = (schema) => Schema.decodeUnknownEffect(Schema.toCodecJson(schema));
15
+ const readErrorBody = (text, schema) => {
16
+ let json;
17
+ try {
18
+ json = parseBody(text);
19
+ } catch {
20
+ return Effect.succeed(void 0);
21
+ }
22
+ if (json === void 0 || schema === void 0) return Effect.succeed(json);
23
+ return Effect.orElseSucceed(decodeJson(schema)(json), () => void 0);
24
+ };
25
+ function checkStatus(response, errorSchema) {
26
+ if (response.status >= 200 && response.status < 300) return Effect.succeed(response);
27
+ return Effect.gen(function* () {
28
+ const text = yield* Effect.orElseSucceed(response.text, () => "");
29
+ const body = yield* readErrorBody(text, errorSchema);
30
+ return yield* Effect.fail(new HttpError({
31
+ method: response.request.method,
32
+ url: errorUrl(response.request.url),
33
+ status: response.status,
34
+ headers: response.headers,
35
+ text,
36
+ body
37
+ }));
38
+ });
39
+ }
40
+ /**
41
+ * Decodes a 2xx response body with `output`.
42
+ *
43
+ * An empty or whitespace-only body (204, 205, HEAD, …) is decoded as JSON
44
+ * `null`, which `Schema.Void`, `Schema.Undefined` and `Schema.NullOr`
45
+ * accept. A JSON `null` body is therefore indistinguishable from an empty
46
+ * one.
47
+ *
48
+ * Fails with a `DecodeError` whose `issue` is:
49
+ *
50
+ * - the `HttpClientError` when the body can't be read,
51
+ * - the `SyntaxError` when it isn't valid JSON,
52
+ * - the schema issue when it doesn't match `output`.
53
+ */
54
+ const decodeOutput = (response, output) => {
55
+ const failure = (issue, message) => new DecodeError({
56
+ method: response.request.method,
57
+ url: errorUrl(response.request.url),
58
+ status: response.status,
59
+ issue,
60
+ message
61
+ });
62
+ return Effect.gen(function* () {
63
+ const text = yield* Effect.mapError(response.text, (error) => failure(error, `Failed to read the response body: ${messageOf(error.cause ?? error)}`));
64
+ const json = yield* Effect.try({
65
+ try: () => parseBody(text),
66
+ catch: (cause) => failure(cause, `The response body isn't valid JSON: ${messageOf(cause)}`)
67
+ });
68
+ return yield* Effect.mapError(decodeJson(output)(json ?? null), (error) => failure(error.issue, json === void 0 ? `The response body is empty, but the output schema requires a value: ${error.message}` : error.message));
69
+ });
70
+ };
71
+
72
+ //#endregion
73
+ export { checkStatus, decodeOutput };
@@ -0,0 +1,24 @@
1
+ //#region src/internal/url.ts
2
+ const absoluteUrl = /^https?:\/\//i;
3
+ /**
4
+ * True for `http://` and `https://` URLs only. Protocol-relative (`//host`)
5
+ * and other schemes are treated as paths, so a request can't switch host or
6
+ * protocol by accident.
7
+ */
8
+ const isAbsoluteUrl = (path) => absoluteUrl.test(path);
9
+ /**
10
+ * Joins `base` and `path` with exactly one `/`. An absolute `path` (see
11
+ * `isAbsoluteUrl`) is returned unchanged, as is any `path` when there's no
12
+ * `base`. Uses string operations, not `new URL()`, for React Native.
13
+ */
14
+ const joinUrl = (base, path) => base === void 0 || isAbsoluteUrl(path) ? path : base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "");
15
+ /**
16
+ * `url` without its query string or fragment, so secrets such as
17
+ * `?api_key=…` never reach error messages or logs.
18
+ */
19
+ const errorUrl = (url) => url.split(/[?#]/, 1)[0] ?? "";
20
+ /** True if `url` contains a query string or a fragment. */
21
+ const hasQueryOrFragment = (url) => /[?#]/.test(url);
22
+
23
+ //#endregion
24
+ export { errorUrl, hasQueryOrFragment, isAbsoluteUrl, joinUrl };
@@ -0,0 +1,152 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
3
+
4
+ import { Plugin } from "../Plugin.js";
5
+ //#region src/plugins/bearer.d.ts
6
+ /**
7
+ * `T` itself, once known. In {@link BearerPlugin} it stops TypeScript
8
+ * inferring the token's error and services from where the plugin goes: in
9
+ * a `plugins` list they would become `any`.
10
+ */
11
+ type Settled<T> = [T] extends [infer U] ? U : never;
12
+ /**
13
+ * What a token source gives: the token, or `null`, `undefined` or `""` to
14
+ * send the request without an `Authorization` header.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import type { TokenValue } from "@monarcode/effetch/plugins";
19
+ *
20
+ * const signedOut: TokenValue = undefined;
21
+ * ```
22
+ *
23
+ * @since 0.1.0
24
+ */
25
+ export type TokenValue = string | null | undefined;
26
+ /**
27
+ * Where {@link bearer} gets the token, for every request:
28
+ *
29
+ * - a string, for a token that never changes;
30
+ * - a function of the request, returning the token or a promise of it;
31
+ * - an Effect, whose error and services become the plugin's (and so the
32
+ * client's).
33
+ *
34
+ * A function that throws, or whose promise rejects, makes the call die
35
+ * (a defect), so the error type stays clean. To fail with a typed error,
36
+ * pass an Effect.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import * as Effect from "effect/Effect";
41
+ * import type { TokenSource } from "@monarcode/effetch/plugins";
42
+ *
43
+ * declare const store: { getState(): { accessToken?: string } };
44
+ * declare const readToken: () => Promise<string | null>;
45
+ *
46
+ * const fixed: TokenSource = "dev-token";
47
+ * const sync: TokenSource = () => store.getState().accessToken;
48
+ * const async: TokenSource = () => readToken();
49
+ * const fromEffect: TokenSource = Effect.succeed("token");
50
+ * ```
51
+ *
52
+ * @since 0.1.0
53
+ */
54
+ export type TokenSource<E = never, R = never> =
55
+ | string
56
+ | ((
57
+ request: HttpClientRequest.HttpClientRequest,
58
+ ) => TokenValue | PromiseLike<TokenValue>)
59
+ | Effect.Effect<TokenValue, E, R>;
60
+ /**
61
+ * Options for {@link bearer}.
62
+ *
63
+ * - `token`: where the token comes from (see {@link TokenSource}). It's
64
+ * resolved for every request, inside that request's fiber, and never
65
+ * cached, so a refreshed or cleared token takes effect on the next
66
+ * request.
67
+ * - `scheme`: the word before the token. Defaults to `"Bearer"`.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import type { BearerOptions } from "@monarcode/effetch/plugins";
72
+ *
73
+ * const options: BearerOptions = { token: "dev-token", scheme: "Token" };
74
+ * ```
75
+ *
76
+ * @since 0.1.0
77
+ */
78
+ export interface BearerOptions<E = never, R = never> {
79
+ /** Where the token comes from, resolved for every request. */
80
+ readonly token: TokenSource<E, R>;
81
+ /** The word before the token. Defaults to `"Bearer"`. */
82
+ readonly scheme?: string | undefined;
83
+ }
84
+ /**
85
+ * The plugin made by {@link bearer}: it fails with the token Effect's `E`
86
+ * and needs its `R`. Calls can only skip it (`plugins: { bearer: false }`).
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * import { bearer, type BearerPlugin } from "@monarcode/effetch/plugins";
91
+ *
92
+ * const auth: BearerPlugin = bearer({ token: "dev-token" });
93
+ * ```
94
+ *
95
+ * @since 0.1.0
96
+ */
97
+ export type BearerPlugin<E = never, R = never> = Plugin<
98
+ "bearer",
99
+ Settled<E>,
100
+ Settled<R>
101
+ >;
102
+ /**
103
+ * Sends `Authorization: <scheme> <token>` with every request.
104
+ *
105
+ * The header is left out when the token is `null`, `undefined` or `""`,
106
+ * and a request that already has an `Authorization` header keeps it (the
107
+ * token isn't resolved then).
108
+ *
109
+ * Put it last in the plugin list, so retries ask for the token again.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * import * as Context from "effect/Context";
114
+ * import * as Effect from "effect/Effect";
115
+ * import * as Fetch from "@monarcode/effetch";
116
+ * import { bearer } from "@monarcode/effetch/plugins";
117
+ *
118
+ * declare const SecureStore: {
119
+ * getItemAsync(key: string): Promise<string | null>;
120
+ * };
121
+ *
122
+ * class Session extends Context.Service<
123
+ * Session,
124
+ * { readonly accessToken: Effect.Effect<string | undefined> }
125
+ * >()("Session") {}
126
+ *
127
+ * const fromStorage = bearer({
128
+ * token: () => SecureStore.getItemAsync("access_token"),
129
+ * });
130
+ *
131
+ * const fromSession = bearer({
132
+ * token: Effect.gen(function* () {
133
+ * const session = yield* Session;
134
+ * return yield* session.accessToken;
135
+ * }),
136
+ * });
137
+ *
138
+ * const make = Fetch.make({
139
+ * baseUrl: "https://api.example.com",
140
+ * plugins: [fromSession],
141
+ * });
142
+ *
143
+ * // Per call: api.post("/auth/login", { body, plugins: { bearer: false } })
144
+ * ```
145
+ *
146
+ * @since 0.1.0
147
+ */
148
+ export declare const bearer: <E = never, R = never>(
149
+ options: BearerOptions<E, R>,
150
+ ) => BearerPlugin<E, R>;
151
+ //#endregion
152
+ export {};
@@ -0,0 +1,63 @@
1
+ import { plugin } from "../Plugin.js";
2
+ import { resolveTokenSource } from "../internal/resolve.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Headers from "effect/http/Headers";
5
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
6
+
7
+ //#region src/plugins/bearer.ts
8
+ /**
9
+ * Sends `Authorization: <scheme> <token>` with every request.
10
+ *
11
+ * The header is left out when the token is `null`, `undefined` or `""`,
12
+ * and a request that already has an `Authorization` header keeps it (the
13
+ * token isn't resolved then).
14
+ *
15
+ * Put it last in the plugin list, so retries ask for the token again.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import * as Context from "effect/Context";
20
+ * import * as Effect from "effect/Effect";
21
+ * import * as Fetch from "@monarcode/effetch";
22
+ * import { bearer } from "@monarcode/effetch/plugins";
23
+ *
24
+ * declare const SecureStore: {
25
+ * getItemAsync(key: string): Promise<string | null>;
26
+ * };
27
+ *
28
+ * class Session extends Context.Service<
29
+ * Session,
30
+ * { readonly accessToken: Effect.Effect<string | undefined> }
31
+ * >()("Session") {}
32
+ *
33
+ * const fromStorage = bearer({
34
+ * token: () => SecureStore.getItemAsync("access_token"),
35
+ * });
36
+ *
37
+ * const fromSession = bearer({
38
+ * token: Effect.gen(function* () {
39
+ * const session = yield* Session;
40
+ * return yield* session.accessToken;
41
+ * }),
42
+ * });
43
+ *
44
+ * const make = Fetch.make({
45
+ * baseUrl: "https://api.example.com",
46
+ * plugins: [fromSession],
47
+ * });
48
+ *
49
+ * // Per call: api.post("/auth/login", { body, plugins: { bearer: false } })
50
+ * ```
51
+ *
52
+ * @since 0.1.0
53
+ */
54
+ const bearer = (options) => {
55
+ const scheme = options.scheme ?? "Bearer";
56
+ return plugin({
57
+ name: "bearer",
58
+ onRequest: (request) => Headers.has(request.headers, "authorization") ? Effect.succeed(request) : Effect.map(resolveTokenSource(options.token, request), (token) => token === void 0 || token === null || token === "" ? request : HttpClientRequest.setHeader(request, "authorization", `${scheme} ${token}`))
59
+ });
60
+ };
61
+
62
+ //#endregion
63
+ export { bearer };
@@ -0,0 +1,32 @@
1
+ import {
2
+ BearerOptions,
3
+ BearerPlugin,
4
+ TokenSource,
5
+ TokenValue,
6
+ bearer,
7
+ } from "./bearer.js";
8
+ import { LoggerOptions, LoggerPlugin, logger } from "./logger.js";
9
+ import { RetryOptions, RetryPlugin, retry } from "./retry.js";
10
+ import {
11
+ TimeoutError,
12
+ TimeoutOptions,
13
+ TimeoutPlugin,
14
+ timeout,
15
+ } from "./timeout.js";
16
+ export {
17
+ type BearerOptions,
18
+ type BearerPlugin,
19
+ type LoggerOptions,
20
+ type LoggerPlugin,
21
+ type RetryOptions,
22
+ type RetryPlugin,
23
+ TimeoutError,
24
+ type TimeoutOptions,
25
+ type TimeoutPlugin,
26
+ type TokenSource,
27
+ type TokenValue,
28
+ bearer,
29
+ logger,
30
+ retry,
31
+ timeout,
32
+ };
@@ -0,0 +1,6 @@
1
+ import { bearer } from "./bearer.js";
2
+ import { logger } from "./logger.js";
3
+ import { retry } from "./retry.js";
4
+ import { TimeoutError, timeout } from "./timeout.js";
5
+
6
+ export { TimeoutError, bearer, logger, retry, timeout };
@@ -0,0 +1,66 @@
1
+ import { Plugin } from "../Plugin.js";
2
+ //#region src/plugins/logger.d.ts
3
+ /**
4
+ * Options for {@link logger}.
5
+ *
6
+ * - `level`: the log level of every entry, `"Debug"` (the default) or
7
+ * `"Info"`. Effect's default logger hides Debug entries unless the
8
+ * minimum log level allows them.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import type { LoggerOptions } from "@monarcode/effetch/plugins";
13
+ *
14
+ * const options: LoggerOptions = { level: "Info" };
15
+ * ```
16
+ *
17
+ * @since 0.1.0
18
+ */
19
+ export interface LoggerOptions {
20
+ /** The level of every entry. Defaults to `"Debug"`. */
21
+ readonly level?: "Debug" | "Info" | undefined;
22
+ }
23
+ /**
24
+ * The plugin made by {@link logger}. Calls can only skip it
25
+ * (`plugins: { logger: false }`).
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { logger, type LoggerPlugin } from "@monarcode/effetch/plugins";
30
+ *
31
+ * const log: LoggerPlugin = logger();
32
+ * ```
33
+ *
34
+ * @since 0.1.0
35
+ */
36
+ export type LoggerPlugin = Plugin<"logger">;
37
+ /**
38
+ * Logs one entry per call with Effect's logger: the method, the URL, the
39
+ * status and how long the response took, e.g.
40
+ * `GET https://api.example.com/todos 200 (35ms)`, or `… failed (35ms)`
41
+ * with the failure's cause.
42
+ *
43
+ * The same values are log annotations (`method`, `url`, `status`,
44
+ * `durationMs`). The URL has no query string, and no headers are logged,
45
+ * so tokens and cookies stay out of logs. The duration is measured with
46
+ * Effect's `Clock` until the response headers arrive, in whole
47
+ * milliseconds.
48
+ *
49
+ * Put it first in the plugin list, so it sees the whole call, retries
50
+ * included.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import * as Fetch from "@monarcode/effetch";
55
+ * import { logger } from "@monarcode/effetch/plugins";
56
+ *
57
+ * const make = Fetch.make({
58
+ * baseUrl: "https://api.example.com",
59
+ * plugins: [logger({ level: "Info" })],
60
+ * });
61
+ * ```
62
+ *
63
+ * @since 0.1.0
64
+ */
65
+ export declare const logger: (options?: LoggerOptions) => LoggerPlugin;
66
+ //#endregion