Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

safe-memory-cache

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

safe-memory-cache - npm Package Compare versions

Comparing version
2.0.0
to
3.0.0
+99
-52
index.js

@@ -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)
}
}
{
"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');
});
});
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()
}
}
}