API reference@evolu/commonHttp › fetch

Call Signature

function fetch(
  input: RequestInfo | URL,
  mode: "text",
  init?: Omit<RequestInit, "signal">,
): Task<string, FetchError>;

Defined in: packages/common/src/Http.ts:374

Fetches a resource and consumes the Response inside the Task, so the body is read while the request signal is still alive.

The request runs through run.deps.nativeFetch. Because native fetch is a default dependency, platforms and tests can replace it without changing call sites.

With a FetchMode, non-2xx responses return FetchStatusError (except "headers", which reports status as a value) and unreadable bodies return FetchBodyError. With a FetchConsume callback, native status semantics apply: HTTP error statuses resolve, and the consumer decides how to interpret the status and body.

signal is not accepted in init because abort is controlled by the current Run.

Aborting the Run aborts the underlying request, any response that arrives after abort, and any in-progress body read. Abort is represented as AbortError, not FetchError: run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as an Err.

Some runtimes reject aborted fetches with their own error instead of signal.reason. This wrapper normalizes abort rejections from native fetch, built-in body reads, and consumer callbacks back to the Run's AbortError.

fetch owns request lifetime and Response containment. It does not transform requests or interpret app protocols beyond the built-in modes. Use Task helpers for resilience, app helpers for app conventions, a replacement NativeFetch for request-wide behavior (base URLs, auth, logging), and consumers for response interpretation.

Composing fetch

Resilience is ordinary Task composition: wrap fetch(url, "json") in timeout, then in retry.

import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });

App conventions belong in small app-owned helpers. For example, posting JSON is native init plus two conventions worth centralizing — the content-type header and the stringify:

import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});

Your app's version will grow your conventions — auth, envelopes, error mapping — which is why it belongs to the app, not to fetch.

Intercepting requests

Request-wide behavior belongs to a replacement NativeFetch installed at the composition root. This is the equivalent of interceptors or hooks in libraries that expose client instances.

import { createRun, fetch, type NativeFetch } from "@evolu/common";

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});

Consuming responses

Built-in modes handle common bodies. Specialized response interpretation belongs in a consumer. Typed decoders, response envelopes, streaming, and custom status semantics can be built on top without changing fetch.

import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });

Aborting fetch

Abort follows the standard Task rules: a Fiber from run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as a Result error.

import { AbortError, createRun, fetch, type NativeFetch } from "@evolu/common";

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);

Call Signature

function fetch(
  input: RequestInfo | URL,
  mode: "json",
  init?: Omit<RequestInit, "signal">,
): Task<unknown, FetchError>;

Defined in: packages/common/src/Http.ts:380

Fetches a resource and consumes the Response inside the Task, so the body is read while the request signal is still alive.

The request runs through run.deps.nativeFetch. Because native fetch is a default dependency, platforms and tests can replace it without changing call sites.

With a FetchMode, non-2xx responses return FetchStatusError (except "headers", which reports status as a value) and unreadable bodies return FetchBodyError. With a FetchConsume callback, native status semantics apply: HTTP error statuses resolve, and the consumer decides how to interpret the status and body.

signal is not accepted in init because abort is controlled by the current Run.

Aborting the Run aborts the underlying request, any response that arrives after abort, and any in-progress body read. Abort is represented as AbortError, not FetchError: run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as an Err.

Some runtimes reject aborted fetches with their own error instead of signal.reason. This wrapper normalizes abort rejections from native fetch, built-in body reads, and consumer callbacks back to the Run's AbortError.

fetch owns request lifetime and Response containment. It does not transform requests or interpret app protocols beyond the built-in modes. Use Task helpers for resilience, app helpers for app conventions, a replacement NativeFetch for request-wide behavior (base URLs, auth, logging), and consumers for response interpretation.

Composing fetch

Resilience is ordinary Task composition: wrap fetch(url, "json") in timeout, then in retry.

import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });

App conventions belong in small app-owned helpers. For example, posting JSON is native init plus two conventions worth centralizing — the content-type header and the stringify:

