Cache
In-memory caching with a pluggable store interface, cache-aside helpers, and HTTP response cache headers.
Setup
Using a service provider (recommended)
import { CacheProvider } from '@strav/kernel'
app.use(new CacheProvider())
The CacheProvider registers CacheManager as a singleton. It depends on the config provider.
Manual setup
import { CacheManager } from '@strav/kernel'
app.singleton(CacheManager)
app.resolve(CacheManager)
Create config/cache.ts:
import { env } from '@strav/kernel'
export default {
default: env('CACHE_DRIVER', 'memory'),
prefix: env('CACHE_PREFIX', 'app:'),
ttl: env.int('CACHE_TTL', 3600),
}
Cache helpers
Thecache object is the primary API. All keys are automatically prefixed with the configured prefix.
import { cache } from '@strav/kernel'
remember (cache-aside)
The most common pattern — return a cached value or compute and cache it:
const user = await cache.remember(`user:${id}`, 300, () => User.find(id))
const stats = await cache.remember('dashboard:stats', 60, async () => {
const [users, projects] = await Promise.all([User.count(), Project.count()])
return { users, projects }
})
The factory is only called on a cache miss. The result is stored with the given TTL (in seconds).
rememberForever
Same asremember but without expiry:
const config = await cache.rememberForever('app:config', () => loadExpensiveConfig())
get / set
await cache.set('features', { darkMode: true }, 3600) // TTL in seconds
await cache.set('features', { darkMode: true }) // uses config default TTL
const features = await cache.get<{ darkMode: boolean }>('features')
// { darkMode: true } or null
has / forget / flush
await cache.has('features') // true
await cache.forget('features') // remove one key
await cache.flush() // clear everything
HTTP cache middleware
SetsCache-Control, ETag, and Vary headers on responses. The browser or CDN does the actual caching — this middleware only controls the headers.
import { httpCache } from '@strav/http'
Basic usage
router.group({ prefix: '/api/public', middleware: [httpCache({ maxAge: 300 })] }, (r) => {
r.get('/categories', listCategories)
})
Options
httpCache({
maxAge: 300, // Cache-Control max-age in seconds (default: 0)
sMaxAge: 600, // s-maxage for shared caches (CDN)
directives: ['public'], // default: ['public']
etag: true, // compute weak ETag from response body (default: false)
vary: ['Accept-Encoding'], // Vary header values (default: ['Accept-Encoding'])
skip: (ctx) => ctx.path === '/health', // bypass for certain requests
})
ETag and 304
Whenetag: true, the middleware computes a weak ETag from an MD5 hash of the response body. If the client sends a matching If-None-Match header, a 304 Not Modified is returned with no body.
router.use(httpCache({ maxAge: 60, etag: true }))
Directives
Available directives:public, private, no-cache, no-store, must-revalidate, immutable.
// Immutable versioned assets
httpCache({ maxAge: 31536000, directives: ['public', 'immutable'] })
// Private, must revalidate
httpCache({ directives: ['private', 'must-revalidate'], maxAge: 0 })
The middleware only applies to GET and HEAD requests — POST/PUT/DELETE responses are passed through unchanged.
Custom store
The defaultMemoryCacheStore uses a Map with lazy TTL eviction — suitable for single-process deployments. For distributed setups, implement the CacheStore interface and swap it in:
import type { CacheStore } from '@strav/kernel'
import { CacheManager } from '@strav/kernel'
class RedisCacheStore implements CacheStore {
private redis = new Bun.RedisClient()
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key)
return value ? JSON.parse(value) : null
}
async set(key: string, value: unknown, ttl?: number): Promise<void> {
const serialized = JSON.stringify(value)
if (ttl) {
await this.redis.set(key, serialized, 'EX', ttl)
} else {
await this.redis.set(key, serialized)
}
}
async has(key: string): Promise<boolean> {
return (await this.redis.exists(key)) === 1
}
async forget(key: string): Promise<void> {
await this.redis.del(key)
}
async flush(): Promise<void> {
await this.redis.flushdb()
}
}
// In bootstrap, after resolving CacheManager
CacheManager.useStore(new RedisCacheStore())
Controller example
import { cache } from '@strav/kernel'
export default class ProjectController {
async index(ctx: Context) {
const org = ctx.get<Organization>('organization')
const projects = await cache.remember(
`org:${org.id}:projects`,
120,
() => Project.where('organization_id', org.id).all()
)
return ctx.json({ projects })
}
async update(ctx: Context) {
const project = ctx.get('resource') as Project
const data = await ctx.body<Record<string, unknown>>()
await project.fill(data).save()
// Invalidate cache after mutation
await cache.forget(`org:${project.organizationId}:projects`)
return ctx.json({ project })
}
}