Skip to content

Commit 2fea161

Browse files
authored
feat(fetch): add Effection-native fetch package (#150)
* feat(fetch): add Effection-native fetch package with streaming responses * docs(fetch): add concurrent requests example using all() * feat(fetch): add fluent API with chainable methods - fetch().json(), fetch().text(), fetch().body() for single yield* - fetch().expect().json() for validation before consumption - Rename ensureOk() to expect() - Remove clone() method - Update tests and README with fluent API examples * refactor(fetch): remove signal option, use Effection scope for cancellation - Add FetchInit type (Omit<RequestInit, 'signal'> & { signal?: never }) - Remove signal merging logic, always use Effection scope signal - Remove signal-related test - Update README to clarify cancellation is via structured concurrency * refactor: address PR feedback - use box(), remove manual body guard - Replace captureError() with box() for error testing - Remove 'prevents consuming body twice' test (let native Response handle it) - Add comprehensive JSDoc documentation to all exports - Remove manual consumed tracking and guardBody() - Remove signal from options (use Effection scope signal only)
1 parent 7a0e46d commit 2fea161

File tree

10 files changed

+730
-0
lines changed

10 files changed

+730
-0
lines changed

fetch/README.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# fetch
2+
3+
Effection-native fetch with structured concurrency and streaming response support.
4+
5+
---
6+
7+
## Installation
8+
9+
```bash
10+
npm install @effectionx/fetch effection
11+
```
12+
13+
## Usage
14+
15+
```ts
16+
import { main } from "effection";
17+
import { fetch } from "@effectionx/fetch";
18+
19+
await main(function* () {
20+
let users = yield* fetch("https://api.example.com/users").json();
21+
console.log(users);
22+
});
23+
```
24+
25+
### Fluent API
26+
27+
Chain methods directly on `fetch()` for concise one-liners:
28+
29+
```ts
30+
// JSON
31+
let data = yield* fetch("https://api.example.com/users").json();
32+
33+
// Text
34+
let html = yield* fetch("https://example.com").text();
35+
36+
// With validation - throws HttpError on non-2xx
37+
let data = yield* fetch("https://api.example.com/users").expect().json();
38+
```
39+
40+
### Traditional API
41+
42+
You can also get the response first, then consume the body:
43+
44+
```ts
45+
let response = yield* fetch("https://api.example.com/users");
46+
let data = yield* response.json();
47+
```
48+
49+
### Streaming response bodies
50+
51+
```ts
52+
import { each } from "effection";
53+
import { fetch } from "@effectionx/fetch";
54+
55+
function* example() {
56+
for (let chunk of yield* each(fetch("https://example.com/large-file.bin").body())) {
57+
console.log(chunk.length);
58+
yield* each.next();
59+
}
60+
}
61+
```
62+
63+
### Concurrent requests
64+
65+
```ts
66+
import { all } from "effection";
67+
import { fetch } from "@effectionx/fetch";
68+
69+
function* fetchMultiple() {
70+
let [users, posts, comments] = yield* all([
71+
fetch("https://api.example.com/users").json(),
72+
fetch("https://api.example.com/posts").json(),
73+
fetch("https://api.example.com/comments").json(),
74+
]);
75+
76+
return { users, posts, comments };
77+
}
78+
```
79+
80+
### Validate JSON while parsing
81+
82+
```ts
83+
import { fetch } from "@effectionx/fetch";
84+
85+
interface User {
86+
id: string;
87+
name: string;
88+
}
89+
90+
function parseUser(value: unknown): User {
91+
if (
92+
typeof value === "object" &&
93+
value !== null &&
94+
"id" in value &&
95+
"name" in value
96+
) {
97+
return value as User;
98+
}
99+
100+
throw new Error("invalid user payload");
101+
}
102+
103+
function* getUser() {
104+
return yield* fetch("https://api.example.com/user").json(parseUser);
105+
}
106+
```
107+
108+
### Handle non-2xx responses
109+
110+
```ts
111+
import { HttpError, fetch } from "@effectionx/fetch";
112+
113+
function* getUser(id: string) {
114+
try {
115+
return yield* fetch(`https://api.example.com/users/${id}`).expect().json();
116+
} catch (error) {
117+
if (error instanceof HttpError) {
118+
console.error(error.status, error.statusText);
119+
}
120+
throw error;
121+
}
122+
}
123+
```
124+
125+
## API
126+
127+
### `fetch(input, init?)`
128+
129+
Returns a `FetchOperation` that supports both fluent chaining and traditional usage.
130+
131+
- `input` - URL string, `URL` object, or `Request` object
132+
- `init` - Optional `FetchInit` options (same as `RequestInit` but without `signal`)
133+
134+
Cancellation is handled automatically via Effection's structured concurrency. When the
135+
scope exits, the request is aborted. The `signal` option is intentionally omitted since
136+
Effection manages cancellation for you.
137+
138+
### `FetchOperation`
139+
140+
Chainable fetch operation returned by `fetch()`.
141+
142+
- `json<T>()`, `json<T>(parse)` - parse response as JSON
143+
- `text()` - get response as text
144+
- `arrayBuffer()` - get response as ArrayBuffer
145+
- `blob()` - get response as Blob
146+
- `formData()` - get response as FormData
147+
- `body()` - stream response body as `Stream<Uint8Array, void>`
148+
- `expect()` - returns a new `FetchOperation` that throws `HttpError` on non-2xx
149+
150+
Can also be yielded directly to get a `FetchResponse`:
151+
152+
```ts
153+
let response = yield* fetch("https://api.example.com/users");
154+
```
155+
156+
### `FetchResponse`
157+
158+
Effection wrapper around native `Response` with operation-based body readers.
159+
160+
- `json<T>()`, `json<T>(parse)`
161+
- `text()`
162+
- `arrayBuffer()`
163+
- `blob()`
164+
- `formData()`
165+
- `body(): Stream<Uint8Array, void>`
166+
- `expect()` - throws `HttpError` for non-2xx responses
167+
- `raw` - access the underlying native `Response`

fetch/fetch.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { beforeEach, describe, it } from "@effectionx/bdd";
2+
import { expect } from "expect";
3+
4+
import {
5+
type IncomingMessage,
6+
type ServerResponse,
7+
createServer,
8+
} from "node:http";
9+
import {
10+
Err,
11+
Ok,
12+
type Operation,
13+
type Result,
14+
call,
15+
each,
16+
ensure,
17+
withResolvers,
18+
} from "effection";
19+
20+
import { HttpError, fetch } from "./fetch.ts";
21+
22+
function box<T>(content: () => Operation<T>): Operation<Result<T>> {
23+
return {
24+
*[Symbol.iterator]() {
25+
try {
26+
return Ok(yield* content());
27+
} catch (error) {
28+
return Err(error as Error);
29+
}
30+
},
31+
};
32+
}
33+
34+
describe("fetch()", () => {
35+
let url: string;
36+
37+
beforeEach(function* () {
38+
let server = createServer((req: IncomingMessage, res: ServerResponse) => {
39+
if (req.url === "/json") {
40+
res.writeHead(200, { "Content-Type": "application/json" });
41+
res.end(JSON.stringify({ id: 1, title: "do things" }));
42+
return;
43+
}
44+
45+
if (req.url === "/text") {
46+
res.writeHead(200, { "Content-Type": "text/plain" });
47+
res.end("hello");
48+
return;
49+
}
50+
51+
if (req.url === "/stream") {
52+
res.writeHead(200, { "Content-Type": "application/octet-stream" });
53+
res.write("chunk-1");
54+
res.write("chunk-2");
55+
res.end("chunk-3");
56+
return;
57+
}
58+
59+
res.writeHead(404, { "Content-Type": "text/plain" });
60+
res.end("not found");
61+
});
62+
63+
let ready = withResolvers<void>();
64+
server.listen(0, () => ready.resolve());
65+
yield* ready.operation;
66+
67+
let addr = server.address();
68+
let port = typeof addr === "object" && addr ? addr.port : 0;
69+
70+
url = `http://localhost:${port}`;
71+
yield* ensure(() =>
72+
call(() => new Promise<void>((resolve) => server.close(() => resolve()))),
73+
);
74+
});
75+
76+
describe("traditional API", () => {
77+
it("reads JSON responses", function* () {
78+
let response = yield* fetch(`${url}/json`);
79+
let data = yield* response.json<{ id: number; title: string }>();
80+
81+
expect(data).toEqual({ id: 1, title: "do things" });
82+
});
83+
84+
it("supports parser-based json()", function* () {
85+
let response = yield* fetch(`${url}/json`);
86+
let data = yield* response.json((value) => {
87+
if (
88+
typeof value !== "object" ||
89+
value === null ||
90+
!("id" in value) ||
91+
!("title" in value)
92+
) {
93+
throw new Error("invalid payload");
94+
}
95+
96+
return { id: value.id as number, title: value.title as string };
97+
});
98+
99+
expect(data).toEqual({ id: 1, title: "do things" });
100+
});
101+
102+
it("streams response bodies", function* () {
103+
let response = yield* fetch(`${url}/stream`);
104+
let body = response.body();
105+
let decoder = new TextDecoder();
106+
let chunks: string[] = [];
107+
108+
for (let chunk of yield* each(body)) {
109+
chunks.push(decoder.decode(chunk, { stream: true }));
110+
yield* each.next();
111+
}
112+
113+
chunks.push(decoder.decode());
114+
expect(chunks.join("")).toEqual("chunk-1chunk-2chunk-3");
115+
});
116+
117+
it("throws HttpError for expect() when response is not ok", function* () {
118+
let response = yield* fetch(`${url}/missing`);
119+
let result = yield* box(() => response.expect());
120+
121+
expect(result.ok).toBe(false);
122+
if (!result.ok) {
123+
expect(result.error).toBeInstanceOf(HttpError);
124+
expect(result.error).toMatchObject({
125+
status: 404,
126+
statusText: "Not Found",
127+
});
128+
}
129+
});
130+
});
131+
132+
describe("fluent API", () => {
133+
it("reads JSON with fetch().json()", function* () {
134+
let data = yield* fetch(`${url}/json`).json<{
135+
id: number;
136+
title: string;
137+
}>();
138+
139+
expect(data).toEqual({ id: 1, title: "do things" });
140+
});
141+
142+
it("reads text with fetch().text()", function* () {
143+
let text = yield* fetch(`${url}/text`).text();
144+
145+
expect(text).toEqual("hello");
146+
});
147+
148+
it("supports parser with fetch().json(parse)", function* () {
149+
let data = yield* fetch(`${url}/json`).json((value) => {
150+
if (
151+
typeof value !== "object" ||
152+
value === null ||
153+
!("id" in value) ||
154+
!("title" in value)
155+
) {
156+
throw new Error("invalid payload");
157+
}
158+
159+
return { id: value.id as number, title: value.title as string };
160+
});
161+
162+
expect(data).toEqual({ id: 1, title: "do things" });
163+
});
164+
165+
it("streams response bodies with fetch().body()", function* () {
166+
let body = fetch(`${url}/stream`).body();
167+
let decoder = new TextDecoder();
168+
let chunks: string[] = [];
169+
170+
for (let chunk of yield* each(body)) {
171+
chunks.push(decoder.decode(chunk, { stream: true }));
172+
yield* each.next();
173+
}
174+
175+
chunks.push(decoder.decode());
176+
expect(chunks.join("")).toEqual("chunk-1chunk-2chunk-3");
177+
});
178+
179+
it("throws HttpError with fetch().expect().json()", function* () {
180+
let result = yield* box(() => fetch(`${url}/missing`).expect().json());
181+
182+
expect(result.ok).toBe(false);
183+
if (!result.ok) {
184+
expect(result.error).toBeInstanceOf(HttpError);
185+
expect(result.error).toMatchObject({
186+
status: 404,
187+
statusText: "Not Found",
188+
});
189+
}
190+
});
191+
192+
it("chains expect() before json() successfully", function* () {
193+
let data = yield* fetch(`${url}/json`)
194+
.expect()
195+
.json<{ id: number; title: string }>();
196+
197+
expect(data).toEqual({ id: 1, title: "do things" });
198+
});
199+
});
200+
});

0 commit comments

Comments
 (0)