+244
-16
@@ -22,2 +22,24 @@ /** | ||
| /** | ||
| * Cached event handler returned by `defineCachedHandler`. | ||
| * | ||
| * An {@link EventHandler} augmented with on-demand revalidation methods forwarded from | ||
| * the underlying cached function. Each accepts the {@link HTTPEvent} directly and derives | ||
| * the exact storage key the handler caches under, so no manual key reconstruction is needed. | ||
| */ | ||
| type CachedEventHandler<E extends HTTPEvent = HTTPEvent> = EventHandler<E> & { | ||
| /** Resolves all storage keys (one per base prefix) the handler would cache the event under. */resolveKeys: (event: E) => Promise<string[]>; /** Invalidates (removes) cached entries for the event across all base prefixes. */ | ||
| invalidate: (event: E) => Promise<void>; /** Marks cached entries for the event as stale across all base prefixes. With SWR, stale values are still served (within `staleMaxAge`) while the next access triggers a background refresh. */ | ||
| expire: (event: E) => Promise<void>; | ||
| }; | ||
| /** | ||
| * How a cached value was served on a given call. | ||
| * | ||
| * - `"hit"` — a fresh cached value was returned without re-resolving. | ||
| * - `"stale"` — a stale value was served while a background SWR refresh runs. | ||
| * - `"revalidated"` — a prior value existed but was expired/invalid, so it was | ||
| * re-resolved in the foreground (no stale value served) before returning. | ||
| * - `"miss"` — the value was resolved fresh on this call (nothing was cached). | ||
| */ | ||
| type CacheStatus = "hit" | "stale" | "revalidated" | "miss"; | ||
| /** | ||
| * Stored cache entry wrapping a cached value with metadata. | ||
@@ -36,2 +58,14 @@ */ | ||
| stale?: boolean; | ||
| /** Resolved per-entry `maxAge` (seconds) set by the `getMaxAge` hook. Overrides `CacheOptions.maxAge` for this entry's freshness check and storage TTL. */ | ||
| maxAge?: number; | ||
| /** Resolved per-entry `staleMaxAge` (seconds) set by the `getMaxAge` hook. Overrides `CacheOptions.staleMaxAge` for this entry. */ | ||
| staleMaxAge?: number; | ||
| /** | ||
| * How this value was served on the current call (`"hit"` / `"stale"` / `"revalidated"` / `"miss"`). | ||
| * | ||
| * Populated per-call on the entry passed to `transform` — it is **not** persisted | ||
| * to storage. Read it from `transform` for metrics/observability or to drive | ||
| * conditional logic. See {@link CacheStatus}. | ||
| */ | ||
| status?: CacheStatus; | ||
| } | ||
@@ -46,6 +80,52 @@ /** | ||
| getKey?: (...args: ArgsT) => string | Promise<string>; | ||
| /** Transform the cached entry before returning. Return value replaces the cached value. */ | ||
| /** | ||
| * Transform the cached entry before returning. Return value replaces the cached value. | ||
| * | ||
| * The passed entry carries `entry.status` (`"hit"` / `"stale"` / `"revalidated"` / `"miss"`) describing | ||
| * how the value was served on this call — useful for metrics or conditional logic. | ||
| */ | ||
| transform?: (entry: CacheEntry<T>, ...args: ArgsT) => any; | ||
| /** Validate a cache entry. Return `false` to treat the entry as invalid and re-resolve. */ | ||
| validate?: (entry: CacheEntry<T>, ...args: ArgsT) => boolean; | ||
| /** | ||
| * Prepare the resolved value for storage — the write-side counterpart of `transform`. | ||
| * | ||
| * Runs once, right after the resolver (and after `getMaxAge`, so that hook still sees the | ||
| * raw value) and before the entry is persisted. Return the value to store (the storable | ||
| * shape usually differs from `T`, so the return is untyped like `transform`); `transform` | ||
| * then reconstructs the usable value when the entry is read back. | ||
| * | ||
| * Use this when the resolver returns something a storage backend can't persist as-is | ||
| * (e.g. a `ReadableStream` or a class instance): `serialize` converts it to a storable | ||
| * form on write, `transform` restores it on read. Because it runs exactly once per | ||
| * resolution — even under concurrent, deduplicated calls, where every caller observes | ||
| * the serialized value — it is safe to consume a one-shot source such as a stream here. | ||
| * | ||
| * The second argument carries the `args` the cached function was called with (same | ||
| * shape as `validate`), so serialization can depend on the current call. | ||
| * | ||
| * Note: `validate` always inspects the serialized (stored) shape — on write it runs | ||
| * right after this hook, and on read it sees the entry as persisted. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // Persist a ReadableStream body as a string, restore it on read. | ||
| * serialize: async (entry) => ({ ...entry.value, body: await streamToString(entry.value.body) }), | ||
| * transform: (entry) => ({ ...entry.value, body: stringToStream(entry.value.body) }), | ||
| * ``` | ||
| */ | ||
| serialize?: (entry: CacheEntry<T>, ctx: { | ||
| args: ArgsT; | ||
| }) => any; | ||
| /** | ||
| * Validate a cache entry. Return `false` (or a Promise resolving to `false`) to treat | ||
| * the entry as invalid and re-resolve. Asynchronous validation is supported for cases | ||
| * that need to check the cached value against an external source (e.g. fetching a | ||
| * signed URL to confirm it is still valid). | ||
| * | ||
| * The second argument carries the `args` the cached function was called with, so the | ||
| * entry can be validated against the current call (e.g. comparing a request parameter | ||
| * against `entry.mtime`). | ||
| */ | ||
| validate?: (entry: CacheEntry<T>, ctx: { | ||
| args: ArgsT; | ||
| }) => boolean | Promise<boolean>; | ||
| /** When returns `true`, the cache is invalidated and the function is re-invoked. */ | ||
@@ -61,6 +141,29 @@ shouldInvalidateCache?: (...args: ArgsT) => boolean | Promise<boolean>; | ||
| maxAge?: number; | ||
| /** Enable stale-while-revalidate behavior. When `true`, returns stale cache while refreshing in the background. Defaults to `true`. */ | ||
| /** Enable stale-while-revalidate behavior. When `true`, returns stale cache while refreshing in the background. Defaults to `false` (an expired entry is re-resolved in the foreground before returning). */ | ||
| swr?: boolean; | ||
| /** Maximum number of seconds a stale entry can be served while revalidating. */ | ||
| /** Maximum number of seconds a stale entry can be served while revalidating. `0` means stale is never served — once expired, revalidation blocks the request. */ | ||
| staleMaxAge?: number; | ||
| /** | ||
| * Derive the per-entry cache lifetime from the resolved value. Runs after the resolver and before | ||
| * the entry is persisted. Return a number (seconds) as shorthand for `maxAge`, or an object to also | ||
| * override `staleMaxAge`. The resolved values override the static options for that entry and drive | ||
| * both the read freshness check and the storage TTL. Return `undefined` (or omit a field) to fall | ||
| * back to the static option. A resolved value `<= 0` disables caching for that entry (re-resolves | ||
| * on every access); negatives are clamped to `0` rather than treated as "cache forever". | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // Cache an OAuth token for exactly its `expires_in` | ||
| * getMaxAge: (entry) => entry.value?.expires_in, | ||
| * // Override both the fresh and stale windows | ||
| * getMaxAge: (entry) => ({ maxAge: 60, staleMaxAge: 300 }), | ||
| * ``` | ||
| */ | ||
| getMaxAge?: (entry: CacheEntry<T>) => number | { | ||
| maxAge?: number; | ||
| staleMaxAge?: number; | ||
| } | undefined | Promise<number | { | ||
| maxAge?: number; | ||
| staleMaxAge?: number; | ||
| } | undefined>; | ||
| /** Base path prefix(es) for cache keys. When an array, reads try each prefix in order (multi-tier) and writes go to all prefixes. Defaults to `"/cache"`. */ | ||
@@ -81,4 +184,15 @@ base?: string | string[]; | ||
| headers: Record<string, string>; | ||
| /** Response body as a string. */ | ||
| /** | ||
| * Serialized response body. Text bodies are stored verbatim; bodies that aren't | ||
| * valid UTF-8 (images, protobuf, other binary payloads) are base64-encoded and | ||
| * flagged with {@link base64}, so they survive both the lossy `res.text()` decode | ||
| * and JSON-serializing storage backends. Always a string when set. | ||
| */ | ||
| body: string | undefined; | ||
| /** | ||
| * When `true`, {@link body} is base64-encoded raw bytes (a non-UTF-8 binary body). | ||
| * The read path decodes it back to a `Uint8Array` before rebuilding the Response. | ||
| * Absent for text bodies. | ||
| */ | ||
| base64?: boolean; | ||
| } | ||
@@ -96,10 +210,87 @@ /** | ||
| * | ||
| * Extends {@link CacheOptions} (without `transform` and `validate`, which are set internally). | ||
| * Extends {@link CacheOptions} (without `transform`, `validate`, and `serialize`, which are | ||
| * set internally): the resolver returns the live `Response`, an internal `serialize` hook | ||
| * turns it into the stored `ResponseCacheEntry`, and `transform` reconstructs the servable | ||
| * shape on read. Because the cached value is the `Response`, hooks that run before | ||
| * serialization — notably `getMaxAge` — receive `CacheEntry<Response>` (inspect its headers | ||
| * or status; do not consume its body, which `serialize` reads exactly once). | ||
| */ | ||
| interface CachedEventHandlerOptions<E extends HTTPEvent = HTTPEvent> extends Omit<CacheOptions<ResponseCacheEntry, [E]>, "transform" | "validate"> { | ||
| interface CachedEventHandlerOptions<E extends HTTPEvent = HTTPEvent> extends Omit<CacheOptions<Response, [E]>, "transform" | "validate" | "serialize"> { | ||
| /** When `true`, only handles conditional headers (304 responses) without full response caching. */ | ||
| headersOnly?: boolean; | ||
| /** Request header names that should vary the cache key (e.g., `["accept-language"]`). */ | ||
| /** | ||
| * Request header names that should vary the cache key (e.g., `["accept-language"]`). | ||
| * These names are also merged into the response's `Vary` header so downstream | ||
| * caches/CDNs/browsers store a separate variant per value. | ||
| */ | ||
| varies?: string[] | readonly string[]; | ||
| /** | ||
| * Allowlist of query parameter names that vary the cache key (e.g., `["color"]`). | ||
| * When set, only these params affect the auto-generated key; all others are | ||
| * ignored. When unset, the full query string varies the key. Case-sensitive. | ||
| * | ||
| * If a custom `getKey` is provided it controls the key entirely and this no | ||
| * longer affects it, but non-allowlisted params are still stripped from the | ||
| * URL the handler sees. | ||
| */ | ||
| allowQuery?: string[] | readonly string[]; | ||
| /** | ||
| * Allowlist of cookie names that participate in caching. | ||
| * | ||
| * **By default no cookies are allowed** (secure default), in both directions: | ||
| * - the `Cookie` request header is stripped before the handler runs and never | ||
| * varies the cache key, so a handler cannot produce cookie-dependent output | ||
| * that leaks across users, and | ||
| * - any `Set-Cookie` the handler sets is stripped from the response before it is | ||
| * cached or returned (mirroring how shared caches / CDNs drop `Set-Cookie` on | ||
| * cacheable responses), so a per-request cookie such as a session id can never | ||
| * reach another user — whether via a later cache hit or a concurrent, coalesced | ||
| * caller sharing the single resolution. The rest of the response is still cached. | ||
| * | ||
| * When set, only the listed cookies are kept: their name/value pairs vary the | ||
| * cache key (sorted, order-independent — like {@link allowQuery}) and survive in | ||
| * the `Cookie` header the handler sees; on the response, non-allowlisted | ||
| * `Set-Cookie`s are stripped and the rest is still cached. Case-sensitive. | ||
| * | ||
| * ⚠️ An allowlisted cookie **participates in caching** — its value is shared | ||
| * across every caller that resolves to the same cache key (concurrent requests are | ||
| * coalesced into one handler call, and the cached `Set-Cookie` is replayed to later | ||
| * hits). It is the caller's responsibility to only allowlist cookies whose value is | ||
| * safe to share across those users — a `theme`/`locale` preference that is *part | ||
| * of* the key, never a per-user secret. To cache a handler that mints a per-request | ||
| * cookie, give it a user-specific `getKey`/`varies` so each user keys to a distinct | ||
| * entry (or don't cache it). | ||
| * | ||
| * Only cacheable requests (`GET`/`HEAD`) are affected: methods that bypass | ||
| * caching (e.g. `POST`) reach the handler with their request untouched and their | ||
| * `Set-Cookie` passed through. | ||
| * | ||
| * Supersedes `varies: ["cookie"]` (which hashes the entire raw `Cookie` header). | ||
| */ | ||
| allowCookies?: string[] | readonly string[]; | ||
| /** | ||
| * Whether to synthesize a `Cache-Control` response header. Defaults to `true`. | ||
| * | ||
| * Set to `false` for **server-only caching**: the response is still stored and | ||
| * served from cache (SWR, `etag`, and `last-modified` all still apply), but no | ||
| * `Cache-Control` header is emitted to clients/CDNs. This decouples internal | ||
| * storage caching from downstream cache advertisement — unlike setting | ||
| * `Cache-Control: no-store`/`private` on the response, which also disqualifies | ||
| * the entry from storage via the built-in `validate` checks. | ||
| * | ||
| * Only governs ocache's own synthesis: a `Cache-Control` the handler set | ||
| * explicitly is left untouched (as always) and still sent. | ||
| */ | ||
| sendCacheControl?: boolean; | ||
| /** | ||
| * Add a cache-status response header (CDN-style `X-Cache: HIT | STALE | REVALIDATED | MISS`). | ||
| * | ||
| * - `true` (default) — sets the `X-Cache` header. | ||
| * - a string — sets a custom header name (e.g. `"x-nitro-cache"`). | ||
| * - `false` — no header is set. | ||
| * | ||
| * Has no effect in `headersOnly` mode (no value is cached there). | ||
| */ | ||
| cacheStatusHeader?: boolean | string; | ||
| /** | ||
| * Convert handler return value to a Response. | ||
@@ -110,6 +301,8 @@ * Default: `rawValue instanceof Response ? rawValue : new Response(String(rawValue))`. | ||
| /** | ||
| * Create the final cached Response from serialized cache entry data. | ||
| * Create the final cached Response from serialized cache entry data. The body is a | ||
| * `string` for text responses, a `Uint8Array` for cached binary responses (decoded | ||
| * from the stored base64), or `null` for empty/304 responses. | ||
| * Default: `new Response(body, init)`. | ||
| */ | ||
| createResponse?: (body: string | null, init: ResponseInit) => Response; | ||
| createResponse?: (body: string | Uint8Array | null, init: ResponseInit) => Response; | ||
| /** | ||
@@ -121,2 +314,27 @@ * Check conditional request headers (etag/if-modified-since). | ||
| handleCacheHeaders?: (event: E, conditions: CacheConditions) => boolean; | ||
| /** | ||
| * Additional predicate deciding whether a handler response is cacheable. | ||
| * | ||
| * Runs *after* — and in addition to — the built-in response validation, which | ||
| * always applies and cannot be bypassed (it rejects `4xx`/`5xx` statuses, | ||
| * `Cache-Control: no-store`/`private`, missing bodies, and absent | ||
| * `etag`/`last-modified`). Return `false` (or a Promise resolving to `false`) | ||
| * to treat the response as non-cacheable; it is still returned to the caller, | ||
| * just not stored. Receives the serialized response entry. | ||
| * | ||
| * Because it is ANDed with the built-ins, it can only *narrow* what gets | ||
| * cached — it cannot force-cache a response the built-in checks reject. | ||
| * | ||
| * Note it gates both storing a fresh response **and** serving a stored one, so | ||
| * it also runs on cache reads (including the stale-while-revalidate serve | ||
| * decision). Keep it fast and pure (decide only from `entry`); a throwing hook | ||
| * fails closed (treated as non-cacheable) and is reported via `onError`. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // Don't cache redirects (3xx), which the built-in checks would otherwise allow. | ||
| * shouldCache: (res) => res.status < 300 || res.status >= 400, | ||
| * ``` | ||
| */ | ||
| shouldCache?: (entry: ResponseCacheEntry) => boolean | Promise<boolean>; | ||
| } | ||
@@ -224,5 +442,7 @@ type CachedFunction<T, ArgsT extends unknown[]> = { | ||
| * @param opts - Cache and HTTP-specific configuration options. | ||
| * @returns A new event handler that serves cached responses when available. | ||
| * @returns A new event handler that serves cached responses when available. The handler | ||
| * also exposes `.resolveKeys(event)`, `.invalidate(event)`, and `.expire(event)` for | ||
| * on-demand revalidation, keyed exactly as the handler caches (no key reconstruction). | ||
| */ | ||
| declare function defineCachedHandler<E extends HTTPEvent = HTTPEvent>(handler: EventHandler<E>, opts?: CachedEventHandlerOptions<E>): EventHandler<E>; | ||
| declare function defineCachedHandler<E extends HTTPEvent = HTTPEvent>(handler: EventHandler<E>, opts?: CachedEventHandlerOptions<E>): CachedEventHandler<E>; | ||
| interface StorageInterface { | ||
@@ -234,4 +454,12 @@ get<T = unknown>(key: string): T | null | Promise<T | null>; | ||
| } | ||
| /** Creates an in-memory storage backed by a `Map` with optional TTL support (in seconds). */ | ||
| declare function createMemoryStorage(): StorageInterface; | ||
| interface MemoryStorageOptions { | ||
| /** | ||
| * Maximum number of entries to keep. When exceeded, the least-recently-used | ||
| * entries are evicted. Defaults to `10 000`. Pass `Infinity` (or `0`) to | ||
| * disable the ceiling and grow unbounded. | ||
| */ | ||
| maxSize?: number; | ||
| } | ||
| /** Creates an in-memory storage backed by a `Map` with optional TTL support (in seconds) and LRU eviction. */ | ||
| declare function createMemoryStorage(opts?: MemoryStorageOptions): StorageInterface; | ||
| /** Returns the current storage instance. If none has been set via `setStorage`, lazily initializes an in-memory storage. */ | ||
@@ -241,2 +469,2 @@ declare function useStorage(): StorageInterface; | ||
| declare function setStorage(storage: StorageInterface): void; | ||
| export { type CacheConditions, type CacheEntry, type CacheOptions, type CachedEventHandlerOptions, type CachedFunction, type EventHandler, type HTTPEvent, type ResponseCacheEntry, type ServerRequest, type StorageInterface, cachedFunction, createMemoryStorage, defineCachedFunction, defineCachedHandler, expireCache, invalidateCache, resolveCacheKeys, setStorage, useStorage }; | ||
| export { type CacheConditions, type CacheEntry, type CacheOptions, type CacheStatus, type CachedEventHandler, type CachedEventHandlerOptions, type CachedFunction, type EventHandler, type HTTPEvent, type MemoryStorageOptions, type ResponseCacheEntry, type ServerRequest, type StorageInterface, cachedFunction, createMemoryStorage, defineCachedFunction, defineCachedHandler, expireCache, invalidateCache, resolveCacheKeys, setStorage, useStorage }; |
+307
-67
| import { hash } from "ohash"; | ||
| function createMemoryStorage() { | ||
| const DEFAULT_MEMORY_MAX_SIZE = 1e4; | ||
| function createMemoryStorage(opts = {}) { | ||
| const rawMaxSize = opts.maxSize ?? DEFAULT_MEMORY_MAX_SIZE; | ||
| const maxSize = Number.isFinite(rawMaxSize) && rawMaxSize > 0 ? rawMaxSize : void 0; | ||
| const map = /* @__PURE__ */ new Map(); | ||
| const timers = /* @__PURE__ */ new Map(); | ||
| function _delete(key) { | ||
| map.delete(key); | ||
| _clearTimer(timers, key); | ||
| } | ||
| return { | ||
@@ -10,6 +17,9 @@ get(key) { | ||
| if (entry.expires && Date.now() > entry.expires) { | ||
| map.delete(key); | ||
| _clearTimer(timers, key); | ||
| _delete(key); | ||
| return null; | ||
| } | ||
| if (maxSize) { | ||
| map.delete(key); | ||
| map.set(key, entry); | ||
| } | ||
| return entry.value; | ||
@@ -23,2 +33,3 @@ }, | ||
| } | ||
| map.delete(key); | ||
| const ttlMs = opts?.ttl ? opts.ttl * 1e3 : void 0; | ||
@@ -37,2 +48,7 @@ map.set(key, { | ||
| } | ||
| if (maxSize) while (map.size > maxSize) { | ||
| const oldest = map.keys().next().value; | ||
| if (oldest === void 0) break; | ||
| _delete(oldest); | ||
| } | ||
| } | ||
@@ -60,3 +76,3 @@ }; | ||
| base: "/cache", | ||
| swr: true, | ||
| swr: false, | ||
| maxAge: 1 | ||
@@ -66,9 +82,10 @@ }; | ||
| function defineCachedFunction(fn, opts = {}) { | ||
| const name = opts.name || fn.name || `anon_${hash(fn).slice(0, 16)}`; | ||
| opts = { | ||
| ...defaultCacheOptions$1(), | ||
| ...opts | ||
| ...opts, | ||
| name | ||
| }; | ||
| const pending = {}; | ||
| const group = opts.group || "functions"; | ||
| const name = opts.name || fn.name || "_"; | ||
| const integrity = opts.integrity || hash([fn, _integrityOpts$1(opts)]); | ||
@@ -80,3 +97,4 @@ const validate = opts.validate || ((entry) => entry.value !== void 0); | ||
| }; | ||
| async function get(key, resolver, shouldInvalidateCache, event) { | ||
| async function get(key, resolver, args, shouldInvalidateCache, event) { | ||
| const validateCtx = { args }; | ||
| const bases = _normalizeBases(opts.base); | ||
@@ -103,8 +121,12 @@ let entry = {}; | ||
| _onError("[cache]", /* @__PURE__ */ new Error("Malformed data read from cache.")); | ||
| } | ||
| const ttl = (opts.maxAge ?? 0) * 1e3; | ||
| } else entry = { ...entry }; | ||
| const readMaxAge = entry.maxAge ?? opts.maxAge; | ||
| const readStaleMaxAge = entry.staleMaxAge ?? opts.staleMaxAge; | ||
| const ttl = (readMaxAge ?? 0) * 1e3; | ||
| if (ttl > 0) entry.expires = Date.now() + ttl; | ||
| const staleTtl = opts.swr && opts.staleMaxAge != null && opts.staleMaxAge >= 0 ? opts.staleMaxAge * 1e3 : void 0; | ||
| const isFullyExpired = staleTtl !== void 0 && ttl > 0 && Date.now() - (entry.mtime || 0) > ttl + staleTtl; | ||
| const expired = shouldInvalidateCache || entry.stale === true || entry.integrity !== integrity || opts.maxAge === 0 || ttl > 0 && Date.now() - (entry.mtime || 0) > ttl || validate(entry) === false; | ||
| const staleTtl = opts.swr && readStaleMaxAge != null && readStaleMaxAge >= 0 ? readStaleMaxAge * 1e3 : void 0; | ||
| const swr = opts.swr && staleTtl !== 0; | ||
| const isFullyExpired = staleTtl !== void 0 && readMaxAge != null && Date.now() - (entry.mtime || 0) > ttl + staleTtl; | ||
| const _isValid = await validate(entry, validateCtx) !== false; | ||
| const expired = shouldInvalidateCache || entry.stale === true || entry.integrity !== integrity || readMaxAge === 0 || ttl > 0 && Date.now() - (entry.mtime || 0) > ttl || !_isValid; | ||
| if (isFullyExpired) { | ||
@@ -116,2 +138,3 @@ entry.value = void 0; | ||
| } | ||
| const status = entry.value === void 0 ? "miss" : !expired ? "hit" : swr && _isValid ? "stale" : "revalidated"; | ||
| const _resolve = async () => { | ||
@@ -126,6 +149,31 @@ const isPending = pending[key]; | ||
| } | ||
| pending[key] = Promise.resolve(resolver()); | ||
| pending[key] = (async () => { | ||
| const value = await resolver(); | ||
| const resolvedEntry = { | ||
| value, | ||
| mtime: Date.now(), | ||
| integrity | ||
| }; | ||
| let maxAge; | ||
| let staleMaxAge; | ||
| if (opts.getMaxAge) try { | ||
| const resolved = await opts.getMaxAge(resolvedEntry); | ||
| const dynamic = typeof resolved === "number" ? { maxAge: resolved } : resolved; | ||
| maxAge = _clampTtl(dynamic?.maxAge); | ||
| staleMaxAge = _clampTtl(dynamic?.staleMaxAge); | ||
| resolvedEntry.maxAge = maxAge; | ||
| resolvedEntry.staleMaxAge = staleMaxAge; | ||
| } catch (error) { | ||
| _onError("[cache] getMaxAge hook error.", error); | ||
| } | ||
| return { | ||
| value: opts.serialize ? await opts.serialize(resolvedEntry, validateCtx) : value, | ||
| maxAge, | ||
| staleMaxAge | ||
| }; | ||
| })(); | ||
| } | ||
| let resolved; | ||
| try { | ||
| entry.value = await pending[key]; | ||
| resolved = await pending[key]; | ||
| } catch (error) { | ||
@@ -141,2 +189,3 @@ if (!isPending) { | ||
| } | ||
| entry.value = resolved.value; | ||
| if (!isPending) { | ||
@@ -147,8 +196,15 @@ entry.mtime = Date.now(); | ||
| delete pending[key]; | ||
| if (validate(entry) !== false) { | ||
| if (opts.getMaxAge) { | ||
| entry.maxAge = resolved.maxAge; | ||
| entry.staleMaxAge = resolved.staleMaxAge; | ||
| } | ||
| if (await validate(entry, validateCtx) !== false) { | ||
| const writeMaxAge = entry.maxAge ?? opts.maxAge; | ||
| const writeStaleMaxAge = entry.staleMaxAge ?? opts.staleMaxAge; | ||
| let setOpts; | ||
| if (opts.maxAge != null && opts.maxAge > 0) if (opts.swr) { | ||
| if (opts.staleMaxAge != null && opts.staleMaxAge >= 0) setOpts = { ttl: opts.maxAge + opts.staleMaxAge }; | ||
| } else setOpts = { ttl: opts.maxAge }; | ||
| if (writeMaxAge != null && writeMaxAge > 0) if (opts.swr) { | ||
| if (writeStaleMaxAge != null && writeStaleMaxAge >= 0) setOpts = { ttl: writeMaxAge + writeStaleMaxAge }; | ||
| } else setOpts = { ttl: writeMaxAge }; | ||
| const writeBases = hitIndex < 0 ? bases : bases.slice(0, hitIndex + 1); | ||
| const { status: _status, ...toStore } = entry; | ||
| const promise = (async () => { | ||
@@ -159,3 +215,3 @@ try { | ||
| name | ||
| }, b), entry, setOpts))); | ||
| }, b), toStore, setOpts))); | ||
| } catch (error) { | ||
@@ -166,3 +222,3 @@ _onError("[cache] Cache write error.", error); | ||
| event?.req.waitUntil?.(promise); | ||
| } else { | ||
| } else if (hitIndex >= 0) { | ||
| const evictPromise = _evictFromStorage(key, bases, group, name).catch((error) => { | ||
@@ -178,3 +234,9 @@ _onError("[cache] Cache eviction error.", error); | ||
| else if (expired) event?.req.waitUntil?.(_resolvePromise); | ||
| if (opts.swr && validate(entry) !== false) { | ||
| Object.defineProperty(entry, "status", { | ||
| value: status, | ||
| enumerable: false, | ||
| writable: true, | ||
| configurable: true | ||
| }); | ||
| if (swr && await validate(entry, validateCtx) !== false) { | ||
| _resolvePromise.catch((error) => { | ||
@@ -189,3 +251,3 @@ _onError("[cache] SWR handler error.", error); | ||
| if (await opts.shouldBypassCache?.(...args)) return fn(...args); | ||
| const entry = await get(await (opts.getKey || getKey)(...args), () => fn(...args), await opts.shouldInvalidateCache?.(...args), isHTTPEvent(args[0]) ? args[0] : void 0); | ||
| const entry = await get(await (opts.getKey || getKey)(...args), () => fn(...args), args, await opts.shouldInvalidateCache?.(...args), isHTTPEvent(args[0]) ? args[0] : void 0); | ||
| let value = entry.value; | ||
@@ -237,2 +299,5 @@ if (opts.transform) value = await opts.transform(entry, ...args) || value; | ||
| } | ||
| function _clampTtl(value) { | ||
| return value == null || !Number.isFinite(value) ? void 0 : Math.max(0, value); | ||
| } | ||
| function getKey(...args) { | ||
@@ -260,4 +325,6 @@ return args.length > 0 ? hash(args) : ""; | ||
| function _remainingTtl(entry, opts) { | ||
| if (!entry.mtime || opts.maxAge == null || opts.maxAge <= 0) return; | ||
| const ttlWindow = opts.swr === false ? opts.maxAge : opts.staleMaxAge != null && opts.staleMaxAge >= 0 ? opts.maxAge + opts.staleMaxAge : void 0; | ||
| const maxAge = entry.maxAge ?? opts.maxAge; | ||
| const staleMaxAge = entry.staleMaxAge ?? opts.staleMaxAge; | ||
| if (!entry.mtime || maxAge == null || maxAge <= 0) return; | ||
| const ttlWindow = !opts.swr ? maxAge : staleMaxAge != null && staleMaxAge >= 0 ? maxAge + staleMaxAge : void 0; | ||
| if (ttlWindow === void 0) return; | ||
@@ -274,4 +341,5 @@ return { ttl: Math.max(Math.ceil((entry.mtime + ttlWindow * 1e3 - Date.now()) / 1e3), 1) }; | ||
| base: "/cache", | ||
| swr: true, | ||
| maxAge: 1 | ||
| swr: false, | ||
| maxAge: 1, | ||
| cacheStatusHeader: true | ||
| }; | ||
@@ -284,45 +352,109 @@ } | ||
| }; | ||
| const variableHeaderNames = (opts.varies || []).filter(Boolean).map((h) => h.toLowerCase()).sort(); | ||
| const _cookieNames = [...new Set((opts.allowCookies ?? []).map((c) => c?.trim()).filter(Boolean))]; | ||
| const allowedCookieNames = _cookieNames.length > 0 ? _cookieNames : void 0; | ||
| const variableHeaderNames = (opts.varies || []).filter(Boolean).map((h) => h.toLowerCase()).filter((h) => !(allowedCookieNames && h === "cookie")).sort(); | ||
| const allowedQueryNames = opts.allowQuery ? [...new Set(opts.allowQuery.filter(Boolean))] : void 0; | ||
| const _shouldBypassCache = (event) => event.req.method !== "GET" && event.req.method !== "HEAD"; | ||
| const _searchCache = /* @__PURE__ */ new WeakMap(); | ||
| const _filteredSearch = (event, url) => { | ||
| let search = _searchCache.get(event); | ||
| if (search === void 0) { | ||
| search = _filterSearch(url, allowedQueryNames); | ||
| _searchCache.set(event, search); | ||
| } | ||
| return search; | ||
| }; | ||
| const _toResponse = opts.toResponse || ((rawValue) => rawValue instanceof Response ? rawValue : new Response(String(rawValue))); | ||
| const _createResponse = opts.createResponse || ((body, init) => new Response(body, init)); | ||
| const _handleCacheHeaders = opts.handleCacheHeaders || _defaultHandleCacheHeaders; | ||
| const _statusHeader = opts.cacheStatusHeader === true ? "x-cache" : typeof opts.cacheStatusHeader === "string" && opts.cacheStatusHeader ? opts.cacheStatusHeader.toLowerCase() : void 0; | ||
| const _cachedHandler = cachedFunction(async (event) => { | ||
| const filteredHeaders = [...event.req.headers.entries()].filter(([key]) => !variableHeaderNames.includes(key.toLowerCase())); | ||
| try { | ||
| const originalReq = event.req; | ||
| event.req = new Request(event.req.url, { | ||
| method: event.req.method, | ||
| headers: filteredHeaders | ||
| if (!_shouldBypassCache(event)) { | ||
| const filteredHeaders = [...event.req.headers.entries()].filter(([key]) => !variableHeaderNames.includes(key.toLowerCase())).flatMap(([key, value]) => { | ||
| if (key.toLowerCase() !== "cookie") return [[key, value]]; | ||
| const cookie = allowedCookieNames ? _filterCookie(value, allowedCookieNames) : ""; | ||
| return cookie ? [["cookie", cookie]] : []; | ||
| }); | ||
| if (originalReq.runtime) event.req.runtime = originalReq.runtime; | ||
| } catch (error) { | ||
| console.error("[cache] Failed to filter headers:", error); | ||
| let _reqUrl = event.req.url; | ||
| if (allowedQueryNames) { | ||
| const _url = event.url ?? new URL(event.req.url); | ||
| const _filteredUrl = new URL(_url); | ||
| _filteredUrl.search = _filteredSearch(event, _url); | ||
| _reqUrl = _filteredUrl.href; | ||
| } | ||
| try { | ||
| const originalReq = event.req; | ||
| event.req = new Request(_reqUrl, { | ||
| method: event.req.method, | ||
| headers: filteredHeaders | ||
| }); | ||
| if (originalReq.runtime) event.req.runtime = originalReq.runtime; | ||
| if (allowedQueryNames && event.url) event.url = new URL(_reqUrl); | ||
| } catch (error) { | ||
| console.error("[cache] Failed to filter request:", error); | ||
| } | ||
| } | ||
| const res = await _toResponse(await handler(event), event); | ||
| const body = await res.text(); | ||
| if (!res.headers.has("etag")) res.headers.set("etag", `W/"${hash(body)}"`); | ||
| if (!res.headers.has("last-modified")) res.headers.set("last-modified", (/* @__PURE__ */ new Date()).toUTCString()); | ||
| const cacheControl = []; | ||
| if (opts.swr) { | ||
| if (opts.maxAge != null) cacheControl.push(`s-maxage=${opts.maxAge}`); | ||
| if (opts.staleMaxAge != null) cacheControl.push(`stale-while-revalidate=${opts.staleMaxAge}`); | ||
| else cacheControl.push("stale-while-revalidate"); | ||
| } else if (opts.maxAge) cacheControl.push(`max-age=${opts.maxAge}`); | ||
| if (cacheControl.length > 0) res.headers.set("cache-control", cacheControl.join(", ")); | ||
| return { | ||
| status: res.status, | ||
| statusText: res.statusText, | ||
| headers: Object.fromEntries(res.headers.entries()), | ||
| body | ||
| }; | ||
| const rawValue = await handler(event); | ||
| return _toResponse(rawValue, event); | ||
| }, { | ||
| ...opts, | ||
| shouldBypassCache: (event) => { | ||
| return event.req.method !== "GET" && event.req.method !== "HEAD"; | ||
| transform: _statusHeader ? (entry) => { | ||
| const value = entry.value; | ||
| if (!value) return; | ||
| return { | ||
| ...value, | ||
| headers: { | ||
| ...value.headers, | ||
| [_statusHeader]: String(entry.status).toUpperCase() | ||
| } | ||
| }; | ||
| } : void 0, | ||
| serialize: async (entry) => { | ||
| const res = entry.value; | ||
| const bytes = new Uint8Array(await res.arrayBuffer()); | ||
| const text = _decodeUtf8(bytes); | ||
| const base64 = text === void 0; | ||
| const body = base64 ? _bytesToBase64(bytes) : text; | ||
| if (!res.headers.has("etag")) res.headers.set("etag", `W/"${hash(body)}"`); | ||
| if (!res.headers.has("last-modified")) res.headers.set("last-modified", (/* @__PURE__ */ new Date()).toUTCString()); | ||
| if (opts.sendCacheControl !== false && !res.headers.has("cache-control")) { | ||
| const cacheControl = []; | ||
| if (opts.swr) { | ||
| if (opts.maxAge != null) cacheControl.push(`s-maxage=${opts.maxAge}`); | ||
| if (opts.staleMaxAge != null) cacheControl.push(`stale-while-revalidate=${opts.staleMaxAge}`); | ||
| else cacheControl.push("stale-while-revalidate"); | ||
| } else if (opts.maxAge) cacheControl.push(`max-age=${opts.maxAge}`); | ||
| if (cacheControl.length > 0) res.headers.set("cache-control", cacheControl.join(", ")); | ||
| } | ||
| if (variableHeaderNames.length > 0) _appendVary(res.headers, variableHeaderNames); | ||
| if (typeof res.headers.getSetCookie === "function") { | ||
| const setCookies = res.headers.getSetCookie(); | ||
| const kept = setCookies.filter((c) => allowedCookieNames?.includes(_cookieName(c))); | ||
| if (kept.length !== setCookies.length) { | ||
| res.headers.delete("set-cookie"); | ||
| for (const c of kept) res.headers.append("set-cookie", c); | ||
| } | ||
| } else if (res.headers.has("set-cookie")) res.headers.delete("set-cookie"); | ||
| for (const header of _transportHeaders) res.headers.delete(header); | ||
| return { | ||
| status: res.status, | ||
| statusText: res.statusText, | ||
| headers: Object.fromEntries(res.headers.entries()), | ||
| body, | ||
| ...base64 && { base64: true } | ||
| }; | ||
| }, | ||
| shouldBypassCache: async (event) => { | ||
| if (_shouldBypassCache(event)) return true; | ||
| return await opts.shouldBypassCache?.(event) === true; | ||
| }, | ||
| getKey: async (event) => { | ||
| const customKey = await opts.getKey?.(event); | ||
| if (customKey) return escapeKey(customKey); | ||
| if (customKey) { | ||
| const _key = escapeKey(customKey); | ||
| return _key === customKey ? _key : `${_key.slice(0, 64)}.${hash(customKey)}`; | ||
| } | ||
| const _url = event.url ?? new URL(event.req.url); | ||
| const _path = _url.pathname + _url.search; | ||
| const _search = allowedQueryNames ? _filteredSearch(event, _url) : _url.search; | ||
| const _path = _url.pathname + _search; | ||
| let _pathname; | ||
@@ -334,9 +466,27 @@ try { | ||
| } | ||
| return [`${_pathname}.${hash(_path)}`, ...variableHeaderNames.map((header) => [header, event.req.headers.get(header)]).map(([name, value]) => `${escapeKey(name)}.${hash(value)}`)].join(":"); | ||
| const _hashedPath = `${_pathname}.${hash(_path)}`; | ||
| const _headers = variableHeaderNames.map((header) => [header, event.req.headers.get(header)]).map(([name, value]) => `${escapeKey(name)}.${hash(value)}`); | ||
| const _cookies = allowedCookieNames ? [`cookie.${hash(_filterCookie(event.req.headers.get("cookie"), allowedCookieNames))}`] : []; | ||
| return [ | ||
| _hashedPath, | ||
| ..._headers, | ||
| ..._cookies | ||
| ].join(":"); | ||
| }, | ||
| validate: (entry) => { | ||
| if (!entry.value) return false; | ||
| if (entry.value.status >= 400) return false; | ||
| if (entry.value.body === void 0) return false; | ||
| if (entry.value.headers.etag === "undefined" || entry.value.headers["last-modified"] === "undefined") return false; | ||
| validate: async (entry) => { | ||
| const value = entry.value; | ||
| if (!value) return false; | ||
| if (_forbidsSharedCaching(value.headers?.["cache-control"])) return false; | ||
| const _setCookie = value.headers?.["set-cookie"]; | ||
| if (_setCookie && !allowedCookieNames?.includes(_cookieName(_setCookie))) return false; | ||
| if (value.status >= 400) return false; | ||
| if (value.body === void 0) return false; | ||
| if (value.headers.etag === "undefined" || value.headers["last-modified"] === "undefined") return false; | ||
| if (opts.shouldCache) try { | ||
| if (await opts.shouldCache(value) === false) return false; | ||
| } catch (error) { | ||
| if (opts.onError) opts.onError(error); | ||
| else console.error("[cache] shouldCache hook error.", error); | ||
| return false; | ||
| } | ||
| return true; | ||
@@ -347,3 +497,3 @@ }, | ||
| }); | ||
| return async (event) => { | ||
| const cachedHandler = async (event) => { | ||
| if (opts.headersOnly) { | ||
@@ -353,3 +503,5 @@ if (_handleCacheHeaders(event, { maxAge: opts.maxAge })) return _createResponse(null, { status: 304 }); | ||
| } | ||
| const response = await _cachedHandler(event); | ||
| const cached = await _cachedHandler(event); | ||
| if (cached instanceof Response) return cached; | ||
| const response = cached; | ||
| if (_handleCacheHeaders(event, { | ||
@@ -359,4 +511,15 @@ modifiedTime: new Date(response.headers["last-modified"]), | ||
| maxAge: opts.maxAge | ||
| })) return _createResponse(null, { status: 304 }); | ||
| return _createResponse(response.body ?? null, { | ||
| })) { | ||
| const notModifiedHeaders = {}; | ||
| const statusValue = _statusHeader ? response.headers[_statusHeader] : void 0; | ||
| if (statusValue !== void 0) notModifiedHeaders[_statusHeader] = statusValue; | ||
| const varyValue = response.headers.vary; | ||
| if (varyValue !== void 0) notModifiedHeaders.vary = varyValue; | ||
| return _createResponse(null, { | ||
| status: 304, | ||
| headers: Object.keys(notModifiedHeaders).length > 0 ? notModifiedHeaders : void 0 | ||
| }); | ||
| } | ||
| const body = response.base64 && typeof response.body === "string" ? _base64ToBytes(response.body) : response.body ?? null; | ||
| return _createResponse(body, { | ||
| status: response.status, | ||
@@ -367,6 +530,83 @@ statusText: response.statusText, | ||
| }; | ||
| const _revalidate = cachedHandler; | ||
| _revalidate.resolveKeys = (event) => _cachedHandler.resolveKeys(event); | ||
| _revalidate.invalidate = (event) => _cachedHandler.invalidate(event); | ||
| _revalidate.expire = (event) => _cachedHandler.expire(event); | ||
| return _revalidate; | ||
| } | ||
| const _transportHeaders = [ | ||
| "content-encoding", | ||
| "content-length", | ||
| "transfer-encoding" | ||
| ]; | ||
| const _utf8Decoder = new TextDecoder("utf-8", { | ||
| fatal: true, | ||
| ignoreBOM: true | ||
| }); | ||
| function _decodeUtf8(bytes) { | ||
| try { | ||
| return _utf8Decoder.decode(bytes); | ||
| } catch { | ||
| return; | ||
| } | ||
| } | ||
| function _bytesToBase64(bytes) { | ||
| let binary = ""; | ||
| const CHUNK = 32768; | ||
| for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); | ||
| return btoa(binary); | ||
| } | ||
| function _base64ToBytes(base64) { | ||
| const binary = atob(base64); | ||
| const bytes = new Uint8Array(binary.length); | ||
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | ||
| return bytes; | ||
| } | ||
| function escapeKey(key) { | ||
| return String(key).replace(/\W/g, ""); | ||
| } | ||
| function _filterSearch(url, names) { | ||
| const filtered = new URLSearchParams(); | ||
| for (const name of names) for (const value of url.searchParams.getAll(name).sort()) filtered.append(name, value); | ||
| const query = filtered.toString(); | ||
| return query ? `?${query}` : ""; | ||
| } | ||
| function _filterCookie(header, names) { | ||
| if (!header) return ""; | ||
| const kept = []; | ||
| for (const part of header.split(";")) { | ||
| const eq = part.indexOf("="); | ||
| const name = (eq < 0 ? part : part.slice(0, eq)).trim(); | ||
| if (name && names.includes(name)) kept.push([name, eq < 0 ? "" : part.slice(eq + 1).trim()]); | ||
| } | ||
| kept.sort((a, b) => a[0] === b[0] ? a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0 : a[0] < b[0] ? -1 : 1); | ||
| return kept.map(([n, v]) => `${n}=${v}`).join("; "); | ||
| } | ||
| function _cookieName(setCookie) { | ||
| const eq = setCookie.indexOf("="); | ||
| return (eq < 0 ? setCookie.split(";")[0] : setCookie.slice(0, eq)).trim(); | ||
| } | ||
| function _appendVary(headers, names) { | ||
| const existing = headers.get("vary"); | ||
| if (existing && existing.split(",").some((part) => part.trim() === "*")) return; | ||
| const seen = /* @__PURE__ */ new Set(); | ||
| const merged = []; | ||
| const add = (raw) => { | ||
| const name = raw.trim(); | ||
| const key = name.toLowerCase(); | ||
| if (!name || seen.has(key)) return; | ||
| seen.add(key); | ||
| merged.push(name); | ||
| }; | ||
| if (existing) for (const part of existing.split(",")) add(part); | ||
| for (const name of names) add(name); | ||
| headers.set("vary", merged.join(", ")); | ||
| } | ||
| function _forbidsSharedCaching(cacheControl) { | ||
| if (typeof cacheControl !== "string" || !cacheControl) return false; | ||
| return cacheControl.split(",").some((directive) => { | ||
| const name = directive.trim().split("=")[0].toLowerCase(); | ||
| return name === "no-store" || name === "private"; | ||
| }); | ||
| } | ||
| function _integrityOpts(opts) { | ||
@@ -373,0 +613,0 @@ const { base: _, group: _g, name: _n, ...rest } = opts; |
+10
-10
| { | ||
| "name": "ocache", | ||
| "version": "0.1.5", | ||
| "description": "Standalone caching utilities with TTL, SWR, and HTTP response caching", | ||
| "version": "0.2.0", | ||
| "description": "Composable caching primitives with TTL, SWR, and HTTP response caching", | ||
| "license": "MIT", | ||
@@ -30,5 +30,5 @@ "repository": "unjs/ocache", | ||
| "devDependencies": { | ||
| "@types/node": "^25.9.2", | ||
| "@typescript/native-preview": "7.0.0-dev.20260609.1", | ||
| "@vitest/coverage-v8": "^4.1.8", | ||
| "@types/node": "^26.0.1", | ||
| "@typescript/native-preview": "latest", | ||
| "@vitest/coverage-v8": "^4.1.10", | ||
| "automd": "^0.4.3", | ||
@@ -38,9 +38,9 @@ "changelogen": "^0.6.2", | ||
| "mitata": "^1.0.34", | ||
| "obuild": "^0.4.36", | ||
| "oxfmt": "^0.54.0", | ||
| "oxlint": "^1.69.0", | ||
| "obuild": "^0.4.37", | ||
| "oxfmt": "^0.58.0", | ||
| "oxlint": "^1.73.0", | ||
| "typescript": "^6.0.3", | ||
| "vitest": "^4.1.8" | ||
| "vitest": "^4.1.10" | ||
| }, | ||
| "packageManager": "pnpm@11.5.2" | ||
| "packageManager": "pnpm@11.10.0" | ||
| } |
+45
-127
@@ -10,2 +10,15 @@ # ocache | ||
| Composable caching primitives with TTL, stale-while-revalidate, and HTTP response caching. Zero framework dependencies — works with any runtime that has standard `Request`/`Response`. | ||
| > [!TIP] | ||
| > 📖 Head to the [documentation](https://ocache.unjs.io/guide) to learn more. | ||
| ## Features | ||
| - 🗃️ **[Function caching](https://ocache.unjs.io/guide/functions)** — wrap any function with TTL, stale-while-revalidate, and request deduplication. | ||
| - 🌐 **[HTTP response caching](https://ocache.unjs.io/guide/handler)** — automatic `etag`, `last-modified`, and `304 Not Modified` support. | ||
| - 🔑 **[Smart cache keys](https://ocache.unjs.io/guide/query-params)** — derived from arguments or request URL, with per-header and per-query variance. | ||
| - 🔌 **[Pluggable storage](https://ocache.unjs.io/guide/storage)** — bring your own backend via a minimal `get`/`set` interface. | ||
| - ♻️ **[Invalidation & expiration](https://ocache.unjs.io/guide/invalidation)** — remove or mark entries stale on demand, with SWR background refresh. | ||
| ## Usage | ||
@@ -35,21 +48,5 @@ | ||
| #### Options | ||
| > [!NOTE] | ||
| > Learn more in the [Caching Functions](https://ocache.unjs.io/guide/functions) guide, and see [Invalidation & Expiration](https://ocache.unjs.io/guide/invalidation) and [Storage](https://ocache.unjs.io/guide/storage). | ||
| ```ts | ||
| const cached = defineCachedFunction(fn, { | ||
| name: "my-fn", // Cache key name (defaults to function name) | ||
| maxAge: 10, // TTL in seconds (default: 1) | ||
| swr: true, // Stale-while-revalidate (default: true) | ||
| staleMaxAge: 60, // Max seconds to serve stale content | ||
| base: "/cache", // Base prefix for cache keys (string or string[] for multi-tier) | ||
| group: "my-group", // Cache key group (default: "functions") | ||
| getKey: (...args) => "custom-key", // Custom cache key generator | ||
| shouldBypassCache: (...args) => false, // Skip cache entirely when true | ||
| shouldInvalidateCache: (...args) => false, // Force refresh when true | ||
| validate: (entry) => entry.value !== undefined, // Custom validation | ||
| transform: (entry) => entry.value, // Transform before returning | ||
| onError: (error) => console.error(error), // Error handler | ||
| }); | ||
| ``` | ||
| ### Caching HTTP Handlers | ||
@@ -75,3 +72,4 @@ | ||
| staleMaxAge: 600, | ||
| varies: ["accept-language"], // Vary cache by these headers | ||
| varies: ["accept-language"], // Vary cache key by these headers (also emitted as `Vary`) | ||
| allowQuery: ["color"], // Vary cache by these query params only | ||
| }, | ||
@@ -81,129 +79,47 @@ ); | ||
| #### Headers-only Mode | ||
| > [!NOTE] | ||
| > Learn more in the [Caching HTTP Handlers](https://ocache.unjs.io/guide/handler) guide, and see [Query Parameters](https://ocache.unjs.io/guide/query-params), [Cookies](https://ocache.unjs.io/guide/cookies), [Cache-Control & Eligibility](https://ocache.unjs.io/guide/cache-control), and [Incremental Static Regeneration](https://ocache.unjs.io/guide/isr). | ||
| Use `headersOnly` to handle conditional requests without caching the full response: | ||
| ## API | ||
| ```ts | ||
| const handler = defineCachedHandler(myHandler, { | ||
| headersOnly: true, | ||
| maxAge: 60, | ||
| }); | ||
| ``` | ||
| <!-- automd:docs4ts --> | ||
| ### Cache Invalidation | ||
| ### `CachedEventHandler` | ||
| Cached functions have an `.invalidate()` method that removes cached entries across all base prefixes: | ||
| ```ts | ||
| import { defineCachedFunction } from "ocache"; | ||
| const getUser = defineCachedFunction(async (id: string) => db.users.find(id), { | ||
| name: "getUser", | ||
| maxAge: 60, | ||
| getKey: (id: string) => id, | ||
| }); | ||
| const user = await getUser("user-123"); | ||
| // Invalidate a specific entry | ||
| await getUser.invalidate("user-123"); | ||
| // Next call will re-invoke the function | ||
| const freshUser = await getUser("user-123"); | ||
| type CachedEventHandler<E extends HTTPEvent = HTTPEvent> = EventHandler<E> & | ||
| ``` | ||
| You can also use the standalone `invalidateCache()` when you don't have a reference to the cached function — just pass the same options: | ||
| Cached event handler returned by `defineCachedHandler`. | ||
| ```ts | ||
| import { invalidateCache } from "ocache"; | ||
| An [`EventHandler`](#eventhandler) augmented with on-demand revalidation methods forwarded from | ||
| the underlying cached function. Each accepts the [`HTTPEvent`](#httpevent) directly and derives | ||
| the exact storage key the handler caches under, so no manual key reconstruction is needed. | ||
| await invalidateCache({ | ||
| options: { name: "getUser", getKey: (id: string) => id }, | ||
| args: ["user-123"], | ||
| }); | ||
| ``` | ||
| --- | ||
| For advanced use cases, `.resolveKeys()` returns the raw storage keys: | ||
| ### `cachedFunction` | ||
| ```ts | ||
| const keys = await getUser.resolveKeys("user-123"); | ||
| // ["/cache:functions:getUser:user-123.json"] | ||
| const cachedFunction = defineCachedFunction; | ||
| ``` | ||
| ### Cache Expiration (SWR refresh) | ||
| Alias for [`defineCachedFunction`](#definecachedfunction). | ||
| While `.invalidate()` removes an entry entirely (the next call must wait for a fresh value), `.expire()` only marks it as stale. With SWR enabled, stale values keep being served — still bounded by the originally configured `staleMaxAge` window — and the next access triggers a background refresh: | ||
| --- | ||
| ```ts | ||
| // Mark the entry stale: next call serves the stale value and refetches in the background | ||
| await getUser.expire("user-123"); | ||
| ``` | ||
| ### `CacheStatus` | ||
| The standalone `expireCache()` works like `invalidateCache()` — pass the same `maxAge` / `swr` / `staleMaxAge` options you cache with so the remaining storage TTL is preserved: | ||
| ```ts | ||
| import { expireCache } from "ocache"; | ||
| await expireCache({ | ||
| options: { name: "getUser", getKey: (id: string) => id, maxAge: 60, staleMaxAge: 300 }, | ||
| args: ["user-123"], | ||
| }); | ||
| type CacheStatus = "hit" | "stale" | "revalidated" | "miss"; | ||
| ``` | ||
| ### Multi-tier Caching | ||
| How a cached value was served on a given call. | ||
| Use an array of `base` prefixes to enable multi-tier caching. On read, each prefix is tried in order and the first hit is used. On write, the entry is written to all prefixes: | ||
| - `"hit"` — a fresh cached value was returned without re-resolving. | ||
| - `"stale"` — a stale value was served while a background SWR refresh runs. | ||
| - `"revalidated"` — a prior value existed but was expired/invalid, so it was | ||
| re-resolved in the foreground (no stale value served) before returning. | ||
| - `"miss"` — the value was resolved fresh on this call (nothing was cached). | ||
| ```ts | ||
| const cachedFetch = defineCachedFunction( | ||
| async (url: string) => { | ||
| const res = await fetch(url); | ||
| return res.json(); | ||
| }, | ||
| { | ||
| maxAge: 60, | ||
| base: ["/tmp", "/cache"], | ||
| }, | ||
| ); | ||
| ``` | ||
| This is useful for layered cache setups (e.g., fast local cache + shared remote cache) where you want reads to prefer the nearest tier while keeping all tiers populated on writes. | ||
| ### Custom Storage | ||
| By default, ocache uses an in-memory `Map`-based storage. You can provide a custom storage implementation: | ||
| ```ts | ||
| import { setStorage } from "ocache"; | ||
| import type { StorageInterface } from "ocache"; | ||
| const redisStorage: StorageInterface = { | ||
| get: async (key) => { | ||
| return JSON.parse(await redis.get(key)); | ||
| }, | ||
| set: async (key, value, opts) => { | ||
| // Setting null/undefined deletes the entry (used for cache invalidation) | ||
| if (value === null || value === undefined) { | ||
| await redis.del(key); | ||
| return; | ||
| } | ||
| await redis.set(key, JSON.stringify(value), opts?.ttl ? { EX: opts.ttl } : undefined); | ||
| }, | ||
| }; | ||
| setStorage(redisStorage); | ||
| ``` | ||
| ## API | ||
| <!-- automd:docs4ts --> | ||
| ### `cachedFunction` | ||
| ```ts | ||
| const cachedFunction = defineCachedFunction; | ||
| ``` | ||
| Alias for [`defineCachedFunction`](#definecachedfunction). | ||
| --- | ||
@@ -214,6 +130,6 @@ | ||
| ```ts | ||
| function createMemoryStorage(): StorageInterface; | ||
| function createMemoryStorage(opts: MemoryStorageOptions = | ||
| ``` | ||
| Creates an in-memory storage backed by a `Map` with optional TTL support (in seconds). | ||
| Creates an in-memory storage backed by a `Map` with optional TTL support (in seconds) and LRU eviction. | ||
@@ -260,3 +176,5 @@ --- | ||
| **Returns:** — A new event handler that serves cached responses when available. | ||
| **Returns:** — A new event handler that serves cached responses when available. The handler | ||
| also exposes `.resolveKeys(event)`, `.invalidate(event)`, and `.expire(event)` for | ||
| on-demand revalidation, keyed exactly as the handler caches (no key reconstruction). | ||
@@ -263,0 +181,0 @@ --- |
56772
56.65%601
66.48%323
-20.25%