@clov-std/elysia-cache 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ <p align="center">
2
+ <img src="https://cdn.jsdelivr.net/gh/ClovLabs/std@main/packages/elysia-cache/logo-elysia-cache.png" alt="Clov Elysia Cache logo" width="200" />
3
+ </p>
4
+
5
+ # ⚡ Clov Elysia Cache
6
+
7
+ Response caching for Elysia routes, guards, and groups, as a macro.
8
+ Drop `isCached` on any endpoint and repeated requests are served from the store before your logic ever runs.
9
+
10
+ ## Why this package?
11
+
12
+ This plugin uses Elysia's macro system to add response caching to any route, guard, or group.
13
+ You add `isCached: { ttl: 60 }` and you're done.
14
+
15
+ Cache keys are readable strings built from the request method, URL, the `vary` headers (default: `authorization`), and the parsed body - so different inputs never collide. `generateCacheKey` builds them, and keys longer than 1024 characters are hashed with SHA-256 before being stored.
16
+
17
+ Storage is handled by `@clov-std/kv-store`, so you start with in-memory and move to Redis when you need to, without changing your routes.
18
+
19
+ ## 📌 Table of Contents
20
+
21
+ - [Features](#-features)
22
+ - [Installation](#-installation)
23
+ - [Usage](#-usage)
24
+ - [Cache semantics](#-cache-semantics)
25
+ - [API Reference](#-api-reference)
26
+ - [License](#-license)
27
+ - [Contact](#-contact)
28
+
29
+ ## ✨ Features
30
+
31
+ - 🎯 **Per-route macros** : Attach `isCached` to any route independently, with its own `ttl` and options.
32
+ - 🔑 **Readable keys** : `method:url`, the `vary` header values, and the parsed body - SHA-256 hashed only when the key exceeds 1024 characters.
33
+ - 🗃️ **KvStore-agnostic** : Works with `MemoryStore` out of the box; swap in `BunRedisStore` or your own adapter.
34
+ - ⚡ **Early serving** : Returns the cached response in `beforeHandle`, before handlers run.
35
+ - 📡 **Cache headers** : Sets `Cache-Control: max-age=N, private` and `X-Cache: HIT` / `MISS`.
36
+ - 🛡️ **Opaque-body safety** : Requests carrying `Blob` / `ArrayBuffer` / typed-array bodies are never keyed, never cached.
37
+
38
+ ## 🔧 Installation
39
+
40
+ ```bash
41
+ bun add @clov-std/elysia-cache elysia typebox
42
+ ```
43
+
44
+ > **Peer dependencies:** `elysia`(v2) and `typebox` must be installed alongside.
45
+
46
+ ## ⚙️ Usage
47
+
48
+ ### Basic - cache GET responses
49
+
50
+ The simplest form: pass `ttl` (time to live in seconds). Responses are cached for that window.
51
+
52
+ ```ts
53
+ import { cachePlugin } from '@clov-std/elysia-cache';
54
+ import { Elysia } from 'elysia';
55
+
56
+ new Elysia()
57
+ .use(cachePlugin())
58
+ .get(
59
+ '/catalog/items',
60
+ { isCached: { ttl: 60 } }, // cached for 60 seconds
61
+ () => listItems()
62
+ )
63
+ .listen(3000);
64
+ ```
65
+
66
+ ### Custom store - Redis
67
+
68
+ By default, cached responses are kept in memory. Pass a `BunRedisStore` (or any `KvStore` adapter) for persistence across restarts and multi-instance deployments.
69
+
70
+ ```ts
71
+ import { BunRedisStore } from '@clov-std/kv-store';
72
+ import { cachePlugin } from '@clov-std/elysia-cache';
73
+ import { Elysia } from 'elysia';
74
+
75
+ const store = new BunRedisStore('redis://localhost:6379');
76
+
77
+ new Elysia()
78
+ .use(cachePlugin(store))
79
+ .get('/catalog/items', { isCached: { ttl: 60 } }, () => listItems())
80
+ .listen(3000);
81
+ ```
82
+
83
+ > **Note:** Elysia keeps one plugin instance per name, so the store must be passed to the first `cachePlugin()` used in the app tree. A later `cachePlugin(otherStore)` on the same app is ignored.
84
+
85
+ ### Narrowing the key - `vary`
86
+
87
+ The default key only includes the `authorization` header. If a route varies on more headers, list them in `vary`:
88
+
89
+ ```ts
90
+ new Elysia()
91
+ .use(cachePlugin())
92
+ .get(
93
+ '/catalog/items',
94
+ { isCached: { ttl: 60, vary: ['authorization', 'accept-language'] } },
95
+ () => listItems()
96
+ )
97
+ .listen(3000);
98
+ ```
99
+
100
+ ### Custom key generation
101
+
102
+ For full control, pass a `keyGenerator`. It receives the `Request` and the parsed body, and can return a string, `undefined` (opts the request out of caching), or a promise of either. When provided, `vary` is not used.
103
+
104
+ ```ts
105
+ import { cachePlugin, generateCacheKey } from '@clov-std/elysia-cache';
106
+ import { Elysia } from 'elysia';
107
+
108
+ new Elysia()
109
+ .use(cachePlugin())
110
+ .get(
111
+ '/catalog/items',
112
+ {
113
+ isCached: {
114
+ ttl: 60,
115
+ keyGenerator: (request, body) => generateCacheKey(request, body, ['accept-language'])
116
+ }
117
+ },
118
+ () => listItems()
119
+ )
120
+ .listen(3000);
121
+ ```
122
+
123
+ > **Tip:** `generateCacheKey` is exported so you can reuse the default key format with your own header subset.
124
+
125
+ ## 🧯 Cache semantics
126
+
127
+ A cache hit short-circuits the handler: the stored response is returned from `beforeHandle` with `X-Cache: HIT` and `Cache-Control: max-age=<remaining>, private`, computed from the entry's creation time.
128
+
129
+ A miss runs the handler, stores the response with the `ttl`, and stamps `X-Cache: MISS` with `Cache-Control: max-age=<ttl>, private`. `Response` instances are cloned before being stored or replayed, so a cached body can be reused across requests. `undefined` return values are never cached, and requests whose parsed body is (or contains) binary data (`Blob`, `ArrayBuffer`, typed arrays) are skipped entirely.
130
+
131
+ ## 📚 API Reference
132
+
133
+ ### `cachePlugin(store?)`
134
+
135
+ Creates the plugin. `store` defaults to a new `MemoryStore`.
136
+
137
+ ### `generateCacheKey(request, body?, vary?)`
138
+
139
+ Builds the key for a request: `method:url`, the `vary` header values, then the parsed body. Without `vary`, every request header is included. Returns `undefined` when the body is opaque and the request must not be cached.
140
+
141
+ ### `CacheOptions`
142
+
143
+ | Option | Type | Default | Description |
144
+ | -------------- | --------------------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
145
+ | `ttl` | `number` | - | Time to live, in seconds. |
146
+ | `vary` | `string[]` | `['authorization']` | Header names included in the key. |
147
+ | `keyGenerator` | `(request: Request, body: unknown) => string \| undefined \| Promise<string \| undefined>` | `generateCacheKey(request, body, vary)` | Custom key builder. Returning `undefined` opts the request out of caching. |
148
+
149
+ Full docs: [https://clovlabs.github.io/std/](https://clovlabs.github.io/std/)
150
+
151
+ ## ⚖️ License
152
+
153
+ MIT - see [LICENSE.md](LICENSE.md).
154
+
155
+ ## 📧 Contact
156
+
157
+ Maintained by [Clov](https://github.com/ClovLabs).
@@ -0,0 +1,84 @@
1
+ import { type KvStore } from '@clov-std/kv-store';
2
+ import { Elysia, type HTTPHeaders } from 'elysia';
3
+ /**
4
+ * Generates a cache key from a request and the body Elysia parsed for it
5
+ *
6
+ * The key is a readable string built like the rate limit plugin's: `method:url` followed by
7
+ * the `vary` header values and the body. The cache macro prefixes it with `cache:` and
8
+ * hashes it when it grows past the store's key limit.
9
+ *
10
+ * @param request - The request to generate a cache key for
11
+ * @param body - The parsed body, keying on it avoids consuming the raw stream
12
+ * @param vary - Header names to include in the key, every header is included when omitted.
13
+ * Iterating all of them is both the slowest part of this function and what fragments the
14
+ * cache per client, so a route that varies on a few headers should list them.
15
+ * @returns The key, or `undefined` when the request cannot be keyed and must not be cached
16
+ */
17
+ export declare const generateCacheKey: (request: Request, body?: unknown, vary?: string[]) => string | undefined;
18
+ type CacheDerived = {
19
+ cacheKey?: string;
20
+ };
21
+ interface CacheDeriveContext {
22
+ request: Request;
23
+ body: unknown;
24
+ }
25
+ interface CacheContext extends CacheDerived {
26
+ set: {
27
+ headers: HTTPHeaders;
28
+ };
29
+ }
30
+ interface CacheAfterHandleContext extends CacheContext {
31
+ responseValue: unknown;
32
+ }
33
+ export interface CacheOptions {
34
+ /**
35
+ * TTL in seconds
36
+ */
37
+ ttl: number;
38
+ /**
39
+ * Header names the key varies on. Only those are hashed instead of every request header,
40
+ * which keeps the cache from fragmenting on `user-agent`, `accept-encoding`, etc.
41
+ *
42
+ * @defaultValue every request header
43
+ */
44
+ vary?: string[];
45
+ /**
46
+ * Builds the cache key for a request. Returning `undefined` leaves that request uncached,
47
+ * which is also the way to opt a request out
48
+ *
49
+ * @defaultValue {@link generateCacheKey}
50
+ */
51
+ keyGenerator?: (request: Request, body: unknown) => string | undefined | Promise<string | undefined>;
52
+ }
53
+ type CacheMacro = (options: CacheOptions) => {
54
+ derive: (context: CacheDeriveContext) => Promise<CacheDerived>;
55
+ beforeHandle: (context: CacheContext) => Promise<unknown>;
56
+ afterHandle: (context: CacheAfterHandleContext) => Promise<void>;
57
+ };
58
+ /**
59
+ * Creates the cache plugin
60
+ *
61
+ * Elysia keeps one definition per plugin name, so every `cachePlugin()` of an app tree
62
+ * resolves to a single plugin instance — the first one. Pass the store here to change it
63
+ * for the whole app, a later `cachePlugin(store)` is ignored.
64
+ */
65
+ export declare const cachePlugin: (store?: KvStore) => Elysia<'cachePlugin', 'local', {
66
+ decorator: {};
67
+ derive: {};
68
+ store: {};
69
+ }, {
70
+ typebox: {};
71
+ error: [];
72
+ }, {
73
+ schema: {};
74
+ schemas: {};
75
+ macro: Partial<{
76
+ readonly isCached: CacheOptions;
77
+ }>;
78
+ macroFn: {
79
+ isCached: CacheMacro;
80
+ };
81
+ parser: {};
82
+ response: {};
83
+ }>;
84
+ export {};
@@ -0,0 +1 @@
1
+ export { cachePlugin, generateCacheKey, type CacheOptions } from './cache';
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ // @bun
2
+ // src/cache.ts
3
+ import { MemoryStore } from "@clov-std/kv-store";
4
+ import { Elysia } from "elysia";
5
+ var isOpaque = (value) => value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
6
+ var MAX_KEY_LENGTH = 1024;
7
+ var generateCacheKey = (request, body, vary) => {
8
+ const { method, url, headers } = request;
9
+ let key = `${method}:${url}`;
10
+ if (vary)
11
+ for (const name of vary)
12
+ key += `:${name}=${headers.get(name) ?? ""}`;
13
+ else
14
+ for (const [name, value] of headers)
15
+ key += `:${name}=${value}`;
16
+ if (typeof body === "string")
17
+ key += `:${body}`;
18
+ else if (body !== undefined && body !== null) {
19
+ if (isOpaque(body) || Object.values(body).some(isOpaque))
20
+ return;
21
+ key += `:${JSON.stringify(body)}`;
22
+ }
23
+ return key;
24
+ };
25
+ var cachePlugin = (store = new MemoryStore) => new Elysia({ name: "cachePlugin" }).macro({
26
+ isCached: ({
27
+ ttl,
28
+ vary = ["authorization"],
29
+ keyGenerator = (request, body) => generateCacheKey(request, body, vary)
30
+ }) => ({
31
+ derive: async ({ request, body }) => {
32
+ const key = await keyGenerator(request, body);
33
+ if (key === undefined)
34
+ return {};
35
+ const cacheKey = `cache:${key}`;
36
+ if (cacheKey.length > MAX_KEY_LENGTH)
37
+ return {
38
+ cacheKey: `cache:${new Bun.CryptoHasher("sha256").update(cacheKey).digest("hex")}`
39
+ };
40
+ return { cacheKey };
41
+ },
42
+ beforeHandle: async ({ cacheKey, set }) => {
43
+ if (cacheKey === undefined)
44
+ return;
45
+ const cacheItem = await store.get(cacheKey);
46
+ if (cacheItem && typeof cacheItem === "object" && "response" in cacheItem && "metadata" in cacheItem) {
47
+ const createdAt = new Date(cacheItem.metadata.createdAt);
48
+ const expiresAt = new Date(createdAt.getTime() + ttl * 1000);
49
+ const remaining = Math.max(0, Math.ceil((expiresAt.getTime() - Date.now()) / 1000));
50
+ set.headers["cache-control"] = `max-age=${remaining}, private`;
51
+ set.headers["x-cache"] = "HIT";
52
+ if (cacheItem.response instanceof Response)
53
+ return cacheItem.response.clone();
54
+ return cacheItem.response;
55
+ }
56
+ return;
57
+ },
58
+ afterHandle: async ({ cacheKey, set, responseValue }) => {
59
+ if (cacheKey === undefined || responseValue === undefined || set.headers["x-cache"] === "HIT")
60
+ return;
61
+ const now = new Date;
62
+ set.headers["cache-control"] = `max-age=${ttl}, private`;
63
+ set.headers["x-cache"] = "MISS";
64
+ const cacheItem = {
65
+ response: responseValue instanceof Response ? responseValue.clone() : responseValue,
66
+ metadata: {
67
+ createdAt: now.toUTCString()
68
+ }
69
+ };
70
+ await store.set(cacheKey, cacheItem, ttl);
71
+ }
72
+ })
73
+ });
74
+ export {
75
+ cachePlugin,
76
+ generateCacheKey
77
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@clov-std/elysia-cache",
3
+ "version": "1.0.0",
4
+ "description": "Cache plugin for Elysia framework.",
5
+ "keywords": [
6
+ "bun",
7
+ "clov",
8
+ "elysia",
9
+ "macro",
10
+ "open-source",
11
+ "plugin",
12
+ "cache",
13
+ "typescript"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Clov",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ClovLabs/std.git",
20
+ "directory": "packages/elysia-rcache"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "scripts": {
33
+ "build": "bun builder.ts",
34
+ "docs": "bunx typedoc --tsconfig tsconfig.build.json",
35
+ "fmt:check": "oxfmt --check",
36
+ "fmt": "oxfmt",
37
+ "lint:fix": "oxlint --type-aware --type-check --fix ./src",
38
+ "lint:github": "oxlint --type-aware --type-check --format=github ./src",
39
+ "lint": "oxlint --type-aware --type-check ./src",
40
+ "test": "bun test --pass-with-no-tests --coverage"
41
+ },
42
+ "dependencies": {
43
+ "@clov-std/kv-store": "workspace:^"
44
+ },
45
+ "devDependencies": {
46
+ "@types/bun": "catalog:base",
47
+ "oxfmt": "catalog:base",
48
+ "oxlint": "catalog:base",
49
+ "oxlint-tsgolint": "catalog:base",
50
+ "typescript": "catalog:base"
51
+ },
52
+ "peerDependencies": {
53
+ "@clov-std/kv-store": "workspace:^",
54
+ "elysia": "next",
55
+ "typebox": "^1.3.11"
56
+ }
57
+ }