@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.
@@ -0,0 +1,67 @@
1
+ import { errorUrl } from "../internal/url.js";
2
+ import { plugin } from "../Plugin.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import * as Cause from "effect/Cause";
6
+ import * as Clock from "effect/Clock";
7
+ import * as Exit from "effect/Exit";
8
+
9
+ //#region src/plugins/logger.ts
10
+ /**
11
+ * Logs one entry per call with Effect's logger: the method, the URL, the
12
+ * status and how long the response took, e.g.
13
+ * `GET https://api.example.com/todos 200 (35ms)`, or `… failed (35ms)`
14
+ * with the failure's cause.
15
+ *
16
+ * The same values are log annotations (`method`, `url`, `status`,
17
+ * `durationMs`). The URL has no query string, and no headers are logged,
18
+ * so tokens and cookies stay out of logs. The duration is measured with
19
+ * Effect's `Clock` until the response headers arrive, in whole
20
+ * milliseconds.
21
+ *
22
+ * Put it first in the plugin list, so it sees the whole call, retries
23
+ * included.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import * as Fetch from "@monarcode/effetch";
28
+ * import { logger } from "@monarcode/effetch/plugins";
29
+ *
30
+ * const make = Fetch.make({
31
+ * baseUrl: "https://api.example.com",
32
+ * plugins: [logger({ level: "Info" })],
33
+ * });
34
+ * ```
35
+ *
36
+ * @since 0.1.0
37
+ */
38
+ const logger = (options = {}) => {
39
+ const log = options.level === "Info" ? Effect.logInfo : Effect.logDebug;
40
+ return plugin({
41
+ name: "logger",
42
+ wrap: (client) => HttpClient.transform(client, (response, request) => Effect.gen(function* () {
43
+ const start = yield* Clock.currentTimeMillis;
44
+ const exit = yield* Effect.exit(response);
45
+ const durationMs = Math.round((yield* Clock.currentTimeMillis) - start);
46
+ const method = request.method;
47
+ const url = errorUrl(request.url);
48
+ if (Exit.isSuccess(exit)) {
49
+ const { status } = exit.value;
50
+ yield* Effect.annotateLogs(log(`${method} ${url} ${status} (${durationMs}ms)`), {
51
+ method,
52
+ url,
53
+ status,
54
+ durationMs
55
+ });
56
+ } else if (!Cause.hasInterruptsOnly(exit.cause)) yield* Effect.annotateLogs(log(`${method} ${url} failed (${durationMs}ms)`, exit.cause), {
57
+ method,
58
+ url,
59
+ durationMs
60
+ });
61
+ return yield* exit;
62
+ }))
63
+ });
64
+ };
65
+
66
+ //#endregion
67
+ export { logger };
@@ -0,0 +1,94 @@
1
+ import * as HttpMethod from "effect/http/HttpMethod";
2
+ import * as Schedule from "effect/Schedule";
3
+
4
+ import { Plugin } from "../Plugin.js";
5
+ //#region src/plugins/retry.d.ts
6
+ /**
7
+ * Options for {@link retry}.
8
+ *
9
+ * - `times`: how many times to retry, after the first attempt. Defaults to
10
+ * `2`. Calls can override it.
11
+ * - `schedule`: the delays between attempts. Defaults to an exponential
12
+ * backoff from 200ms, with jitter. `times` still caps the retries.
13
+ * - `methods`: the methods that are retried. Defaults to the idempotent
14
+ * ones: `GET`, `HEAD`, `OPTIONS`, `PUT` and `DELETE`.
15
+ * - `statuses`: the response statuses that are retried. Defaults to `408`,
16
+ * `429`, `500`, `502`, `503` and `504`.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import * as Schedule from "effect/Schedule";
21
+ * import type { RetryOptions } from "@monarcode/effetch/plugins";
22
+ *
23
+ * const options: RetryOptions = {
24
+ * times: 3,
25
+ * schedule: Schedule.spaced("1 second"),
26
+ * methods: ["GET"],
27
+ * statuses: [503],
28
+ * };
29
+ * ```
30
+ *
31
+ * @since 0.1.0
32
+ */
33
+ export interface RetryOptions {
34
+ /** How many times to retry after the first attempt. Defaults to `2`. */
35
+ readonly times?: number | undefined;
36
+ /** The delays between attempts. Defaults to jittered exponential backoff. */
37
+ readonly schedule?: Schedule.Schedule<unknown, unknown> | undefined;
38
+ /** The methods that are retried. Defaults to the idempotent ones. */
39
+ readonly methods?: ReadonlyArray<HttpMethod.HttpMethod> | undefined;
40
+ /** The statuses that are retried. Defaults to 408, 429, 500, 502–504. */
41
+ readonly statuses?: ReadonlyArray<number> | undefined;
42
+ }
43
+ /**
44
+ * The plugin made by {@link retry}. It adds no errors; calls can override
45
+ * `times` or skip it.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * import { retry, type RetryPlugin } from "@monarcode/effetch/plugins";
50
+ *
51
+ * const retryTwice: RetryPlugin = retry();
52
+ * ```
53
+ *
54
+ * @since 0.1.0
55
+ */
56
+ export type RetryPlugin = Plugin<
57
+ "retry",
58
+ never,
59
+ never,
60
+ {
61
+ readonly times: number;
62
+ }
63
+ >;
64
+ /**
65
+ * Retries a call that failed with a `NetworkError` or got a retryable
66
+ * status, for the configured methods only (idempotent ones by default, so
67
+ * a `POST` is never sent twice).
68
+ *
69
+ * When the retries run out, the last response is returned unchanged, so a
70
+ * `503` still ends as an `HttpError` with that response's body, and the
71
+ * last `NetworkError` is the call's error. Other failures, a timeout from
72
+ * the `timeout` plugin included, aren't retried.
73
+ *
74
+ * It retries everything after it in the plugin list. Put it before
75
+ * `timeout` and `bearer`, so each attempt gets its own time limit and a
76
+ * fresh token.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import * as Fetch from "@monarcode/effetch";
81
+ * import { retry } from "@monarcode/effetch/plugins";
82
+ *
83
+ * const make = Fetch.make({
84
+ * baseUrl: "https://api.example.com",
85
+ * plugins: [retry({ times: 3 })],
86
+ * });
87
+ *
88
+ * // Per call: api.get("/health", { plugins: { retry: { times: 0 } } })
89
+ * ```
90
+ *
91
+ * @since 0.1.0
92
+ */
93
+ export declare const retry: (options?: RetryOptions) => RetryPlugin;
94
+ //#endregion
@@ -0,0 +1,74 @@
1
+ import { NetworkError } from "../Errors.js";
2
+ import { plugin } from "../Plugin.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import * as Data from "effect/Data";
6
+ import * as Schedule from "effect/Schedule";
7
+
8
+ //#region src/plugins/retry.ts
9
+ const idempotentMethods = [
10
+ "GET",
11
+ "HEAD",
12
+ "OPTIONS",
13
+ "PUT",
14
+ "DELETE"
15
+ ];
16
+ const transientStatuses = [
17
+ 408,
18
+ 429,
19
+ 500,
20
+ 502,
21
+ 503,
22
+ 504
23
+ ];
24
+ var RetryableStatus = class extends Data.TaggedError("RetryableStatus") {};
25
+ /**
26
+ * Retries a call that failed with a `NetworkError` or got a retryable
27
+ * status, for the configured methods only (idempotent ones by default, so
28
+ * a `POST` is never sent twice).
29
+ *
30
+ * When the retries run out, the last response is returned unchanged, so a
31
+ * `503` still ends as an `HttpError` with that response's body, and the
32
+ * last `NetworkError` is the call's error. Other failures, a timeout from
33
+ * the `timeout` plugin included, aren't retried.
34
+ *
35
+ * It retries everything after it in the plugin list. Put it before
36
+ * `timeout` and `bearer`, so each attempt gets its own time limit and a
37
+ * fresh token.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import * as Fetch from "@monarcode/effetch";
42
+ * import { retry } from "@monarcode/effetch/plugins";
43
+ *
44
+ * const make = Fetch.make({
45
+ * baseUrl: "https://api.example.com",
46
+ * plugins: [retry({ times: 3 })],
47
+ * });
48
+ *
49
+ * // Per call: api.get("/health", { plugins: { retry: { times: 0 } } })
50
+ * ```
51
+ *
52
+ * @since 0.1.0
53
+ */
54
+ const retry = (options = {}) => {
55
+ const methods = new Set(options.methods ?? idempotentMethods);
56
+ const statuses = new Set(options.statuses ?? transientStatuses);
57
+ const schedule = options.schedule ?? Schedule.jittered(Schedule.exponential("200 millis"));
58
+ return plugin({
59
+ name: "retry",
60
+ defaults: { times: options.times ?? 2 },
61
+ wrap: (client, callOptions) => HttpClient.transform(client, (response, request) => Effect.gen(function* () {
62
+ const { times } = yield* callOptions;
63
+ if (!(times > 0) || !methods.has(request.method)) return yield* response;
64
+ return yield* Effect.flatMap(response, (received) => statuses.has(received.status) ? Effect.fail(new RetryableStatus({ response: received })) : Effect.succeed(received)).pipe(Effect.retry({
65
+ schedule,
66
+ times,
67
+ while: (error) => error instanceof RetryableStatus || error instanceof NetworkError
68
+ }), Effect.catchTag("RetryableStatus", (error) => Effect.succeed(error.response)));
69
+ }))
70
+ });
71
+ };
72
+
73
+ //#endregion
74
+ export { retry };
@@ -0,0 +1,108 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Schema from "effect/Schema";
3
+
4
+ import { Plugin } from "../Plugin.js";
5
+ //#region src/plugins/timeout.d.ts
6
+ declare const TimeoutError_base: Schema.Class<
7
+ TimeoutError,
8
+ Schema.TaggedStruct<
9
+ "TimeoutError",
10
+ {
11
+ readonly method: Schema.String;
12
+ readonly url: Schema.String;
13
+ readonly duration: Schema.String;
14
+ }
15
+ >,
16
+ import("effect/Cause").YieldableError
17
+ >;
18
+ /**
19
+ * No response arrived within the {@link timeout} plugin's duration. The
20
+ * request was aborted.
21
+ *
22
+ * - `url` is the request URL without its query string.
23
+ * - `duration` is the limit that was exceeded, formatted (`"10s"`).
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import * as Effect from "effect/Effect";
28
+ * import type { TimeoutError } from "@monarcode/effetch/plugins";
29
+ *
30
+ * declare const call: Effect.Effect<string, TimeoutError>;
31
+ *
32
+ * const program = call.pipe(
33
+ * Effect.catchTag("TimeoutError", (error) =>
34
+ * Effect.succeed(`Gave up on ${error.url} after ${error.duration}`),
35
+ * ),
36
+ * );
37
+ * ```
38
+ *
39
+ * @since 0.1.0
40
+ */
41
+ export declare class TimeoutError extends TimeoutError_base {
42
+ /** `"<method> <url>: timed out after <duration>"`. */
43
+ get message(): string;
44
+ }
45
+ /**
46
+ * Options for {@link timeout}, and what a call can override:
47
+ * `plugins: { timeout: { duration } }`.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * import type { TimeoutOptions } from "@monarcode/effetch/plugins";
52
+ *
53
+ * const options: TimeoutOptions = { duration: "10 seconds" };
54
+ * ```
55
+ *
56
+ * @since 0.1.0
57
+ */
58
+ export interface TimeoutOptions {
59
+ /** How long to wait for the response headers. */
60
+ readonly duration: Duration.Input;
61
+ }
62
+ /**
63
+ * The plugin made by {@link timeout}: it adds {@link TimeoutError}, and
64
+ * calls can override its `duration` or skip it.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * import { timeout, type TimeoutPlugin } from "@monarcode/effetch/plugins";
69
+ *
70
+ * const tenSeconds: TimeoutPlugin = timeout({ duration: "10 seconds" });
71
+ * ```
72
+ *
73
+ * @since 0.1.0
74
+ */
75
+ export type TimeoutPlugin = Plugin<
76
+ "timeout",
77
+ TimeoutError,
78
+ never,
79
+ TimeoutOptions
80
+ >;
81
+ /**
82
+ * Fails the call with a {@link TimeoutError}, and aborts the request, when
83
+ * the response headers don't arrive within `duration`.
84
+ *
85
+ * It covers the plugins after it in the list, but not reading the body: a
86
+ * large response that starts in time isn't cut off while it downloads.
87
+ * Put it after `retry` so each attempt gets the full duration. A timed-out
88
+ * attempt isn't retried.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import * as Fetch from "@monarcode/effetch";
93
+ * import { timeout } from "@monarcode/effetch/plugins";
94
+ *
95
+ * const make = Fetch.make({
96
+ * baseUrl: "https://api.example.com",
97
+ * plugins: [timeout({ duration: "10 seconds" })],
98
+ * });
99
+ *
100
+ * // Per call:
101
+ * // api.get("/export", { plugins: { timeout: { duration: "2 minutes" } } })
102
+ * ```
103
+ *
104
+ * @since 0.1.0
105
+ */
106
+ export declare const timeout: (options: TimeoutOptions) => TimeoutPlugin;
107
+ //#endregion
108
+ export {};
@@ -0,0 +1,84 @@
1
+ import { errorUrl } from "../internal/url.js";
2
+ import { plugin } from "../Plugin.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import * as Schema from "effect/Schema";
6
+ import * as Duration from "effect/Duration";
7
+
8
+ //#region src/plugins/timeout.ts
9
+ /**
10
+ * No response arrived within the {@link timeout} plugin's duration. The
11
+ * request was aborted.
12
+ *
13
+ * - `url` is the request URL without its query string.
14
+ * - `duration` is the limit that was exceeded, formatted (`"10s"`).
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import * as Effect from "effect/Effect";
19
+ * import type { TimeoutError } from "@monarcode/effetch/plugins";
20
+ *
21
+ * declare const call: Effect.Effect<string, TimeoutError>;
22
+ *
23
+ * const program = call.pipe(
24
+ * Effect.catchTag("TimeoutError", (error) =>
25
+ * Effect.succeed(`Gave up on ${error.url} after ${error.duration}`),
26
+ * ),
27
+ * );
28
+ * ```
29
+ *
30
+ * @since 0.1.0
31
+ */
32
+ var TimeoutError = class extends Schema.TaggedError()("TimeoutError", {
33
+ method: Schema.String,
34
+ url: Schema.String,
35
+ duration: Schema.String
36
+ }) {
37
+ /** `"<method> <url>: timed out after <duration>"`. */
38
+ get message() {
39
+ return `${this.method} ${this.url}: timed out after ${this.duration}`;
40
+ }
41
+ };
42
+ /**
43
+ * Fails the call with a {@link TimeoutError}, and aborts the request, when
44
+ * the response headers don't arrive within `duration`.
45
+ *
46
+ * It covers the plugins after it in the list, but not reading the body: a
47
+ * large response that starts in time isn't cut off while it downloads.
48
+ * Put it after `retry` so each attempt gets the full duration. A timed-out
49
+ * attempt isn't retried.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import * as Fetch from "@monarcode/effetch";
54
+ * import { timeout } from "@monarcode/effetch/plugins";
55
+ *
56
+ * const make = Fetch.make({
57
+ * baseUrl: "https://api.example.com",
58
+ * plugins: [timeout({ duration: "10 seconds" })],
59
+ * });
60
+ *
61
+ * // Per call:
62
+ * // api.get("/export", { plugins: { timeout: { duration: "2 minutes" } } })
63
+ * ```
64
+ *
65
+ * @since 0.1.0
66
+ */
67
+ const timeout = (options) => plugin({
68
+ name: "timeout",
69
+ defaults: { duration: options.duration },
70
+ wrap: (client, callOptions) => HttpClient.transform(client, (response, request) => Effect.gen(function* () {
71
+ const { duration } = yield* callOptions;
72
+ return yield* Effect.timeoutOrElse(response, {
73
+ duration,
74
+ orElse: () => Effect.fail(new TimeoutError({
75
+ method: request.method,
76
+ url: errorUrl(request.url),
77
+ duration: Duration.format(Duration.fromInputUnsafe(duration))
78
+ }))
79
+ });
80
+ }))
81
+ });
82
+
83
+ //#endregion
84
+ export { TimeoutError, timeout };
package/package.json CHANGED
@@ -1,6 +1,91 @@
1
1
  {
2
- "name": "@monarcode/effetch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@monarcode/effetch",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Typed, schema-driven HTTP calls on Effect v4's HttpClient: path params, object queries, decoded responses and plugins",
5
+ "keywords": [
6
+ "effect",
7
+ "effect-ts",
8
+ "fetch",
9
+ "http",
10
+ "http-client",
11
+ "react-native",
12
+ "schema"
13
+ ],
14
+ "homepage": "https://github.com/monarcode/effetch#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/monarcode/effetch/issues"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/monarcode/effetch.git"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE",
27
+ "CHANGELOG.md"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./plugins": {
39
+ "types": "./dist/plugins/index.d.ts",
40
+ "default": "./dist/plugins/index.js"
41
+ },
42
+ "./package.json": "./package.json"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org/"
47
+ },
48
+ "scripts": {
49
+ "prepare": "husky",
50
+ "lint": "oxlint .",
51
+ "lint:fix": "oxlint --fix .",
52
+ "lint:effect": "effect-language-service diagnostics --project tsconfig.json --format text",
53
+ "format": "oxfmt --write .",
54
+ "format:check": "oxfmt --check .",
55
+ "build": "tsdown",
56
+ "dev": "tsdown --watch",
57
+ "typecheck": "tsc --noEmit",
58
+ "check:docs": "node scripts/check-docs.mjs",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "check:package": "publint --strict && attw --pack . --profile esm-only",
62
+ "smoke": "bash scripts/smoke.sh",
63
+ "check": "pnpm run lint && pnpm run format:check && pnpm run typecheck && pnpm run check:docs && pnpm run lint:effect && pnpm test && pnpm run build && pnpm run check:package && pnpm run smoke",
64
+ "prepublishOnly": "pnpm run check"
65
+ },
66
+ "devDependencies": {
67
+ "@arethetypeswrong/cli": "0.18.5",
68
+ "@effect/language-service": "0.87.4",
69
+ "@effect/vitest": "4.0.2",
70
+ "@types/node": "26.6.5",
71
+ "effect": "4.0.2",
72
+ "husky": "9.1.7",
73
+ "oxfmt": "0.72.0",
74
+ "oxlint": "1.87.0",
75
+ "publint": "0.3.25",
76
+ "tsdown": "0.23.1",
77
+ "typescript": "6.0.3",
78
+ "vitest": "5.0.3"
79
+ },
80
+ "peerDependencies": {
81
+ "effect": "^4.0.0"
82
+ },
83
+ "devEngines": {
84
+ "runtime": {
85
+ "name": "node",
86
+ "version": ">=24",
87
+ "onFail": "error"
88
+ }
89
+ },
90
+ "packageManager": "pnpm@10.12.1"
91
+ }