cache
An LRU/KV cache in front of head(), url(), and small download()s. Repeat reads of an unchanged key are served from memory; writes through the instance invalidate the affected key. Body-transparent, no native dependencies, works on any adapter.
The built-in cache() plugin puts an LRU (or your own KV) in front of the cheap read verbs. A repeat head() or url() - and, opt-in, a small download() - for an unchanged key is served from memory instead of round-tripping to the provider. Any write through the instance (upload, delete, copy, move) invalidates the affected key, so the next read re-fetches.
It writes no object metadata and has no native dependencies, so it works on any adapter. Like the other plugins it runs outside retries - a cache hit skips the retry loop entirely.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { cache } from "files-sdk/cache";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [cache()],
});
await files.head("a.png"); // miss → provider
await files.head("a.png"); // hit → memory
await files.upload("a.png", body); // invalidates "a.png"
await files.head("a.png"); // miss → provider againinvalidateCache(), cacheStats(), and resetCacheStats() are contributed
by the plugin's extend, so they only appear on the type when you
construct with createFiles (identical to new Files() at runtime).
What gets cached
By default cache() caches the two cheap, body-free verbs - head and url. Pass operations to change the set:
cache({ operations: ["head", "url", "download"] });headcaches the metadata only. A hit returns aStoredFilewhose body still lazy-fetches on access - exactly the contract an uncachedheadhas - so nothing is buffered up front.urlcaches the returned string, keyed per url-options signature (so a plainurl()and aurl({ expiresIn })cache apart). Each entry is additionally capped at its ownexpiresIn, so a presigned URL is never handed out past its signature.downloadis off by default. With"download"enabled, only known-length bodies at or undermaxBytes(default 1 MiB) are buffered and cached; anything larger - or of unknown length - streams straight through uncached, so streaming and range downloads keep working. A cached small body is re-served as a fresh, re-readableStoredFile.
cache({
operations: ["head", "url", "download"],
maxBytes: 256 * 1024, // only cache downloads ≤ 256 KiB
});Invalidation
Caching is only safe because writes evict. Every mutation through the instance drops the affected key's entire record (all of its cached verbs) once the write lands:
| Write | Invalidates |
|---|---|
upload(key) | key |
delete(key) | key |
copy(from, to) | to (destination) |
move(from, to) | from and to |
Invalidation is keyed by the caller-facing key - never the internal prefixed path - so it lines up with the keys reads are cached under.
Writes the cache can't see
A change the plugin never observes won't invalidate: an upload made through a presigned URL, or a mutation straight against the provider. Treat the cache as eventually-consistent and evict by hand when that happens:
await files.invalidateCache("a.png"); // drop one key
await files.invalidateCache(); // drop everythingStats
cacheStats() returns a fresh { hits, misses } snapshot for tuning your TTL and entry budget; resetCacheStats() starts a new window:
files.cacheStats(); // { hits: 41, misses: 9 }
files.resetCacheStats();The store
By default the cache is a bounded in-memory LRU keyed by object key, holding maxEntries keys (default 1000) before evicting the least-recently-used. Each key's record bundles every cached verb together, which is what makes invalidation a single delete.
Worst-case memory is roughly maxEntries * maxBytes once download caching
is on. The default set (head + url) stores only small metadata, so the
entry count is the only thing to size.
Pass your own store to share the cache across instances or processes - e.g. a Redis-backed CacheStore that serializes each record:
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [cache({ store: myRedisStore })],
});A CacheStore is four methods - get, set, delete, clear - each of which may be sync or async. A distributed store has an inherent read-modify-write race when two different verbs for the same key are first cached at the exact same instant; it's harmless - it just costs a re-fetch next time.
TTL
Every entry honors a ttl (default 60_000 ms). Set 0 to disable time-based expiry entirely (entries then live until evicted or invalidated):
cache({ ttl: 30_000 });For url, keep ttl comfortably below your signed-URL expiry. The per-entry expiresIn cap guarantees you never serve a dead URL, but a short ttl keeps the URLs you hand out fresh with plenty of life left.
Ordering
Place cache() first (outermost) so a hit short-circuits before the rest of the pipeline does any work:
plugins: [cache(), encryption(key)];Put it after a body-transforming plugin only if you deliberately want to cache the transformed bytes (e.g. caching post-compression() output).
Things to keep in mind
- A cache is eventually-consistent. Out-of-band writes (presigned uploads, direct provider changes) won't invalidate - call
invalidateCache(), and keep thettlhonest. downloadcaching buffers bodies. It's gated to small, known-length objects for exactly this reason; large and unknown-length downloads always stream through untouched.- Bound your memory. With
downloadenabled, sizemaxEntriesandmaxBytestogether - the product is your ceiling. - It's per-instance by default. The in-memory store lives with the
Filesinstance. Reach for a sharedstoreto cache across processes.
audit
Write a structured who/what/when record of every mutation to an awaited sink - the durable, awaitable counterpart to the fire-and-forget onAction hook. One record per operation carrying the verb, caller-facing key, actor, time, duration, and outcome. Body-transparent, no metadata, no native dependencies.
compression
Transparently compress object bodies at rest with gzip, deflate, or deflate-raw. The original size and algorithm ride along in metadata, and reads decompress automatically - provider-agnostic, no native dependencies.