UNPKG

node-persist

Version:

Super-easy (and fast) persistent data structures in Node.js, modeled after HTML5 localStorage

522 lines (457 loc) 12.9 kB
/* * Simon Last, Sept 2013 * http://simonlast.org */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { nextTick } = require('process'); const pLimit = require('p-limit'); const pkg = require('../package.json'); const defaults = { ttl: false, logging: false, encoding: 'utf8', parse: JSON.parse, stringify: JSON.stringify, forgiveParseErrors: false, expiredInterval: 2 * 60 * 1000, /* every 2 minutes */ dir: '.' + pkg.name + '/storage', writeQueue: true, writeQueueIntervalMs: 1000, writeQueueWriteOnlyLast: true, maxFileDescriptors: Infinity, }; const defaultTTL = 24 * 60 * 60 * 1000; /* if ttl is truthy but it's not a number, use 24h as default */ const isFunction = function(fn) { return typeof fn === 'function'; }; const isNumber = function(n) { return !isNaN(parseFloat(n)) && isFinite(n); }; const isDate = function(d) { return Object.prototype.toString.call(d) === '[object Date]'; }; const isValidDate = function(d) { return isDate(d) && !isNaN(d); }; const isFutureDate = function(d) { return isValidDate(d) && d.getTime() > (+new Date); }; const sha256 = function (key) { return crypto.createHash('sha256').update(key).digest('hex'); }; const isValidStorageFileContent = function (content) { return content && content.key; }; const isExpired = function (datum) { return datum && datum.ttl && datum.ttl < (new Date()).getTime(); }; const isNotExpired = function (datum) { return !isExpired(datum); }; const resolveDir = function(dir) { dir = path.normalize(dir); if (path.isAbsolute(dir)) { return dir; } return path.join(process.cwd(), dir); }; const LocalStorage = function (options) { if(!(this instanceof LocalStorage)) { return new LocalStorage(options); } this.setOptions(options); }; LocalStorage.prototype = { init: async function (options) { if (options) { this.setOptions(options); } await this.ensureDirectory(this.options.dir); if (this.options.expiredInterval) { this.startExpiredKeysInterval(); } this.q = {} this.startWriteQueueInterval(); return this.options; }, initSync: function (options) { if (options) { this.setOptions(options); } this.ensureDirectorySync(this.options.dir); if (this.options.expiredInterval) { this.startExpiredKeysInterval(); } this.q = {} this.startWriteQueueInterval(); return this.options; }, setOptions: function (userOptions) { let options = {}; if (!userOptions) { options = defaults; } else { for (let key in defaults) { if (userOptions.hasOwnProperty(key)) { options[key] = userOptions[key]; } else { options[key] = this.options && this.options[key] != null ? this.options[key] : defaults[key]; } } options.dir = resolveDir(options.dir); options.ttl = options.ttl ? isNumber(options.ttl) && options.ttl > 0 ? options.ttl : defaultTTL : false; } // Check to see if we received an external logging function if (isFunction(options.logging)) { // Overwrite log function with external logging function this.log = options.logging; options.logging = true; } // Update limiter if maxFileDescriptors has changed if (!this.limit || options.maxFileDescriptors !== this.options?.maxFileDescriptors) { this.limit = pLimit(options.maxFileDescriptors); } this.options = options; }, data: function () { return this.readDirectory(this.options.dir); }, keys: async function (filter) { let data = await this.data(); if (filter) { data = data.filter(filter); } return data.map(datum => datum.key); }, values: async function (filter) { let data = await this.data(); if (filter) { data = data.filter(filter); } return data.map(datum => datum.value); }, length: async function (filter) { let data = await this.data(); if (filter) { data = data.filter(filter); } return data.length; }, forEach: async function(callback) { let data = await this.data(); for (let d of data) { await callback(d); } }, valuesWithKeyMatch: function(match) { match = match || /.*/; let filter = match instanceof RegExp ? datum => match.test(datum.key) : datum => datum.key.indexOf(match) !== -1; return this.values(filter); }, set: function (key, value, options = {}) { return this.setItem(key, value, options); }, setItem: function (key, datumValue, options = {}) { let value = this.copy(datumValue); let ttl = this.calcTTL(options.ttl); this.log(`set ('${key}': '${this.stringify(value)}')`); let datum = { key, value, ttl }; return this.queueWriteFile(this.getDatumPath(key), datum); }, update: function (key, value, options = {}) { return this.updateItem(key, value, options); }, updateItem: async function (key, datumValue, options = {}) { let previousDatum = await this.getDatum(key); if (previousDatum && isNotExpired(previousDatum)) { let newDatumValue = this.copy(datumValue); let ttl; if (options.ttl) { ttl = this.calcTTL(options.ttl); } else { ttl = previousDatum.ttl; } this.log(`update ('${key}': '${this.stringify(newDatumValue)}')`); let datum = { key, value: newDatumValue, ttl }; return this.queueWriteFile(this.getDatumPath(key), datum); } else { return this.setItem(key, datumValue, options); } }, get: function (key) { return this.getItem(key); }, getItem: async function (key) { let datum = await this.getDatum(key); if (isExpired(datum)) { this.log(`${key} has expired`); await this.removeItem(key); } else { return datum.value; } }, getDatum: function (key) { return this.readFile(this.getDatumPath(key)); }, getRawDatum: function (key) { return this.readFile(this.getDatumPath(key), {raw: true}); }, getDatumValue: async function (key) { let datum = await this.getDatum(key); return datum && datum.value; }, getDatumPath: function (key) { return path.join(this.options.dir, sha256(key)); }, del: function (key) { return this.removeItem(key); }, rm: function (key) { return this.removeItem(key); }, removeItem: function (key) { return this.deleteFile(this.getDatumPath(key)); }, removeExpiredItems: async function () { let keys = await this.keys(isExpired); for (let key of keys) { await this.removeItem(key); } }, clear: async function () { let data = await this.data(); for (let d of data) { await this.removeItem(d.key); } }, ensureDirectory: function (dir) { return new Promise((resolve, reject) => { let result = {dir: dir}; fs.access(dir, (accessErr) => { if (!accessErr) { return resolve(result); } else { fs.mkdir(dir, { recursive: true }, (err) => { if (err) { return reject(err); } this.log('created ' + dir); resolve(result); }); } }); }); }, ensureDirectorySync: function (dir) { let result = {dir: dir}; try { fs.accessSync(dir) return result } catch (e) { fs.mkdirSync(dir, { recursive: true }) this.log('created ' + dir); return result } }, readDirectory: function (dir) { return new Promise((resolve, reject) => { //check to see if dir is present fs.access(dir, (accessErr) => { if (!accessErr) { //load data fs.readdir(dir, async (err, arr) => { if (err) { return reject(err); } let data = []; try { for (let currentFile of arr) { if (currentFile[0] !== '.') { // Limit concurrent reads data.push(await this.limit(() => this.readFile(path.join(this.options.dir, currentFile)))); } } } catch (err) { reject(err) } resolve(data); }); } else { reject(new Error(`[node-persist][readDirectory] ${dir} does not exists!`)); } }); }); }, readFile: function (file, options = {}) { return this.limit(() => new Promise((resolve, reject) => { fs.readFile(file, this.options.encoding, (err, text) => { if (err) { /* Only throw the error if the error is something else other than the file doesn't exist */ if (err.code === 'ENOENT') { this.log(`${file} does not exist, returning undefined value`); resolve(options.raw ? '{}' : {}); } else { return reject(err); } } let input = options.raw ? text : this.parse(text); if (!options.raw && !isValidStorageFileContent(input)) { return this.options.forgiveParseErrors ? resolve(options.raw ? '{}' : {}) : reject(new Error(`[node-persist][readFile] ${file} does not look like a valid storage file!`)); } resolve(input); }); })); }, queueWriteFile: async function (file, content) { if (this.options.writeQueue === false) { return this.writeFile(file, content) } this.q[file] = this.q[file] || [] nextTick(() => { this.startWriteQueueInterval() }) return new Promise((resolve, reject) => { this.q[file].push({ content, resolve, reject }) }) }, processWriteQueue: async function () { if (this.processingWriteQueue) { this.log('Still processing write queue, waiting...'); return } this.processingWriteQueue = true const promises = Object.keys(this.q).map(async file => { let writeItem if (this.options.writeQueueWriteOnlyLast) { // lifo writeItem = this.q[file].pop() } else { // fifo writeItem = this.q[file].shift() } try { const ret = await this.writeFile(file, writeItem.content) if (this.options.writeQueueWriteOnlyLast) { while (this.q[file].length) { const writeItem0 = this.q[file].shift() writeItem0.resolve(ret) } } writeItem.resolve(ret) } catch (e) { while (this.q[file].length) { const writeItem0 = this.q[file].shift() writeItem0.reject(e) } writeItem.reject(e) } if (!this.q[file] || !this.q[file].length) { delete this.q[file] } }) try { await Promise.all(promises) } finally { this.processingWriteQueue = false } }, startWriteQueueInterval: function () { this.processWriteQueue() if (!this._writeQueueInterval) { this._writeQueueInterval = setInterval(() => this.processWriteQueue(), this.options.writeQueueIntervalMs || 1000) this._writeQueueInterval.unref && this._writeQueueInterval.unref(); } }, stopWriteQueueInterval: function () { clearInterval(this._writeQueueInterval); }, writeFile: async function (file, content) { return this.limit(() => new Promise((resolve, reject) => { fs.writeFile(file, this.stringify(content), this.options.encoding, (err) => { if (err) { return reject(err); } resolve({file: file, content: content}); this.log('wrote: ' + file); }); })); }, deleteFile: function (file) { return this.limit(() => new Promise((resolve, reject) => { fs.access(file, (accessErr) => { if (!accessErr) { this.log(`Removing file:${file}`); fs.unlink(file, (err) => { /* Only throw the error if the error is something else */ if (err && err.code !== 'ENOENT') { return reject(err); } let result = {file: file, removed: !err, existed: !accessErr}; err && this.log(`Failed to remove file:${file} because it doesn't exist anymore.`); resolve(result); }); } else { this.log(`Not removing file:${file} because it doesn't exist`); let result = {file: file, removed: false, existed: false}; resolve(result); } }); })); }, stringify: function (obj) { return this.options.stringify(obj); }, parse: function(str) { if (str == null) { return undefined; } try { return this.options.parse(str); } catch(e) { this.log('parse error: ', this.stringify(e), 'for:', str); return undefined; } }, copy: function (value) { // don't copy literals since they're passed by value if (typeof value !== 'object') { return value; } return this.parse(this.stringify(value)); }, startExpiredKeysInterval: function () { this.stopExpiredKeysInterval(); this._expiredKeysInterval = setInterval(this.removeExpiredItems.bind(this), this.options.expiredInterval); this._expiredKeysInterval.unref && this._expiredKeysInterval.unref(); }, stopExpiredKeysInterval: function () { clearInterval(this._expiredKeysInterval); }, log: function () { this.options && this.options.logging && console.log.apply(console, arguments); }, calcTTL: function (ttl) { let now = new Date(); let nowts = now.getTime(); // only check for undefined, if null was passed in setItem then we probably didn't want to use the this.options.ttl if (typeof ttl === 'undefined') { ttl = this.options.ttl; } if (ttl) { if (isDate(ttl)) { if (!isFutureDate(ttl)) { ttl = defaultTTL; } ttl = ttl.getTime ? ttl.getTime() : ttl; } else { ttl = ttl ? isNumber(ttl) && ttl > 0 ? nowts + ttl : defaultTTL : void 0; } return ttl; } else { return void 0; } } }; module.exports = LocalStorage;