-
Notifications
You must be signed in to change notification settings - Fork 0
services cache
io.cache is an application-governed response cache for GET requests, attached and on by default. It is not an HTTP cache: it does not read Cache-Control or max-age. TTLs come from your call, the app decides what to store, and the app decides when to sweep.
import io from 'double-meh';
const a = await io.get('https://example.com/users/42');
const b = await io.get('https://example.com/users/42');
// second call is served from the cache, no network requestGETs are cached by default; cache: false opts a request out. Use cache: {ttl} (milliseconds) to override the default TTL, or cache: true to force caching for a request the default predicate excludes. Hard gates apply regardless: only GET is ever cached (cache: true on a POST does nothing), and stream: true or bust requests are never stored.
await io.get('https://example.com/c'); // cached with the default TTL
await io.get('https://example.com/c', null, {cache: {ttl: 30_000}});
await io.get('https://example.com/c', null, {cache: false}); // straight to the networkio.cache.theDefault — a boolean or a predicate (options) => boolean — decides which requests are cached when they don't say. The shipped default is options => !options.transport: every GET on the default transport.
io.cache.theDefault = options => options.url.startsWith('https://api.example.com/');io.cache.defaultTtl is 5 minutes. Each entry stores an absolute expiresAt; a fresh entry is served directly, an expired one is refetched. Pass ttl: Infinity to keep an entry until you evict it, or ttl: 0 to force a refetch every time.
io.cache.defaultTtl = 60_000;
await io.get('https://example.com/t', null, {cache: {ttl: 0}}); // always refetchedWhen a stale entry carries an ETag or Last-Modified, the next read attaches If-None-Match / If-Modified-Since. A 304 Not Modified reuses the stored body, merges the 304's headers into the stored entry (all but content-length), and refreshes the entry's expiresAt — no re-download.
await io.get('https://example.com/r', null, {cache: {ttl: 0}});
// stale entry with ETag "v1" → conditional request → 304 → cached body reused
const body = await io.get('https://example.com/r', null, {cache: {ttl: 0}});Only ok (2xx) responses are ever stored; error responses pass straight through.
Cache identity is representation-aware on both sides of the wire:
-
Request side — the effective
Acceptextends the cache key (see keys & URLs), so a JSON read and a CSV read of the same URL are two entries that coexist; neither overwrites the other. -
Response side — when a response names selecting headers via
Vary(sayVary: Accept-Language), the entry snapshots the request's values for them. A later read whose values differ is a miss and refetches (one variant per key: the new response replaces the old).Vary: *marks the response uncacheable — it is never stored.
await io.get('/report'); // JSON entry
await io.get('/report', null, {accept: 'text/csv'}); // CSV entry — both cached
await io.get('/doc', null, {headers: {'accept-language': 'de'}});
// if the response says Vary: Accept-Language, an 'en' read refetches instead of serving Germanio.cache.remove(pattern) evicts by:
- an exact URL string,
- a trailing-
*prefix string, - a
RegExptested against the stored key, - a function
(key) => boolean.
await io.cache.remove('https://example.com/users/42'); // exact
await io.cache.remove('https://example.com/users/*'); // prefix
await io.cache.remove(/\/users\//); // RegExp on the key
await io.cache.remove(key => key.includes('draft')); // predicateAn exact URL owns all of its accept variants — removing it evicts the JSON and the CSV entry alike (io.update's invalidation relies on this).
With the invalidation channel installed, the string forms also propagate to other tabs (and a controlling service worker's shared tier); RegExp and predicate removals stay local.
io.cache.clear() drops everything. io.cache.sweep() deletes only expired entries — it is app-called, nothing runs it for you, so call it on your own schedule (e.g. an interval or idle callback).
await io.cache.clear();
await io.cache.sweep(); // purge entries past their expiresAtThe backend lives at io.cache.storage and defaults to an in-memory Map. Any object implementing get / set / delete / clear / keys (sync or async) can replace it. Persistent backends ship with the library — filesystem and SQLite for CLIs, the Cache API for browsers — see Storage backends.
import {fsStorage} from 'double-meh/storage/fs.js';
io.cache.storage = fsStorage({name: 'my-app'}); // survives runs, shared across processesio.cache.save(target, response, ttl?) stores a response directly. io.adopt uses it under the hood, so an adopted GET is durable past its in-flight window and later reads hit without a network call.
The adopted envelope resolves without waiting for the storage write — a slow persistent backend never delays adopters. When ordering matters (a test asserting the very next GET hits the cache), io.cache.idle() awaits all outstanding writes.
await io.adopt('https://example.com/cf', json({from: 'prefetch'}));
await io.cache.idle(); // storage write landed
const data = await io.get('https://example.com/cf'); // no networkThe main entry attaches the cache for you. io.cache.detach() removes the service; io.cache.attach() reinstalls it. Both maintain io.cache.isActive and return io for chaining.
io.cache.detach();
io.cache.attach();Guides
Concepts
Services
Modules
- fetch transport
- keys & URLs
- helpers
- records
- sse
- storage backends
- compression encoders
- Service Worker integration
Cookbook