Features

Response Cache

Rezo includes a built-in ResponseCache that stores HTTP responses in an LRU (Least Recently Used) cache. It supports memory-only and disk-persistent modes, respects Cache-Control directives, performs conditional GET revalidation with ETag and Last-Modified, and integrates with the beforeCache hook for custom caching logic.

Quick Start

import { Rezo } from 'rezo';

// Enable caching with defaults (memory only, 30 min TTL, 500 entries)
const client = new Rezo({
  cache: true
});

// First request: fetches from server, caches response
const { data } = await client.get('https://api.example.com/users');

// Second request: served from cache (no network request)
const { data: cached } = await client.get('https://api.example.com/users');

Configuration

The cache option on the Rezo constructor accepts several formats.

Boolean

cache: true enables both response caching and DNS caching with default settings:

const client = new Rezo({ cache: true });

Object (Fine-Grained Control)

const client = new Rezo({
  cache: {
    response: {
      enable: true,
      ttl: 600000,           // 10 minutes
      maxEntries: 1000,
      methods: ['GET', 'HEAD'],
      respectHeaders: true,
      cacheDir: '/tmp/rezo-cache'  // Enable disk persistence
    },
    dns: true  // Also enable DNS caching
  }
});

ResponseCacheConfig

interface ResponseCacheConfig {
  /** Enable or disable the cache */
  enable?: boolean;
  /** Directory path for disk persistence (omit for memory-only) */
  cacheDir?: string;
  /** Whether to check network before serving cached responses */
  networkCheck?: boolean;
  /** Time-to-live for cached entries in milliseconds */
  ttl?: number;
  /** Maximum number of entries in the LRU cache */
  maxEntries?: number;
  /** HTTP methods to cache */
  methods?: string[];
  /** Whether to respect Cache-Control headers from responses */
  respectHeaders?: boolean;
}

Defaults

OptionDefaultDescription
enabletrueCache is active
ttl3000000 (30 min)Time-to-live per entry
maxEntries500LRU cache capacity
methods['GET', 'HEAD']Only cache safe methods
respectHeaderstrueObey Cache-Control directives
cacheDirundefinedMemory-only (no disk)

Memory and Disk Persistence

Memory-Only (Default)

When no cacheDir is specified, all cached responses live in memory. The LRU cache evicts the least recently used entries when maxEntries is exceeded.

const client = new Rezo({
  cache: { response: { enable: true, maxEntries: 200 } }
});

Disk Persistence

When cacheDir is provided, the cache persists entries to disk as JSON files. This allows cached responses to survive process restarts.

const client = new Rezo({
  cache: {
    response: {
      enable: true,
      cacheDir: '/var/cache/rezo',
      ttl: 86400000  // 24 hours
    }
  }
});

Disk persistence works as follows:

  • On eviction: When an entry is evicted from the in-memory LRU (due to capacity), it is written to disk as {base64url-encoded-key}.json
  • On cache miss: If an entry is not in memory, Rezo checks disk. If found and not expired, it is loaded back into memory.
  • On startup: Existing disk cache files are loaded into memory. Expired files are deleted.
  • Non-blocking: Disk writes are fire-and-forget (async, errors silently ignored) to avoid slowing down requests.

isCacheable()

The isCacheable() method determines whether a response should be cached based on the current configuration:

const cache = new ResponseCache({
  enable: true,
  methods: ['GET', 'HEAD'],
  respectHeaders: true
});

cache.isCacheable('GET', 200);          // true
cache.isCacheable('POST', 200);         // false (POST not in methods)
cache.isCacheable('GET', 404);          // false (non-2xx status)
cache.isCacheable('GET', 200, {         // false (no-store directive)
  'cache-control': 'no-store'
});

Rules checked in order:

  1. Is caching enabled?
  2. Is the HTTP method in the methods list?
  3. Is the status code 2xx (200-299)?
  4. If respectHeaders: true, does Cache-Control contain no-store?

Conditional GET (ETag / Last-Modified)

The response cache supports HTTP conditional requests for efficient revalidation. When a cached response contains ETag or Last-Modified headers, subsequent requests include validation headers:

How It Works

  1. First request: Server responds with ETag: "abc123" and Last-Modified: Thu, 01 Jan 2026 00:00:00 GMT. Rezo caches the full response.

  2. Revalidation request: Rezo sends If-None-Match: "abc123" and If-Modified-Since: Thu, 01 Jan 2026 00:00:00 GMT.

  3. 304 Not Modified: Server confirms the cached version is still valid. Rezo updates the cache TTL and returns the cached data without transferring the body.

  4. 200 OK: Server sends a new response. Rezo replaces the cached entry.

getConditionalHeaders()

Returns the conditional headers to send for revalidation, based on the cached response:

const headers = cache.getConditionalHeaders('GET', 'https://api.example.com/data');
// { 'If-None-Match': '"abc123"', 'If-Modified-Since': 'Thu, 01 Jan 2026 00:00:00 GMT' }
// or undefined if no cached entry exists

updateRevalidated()

When a 304 response is received, updates the cached entry with new headers and refreshes the TTL:

// After receiving a 304 response:
const updated = cache.updateRevalidated('GET', url, newHeaders);
// Returns the updated CachedResponse with refreshed timestamp and TTL

