@zudojs/cache 0.0.1 → 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.
Files changed (88) hide show
  1. package/README.md +90 -14
  2. package/dist/cache.d.ts +107 -18
  3. package/dist/cache.js +393 -33
  4. package/dist/constants.d.ts +46 -12
  5. package/dist/constants.js +47 -19
  6. package/dist/index.d.ts +10 -9
  7. package/dist/index.js +9 -8
  8. package/dist/invalidation.d.ts +23 -16
  9. package/dist/invalidation.js +43 -33
  10. package/dist/key-builder.d.ts +15 -0
  11. package/dist/key-builder.js +43 -3
  12. package/dist/lock.d.ts +36 -2
  13. package/dist/lock.js +160 -30
  14. package/dist/memory.d.ts +63 -7
  15. package/dist/memory.js +223 -39
  16. package/dist/metrics.d.ts +10 -0
  17. package/dist/metrics.js +49 -4
  18. package/dist/serializer.d.ts +19 -10
  19. package/dist/serializer.js +88 -21
  20. package/dist/store.d.ts +21 -11
  21. package/dist/store.js +125 -65
  22. package/dist/tags.d.ts +32 -12
  23. package/dist/tags.js +113 -33
  24. package/dist/types-adapter.d.ts +23 -9
  25. package/dist/types-config.d.ts +46 -9
  26. package/dist/types-health.d.ts +5 -2
  27. package/dist/types-keys.d.ts +8 -4
  28. package/dist/types-lock.d.ts +8 -0
  29. package/dist/types-operations.d.ts +13 -20
  30. package/dist/types-results.d.ts +7 -1
  31. package/dist/types-tags.d.ts +9 -0
  32. package/dist/types-utility.d.ts +6 -10
  33. package/dist/types-values.d.ts +9 -14
  34. package/dist/types.d.ts +6 -6
  35. package/dist/utils.d.ts +65 -0
  36. package/dist/utils.js +210 -0
  37. package/package.json +14 -7
  38. package/dist/.tsbuildinfo +0 -1
  39. package/dist/cache.d.ts.map +0 -1
  40. package/dist/cache.js.map +0 -1
  41. package/dist/constants.d.ts.map +0 -1
  42. package/dist/constants.js.map +0 -1
  43. package/dist/errors.d.ts.map +0 -1
  44. package/dist/errors.js.map +0 -1
  45. package/dist/index.d.ts.map +0 -1
  46. package/dist/index.js.map +0 -1
  47. package/dist/invalidation.d.ts.map +0 -1
  48. package/dist/invalidation.js.map +0 -1
  49. package/dist/key-builder.d.ts.map +0 -1
  50. package/dist/key-builder.js.map +0 -1
  51. package/dist/lock.d.ts.map +0 -1
  52. package/dist/lock.js.map +0 -1
  53. package/dist/memory.d.ts.map +0 -1
  54. package/dist/memory.js.map +0 -1
  55. package/dist/metrics.d.ts.map +0 -1
  56. package/dist/metrics.js.map +0 -1
  57. package/dist/serializer.d.ts.map +0 -1
  58. package/dist/serializer.js.map +0 -1
  59. package/dist/store.d.ts.map +0 -1
  60. package/dist/store.js.map +0 -1
  61. package/dist/tags.d.ts.map +0 -1
  62. package/dist/tags.js.map +0 -1
  63. package/dist/types-adapter.d.ts.map +0 -1
  64. package/dist/types-adapter.js.map +0 -1
  65. package/dist/types-config.d.ts.map +0 -1
  66. package/dist/types-config.js.map +0 -1
  67. package/dist/types-events.d.ts.map +0 -1
  68. package/dist/types-events.js.map +0 -1
  69. package/dist/types-health.d.ts.map +0 -1
  70. package/dist/types-health.js.map +0 -1
  71. package/dist/types-keys.d.ts.map +0 -1
  72. package/dist/types-keys.js.map +0 -1
  73. package/dist/types-lock.d.ts.map +0 -1
  74. package/dist/types-lock.js.map +0 -1
  75. package/dist/types-metrics.d.ts.map +0 -1
  76. package/dist/types-metrics.js.map +0 -1
  77. package/dist/types-operations.d.ts.map +0 -1
  78. package/dist/types-operations.js.map +0 -1
  79. package/dist/types-results.d.ts.map +0 -1
  80. package/dist/types-results.js.map +0 -1
  81. package/dist/types-tags.d.ts.map +0 -1
  82. package/dist/types-tags.js.map +0 -1
  83. package/dist/types-utility.d.ts.map +0 -1
  84. package/dist/types-utility.js.map +0 -1
  85. package/dist/types-values.d.ts.map +0 -1
  86. package/dist/types-values.js.map +0 -1
  87. package/dist/types.d.ts.map +0 -1
  88. package/dist/types.js.map +0 -1
