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. +

+
+ + + +
    + {outcomes.map((o) => ( +
  • + #{o.n}: {o.ok ? `OK — "${o.title}"` : o.message} +
  • + ))} +
+
+ ); +} diff --git a/examples/nextjs/app/rate-limit/page.tsx b/examples/nextjs/app/rate-limit/page.tsx new file mode 100644 index 0000000..ca9d76e --- /dev/null +++ b/examples/nextjs/app/rate-limit/page.tsx @@ -0,0 +1,5 @@ +import { RateLimitDemo } from './RateLimitDemo'; + +export default function RateLimitPage() { + return ; +} diff --git a/examples/nextjs/lib/api/api.config.ts b/examples/nextjs/lib/api/api.config.ts index 31095bc..54b1f7c 100644 --- a/examples/nextjs/lib/api/api.config.ts +++ b/examples/nextjs/lib/api/api.config.ts @@ -139,3 +139,24 @@ export const rpcHandler = createRpcHandler(api, { console.error(`[rpc] ${call?.module}.${call?.method} failed:`, error); }, }); + +/** + * A SECOND, dedicated bridge for the rate-limiting demo (`app/rate-limit/`). + * Docs: https://github.com/developerEhsan/api-client/blob/master/docs/rpc-rate-limiting.md + * + * Deliberately tiny (3 calls / 10s, one shared bucket) so a developer can + * trigger the 429 in a few clicks without touching — or exhausting — the main + * app's `rpcHandler` budget above. Everything else (deny-by-default `expose`) + * is identical to the main bridge. + */ +const demoLimiter = createRateLimiter({ windowMs: 10_000, max: 3 }); + +export const rateLimitDemoHandler = createRpcHandler(api, { + expose: { + products: ['getProductById'], + }, + onRequest: demoLimiter.onRequest, + onError: (error, call) => { + console.error(`[rpc:rate-limit-demo] ${call?.module}.${call?.method} failed:`, error); + }, +}); diff --git a/examples/nextjs/lib/api/rate-limit-demo-client.ts b/examples/nextjs/lib/api/rate-limit-demo-client.ts new file mode 100644 index 0000000..97fe88c --- /dev/null +++ b/examples/nextjs/lib/api/rate-limit-demo-client.ts @@ -0,0 +1,15 @@ +/** + * Browser client for the dedicated rate-limiting demo bridge. + * Docs: https://github.com/developerEhsan/api-client/blob/master/docs/rpc-rate-limiting.md + * + * Same pattern as `./rpc-http-client.ts`, pointed at the demo's own + * `/api/rpc-rate-limit-demo` route (3 calls / 10s) so it can be driven to a + * 429 quickly without touching the main app's shared budget. + */ +import { createRpcClient, httpTransport } from '@developerehsan/api-client/browser'; +import type { Api } from './api.config'; + +export const apiRateLimitDemo = createRpcClient( + httpTransport({ endpoint: '/api/rpc-rate-limit-demo' }), + { batch: false }, // one call = one round-trip, so the count-to-3 is exact +); diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 64fb949..01d75d6 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "generate": "developerEhsan-api-client generate --input ./lib/api/openapi.json --output ./lib/api/types/generated" }, "dependencies": { "@developerehsan/api-client": "^1.0.0", diff --git a/examples/react-vite/package.json b/examples/react-vite/package.json index 31e2009..686a60c 100644 --- a/examples/react-vite/package.json +++ b/examples/react-vite/package.json @@ -8,7 +8,9 @@ "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "typecheck:js": "tsc -p tsconfig.jscheck.json" + "typecheck:js": "tsc -p tsconfig.jscheck.json", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@developerehsan/api-client": "^1.0.0", @@ -30,6 +32,7 @@ "babel-plugin-react-compiler": "^1.0.0", "oxlint": "^1.74.0", "typescript": "~6.0.2", - "vite": "^8.1.5" + "vite": "^8.1.5", + "vitest": "^2.1.0" } } diff --git a/examples/react-vite/src/App.tsx b/examples/react-vite/src/App.tsx index 87c602d..ace8d96 100644 --- a/examples/react-vite/src/App.tsx +++ b/examples/react-vite/src/App.tsx @@ -11,16 +11,37 @@ */ import { useState } from 'react'; import './App.css'; +import { CachePersistenceDemo } from './features/CachePersistenceDemo'; +import { ConcurrencyQueueDemo } from './features/ConcurrencyQueueDemo'; import { DirectClientDemo } from './features/DirectClientDemo'; import { FeatureLab } from './features/FeatureLab'; +import { ManualTypesDemo } from './features/ManualTypesDemo'; +import { MultiTenancyDemo } from './features/MultiTenancyDemo'; +import { StreamingDemo } from './features/StreamingDemo'; import { TanstackDemo } from './features/TanstackDemo'; +import { TimeoutsCancellationDemo } from './features/TimeoutsCancellationDemo'; -type Tab = 'direct' | 'tanstack' | 'lab'; +type Tab = + | 'direct' + | 'tanstack' + | 'lab' + | 'queue' + | 'tenancy' + | 'streaming' + | 'persistence' + | 'timeouts' + | 'manual-types'; const TABS: { id: Tab; label: string }[] = [ { id: 'direct', label: '1 · Direct client' }, { id: 'tanstack', label: '2 · TanStack Query' }, { id: 'lab', label: '3 · Feature lab' }, + { id: 'queue', label: '4 · Concurrency queue' }, + { id: 'tenancy', label: '5 · Multi-tenancy' }, + { id: 'streaming', label: '6 · Streaming' }, + { id: 'persistence', label: '7 · Cache persistence' }, + { id: 'timeouts', label: '8 · Timeouts & cancellation' }, + { id: 'manual-types', label: '9 · Manual types' }, ]; function App() { @@ -55,6 +76,12 @@ function App() { {tab === 'direct' ? : null} {tab === 'tanstack' ? : null} {tab === 'lab' ? : null} + {tab === 'queue' ? : null} + {tab === 'tenancy' ? : null} + {tab === 'streaming' ? : null} + {tab === 'persistence' ? : null} + {tab === 'timeouts' ? : null} + {tab === 'manual-types' ? : null}