Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions fetch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# fetch

Effection-native fetch with structured concurrency and streaming response support.

---

## Installation

```bash
npm install @effectionx/fetch effection
```

## Usage

```ts
import { main } from "effection";
import { fetch } from "@effectionx/fetch";

await main(function* () {
let users = yield* fetch("https://api.example.com/users").json();
console.log(users);
});
```

### Fluent API

Chain methods directly on `fetch()` for concise one-liners:

```ts
// JSON
let data = yield* fetch("https://api.example.com/users").json();

// Text
let html = yield* fetch("https://example.com").text();

// With validation - throws HttpError on non-2xx
let data = yield* fetch("https://api.example.com/users").expect().json();
```

### Traditional API

You can also get the response first, then consume the body:

```ts
let response = yield* fetch("https://api.example.com/users");
let data = yield* response.json();
```

### Streaming response bodies

```ts
import { each } from "effection";
import { fetch } from "@effectionx/fetch";

function* example() {
for (let chunk of yield* each(fetch("https://example.com/large-file.bin").body())) {
console.log(chunk.length);
yield* each.next();
}
}
```

### Concurrent requests

```ts
import { all } from "effection";
import { fetch } from "@effectionx/fetch";

function* fetchMultiple() {
let [users, posts, comments] = yield* all([
fetch("https://api.example.com/users").json(),
fetch("https://api.example.com/posts").json(),
fetch("https://api.example.com/comments").json(),
]);

return { users, posts, comments };
}
```

### Validate JSON while parsing

```ts
import { fetch } from "@effectionx/fetch";

interface User {
id: string;
name: string;
}

function parseUser(value: unknown): User {
if (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
) {
return value as User;
}

throw new Error("invalid user payload");
}

function* getUser() {
return yield* fetch("https://api.example.com/user").json(parseUser);
}
```

### Handle non-2xx responses

```ts
import { HttpError, fetch } from "@effectionx/fetch";

function* getUser(id: string) {
try {
return yield* fetch(`https://api.example.com/users/${id}`).expect().json();
} catch (error) {
if (error instanceof HttpError) {
console.error(error.status, error.statusText);
}
throw error;
}
}
```

## API

### `fetch(input, init?)`

Returns a `FetchOperation` that supports both fluent chaining and traditional usage.

- `input` - URL string, `URL` object, or `Request` object
- `init` - Optional `FetchInit` options (same as `RequestInit` but without `signal`)

Cancellation is handled automatically via Effection's structured concurrency. When the
scope exits, the request is aborted. The `signal` option is intentionally omitted since
Effection manages cancellation for you.

### `FetchOperation`

Chainable fetch operation returned by `fetch()`.

- `json<T>()`, `json<T>(parse)` - parse response as JSON
- `text()` - get response as text
- `arrayBuffer()` - get response as ArrayBuffer
- `blob()` - get response as Blob
- `formData()` - get response as FormData
- `body()` - stream response body as `Stream<Uint8Array, void>`
- `expect()` - returns a new `FetchOperation` that throws `HttpError` on non-2xx

Can also be yielded directly to get a `FetchResponse`:

```ts
let response = yield* fetch("https://api.example.com/users");
```

### `FetchResponse`

Effection wrapper around native `Response` with operation-based body readers.

- `json<T>()`, `json<T>(parse)`
- `text()`
- `arrayBuffer()`
- `blob()`
- `formData()`
- `body(): Stream<Uint8Array, void>`
- `expect()` - throws `HttpError` for non-2xx responses
- `raw` - access the underlying native `Response`
200 changes: 200 additions & 0 deletions fetch/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { beforeEach, describe, it } from "@effectionx/bdd";
import { expect } from "expect";

import {
type IncomingMessage,
type ServerResponse,
createServer,
} from "node:http";
import {
Err,
Ok,
type Operation,
type Result,
call,
each,
ensure,
withResolvers,
} from "effection";

import { HttpError, fetch } from "./fetch.ts";

function box<T>(content: () => Operation<T>): Operation<Result<T>> {
return {
*[Symbol.iterator]() {
try {
return Ok(yield* content());
} catch (error) {
return Err(error as Error);
}
},
};
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper functions should be at the bottom because the test module should get to the test as the highest priority.


describe("fetch()", () => {
let url: string;

beforeEach(function* () {
let server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (req.url === "/json") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: 1, title: "do things" }));
return;
}

if (req.url === "/text") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("hello");
return;
}

if (req.url === "/stream") {
res.writeHead(200, { "Content-Type": "application/octet-stream" });
res.write("chunk-1");
res.write("chunk-2");
res.end("chunk-3");
return;
}

res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
});

let ready = withResolvers<void>();
server.listen(0, () => ready.resolve());
yield* ready.operation;

let addr = server.address();
let port = typeof addr === "object" && addr ? addr.port : 0;

url = `http://localhost:${port}`;
yield* ensure(() =>
call(() => new Promise<void>((resolve) => server.close(() => resolve()))),
);
});

