safe-memory-cache
Advanced tools
+99
-52
@@ -1,80 +0,127 @@ | ||
| function createMem(number, limit) { | ||
| var mem = Object.create(bucketsProto) | ||
| mem.N = number | ||
| mem.max = limit | ||
| mem.clear() | ||
| return mem | ||
| const { create, freeze, defineProperties } = Object | ||
| const { bind } = Function.prototype | ||
| const uncurryThis = bind.bind(bind.call) | ||
| const { setTimeout, setInterval, clearInterval } = globalThis | ||
| const arrayUnshift = uncurryThis(Array.prototype.unshift) | ||
| const arrayPush = uncurryThis(Array.prototype.push) | ||
| const arrayPop = uncurryThis(Array.prototype.pop) | ||
| const mapHas = uncurryThis(Map.prototype.has) | ||
| const mapGet = uncurryThis(Map.prototype.get) | ||
| const mapSet = uncurryThis(Map.prototype.set) | ||
| const private = new WeakMap() | ||
| const getPriv = private.get.bind(private) | ||
| const setPriv = private.set.bind(private) | ||
| function spawnBucket(buckets) { | ||
| arrayUnshift(buckets, new Map()) | ||
| } | ||
| function rotateBuckets(buckets, rotationHook) { | ||
| let i = 0 | ||
| while (i<buckets.length && buckets[i].size === 0) { | ||
| i++ | ||
| } | ||
| if (i === buckets.length) { | ||
| // nothing to do, all buckets empty | ||
| return | ||
| } | ||
| const dropped = arrayPop(buckets) | ||
| spawnBucket(buckets) | ||
| if (rotationHook) { | ||
| rotationHook(dropped) | ||
| } | ||
| } | ||
| var bucketsProto = { | ||
| clear: function clear() { | ||
| this.size = 0 | ||
| this.buckets = []; | ||
| const buckets = [] | ||
| setPriv(this, buckets) | ||
| for (var i = 0; i < this.N; i++) { | ||
| this.spawnBucket() | ||
| spawnBucket(buckets) | ||
| } | ||
| }, | ||
| spawnBucket: function spawnBucket() { | ||
| this.buckets.unshift(new Map()) | ||
| }, | ||
| rotateBuckets: function rotateBuckets() { | ||
| var dropped = this.buckets.pop() | ||
| this.spawnBucket() | ||
| this.size = 0 | ||
| if (this.rotationHook) { | ||
| this.rotationHook(dropped) | ||
| } | ||
| }, | ||
| set: function set(key, value) { | ||
| if (!(this.buckets[0].has(key))) { | ||
| this.size++; | ||
| if (this.max && this.size >= Math.ceil(this.max / this.buckets.length)) { | ||
| this.rotateBuckets() | ||
| const buckets = getPriv(this) | ||
| if (!(mapHas(buckets[0], key))) { | ||
| if (this.max && buckets[0].size >= Math.ceil(this.max / this.N)) { | ||
| rotateBuckets(buckets, this.rotationHook) | ||
| } | ||
| } | ||
| this.buckets[0].set(key, value) | ||
| mapSet(buckets[0], key, value) | ||
| return value | ||
| }, | ||
| get: function get(key) { | ||
| for (var i = 0; i < this.buckets.length; i++) { | ||
| if (this.buckets[i].has(key)) { | ||
| //todo: this should be configurable | ||
| if (i) { | ||
| //put a reference in the newest bucket | ||
| return this.set(key, this.buckets[i].get(key)) | ||
| const buckets = getPriv(this) | ||
| const retain = this.retainUsed | ||
| for (var i = 0; i < buckets.length; i++) { | ||
| if (mapHas(buckets[i], key)) { | ||
| const value = mapGet(buckets[i], key) | ||
| if (i && retain) { | ||
| //put a reference in the newest bucket to retain most used refs longer | ||
| return this.set(key, value) | ||
| } | ||
| return this.buckets[i].get(key) | ||
| return value | ||
| } | ||
| } | ||
| }, | ||
| _get_buckets: function () { return getPriv(this) }, | ||
| } | ||
| const weakRotate = (memWeakRef) => { | ||
| const mem = memWeakRef.deref() | ||
| if (mem) { | ||
| rotateBuckets(getPriv(mem), mem.rotationHook) | ||
| return true | ||
| } else { | ||
| return false | ||
| } | ||
| } | ||
| function rotateBucketsPeriodically(memWeakRef, interval) { | ||
| const handle = setInterval(() => { | ||
| if (false === weakRotate(memWeakRef)) { | ||
| clearInterval(handle) | ||
| } | ||
| }, interval) | ||
| // don't keep node live | ||
| if (handle.unref) { | ||
| handle.unref() | ||
| } | ||
| } | ||
| module.exports = { | ||
| /** | ||
| * | ||
| * @param {object} opts | ||
| * @param {number} [opts.buckets] - number of buckets to use (default 2) | ||
| * @param {number} [opts.limit] - max number of items to store (default unlimited) | ||
| * @param {number} [opts.maxTTL] - max time to live for an item | ||
| * @param {boolean} [opts.retainUsed] - retain most used items longer if possible | ||
| * @param {function} [opts.cleanupListener] - callback to call when a bucket is rotated | ||
| */ | ||
| safeMemoryCache(opts) { | ||
| var buckets = ~~(opts.buckets) || 2; | ||
| var mem = createMem(buckets, opts.limit) | ||
| mem.rotationHook = opts.cleanupListener || null | ||
| const number = ~~(opts.buckets) || 2; | ||
| const mem = create(bucketsProto) | ||
| defineProperties(mem, { | ||
| N: { value: number, enumerable: false }, | ||
| max: { value: opts.limit, enumerable: false }, | ||
| rotationHook: { value: opts.cleanupListener || null, enumerable: false }, | ||
| retainUsed: { value: opts.retainUsed || false, enumerable: false } | ||
| }) | ||
| mem.clear() | ||
| if (opts.maxTTL) { | ||
| var intervalHandle = setInterval(mem.rotateBuckets.bind(mem), ~~(opts.maxTTL / buckets)) | ||
| // use a weakref to not retain mem while accessing rotateBucket | ||
| const memWeakRef = new WeakRef(mem) | ||
| rotateBucketsPeriodically(memWeakRef, ~~(opts.maxTTL / number)) | ||
| } | ||
| return { | ||
| set: mem.set.bind(mem), | ||
| get: mem.get.bind(mem), | ||
| clear: mem.clear.bind(mem), | ||
| destroy: function () { | ||
| clearInterval(intervalHandle) | ||
| }, | ||
| _get_buckets: function () { | ||
| return mem.buckets | ||
| }, | ||
| _rotate_buckets: function () { | ||
| return mem.rotateBuckets() | ||
| } | ||
| } | ||
| return freeze(mem) | ||
| } | ||
| } |
+3
-3
| { | ||
| "name": "safe-memory-cache", | ||
| "version": "2.0.0", | ||
| "version": "3.0.0", | ||
| "description": "Secure and size-limited in-memory cache for Node.js and browsers", | ||
| "main": "index.js", | ||
| "scripts": { | ||
| "test": "node test.js" | ||
| "test": "node --expose-gc --test test.js" | ||
| }, | ||
@@ -22,3 +22,3 @@ "repository": { | ||
| ], | ||
| "author": "Zbyszek Tenerowicz <naugtur@gmail.com>", | ||
| "author": "naugtur <naugtur@gmail.com>", | ||
| "license": "MIT", | ||
@@ -25,0 +25,0 @@ "bugs": { |
+12
-25
| # safe-memory-cache | ||
| Secure and size-limited in-memory cache for Node.js and browsers. | ||
| Updated with defensive coding (for prototype poisoning immunity) | ||
| ## Why another cache package? | ||
| - Is lightweight and has trivial API | ||
| - Can't be broken by a malicious key (`__proto__`) | ||
| - Can't be broken by a malicious key (`__proto__`) or a prototype poisoning of intrinsics | ||
| - Limits the number of items without the use of `delete` (and no memory leaks caused by `delete`), plays well with garbage collector. But also **doesn't drop the whole cache when full, frees up gradually** | ||
@@ -18,16 +20,9 @@ - Doesn't waste your eventloop ticks with timeouts set to remove single items from cache, but still deletes oldest items first | ||
| cache.set("key1","value1") | ||
| cache.get("key1") == "value1" | ||
| cache.get("key1") === "value1" | ||
| cache.clear() | ||
| cache.get("key1") == undefined | ||
| cache.get("key1") === undefined | ||
| cache.destroy() //only needed if you use maxTTL | ||
| ``` | ||
| If your engine doesn't support `Map`, you can use the legacy version. It does manual sanitization on keys and it uses plain objects as buckets for storage. | ||
| ``` | ||
| const safeMemoryCache = require('safe-memory-cache/legacy') | ||
| ``` | ||
| ### options: | ||
@@ -39,34 +34,24 @@ | ||
| maxTTL | number | N | Time in miliseconds within which an element should no longer be in cache if it was not accessed. Actual time is approximate and will be less or equal `maxTTL` | ||
| strongSanitizer | bool | N | When set to `true` sanitizes keys to prevent memory issues in older JS engines. Defaults to `false`. No sanitization if you use the Map based version. | ||
| buckets | number | N | Overrides the number of buckets used internally. Default is 2 | ||
| cleanupListener | function | N | Calls the function with a storage bucket that's been removed | ||
| retainUsed | boolean | N | Keep items longer than the maxTTL if they are used | ||
| #### What limit should I set ? | ||
| If you expect N keys to be used most frequently, limit/buckets should be at least N. | ||
| If you expect N keys to be used most frequently, (limit/buckets) >= N | ||
| ## What is it fit for? | ||
| Caching in general. When you need to cache results of some long running process or a lot of them and you **don't** have a strong requirement to keep every item until its exact expiry time. | ||
| Caching in general. When you need to cache results of some long running process or a lot of them and you **don't** have a strong requirement to keep every item until its exact expiry time. | ||
| ## Technicalities | ||
| ### What's the point of strongSanitizer? | ||
| Some older JavaScript engines had memory leaks triggered by object keys which contain control characters like `-` or `.` | ||
| `strongSanitizer:true` makes sure the keys are converted to alphanumeric characters only. | ||
| Prevents any potential hacks like overwriting `__proto__` too. | ||
| Default sanitizer is only preventing `__proto__` key from breaking the functionality. | ||
| ### How do you know items added to native prototypes won't affect the cache? | ||
| Objects used for storing key/value pairs don't inherit from any of the native prototypes, nor `Object` | ||
| The implementation uses defensive coding to avoid relying on intrinsics that could be modified later. | ||
| ### What's with the memory leaks, buckets and delete? | ||
| `delete` keyword removes fields from objects, but changes the hidden class of the object which takes up some memory. As a result, adding and deleting unique fields to a plain JavaScript object may cause memory consumption to grow. Some JavaScript engines had it leak memory in various ways. | ||
| `delete` keyword removes fields from objects, but changes the `hidden class` aka `shape` of the object which takes up some memory. As a result, adding and deleting unique fields to a plain JavaScript object may cause memory consumption to grow. Some JavaScript engines had it leak memory in various ways. | ||
@@ -76,1 +61,3 @@ *Then how do you remove old items from cache if you can't use delete?* | ||
| Cache consists of a number of buckets and the oldest bucket is removed when new room is needed. Therefore the oldest (1/buckets) of entries gets removed. | ||
| There's only one interval created per cache instance. |
+71
-84
@@ -1,94 +0,81 @@ | ||
| var assert = require('assert') | ||
| const assert = require('assert'); | ||
| const { describe, it } = require('node:test'); | ||
| const { safeMemoryCache } = require('./index'); | ||
| const { setTimeout } = require('timers/promises'); | ||
| describe('Safe Memory Cache', () => { | ||
| it('should handle size limit correctly', () => { | ||
| const c = safeMemoryCache({ | ||
| limit: 5 | ||
| }); | ||
| console.log('Empty state', c._get_buckets()); | ||
| assert.strictEqual(c.get('a'), undefined, 'expected nothing'); | ||
| console.log('# Legacy implementation') | ||
| var safeMemoryCache = require('./legacy') | ||
| c.set('a', 'x'); | ||
| c.set('a', 'x'); | ||
| c.set('a', 'x'); | ||
| c.set('a', 'x'); | ||
| var c = safeMemoryCache({ | ||
| limit:5 | ||
| }) | ||
| console.log('After adding the same item multiple times', c._get_buckets()); | ||
| assert.strictEqual(c.get('a'), 'x', 'expected b'); | ||
| console.log('Empty state', c._get_buckets()) | ||
| assert.equal(c.get('a'), null, 'expected nothing') | ||
| console.log('ok') | ||
| c.set('1', 'x'); | ||
| c.set('2', 'x'); | ||
| c.set('3', 'x'); | ||
| c.set('4', 'x'); | ||
| c.set('a','x') | ||
| c.set('a','x') | ||
| c.set('a','x') | ||
| c.set('a','x') | ||
| console.log('After adding 4 more items to a collection limited to 5', c._get_buckets()); | ||
| assert.strictEqual(c.get('a'), 'x', 'a should be still available from second bucket'); | ||
| console.log('After running a .get', c._get_buckets()); | ||
| console.log('After adding the same item multiple times', c._get_buckets()) | ||
| assert.equal(c.get('a'), 'x', 'expected b') | ||
| console.log('ok') | ||
| c.set('q1', 'x'); | ||
| c.set('q2', 'x'); | ||
| c.set('q3', 'x'); | ||
| c.set('q4', 'x'); | ||
| c.set('q5', 'x'); | ||
| c.set('q6', 'x'); | ||
| c.set('q7', 'x'); | ||
| console.log('After adding even more items', c._get_buckets()); | ||
| assert.strictEqual(c.get('a'), undefined, 'a should no longer be in the set'); | ||
| }); | ||
| c.set('1','x') | ||
| c.set('2','x') | ||
| c.set('3','x') | ||
| c.set('4','x') | ||
| it('should implement ttl transparently; clean up its own interval when collected', async () => { | ||
| let count = 0; | ||
| let countSnapshot; | ||
| const rotateListener = (bucket) => { | ||
| count++; | ||
| } | ||
| await (async function () { | ||
| const c = safeMemoryCache({ | ||
| limit: 5, | ||
| buckets: 5, | ||
| maxTTL: 100, | ||
| cleanupListener: rotateListener | ||
| }); | ||
| c.set('a', 'x'); | ||
| await setTimeout(100); | ||
| countSnapshot = count; | ||
| })() | ||
| gc(); | ||
| await setTimeout(100); | ||
| assert(countSnapshot > 0, 'expected bucket rotation to ocur while the cache was alive'); | ||
| assert.strictEqual(count, countSnapshot, 'expected no bucket rotation after cache was garbage-collected'); | ||
| }); | ||
| console.log('After adding 4 more items to a collection limited to 5', c._get_buckets()) | ||
| assert.equal(c.get('a'), 'x', 'a should be still available from second bucket') | ||
| console.log('After running a .get', c._get_buckets()) | ||
| console.log('ok') | ||
| c.set('q1','x') | ||
| c.set('q2','x') | ||
| c.set('q3','x') | ||
| c.set('q4','x') | ||
| c.set('q5','x') | ||
| c.set('q6','x') | ||
| c.set('q7','x') | ||
| console.log('After adding even more items', c._get_buckets()) | ||
| assert.equal(c.get('a'), null, 'a should no longer be in the set') | ||
| console.log('ok') | ||
| console.log('# Map implementation') | ||
| var {safeMemoryCache} = require('./index') | ||
| var c = safeMemoryCache({ | ||
| limit:5 | ||
| }) | ||
| console.log('Empty state', c._get_buckets()) | ||
| assert.equal(c.get('a'), null, 'expected nothing') | ||
| console.log('ok') | ||
| c.set('a','x') | ||
| c.set('a','x') | ||
| c.set('a','x') | ||
| c.set('a','x') | ||
| console.log('After adding the same item multiple times', c._get_buckets()) | ||
| assert.equal(c.get('a'), 'x', 'expected b') | ||
| console.log('ok') | ||
| c.set('1','x') | ||
| c.set('2','x') | ||
| c.set('3','x') | ||
| c.set('4','x') | ||
| console.log('After adding 4 more items to a collection limited to 5', c._get_buckets()) | ||
| assert.equal(c.get('a'), 'x', 'a should be still available from second bucket') | ||
| console.log('After running a .get', c._get_buckets()) | ||
| console.log('ok') | ||
| c.set('q1','x') | ||
| c.set('q2','x') | ||
| c.set('q3','x') | ||
| c.set('q4','x') | ||
| c.set('q5','x') | ||
| c.set('q6','x') | ||
| c.set('q7','x') | ||
| console.log('After adding even more items', c._get_buckets()) | ||
| assert.equal(c.get('a'), null, 'a should no longer be in the set') | ||
| console.log('ok') | ||
| it('should not rotate if all buckets are empty', async () => { | ||
| let count = 0; | ||
| const rotateListener = (bucket) => { | ||
| count++; | ||
| } | ||
| const c = safeMemoryCache({ | ||
| limit: 5, | ||
| buckets: 5, | ||
| maxTTL: 100, | ||
| cleanupListener: rotateListener | ||
| }); | ||
| await setTimeout(100); | ||
| assert.strictEqual(count, 0, 'expected no bucket rotation to trigger'); | ||
| }); | ||
| }); |
-96
| function createMem(number, limit) { | ||
| var mem = Object.create(bucketsProto) | ||
| mem.N = number | ||
| mem.max = limit | ||
| mem.clear() | ||
| return mem | ||
| } | ||
| var bucketsProto = { | ||
| clear: function clear() { | ||
| this.size = 0 | ||
| this.buckets=[]; | ||
| for (var i = 0; i < this.N; i++) { | ||
| this.spawnBucket() | ||
| } | ||
| }, | ||
| spawnBucket: function spawnBucket() { | ||
| this.buckets.unshift(Object.create(null)) | ||
| }, | ||
| rotateBuckets: function rotateBuckets() { | ||
| var dropped = this.buckets.pop() | ||
| this.spawnBucket() | ||
| this.size = 0 | ||
| if(this.rotationHook){ | ||
| this.rotationHook(dropped) | ||
| } | ||
| }, | ||
| set: function set(key, value) { | ||
| if (!(key in this.buckets[0])) { | ||
| this.size++; | ||
| if (this.max && this.size >= Math.ceil(this.max / this.buckets.length)) { | ||
| this.rotateBuckets() | ||
| } | ||
| } | ||
| this.buckets[0][key] = value | ||
| return value | ||
| }, | ||
| get: function get(key) { | ||
| for (var i = 0; i < this.buckets.length; i++) { | ||
| if (key in this.buckets[i]) { | ||
| //todo: this should be configurable | ||
| if (i) { | ||
| //put a reference in the newest bucket | ||
| return this.set(key,this.buckets[i][key]) | ||
| } | ||
| return this.buckets[i][key] | ||
| } | ||
| } | ||
| } | ||
| } | ||
| var protoRegex = /__proto__/g; | ||
| function sanitizeSimple(key) { | ||
| return '' + key.replace(protoRegex, 'z__proto__') | ||
| } | ||
| function sanitizeHeavy(key) { | ||
| return ('' + key).split('').map(function(char) { | ||
| return char.charCodeAt(0).toString(32) | ||
| }).join('z') | ||
| } | ||
| module.exports = function(opts) { | ||
| var buckets = ~~(opts.buckets) || 2; | ||
| var mem = createMem(buckets, opts.limit) | ||
| mem.rotationHook = opts.cleanupListener || null | ||
| var sanitize = (opts.strongSanitizer ? sanitizeHeavy : sanitizeSimple) | ||
| if (opts.maxTTL) { | ||
| var intervalHandle = setInterval(mem.rotateBuckets.bind(mem), ~~(opts.maxTTL / buckets)) | ||
| } | ||
| return { | ||
| set: function(key, value) { | ||
| return mem.set(sanitize(key), value) | ||
| }, | ||
| get: function(key) { | ||
| return mem.get(sanitize(key)) | ||
| }, | ||
| clear: mem.clear.bind(mem), | ||
| destroy: function() { | ||
| mem.rotationHook = null | ||
| clearInterval(intervalHandle) | ||
| }, | ||
| _get_buckets: function(){ | ||
| return mem.buckets | ||
| }, | ||
| _rotate_buckets: function() { | ||
| return mem.rotateBuckets() | ||
| } | ||
| } | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
11112
-6%5
-16.67%184
-18.58%61
-17.57%1
Infinity%