Skip to content
Open
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
26 changes: 26 additions & 0 deletions .changeset/v2-consumer-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@developerehsan/api-client': major
---

v2: safer defaults and first-class file transfer, from production consumer reports (#37–#46).

**Breaking**

- **Response cache is off by default** (#40). `cache.enabled` was `true` with a 60s `cache-first` TTL, which served stale reads under an app's own query layer after a write. Pass `cache: { enabled: true }` to keep it.
- **Unsafe methods are no longer retried** (#42). Only `GET`/`HEAD`/`OPTIONS`/`TRACE`/`PUT`/`DELETE` retry by default — a failed `POST` may have already committed server-side. Opt back in with `retry: { retryUnsafeMethods: true }`.
- **Next.js server-context helpers moved to `@developerehsan/api-client/next`** (#41). The root entry no longer references `next/headers`, which was breaking Vite `optimizeDeps` in projects without Next.js installed.

**Added**

- `http.idempotency` — one auto-generated `Idempotency-Key` per logical call, replayed across its retries, making a retried write collapsible server-side (#42).
- `errorEnvelope` — teach the classifier a nested error shape so `error.message` is the server's message rather than a stringified payload (#39).
- `paginate` / `paginateItems` / `collectPages` — drive a cursor to exhaustion as an `AsyncIterable`, with same-cursor and `maxPages` guards (#43).
- `createIndexedDbStore({ crossTab: true })` — cross-tab cache invalidation over `BroadcastChannel`, matching the Redis store's `crossInstance` (#46).
- `FormData` bodies over the direct client and the RPC bridge, via `createRpcRouteHandler({ multipart: true })` (#38).
- Binary/file responses through the RPC bridge, via a server-declared `binary` allowlist on `createRpcHandler` (#37). Client-supplied `responseType` still cannot force it.
- `openapi` is now optional on `GlobalConfig` (#45).

**Fixed**

- A configured `Content-Type: application/json` no longer corrupts a `FormData` body by suppressing the multipart boundary (#38).
- Removed a stale "stubbed with TODOs" note from `createClient`'s shipped JSDoc that contradicted the README (#44).
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ coverage/
.DS_Store
*.tsbuildinfo
.pnpm-store/

# Serena MCP server workspace
.serena/
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ so they run with **no backend setup**.
## Guides by topic

### Foundations
- [Migrating to v2](./migrating-v2.md) — three changed defaults, and everything new
- [Getting started](./getting-started.md) — your first client in 5 minutes
- [Mental model](./mental-model.md) — the three concepts and the request pipeline
- [Modules & methods](./modules-and-methods.md) — `defineModule`, `ctx.request`, composed calls
Expand Down
178 changes: 178 additions & 0 deletions docs/migrating-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Migrating to v2

v2 is a small release with a sharp edge: three defaults changed, and all three
changed because the old default was the unsafe one. Everything else is additive.

Every change below came from [issues #37–#46](https://github.com/developerEhsan/api-client/issues),
reported by a production consumer.

## Breaking changes

### 1. The response cache is off by default (#40)

**Before:** `cache.enabled` defaulted to `true` with a 60s `cache-first` TTL.
**Now:** it defaults to `false`.

A write would commit, your query layer would refetch, and the client's own
cache would still serve the 60s-old response underneath it — a stale read with
nothing in the UI to explain it. Apps pairing this client with TanStack Query
or SWR were silently double-cached.

```diff
createClient({
baseURL,
+ cache: { enabled: true }, // only if you actually want the L1 cache
cache: { ttl: 60_000, strategy: 'cache-first' },
})
```

Nothing to do if you were already passing `cache: { enabled: false }`, or if
you never wanted the cache.

### 2. Unsafe HTTP methods are no longer retried (#42)

**Before:** all 5xx/429/network failures retried, on any method.
**Now:** only `GET`, `HEAD`, `OPTIONS`, `TRACE`, `PUT` and `DELETE` retry.

A `POST` that fails with a 5xx or a dropped connection may have already
committed server-side. Retrying it creates the resource twice.

To keep the old behavior on a write, opt in — and send an idempotency key so
the duplicate is collapsed rather than committed:

```ts
createClient({
baseURL,
http: {
retry: { attempts: 3, retryUnsafeMethods: true },
idempotency: { enabled: true }, // Idempotency-Key on POST + PATCH
},
})
```

One key is generated per logical call and replayed across every retry of that
call. Your API has to honor the header for it to mean anything — the client
only guarantees the key is stable.

A custom `retryOn` predicate still overrides this entirely; it is you stating
the policy yourself.

### 3. Next.js helpers moved to `/next` (#41)

`readServerHeader`, `readServerCookie`, `serverTenantResolver` and
`serverTokenFromCookie` are no longer exported from the root entry.

```diff
- import { serverTokenFromCookie } from '@developerehsan/api-client'
+ import { serverTokenFromCookie } from '@developerehsan/api-client/next'
```

They reach for `next/headers`, and having them on the root entry meant every
consumer's bundler tried to resolve `next` — which broke Vite dev servers in
projects with no Next.js installed. The root entry now contains no reference to
`next` at all, so the `optimizeDeps.exclude` workaround is no longer needed.

## New

### `openapi` is optional (#45)

```diff
- createClient({ baseURL, openapi: {} })
+ createClient({ baseURL })
```

Every field inside it was already optional; requiring the block bought no type
safety.

### Custom error envelopes (#39)

The classifier only recognized a flat `{ code, message }`. A nested envelope
fell through to `rawBody`, so `error.message` came out as a stringified JSON
blob — in practice, raw JSON rendered into user-facing toasts.

```ts
createClient({
baseURL,
// API returns { error: { code, message, details } }
errorEnvelope: (body) => (body as { error?: ServerErrorBody }).error ?? null,
})
```

Return `null` to fall back to the built-in detection. A parser that throws is
caught — it can never mask the original failure.

### Cursor pagination (#43)

```ts
import { paginate, paginateItems, collectPages } from '@developerehsan/api-client'

for await (const flag of paginateItems(
(cursor) => api.flags.list({ query: { cursor, limit: 100 } }),
{ nextCursor: (p) => p.nextCursor, items: (p) => p.data },
)) { /* ... */ }
```

`paginate` yields pages, `paginateItems` yields items, `collectPages` drains
into an array. A server that echoes the same cursor back terminates the loop
rather than spinning, and `maxPages` (default 10,000) backstops the rest.

### Cross-tab cache invalidation for IndexedDB (#46)

The browser-side L2 store now has the same cross-instance coherency the Redis
store got, over `BroadcastChannel`:

```ts
createIndexedDbStore({ crossTab: true })
```

A mutation in one tab now invalidates the cached GET in every other open tab.
No-ops where `BroadcastChannel` is unavailable.

### `FormData` bodies (#38)

Multipart bodies now work on both paths.

**Direct client** — a `FormData` body is passed through untouched, and a
globally-configured `Content-Type: application/json` is dropped for it so the
runtime can generate the multipart boundary:

```ts
await api.files.upload(formData)
```

**RPC bridge** — `httpTransport` detects `File`/`Blob` arguments and switches
that call to a multipart body: the JSON envelope travels in an `__rpc` part
with `{ __rpcFile }` placeholders, the bytes as their own parts. Opt the route
in:

```ts
createRpcRouteHandler(handler, {
multipart: true,
maxUploadBytes: 10 * 1024 * 1024, // default 10 MB
})
```

Off by default: multipart widens the accepted content types, and such a body is
not covered by the JSON-content-type half of the CSRF defence — it leans
entirely on the Origin check. Keep `csrf: true` when enabling it.

### Binary responses through the RPC bridge (#37)

A module method can now return a file through the bridge. **The server
declares which methods are binary** — a client-supplied `responseType` is still
stripped by `sanitizePerCall` (S4), so the browser can never turn a JSON method
into a byte stream:

```ts
createRpcHandler(api, {
expose: { exports: ['flagsCsv', 'logsCsv'] },
binary: { exports: ['flagsCsv', 'logsCsv'] }, // must be in BOTH
binaryFilename: (call) => `${call.method}-${Date.now()}.csv`,
})
```

The route streams the result back with its own `Content-Type` and
`Content-Disposition` instead of wrapping it in the JSON envelope, and
`httpTransport` surfaces it to the caller as a `Blob`. Filenames are sanitized
against header injection. A binary method inside a *batch* is refused — a batch
is one JSON array, with nowhere for a byte stream to go.
58 changes: 54 additions & 4 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ export const api = createClient({
openapi: { mode: 'runtime' },
auth: { strategy: 'bearer', getToken: () => localStorage.getItem('access_token') },
http: { timeout: 10_000, retry: { attempts: 3 } },
cache: { strategy: 'stale-while-revalidate', ttl: 60_000 },
// Caching is OFF unless you ask for it — see "Defaults worth knowing" below.
cache: { enabled: true, strategy: 'stale-while-revalidate', ttl: 60_000 },
modules: {
users: defineModule({
methods: {
Expand All @@ -80,16 +81,62 @@ const made = await api.users.create({ name: 'Ada', email: 'ada@x.com' })
That is a fully working client with auth, caching, retries, timeouts, and dedup
already active.

## Defaults worth knowing

The defaults are deliberately conservative — the client should never silently
change what your app observes.

| Behavior | Default | Why |
| --- | --- | --- |
| **Response cache** | **off** (`cache.enabled: false`) | Most apps already run TanStack Query / SWR. A second cache underneath that one serves a stale read after a write with nothing in the UI to explain it. Opt in with `cache: { enabled: true }` (then `ttl` 60s, `cache-first`). |
| **Retries** | 3 attempts, **safe methods only** | `GET`/`HEAD`/`OPTIONS`/`TRACE`/`PUT`/`DELETE`. A `POST` is never retried automatically: a 5xx or dropped connection can arrive *after* the server committed the write, so a retry would create the resource twice. |
| **Idempotency keys** | off | Turn on with `http: { idempotency: { enabled: true } }` to make retrying a write safe (see below). |
| **Dedup** | on, `GET` only | Identical in-flight requests share one network call. |
| **Timeout** | 10s | Per request, enforced on every adapter. |
| **OpenAPI** | none | `openapi` is optional; omit it entirely unless you use codegen, runtime validation, or drift detection. |

### Retrying writes safely

To retry a `POST`/`PATCH`, opt the method in *and* send an idempotency key so
the server can collapse the duplicate:

```ts
createClient({
baseURL,
http: {
retry: { attempts: 3, retryUnsafeMethods: true },
idempotency: { enabled: true }, // Idempotency-Key, POST + PATCH
},
})
```

One key is generated per logical call and replayed on every retry of that call,
which is what makes the retry safe. Your API has to honor the header.

### Custom error shapes

The classifier recognizes a flat `{ code, message }` body. If your API nests its
errors, teach it the shape — otherwise `error.message` is the whole payload as a
JSON string:

```ts
createClient({
baseURL,
// API returns { error: { code, message, details } }
errorEnvelope: (body) => (body as { error?: ServerErrorBody }).error ?? null,
})
```

## Features

| Area | What you get |
| --- | --- |
| **Typed proxy** | `api.[module].[method](args)` with full input/output inference |
| **Adapters** | Axios (default) or native `fetch`; auto-fallback to `fetch` on edge |
| **Auth** | Bearer, Cookie, API key, OAuth2 (with automatic 401 → refresh → retry) |
| **Caching** | In-memory LRU, TTL, `cache-first` / `network-first` / `stale-while-revalidate`, glob invalidation |
| **Caching** | Opt-in (`cache.enabled`); in-memory LRU, TTL, `cache-first` / `network-first` / `stale-while-revalidate`, glob invalidation |
| **Dedup** | Identical in-flight requests share one network call |
| **Retries** | Exponential/linear/fixed backoff, full-jitter, honors `Retry-After` |
| **Retries** | Exponential/linear/fixed backoff, full-jitter, honors `Retry-After`; safe methods only by default, with optional idempotency keys |
| **Timeouts** | Per-request, enforced on every adapter (incl. `fetch`) |
| **Cancellation** | `AbortSignal` support + debounce-cancel |
| **Concurrency** | Global request queue with a configurable limit |
Expand All @@ -103,7 +150,9 @@ already active.
| **RPC rate limiter** | Built-in `createRateLimiter` for the handler `onRequest` (per-IP / per-session, pluggable store) |
| **Streaming** | `ctx.stream()` → `AsyncIterable` for NDJSON / SSE / raw byte streams |
| **Modules beyond HTTP** | `ctx.run()` runs any async logic with opt-in queue/dedup/retry/timeout; `ctx.emit` / `ctx.logger` / `ctx.config` |
| **Cache persistence** | Pluggable L2 stores (IndexedDB / Redis) behind the in-memory LRU |
| **Cache persistence** | Pluggable L2 stores (IndexedDB / Redis) behind the in-memory LRU; cross-instance invalidation via Redis pub/sub or `BroadcastChannel` across tabs |
| **Pagination** | `paginate` / `paginateItems` / `collectPages` drive a cursor to exhaustion as an `AsyncIterable` |
| **File transfer** | `FormData` bodies and binary (`Blob`/stream) responses, over the direct client *and* the RPC bridge |
| **Hooks** | `onRequest`/`onResponse`/`onError`/`onRetry`/`onSuccess`/`onSettled` composed across global → module → per-call |
| **Testing** | `createMockClient` + `MockAdapter` |

Expand All @@ -116,6 +165,7 @@ already active.
| `@developerehsan/api-client/testing` | Mock client & adapter |
| `@developerehsan/api-client/server` | SSR RPC bridge — server half (`createRpcHandler`, batching, `createRateLimiter`, Next / TanStack Start / Remix glue) |
| `@developerehsan/api-client/browser` | SSR RPC bridge — dependency-free browser client (`createRpcClient`, transports, batching) |
| `@developerehsan/api-client/next` | Next.js server-context helpers (`serverTokenFromCookie`, `serverTenantResolver`) — the only entry that touches `next/headers` |
| `@developerehsan/api-client/cache-stores` | Pluggable persistent cache stores (memory / IndexedDB / Redis) |

## Related packages
Expand Down
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@
"import": "./dist/server.js",
"require": "./dist/server.cjs"
},
"./next": {
"types": "./dist/next.d.ts",
"import": "./dist/next.js",
"require": "./dist/next.cjs"
},
"./browser": {
"types": "./dist/browser.d.ts",
"browser": "./dist/browser.js",
Expand Down
Loading
Loading