From 7a6818604bd5d64c8fca56bd691c77c3e61ada05 Mon Sep 17 00:00:00 2001
From: developerehsan
Date: Wed, 12 Aug 2026 13:44:37 +0500
Subject: [PATCH 1/2] docs: JSDoc @example coverage, new feature demos, docs
depth pass
Implements docs/plan/11-docs-jsdoc-examples-overhaul.md: adds @example
blocks to public exports and config fields (core + tanstack-query) with
GitHub-linked @see references, adds example demos for previously
undemonstrated features (concurrency queue, multi-tenancy, streaming,
cache persistence, timeouts/cancellation, manual types, RPC rate
limiting, environment switching, live codegen), and deepens six thin
docs pages with source-verified worked examples.
Co-Authored-By: Claude Sonnet 5
---
docs/concurrency-queue.md | 104 +++++++-
docs/deduplication.md | 86 +++++-
docs/environments.md | 105 +++++++-
docs/hooks-and-events.md | 124 ++++++++-
docs/plan/11-docs-jsdoc-examples-overhaul.md | 247 ++++++++++++++++++
docs/plan/README.md | 1 +
docs/retries.md | 121 +++++++--
docs/rpc-rate-limiting.md | 132 ++++++++--
examples/nextjs/README.md | 34 ++-
examples/nextjs/app/ProductDemo.tsx | 10 +-
examples/nextjs/app/api/env-edge/route.ts | 38 +++
examples/nextjs/app/api/env-node/route.ts | 36 +++
.../app/api/rpc-rate-limit-demo/route.ts | 12 +
.../app/environments/EnvironmentDemo.tsx | 79 ++++++
examples/nextjs/app/environments/page.tsx | 5 +
.../nextjs/app/rate-limit/RateLimitDemo.tsx | 78 ++++++
examples/nextjs/app/rate-limit/page.tsx | 5 +
examples/nextjs/lib/api/api.config.ts | 21 ++
.../nextjs/lib/api/rate-limit-demo-client.ts | 15 ++
examples/nextjs/package.json | 3 +-
examples/react-vite/package.json | 7 +-
examples/react-vite/src/App.tsx | 29 +-
.../src/features/CachePersistenceDemo.tsx | 93 +++++++
.../src/features/ConcurrencyQueueDemo.tsx | 119 +++++++++
.../src/features/ManualTypesDemo.test.ts | 84 ++++++
.../src/features/ManualTypesDemo.tsx | 95 +++++++
.../src/features/MultiTenancyDemo.tsx | 101 +++++++
.../react-vite/src/features/StreamingDemo.tsx | 125 +++++++++
.../src/features/TimeoutsCancellationDemo.tsx | 112 ++++++++
.../lib/api/types/generated/api.modules.ts | 2 +-
.../src/lib/api/types/generated/api.rpc.ts | 2 +-
.../src/lib/api/types/generated/api.types.ts | 2 +-
examples/react-vite/vite.config.ts | 36 ++-
packages/core/src/auth/authManager.ts | 25 ++
packages/core/src/codegen/moduleEmitter.ts | 10 +
packages/core/src/codegen/schemaValidator.ts | 10 +
packages/core/src/factory/createClient.ts | 11 +
.../core/src/http/adapters/fetchAdapter.ts | 28 +-
packages/core/src/http/streaming.ts | 47 +++-
packages/core/src/runtime/driftDetector.ts | 10 +
packages/core/src/runtime/schemaCache.ts | 15 ++
packages/core/src/runtime/schemaLoader.ts | 20 ++
packages/core/src/types/auth.types.ts | 9 +
packages/core/src/types/config.types.ts | 77 ++++++
packages/core/src/types/environment.types.ts | 28 ++
packages/core/src/types/http.types.ts | 9 +
packages/core/src/types/module.types.ts | 13 +
packages/core/src/types/openapi.types.ts | 13 +
packages/tanstack-query/src/core/queryKeys.ts | 15 +-
packages/tanstack-query/src/core/types.ts | 112 +++++++-
pnpm-lock.yaml | 74 ++++++
51 files changed, 2488 insertions(+), 101 deletions(-)
create mode 100644 docs/plan/11-docs-jsdoc-examples-overhaul.md
create mode 100644 examples/nextjs/app/api/env-edge/route.ts
create mode 100644 examples/nextjs/app/api/env-node/route.ts
create mode 100644 examples/nextjs/app/api/rpc-rate-limit-demo/route.ts
create mode 100644 examples/nextjs/app/environments/EnvironmentDemo.tsx
create mode 100644 examples/nextjs/app/environments/page.tsx
create mode 100644 examples/nextjs/app/rate-limit/RateLimitDemo.tsx
create mode 100644 examples/nextjs/app/rate-limit/page.tsx
create mode 100644 examples/nextjs/lib/api/rate-limit-demo-client.ts
create mode 100644 examples/react-vite/src/features/CachePersistenceDemo.tsx
create mode 100644 examples/react-vite/src/features/ConcurrencyQueueDemo.tsx
create mode 100644 examples/react-vite/src/features/ManualTypesDemo.test.ts
create mode 100644 examples/react-vite/src/features/ManualTypesDemo.tsx
create mode 100644 examples/react-vite/src/features/MultiTenancyDemo.tsx
create mode 100644 examples/react-vite/src/features/StreamingDemo.tsx
create mode 100644 examples/react-vite/src/features/TimeoutsCancellationDemo.tsx
diff --git a/docs/concurrency-queue.md b/docs/concurrency-queue.md
index 7a8750b..bc33cef 100644
--- a/docs/concurrency-queue.md
+++ b/docs/concurrency-queue.md
@@ -2,7 +2,9 @@
[← Docs index](./README.md)
-Limit how many requests are in flight at once (useful against rate limits):
+A browser or server process firing dozens of requests at once can overwhelm a
+backend or trip its rate limiter. The concurrency queue caps how many
+requests are **in flight simultaneously**; the rest wait their turn.
```ts
http: {
@@ -14,19 +16,109 @@ http: {
}
```
-Requests beyond the limit wait their turn. Aborting a queued (not-yet-started)
-request removes it from the queue and rejects it.
+## How it actually schedules (verified against `utilities/queue.ts`)
+
+`createQueue({ concurrency, priority })` keeps a `waiting` array and a
+`running` counter. Whenever a slot frees up, `dispatch()` pulls the next item:
+
+```ts
+const item = priority === 'lifo' ? waiting.pop() : waiting.shift();
+```
+
+- **`'fifo'` (default):** `waiting.shift()` — strict first-in-first-out; the
+ request that queued earliest runs next once a slot opens.
+- **`'lifo'`:** `waiting.pop()` — the *most recently* queued request runs
+ next, ahead of ones that have been waiting longer. Useful when only the
+ newest request's result matters (e.g. a fast-typing search box) and older
+ queued ones are effectively stale.
+- There is **no separate priority level or per-request priority field** —
+ `priority` is a single queue-wide scheduling mode, not a per-call priority
+ you can attach to individual requests. All items in the queue are peers;
+ ordering is purely FIFO or LIFO.
+- Aborting a queued (not-yet-started) request via its `AbortSignal` removes it
+ from `waiting` and rejects it immediately with an `AbortError` — it never
+ occupies a concurrency slot.
+- A per-call `queue: false` bypasses the queue entirely for that request
+ (`createClient.ts`'s `queueForThisCall = resolved.queue ?? queueEnabled`);
+ it runs immediately regardless of how many other requests are in flight.
+
+## Per-module vs global queue
+
+The queue is created **once** per client and shared across all modules and
+even non-HTTP module calls (`createClient.ts`: "Shares the client's queue +
+deduplicator so non-HTTP module work coordinates with HTTP requests") — there
+is no separate queue instance per module. To give one module a different
+effective concurrency, use the per-call/per-method escape hatch instead of
+expecting a second global queue:
+
+```ts
+// Global queue: at most 6 concurrent requests across the WHOLE client.
+const api = createClient({
+ baseURL,
+ http: { queue: { concurrency: 6 } },
+ openapi: { mode: 'runtime' },
+})
+
+// Opt a specific, latency-sensitive call OUT of the shared queue so it never
+// waits behind bulk/background traffic:
+await api.search.query({ q }, undefined, { queue: false })
+```
+
+## Worked example: burst throttling against a rate-limited backend
+
+If a backend enforces, say, 5 concurrent connections per client, set
+`concurrency` to match (or slightly under) that limit so requests queue
+client-side instead of getting rejected server-side:
+
+```ts
+const api = createClient({
+ baseURL: 'https://api.example.com',
+ http: {
+ queue: { enabled: true, concurrency: 5, priority: 'fifo' },
+ retry: { attempts: 3, backoff: 'exponential', baseDelay: 500 }, // see retries.md
+ },
+ openapi: { mode: 'runtime' },
+})
+
+// Firing 50 calls only ever runs 5 at a time; the rest wait in FIFO order.
+await Promise.all(items.map((item) => api.items.sync(item)))
+```
+
+Combine with [deduplication](./deduplication.md) so identical calls inside
+that burst don't each consume a separate queue slot — dedup coalesces them
+into one before the queue/network layer ever sees more than one request for
+the same identity.
**See it live:** the example configures `http.queue.concurrency: 6` —
[`examples/react-vite/src/lib/api/api.config.ts`](../examples/react-vite/src/lib/api/api.config.ts).
-Combined with [deduplication](./deduplication.md), the Feature Lab's "Deduplication
-(6→1)" burst demonstrates how concurrent traffic is managed.
+Combined with deduplication, the Feature Lab's "Deduplication (6→1)" burst
+demonstrates how concurrent traffic is managed.
## Advanced: standalone queue utility
```ts
import { createQueue } from '@developerehsan/api-client'
+
+const queue = createQueue({ concurrency: 3, priority: 'fifo' })
+await queue.add(() => doSomeWork(), { signal: controller.signal })
+queue.size() // tasks waiting, not yet started
+queue.active() // tasks currently running
```
See the [API reference](./api-reference.md#standalone-utilities).
-
+
+## Gotchas / troubleshooting
+
+- **"Setting `priority: 'lifo'` didn't change which request finished first,
+ only which one started next."** Correct — LIFO changes *dispatch order out
+ of the waiting list*, not execution speed. Once running, requests still race
+ independently; LIFO only affects which queued item is pulled next when a
+ slot frees up.
+- **"My per-module concurrency setting seems to affect other modules too."**
+ Expected — there's one shared queue for the whole client, not one per
+ module. Use per-call `{ queue: false }` for one-off exceptions instead.
+- **"Queue never processes my request."** Check `http.queue.enabled` isn't
+ `false` at any layer and that you didn't pass an already-aborted `signal`.
+- Related: [deduplication](./deduplication.md) for the layer checked right
+ after the queue in the pipeline, [retries](./retries.md) for what happens
+ when a queued-and-dispatched request fails.
diff --git a/docs/deduplication.md b/docs/deduplication.md
index e5ca658..ed23895 100644
--- a/docs/deduplication.md
+++ b/docs/deduplication.md
@@ -2,25 +2,81 @@
[← Docs index](./README.md)
-Identical in-flight requests are collapsed into **one** network call; every
-caller receives the same result (or the same error). On by default for `GET`.
+When several callers ask for the same thing at the same time — two components
+mounting and both requesting the current user, a burst of retries hitting the
+same endpoint — deduplication collapses the identical **in-flight** requests
+into **one** network call; every caller receives the same result (or the same
+error). On by default for `GET`.
```ts
http: {
deduplication: true, // default
dedupeMethod: ['GET'], // add 'POST' etc. to dedupe those too
}
+```
+
+## Pipeline order: cache is checked first
+
+The client's `run` closure resolves a response in this order (verified against
+`packages/core/src/factory/createClient.ts`):
+
+1. **Cache lookup** (`produce()`, `createClient.ts:989-991`) — for a `GET`
+ with cache enabled, the cache store is consulted *before* the queue or
+ dedup ever run. A cache hit (under `cache-first`, the default strategy)
+ returns immediately — no queueing, no dedup, no network call.
+2. Only on a cache **miss** (or under `strategy: 'network-first'`) does the
+ request reach `fetchThrough()` (`createClient.ts:930-984`), which wraps the
+ network call as `withQueue(() => withDedup(async () => { ...runNetwork() })`
+ — i.e. **queue, then dedup**, then the actual dispatch/retry/validate cycle.
+3. A successful network response is written back to the cache
+ (write-through) before being returned.
+
+So the full order is **cache → queue → dedup → dispatch(retry) → validate →
+cache write-through**. This resolves an apparent conflict between two
+descriptions elsewhere: cache genuinely sits in front of queue/dedup for a
+single request, but because concurrent *cache misses* all fall through to the
+same `fetchThrough()` call, they still coalesce into one shared in-flight
+network request via dedup — the cache is only checked once per call, not
+re-checked while waiting on that shared promise.
+
+Dedup keys include the **auth fingerprint** and **tenant**, so requests with
+different credentials or tenants are never merged (see
+[authentication](./authentication.md#auth--cachededup-safety)).
+
+## Custom dedup key via `keyResolver` interaction
+
+Dedup itself doesn't take a custom-key option — it derives its key from
+method + identity URL + body + tenant + auth fingerprint
+(`computeDedupeKey`). If you need requests with different query params that
+are *conceptually* the same call to dedup together, normalize the URL/args
+before calling (e.g. sort query params in the descriptor), rather than trying
+to override the dedup key directly — there's no separate dedup `keyResolver`
+the way `cache.keyResolver` exists for cache keys (see
+[caching](./caching.md)).
+
+## Per-call opt-out
-// Opt out for a single call:
+```ts
await api.users.get('42', undefined, { skipDedup: true })
```
-Dedup keys include the **auth fingerprint** and **tenant**, so requests with
-different credentials are never merged.
+Use this when you deliberately want two calls that look identical to hit the
+network independently (e.g. a manual "retry" button that shouldn't just
+attach to the original in-flight promise).
+
+## Opting additional methods in
+
+```ts
+http: {
+ deduplication: true,
+ dedupeMethod: ['GET', 'POST'], // now identical POSTs also coalesce
+}
+```
-Deduplication happens *after* the [cache](./caching.md) lookup, so concurrent
-cache misses still coalesce into a single network round-trip and then populate
-the cache once.
+Only opt a `POST`/`PATCH`/etc. into dedup when it's genuinely idempotent from
+the caller's point of view — two logically-identical mutating calls sharing
+one in-flight promise means the second caller never actually re-triggers the
+side effect, it just observes the first one's result.
## See it live
@@ -28,4 +84,16 @@ The Feature Lab "Deduplication (6→1)" button fires **six** identical requests
once and the live pipeline log shows only **one** `→ request` line — the other
five shared it:
[`examples/react-vite/src/features/FeatureLab.tsx`](../examples/react-vite/src/features/FeatureLab.tsx).
-
+
+## Gotchas / troubleshooting
+
+- **"My dedup isn't merging requests."** Check `dedupeMethod` includes your
+ HTTP method, and that neither call passed `skipDedup: true`. Also confirm
+ both calls resolve to the same auth fingerprint — see
+ [authentication](./authentication.md#auth--cachededup-safety).
+- **"Two different users are seeing merged data."** Shouldn't happen — dedup
+ keys always include the auth fingerprint and tenant. If it does, check that
+ your `getToken`/tenant resolver isn't returning a shared/incorrect value.
+- Related: [caching](./caching.md) for the layer checked before dedup,
+ [concurrency queue](./concurrency-queue.md) for the layer checked between
+ cache and dedup.
diff --git a/docs/environments.md b/docs/environments.md
index 9baf82a..a460d90 100644
--- a/docs/environments.md
+++ b/docs/environments.md
@@ -2,6 +2,13 @@
[← Docs index](./README.md)
+An app typically talks to different hosts across dev/staging/prod, and the
+same client code may run in different JS runtimes (browser, Node, edge) that
+support different adapters. `environments` handles the first; runtime
+detection (automatic, no config) handles the second.
+
+## Configuring named environments
+
```ts
createClient({
environments: {
@@ -21,11 +28,105 @@ api.setEnvironment('staging')
time (fail fast).
- A module can target a different host with `config.baseURL` — see
[modules & methods](./modules-and-methods.md#module-level-configuration).
-- Switching environments **clears the cache**, so stale data from one
+- `setEnvironment(name)` throws a `ConfigurationError` if `name` isn't a key in
+ `environments` (`factory/createClient.ts`'s `setEnvironment`), and on success
+ it clears **the entire cache** (`cacheStore.clear()`) so stale data from one
environment never leaks into another.
+## Runtime detection: `DetectedEnvironment` / `PlatformCapabilities`
+
+Separately from the `environments` map, the client detects *what JS runtime
+it's executing in* (`packages/core/src/environment/detect.ts`,
+`detectEnvironment()`) and uses that to pick an HTTP adapter and decide
+whether tenant-context propagation via `AsyncLocalStorage` is available. The
+shapes, verified against `packages/core/src/types/environment.types.ts`:
+
+```ts
+type Environment = 'browser' | 'node' | 'edge' | 'nextjs-server' | 'nextjs-client';
+
+interface PlatformCapabilities {
+ supportsAxios: boolean; // false on edge runtimes
+ supportsAsyncLocalStorage: boolean; // Node-like server runtimes only
+ hasDom: boolean; // window + document present
+ hasFetch: boolean; // global fetch available
+ hasVisibilityApi: boolean; // document present (visibilitychange)
+}
+
+interface DetectedEnvironment {
+ environment: Environment;
+ capabilities: PlatformCapabilities;
+}
+```
+
+| Field | Type | Meaning |
+| --- | --- | --- |
+| `environment` | `Environment` | Which of the five runtimes was detected |
+| `capabilities.supportsAxios` | `boolean` | Whether the axios adapter can be used |
+| `capabilities.supportsAsyncLocalStorage` | `boolean` | Whether Node's `AsyncLocalStorage` is usable |
+| `capabilities.hasDom` | `boolean` | Whether `window`/`document` are present |
+| `capabilities.hasFetch` | `boolean` | Whether global `fetch` exists |
+| `capabilities.hasVisibilityApi` | `boolean` | Whether `visibilitychange` events are available |
+
+Detection order (from `detectEnvironment`'s own doc comment): **edge → browser
+(DOM present) → node → nextjs-server**. It never throws, and the result is
+memoized at the module level for the life of the process.
+
+## Edge downgrade example
+
+If you explicitly request the axios adapter but the detected runtime is edge
+(`capabilities.supportsAxios === false`), the client silently downgrades to
+`fetch` rather than failing (`environment/edgeSafe.ts`):
+
+```ts
+createClient({
+ baseURL: 'https://api.example.com',
+ http: { adapter: 'axios' }, // requested...
+ openapi: { mode: 'runtime' },
+})
+// ...but on Cloudflare Workers / Vercel Edge, this transparently becomes
+// the fetch adapter — no error, no config change needed on your part.
+```
+
+This is why the CLAUDE.md architecture note describes adapter choice as
+"`fetch` or `axios`, chosen by `environment/` detection (edge downgrades
+axios→fetch)" — there's no separate edge-specific config; it's automatic
+based on `PlatformCapabilities.supportsAxios`.
+
+## Manual `setEnvironment` override
+
+Useful for an admin/debug panel that lets an operator point the same running
+app at a different backend without a reload:
+
+```ts
+const api = createClient({
+ environments: { dev: 'http://localhost:3000', prod: 'https://api.example.com' },
+ activeEnvironment: 'dev',
+ openapi: { mode: 'runtime' },
+})
+
+function switchToProd() {
+ api.setEnvironment('prod') // throws ConfigurationError if 'prod' isn't in the map
+ // any cached responses from 'dev' are now gone — the next call re-fetches
+}
+```
+
**See it live:** the Feature Lab "Environments" button calls
`api.setEnvironment(...)` to switch the active base URL at runtime and shows the
resolved config change —
[`examples/react-vite/src/features/FeatureLab.tsx`](../examples/react-vite/src/features/FeatureLab.tsx).
-
+
+## Gotchas / troubleshooting
+
+- **"`setEnvironment` throws immediately."** The name must already be a key in
+ the `environments` map passed to `createClient` — it can't introduce a new
+ host on the fly.
+- **"All my cached data vanished after switching environments."** Expected —
+ `setEnvironment` clears the whole cache (`cache.clear()` semantics), same as
+ [logout](./authentication.md#logout-clearing-the-cache), to prevent
+ cross-environment data leaking.
+- **"Axios options I set are being ignored on edge."** Check
+ `detectEnvironment().capabilities.supportsAxios` — on edge runtimes it's
+ always `false` and the fetch adapter is used regardless of `http.adapter`.
+- Related: [modules & methods](./modules-and-methods.md#module-level-configuration)
+ for per-module `baseURL` overrides, [caching](./caching.md) for what "clears
+ the cache" actually clears.
diff --git a/docs/hooks-and-events.md b/docs/hooks-and-events.md
index 2f79a55..9ba4a23 100644
--- a/docs/hooks-and-events.md
+++ b/docs/hooks-and-events.md
@@ -2,38 +2,120 @@
[← Docs index](./README.md)
-Two ways to observe and transform the pipeline: **declarative hooks** (in config)
-and an **imperative event emitter** (`api.on` / `api.off`).
+Two ways to observe and transform the pipeline: **declarative hooks** (in
+config, can transform request/response) and an **imperative event emitter**
+(`api.on` / `api.off`, observation only — payloads are read-only, listeners
+can't alter what the caller receives).
## Lifecycle hooks (config)
+`LifecycleHooks` (`packages/core/src/types/config.types.ts`) has exactly
+**8** fields, confirmed by reading the interface:
+
```ts
createClient({
hooks: {
- onRequest: (req) => ({ ...req, headers: { ...req.headers, 'X-Trace': id() } }),
- onResponse: (res) => res,
- onError: (err) => log(err),
- onRetry: (attempt, err) => {},
- onCacheHit: (key, entry) => {},
- onCacheMiss:(key) => {},
+ onRequest: (req) => ({ ...req, headers: { ...req.headers, 'X-Trace': id() } }),
+ onResponse: (res) => res,
+ onError: (err) => log(err),
+ onRetry: (attempt, err) => {},
+ onCacheHit: (key, entry) => {},
+ onCacheMiss: (key) => {},
+ onSuccess: (res) => {},
+ onSettled: (res, err) => {},
},
})
```
-- `onRequest`/`onResponse` may **transform** (and must return) their argument.
-- Hooks compose across **global → module → per-call** layers.
+- `onRequest`/`onResponse` may **transform** (and must return) their argument;
+ when set at multiple layers (global → module → per-call) they **chain** —
+ each hook receives the previous layer's output. Both may be async.
+- `onError` only **observes** — it cannot suppress or replace the thrown
+ error.
+- `onRetry(attempt, error)` fires before each re-attempt with the 1-based
+ attempt number just failed.
+- `onCacheHit(key, entry)` / `onCacheMiss(key)` fire on the cache lookup — see
+ [caching](./caching.md).
+- `onSuccess(response)` fires once a request resolves successfully (from
+ network **or** cache), just before the response reaches the caller — purely
+ observational.
+- `onSettled(response, error)` fires **exactly once** per request regardless
+ of outcome — the `finally` of the request lifecycle
+ (`createClient.ts`'s `run`, in a `finally` block). Exactly one of
+ `response`/`error` is set on success/failure; **both are `undefined` on
+ cancellation** (an aborted request doesn't populate either). It's `await`ed,
+ so an async `onSettled` completes before the request promise itself
+ resolves.
**See it live:** the Next.js example wires `onCacheHit` and `onRetry` hooks —
[`examples/nextjs/lib/api/api.config.ts`](../examples/nextjs/lib/api/api.config.ts).
+## Worked example: one hook per field
+
+```ts
+createClient({
+ baseURL: 'https://api.example.com',
+ openapi: { mode: 'runtime' },
+ hooks: {
+ onRequest: (req) => ({ ...req, headers: { ...req.headers, 'X-Request-Id': crypto.randomUUID() } }),
+ onResponse: (res) => { metrics.timing('http.response', Date.now() - res.headers['x-start']); return res },
+ onError: (err) => Sentry.captureException(err),
+ onRetry: (attempt, err) => logger.warn(`retry ${attempt}`, { status: err.status }),
+ onCacheHit: (key) => metrics.increment('cache.hit'),
+ onCacheMiss: (key) => metrics.increment('cache.miss'),
+ onSuccess: (res) => logger.debug('ok', res.status),
+ onSettled: (res, err) => logger.debug('settled', { ok: !err, status: res?.status }),
+ },
+})
+```
+
+## Worked example: layering hooks (global → module → per-call)
+
+```ts
+createClient({
+ baseURL,
+ hooks: { onRequest: (req) => ({ ...req, headers: { ...req.headers, 'X-Global': '1' } }) },
+ modules: {
+ users: {
+ config: {
+ hooks: { onRequest: (req) => ({ ...req, headers: { ...req.headers, 'X-Module': '1' } }) },
+ },
+ },
+ },
+})
+
+// This call's request ends up with BOTH X-Global and X-Module headers —
+// each layer receives the previous layer's already-transformed request.
+await api.users.get('42', undefined, {
+ hooks: { onRequest: (req) => ({ ...req, headers: { ...req.headers, 'X-Call': '1' } }) },
+})
+```
+
## Event emitter (imperative)
+The emitter fires the same lifecycle moments as an event stream, for code
+that wants to subscribe/unsubscribe dynamically rather than bake hooks into
+config. Verified event names, from every `emit(...)` call site in
+`createClient.ts`:
+
```ts
const handler = (payload) => {}
-api.on('request', handler) // 'request' | 'response' | 'error' | 'cacheHit' | 'cacheMiss'
+api.on('request', handler)
+api.on('response', handler)
+api.on('error', handler)
+api.on('cacheHit', handler)
+api.on('cacheMiss', handler)
+api.on('success', handler)
+api.on('settled', handler) // payload: { response, error } — same both-undefined-on-abort rule as onSettled
api.off('request', handler)
```
+That's 7 named events — one per hook except `onRequest`/`onResponse`, which
+also fire `'request'`/`'response'` events carrying the (already
+hook-transformed) value. Module-scoped work additionally emits
+`module::` internally, but the 7 above are the public,
+documented surface.
+
**See it live:** the React example's `useEventLog` hook subscribes to
`request`/`response`/`error` and mirrors them into the Feature Lab's live log —
this is how the whole demo visualizes the pipeline:
@@ -49,4 +131,22 @@ createClient({ dev: { logging: 'verbose' } }) // true | 'verbose' | false
Both examples enable `dev.logging` so every request/response prints to the
console.
-
+
+## Gotchas / troubleshooting
+
+- **"My `onRequest` hook's changes disappeared."** Make sure you `return` the
+ modified request — returning `void`/`undefined` is treated as "pass through
+ unchanged," not "clear the request."
+- **"`onSettled` gave me `undefined` for both `response` and `error`."**
+ Expected on cancellation (an aborted call) — that's the documented
+ both-undefined case, not a bug.
+- **"`onError` didn't stop the request from throwing."** By design — `onError`
+ is observation-only, it can never suppress the throw. If you need to
+ swallow or transform an error, do it at the call site with try/catch.
+- **`hooks` config vs `api.on`/`api.off`:** hooks compose across
+ global/module/per-call config layers and can transform values; events are a
+ flat, dynamic subscribe/unsubscribe surface and are read-only. Pick hooks
+ for anything that needs to run at a specific config layer or mutate the
+ request/response; pick events for ad hoc logging/telemetry wiring.
+- Related: [caching](./caching.md) for `onCacheHit`/`onCacheMiss` detail,
+ [retries](./retries.md) for `onRetry` timing.
diff --git a/docs/plan/11-docs-jsdoc-examples-overhaul.md b/docs/plan/11-docs-jsdoc-examples-overhaul.md
new file mode 100644
index 0000000..7a2d1a1
--- /dev/null
+++ b/docs/plan/11-docs-jsdoc-examples-overhaul.md
@@ -0,0 +1,247 @@
+# 11 — Docs, JSDoc & Example-App Overhaul
+
+Status: **in progress**. Independent of files `01`-`10` — can run in parallel
+with the caching plan. Grounded by a repo survey performed 2026-08-10;
+re-verify counts before trusting them if this file is picked up much later.
+
+Format: each step is small, has an explicit **Verify** command, and does not
+depend on later steps. Do them in order within a Part; Parts A/B/C are
+independent of each other and may run in parallel.
+
+## Problem statement
+
+1. **JSDoc coverage is inconsistent and thin on examples.** Of 130 exports
+ from `packages/core/src/index.ts`, 109 have a JSDoc block but only 31
+ include an `@example`. `config.types.ts` is worst: 79/127 fields have
+ prose JSDoc, only 2 have `@example` (both on `RetryConfig`).
+2. **Example apps under-demonstrate the feature set.** Not shown anywhere:
+ concurrency-queue, multi-tenancy, streaming, cache-persistence,
+ rpc-rate-limiting, environment switching, timeouts-and-cancellation,
+ modules-beyond-http, manual-types, testing, live codegen run.
+3. **Six docs pages are skeletons** relative to feature surface:
+ `deduplication.md` (31 lines), `environments.md` (31),
+ `concurrency-queue.md` (32), `rpc-rate-limiting.md` (46), `retries.md`
+ (50), `hooks-and-events.md` (52).
+
+## Non-goals
+
+- No new features, no runtime API changes.
+- No renaming/restructuring of `docs/*.md` files.
+
+## Cross-cutting rule
+
+Every new `@example`/snippet must be copy-paste runnable against the
+**current** API — verify field/symbol names against the actual source file
+at write time, never from memory of this plan or old docs.
+
+---
+
+## Part A — JSDoc pass
+
+### Step A1 — Zero-JSDoc exports get a full JSDoc block + `@example`
+Targets: `ClientEventListener`, `createFetchAdapter`
+(`http/adapters/fetchAdapter.ts`), `createAuthManager`/`AuthManager`
+(`auth/authManager.ts`), `AuthStrategyName` (`types/auth.types.ts`),
+`createSchemaCache`/`SchemaCache` (`runtime/schemaCache.ts`),
+`createSchemaLoader`/`SchemaLoader` (`runtime/schemaLoader.ts`),
+`DriftPolicy` (`runtime/driftDetector.ts`), `ValidationResult`
+(`codegen/schemaValidator.ts`), `ResponseType` (`types/http.types.ts`),
+`ModuleMethods` (`types/module.types.ts`), `SchemaAST`
+(`types/openapi.types.ts`), `DetectedEnvironment`/`PlatformCapabilities`
+(`types/environment.types.ts`), `EmitModulesOptions`
+(`codegen/moduleEmitter.ts`), plus whatever `http/streaming.ts` turns out to
+be missing (`parseSse`, `parseNdjson`, `iterateBytes`, `iterateLines`,
+`SseEvent` — unconfirmed by the initial survey, check first).
+**Verify:** `pnpm --filter @developerehsan/api-client build` succeeds (JSDoc
+syntax errors surface as TS parse errors in some setups, but the real check
+is `pnpm --filter @developerehsan/api-client exec tsc --noEmit`) — zero new
+errors vs. baseline.
+
+### Step A2 — `config.types.ts` fields get `@example`
+Every user-facing field in `ClientConfig`/`HttpConfig`/`QueueConfig`/
+`ValidationConfig`/`OpenApiConfig`/`TenancyConfig`/`CancellationConfig`/
+`DevConfig`/`LifecycleHooks`/`GlobalConfig`/`ModuleConfig`/`PerCallConfig`
+gets a one-line `@example` (e.g. `{ cache: { ttl: 5000 } }`). Skip
+`ResolvedRequestConfig`/`ResolvedConfigSnapshot`/internal literal shapes —
+not user-facing.
+**Verify:** `pnpm --filter @developerehsan/api-client exec tsc --noEmit`
+clean; grep count of `@example` in `config.types.ts` increases from 2 to
+match the field count touched (spot check: `grep -c '@example'
+packages/core/src/types/config.types.ts`).
+
+### Step A3 — `@see` cross-links
+Every `@example` added in A1/A2 gets a trailing `@see` line pointing at the
+relevant `docs/*.md` file as a full GitHub URL —
+`{@link https://github.com/developerEhsan/api-client/blob/master/docs/.md}`
+(pinned to `master`, not a relative repo path) — so the link resolves
+correctly from IDE hovers, generated `.d.ts` output, and npm's rendered
+README/docs regardless of the reader's local checkout state. Mirrors
+`packages/tanstack-query/src/core/createIntegration.ts`'s existing JSDoc
+style, updated to the URL form.
+**Verify:** `grep -rL '@see' ` returns empty (every
+touched file has at least one `@see`).
+
+### Step A4 — tanstack-query JSDoc
+`core/types.ts` (24 blocks, 0 `@example`) gets one-liners; `moduleKey`/
+`methodKey` get `@example` each.
+**Verify:** `pnpm --filter @developerehsan/api-client-query exec tsc
+--noEmit` clean.
+
+### Step A5 — Full workspace check
+**Verify:** `pnpm build && pnpm typecheck` clean at the repo root (rebuild
+core first since tanstack-query/examples consume its `dist/`, per
+`CLAUDE.md`'s build-ordering rule).
+
+---
+
+## Part B — Example-app feature completion
+
+Each step adds one new, self-contained demo file plus a short header
+comment linking to its `docs/*.md` page. One step = one file = independently
+verifiable.
+
+### Step B1 — `examples/react-vite/src/ConcurrencyQueueDemo.tsx`
+`queue` config; visualize in-flight vs queued calls. Links to
+`concurrency-queue.md`.
+**Verify:** `pnpm --filter react-vite-example build` (or repo-appropriate
+package name — confirm via `examples/react-vite/package.json` `name` field
+first) succeeds; manually load the demo route/component in dev server and
+confirm no console errors.
+
+### Step B2 — `examples/react-vite/src/MultiTenancyDemo.tsx`
+`tenancy` config, tenant switch, cache isolation. Links to
+`multi-tenancy.md`.
+**Verify:** same build + dev-server smoke check as B1.
+
+### Step B3 — `examples/react-vite/src/StreamingDemo.tsx`
+SSE or NDJSON via `http/streaming.ts` exports. Links to `streaming.md`.
+**Verify:** same build + dev-server smoke check; confirm streamed chunks
+actually render incrementally (not just on completion).
+
+### Step B4 — `examples/react-vite/src/CachePersistenceDemo.tsx`
+IndexedDB L2 store in-browser. Links to `cache-persistence.md`.
+**Verify:** same build + dev-server smoke check; confirm cache survives a
+page reload (IndexedDB persistence is the point of the demo).
+
+### Step B5 — `examples/react-vite/src/TimeoutsCancellationDemo.tsx`
+Abort in-flight request, per-call timeout. Links to
+`timeouts-and-cancellation.md`.
+**Verify:** same build + dev-server smoke check; trigger both an abort and
+a timeout, confirm distinct error types surface (`ApiError` subclasses).
+
+### Step B6 — `examples/react-vite/src/ManualTypesDemo.tsx`
+`createModuleDefiner`/`defineModule` without codegen. Links to
+`manual-types.md`.
+**Verify:** same build + dev-server smoke check; confirm method-name
+autocomplete actually works in the IDE for this file (the point of
+`defineModule` per `CLAUDE.md`).
+
+### Step B7 — Testing demo
+A `*.test.tsx`/`*.test.ts` using the mock client from `testing.md`,
+colocated in `examples/react-vite/src/`.
+**Verify:** `pnpm --filter react-vite-example test` (confirm actual test
+script name first) passes.
+
+### Step B8 — `examples/nextjs` rate-limiting route
+Route/page demonstrating `rpc-rate-limiting.md`'s config on
+`createRpcHandler`, including what the client sees on a 429.
+**Verify:** `pnpm --filter nextjs-example build` succeeds; manually hit the
+route enough times to trigger the 429 and confirm client-side error
+handling.
+
+### Step B9 — `examples/nextjs` environment-switching demo
+Edge vs Node route showing fetch/axios adapter downgrade. Links to
+`environments.md`.
+**Verify:** same Next.js build; confirm both routes actually exercise
+different adapters (log or assert `getSchema()`/adapter identity, don't
+just assume from route config).
+
+### Step B10 — `examples/nextjs` live codegen run
+Script or `package.json` command that runs codegen against
+`lib/api/openapi.json` instead of only shipping pre-generated output;
+document the command in the example's own README.
+**Verify:** run the documented command from a clean checkout of
+`examples/nextjs`, confirm it regenerates `types/generated/` matching what's
+currently committed (no drift) or intentionally update the committed output
+if it doesn't.
+
+### Step B11 — Full example workspace check
+**Verify:** `pnpm build && pnpm typecheck` clean at repo root after all of
+B1-B10.
+
+---
+
+## Part C — Docs depth pass
+
+Each step brings one thin page up to the shape of `cache-persistence.md`/
+`authentication.md` (problem statement → config shape → 2-3 worked examples
+→ gotchas/troubleshooting cross-link). One step = one file.
+
+### Step C1 — `deduplication.md`
+Add: custom dedup key example, per-call opt-out, interaction with cache
+(verify actual pipeline order — queue → dedup → cache per `CLAUDE.md`, but
+confirm against `createClient.ts`'s current `run` closure before writing,
+since this plan's own README notes cache is checked before queue/dedup —
+these two descriptions conflict, resolve by reading the code, not by
+picking one).
+**Verify:** re-read `packages/core/src/factory/createClient.ts`'s `run`
+closure and quote the actual order in the doc; no invented behavior.
+
+### Step C2 — `environments.md`
+Add: `DetectedEnvironment`/`PlatformCapabilities` shape table,
+edge-downgrade example, manual `setEnvironment` override example.
+**Verify:** every field named in the table exists in
+`types/environment.types.ts` (grep to confirm).
+
+### Step C3 — `concurrency-queue.md`
+Add: per-module vs global queue config example, ordering/priority behavior
+if any, worked burst-throttling example.
+**Verify:** ordering/priority claims match actual queue implementation
+behavior — trace it in source, don't assert unverified behavior.
+
+### Step C4 — `rpc-rate-limiting.md`
+Add: full `createRpcHandler` config example with rate-limit option beside
+`expose`, and client-visible behavior on a 429.
+**Verify:** config field names match `server/` source exactly.
+
+### Step C5 — `retries.md`
+Add: backoff strategy table, retry-on-condition example, interaction with
+`AbortSignal`/timeouts.
+**Verify:** backoff formula described matches `RetryConfig`'s actual
+implementation, not a generic assumption.
+
+### Step C6 — `hooks-and-events.md`
+Add: one worked example per `LifecycleHooks` field (confirm exact field
+count/names in `config.types.ts` first — the survey said 8, re-verify).
+**Verify:** every hook name in the doc exists verbatim in
+`LifecycleHooks`.
+
+---
+
+## Final verification (run after all Parts complete)
+
+1. `pnpm build` — clean, all packages.
+2. `pnpm typecheck` — clean, all packages.
+3. `pnpm test` — clean, all packages.
+4. `node scripts/check-browser-bundle.mjs` — still passes (Part B may have
+ touched browser-adjacent example code).
+5. Grep sweep: `grep -rL '@example' packages/core/src/index.ts
+ packages/core/src/server/index.ts packages/core/src/browser/index.ts
+ packages/core/src/codegen/index.ts` — should list zero files, or only
+ files where every individual export inside already has `@example`
+ (grep-by-file is a coarse signal, spot check the actual exports too).
+6. Manual smoke test of each new example demo in a running dev server (not
+ just build success) — a build passing doesn't mean the feature actually
+ renders/works.
+7. Changeset entry added (`.changeset/README.md` convention) noting
+ docs/DX-only change — JSDoc ships in `.d.ts` output so still worth one.
+
+## What "done" looks like
+
+- 100% of public exports across all 4 core subpaths + 3 tanstack-query
+ barrels have JSDoc with ≥1 `@example`.
+- Every user-facing `config.types.ts` field has an inline `@example`.
+- Every feature listed in Part B has a corresponding example file,
+ cross-linked to its doc page, and manually smoke-tested.
+- The 6 thin docs pages match the depth pattern of `cache-persistence.md`.
+- Final verification section above is fully green.
diff --git a/docs/plan/README.md b/docs/plan/README.md
index e77832f..3ab12e6 100644
--- a/docs/plan/README.md
+++ b/docs/plan/README.md
@@ -32,6 +32,7 @@ and proceed; the design intent is what matters, not the exact line.
8. [`08-additional-hardening.md`](./08-additional-hardening.md) — grab-bag of smaller, independent improvements (negative caching, ETag support, metrics hooks, circuit breaker, at-rest encryption, logout-triggered clear, schema-version cache busting, dry-run invalidation).
9. [`09-filesystem-cache-store.md`](./09-filesystem-cache-store.md) — disk-backed `PersistentCacheStore` adapter (Next.js data-cache / nginx `proxy_cache` precedent) for long-running server instances that want to trade RAM for disk; not for serverless/edge.
10. [`10-custom-cache-key-composition.md`](./10-custom-cache-key-composition.md) — `cacheKeyParts` lets a developer add extra key dimensions (e.g. a viewed workspace/user id) on top of the built-in tenant/auth scoping, without the unsafe full-override `keyResolver` has to accept today.
+11. [`11-docs-jsdoc-examples-overhaul.md`](./11-docs-jsdoc-examples-overhaul.md) — independent of 01-10 (can run in parallel): JSDoc `@example` coverage on every public export/config field, example apps demonstrating every currently-undemonstrated feature (queue, multi-tenancy, streaming, cache-persistence, rate-limiting, environments, timeouts, manual-types, testing, codegen), and a depth pass on the 6 thinnest docs pages.
## Already implemented — do not rebuild
diff --git a/docs/retries.md b/docs/retries.md
index dd5c882..b2d25d1 100644
--- a/docs/retries.md
+++ b/docs/retries.md
@@ -2,31 +2,104 @@
[← Docs index](./README.md)
-Failed requests are retried when they are **retryable** (5xx, 429, network, and
-timeout errors by default).
+Transient failures — a flaky network hop, a backend that's momentarily
+overloaded (503), a rate limit (429) — often succeed on a second try. Rather
+than making every caller hand-roll a retry loop, the client retries
+**retryable** failures (5xx, 429, network, and timeout errors by default)
+automatically, with configurable backoff.
+
+## Config shape
```ts
http: {
retry: {
- attempts: 3, // total tries
- backoff: 'exponential', // 'exponential' | 'linear' | 'fixed'
- baseDelay: 500, // ms
- maxDelay: 30_000, // ms — hard ceiling (also caps Retry-After)
- jitter: true, // full-jitter to avoid thundering herds
+ attempts: 3, // total tries, including the first (default 3)
+ backoff: 'exponential', // 'exponential' | 'linear' | 'fixed' (default 'exponential')
+ baseDelay: 500, // ms (default 500)
+ maxDelay: 30_000, // ms — hard ceiling, also caps Retry-After
+ jitter: true, // full-jitter to avoid thundering herds (default true)
retryOn: (error) => error.status === 503, // custom predicate
onRetry: (attempt, error) => console.warn('retry', attempt, error.status),
},
}
```
-- A `429`/`503` with a `Retry-After` header is honored (seconds **or**
- HTTP-date), but never longer than `maxDelay`.
-- Backoff waits are **abort-interruptible** — cancelling stops the wait
- immediately.
-- 4xx (except 401 handled by [auth](./authentication.md)) are **not** retried by
- default.
+## Backoff formula (verified against `utilities/retry.ts`'s `computeBackoff`)
+
+For 1-based retry attempt `n`:
+
+| `backoff` | Delay before capping/jitter |
+| --- | --- |
+| `'exponential'` (default) | `baseDelay * 2 ** (n - 1)` → `500, 1000, 2000, 4000, ...` |
+| `'linear'` | `baseDelay * n` → `500, 1000, 1500, 2000, ...` |
+| `'fixed'` | `baseDelay` every time → `500, 500, 500, ...` |
+
+That raw delay is then:
+
+1. **Capped** at `maxDelay`: `Math.min(delay, maxDelay)`.
+2. **Jittered**, if `jitter` (default `true`): replaced with
+ `Math.random() * cappedDelay` — this is **full jitter** (a random value
+ between `0` and the capped delay), not "cap ± a percentage."
+
+A response's `Retry-After` header (seconds or an HTTP-date, parsed by
+`parseRetryAfter`) **takes priority over the strategy** entirely — when
+present it's used as-is, still capped by `maxDelay`, but never run through the
+backoff formula or jitter:
+
+```ts
+// computeBackoff, simplified:
+if (retryAfterMs !== undefined) return Math.min(retryAfterMs, maxDelay);
+// otherwise: strategy formula -> cap at maxDelay -> jitter if enabled
+```
+
+- Backoff waits are **abort-interruptible** — cancelling the request's
+ `AbortSignal` stops the wait immediately rather than sleeping out the full
+ delay.
+- 4xx errors (except 401, handled by [auth](./authentication.md)'s
+ refresh flow) are **not** retried by default — override with `retryOn` if
+ you have a specific 4xx that's safe to retry.
- Each retry attempt gets a fresh [timeout](./timeouts-and-cancellation.md)
- budget.
+ budget — a slow attempt timing out doesn't shrink the budget available to
+ the next attempt.
+
+## Worked example: custom `retryOn` condition
+
+The default retryable set is 5xx/429/network/timeout. To retry only a
+specific status your backend uses for "try again shortly" (e.g. 425 Too
+Early), or to *narrow* the default set:
+
+```ts
+http: {
+ retry: {
+ attempts: 4,
+ retryOn: (error) => error.status === 425 || error.isRetryable, // custom + the built-in set
+ },
+}
+```
+
+`retryOn` fully replaces the default predicate when supplied — if you want the
+built-ins plus one more case, OR it with `error.isRetryable` as above rather
+than only checking your custom condition.
+
+## Worked example: interaction with `AbortSignal` / timeouts
+
+```ts
+const controller = new AbortController()
+setTimeout(() => controller.abort(), 5000) // give up after 5s total, across all retries
+
+try {
+ await api.reports.generate(payload, undefined, { signal: controller.signal })
+} catch (err) {
+ // If the abort fires mid-backoff-wait, it rejects immediately as an
+ // AbortError rather than completing the sleep and trying again.
+}
+```
+
+Per-call `timeout` is a *per-attempt* budget (a fresh one each retry); the
+`AbortSignal` above is the mechanism for bounding the *entire* multi-attempt
+operation, including time spent sleeping between attempts. Use both together
+when you need "give up on this attempt after Xs" and "give up on the whole
+thing after Ys" to mean different things.
## See it live
@@ -40,11 +113,27 @@ The Next.js example logs every retry via the `onRetry` hook —
## Advanced: standalone retry utility
-For non-pipeline code you can use the exported helper directly:
+For non-pipeline code you can use the exported helpers directly:
```ts
import { withRetry, computeBackoff, parseRetryAfter } from '@developerehsan/api-client'
```
See the [API reference](./api-reference.md#standalone-utilities).
-
+
+## Gotchas / troubleshooting
+
+- **"My retries all happen instantly, no backoff."** Check you didn't set
+ `jitter` in a way that makes delays look small by chance (full jitter can
+ legitimately draw a near-zero delay) — that's expected, not a bug; jitter
+ spreads a *distribution*, it doesn't guarantee a minimum wait.
+- **"A 404 got retried."** It shouldn't by default; check a custom `retryOn`
+ isn't accidentally broader than intended (`retryOn` fully replaces the
+ default predicate).
+- **"Retry-After from the server seems ignored."** It's honored but always
+ capped by `maxDelay` — if `maxDelay` is set low, a large `Retry-After` gets
+ clamped down rather than followed exactly.
+- Related: [rpc-rate-limiting](./rpc-rate-limiting.md) for how a 429 from the
+ RPC bridge specifically is retryable, [timeouts &
+ cancellation](./timeouts-and-cancellation.md) for the per-attempt timeout
+ budget mentioned above.
diff --git a/docs/rpc-rate-limiting.md b/docs/rpc-rate-limiting.md
index fd48e96..5868815 100644
--- a/docs/rpc-rate-limiting.md
+++ b/docs/rpc-rate-limiting.md
@@ -2,33 +2,107 @@
[← Docs index](./README.md)
-The [SSR RPC bridge](./ssr-rpc-bridge.md) handler exposes an `onRequest` hook
-that runs before every dispatch. `createRateLimiter` plugs into it to throttle
-abusive clients — per-IP or per-session, with a pluggable store.
+An [SSR RPC bridge](./ssr-rpc-bridge.md) handler is a single server endpoint
+that fans out to arbitrary backend calls — without a limiter, one abusive
+client (or a runaway retry loop on the frontend) can hammer it with unlimited
+requests. `createRateLimiter` plugs into the handler's `onRequest` hook to
+throttle per-IP or per-session, with a pluggable counter store.
+
+## Config shape (verified against `packages/core/src/server/rateLimit.ts` and `createRpcHandler.ts`)
```ts
import { createRateLimiter, createRpcHandler } from '@developerehsan/api-client/server'
const limiter = createRateLimiter({
- windowMs: 10_000,
- max: 30,
- // Key by session cookie, falling back to a shared bucket:
- keyFor: async (ctx) => (await ctx.getCookie?.('demo_session')) ?? 'anon',
- // For per-IP: use trustProxy + the request's address instead of keyFor.
+ windowMs: 10_000, // required: window length in ms
+ max: 30, // required: max requests per key per window
+ keyFor: async (ctx, call) => (await ctx.getCookie?.('demo_session')) ?? 'anon',
+ trustProxy: false, // default false — only honor X-Forwarded-For behind a trusted proxy
+ store: undefined, // default: bounded in-memory fixed-window store
+ maxKeys: 10_000, // default: LRU bound on the default memory store's distinct keys
})
export const rpcHandler = createRpcHandler(api, {
- expose: { /* ... */ },
+ expose: { pet: ['getPetById', 'findPetsByStatus'] }, // required, deny-by-default
onRequest: limiter.onRequest, // throws to reject over-budget calls
})
```
-- Over budget → a uniform `rate_limited` error (HTTP 429 in the RPC envelope),
- rehydrated as an `ApiError` on the browser.
-- The default store is in-memory; pass a custom store for multi-instance
- deployments.
-- Because it runs per RPC **call**, a batched request is rate-limited per
- sub-call.
+`RateLimiterOptions` fields, exactly as declared:
+
+| Field | Type | Default | Notes |
+| --- | --- | --- | --- |
+| `windowMs` | `number` | required | Fixed window length in ms |
+| `max` | `number` | required | Max requests per key per window |
+| `keyFor` | `(ctx, call) => string \| Promise` | first `X-Forwarded-For` hop if `trustProxy`, else `"global"` | Derives the bucket key |
+| `trustProxy` | `boolean` | `false` | Only honor `X-Forwarded-For` when a trusted proxy sets it — leaving this `false` behind an untrusted edge means one spoofed header can't forge a different bucket |
+| `store` | `RateLimitStore` | bounded in-memory fixed-window store | Swap for Redis etc. for multi-instance deployments |
+| `maxKeys` | `number` | `10_000` | LRU bound on distinct keys held by the *default* memory store |
+
+`createRpcHandler`'s own dispatch order (`createRpcHandler.ts:169`):
+**allowlist (`expose`) → input caps (`maxInputDepth`/`maxInputKeys`) →
+`authorize` → `onRequest` → dispatch → `transformResult`** — so the rate
+limiter (wired as `onRequest`) runs *after* the exposure/authorization checks
+already passed, meaning unauthorized calls are rejected before they ever
+consume rate-limit budget.
+
+## Client-visible behavior on a 429
+
+Over budget, `createRateLimiter`'s `check()` throws `new
+RpcSecurityError('rate_limited', 429)` (`rateLimit.ts:138`) — a uniform,
+sanitized denial that rides the `RpcResponse` envelope as `status: 429`,
+`code: 'rate_limited'`. The `/browser` client rehydrates this into a real
+`ApiError` with `status === 429`:
+
+```ts
+try {
+ await rpcClient.pet.getPetById({ id: 1 })
+} catch (err) {
+ if (err instanceof ApiError && err.status === 429) {
+ // show a "slow down" message; ApiError.isRetryable is true for 429
+ // (errors/ApiError.ts:66), so a retry policy will back off and retry it.
+ }
+}
+```
+
+Because it runs per RPC **call**, not per HTTP request, a single **batched**
+request (multiple calls in one POST) is rate-limited per sub-call — a batch of
+5 calls consumes 5 units of budget, not 1.
+
+## Worked example: per-session vs per-IP
+
+Per-session (cookie-keyed), the default shown above, is the safer default
+when you don't control the proxy layer — it doesn't depend on trusting a
+spoofable header. Per-IP requires an explicit opt-in because the default key
+derivation only reads `X-Forwarded-For` when you assert you're behind a
+trusted proxy that sets it honestly:
+
+```ts
+// Per-IP, ONLY if you terminate a trusted proxy that sets X-Forwarded-For:
+const limiter = createRateLimiter({ windowMs: 10_000, max: 30, trustProxy: true })
+
+// Per-session, no proxy trust required (the default keyFor pattern):
+const limiter = createRateLimiter({
+ windowMs: 60_000,
+ max: 100,
+ keyFor: async (ctx) => (await ctx.getCookie?.('session')) ?? 'anon',
+})
+```
+
+## Worked example: shared store for multi-instance deployments
+
+The default in-memory store is per-process — on multiple server instances
+behind a load balancer, each instance has its own budget, so the effective
+limit is `max * instanceCount`. Pass a `store` backed by something shared
+(e.g. Redis) to enforce one true limit across instances:
+
+```ts
+const limiter = createRateLimiter({
+ windowMs: 60_000,
+ max: 100,
+ store: myRedisBackedStore, // implements RateLimitStore: hit(key, windowMs, now)
+})
+```
**See it live:** the Next.js example wires a 30-calls-per-10s limiter keyed by a
session cookie into the handler's `onRequest` —
@@ -38,9 +112,27 @@ session cookie into the handler's `onRequest` —
| Option | Purpose |
| --- | --- |
+| `expose` | Required deny-by-default allowlist of module/method calls |
| `onRequest` | Where the limiter attaches; also good for logging |
-| `authorize` | Per-call permission (see [SSR RPC bridge](./ssr-rpc-bridge.md#security-model-deny-by-default)) |
-| `maxBatchSize` | Cap how many calls a single batch may carry |
-| `maxInputDepth` / `maxInputKeys` | Input DoS caps |
-| `maxBodyBytes` (route) | Body-size cap on the generic HTTP route |
-
+| `authorize` | Per-call permission (see [SSR RPC bridge](./ssr-rpc-bridge.md#security-model-deny-by-default)) — runs *before* `onRequest` |
+| `maxBatchSize` | Cap how many calls a single batch may carry (default `10`) |
+| `maxInputDepth` / `maxInputKeys` | Input DoS caps (defaults `8` / `1000`), checked before `authorize`/`onRequest` |
+| `maxBodyBytes` (route, `createRpcRouteHandler`) | Body-size cap on the generic HTTP route (default `128 * 1024`) |
+
+## Gotchas / troubleshooting
+
+- **"Rate limit resets seem inconsistent across instances."** The default
+ store is per-process in-memory — pass a shared `store` (Redis-backed) for
+ multi-instance deployments, or accept that the effective limit multiplies
+ by instance count.
+- **"A client behind a corporate proxy shares a budget with others."** Expected
+ with the default `keyFor` when `trustProxy` is `false` — everyone falls into
+ the `"global"` bucket unless you supply a `keyFor` that reads something
+ client-specific (a session cookie, an authenticated user id).
+- **"429s aren't retried automatically."** They are, by default — `ApiError`
+ treats `status === 429` as retryable (`errors/ApiError.ts:66`); check your
+ `http.retry` config on the *client* side (see [retries](./retries.md)) isn't
+ disabling retry or excluding 429 via `retryOn`.
+- Related: [SSR RPC bridge](./ssr-rpc-bridge.md) for the full trust-boundary
+ model this limiter plugs into, [retries](./retries.md) for client-side 429
+ handling.
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 245a5f7..69c7031 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -29,13 +29,15 @@ Open [http://localhost:3000](http://localhost:3000) and keep the **Network tab**
open — the whole point is that client-component calls show only a same-origin
`POST` carrying `{ module, method, args }`, never `dummyjson.com` or any path.
-## The three routes
+## The routes
-| Route | File | What it shows |
-| --------- | ------------------------------------------------ | ---------------------------------------------------------------------- |
-| `/` | [`app/ProductDemo.tsx`](app/ProductDemo.tsx) | The bridge from a **client component** via a Next.js **Server Action** |
-| `/http` | [`app/http/HttpDemo.tsx`](app/http/HttpDemo.tsx) | The **same** bridge via the framework-agnostic `POST /api/rpc` route |
-| `/server` | [`app/server/page.tsx`](app/server/page.tsx) | **Direct** server-side usage (RSC) — no bridge needed, nothing leaks |
+| Route | File | What it shows |
+| -------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| `/` | [`app/ProductDemo.tsx`](app/ProductDemo.tsx) | The bridge from a **client component** via a Next.js **Server Action** |
+| `/http` | [`app/http/HttpDemo.tsx`](app/http/HttpDemo.tsx) | The **same** bridge via the framework-agnostic `POST /api/rpc` route |
+| `/server` | [`app/server/page.tsx`](app/server/page.tsx) | **Direct** server-side usage (RSC) — no bridge needed, nothing leaks |
+| `/rate-limit` | [`app/rate-limit/RateLimitDemo.tsx`](app/rate-limit/RateLimitDemo.tsx) | [RPC rate limiting](../../docs/rpc-rate-limiting.md) — a dedicated 3-calls/10s bridge, client-visible 429 |
+| `/environments`| [`app/environments/EnvironmentDemo.tsx`](app/environments/EnvironmentDemo.tsx) | [Environment detection](../../docs/environments.md) — Node vs Edge, the Axios→fetch adapter downgrade |
## How it fits together
@@ -99,16 +101,26 @@ See the full guarantee table in
3. Toggle **Become editor**, then **Add a product** — now allowed.
4. Click **Fetch #1, #2, #3 together** and watch the Network tab: **one** POST.
5. Visit `/http` for the `httpTransport` variant and `/server` for direct RSC usage.
+6. Visit `/rate-limit` and click **Fire 5 calls** — a dedicated bridge allows
+ only 3 calls/10s, so calls #4-5 come back as a 429 `rate_limited` `ApiError`
+ (see [`docs/rpc-rate-limiting.md`](../../docs/rpc-rate-limiting.md)).
+7. Visit `/environments` and click the button — calls `/api/env-node` and
+ `/api/env-edge` side by side and reports each route's detected environment
+ and the adapter it actually ended up using (see
+ [`docs/environments.md`](../../docs/environments.md)).
## Regenerating types
-The spec lives at [`lib/api/openapi.json`](lib/api/openapi.json):
+The spec lives at [`lib/api/openapi.json`](lib/api/openapi.json). Regenerate
+`lib/api/types/generated/` from it with:
```bash
-npx @developerehsan/api-client generate \
- --input ./lib/api/openapi.json --output ./lib/api/types/generated
+pnpm --filter nextjs generate
```
-This also emits `api.rpc.ts` — the **paths-stripped** descriptor the browser
-bridge + TanStack integration use so no backend path ships to the client.
+(equivalent to `npx developerEhsan-api-client generate --input
+./lib/api/openapi.json --output ./lib/api/types/generated`, wired as the
+`generate` script in this package's `package.json`). This also emits
+`api.rpc.ts` — the **paths-stripped** descriptor the browser bridge +
+TanStack integration use so no backend path ships to the client.
diff --git a/examples/nextjs/app/ProductDemo.tsx b/examples/nextjs/app/ProductDemo.tsx
index db8bac0..1535e9c 100644
--- a/examples/nextjs/app/ProductDemo.tsx
+++ b/examples/nextjs/app/ProductDemo.tsx
@@ -315,7 +315,15 @@ export function ProductDemo() {
/server
{' '}
- for direct server-side usage.
+ for direct server-side usage,{' '}
+
+ /rate-limit
+ {' '}
+ for RPC rate limiting, and{' '}
+
+ /environments
+ {' '}
+ for the edge-vs-node adapter downgrade.
);
diff --git a/examples/nextjs/app/api/env-edge/route.ts b/examples/nextjs/app/api/env-edge/route.ts
new file mode 100644
index 0000000..4c27cf2
--- /dev/null
+++ b/examples/nextjs/app/api/env-edge/route.ts
@@ -0,0 +1,38 @@
+/**
+ * Edge runtime variant of the environment-switching demo.
+ * Docs: https://github.com/developerEhsan/api-client/blob/master/docs/environments.md
+ *
+ * `runtime = 'edge'` runs this route on the Edge runtime, where
+ * `detectEnvironment()` reports `capabilities.supportsAxios: false` — Axios
+ * depends on Node APIs unavailable there. Requesting `http.adapter: 'axios'`
+ * still works, but is silently downgraded to 'fetch' with a `console.warn`
+ * (see `packages/core/src/environment/edgeSafe.ts`'s `resolveAdapterName`).
+ */
+import { createClient, detectEnvironment } from '@developerehsan/api-client';
+
+export const runtime = 'edge';
+
+export async function GET(): Promise {
+ const env = detectEnvironment();
+
+ let warned = false;
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => {
+ warned = true;
+ originalWarn(...args);
+ };
+ try {
+ // Requesting 'axios' explicitly — on edge this gets downgraded to 'fetch'.
+ createClient({ baseURL: 'https://dummyjson.com', openapi: { mode: 'runtime' }, http: { adapter: 'axios' } });
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ return Response.json({
+ route: '/api/env-edge',
+ environment: env.environment,
+ supportsAxios: env.capabilities.supportsAxios,
+ requestedAdapter: 'axios',
+ effectiveAdapter: warned ? 'fetch (downgraded)' : 'axios',
+ });
+}
diff --git a/examples/nextjs/app/api/env-node/route.ts b/examples/nextjs/app/api/env-node/route.ts
new file mode 100644
index 0000000..31534c3
--- /dev/null
+++ b/examples/nextjs/app/api/env-node/route.ts
@@ -0,0 +1,36 @@
+/**
+ * Node runtime variant of the environment-switching demo.
+ * Docs: https://github.com/developerEhsan/api-client/blob/master/docs/environments.md
+ *
+ * `runtime = 'nodejs'` is the Next.js default, but is stated explicitly here
+ * to contrast with `../env-edge/route.ts`'s `runtime = 'edge'`.
+ */
+import { createClient, detectEnvironment } from '@developerehsan/api-client';
+
+export const runtime = 'nodejs';
+
+export async function GET(): Promise {
+ const env = detectEnvironment();
+
+ let warned = false;
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => {
+ warned = true;
+ originalWarn(...args);
+ };
+ try {
+ // Requesting 'axios' explicitly — on Node this is honored as-is.
+ createClient({ baseURL: 'https://dummyjson.com', openapi: { mode: 'runtime' }, http: { adapter: 'axios' } });
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ return Response.json({
+ route: '/api/env-node',
+ environment: env.environment,
+ supportsAxios: env.capabilities.supportsAxios,
+ requestedAdapter: 'axios',
+ // No downgrade warning fired => the requested adapter stayed in effect.
+ effectiveAdapter: warned ? 'fetch (downgraded)' : 'axios',
+ });
+}
diff --git a/examples/nextjs/app/api/rpc-rate-limit-demo/route.ts b/examples/nextjs/app/api/rpc-rate-limit-demo/route.ts
new file mode 100644
index 0000000..4488b76
--- /dev/null
+++ b/examples/nextjs/app/api/rpc-rate-limit-demo/route.ts
@@ -0,0 +1,12 @@
+import { rateLimitDemoHandler } from '@/lib/api/api.config';
+/**
+ * Route for the dedicated rate-limiting demo bridge (3 calls / 10s).
+ * Docs: https://github.com/developerEhsan/api-client/blob/master/docs/rpc-rate-limiting.md
+ */
+import { createRpcRouteHandler } from '@developerehsan/api-client/server';
+
+const handle = createRpcRouteHandler(rateLimitDemoHandler);
+
+export function POST(request: Request): Promise {
+ return handle(request);
+}
diff --git a/examples/nextjs/app/environments/EnvironmentDemo.tsx b/examples/nextjs/app/environments/EnvironmentDemo.tsx
new file mode 100644
index 0000000..dd8bc73
--- /dev/null
+++ b/examples/nextjs/app/environments/EnvironmentDemo.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+/**
+ * ENVIRONMENT SWITCHING DEMO (client shell)
+ * ------------------------------------------
+ * Docs: https://github.com/developerEhsan/api-client/blob/master/docs/environments.md
+ *
+ * Hits two API routes that run the SAME client config (`http.adapter: 'axios'`)
+ * on two different Next.js runtimes:
+ * - `/api/env-node` (default `runtime = 'nodejs'`) → Axios is available,
+ * stays 'axios'.
+ * - `/api/env-edge` (`runtime = 'edge'`) → `detectEnvironment()`
+ * reports `supportsAxios: false`, so the client downgrades the requested
+ * 'axios' adapter to 'fetch' (with a console.warn on the server).
+ *
+ * Each route calls `detectEnvironment()` itself and reports back the actual
+ * `DetectedEnvironment` + which adapter ended up in effect — not just what was
+ * requested — so the downgrade is verified, not assumed from route config.
+ */
+import { useState } from 'react';
+
+interface EnvReport {
+ route: string;
+ environment: string;
+ supportsAxios: boolean;
+ requestedAdapter: string;
+ effectiveAdapter: string;
+}
+
+export function EnvironmentDemo() {
+ const [reports, setReports] = useState([]);
+ const [busy, setBusy] = useState(false);
+
+ async function runBoth() {
+ setBusy(true);
+ try {
+ const [node, edge] = await Promise.all([
+ fetch('/api/env-node').then((r) => r.json()),
+ fetch('/api/env-edge').then((r) => r.json()),
+ ]);
+ setReports([node, edge]);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ Environment detection & adapter downgrade
+
+
+ Both routes request http: {'{'} adapter: 'axios' {'}'}. Only the edge
+ route gets downgraded to fetch.
+
+
+
+
+
+
+ {reports.map((r) => (
+
+ {JSON.stringify(r, null, 2)}
+
+ ))}
+
+
+ );
+}
diff --git a/examples/nextjs/app/environments/page.tsx b/examples/nextjs/app/environments/page.tsx
new file mode 100644
index 0000000..aa1d50d
--- /dev/null
+++ b/examples/nextjs/app/environments/page.tsx
@@ -0,0 +1,5 @@
+import { EnvironmentDemo } from './EnvironmentDemo';
+
+export default function EnvironmentsPage() {
+ return ;
+}
diff --git a/examples/nextjs/app/rate-limit/RateLimitDemo.tsx b/examples/nextjs/app/rate-limit/RateLimitDemo.tsx
new file mode 100644
index 0000000..1c31b42
--- /dev/null
+++ b/examples/nextjs/app/rate-limit/RateLimitDemo.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+/**
+ * RPC RATE-LIMITING DEMO
+ * -----------------------
+ * Docs: https://github.com/developerEhsan/api-client/blob/master/docs/rpc-rate-limiting.md
+ *
+ * The RPC bridge behind `/api/rpc-rate-limit-demo` allows only 3 calls per
+ * 10s (see `rateLimitDemoHandler` in `lib/api/api.config.ts`). Click "Fire 5
+ * calls" to send 5 sequential requests — the first 3 succeed, the last 2
+ * come back as a uniform `rate_limited` `ApiError` with `status === 429`,
+ * rehydrated on the browser exactly like any other typed error.
+ */
+import { apiRateLimitDemo } from '@/lib/api/rate-limit-demo-client';
+import { ApiError } from '@developerehsan/api-client/browser';
+import { useState } from 'react';
+
+type Outcome = { n: number; ok: true; title: string } | { n: number; ok: false; message: string };
+
+export function RateLimitDemo() {
+ const [outcomes, setOutcomes] = useState([]);
+ const [busy, setBusy] = useState(false);
+
+ async function fireFive() {
+ setBusy(true);
+ setOutcomes([]);
+ for (let n = 1; n <= 5; n++) {
+ try {
+ const product = await apiRateLimitDemo.products.getProductById({ id: 1 });
+ setOutcomes((prev) => [...prev, { n, ok: true, title: product.title }]);
+ } catch (error) {
+ const message =
+ error instanceof ApiError
+ ? `ApiError status=${error.status} code=${error.code ?? '?'}: ${error.message}`
+ : String(error);
+ setOutcomes((prev) => [...prev, { n, ok: false, message }]);
+ }
+ }
+ setBusy(false);
+ }
+
+ return (
+
+
+
RPC rate limiting
+
+ The bridge behind this page allows 3 calls / 10s. Firing 5 sequential calls should
+ show the first 3 succeed and the last 2 come back as a 429{' '}
+
+ rate_limited
+ {' '}
+ error.
+