Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/adapter-specific-cache-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Give the IndexedDB and Redis cache stores adapter-specific config: IndexedDB gains `version`, `onQuotaExceeded`, and proactive `maxEntries` eviction; Redis gains a real `clear()` (opt-in via `scanKeys`, namespace-safe) and a custom `serializer`. Both gain a shared `onStoreError` diagnostic hook (error + op/key only, never cached data).
15 changes: 15 additions & 0 deletions .changeset/additional-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@developerehsan/api-client": minor
---

Add a grab-bag of caching hardening features:

- Negative caching (`cache.cacheableStatuses`/`negativeTtl`) for error statuses like 404.
- ETag/`If-None-Match` conditional revalidation — automatic once a response captures an `ETag`.
- `client.cache.getStats()` — aggregate hit/miss/hitRate metrics.
- A circuit breaker for a failing persistent (L2) store (`cache.circuitBreaker`), observable via `cache.onStoreError`.
- At-rest encryption for IndexedDB (`createIndexedDbStore({ encrypt })`) — bring your own cipher, no default key management.
- Schema-version cache busting: a cached entry is invalidated automatically once the runtime OpenAPI schema hash changes (e.g. after a deploy).
- `client.cache.preview(pattern)` — dry-run a glob invalidation before running it for real.

All are opt-in / additive; default behavior is unchanged.
5 changes: 5 additions & 0 deletions .changeset/cross-instance-invalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Add cross-instance cache invalidation via Redis pub/sub: `createRedisStore(client, { crossInstance: true })` broadcasts tag/key/clear invalidations to sibling server instances (opt-in, off by default). Fixes stale cache reads when a mutation lands on one instance behind a load balancer but the stale GET was cached on another.
5 changes: 5 additions & 0 deletions .changeset/custom-cache-key-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Add `cache.cacheKeyParts` — extra cache-key dimensions folded into the built-in tenant/auth-fingerprint scoping, settable per auto-method descriptor (computed from call args) or statically at the module/global/per-call layer. Safer than `keyResolver` for the common "add one more thing to the scope" case (e.g. an admin viewing "as" a target user, or a multi-workspace dashboard) since it can only add dimensions, never drop the existing tenant/auth isolation. A descriptor's `cacheKeyParts(args)` throwing fails closed (disables caching for that call). Also fixes a pre-existing gap where `cache.keyResolver` was declared on `CacheConfig` but never actually wired into the request pipeline.
5 changes: 5 additions & 0 deletions .changeset/filesystem-cache-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Add `createFileSystemStore` — a Node-only, disk-backed `PersistentCacheStore` (the `.next/cache` / nginx `proxy_cache` shape) for long-running server processes that want to trade RAM for disk and survive restarts. Cache keys are SHA-256 hashed into fixed-length filenames (no path traversal), writes are atomic (write-then-rename), and it accepts the same `onStoreError`/`maxSizeBytes`/`encrypt` options as the other adapters. Not for serverless/edge; does not solve cross-instance staleness (use Redis `crossInstance` for that).
5 changes: 5 additions & 0 deletions .changeset/manual-types-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": patch
---

Document and type-test full type-safety without codegen: `createTypedClient<Ops>()` already accepts a hand-written `Ops` interface with the exact shape codegen emits (`{ params, query, body, response }` per operation) — see `docs/manual-types.md`.
5 changes: 5 additions & 0 deletions .changeset/memory-aware-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Add memory-aware caching for constrained hosts: `cache.maxSizeBytes` (approximate byte cap alongside the existing entry-count `maxSize`), `cache.mode` (`'layered'` default, `'l1-only'`, `'l2-only'` with a tiny bounded shadow L1), and a Node-only opt-in `cache.memoryPressure` guard that proactively evicts before RSS crosses a threshold.
5 changes: 5 additions & 0 deletions .changeset/refresh-token-dx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Add a callback alternative to endpoint-based OAuth2 refresh (`refresh: (refreshToken) => Promise<OAuth2Tokens>`, for refresh logic that isn't "just POST a URL") and pluggable `tokenStorage` (mirrors `PersistentCacheStore`'s adapter pattern; ships `createMemoryTokenStorage`/`createLocalStorageTokenStorage`). Both are mutually exclusive with the existing `refreshEndpoint`/manual-getters shape, enforced at the type level and defense-in-depth at construction time.
5 changes: 5 additions & 0 deletions .changeset/server-only-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Add a dev-time server-only guard: `createRpcHandler` now throws immediately if constructed in a browser context (`enforceServerOnly`, on by default), and `defineModule({ serverOnly: true, ... })` does the same for your own server-only modules. Both are defense-in-depth on top of the existing `scripts/check-browser-bundle.mjs` CI check, which remains authoritative.
5 changes: 5 additions & 0 deletions .changeset/tag-based-cache-invalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@developerehsan/api-client": minor
---