package/dist/memory.js CHANGED
@@ -1,47 +1,154 @@
1
1
  /**
2
2
  * @zudojs/cache — Memory Adapter
3
3
  * In-memory cache adapter using a Map. Suitable for development, testing, and single-process deployments.
4
+ *
5
+ * Receives fully-qualified keys; namespace resolution happens in CacheService.
6
+ *
7
+ * Bounds: the store is capped both by entry count (`maxEntries`) and by an
8
+ * approximate byte budget (`maxBytes`). Eviction is LRU — reads move an
9
+ * entry to the back of the queue, so the hottest keys survive.
10
+ *
11
+ * Expiry is measured on a monotonic clock, so an NTP step or a VM resume
12
+ * cannot extend (or collapse) an entry's lifetime. The wall-clock
13
+ * `expiresAt` returned to callers is derived from it for display only.
4
14
  */
5
- import { DEFAULT_MAX_ENTRIES, DEFAULT_TTL_MS } from "./constants.js";
15
+ import { DEFAULT_MAX_ENTRIES, DEFAULT_MAX_MEMORY_BYTES, DEFAULT_SEPARATOR, DEFAULT_TTL_MS, EXPIRED_PURGE_INTERVAL_MS, SIZE_ESTIMATE_NODE_BUDGET, } from "./constants.js";
16
+ import { assertValidTtl, createGlobMatcher, deleteManyViaDelete, monotonicNow, wallClockFor, } from "./utils.js";
17
+ /**
18
+ * Approximates the retained size of a value in bytes.
19
+ *
20
+ * Deliberately bounded: at most `SIZE_ESTIMATE_NODE_BUDGET` nodes are
21
+ * visited, so `set` stays cheap for large object graphs. Beyond the budget
22
+ * the remainder is charged a flat per-node estimate, which keeps the number
23
+ * monotone in value size without walking the whole graph.
24
+ */
25
+ export function estimateValueBytes(value) {
26
+ let budget = SIZE_ESTIMATE_NODE_BUDGET;
27
+ const seen = new Set();
28
+ const walk = (node) => {
29
+ if (budget <= 0)
30
+ return 16;
31
+ budget--;
32
+ switch (typeof node) {
33
+ case "string":
34
+ return 16 + node.length * 2;
35
+ case "number":
36
+ return 8;
37
+ case "boolean":
38
+ return 4;
39
+ case "bigint":
40
+ return 16;
41
+ case "undefined":
42
+ return 0;
43
+ case "function":
44
+ return 32;
45
+ default:
46
+ break;
47
+ }
48
+ if (node === null)
49
+ return 0;
50
+ const obj = node;
51
+ if (seen.has(obj))
52
+ return 0;
53
+ seen.add(obj);
54
+ if (ArrayBuffer.isView(obj))
55
+ return 16 + obj.byteLength;
56
+ if (obj instanceof ArrayBuffer)
57
+ return 16 + obj.byteLength;
58
+ if (obj instanceof Date)
59
+ return 16;
60
+ if (Array.isArray(obj)) {
61
+ let total = 16;
62
+ for (const item of obj)
63
+ total += walk(item);
64
+ return total;
65
+ }
66
+ if (obj instanceof Map) {
67
+ let total = 16;
68
+ for (const [k, v] of obj)
69
+ total += walk(k) + walk(v);
70
+ return total;
71
+ }
72
+ if (obj instanceof Set) {
73
+ let total = 16;
74
+ for (const item of obj)
75
+ total += walk(item);
76
+ return total;
77
+ }
78
+ let total = 16;
79
+ for (const [k, v] of Object.entries(obj))
80
+ total += 16 + k.length * 2 + walk(v);
81
+ return total;
82
+ };
83
+ return walk(value);
84
+ }
6
85
  export class MemoryCacheAdapter {
7
86
  name = "memory";
8
87
  store = new Map();
9
88
  maxEntries;
89
+ maxBytes;
10
90
  defaultTtl;
91
+ separator;
92
+ bytesUsed = 0;
93
+ lastPurgeAt = monotonicNow();
11
94
  constructor(options) {
12
95
  this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
96
+ this.maxBytes = options?.maxBytes ?? DEFAULT_MAX_MEMORY_BYTES;
13
97
  this.defaultTtl = options?.defaultTtl ?? DEFAULT_TTL_MS;
98
+ this.separator = options?.separator ?? DEFAULT_SEPARATOR;
14
99
  }
15
100
  async get(key) {
16
101
  const entry = this.store.get(key);
17
102
  if (!entry)
18
103
  return { hit: false, value: null };
19
104
  if (this.isExpired(entry)) {
20
- this.store.delete(key);
105
+ this.remove(key);
21
106
  return { hit: false, value: null };
22
107
  }
23
- return { hit: true, value: entry.value };
108
+ // LRU: a read moves the entry to the back of the eviction queue, so the
109
+ // hottest key is not evicted merely for being the oldest write.
110
+ this.store.delete(key);
111
+ this.store.set(key, entry);
112
+ return {
113
+ hit: true,
114
+ value: entry.value,
115
+ entry: this.toCacheEntry(key, entry),
116
+ };
24
117
  }
25
118
  async set(key, value, options) {
26
- this.evictIfNeeded();
27
- const ttl = options?.ttl ?? this.defaultTtl;
28
- const now = Date.now();
119
+ const ttl = options?.ttl !== undefined ? options.ttl : this.defaultTtl;
120
+ assertValidTtl(ttl);
121
+ if (options?.overwrite === false && (await this.has(key))) {
122
+ return { success: false, key, expiresAt: null, skipped: true };
123
+ }
124
+ const now = monotonicNow();
125
+ const bytes = estimateValueBytes(value) + key.length * 2;
126
+ // The opportunistic purge runs on the overwrite path too — time-gated,
127
+ // so re-setting hot keys stays cheap while dead entries elsewhere in the
128
+ // store still get collected. Overwriting releases the old entry's budget
129
+ // first, so it never counts twice.
130
+ this.maybePurgeExpired(now);
131
+ if (this.store.has(key))
132
+ this.remove(key);
133
+ this.evictIfNeeded(bytes);
29
134
  this.store.set(key, {
30
135
  value,
31
136
  createdAt: now,
32
137
  expiresAt: ttl !== null ? now + ttl : null,
33
138
  tags: options?.tags ?? [],
34
139
  metadata: options?.metadata ?? {},
140
+ bytes,
35
141
  });
142
+ this.bytesUsed += bytes;
36
143
  return {
37
144
  success: true,
38
145
  key,
39
- expiresAt: ttl !== null ? new Date(now + ttl) : null,
146
+ expiresAt: ttl !== null ? wallClockFor(now + ttl) : null,
40
147
  };
41
148
  }
42
149
  async delete(key) {
43
150
  const existed = this.store.has(key);
44
- this.store.delete(key);
151
+ this.remove(key);
45
152
  return { deleted: existed, key };
46
153
  }
47
154
  async has(key) {
@@ -49,7 +156,7 @@ export class MemoryCacheAdapter {
49
156
  if (!entry)
50
157
  return false;
51
158
  if (this.isExpired(entry)) {
52
- this.store.delete(key);
159
+ this.remove(key);
53
160
  return false;
54
161
  }
55
162
  return true;
@@ -58,28 +165,49 @@ export class MemoryCacheAdapter {
58
165
  if (!options?.pattern) {
59
166
  const size = this.store.size;
60
167
  this.store.clear();
168
+ this.bytesUsed = 0;
61
169
  return { cleared: size };
62
170
  }
63
171
  let cleared = 0;
64
- const regex = this.patternToRegex(options.pattern);
172
+ const matches = createGlobMatcher(options.pattern, {
173
+ separator: this.separator,
174
+ });
65
175
  for (const key of [...this.store.keys()]) {
66
- if (regex.test(key)) {
67
- this.store.delete(key);
176
+ if (matches(key)) {
177
+ this.remove(key);
68
178
  cleared++;
69
179
  }
70
180
  }
71
181
  return { cleared };
72
182
  }
73
183
  async keys(options) {
74
- let filtered = [...this.store.keys()];
184
+ let filtered = [];
185
+ for (const [key, entry] of [...this.store]) {
186
+ if (this.isExpired(entry)) {
187
+ this.remove(key);
188
+ continue;
189
+ }
190
+ filtered.push(key);
191
+ }
75
192
  if (options?.pattern) {
76
- const regex = this.patternToRegex(options.pattern);
77
- filtered = filtered.filter((k) => regex.test(k));
193
+ const matches = createGlobMatcher(options.pattern, {
194
+ separator: this.separator,
195
+ });
196
+ filtered = filtered.filter(matches);
78
197
  }
79
198
  if (options?.limit !== undefined)
80
199
  filtered = filtered.slice(0, options.limit);
81
200
  return filtered;
82
201
  }
202
+ /** Number of live entries currently held. */
203
+ async size() {
204
+ this.purgeExpired();
205
+ return this.store.size;
206
+ }
207
+ /** Estimated bytes currently retained by cached values. */
208
+ get estimatedBytes() {
209
+ return this.bytesUsed;
210
+ }
83
211
  async getMany(keys) {
84
212
  const results = new Map();
85
213
  for (const key of keys)
@@ -93,45 +221,101 @@ export class MemoryCacheAdapter {
93
221
  return results;
94
222
  }
95
223
  async deleteMany(keys) {
96
- let deleted = 0;
97
- const deletedKeys = [];
98
- for (const key of keys) {
99
- const result = await this.delete(key);
100
- if (result.deleted) {
101
- deleted++;
102
- deletedKeys.push(key);
103
- }
104
- }
105
- return { deleted, keys: deletedKeys };
224
+ return deleteManyViaDelete(keys, (key) => this.delete(key));
106
225
  }
226
+ /**
227
+ * Remaining TTL for a key in milliseconds.
228
+ * - `undefined` — the key does not exist (expired entries are deleted)
229
+ * - `null` — the key exists and never expires
230
+ * - `number` — remaining milliseconds until expiry
231
+ */
107
232
  async ttl(key) {
108
233
  const entry = this.store.get(key);
109
- if (!entry || entry.expiresAt === null)
234
+ if (!entry)
235
+ return undefined;
236
+ if (this.isExpired(entry)) {
237
+ this.remove(key);
238
+ return undefined;
239
+ }
240
+ if (entry.expiresAt === null)
110
241
  return null;
111
- const remaining = entry.expiresAt - Date.now();
112
- return remaining > 0 ? remaining : null;
242
+ return entry.expiresAt - monotonicNow();
113
243
  }
114
244
  async expire(key, ttl) {
245
+ assertValidTtl(ttl);
115
246
  const entry = this.store.get(key);
116
247
  if (!entry)
117
248
  return false;
118
- this.store.set(key, { ...entry, expiresAt: Date.now() + ttl });
249
+ if (this.isExpired(entry)) {
250
+ this.remove(key);
251
+ return false;
252
+ }
253
+ entry.expiresAt = ttl !== null ? monotonicNow() + ttl : null;
119
254
  return true;
120
255
  }
256
+ /* ---- Internals ---- */
257
+ toCacheEntry(key, entry) {
258
+ return {
259
+ key,
260
+ value: entry.value,
261
+ createdAt: wallClockFor(entry.createdAt),
262
+ expiresAt: entry.expiresAt !== null ? wallClockFor(entry.expiresAt) : null,
263
+ tags: entry.tags,
264
+ metadata: entry.metadata,
265
+ };
266
+ }
267
+ /**
268
+ * An entry is dead *at* its deadline, not one millisecond after it, so
269
+ * `ttl(key) === 0` and "present" are never both true.
270
+ */
121
271
  isExpired(entry) {
122
- return entry.expiresAt !== null && Date.now() > entry.expiresAt;
272
+ return entry.expiresAt !== null && monotonicNow() >= entry.expiresAt;
123
273
  }
124
- evictIfNeeded() {
125
- if (this.store.size >= this.maxEntries) {
126
- const firstKey = this.store.keys().next().value;
127
- if (firstKey !== undefined)
128
- this.store.delete(firstKey);
274
+ remove(key) {
275
+ const entry = this.store.get(key);
276
+ if (!entry)
277
+ return;
278
+ this.store.delete(key);
279
+ this.bytesUsed -= entry.bytes;
280
+ if (this.bytesUsed < 0)
281
+ this.bytesUsed = 0;
282
+ }
283
+ purgeExpired() {
284
+ for (const [key, entry] of [...this.store]) {
285
+ if (this.isExpired(entry))
286
+ this.remove(key);
129
287
  }
288
+ this.lastPurgeAt = monotonicNow();
289
+ }
290
+ maybePurgeExpired(now) {
291
+ if (now - this.lastPurgeAt < EXPIRED_PURGE_INTERVAL_MS)
292
+ return;
293
+ this.purgeExpired();
130
294
  }
131
- patternToRegex(pattern) {
132
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
- const regexStr = escaped.replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
134
- return new RegExp(`^${regexStr}$`);
295
+ /**
296
+ * Makes room for an incoming entry of `incomingBytes`, honouring both the
297
+ * entry-count cap and the memory budget. Expired entries are purged before
298
+ * live ones are evicted; live eviction is least-recently-used.
299
+ */
300
+ evictIfNeeded(incomingBytes) {
301
+ const overCount = this.store.size >= this.maxEntries;
302
+ const overBytes = this.bytesUsed + incomingBytes > this.maxBytes;
303
+ if (!overCount && !overBytes)
304
+ return;
305
+ this.purgeExpired();
306
+ while (this.store.size >= this.maxEntries) {
307
+ const lru = this.store.keys().next().value;
308
+ if (lru === undefined)
309
+ break;
310
+ this.remove(lru);
311
+ }
312
+ while (this.bytesUsed + incomingBytes > this.maxBytes &&
313
+ this.store.size > 0) {
314
+ const lru = this.store.keys().next().value;
315
+ if (lru === undefined)
316
+ break;
317
+ this.remove(lru);
318
+ }
135
319
  }
136
320
  }
137
321
  export function createMemoryCacheAdapter(options) {
package/dist/metrics.d.ts CHANGED
@@ -12,6 +12,11 @@ export declare class InMemoryCacheMetrics implements CacheMetrics {
12
12
  private readonly keyHits;
13
13
  private readonly latencies;
14
14
  incrementHit(key?: CacheKey): void;
15
+ /**
16
+ * Removes the least-hit tracked key and returns its count. Ties are
17
+ * broken by insertion order, so the oldest of the coldest goes first.
18
+ */
19
+ private evictColdestKey;
15
20
  incrementMiss(_key?: CacheKey): void;
16
21
  incrementSet(_key?: CacheKey): void;
17
22
  incrementDelete(_key?: CacheKey): void;
@@ -27,6 +32,11 @@ export declare class InMemoryCacheMetrics implements CacheMetrics {
27
32
  readonly p95: number;
28
33
  readonly p99: number;
29
34
  };
35
+ /**
36
+ * The most-read keys currently tracked, hottest first. The tracked set is
37
+ * bounded by MAX_TRACKED_KEYS and evicts its coldest member, so the
38
+ * ranking follows the live workload rather than freezing at start-up.
39
+ */
30
40
  getHotKeys(topN?: number): readonly {
31
41
  readonly key: CacheKey;
32
42
  readonly hits: number;
package/dist/metrics.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @zudojs/cache — Metrics
3
3
  * Tracks cache operation metrics including hit/miss ratios, latency histograms, and error counts.
4
4
  */
5
- import { LATENCY_BUCKETS, MAX_LATENCY_SAMPLES } from "./constants.js";
5
+ import { LATENCY_BUCKETS, MAX_LATENCY_SAMPLES, MAX_TRACKED_KEYS, } from "./constants.js";
6
6
  export class InMemoryCacheMetrics {
7
7
  hits = 0;
8
8
  misses = 0;
@@ -13,8 +13,43 @@ export class InMemoryCacheMetrics {
13
13
  latencies = new Map();
14
14
  incrementHit(key) {
15
15
  this.hits++;
16
- if (key)
17
- this.keyHits.set(key, (this.keyHits.get(key) ?? 0) + 1);
16
+ if (!key)
17
+ return;
18
+ const current = this.keyHits.get(key);
19
+ if (current !== undefined) {
20
+ this.keyHits.set(key, current + 1);
21
+ return;
22
+ }
23
+ // The tracked-key cap is an eviction policy, not a wall. A hard stop
24
+ // would freeze `getHotKeys()` on the first MAX_TRACKED_KEYS keys the
25
+ // process ever saw, so it would answer "what was hot at start-up" while
26
+ // looking live. Instead the coldest tracked key is evicted, and the new
27
+ // key inherits its count so it is not immediately evicted in turn and
28
+ // can climb the ranking if it really is hot.
29
+ if (this.keyHits.size >= MAX_TRACKED_KEYS) {
30
+ const coldest = this.evictColdestKey();
31
+ this.keyHits.set(key, coldest + 1);
32
+ return;
33
+ }
34
+ this.keyHits.set(key, 1);
35
+ }
36
+ /**
37
+ * Removes the least-hit tracked key and returns its count. Ties are
38
+ * broken by insertion order, so the oldest of the coldest goes first.
39
+ */
40
+ evictColdestKey() {
41
+ let coldestKey;
42
+ let coldestHits = Number.POSITIVE_INFINITY;
43
+ for (const [key, hits] of this.keyHits) {
44
+ if (hits < coldestHits) {
45
+ coldestHits = hits;
46
+ coldestKey = key;
47
+ }
48
+ }
49
+ if (coldestKey === undefined)
50
+ return 0;
51
+ this.keyHits.delete(coldestKey);
52
+ return coldestHits;
18
53
  }
19
54
  incrementMiss(_key) {
20
55
  this.misses++;
@@ -62,6 +97,11 @@ export class InMemoryCacheMetrics {
62
97
  p99: percentile(samples, 99),
63
98
  };
64
99
  }
100
+ /**
101
+ * The most-read keys currently tracked, hottest first. The tracked set is
102
+ * bounded by MAX_TRACKED_KEYS and evicts its coldest member, so the
103
+ * ranking follows the live workload rather than freezing at start-up.
104
+ */
65
105
  getHotKeys(topN = 10) {
66
106
  return [...this.keyHits.entries()]
67
107
  .sort((a, b) => b[1] - a[1])
@@ -70,7 +110,12 @@ export class InMemoryCacheMetrics {
70
110
  }
71
111
  getLatencyHistogram(operation) {
72
112
  const samples = this.latencies.get(operation) ?? [];
73
- return LATENCY_BUCKETS.map((boundary) => ({
113
+ // A +Infinity bucket captures samples above the largest finite boundary.
114
+ const boundaries = [
115
+ ...LATENCY_BUCKETS,
116
+ Number.POSITIVE_INFINITY,
117
+ ];
118
+ return boundaries.map((boundary) => ({
74
119
  bucket: boundary,
75
120
  count: samples.filter((s) => s <= boundary).length,
76
121
  }));
@@ -6,11 +6,27 @@
6
6
  * for the actual JSON serialization with type preservation.
7
7
  */
8
8
  import type { CacheSerializer } from "./types.js";
9
+ /**
10
+ * Strips prototype-polluting own keys from a freshly deserialized value.
11
+ *
12
+ * This is defence in depth at the trust boundary: a cached payload can come
13
+ * from a shared backing store, a restored backup, or another writer, so the
14
+ * bytes are not necessarily ones this process serialized. `@zudojs/errors`'
15
+ * sibling `@zudojs/serialization` accepts an `allowUnsafeKeys` option but
16
+ * the published build does not implement it, and `JSON.parse` leaves
17
+ * `__proto__` as a genuine own property — which re-triggers the setter on
18
+ * any later spread or `Object.assign`. Both routes are closed here.
19
+ */
20
+ export declare function stripUnsafeKeys<T>(value: T): T;
9
21
  /**
10
22
  * Serializes values to JSON strings and deserializes them back.
11
23
  *
12
- * Uses @zudojs/serialization's JSONSerializer for type preservation
13
- * (Date, BigInt, Map, Set, Uint8Array) when configured.
24
+ * Preserves special types (Date, BigInt, Map, Set, Uint8Array) by default —
25
+ * round-trip fidelity is the point of a cache, and a silently lossy default
26
+ * turns `set(k, { at: new Date() })` into a string on the way back out. Pass
27
+ * `{ preserveTypes: false }` for plain JSON semantics.
28
+ *
29
+ * Deserialization is hardened against prototype-polluting keys.
14
30
  */
15
31
  export declare class JsonCacheSerializer implements CacheSerializer<unknown, string> {
16
32
  private readonly inner;
@@ -29,15 +45,8 @@ export declare class RawCacheSerializer implements CacheSerializer<unknown, unkn
29
45
  serialize(value: unknown): unknown;
30
46
  deserialize(value: unknown): unknown;
31
47
  }
32
- /** Default JSON serializer instance. */
48
+ /** Default JSON serializer instance (type-preserving). */
33
49
  export declare const defaultSerializer: JsonCacheSerializer;
34
50
  /** Default raw (pass-through) serializer instance. */
35
51
  export declare const rawSerializer: RawCacheSerializer;
36
- /**
37
- * Returns the appropriate serializer for the given value type.
38
- *
39
- * - Objects, arrays, dates → JSON serializer
40
- * - Strings, numbers, booleans, null → raw serializer
41
- */
42
- export declare function getSerializer(value: unknown): CacheSerializer<unknown, unknown>;
43
52
  //# sourceMappingURL=serializer.d.ts.map
@@ -7,19 +7,97 @@
7
7
  */
8
8
  import { JSONSerializer } from "@zudojs/serialization";
9
9
  /* -------------------------------------------------------------------------- */
10
+ /* Prototype-pollution hardening */
11
+ /* -------------------------------------------------------------------------- */
12
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
13
+ /**
14
+ * Strips prototype-polluting own keys from a freshly deserialized value.
15
+ *
16
+ * This is defence in depth at the trust boundary: a cached payload can come
17
+ * from a shared backing store, a restored backup, or another writer, so the
18
+ * bytes are not necessarily ones this process serialized. `@zudojs/errors`'
19
+ * sibling `@zudojs/serialization` accepts an `allowUnsafeKeys` option but
20
+ * the published build does not implement it, and `JSON.parse` leaves
21
+ * `__proto__` as a genuine own property — which re-triggers the setter on
22
+ * any later spread or `Object.assign`. Both routes are closed here.
23
+ */
24
+ export function stripUnsafeKeys(value) {
25
+ const seen = new Set();
26
+ const walk = (node) => {
27
+ if (node === null || typeof node !== "object")
28
+ return;
29
+ const obj = node;
30
+ if (seen.has(obj))
31
+ return;
32
+ seen.add(obj);
33
+ if (Array.isArray(obj)) {
34
+ for (const item of obj)
35
+ walk(item);
36
+ return;
37
+ }
38
+ if (obj instanceof Map) {
39
+ for (const entry of obj.values())
40
+ walk(entry);
41
+ return;
42
+ }
43
+ if (obj instanceof Set) {
44
+ for (const entry of obj)
45
+ walk(entry);
46
+ return;
47
+ }
48
+ for (const key of Object.getOwnPropertyNames(obj)) {
49
+ if (UNSAFE_KEYS.has(key)) {
50
+ Reflect.deleteProperty(obj, key);
51
+ continue;
52
+ }
53
+ walk(obj[key]);
54
+ }
55
+ // A hijacked prototype survives key deletion, so reset it too.
56
+ if (Object.getPrototypeOf(obj) !== Object.prototype && isPlainish(obj)) {
57
+ Object.setPrototypeOf(obj, Object.prototype);
58
+ }
59
+ };
60
+ walk(value);
61
+ return value;
62
+ }
63
+ /**
64
+ * True when an object is a plain data container whose prototype should be
65
+ * `Object.prototype` — i.e. not a Date/Map/Set/typed array/class instance we
66
+ * deliberately reconstructed.
67
+ */
68
+ function isPlainish(obj) {
69
+ if (obj instanceof Date ||
70
+ obj instanceof Map ||
71
+ obj instanceof Set ||
72
+ obj instanceof RegExp ||
73
+ obj instanceof Error ||
74
+ ArrayBuffer.isView(obj) ||
75
+ obj instanceof ArrayBuffer) {
76
+ return false;
77
+ }
78
+ const proto = Object.getPrototypeOf(obj);
79
+ // Only a null prototype or a foreign literal object indicates tampering;
80
+ // anything with a real constructor is left alone.
81
+ return proto === null || proto.constructor === Object;
82
+ }
83
+ /* -------------------------------------------------------------------------- */
10
84
  /* JSON Serializer */
11
85
  /* -------------------------------------------------------------------------- */
12
86
  /**
13
87
  * Serializes values to JSON strings and deserializes them back.
14
88
  *
15
- * Uses @zudojs/serialization's JSONSerializer for type preservation
16
- * (Date, BigInt, Map, Set, Uint8Array) when configured.
89
+ * Preserves special types (Date, BigInt, Map, Set, Uint8Array) by default —
90
+ * round-trip fidelity is the point of a cache, and a silently lossy default
91
+ * turns `set(k, { at: new Date() })` into a string on the way back out. Pass
92
+ * `{ preserveTypes: false }` for plain JSON semantics.
93
+ *
94
+ * Deserialization is hardened against prototype-polluting keys.
17
95
  */
18
96
  export class JsonCacheSerializer {
19
97
  inner;
20
98
  preserveTypes;
21
99
  constructor(options) {
22
- this.preserveTypes = options?.preserveTypes ?? false;
100
+ this.preserveTypes = options?.preserveTypes ?? true;
23
101
  this.inner = new JSONSerializer();
24
102
  }
25
103
  serialize(value) {
@@ -28,9 +106,14 @@ export class JsonCacheSerializer {
28
106
  });
29
107
  }
30
108
  deserialize(value) {
31
- return this.inner.deserialize(value, {
109
+ const parsed = this.inner.deserialize(value, {
32
110
  preserveTypes: this.preserveTypes,
111
+ // Stated at the call site even though the published sibling build
112
+ // ignores them; `stripUnsafeKeys` enforces the intent regardless.
113
+ strict: true,
114
+ allowUnsafeKeys: false,
33
115
  });
116
+ return stripUnsafeKeys(parsed);
34
117
  }
35
118
  }
36
119
  /* -------------------------------------------------------------------------- */
@@ -51,24 +134,8 @@ export class RawCacheSerializer {
51
134
  /* -------------------------------------------------------------------------- */
52
135
  /* Default Singleton */
53
136
  /* -------------------------------------------------------------------------- */
54
- /** Default JSON serializer instance. */
137
+ /** Default JSON serializer instance (type-preserving). */
55
138
  export const defaultSerializer = new JsonCacheSerializer();
56
139
  /** Default raw (pass-through) serializer instance. */
57
140
  export const rawSerializer = new RawCacheSerializer();
58
- /**
59
- * Returns the appropriate serializer for the given value type.
60
- *
61
- * - Objects, arrays, dates → JSON serializer
62
- * - Strings, numbers, booleans, null → raw serializer
63
- */
64
- export function getSerializer(value) {
65
- if (value === null ||
66
- value === undefined ||
67
- typeof value === "string" ||
68
- typeof value === "number" ||
69
- typeof value === "boolean") {
70
- return rawSerializer;
71
- }
72
- return defaultSerializer;
73
- }
74
141
  //# sourceMappingURL=serializer.js.map