If the 304 response includes Cache-Control: no-store, the cached entry is deleted instead.

Cache-Control Compliance

When respectHeaders: true (the default), the cache parses and respects Cache-Control directives:

no-store

Response is never cached. If already cached, it is removed.

Cache-Control: no-store

no-cache

Response is stored but must be revalidated with the server before use. The cache stores the entry but marks it for conditional GET on subsequent requests.

Cache-Control: no-cache

must-revalidate

Once the cached entry expires, it must be revalidated before use. Stale entries cannot be served.

Cache-Control: must-revalidate

max-age

Sets the TTL for the cached entry, overriding the configured default TTL.

Cache-Control: max-age=3600  -> cache for 1 hour (3,600,000ms)

s-maxage

Like max-age but for shared caches. When present, it takes priority over max-age.

Cache-Control: s-maxage=7200  -> cache for 2 hours

Cache Key Generation

Cache keys are generated from the HTTP method, URL, and select request headers:

GET:https://api.example.com/data:accept=application/json:encoding=gzip

The key includes:

  • HTTP method (uppercased)
  • Full URL
  • Accept header (if present)
  • Accept-Encoding header (if present)

This ensures different content negotiations produce different cache entries.

Invalidation

invalidate(url, method?)

Remove cached entries for a specific URL. If method is omitted, all cached methods for that URL are invalidated.

// Invalidate GET cache for a specific URL
client.invalidateCache('https://api.example.com/users');

// Invalidate only HEAD cache
client.invalidateCache('https://api.example.com/users', 'HEAD');

Also removes the corresponding disk file when persistence is enabled.

clear()

Remove all cached entries from memory and disk.

client.clearCache();

Cache Statistics

const stats = client.getCacheStats();
// {
//   response: { size: 42, enabled: true, persistent: true },
//   dns: { size: 15, enabled: true }
// }

// Direct access
console.log(client.responseCache?.size);         // 42
console.log(client.responseCache?.isEnabled);     // true
console.log(client.responseCache?.isPersistent);  // true

The beforeCache Hook

The beforeCache hook fires before a response is stored in the cache. Return false to prevent caching.

const client = new Rezo({
  cache: true,
  hooks: {
    beforeCache: [
      (event) => {
        // Never cache user-specific data
        if (event.url.includes('/me') || event.url.includes('/profile')) {
          return false;
        }

        // Never cache if server says private
        if (event.cacheControl?.private) {
          return false;
        }

        // Never cache error-like responses wrapped in 200
        // (isCacheable already filters non-2xx)
      }
    ]
  }
});

CacheEvent

interface CacheEvent {
  /** HTTP status code */
  status: number;
  /** Response headers */
  headers: RezoHeaders;
  /** URL being cached */
  url: string;
  /** Cache key */
  cacheKey?: string;
  /** Whether the response is cacheable by default rules */
  isCacheable: boolean;
  /** Parsed Cache-Control directives */
  cacheControl?: {
    maxAge?: number;
    sMaxAge?: number;
    noCache?: boolean;
    noStore?: boolean;
    mustRevalidate?: boolean;
    private?: boolean;
    public?: boolean;
  };
}

Cached Response Structure

interface CachedResponse {
  status: number;
  statusText: string;
  headers: Record<string, string>;
  data: unknown;
  url: string;
  timestamp: number;
  ttl: number;
  etag?: string;
  lastModified?: string;
}

Practical Examples

API Gateway with Caching

const api = new Rezo({
  baseURL: 'https://api.example.com',
  cache: {
    response: {
      enable: true,
      ttl: 300000,          // 5 minutes
      maxEntries: 1000,
      respectHeaders: true  // Server can override TTL
    }
  }
});

// Cached automatically
const users = await api.get('/users');

// Invalidate after mutation
await api.post('/users', { name: 'Alice' });
api.invalidateCache('https://api.example.com/users');

Persistent Cache for CLI Tool

import { join } from 'node:path';
import { homedir } from 'node:os';

const client = new Rezo({
  cache: {
    response: {
      enable: true,
      cacheDir: join(homedir(), '.myapp', 'http-cache'),
      ttl: 86400000,      // 24 hours
      maxEntries: 5000
    }
  }
});

// Cached responses survive process restarts
const { data } = await client.get('https://registry.npmjs.org/rezo');

Selective Caching with Hooks

const client = new Rezo({
  cache: { response: { enable: true, ttl: 600000 } },
  hooks: {
    beforeCache: [
      (event) => {
        // Only cache responses with explicit cache headers
        if (!event.cacheControl?.maxAge && !event.headers.get('etag')) {
          return false;
        }
      }
    ]
  }
});

Cache with Revalidation

const client = new Rezo({
  cache: {
    response: {
      enable: true,
      ttl: 60000,           // 1 minute in-memory TTL
      respectHeaders: true   // But server's max-age takes priority
    }
  }
});

// First request: full response, cached with ETag
await client.get('https://api.example.com/config');

// After TTL expires: conditional GET with If-None-Match
// If 304: cache refreshed, no body transferred
// If 200: new data cached
await client.get('https://api.example.com/config');