Add tag-based cache invalidation: `cacheTags`/`invalidatesTags` on auto-method descriptors and `cache.tags`/`cache.invalidatesTags` on per-call config, plus `client.cache.invalidateTags(tags)`. A mutation's `invalidatesTags` now clears every cached copy of a tagged resource in one process, regardless of which auth scope cached it — fixing stale GETs after a mutation and same-VM cross-user staleness.
83 changes: 83 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,63 @@ The refresh response is expected to contain `access_token`/`accessToken` (and
optionally `refresh_token`/`refreshToken`). A second 401 after refreshing is
**not** re-refreshed (prevents infinite loops).

### Callback refresh (no HTTP endpoint)

Some setups don't have a plain refresh URL — the logic lives behind a BFF
call, a third-party SDK, or custom signing. Use `refresh` instead of
`refreshEndpoint`; everything else (the mutex, `concurrentRefreshStrategy`,
`onTokensRefreshed`/`onRefreshFailed`) works identically:

```ts
auth: {
strategy: 'oauth2',
getAccessToken: () => tokenStore.access,
getRefreshToken: () => tokenStore.refresh,
refresh: async (refreshToken) => {
const tokens = await thirdPartySdk.refresh(refreshToken) // returns { accessToken, refreshToken? }
return tokens
},
onTokensRefreshed: (tokens) => { tokenStore.access = tokens.accessToken },
onRefreshFailed: (error) => { redirectToLogin() },
}
```

Exactly one of `refreshEndpoint` or `refresh` must be set — the type enforces
this, and the client also throws a `ConfigurationError` at construction time
as defense-in-depth. A `refresh()` that throws/rejects is treated as a
refresh failure (`onRefreshFailed` fires); a resolved value with no
`accessToken` string is treated the same way — it's validated like an HTTP
response body would be, never silently proceeds with `undefined`.

### Pluggable token storage

Instead of hand-writing `getAccessToken`/`getRefreshToken`/
`onTokensRefreshed`, supply `tokenStorage` — an adapter mirroring
`PersistentCacheStore`'s pattern. The client derives all three from it:

```ts
import { createMemoryTokenStorage, createLocalStorageTokenStorage } from '@developerehsan/api-client'

auth: {
strategy: 'oauth2',
tokenStorage: createLocalStorageTokenStorage(), // or createMemoryTokenStorage() for tests/SSR warm-up
refreshEndpoint: 'https://api.example.com/oauth/token', // or `refresh:`
onRefreshFailed: (error) => { redirectToLogin() },
}
```

`tokenStorage` and the manual triplet are mutually exclusive — set exactly
one.

**`httpOnly` cookies:** `createLocalStorageTokenStorage` reads/writes
`localStorage`, which is readable by any script on the page (an XSS risk if
your threat model cares about that). It is **not** a way to work with
`httpOnly` cookies — those aren't readable/writable from JS by design. If
your backend sets an `httpOnly` session cookie, use `strategy: 'cookie'`
instead of `tokenStorage`; there is intentionally no cookie-backed
`TokenStorage` adapter, since one that could read/write an `httpOnly` cookie
from client JS would defeat the point of `httpOnly`.

## Per-call: skip auth

```ts
Expand All @@ -91,4 +148,30 @@ auth: { strategy: 'bearer', getToken: serverTokenFromCookie('access_token') }
Cache and dedup keys include an **auth fingerprint**, so two users with different
tokens never share a cached or deduped response. See [caching](./caching.md) and
[deduplication](./deduplication.md).

## Logout: clearing the cache

On the same device, a stale cache entry scoped to a now-logged-out user can
otherwise linger until it naturally expires. Clear it wherever your app
already handles session end — `client.cache.clear()` clears every layer (the
in-memory L1, an optional persistent L2, and the tag index) in one call, so
there's no second, partial clear path to keep in sync:

```ts
auth: {
strategy: 'oauth2',
// ...
onRefreshFailed: async (error) => {
await api.cache.clear()
redirectToLogin()
},
}

// and/or a user-initiated "log out" button, which isn't preceded by a failed refresh:
async function logout() {
await api.cache.clear()
await tokenStorage.clearTokens() // if using a TokenStorage adapter
redirectToLogin()
}
```
</content>
Loading
Loading