Skip to content

Commit 504a982

Browse files
committed
feat: cache() function for server-side RPC query caching
Simple, explicit, no magic: export const listPosts = cache( async () => prisma.post.findMany(), { key: 'posts', ttl: 60 } ); // Invalidate after mutations await listPosts.invalidate(); Backed by the cache store (memory default, Redis if user configures). For HTTP-level caching, use metadata.cacheControl on pages instead.
1 parent 74edab3 commit 504a982

2 files changed

Lines changed: 86 additions & 0 deletions

File tree

packages/server/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export { headers, cookies, getRequest, withRequest } from './src/context.js';
1717
export { defaultLogger } from './src/logger.js';
1818
export { rateLimit, parseWindow } from './src/rate-limit.js';
1919
export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
20+
export { cache } from './src/cache-fn.js';
2021
export { session, cookieSession, storeSession, getSession } from './src/session.js';
2122
export { broadcast } from './src/broadcast.js';
2223
export { json, readBody } from './src/json.js';

packages/server/src/cache-fn.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Server-side function caching for RPC queries.
3+
*
4+
* Wraps an async function with a cache layer backed by the cache store.
5+
* Same function + same arguments = cached result until TTL expires.
6+
*
7+
* ```js
8+
* import { cache } from '@webjs/server';
9+
*
10+
* export const listPosts = cache(
11+
* async () => prisma.post.findMany({ orderBy: { createdAt: 'desc' } }),
12+
* { key: 'posts', ttl: 60 }
13+
* );
14+
*
15+
* // Call it normally — first call hits DB, subsequent calls serve cache
16+
* const posts = await listPosts();
17+
* ```
18+
*
19+
* For page-level HTTP caching, use `metadata.cacheControl` instead —
20+
* that sets standard Cache-Control headers for browsers and CDNs.
21+
* This `cache()` is for server-side query result caching.
22+
*
23+
* @module cache-fn
24+
*/
25+
26+
import { getStore } from './cache.js';
27+
28+
/**
29+
* Wrap an async function with server-side caching.
30+
*
31+
* @template {(...args: any[]) => Promise<any>} T
32+
* @param {T} fn The async function to cache.
33+
* @param {{
34+
* key: string,
35+
* ttl?: number,
36+
* }} opts
37+
* - `key`: cache key prefix. Combined with serialized args to form the full key.
38+
* - `ttl`: time-to-live in seconds. Default: 60.
39+
* @returns {T & { invalidate: () => Promise<void> }}
40+
* The cached function with the same signature, plus an `invalidate()`
41+
* method to manually clear the cache.
42+
*/
43+
export function cache(fn, opts) {
44+
const prefix = opts.key;
45+
const ttlMs = (opts.ttl ?? 60) * 1000;
46+
47+
const wrapped = /** @type {T & { invalidate: () => Promise<void> }} */ (
48+
async function (...args) {
49+
const store = getStore();
50+
const cacheKey = args.length
51+
? `cache:${prefix}:${JSON.stringify(args)}`
52+
: `cache:${prefix}`;
53+
54+
const hit = await store.get(cacheKey);
55+
if (hit !== null) {
56+
try { return JSON.parse(hit); } catch { /* corrupted — recompute */ }
57+
}
58+
59+
const result = await fn(...args);
60+
await store.set(cacheKey, JSON.stringify(result), ttlMs);
61+
return result;
62+
}
63+
);
64+
65+
/**
66+
* Manually invalidate this cache. Call after mutations:
67+
*
68+
* ```js
69+
* export async function createPost(input) {
70+
* await prisma.post.create({ data: input });
71+
* await listPosts.invalidate();
72+
* }
73+
* ```
74+
*/
75+
wrapped.invalidate = async function () {
76+
const store = getStore();
77+
// Delete the base key (no-args call)
78+
await store.delete(`cache:${prefix}`);
79+
// Note: arg-specific keys are not tracked. If the cached function
80+
// is called with different arguments, those entries expire via TTL.
81+
// For full invalidation of arg-specific keys, use a short TTL.
82+
};
83+
84+
return wrapped;
85+
}

0 commit comments

Comments
 (0)