import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});

Your app's version will grow your conventions — auth, envelopes, error mapping — which is why it belongs to the app, not to fetch.

Intercepting requests

Request-wide behavior belongs to a replacement NativeFetch installed at the composition root. This is the equivalent of interceptors or hooks in libraries that expose client instances.

import { createRun, fetch, type NativeFetch } from "@evolu/common";

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});

Consuming responses

Built-in modes handle common bodies. Specialized response interpretation belongs in a consumer. Typed decoders, response envelopes, streaming, and custom status semantics can be built on top without changing fetch.

import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });

Aborting fetch

Abort follows the standard Task rules: a Fiber from run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as a Result error.

import { AbortError, createRun, fetch, type NativeFetch } from "@evolu/common";

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);

Call Signature

function fetch(
  input: RequestInfo | URL,
  mode: "bytes",
  init?: Omit<RequestInit, "signal">,
): Task<Uint8Array<ArrayBuffer>, FetchError>;

Defined in: packages/common/src/Http.ts:386

Fetches a resource and consumes the Response inside the Task, so the body is read while the request signal is still alive.

The request runs through run.deps.nativeFetch. Because native fetch is a default dependency, platforms and tests can replace it without changing call sites.

With a FetchMode, non-2xx responses return FetchStatusError (except "headers", which reports status as a value) and unreadable bodies return FetchBodyError. With a FetchConsume callback, native status semantics apply: HTTP error statuses resolve, and the consumer decides how to interpret the status and body.

signal is not accepted in init because abort is controlled by the current Run.

Aborting the Run aborts the underlying request, any response that arrives after abort, and any in-progress body read. Abort is represented as AbortError, not FetchError: run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as an Err.

Some runtimes reject aborted fetches with their own error instead of signal.reason. This wrapper normalizes abort rejections from native fetch, built-in body reads, and consumer callbacks back to the Run's AbortError.

fetch owns request lifetime and Response containment. It does not transform requests or interpret app protocols beyond the built-in modes. Use Task helpers for resilience, app helpers for app conventions, a replacement NativeFetch for request-wide behavior (base URLs, auth, logging), and consumers for response interpretation.

Composing fetch

Resilience is ordinary Task composition: wrap fetch(url, "json") in timeout, then in retry.

import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });

App conventions belong in small app-owned helpers. For example, posting JSON is native init plus two conventions worth centralizing — the content-type header and the stringify:

import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});

Your app's version will grow your conventions — auth, envelopes, error mapping — which is why it belongs to the app, not to fetch.

Intercepting requests

Request-wide behavior belongs to a replacement NativeFetch installed at the composition root. This is the equivalent of interceptors or hooks in libraries that expose client instances.

import { createRun, fetch, type NativeFetch } from "@evolu/common";

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});

Consuming responses

Built-in modes handle common bodies. Specialized response interpretation belongs in a consumer. Typed decoders, response envelopes, streaming, and custom status semantics can be built on top without changing fetch.

import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });

Aborting fetch

Abort follows the standard Task rules: a Fiber from run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as a Result error.

import { AbortError, createRun, fetch, type NativeFetch } from "@evolu/common";

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);

Call Signature

function fetch(
  input: RequestInfo | URL,
  mode: "headers",
  init?: Omit<RequestInit, "signal">,
): Task<FetchResponse, FetchTransportError>;

Defined in: packages/common/src/Http.ts:392

Fetches a resource and consumes the Response inside the Task, so the body is read while the request signal is still alive.

The request runs through run.deps.nativeFetch. Because native fetch is a default dependency, platforms and tests can replace it without changing call sites.

With a FetchMode, non-2xx responses return FetchStatusError (except "headers", which reports status as a value) and unreadable bodies return FetchBodyError. With a FetchConsume callback, native status semantics apply: HTTP error statuses resolve, and the consumer decides how to interpret the status and body.

signal is not accepted in init because abort is controlled by the current Run.

Aborting the Run aborts the underlying request, any response that arrives after abort, and any in-progress body read. Abort is represented as AbortError, not FetchError: run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as an Err.

