Skip to content

Commit da377a6

Browse files
Merge pull request #29 from hamdymohamedak/main
Enhance client debug pipeline and secure supply chain features
2 parents d4982e1 + 6f6bbbe commit da377a6

2 files changed

Lines changed: 154 additions & 7 deletions

File tree

README.md

Lines changed: 153 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88

99
# @hamdymohamedak/openfetch
1010

11-
A small, dependency-free HTTP client for JavaScript runtimes that expose the standard [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API. It supports instances with defaults, request and response interceptors, HTTP verb helpers, optional request/response transforms, composable middleware, retries, and in-memory caching—without legacy browser-only globals.
11+
A small, dependency-free HTTP client for JavaScript runtimes that expose the standard [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API. It supports instances with defaults, request and response interceptors, HTTP verb helpers, optional request/response transforms, composable middleware, retries, structured debug logging, optional JSON validation ([Standard Schema](https://github.com/standard-schema/standard-schema)), and in-memory caching—without legacy browser-only globals.
1212

1313
**What you get**
1414

15+
- **ESM-only** — use `import` / `import type`; there is no CommonJS build. **1.x** follows [semantic versioning](https://semver.org/).
1516
- One transport: `fetch` only (Node 18+, Bun, Deno, Cloudflare Workers, browsers).
1617
- No polyfills required for supported environments.
1718
- Safe for server rendering and React Server Components: no `window`, `document`, `localStorage`, or framework coupling.
@@ -20,6 +21,8 @@ A small, dependency-free HTTP client for JavaScript runtimes that expose the sta
2021

2122
```bash
2223
npm install @hamdymohamedak/openfetch
24+
# or pin the stable major line:
25+
npm install @hamdymohamedak/openfetch@^1
2326
```
2427

2528
## Quick start
@@ -47,14 +50,14 @@ const users = await api.get("/v1/users");
4750
|------|---------|
4851
| Instances | `createClient()` / `create()` with mutable `defaults` |
4952
| HTTP verbs | `request`, `get`, `post`, `put`, `patch`, `delete`, `head`, `options` |
50-
| Config | `baseURL`, `params`, `headers`, `timeout`, `signal`, `data` / `body`, `auth`, `responseType`, `rawResponse`, `validateStatus` |
53+
| Config | `baseURL`, `params`, `headers`, `timeout`, `signal`, `data` / `body`, `auth`, `responseType`, `rawResponse`, `validateStatus`, `throwHttpErrors`, `jsonSchema` (Standard Schema), `init` hooks, `debug` / `logger` (pipeline events), `assertSafeUrl`, `unwrapResponse`, plus `RequestInit` fields (`credentials`, `redirect`, …) |
5154
| Interceptors | Request and response stacks (documented call order) |
5255
| Middleware | Async `use()` hooks wrapping the fetch adapter |
53-
| Errors | `OpenFetchError` with `toShape()` / `toJSON()` for structured logging |
54-
| Retry | `createRetryMiddleware()` — backoff, `timeoutTotalMs` / `timeoutPerAttemptMs`, idempotent POST key |
56+
| Errors | `OpenFetchError` with `toShape()` / `toJSON()`; `SchemaValidationError` when `jsonSchema` fails |
57+
| Retry | `createRetryMiddleware()` — backoff, `timeoutTotalMs` / `timeoutPerAttemptMs`, idempotent POST key; `OpenFetchForceRetry` from `hooks({ onAfterResponse })` to force another attempt |
5558
| Cache | `MemoryCacheStore` + `createCacheMiddleware()` (TTL, optional stale-while-revalidate) |
56-
| Plugins | `retry({ attempts })`, `timeout(ms)`, `hooks(...)`, `debug({ maskStrategy: 'partial' \| 'hash', … })`, `strictFetch()` |
57-
| Fluent API | `createFluentClient()` — lazy chain; **each** `.json()` / `.raw()` / … runs **one** request unless you use `.memo()`; `.raw()``Response` |
59+
| Plugins | `retry({ attempts, … })`, `timeout(ms)`, `hooks({ onBeforeRetry, onAfterResponse, … })`, `debug({ maskStrategy: 'partial' \| 'hash', … })`, `strictFetch()` |
60+
| Fluent API | `createFluentClient()` — lazy chain; **each** `.json()` / `.json(schema)` / `.raw()` / … runs **one** request unless you use `.memo()`; `.raw()``Response` |
5861

5962
Subpath imports (tree-shaking): `@hamdymohamedak/openfetch/plugins`, `@hamdymohamedak/openfetch/sugar`.
6063

@@ -134,6 +137,150 @@ For URLs influenced by untrusted input, either call `assertSafeHttpUrl(url)` bef
134137
## Documentation
135138

136139
- **Guide (VitePress):** [openfetch-js.github.io/openfetch-docs/](https://openfetch-js.github.io/openfetch-docs/)
140+
- **Changelog:** [CHANGELOG.md](https://github.com/openfetch-js/OpenFetch/blob/main/CHANGELOG.md)
141+
- **Security:** [SECURITY.md](https://github.com/openfetch-js/OpenFetch/blob/main/SECURITY.md)
142+
- **Claude Code:** `claude plugin marketplace add openfetch-js/OpenFetch`, then `claude plugin install openfetch@openfetch-js`. Published skill plugin: [openFetchSkill — README](https://github.com/openfetch-js/openFetchSkill/blob/main/README.md).
143+
- **Skill folder template (this monorepo):** [examples/claude-skill](https://github.com/openfetch-js/OpenFetch/tree/main/examples/claude-skill) — layout reference; see [examples/README.md](https://github.com/openfetch-js/OpenFetch/blob/main/examples/README.md).
144+
- **Contributing:** [CONTRIBUTING.md](https://github.com/openfetch-js/OpenFetch/blob/main/CONTRIBUTING.md)
145+
146+
## Requirements
147+
148+
- Node.js **18** or newer (or any runtime with `fetch` and `AbortController`).
149+
150+
## License
151+
152+
MIT
153+
154+
A small, dependency-free HTTP client for JavaScript runtimes that expose the standard [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API. It supports instances with defaults, request and response interceptors, HTTP verb helpers, optional request/response transforms, composable middleware, retries, structured debug logging, optional JSON validation ([Standard Schema](https://github.com/standard-schema/standard-schema)), and in-memory caching—without legacy browser-only globals.
155+
156+
**What you get**
157+
158+
- **ESM-only** — use `import` / `import type`; there is no CommonJS build. **1.x** follows [semantic versioning](https://semver.org/).
159+
- One transport: `fetch` only (Node 18+, Bun, Deno, Cloudflare Workers, browsers).
160+
- No polyfills required for supported environments.
161+
- Safe for server rendering and React Server Components: no `window`, `document`, `localStorage`, or framework coupling.
162+
163+
## Installation
164+
165+
```bash
166+
npm install @hamdymohamedak/openfetch
167+
# or pin the stable major line:
168+
npm install @hamdymohamedak/openfetch@^1
169+
```
170+
171+
## Quick start
172+
173+
```ts
174+
import openFetch, { createClient } from "@hamdymohamedak/openfetch";
175+
176+
const { data, status, headers } = await openFetch.get(
177+
"https://api.example.com/v1/users"
178+
);
179+
180+
const api = createClient({
181+
baseURL: "https://api.example.com",
182+
headers: { Authorization: "Bearer <token>" },
183+
timeout: 10_000,
184+
unwrapResponse: true,
185+
});
186+
187+
const users = await api.get("/v1/users");
188+
```
189+
190+
## Features
191+
192+
| Area | Details |
193+
|------|---------|
194+
| Instances | `createClient()` / `create()` with mutable `defaults` |
195+
| HTTP verbs | `request`, `get`, `post`, `put`, `patch`, `delete`, `head`, `options` |
196+
| Config | `baseURL`, `params`, `headers`, `timeout`, `signal`, `data` / `body`, `auth`, `responseType`, `rawResponse`, `validateStatus`, `throwHttpErrors`, `jsonSchema` (Standard Schema), `init` hooks, `debug` / `logger` (pipeline events), `assertSafeUrl`, `unwrapResponse`, plus `RequestInit` fields (`credentials`, `redirect`, …) |
197+
| Interceptors | Request and response stacks (documented call order) |
198+
| Middleware | Async `use()` hooks wrapping the fetch adapter |
199+
| Errors | `OpenFetchError` with `toShape()` / `toJSON()`; `SchemaValidationError` when `jsonSchema` fails |
200+
| Retry | `createRetryMiddleware()` — backoff, `timeoutTotalMs` / `timeoutPerAttemptMs`, idempotent POST key; `OpenFetchForceRetry` from `hooks({ onAfterResponse })` to force another attempt |
201+
| Cache | `MemoryCacheStore` + `createCacheMiddleware()` (TTL, optional stale-while-revalidate) |
202+
| Plugins | `retry({ attempts, … })`, `timeout(ms)`, `hooks({ onBeforeRetry, onAfterResponse, … })`, `debug({ maskStrategy: 'partial' \| 'hash', … })`, `strictFetch()` |
203+
| Fluent API | `createFluentClient()` — lazy chain; **each** `.json()` / `.json(schema)` / `.raw()` / … runs **one** request unless you use `.memo()`; `.raw()``Response` |
204+
205+
Subpath imports (tree-shaking): `@hamdymohamedak/openfetch/plugins`, `@hamdymohamedak/openfetch/sugar`.
206+
207+
```ts
208+
import { createFluentClient, retry, timeout } from "@hamdymohamedak/openfetch";
209+
210+
const client = createFluentClient({ baseURL: "https://api.example.com" })
211+
.use(retry({ attempts: 3 }))
212+
.use(timeout(5000));
213+
214+
const data = await client("/v1/user").json<{ id: string }>();
215+
216+
// Native Response (unread body); second terminal = second HTTP call
217+
const res = await client("/v1/export").get().raw();
218+
const blob = await res.blob();
219+
220+
// One HTTP round-trip, then parse as JSON and as text (body buffered once — not HTTP caching)
221+
const memoed = client("/v1/profile").get().memo();
222+
const profile = await memoed.json();
223+
const rawText = await memoed.text();
224+
```
225+
226+
Register **`retry` before `timeout`** so retries wrap the full inner stack. Use **interceptors** to mutate config/response; use **`hooks`** for side-effect logging around the middleware pipeline.
227+
228+
**Fluent:** `.get()` / `.post()` only build config. **Each terminal** (`.json()`, `.text()`, `.send()`, `.raw()`, …) triggers a **new** `fetch` unless the chain used **`.memo()`** (request-level memoization: one `fetch`, body read once into memory). For two reads of the same native `Response`, use **`cloneResponse(res)`** from the package exports (or `.clone()` on the `Response`).
229+
230+
**`rawResponse` / `.raw()`:** the adapter does **not** read the body and skips **`transformResponse`**. Client **response interceptors** still run (`data` is the native `Response`). Middleware that expects parsed `ctx.response.data` will not see transforms until you parse yourself.
231+
232+
**Retry timing:** `retry.timeoutTotalMs` measures elapsed time with a monotonic clock (`performance.now()` when available), so the budget is not skewed by system clock changes. By default (`retry.enforceTotalTimeout !== false`), each attempt merges a deadline into the request `signal` so an in-flight `fetch` aborts when the budget runs out (`ERR_RETRY_TIMEOUT`). Set `retry.enforceTotalTimeout: false` to enforce the budget only between attempts. `retry.timeoutPerAttemptMs` sets `timeout` for every attempt inside the retry middleware. Each `dispatch` uses `clearTimeout` in a `finally` block so per-attempt timers are not left dangling.
233+
234+
**Debug:** Default logs omit request headers. Logged URLs **redact common sensitive query parameters** (`token`, `code`, `password`, …); set `maskUrlQuery: false` to log raw URLs (avoid in production). Use `debug({ includeRequestHeaders: true, maskHeaders: ["authorization"], maskStrategy: "partial" })` for values like `Bearer ****abcd`, or `maskStrategy: "hash"` for a short fingerprint. **`maskHeaderValues`** supports the same strategies when building your own logs.
235+
236+
### Execution model
237+
238+
Understanding order helps avoid surprises with retries, timeouts, and escape hatches.
239+
240+
1. **Request interceptors** run on the merged config (mutations apply to the in-flight request).
241+
2. **Middleware stack** runs in registration order: the **first** `use()` is the **outer** shell; its `next()` enters the next middleware, and the **last** middleware’s `next()` runs the built-in handler that calls **`dispatch`** (`fetch` + parse, unless `rawResponse`).
242+
3. **Inside `dispatch`:** `transformRequest``fetch` → (optional body parse) → **`transformResponse`** (skipped when `rawResponse`).
243+
4. **Response interceptors** run on the `OpenFetchResponse` (for `rawResponse`, `data` is still a native `Response`).
244+
5. **Retry** (`createRetryMiddleware` / `retry()`): each retry calls `next()` again, so middleware **below** retry in the stack runs **once per attempt**; middleware **above** retry wraps the whole loop (one outer enter/exit per logical request).
245+
6. **Terminal methods** (fluent `.json()`, `.text()`, client `.get()`, …) each start a **new** pipeline invocation unless you used **`.memo()`** on that chain.
246+
247+
**Backoff:** between retries, the retry middleware sleeps with jitter; if the request **`signal`** aborts during that wait, the loop stops (`ERR_CANCELED`).
248+
249+
### Memory cache and authentication
250+
251+
The default cache key is ``METHOD fullUrl``. The first request with **`Authorization` or `Cookie`** and no `varyHeaderNames` / custom `key` triggers a **one-time `console.warn`** (suppress with `suppressAuthCacheKeyWarning: true` if you only cache public data). For **authenticated or per-user** GETs, also pass header names that affect the response so entries do not leak across users:
252+
253+
```ts
254+
createCacheMiddleware(store, {
255+
ttlMs: 60_000,
256+
varyHeaderNames: ["authorization", "cookie"],
257+
});
258+
```
259+
260+
Or build a custom `key` and use `appendCacheKeyVaryHeaders` from the package exports. See [SECURITY.md](https://github.com/openfetch-js/OpenFetch/blob/main/SECURITY.md).
261+
262+
### Retries and POST/PUT
263+
264+
By default, retries after network failures or retryable HTTP statuses run only for **GET**, **HEAD**, **OPTIONS**, and **TRACE**. To retry mutating methods, set `retry: { retryNonIdempotentMethods: true }` (per client or per request).
265+
266+
When `retryNonIdempotentMethods` is true and `maxAttempts > 1`, **POST** requests automatically receive a stable **`Idempotency-Key`** header (if you did not set one) so retries share the same key (Stripe-style deduplication). Opt out with `retry: { autoIdempotencyKey: false }`. You can still set `Idempotency-Key` / `idempotency-key` yourself; it will be respected.
267+
268+
If the request `signal` is aborted (`AbortController.abort()`), the retry middleware stops: no more `fetch` attempts, and backoff ends early when a signal is linked.
269+
270+
For low-level access without consuming the body in openFetch, set `rawResponse: true` on a request or use fluent `.raw()`.
271+
272+
### Optional URL guard (server-side)
273+
274+
For URLs influenced by untrusted input, either call `assertSafeHttpUrl(url)` before requesting or enable **`assertSafeUrl: true`** on the client (defaults or per request). That blocks literal private/loopback IPs for `http:`/`https:` on the fully resolved URL; it does not fix DNS rebinding — see [SECURITY.md](https://github.com/openfetch-js/OpenFetch/blob/main/SECURITY.md).
275+
276+
### Errors and logging
277+
278+
`OpenFetchError.toShape()` / `toJSON()` omit `config.auth` and, **by default**, omit response **`data`** and **`headers`**; pass `includeResponseData: true` / `includeResponseHeaders: true` when you need them for trusted diagnostics. By default the serialized `url` **redacts common sensitive query parameters**; pass `redactSensitiveUrlQuery: false` only in trusted environments. The error instance itself can still hold full `config`; do not expose it raw.
279+
280+
## Documentation
281+
282+
- **Guide (VitePress):** [openfetch-js.github.io/openfetch-docs/](https://openfetch-js.github.io/openfetch-docs/)
283+
- **Changelog:** [CHANGELOG.md](https://github.com/openfetch-js/OpenFetch/blob/main/CHANGELOG.md)
137284
- **Security:** [SECURITY.md](https://github.com/openfetch-js/OpenFetch/blob/main/SECURITY.md)
138285
- **Claude Code:** `claude plugin marketplace add openfetch-js/OpenFetch`, then `claude plugin install openfetch@openfetch-js`. Published skill plugin: [openFetchSkill — README](https://github.com/openfetch-js/openFetchSkill/blob/main/README.md).
139286
- **Skill folder template (this monorepo):** [examples/claude-skill](https://github.com/openfetch-js/OpenFetch/tree/main/examples/claude-skill) — layout reference; see [examples/README.md](https://github.com/openfetch-js/OpenFetch/blob/main/examples/README.md).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hamdymohamedak/openfetch",
3-
"version": "0.2.9",
3+
"version": "1.0.0",
44
"description": "OpenFetch is a lean, TypeScript-first HTTP client on the native Fetch API—middleware and interceptors instead of a monolithic core, with plug-in retries, timeouts, lifecycle hooks, and structured debug logging. Optional in-memory caching, fluent chaining via the sugar export, and small utilities for safer URLs, idempotency keys, and redacted logs (headers and query strings) keep requests observable without leaking secrets. ESM-only, zero runtime dependencies, tree-shakeable exports; runs on Node 18+, browsers, Bun, Deno, and edge runtimes (e.g. Cloudflare Workers), with a design that plays well with Server Components",
55
"type": "module",
66
"sideEffects": false,

0 commit comments

Comments
 (0)