API reference › @evolu/common › Http › 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);