Some runtimes reject aborted fetches with their own error instead of signal.reason. This wrapper normalizes abort rejections from native fetch, built-in body reads, and consumer callbacks back to the Run's AbortError.

fetch owns request lifetime and Response containment. It does not transform requests or interpret app protocols beyond the built-in modes. Use Task helpers for resilience, app helpers for app conventions, a replacement NativeFetch for request-wide behavior (base URLs, auth, logging), and consumers for response interpretation.

Composing fetch

Resilience is ordinary Task composition: wrap fetch(url, "json") in timeout, then in retry.

import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });

App conventions belong in small app-owned helpers. For example, posting JSON is native init plus two conventions worth centralizing — the content-type header and the stringify:

import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});

Your app's version will grow your conventions — auth, envelopes, error mapping — which is why it belongs to the app, not to fetch.

Intercepting requests

Request-wide behavior belongs to a replacement NativeFetch installed at the composition root. This is the equivalent of interceptors or hooks in libraries that expose client instances.

import { createRun, fetch, type NativeFetch } from "@evolu/common";

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});

Consuming responses

Built-in modes handle common bodies. Specialized response interpretation belongs in a consumer. Typed decoders, response envelopes, streaming, and custom status semantics can be built on top without changing fetch.

import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });

Aborting fetch

Abort follows the standard Task rules: a Fiber from run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as a Result error.

import { AbortError, createRun, fetch, type NativeFetch } from "@evolu/common";

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);

Call Signature

function fetch<T, E>(
  input: RequestInfo | URL,
  consume: FetchConsume<T, E>,
  init?: Omit<RequestInit, "signal">,
): Task<T, FetchTransportError | E>;

Defined in: packages/common/src/Http.ts:398

Fetches a resource and consumes the Response inside the Task, so the body is read while the request signal is still alive.

The request runs through run.deps.nativeFetch. Because native fetch is a default dependency, platforms and tests can replace it without changing call sites.

With a FetchMode, non-2xx responses return FetchStatusError (except "headers", which reports status as a value) and unreadable bodies return FetchBodyError. With a FetchConsume callback, native status semantics apply: HTTP error statuses resolve, and the consumer decides how to interpret the status and body.

signal is not accepted in init because abort is controlled by the current Run.

Aborting the Run aborts the underlying request, any response that arrives after abort, and any in-progress body read. Abort is represented as AbortError, not FetchError: run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as an Err.

Some runtimes reject aborted fetches with their own error instead of signal.reason. This wrapper normalizes abort rejections from native fetch, built-in body reads, and consumer callbacks back to the Run's AbortError.

fetch owns request lifetime and Response containment. It does not transform requests or interpret app protocols beyond the built-in modes. Use Task helpers for resilience, app helpers for app conventions, a replacement NativeFetch for request-wide behavior (base URLs, auth, logging), and consumers for response interpretation.

Composing fetch

Resilience is ordinary Task composition: wrap fetch(url, "json") in timeout, then in retry.

import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });

App conventions belong in small app-owned helpers. For example, posting JSON is native init plus two conventions worth centralizing — the content-type header and the stringify:

import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});

Your app's version will grow your conventions — auth, envelopes, error mapping — which is why it belongs to the app, not to fetch.

Intercepting requests

Request-wide behavior belongs to a replacement NativeFetch installed at the composition root. This is the equivalent of interceptors or hooks in libraries that expose client instances.

import { createRun, fetch, type NativeFetch } from "@evolu/common";

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});

Consuming responses

Built-in modes handle common bodies. Specialized response interpretation belongs in a consumer. Typed decoders, response envelopes, streaming, and custom status semantics can be built on top without changing fetch.

import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });

Aborting fetch

Abort follows the standard Task rules: a Fiber from run(fetch(...)) rejects with AbortError, and run.abortable(fetch(...)) returns it as a Result error.

import { AbortError, createRun, fetch, type NativeFetch } from "@evolu/common";

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);