@webjsdev/server 0.7.2

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/src/cache.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Pluggable cache store: the foundation for rate limiting, sessions,
3
+ * and any feature that needs shared state across requests.
4
+ *
5
+ * Default: in-memory LRU (single-process, great for dev).
6
+ * For production horizontal scaling, the user explicitly switches to Redis:
7
+ *
8
+ * ```js
9
+ * import { setStore, redisStore } from '@webjsdev/server';
10
+ * setStore(redisStore({ url: process.env.REDIS_URL }));
11
+ * ```
12
+ *
13
+ * No magic, no auto-detection. The user decides.
14
+ *
15
+ * @module cache
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} CacheStore
20
+ * @property {(key: string) => Promise<string | null>} get
21
+ * @property {(key: string, value: string, ttlMs?: number) => Promise<void>} set
22
+ * @property {(key: string) => Promise<void>} delete
23
+ * @property {(key: string, ttlMs?: number) => Promise<number>} increment
24
+ * Atomically increment a counter. Returns the new value. Creates the
25
+ * key with value 1 if it doesn't exist. TTL is set on creation only.
26
+ */
27
+
28
+ /**
29
+ * In-memory LRU cache store. Fast, zero dependencies, single-process only.
30
+ * Entries are evicted when the cache exceeds `maxSize`.
31
+ *
32
+ * @param {{ maxSize?: number }} [opts]
33
+ * @returns {CacheStore}
34
+ */
35
+ export function memoryStore(opts = {}) {
36
+ const max = opts.maxSize || 10000;
37
+ /** @type {Map<string, { value: string, expiresAt: number | null }>} */
38
+ const map = new Map();
39
+
40
+ function evict() {
41
+ if (map.size <= max) return;
42
+ const oldest = map.keys().next().value;
43
+ map.delete(oldest);
44
+ }
45
+
46
+ function isExpired(entry) {
47
+ return entry.expiresAt !== null && Date.now() > entry.expiresAt;
48
+ }
49
+
50
+ return {
51
+ async get(key) {
52
+ const entry = map.get(key);
53
+ if (!entry) return null;
54
+ if (isExpired(entry)) { map.delete(key); return null; }
55
+ // Move to end (LRU)
56
+ map.delete(key);
57
+ map.set(key, entry);
58
+ return entry.value;
59
+ },
60
+ async set(key, value, ttlMs) {
61
+ map.delete(key); // remove old position
62
+ map.set(key, {
63
+ value,
64
+ expiresAt: ttlMs ? Date.now() + ttlMs : null,
65
+ });
66
+ evict();
67
+ },
68
+ async delete(key) {
69
+ map.delete(key);
70
+ },
71
+ async increment(key, ttlMs) {
72
+ const entry = map.get(key);
73
+ if (!entry || isExpired(entry)) {
74
+ map.set(key, {
75
+ value: '1',
76
+ expiresAt: ttlMs ? Date.now() + ttlMs : null,
77
+ });
78
+ return 1;
79
+ }
80
+ const next = parseInt(entry.value, 10) + 1;
81
+ entry.value = String(next);
82
+ return next;
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Redis-backed cache store. Scales horizontally: all instances share
89
+ * the same cache. Requires `REDIS_URL` in the environment or explicit URL.
90
+ *
91
+ * Uses the `ioredis` package if available, falls back to built-in
92
+ * `redis` package (Node 20+ ships with `node:` prefix modules but
93
+ * not a Redis client: the user must install one).
94
+ *
95
+ * @param {{ url?: string }} [opts]
96
+ * @returns {CacheStore}
97
+ */
98
+ export function redisStore(opts = {}) {
99
+ const url = opts.url || process.env.REDIS_URL;
100
+ if (!url) throw new Error('redisStore requires REDIS_URL environment variable or opts.url');
101
+
102
+ /** @type {any} */
103
+ let client = null;
104
+ let connecting = null;
105
+
106
+ async function getClient() {
107
+ if (client) return client;
108
+ if (connecting) return connecting;
109
+ connecting = (async () => {
110
+ // Try ioredis first (most popular), then redis package
111
+ try {
112
+ const { default: Redis } = await import('ioredis');
113
+ client = new Redis(url);
114
+ return client;
115
+ } catch {}
116
+ try {
117
+ const { createClient } = await import('redis');
118
+ client = createClient({ url });
119
+ await client.connect();
120
+ return client;
121
+ } catch {}
122
+ throw new Error('Install a Redis client: npm install ioredis (or npm install redis)');
123
+ })();
124
+ return connecting;
125
+ }
126
+
127
+ return {
128
+ async get(key) {
129
+ const c = await getClient();
130
+ return c.get(key);
131
+ },
132
+ async set(key, value, ttlMs) {
133
+ const c = await getClient();
134
+ if (ttlMs) {
135
+ // ioredis: set(key, value, 'PX', ms): redis: set(key, value, { PX: ms })
136
+ if (typeof c.set === 'function' && c.set.length >= 4) {
137
+ await c.set(key, value, 'PX', ttlMs);
138
+ } else {
139
+ await c.set(key, value, { PX: ttlMs });
140
+ }
141
+ } else {
142
+ await c.set(key, value);
143
+ }
144
+ },
145
+ async delete(key) {
146
+ const c = await getClient();
147
+ await c.del(key);
148
+ },
149
+ async increment(key, ttlMs) {
150
+ const c = await getClient();
151
+ const val = await c.incr(key);
152
+ // Set TTL only when counter is first created (val === 1)
153
+ if (val === 1 && ttlMs) {
154
+ await c.pexpire(key, ttlMs);
155
+ }
156
+ return val;
157
+ },
158
+ };
159
+ }
160
+
161
+ /** @type {CacheStore | null} */
162
+ let _defaultStore = null;
163
+
164
+ /**
165
+ * Get the default cache store. Memory store unless explicitly set via
166
+ * `setStore()`. No auto-detection: the user decides.
167
+ *
168
+ * @returns {CacheStore}
169
+ */
170
+ export function getStore() {
171
+ if (!_defaultStore) _defaultStore = memoryStore();
172
+ return _defaultStore;
173
+ }
174
+
175
+ /**
176
+ * Set the default cache store. Call this at app startup to use Redis:
177
+ *
178
+ * ```js
179
+ * import { setStore, redisStore } from '@webjsdev/server';
180
+ * setStore(redisStore({ url: process.env.REDIS_URL }));
181
+ * ```
182
+ *
183
+ * @param {CacheStore} store
184
+ */
185
+ export function setStore(store) {
186
+ _defaultStore = store;
187
+ }