@tekir/cache 0.1.9 → 0.1.10

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.
@@ -83,8 +83,9 @@ export type HttpCacheCtx = {
83
83
  request: {
84
84
  url: string;
85
85
  method: string;
86
- headers: Headers;
87
86
  raw?: Request;
87
+ header?: (name: string, defaultValue?: string) => string | undefined;
88
+ headers: Headers;
88
89
  };
89
90
  params?: Record<string, string>;
90
91
  query?: Record<string, string | string[]>;
package/dist/index.js CHANGED
@@ -1,6 +1,538 @@
1
- export { MemoryCacheStore } from './stores/memory';
2
- export { RedisCacheStore } from './stores/redis';
3
- export { DatabaseCacheStore } from './stores/database';
4
- export { Cache, createCache } from './cache';
5
- export { CacheProvider } from './provider';
6
- export { cache, setDefaultCacheStore, getDefaultCacheStore } from './http-cache';
1
+ import { createRequire } from "node:module";
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
18
+
19
+ // src/stores/memory.ts
20
+ class MemoryCacheStore {
21
+ data = new Map;
22
+ maxEntries;
23
+ writes = 0;
24
+ constructor(options = {}) {
25
+ this.maxEntries = options.maxEntries ?? 1e4;
26
+ }
27
+ prune() {
28
+ const now = Date.now();
29
+ for (const [k, entry] of this.data) {
30
+ if (entry.expiresAt && now > entry.expiresAt)
31
+ this.data.delete(k);
32
+ }
33
+ }
34
+ async get(key) {
35
+ const entry = this.data.get(key);
36
+ if (!entry)
37
+ return null;
38
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
39
+ this.data.delete(key);
40
+ return null;
41
+ }
42
+ return entry.value;
43
+ }
44
+ async set(key, value, ttlSeconds) {
45
+ this.data.set(key, {
46
+ value,
47
+ expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null
48
+ });
49
+ if (this.maxEntries > 0) {
50
+ if (++this.writes % 256 === 0)
51
+ this.prune();
52
+ while (this.data.size > this.maxEntries) {
53
+ const oldest = this.data.keys().next().value;
54
+ if (oldest === undefined)
55
+ break;
56
+ this.data.delete(oldest);
57
+ }
58
+ }
59
+ }
60
+ async has(key) {
61
+ const entry = this.data.get(key);
62
+ if (!entry)
63
+ return false;
64
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
65
+ this.data.delete(key);
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+ async delete(key) {
71
+ return this.data.delete(key);
72
+ }
73
+ async flush() {
74
+ this.data.clear();
75
+ }
76
+ }
77
+
78
+ // src/stores/redis.ts
79
+ var exports_redis = {};
80
+ __export(exports_redis, {
81
+ RedisCacheStore: () => RedisCacheStore
82
+ });
83
+
84
+ class RedisCacheStore {
85
+ redis;
86
+ prefix;
87
+ constructor(redis, prefix = "cache:") {
88
+ this.redis = redis;
89
+ this.prefix = prefix;
90
+ }
91
+ async get(key) {
92
+ const val = await this.redis.get(this.prefix + key);
93
+ if (val === null)
94
+ return null;
95
+ try {
96
+ return JSON.parse(val);
97
+ } catch {
98
+ return val;
99
+ }
100
+ }
101
+ async set(key, value, ttlSeconds) {
102
+ const val = JSON.stringify(value);
103
+ const fullKey = this.prefix + key;
104
+ if (ttlSeconds) {
105
+ const sendable = this.redis;
106
+ if (typeof sendable.send === "function") {
107
+ await sendable.send("SET", [fullKey, val, "EX", String(Math.floor(ttlSeconds))]);
108
+ } else {
109
+ await this.redis.set(fullKey, val);
110
+ await this.redis.expire(fullKey, ttlSeconds);
111
+ }
112
+ } else {
113
+ await this.redis.set(fullKey, val);
114
+ }
115
+ }
116
+ async has(key) {
117
+ return !!await this.redis.exists(this.prefix + key);
118
+ }
119
+ async delete(key) {
120
+ await this.redis.del(this.prefix + key);
121
+ return true;
122
+ }
123
+ async flush() {
124
+ if (!this.prefix) {
125
+ throw new Error("[@tekir/cache] RedisCacheStore.flush() refused: an empty prefix would delete every key in the database. Configure a non-empty prefix.");
126
+ }
127
+ const client = this.redis;
128
+ const pattern = `${this.prefix}*`;
129
+ let cursor = "0";
130
+ do {
131
+ const reply = await client.send("SCAN", [cursor, "MATCH", pattern, "COUNT", "100"]);
132
+ const [next, batch] = reply;
133
+ cursor = next;
134
+ if (batch && batch.length)
135
+ await client.send("DEL", batch);
136
+ } while (cursor !== "0");
137
+ }
138
+ }
139
+
140
+ // src/stores/database.ts
141
+ var exports_database = {};
142
+ __export(exports_database, {
143
+ DatabaseCacheStore: () => DatabaseCacheStore
144
+ });
145
+
146
+ class DatabaseCacheStore {
147
+ db;
148
+ table;
149
+ _ready = false;
150
+ constructor(db, table = "cache") {
151
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
152
+ throw new Error(`Invalid table name: "${table}"`);
153
+ this.db = db;
154
+ this.table = table;
155
+ }
156
+ get driver() {
157
+ return this.db.driver || "sqlite";
158
+ }
159
+ get quotedTable() {
160
+ return this.driver === "mysql" ? `\`${this.table}\`` : `"${this.table}"`;
161
+ }
162
+ sql(statement) {
163
+ if (this.driver !== "postgres")
164
+ return statement;
165
+ let index = 0;
166
+ return statement.replace(/\?/g, () => `$${++index}`);
167
+ }
168
+ async _ensureTable() {
169
+ if (this._ready)
170
+ return;
171
+ try {
172
+ const expiresType = this.driver === "sqlite" ? "INTEGER" : "BIGINT";
173
+ await this.db.exec(`CREATE TABLE IF NOT EXISTS ${this.quotedTable} (key ${this.driver === "mysql" ? "VARCHAR(255)" : "TEXT"} PRIMARY KEY, value TEXT, expires_at ${expiresType})`);
174
+ this._ready = true;
175
+ } catch (e) {
176
+ console.error(`[@tekir/cache] Failed to create cache table "${this.table}": ${e.message}`);
177
+ throw e;
178
+ }
179
+ }
180
+ async get(key) {
181
+ await this._ensureTable();
182
+ const row = await this.db.queryOne(this.sql(`SELECT value, expires_at FROM ${this.quotedTable} WHERE key = ?`), [key]);
183
+ if (!row)
184
+ return null;
185
+ if (row.expires_at && Date.now() > row.expires_at) {
186
+ await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE key = ?`), [key]);
187
+ return null;
188
+ }
189
+ try {
190
+ return JSON.parse(row.value);
191
+ } catch {
192
+ return row.value;
193
+ }
194
+ }
195
+ async set(key, value, ttlSeconds) {
196
+ await this._ensureTable();
197
+ const val = JSON.stringify(value);
198
+ const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null;
199
+ const insert = this.driver === "mysql" ? `INSERT INTO ${this.quotedTable} (key, value, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), expires_at = VALUES(expires_at)` : `INSERT INTO ${this.quotedTable} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`;
200
+ await this.db.run(this.sql(insert), [key, val, expiresAt]);
201
+ }
202
+ async has(key) {
203
+ await this._ensureTable();
204
+ const row = await this.db.queryOne(this.sql(`SELECT expires_at FROM ${this.quotedTable} WHERE key = ?`), [key]);
205
+ if (!row)
206
+ return false;
207
+ if (row.expires_at && Date.now() > row.expires_at) {
208
+ await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE key = ?`), [key]);
209
+ return false;
210
+ }
211
+ return true;
212
+ }
213
+ async delete(key) {
214
+ await this._ensureTable();
215
+ await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE key = ?`), [key]);
216
+ return true;
217
+ }
218
+ async prune() {
219
+ await this._ensureTable();
220
+ await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE expires_at IS NOT NULL AND expires_at < ?`), [Date.now()]);
221
+ }
222
+ async flush() {
223
+ await this._ensureTable();
224
+ await this.db.run(`DELETE FROM ${this.quotedTable}`);
225
+ }
226
+ }
227
+
228
+ // src/cache.ts
229
+ class Cache {
230
+ stores;
231
+ defaultStore;
232
+ defaultTtl;
233
+ inFlight = new Map;
234
+ constructor(config = {}) {
235
+ this.stores = config.stores || { memory: new MemoryCacheStore };
236
+ this.defaultStore = config.default || Object.keys(this.stores)[0];
237
+ this.defaultTtl = config.ttl || 3600;
238
+ }
239
+ store(name) {
240
+ const storeName = name || this.defaultStore;
241
+ const s = this.stores[storeName];
242
+ if (!s)
243
+ throw new Error(`Cache store "${storeName}" not configured`);
244
+ return s;
245
+ }
246
+ async get(key) {
247
+ return this.store().get(key);
248
+ }
249
+ async set(key, value, ttl) {
250
+ return this.store().set(key, value, ttl ?? this.defaultTtl);
251
+ }
252
+ async has(key) {
253
+ return this.store().has(key);
254
+ }
255
+ async delete(key) {
256
+ return this.store().delete(key);
257
+ }
258
+ async flush() {
259
+ return this.store().flush();
260
+ }
261
+ async getOrSet(key, ttl, factory) {
262
+ const cached = await this.get(key);
263
+ if (cached !== null)
264
+ return cached;
265
+ const flightKey = `${this.defaultStore}:${key}`;
266
+ const existing = this.inFlight.get(flightKey);
267
+ if (existing)
268
+ return existing;
269
+ const promise = (async () => {
270
+ const value = await factory();
271
+ await this.set(key, value, ttl);
272
+ return value;
273
+ })().finally(() => this.inFlight.delete(flightKey));
274
+ this.inFlight.set(flightKey, promise);
275
+ return promise;
276
+ }
277
+ async pull(key) {
278
+ const value = await this.get(key);
279
+ if (value !== null || await this.has(key))
280
+ await this.delete(key);
281
+ return value;
282
+ }
283
+ }
284
+ function createCache(config) {
285
+ return new Cache(config);
286
+ }
287
+ var init_cache = () => {};
288
+
289
+ // src/http-cache.ts
290
+ var exports_http_cache = {};
291
+ __export(exports_http_cache, {
292
+ setDefaultCacheStore: () => setDefaultCacheStore,
293
+ getDefaultCacheStore: () => getDefaultCacheStore,
294
+ cache: () => cache
295
+ });
296
+ import { finalizeResponse, hasPendingResponseCookies } from "@tekir/core";
297
+ function setDefaultCacheStore(s) {
298
+ _defaultStore = s;
299
+ }
300
+ function getDefaultCacheStore() {
301
+ return _defaultStore;
302
+ }
303
+ function cache(opts = {}) {
304
+ const ttl = opts.ttl ?? 60;
305
+ const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()));
306
+ const vary = opts.vary ?? [];
307
+ const prefix = opts.prefix ?? "http:";
308
+ const hasCustomKey = typeof opts.key === "function";
309
+ const authMode = opts.authenticated ?? "bypass";
310
+ const effectiveVary = authMode === "vary" ? [...vary, ...CREDENTIAL_HEADERS] : vary;
311
+ const buildKey = opts.key ?? ((ctx) => defaultKey(ctx, effectiveVary));
312
+ const directStore = resolveStore(opts.store);
313
+ return async function cacheMiddleware(ctx, next) {
314
+ const req = ctx.request;
315
+ if (!req || !methods.has(String(req.method ?? "GET").toUpperCase())) {
316
+ await next();
317
+ return;
318
+ }
319
+ if (opts.skip && await opts.skip(ctx)) {
320
+ await next();
321
+ return;
322
+ }
323
+ const cacheControl = requestHeader(req, "cache-control") ?? "";
324
+ if (cacheControl.includes("no-store")) {
325
+ await next();
326
+ return;
327
+ }
328
+ if (authMode === "bypass" && !hasCustomKey && hasCredentials(req)) {
329
+ await next();
330
+ return;
331
+ }
332
+ let store = directStore;
333
+ if (!store)
334
+ store = resolveStore(_defaultStore);
335
+ if (!store) {
336
+ await next();
337
+ return;
338
+ }
339
+ const key = prefix + buildKey(ctx);
340
+ const cached = await store.get(key);
341
+ const ifNoneMatch = requestHeader(req, "if-none-match") ?? "";
342
+ if (cached) {
343
+ if (ifNoneMatch && ifNoneMatch === cached.etag) {
344
+ ctx.$result = new Response(null, {
345
+ status: 304,
346
+ headers: { etag: cached.etag, "x-tekir-cache": "REVALIDATED" }
347
+ });
348
+ return;
349
+ }
350
+ ctx.$result = entryToResponse(cached, opts);
351
+ return;
352
+ }
353
+ await next();
354
+ let result = ctx.$result;
355
+ if (!(result instanceof Response) && result !== null && typeof result === "object" && typeof result.next !== "function") {
356
+ result = new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json" } });
357
+ }
358
+ if (result instanceof Response && ctx.response)
359
+ result = finalizeResponse(ctx.response, result);
360
+ if (result instanceof Response)
361
+ ctx.$result = result;
362
+ if (!(result instanceof Response))
363
+ return;
364
+ if (result.status >= 500 || result.status === 204 || result.status === 304)
365
+ return;
366
+ if (cacheControl.includes("no-cache"))
367
+ return;
368
+ const respCacheControl = result.headers.get("cache-control") ?? "";
369
+ if (respCacheControl.includes("private") || respCacheControl.includes("no-store"))
370
+ return;
371
+ if (ctx.$willSetCookie || result.headers.has("set-cookie") || ctx.response && hasPendingResponseCookies(ctx.response))
372
+ return;
373
+ const entry = await responseToEntry(result);
374
+ await store.set(key, entry, ttl);
375
+ const out = {};
376
+ result.headers.forEach((v, k) => out[k] = v);
377
+ out["etag"] = entry.etag;
378
+ if (opts.setCacheControl !== false && !out["cache-control"]) {
379
+ out["cache-control"] = `public, max-age=${ttl}`;
380
+ }
381
+ out["x-tekir-cache"] = "MISS";
382
+ const body = result.status === 204 || result.status === 205 || result.status === 304 ? null : Buffer.from(entry.bodyBase64, "base64");
383
+ ctx.$result = new Response(body, { status: result.status, headers: out });
384
+ };
385
+ }
386
+ var CREDENTIAL_HEADERS, SAFE_METHODS, isStore = (s) => !!s && typeof s.get === "function" && typeof s.set === "function", _defaultStore = null, hash = (s) => {
387
+ let h = 2166136261;
388
+ for (let i = 0;i < s.length; i++) {
389
+ h ^= s.charCodeAt(i);
390
+ h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
391
+ }
392
+ return h.toString(16).padStart(8, "0");
393
+ }, defaultKey = (ctx, vary) => {
394
+ let host = requestHeader(ctx.request, "host");
395
+ if (!host) {
396
+ try {
397
+ host = new URL(ctx.request.url).host;
398
+ } catch {}
399
+ }
400
+ const parts = [ctx.request.method, host ? `host=${host.toLowerCase()}` : "", ctx.request.url];
401
+ for (const h of vary) {
402
+ const v = requestHeader(ctx.request, h);
403
+ if (v)
404
+ parts.push(`${h}=${v}`);
405
+ }
406
+ return parts.join("|");
407
+ }, requestHeader = (req, name) => {
408
+ if (typeof req.header === "function")
409
+ return req.header(name);
410
+ const rawValue = req.raw?.headers?.get(name);
411
+ if (rawValue != null)
412
+ return rawValue;
413
+ const headers = req.headers;
414
+ if (headers && typeof headers !== "function" && typeof headers.get === "function") {
415
+ return headers.get(name) ?? undefined;
416
+ }
417
+ if (typeof headers === "function") {
418
+ const values = headers();
419
+ return values[name.toLowerCase()] ?? values[name];
420
+ }
421
+ return;
422
+ }, resolveStore = (s) => {
423
+ if (!s)
424
+ return null;
425
+ if (s instanceof Cache)
426
+ return s.store();
427
+ if (isStore(s))
428
+ return s;
429
+ return null;
430
+ }, responseToEntry = async (resp) => {
431
+ const bodyBase64 = Buffer.from(await resp.clone().arrayBuffer()).toString("base64");
432
+ const headers = {};
433
+ resp.headers.forEach((v, k) => {
434
+ if (k === "connection" || k === "keep-alive" || k === "transfer-encoding")
435
+ return;
436
+ headers[k] = v;
437
+ });
438
+ const etag = `W/"${hash(bodyBase64)}"`;
439
+ return { status: resp.status, headers, bodyBase64, etag, storedAt: Date.now() };
440
+ }, entryToResponse = (e, opts) => {
441
+ const headers = { ...e.headers, etag: e.etag };
442
+ if (opts.setCacheControl !== false && !headers["cache-control"]) {
443
+ headers["cache-control"] = `public, max-age=${opts.ttl ?? 60}`;
444
+ }
445
+ headers["x-tekir-cache"] = "HIT";
446
+ const body = e.status === 204 || e.status === 205 || e.status === 304 ? null : Buffer.from(e.bodyBase64, "base64");
447
+ return new Response(body, { status: e.status, headers });
448
+ }, hasCredentials = (req) => {
449
+ for (const h of CREDENTIAL_HEADERS) {
450
+ if (requestHeader(req, h))
451
+ return true;
452
+ }
453
+ return false;
454
+ };
455
+ var init_http_cache = __esm(() => {
456
+ init_cache();
457
+ CREDENTIAL_HEADERS = ["authorization", "cookie"];
458
+ SAFE_METHODS = ["GET", "HEAD"];
459
+ });
460
+
461
+ // src/index.ts
462
+ init_cache();
463
+
464
+ // src/provider.ts
465
+ init_cache();
466
+
467
+ class CacheProvider {
468
+ async register(app) {
469
+ const config = app.use("config");
470
+ if (!config("cache"))
471
+ return;
472
+ const storesConfig = config("cache.stores", {});
473
+ const stores = {};
474
+ for (const [name, storeConfig] of Object.entries(storesConfig)) {
475
+ if (storeConfig && typeof storeConfig.get === "function") {
476
+ stores[name] = storeConfig;
477
+ continue;
478
+ }
479
+ const driver = storeConfig?.driver || name;
480
+ if (driver === "memory") {
481
+ stores[name] = new MemoryCacheStore;
482
+ } else if (driver === "redis") {
483
+ let redis;
484
+ try {
485
+ redis = app.use("redis");
486
+ } catch {}
487
+ if (!redis) {
488
+ let Redis;
489
+ try {
490
+ Redis = (await import("@tekir/redis")).Redis;
491
+ } catch {
492
+ throw new Error(`[@tekir/cache] Store "${name}" uses the redis driver but @tekir/redis is not installed. Run: bun add @tekir/redis and register RedisProvider before CacheProvider.`);
493
+ }
494
+ redis = new Redis({ ...config("redis", {}), ...storeConfig });
495
+ }
496
+ const { RedisCacheStore: RedisCacheStore2 } = await Promise.resolve().then(() => exports_redis);
497
+ stores[name] = new RedisCacheStore2(redis, storeConfig?.prefix);
498
+ } else if (driver === "database") {
499
+ let db;
500
+ try {
501
+ db = app.use("db");
502
+ } catch {}
503
+ if (!db) {
504
+ throw new Error(`[@tekir/cache] Store "${name}" uses the database driver but no database service is registered. Add DatabaseProvider to your kernel before CacheProvider.`);
505
+ }
506
+ const { DatabaseCacheStore: DatabaseCacheStore2 } = await Promise.resolve().then(() => exports_database);
507
+ stores[name] = new DatabaseCacheStore2(db, storeConfig?.table);
508
+ } else {
509
+ throw new Error(`[@tekir/cache] Unknown cache driver "${driver}" for store "${name}". Supported drivers: memory, redis, database`);
510
+ }
511
+ }
512
+ if (Object.keys(stores).length === 0) {
513
+ stores.memory = new MemoryCacheStore;
514
+ }
515
+ const cacheInstance = new Cache({
516
+ stores,
517
+ ttl: config("cache.ttl", 60),
518
+ default: config("cache.default", Object.keys(stores)[0])
519
+ });
520
+ app.instance("cache", cacheInstance);
521
+ const { setDefaultCacheStore: setDefaultCacheStore2 } = await Promise.resolve().then(() => (init_http_cache(), exports_http_cache));
522
+ setDefaultCacheStore2(cacheInstance);
523
+ }
524
+ }
525
+
526
+ // src/index.ts
527
+ init_http_cache();
528
+ export {
529
+ setDefaultCacheStore,
530
+ getDefaultCacheStore,
531
+ createCache,
532
+ cache,
533
+ RedisCacheStore,
534
+ MemoryCacheStore,
535
+ DatabaseCacheStore,
536
+ CacheProvider,
537
+ Cache
538
+ };
@@ -21,6 +21,9 @@ export declare class DatabaseCacheStore implements CacheStore {
21
21
  * @throws Error if the table name contains invalid characters.
22
22
  */
23
23
  constructor(db: any, table?: string);
24
+ private get driver();
25
+ private get quotedTable();
26
+ private sql;
24
27
  private _ensureTable;
25
28
  /**
26
29
  * Retrieve a cached value by key. Expired entries are deleted and `null` is returned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekir/cache",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "In-memory, Redis, and database caching abstraction",
5
5
  "author": "dev@tekir.io",
6
6
  "license": "MIT",
@@ -50,7 +50,7 @@
50
50
  }
51
51
  },
52
52
  "scripts": {
53
- "build": "rm -rf dist && tsc --noEmit false",
53
+ "build": "bun ../../scripts/build-package.ts",
54
54
  "prepublishOnly": "bun run build"
55
55
  },
56
56
  "exports": {