describe("traditional API", () => {
it("reads JSON responses", function* () {
let response = yield* fetch(`${url}/json`);
let data = yield* response.json<{ id: number; title: string }>();

expect(data).toEqual({ id: 1, title: "do things" });
});

it("supports parser-based json()", function* () {
let response = yield* fetch(`${url}/json`);
let data = yield* response.json((value) => {
if (
typeof value !== "object" ||
value === null ||
!("id" in value) ||
!("title" in value)
) {
throw new Error("invalid payload");
}

return { id: value.id as number, title: value.title as string };
});

expect(data).toEqual({ id: 1, title: "do things" });
});

it("streams response bodies", function* () {
let response = yield* fetch(`${url}/stream`);
let body = response.body();
let decoder = new TextDecoder();
let chunks: string[] = [];

for (let chunk of yield* each(body)) {
chunks.push(decoder.decode(chunk, { stream: true }));
yield* each.next();
}

chunks.push(decoder.decode());
expect(chunks.join("")).toEqual("chunk-1chunk-2chunk-3");
});

it("throws HttpError for expect() when response is not ok", function* () {
let response = yield* fetch(`${url}/missing`);
let result = yield* box(() => response.expect());

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(HttpError);
expect(result.error).toMatchObject({
status: 404,
statusText: "Not Found",
});
}
});
});

describe("fluent API", () => {
it("reads JSON with fetch().json()", function* () {
let data = yield* fetch(`${url}/json`).json<{
id: number;
title: string;
}>();

expect(data).toEqual({ id: 1, title: "do things" });
});

it("reads text with fetch().text()", function* () {
let text = yield* fetch(`${url}/text`).text();

expect(text).toEqual("hello");
});

it("supports parser with fetch().json(parse)", function* () {
let data = yield* fetch(`${url}/json`).json((value) => {
if (
typeof value !== "object" ||
value === null ||
!("id" in value) ||
!("title" in value)
) {
throw new Error("invalid payload");
}

return { id: value.id as number, title: value.title as string };
});

expect(data).toEqual({ id: 1, title: "do things" });
});

it("streams response bodies with fetch().body()", function* () {
let body = fetch(`${url}/stream`).body();
let decoder = new TextDecoder();
let chunks: string[] = [];

for (let chunk of yield* each(body)) {
chunks.push(decoder.decode(chunk, { stream: true }));
yield* each.next();
}

chunks.push(decoder.decode());
expect(chunks.join("")).toEqual("chunk-1chunk-2chunk-3");
});

it("throws HttpError with fetch().expect().json()", function* () {
let result = yield* box(() => fetch(`${url}/missing`).expect().json());

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(HttpError);
expect(result.error).toMatchObject({
status: 404,
statusText: "Not Found",
});
}
});

it("chains expect() before json() successfully", function* () {
let data = yield* fetch(`${url}/json`)
.expect()
.json<{ id: number; title: string }>();

expect(data).toEqual({ id: 1, title: "do things" });
});
});
});
Loading
Loading