├── .babelrc ├── .gitignore ├── brownies.js ├── brownies.min.js ├── brownies.test.js ├── lib └── stringify.js ├── package.json ├── readme.md ├── rollup.config.js └── src ├── cookies.js ├── cookies.test.js ├── db.js ├── db.test.js ├── local.js ├── local.test.js ├── options.js ├── packer.js ├── packer.test.js ├── session.js ├── session.test.js ├── subscribe.js ├── subscribe.test.js ├── subscriptions.js ├── unsubscribe.js └── unsubscribe.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["env"] } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | -------------------------------------------------------------------------------- /brownies.js: -------------------------------------------------------------------------------- 1 | import cookies from './src/cookies'; 2 | import local from './src/local'; 3 | import session from './src/session'; 4 | import db from './src/db'; 5 | import options from './src/options'; 6 | import subscribe from './src/subscribe'; 7 | import unsubscribe from './src/unsubscribe'; 8 | 9 | export { cookies, local, session, db, options, subscribe, unsubscribe }; 10 | -------------------------------------------------------------------------------- /brownies.min.js: -------------------------------------------------------------------------------- 1 | (function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?factory(exports):typeof define==="function"&&define.amd?define(["exports"],factory):factory(global.brownies={})})(this,function(exports){"use strict";var options=Symbol("options");var cookies$1=function(data,opt){function defaults(obj,defs){obj=obj||{};for(var key in defs){if(obj[key]===undefined){obj[key]=defs[key]}}return obj}defaults(cookies$1,{expires:365*24*3600,path:"/",secure:window.location.protocol==="https:",nulltoremove:true,autojson:true,autoencode:true,encode:function(val){return encodeURIComponent(val)},decode:function(val){return decodeURIComponent(val)},fallback:false});opt=defaults(opt,cookies$1);function expires(time){var expires=time;if(!(expires instanceof Date)){expires=new Date;expires.setTime(expires.getTime()+time*1e3)}return expires.toUTCString()}if(typeof data==="string"){var value=document.cookie.split(/;\s*/).map(opt.autoencode?opt.decode:function(d){return d}).map(function(part){return part.split("=")}).reduce(function(parts,part){parts[part[0]]=part.splice(1).join("=");return parts},{})[data];if(!opt.autojson)return value;var real;try{real=JSON.parse(value)}catch(e){real=value}if(typeof real==="undefined"&&opt.fallback)real=opt.fallback(data,opt);return real}for(var key in data){var val=data[key];var expired=typeof val==="undefined"||opt.nulltoremove&&val===null;var str=opt.autojson?JSON.stringify(val):val;var encoded=opt.autoencode?opt.encode(str):str;if(expired)encoded="";var res=opt.encode(key)+"="+encoded+(opt.expires?";expires="+expires(expired?-1e4:opt.expires):"")+";path="+opt.path+(opt.domain?";domain="+opt.domain:"")+(opt.secure?";secure":"");if(opt.test)opt.test(res);document.cookie=res}return cookies$1};var subscriptions=[];const get=(target,key)=>{if(key===Symbol.iterator)return getIterator();if(key===options)return cookies$1;const value=cookies$1(key);return typeof value==="undefined"?null:value};const set=(target,key,value=null)=>{if(key===options){for(let key in value){cookies$1[key]=value[key];return true}}cookies$1({[key]:value});subscriptions.filter(sub=>sub.key===key).forEach(({check:check})=>check());return true};const getAll=()=>{const pairs=document.cookie.split(";");const cookies={};for(var i=0;i{const all=Object.values(getAll());return function*(){while(all.length)yield all.shift()}};const getOwnPropertyDescriptor=()=>({enumerable:true,configurable:true});const ownKeys=()=>Object.keys(getAll());const traps={get:get,set:set,deleteProperty:set,getOwnPropertyDescriptor:getOwnPropertyDescriptor,ownKeys:ownKeys};var cookies=new Proxy({},traps);var stringify=(obj,opts)=>{if(!opts)opts={};if(typeof opts==="function")opts={cmp:opts};var space=opts.space||"";if(typeof space==="number")space=Array(space+1).join(" ");var cycles=typeof opts.cycles==="boolean"?opts.cycles:false;var replacer=opts.replacer||function(key,value){return value};var cmp=opts.cmp&&function(f){return function(node){return function(a,b){var aobj={key:a,value:node[a]};var bobj={key:b,value:node[b]};return f(aobj,bobj)}}}(opts.cmp);var seen=[];return function stringify(parent,key,node,level){var indent=space?"\n"+new Array(level+1).join(space):"";var colonSeparator=space?": ":":";if(node&&node.toJSON&&typeof node.toJSON==="function"){node=node.toJSON()}node=replacer.call(parent,key,node);if(node===undefined){return}if(typeof node!=="object"||node===null){return JSON.stringify(node)}if(isArray(node)){var out=[];for(var i=0;i{if(typeof str!=="string"){str=js+stringify(str)}return str};const unpack=str=>{if(str&&typeof str==="string"&&str.slice(0,js.length)===js){return JSON.parse(str.slice(js.length))}return str};const getAll$1=()=>{const all={};for(var key in localStorage){if(local[key]!==null){all[key]=local[key]}}return all};const local=new Proxy({},{get:(target,key)=>{if(key===Symbol.iterator){const all=Object.values(getAll$1());return function*(){while(all.length)yield all.shift()}}return unpack(localStorage.getItem(key))},set:(target,key,value)=>{localStorage.setItem(key,pack(value));subscriptions.filter(sub=>sub.key===key).forEach(({check:check})=>check());return true},deleteProperty:(target,key)=>{localStorage.removeItem(key);subscriptions.filter(sub=>sub.key===key).forEach(({check:check})=>check());return true},getOwnPropertyDescriptor(k){return{enumerable:true,configurable:true}},ownKeys(target){return Object.keys(getAll$1())}});const getAll$2=()=>{const all={};for(var key in sessionStorage){if(local$2[key]!==null){all[key]=local$2[key]}}return all};const local$2=new Proxy({},{get:(target,key)=>{if(key===Symbol.iterator){const all=Object.values(getAll$2());return function*(){while(all.length)yield all.shift()}}return unpack(sessionStorage.getItem(key))},set:(target,key,value)=>{sessionStorage.setItem(key,pack(value));subscriptions.filter(sub=>sub.key===key).forEach(({check:check})=>check());return true},deleteProperty:(target,key)=>{sessionStorage.removeItem(key);subscriptions.filter(sub=>sub.key===key).forEach(({check:check})=>check());return true},getOwnPropertyDescriptor(k){return{enumerable:true,configurable:true}},ownKeys(target){return Object.keys(getAll$2())}});class Store{constructor(dbName="keyval-store",storeName="keyval"){this.storeName=storeName;this._dbp=new Promise((resolve,reject)=>{const openreq=indexedDB.open(dbName,1);openreq.onerror=(()=>reject(openreq.error));openreq.onsuccess=(()=>resolve(openreq.result));openreq.onupgradeneeded=(()=>{openreq.result.createObjectStore(storeName)})})}_withIDBStore(type,callback){return this._dbp.then(db=>new Promise((resolve,reject)=>{const transaction=db.transaction(this.storeName,type);transaction.oncomplete=(()=>resolve());transaction.onabort=transaction.onerror=(()=>reject(transaction.error));callback(transaction.objectStore(this.storeName))}))}}let store;function getDefaultStore(){if(!store)store=new Store;return store}function get$1(key,store=getDefaultStore()){let req;return store._withIDBStore("readonly",store=>{req=store.get(key)}).then(()=>req.result)}function set$1(key,value,store=getDefaultStore()){return store._withIDBStore("readwrite",store=>{store.put(value,key)})}function del(key,store=getDefaultStore()){return store._withIDBStore("readwrite",store=>{store.delete(key)})}function keys(store=getDefaultStore()){const keys=[];return store._withIDBStore("readonly",store=>{(store.openKeyCursor||store.openCursor).call(store).onsuccess=function(){if(!this.result)return;keys.push(this.result.key);this.result.continue()}}).then(()=>keys)}const getAll$3=async()=>{const all={};const ks=await keys();await Promise.all(ks.map(async key=>{all[key]=await get$1(key)}));return all};const db=new Proxy(getAll$3,{get:async(target,key)=>{const value=await get$1(key);return typeof value==="undefined"?null:value},set:(target,key,value)=>{set$1(key,value);subscriptions.filter(sub=>sub.key===key).forEach(({check:check})=>check());return true},deleteProperty:(target,key)=>{del(key);subscriptions.filter(sub=>sub.key===key).forEach(({check:check})=>check());return true}});const isBasic=value=>!value||["boolean","number","string"].includes(typeof value);const clone=value=>{if(isBasic(value))return value;return JSON.parse(stringify(value))};var subscribe=(obj,key,cb)=>{let prev=obj[key]&&obj[key].then?obj[key].then(clone):clone(obj[key]);const check=()=>{const value=obj[key];if(prev&&prev.then||value&&value.then){return Promise.all([prev,value]).then(([previous,value])=>{if(stringify(previous)===stringify(value))return;cb(value,previous);prev=clone(value)})}if(stringify(prev)===stringify(value))return;cb(value,prev);prev=clone(value)};const id=setInterval(check,100);subscriptions.push({id:id,key:key,check:check,cb:cb});return id};var unsubscribe=id=>{if(typeof id==="number"){return clearInterval(id)}return subscriptions.filter(({cb:cb})=>cb===id).map(sub=>clearInterval(sub.id))};exports.cookies=cookies;exports.local=local;exports.session=local$2;exports.db=db;exports.options=options;exports.subscribe=subscribe;exports.unsubscribe=unsubscribe;Object.defineProperty(exports,"__esModule",{value:true})}); -------------------------------------------------------------------------------- /brownies.test.js: -------------------------------------------------------------------------------- 1 | import { cookies, local, session, subscribe } from './brownies'; 2 | const brownies = require('./brownies.min.js'); 3 | const delay = require('delay'); 4 | const { cookies: cookiesReq, local: localReq } = require('./brownies.min.js'); 5 | 6 | describe('minimized', () => { 7 | it('is defined', () => { 8 | expect(cookies).toBeDefined(); 9 | expect(local).toBeDefined(); 10 | 11 | expect(brownies).toBeDefined(); 12 | expect(brownies.cookies).toBeDefined(); 13 | expect(brownies.local).toBeDefined(); 14 | expect(cookiesReq).toBeDefined(); 15 | expect(localReq).toBeDefined(); 16 | }); 17 | 18 | it('will call it appropriately', () => { 19 | const fn = jest.fn(); 20 | subscribe(cookies, 'same', fn); 21 | cookies.same = 'should not trigger'; 22 | expect(fn).toBeCalledWith('should not trigger', null); 23 | }); 24 | 25 | it('does not cross-subscribe', async () => { 26 | const fn = jest.fn(); 27 | subscribe(cookies, 'cross', fn); 28 | session.cross = 'should not trigger'; 29 | local.cross = 'should not trigger'; 30 | await delay(300); 31 | expect(fn).not.toBeCalled(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /lib/stringify.js: -------------------------------------------------------------------------------- 1 | export default (obj, opts) => { 2 | if (!opts) opts = {}; 3 | if (typeof opts === 'function') opts = { 4 | cmp: opts 5 | }; 6 | var space = opts.space || ''; 7 | if (typeof space === 'number') space = Array(space + 1).join(' '); 8 | var cycles = (typeof opts.cycles === 'boolean') ? opts.cycles : false; 9 | var replacer = opts.replacer || function(key, value) { 10 | return value; 11 | }; 12 | 13 | var cmp = opts.cmp && (function(f) { 14 | return function(node) { 15 | return function(a, b) { 16 | var aobj = { 17 | key: a, 18 | value: node[a] 19 | }; 20 | var bobj = { 21 | key: b, 22 | value: node[b] 23 | }; 24 | return f(aobj, bobj); 25 | }; 26 | }; 27 | })(opts.cmp); 28 | 29 | var seen = []; 30 | return (function stringify(parent, key, node, level) { 31 | var indent = space ? ('\n' + new Array(level + 1).join(space)) : ''; 32 | var colonSeparator = space ? ': ' : ':'; 33 | 34 | if (node && node.toJSON && typeof node.toJSON === 'function') { 35 | node = node.toJSON(); 36 | } 37 | 38 | node = replacer.call(parent, key, node); 39 | 40 | if (node === undefined) { 41 | return; 42 | } 43 | if (typeof node !== 'object' || node === null) { 44 | return JSON.stringify(node); 45 | } 46 | if (isArray(node)) { 47 | var out = []; 48 | for (var i = 0; i < node.length; i++) { 49 | var item = stringify(node, i, node[i], level + 1) || JSON.stringify(null); 50 | out.push(indent + space + item); 51 | } 52 | return '[' + out.join(',') + indent + ']'; 53 | } else { 54 | if (seen.indexOf(node) !== -1) { 55 | if (cycles) return JSON.stringify('__cycle__'); 56 | throw new TypeError('Converting circular structure to JSON'); 57 | } else seen.push(node); 58 | 59 | var keys = objectKeys(node).sort(cmp && cmp(node)); 60 | var out = []; 61 | for (var i = 0; i < keys.length; i++) { 62 | var key = keys[i]; 63 | var value = stringify(node, key, node[key], level + 1); 64 | 65 | if (!value) continue; 66 | 67 | var keyValue = JSON.stringify(key) + 68 | colonSeparator + 69 | value;; 70 | out.push(indent + space + keyValue); 71 | } 72 | seen.splice(seen.indexOf(node), 1); 73 | return '{' + out.join(',') + indent + '}'; 74 | } 75 | })({ 76 | '': obj 77 | }, '', obj, 0); 78 | }; 79 | 80 | var isArray = Array.isArray || function(x) { 81 | return {}.toString.call(x) === '[object Array]'; 82 | }; 83 | 84 | var objectKeys = Object.keys || function(obj) { 85 | var has = Object.prototype.hasOwnProperty || function() { 86 | return true 87 | }; 88 | var keys = []; 89 | for (var key in obj) { 90 | if (has.call(obj, key)) keys.push(key); 91 | } 92 | return keys; 93 | }; 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brownies", 3 | "version": "3.0.0", 4 | "description": "🍫 Tastier cookies, local, session, and db storage in a tiny package. Includes subscribe() events for changes.", 5 | "main": "brownies.min.js", 6 | "scripts": { 7 | "build": "rollup -c | uglifyjs -o brownies.min.js", 8 | "pretest": "npm run build", 9 | "test": "jest --coverage --collectCoverageFrom=src/**/*.js --detectOpenHandles", 10 | "gzip": "gzip -c brownies.min.js | wc -c && echo 'bytes' # Only for Unix" 11 | }, 12 | "files": [], 13 | "keywords": [ 14 | "brownies", 15 | "cookie", 16 | "cookies", 17 | "localStorage", 18 | "local", 19 | "store", 20 | "storage", 21 | "proxy", 22 | "synchronous", 23 | "clean", 24 | "interface" 25 | ], 26 | "author": "Francisco Presencia (https://francisco.io/)", 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/franciscop/brownies.git" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/franciscop/brownies/issues" 33 | }, 34 | "license": "MIT", 35 | "devDependencies": { 36 | "babel-core": "^6.26.0", 37 | "babel-jest": "^21.2.0", 38 | "babel-preset-env": "^1.6.1", 39 | "cookiesjs": "^3.0.1", 40 | "delay": "^4.0.1", 41 | "fake-indexeddb": "^2.0.4", 42 | "idb-keyval": "^3.1.0", 43 | "json-stable-stringify": "^1.0.1", 44 | "rollup": "^0.50.0", 45 | "rollup-plugin-commonjs": "^9.2.0", 46 | "rollup-plugin-node-resolve": "^3.4.0", 47 | "sinon": "^6.3.5", 48 | "uglify-es": "^3.1.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Brownies [![npm install brownies](https://img.shields.io/badge/npm%20install-brownies-blue.svg)](https://www.npmjs.com/package/brownies) [![gzip size](https://img.badgesize.io/franciscop/brownies/master/brownies.min.js.svg?compression=gzip)](https://github.com/franciscop/brownies/blob/master/brownies.min.js) [![dependencies](https://img.shields.io/badge/dependencies-0-limegreen.svg)](https://github.com/franciscop/brownies/blob/master/package.json) [![support](https://img.shields.io/badge/es-6-limegreen.svg)](https://caniuse.com/#feat=proxy) [![playground](https://img.shields.io/badge/play-jsfiddle-blue.svg)](https://jsfiddle.net/oc5ju3ge/) 2 | 3 | Tastier `cookies`, `local`, `session`, and `db` storage in a tiny package: 4 | 5 | ```js 6 | import { cookies, local, db } from 'brownies'; 7 | 8 | cookies.token = 42; // Set it 9 | let t = cookies.token; // Get it 10 | delete cookies.token; // Eat it 11 | 12 | local.token = 42; // Set it 13 | let t = local.token; // Get it 14 | delete local.token; // Del it 15 | 16 | // db is ASYNC so read is different 17 | db.token = 42; // Set it 18 | let t = await db.token; // Get it 19 | delete db.token; // Del it 20 | ``` 21 | 22 | Subscribe to changes in any of the objects: 23 | 24 | ```js 25 | import { session, subscribe } from 'brownies'; 26 | 27 | subscribe(session, 'token', value => { 28 | console.log(value); // 42, 'Hello', null 29 | }); 30 | 31 | session.token = 42; 32 | session.token = 'Hello'; 33 | delete session.token; 34 | ``` 35 | 36 | You can also [iterate them as expected](https://github.com/franciscop/brownies/blob/master/src/cookies.test.js) with `Object.keys()`, `Object.values()`, etc: 37 | 38 | ```js 39 | cookies.token = 42; 40 | cookies.name = 'Francisco'; 41 | 42 | console.log(Object.keys(cookies)); // token, name 43 | 44 | for (let val of cookies) { 45 | console.log(val); // 42, 'Francisco' 46 | } 47 | ``` 48 | 49 | 50 | 51 | ## Getting started 52 | 53 | Install it with npm: 54 | 55 | ``` 56 | npm install brownies 57 | ``` 58 | 59 | Then import the different parts: 60 | 61 | ```js 62 | import { cookies, local, ... } from 'brownies'; 63 | const { cookies, local, ... } = require('brownies'); 64 | ``` 65 | 66 | Or use a CDN for the browser: 67 | 68 | ```html 69 | 70 | 74 | ``` 75 | 76 | If you just want to play, go to the [**JSFiddle playground**](https://jsfiddle.net/oc5ju3ge/). 77 | 78 | 79 | 80 | ## Cookies 81 | 82 | Manipulate cookies with the simple getter/setter interface: 83 | 84 | ```js 85 | import { cookies } from 'brownies'; 86 | 87 | cookies.token = 42; // Set it 88 | const res = cookies.token; // Get it 89 | delete cookies.token; // Eat it 90 | ``` 91 | 92 | Cookies will retain the types that is set. This is possible thanks to [the underlying library](https://github.com/franciscop/cookies): 93 | 94 | ```js 95 | cookies.id = 1; 96 | cookies.accepted = true; 97 | cookies.name = 'Francisco'; 98 | cookies.friends = [3, 5]; 99 | cookies.user = { id: 1, accepted: true, name: 'Francisco' }; 100 | console.log(typeof cookies.id); // 'number' 101 | console.log(typeof cookies.accepted); // 'boolean' 102 | console.log(typeof cookies.name); // 'string' 103 | console.log(Array.isArray(cookies.friends)); // true 104 | console.log(typeof cookies.user); // 'object' 105 | ``` 106 | 107 |
108 | Warning: Manually setting cookies with document.cookie or server-side [click for details] 109 | 110 | Values are encoded first with `JSON.stringify()` to allow for different types, and then with `encodeURIComponent()` to remain RFC 6265 compliant. See the details in [the underlying library](https://github.com/franciscop/cookies#advanced-options). If you are setting cookies manually, you'll have to follow the same process: 111 | 112 | ```js 113 | import { cookies } from 'brownies'; 114 | document.cookie = `name=${encodeURIComponent(JSON.stringify('Francisco'))}` 115 | console.log(cookies.name); // Francisco 116 | ``` 117 | 118 |
119 |
120 | 121 | To delete a item, you have to call `delete` on it as you would normally do with object properties: 122 | 123 | ```js 124 | console.log(cookies.id); // null 125 | cookies.id = 1; 126 | console.log(cookies.id); // 1 127 | delete cookies.id; 128 | console.log(cookies.id); // null 129 | ``` 130 | 131 | > Note: the default value for deleted cookies is set to `null` to be consistent with other local storage technologies. 132 | 133 | You can iterate over the cookies in many different standard ways as normal: 134 | 135 | ```js 136 | Object.keys(cookies); 137 | Object.values(cookies); 138 | Object.entries(cookies); 139 | for (let key in cookies) {} 140 | for (let val of cookies) {} 141 | ``` 142 | 143 | 144 | ### Options 145 | 146 | You can change the [cookies **options**](https://github.com/franciscop/cookies#options) globally: 147 | 148 | ```js 149 | import { cookies, options } from 'brownies'; 150 | 151 | // Options with its defaults. Note that expires is set to 100 days 152 | cookies[options] = { 153 | expires: 100 * 24 * 3600, // The time to expire in seconds 154 | domain: false, // The domain for the cookie 155 | path: '/', // The path for the cookie 156 | secure: https ? true : false // Require the use of https 157 | }; 158 | 159 | cookies.token = 24; // Will be stored for ~100 days 160 | ``` 161 | 162 | > **WARNING**: you should import `options` and then use it as a variable like `cookies[options]`. You CANNOT do ~~`cookies.options`~~ nor ~~`cookies['options']`~~. 163 | 164 | 165 | 166 | 167 | ## LocalStorage 168 | 169 | For `localStorage`, we define `local` to simplify the interface: 170 | 171 | ```js 172 | import { local } from 'brownies'; 173 | 174 | local.token = 42; // Set it 175 | const res = local.token; // Get it 176 | delete local.token; // Remove it 177 | ``` 178 | 179 | localStorage items can be set to many different standard values, and they will retain the types: 180 | 181 | ```js 182 | local.id = 1; 183 | local.accepted = true; 184 | local.name = 'Francisco'; 185 | local.friends = [3, 5]; 186 | local.user = { id: 1, accepted: true, name: 'Francisco' }; 187 | console.log(typeof local.id); // 'number' 188 | console.log(typeof local.accepted); // 'boolean' 189 | console.log(typeof local.name); // 'string' 190 | console.log(Array.isArray(local.friends)); // true 191 | console.log(typeof local.user); // 'object' 192 | ``` 193 | 194 | > Since 2.0 we are using custom data storage to keep the types consistent, but this means that you cannot read items that were set by `brownies` like ~~`localStorage.getItem(KEY)`~~. Please use the `local.KEY` provided by `brownies` API instead. 195 | 196 | To delete a item, you have to call `delete` on it as you would normally do with object properties: 197 | 198 | ```js 199 | console.log(local.id); // null 200 | local.id = 1; 201 | console.log(local.id); // 1 202 | delete local.id; 203 | console.log(local.id); // null 204 | ``` 205 | 206 | You can iterate over the items in many different standard ways as normal: 207 | 208 | ```js 209 | Object.keys(local); 210 | Object.values(local); 211 | Object.entries(local); 212 | for (let key in local) {} 213 | for (let val of local) {} 214 | ``` 215 | 216 | So if you wanted to delete them all, you can do so by looping them easily: 217 | 218 | ```js 219 | for (let key in local) { 220 | console.log('Deleting:', key, local[key]); 221 | delete local[key]; 222 | } 223 | ``` 224 | 225 | 226 | 227 | ## SessionStorage 228 | 229 | For the `sessionStorage`, we define `session` to simplify the interface: 230 | 231 | ```js 232 | import { session } from 'brownies'; 233 | 234 | session.token = 42; // Set it 235 | const res = session.token; // Get it 236 | delete session.token; // Remove it 237 | ``` 238 | 239 | sessionStorage items can be set to many different standard values, and they will retain the types: 240 | 241 | ```js 242 | session.id = 1; 243 | session.accepted = true; 244 | session.name = 'Francisco'; 245 | session.friends = [3, 5]; 246 | session.user = { id: 1, accepted: true, name: 'Francisco' }; 247 | console.log(typeof session.id); // 'number' 248 | console.log(typeof session.accepted); // 'boolean' 249 | console.log(typeof session.name); // 'string' 250 | console.log(Array.isArray(session.friends)); // true 251 | console.log(typeof session.user); // 'object' 252 | ``` 253 | 254 | > Since 2.0 we are using custom data storage to keep the types consistent, but this means that you cannot read items that were set by `brownies` like ~~`localStorage.getItem(KEY)`~~. Please use the `local.KEY` provided by `brownies` API instead. 255 | 256 | To delete a item, you have to call `delete` on it as you would normally do with object properties: 257 | 258 | ```js 259 | console.log(session.id); // null 260 | session.id = 1; 261 | console.log(session.id); // 1 262 | delete session.id; 263 | console.log(session.id); // null 264 | ``` 265 | 266 | You can iterate over the items in many different standard ways as normal: 267 | 268 | ```js 269 | Object.keys(session); 270 | Object.values(session); 271 | Object.entries(session); 272 | for (let key in session) {} 273 | for (let val of session) {} 274 | ``` 275 | 276 | So if you wanted to delete them all, you can do so by looping them easily: 277 | 278 | ```js 279 | for (let key in session) { 280 | console.log('Deleting:', key, session[key]); 281 | delete session[key]; 282 | } 283 | ``` 284 | 285 | 286 | 287 | ## Subscribe 288 | 289 | Subscribe allows you to listen to changes to *any* object, including yours: 290 | 291 | ```js 292 | import { local, subscribe } from 'brownies'; 293 | 294 | subscribe(local, 'token', value => { 295 | console.log(value); // 42, null, 'Hello' 296 | }); 297 | 298 | local.token = 42; 299 | delete local.token; 300 | local.token = 'Hello'; 301 | ``` 302 | 303 | **Warning**: `subscribe()` cannot guarantee being sync, so the above might not trigger if the end value is the same as the initial value or middle steps might not be shown. 304 | 305 | Changes work even if you use the native API to change the values, or even if the changes happen on another tab: 306 | 307 | ```js 308 | import { local, subscribe } from 'brownies'; 309 | 310 | subscribe(local, 'token', value => { 311 | console.log(value); // abc (string) 312 | }); 313 | 314 | // Note that this is the native one: 315 | localStorage.setItem('token', 'abc'); 316 | ``` 317 | 318 | To unsubscribe, store the value returned by `subscribe()` and then use it with `unsubscribe()`: 319 | 320 | ```js 321 | import { cookies, subscribe, unsubscribe } from 'brownies'; 322 | 323 | const id = subscribe(cookies, 'token', token => { 324 | console.log(token); 325 | }); 326 | 327 | unsubscribe(id); 328 | ``` 329 | 330 | You can also unsubscribe by the callback, which is very useful in a React context: 331 | 332 | ```js 333 | import { cookies, subscribe, unsubscribe } from 'brownies'; 334 | 335 | const cb = token => console.log('NEW TOKEN:', token); 336 | subscribe(cookies, 'token', cb); 337 | unsubscribe(cb); 338 | ``` 339 | 340 | For instance, if you want to keep the user points synced across tabs with localStorage: 341 | 342 | ```js 343 | import { local, subscribe, unsubscribe } from 'brownies'; 344 | 345 | export default class extends React.Component { 346 | constructor (props) { 347 | super(props); 348 | this.state = { points: local.points }; 349 | this.updatePoints = this.updatePoints.bind(this); 350 | } 351 | updatePoints (points) { 352 | this.setState({ points }); 353 | } 354 | componentDidMount () { 355 | subscribe(local, 'points', this.updatePoints); 356 | } 357 | componentWillUnmount () { 358 | unsubscribe(this.updatePoints); 359 | } 360 | render () { 361 | return
Points: {this.state.points}
; 362 | } 363 | } 364 | ``` 365 | 366 | **Warning**: try to keep the number of subscriptions low since each will incur in a performance cost. 367 | 368 | 369 | 370 | ### Trivia 371 | 372 | My former coworker made delicious brownies when leaving the company and asked me to name a library brownies. I thought it was a fantastic idea, since [brownies are tastier cookies](https://wow-cookies.com/brownies-are-they-cookies-or-cake/) after all 🙂. 373 | 374 | This library was previously named `clean-store`, but I never really liked that name. The stars in this repository [were transferred from the previous repository](https://francisco.io/blog/transferring-github-stars/). 375 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | 3 | export default { 4 | name: 'brownies', 5 | input: 'brownies.js', 6 | output: { 7 | format: 'umd' 8 | }, 9 | plugins: [ 10 | resolve(), 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /src/cookies.js: -------------------------------------------------------------------------------- 1 | import options from './options'; 2 | import engine from 'cookiesjs'; 3 | import subscriptions from './subscriptions'; 4 | 5 | // Get a single item from the cookies (except for getting the options or iterator) 6 | const get = (target, key) => { 7 | if (key === Symbol.iterator) return getIterator(); 8 | if (key === options) return engine; 9 | const value = engine(key); 10 | return (typeof value === 'undefined') ? null : value; 11 | }; 12 | 13 | // Set a specific cookie (except for setting the options) 14 | const set = (target, key, value = null) => { 15 | if (key === options) { 16 | for (let key in value) { 17 | engine[key] = value[key]; 18 | return true; 19 | } 20 | } 21 | engine({ [key]: value }); 22 | subscriptions.filter(sub => sub.key === key).forEach(({ check }) => check()); 23 | return true; 24 | }; 25 | 26 | const getAll = () => { 27 | const pairs = document.cookie.split(";"); 28 | const cookies = {}; 29 | for (var i=0; i { 44 | const all = Object.values(getAll()); 45 | return function* () { 46 | while(all.length) yield all.shift(); 47 | }; 48 | } 49 | 50 | // Allow to do `for (let key in cookies) { ... }` 51 | const getOwnPropertyDescriptor = () => ({ enumerable: true, configurable: true }); 52 | 53 | // Allow to do `Object.keys(cookies)` 54 | const ownKeys = () => Object.keys(getAll()); 55 | 56 | const traps = { get, set, deleteProperty: set, getOwnPropertyDescriptor, ownKeys }; 57 | export default new Proxy({}, traps); 58 | -------------------------------------------------------------------------------- /src/cookies.test.js: -------------------------------------------------------------------------------- 1 | import cookies from './cookies'; 2 | import options from './options'; 3 | import delay from 'delay'; 4 | 5 | 6 | describe('cookies', () => { 7 | it('is defined', () => { 8 | expect(cookies).toBeDefined(); 9 | }); 10 | 11 | it('can set, read and remove cookies', () => { 12 | expect(cookies.name).toBe(null); 13 | cookies.name = 'Francisco'; 14 | expect(cookies.name).toBe('Francisco'); 15 | delete cookies.name; 16 | expect(cookies.name).toBe(null); 17 | }); 18 | 19 | it('does work with the underlying engine', () => { 20 | expect(document.cookie).toBe(""); 21 | cookies.name = 'Francisco'; 22 | const raw = decodeURIComponent(document.cookie); 23 | expect(raw).toBe('name=' + JSON.stringify('Francisco')); 24 | delete cookies.name; 25 | expect(document.cookie).toBe(""); 26 | }); 27 | 28 | it('can list the cookies', () => { 29 | cookies.firstname = 'Francisco'; 30 | cookies.lastname = 'Presencia'; 31 | expect(Object.keys(cookies)).toEqual(['firstname', 'lastname']); 32 | expect(Object.values(cookies)).toEqual(['Francisco', 'Presencia']); 33 | expect(Object.entries(cookies)).toEqual([['firstname', 'Francisco'], ['lastname', 'Presencia']]); 34 | delete cookies.firstname; 35 | delete cookies.lastname; 36 | }); 37 | 38 | it('can iterate with "in"', () => { 39 | cookies.firstname = 'Francisco'; 40 | cookies.lastname = 'Presencia'; 41 | const keys = []; 42 | for (let key in cookies) { 43 | keys.push(key); 44 | } 45 | expect(keys).toEqual(['firstname', 'lastname']); 46 | delete cookies.firstname; 47 | delete cookies.lastname; 48 | }); 49 | 50 | it('throws for the iteration since it is not yet ready', () => { 51 | cookies.firstname = 'Francisco'; 52 | cookies.lastname = 'Presencia'; 53 | const values = []; 54 | for (let val of cookies) { 55 | values.push(val); 56 | } 57 | expect(values).toEqual(['Francisco', 'Presencia']); 58 | }); 59 | 60 | it('retains the types', () => { 61 | cookies.id = 1; 62 | cookies.accepted = true; 63 | cookies.name = 'Francisco'; 64 | cookies.friends = [3, 5]; 65 | cookies.user = { id: 1, accepted: true, name: 'Francisco' }; 66 | expect(typeof cookies.id).toEqual('number'); 67 | expect(typeof cookies.accepted).toEqual('boolean'); 68 | expect(typeof cookies.name).toEqual('string'); 69 | expect(Array.isArray(cookies.friends)).toEqual(true); 70 | expect(typeof cookies.user).toEqual('object'); 71 | for (let key in cookies) { 72 | delete cookies[key]; 73 | } 74 | }); 75 | 76 | it('can iterate with invalid items', () => { 77 | cookies.firstname = 'Francisco'; 78 | document.cookie = 'lastname=Presencia'; 79 | document.cookie = 'age=25'; 80 | const keys = []; 81 | for (let key in cookies) { 82 | keys.push(key); 83 | } 84 | const values = []; 85 | for (let key of cookies) { 86 | values.push(key); 87 | } 88 | expect(keys).toEqual(['firstname', 'lastname', 'age']); 89 | expect(values).toEqual(['Francisco', 'Presencia', 25]); 90 | expect(Object.keys(cookies)).toEqual(['firstname', 'lastname', 'age']); 91 | expect(Object.values(cookies)).toEqual(['Francisco', 'Presencia', 25]); 92 | expect(Object.entries(cookies)).toEqual([ 93 | ['firstname', 'Francisco'], 94 | ['lastname', 'Presencia'], 95 | ['age', 25] 96 | ]); 97 | delete cookies.firstname; 98 | delete cookies.lastname; 99 | delete cookies.age; 100 | }); 101 | 102 | // it('ignores failing values on the iteration', () => { 103 | // session.firstname = 'Francisco'; 104 | // sessionStorage.setItem('lastname', 'Presencia'); 105 | // expect(Object.keys(session)).toEqual(['firstname']); 106 | // expect(Object.values(session)).toEqual(['Francisco']); 107 | // expect(Object.entries(session)).toEqual([['firstname', 'Francisco']]); 108 | // delete session.firstname; 109 | // sessionStorage.removeItem('lastname'); 110 | // }); 111 | 112 | describe('options', () => { 113 | it('will expire naturally', async () => { 114 | cookies[options] = {}; 115 | expect(cookies.id).toBe(null); 116 | cookies.id = 10; 117 | await delay(100); 118 | expect(cookies.id).toBe(10); 119 | delete cookies.id; 120 | await delay(100); 121 | expect(cookies.id).toBe(null); 122 | }); 123 | 124 | it('can set the expiration', async () => { 125 | cookies[options] = { expires: 1 }; 126 | expect(cookies.id).toBe(null); 127 | cookies.id = 10; 128 | expect(cookies.id).toBe(10); 129 | await delay(1100); 130 | expect(cookies[options].expires).toBe(1); 131 | expect(cookies.id).toBe(null); 132 | }); 133 | 134 | it('can set the expiration', async () => { 135 | cookies[options].expires = 2; 136 | expect(cookies.id).toBe(null); 137 | cookies.id = 10; 138 | expect(cookies.id).toBe(10); 139 | expect(cookies[options].expires).toBe(2); 140 | await delay(1100); 141 | expect(cookies.id).toBe(10); 142 | await delay(1000); 143 | expect(cookies.id).toBe(null); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | import subscriptions from './subscriptions'; 2 | import { get, set, del, keys } from 'idb-keyval'; 3 | 4 | const getAll = async () => { 5 | const all = {}; 6 | const ks = await keys(); 7 | await Promise.all(ks.map(async key => { 8 | all[key] = await get(key); 9 | })); 10 | return all; 11 | }; 12 | 13 | const db = new Proxy(getAll, { 14 | get: async (target, key) => { 15 | // Make it consistent with the other browser storage technologies 16 | const value = await get(key); 17 | return typeof value === 'undefined' ? null : value; 18 | }, 19 | 20 | set: (target, key, value) => { 21 | set(key, value); 22 | subscriptions.filter(sub => sub.key === key).forEach(({ check }) => check()); 23 | return true; 24 | }, 25 | 26 | deleteProperty: (target, key) => { 27 | del(key); 28 | subscriptions.filter(sub => sub.key === key).forEach(({ check }) => check()); 29 | return true; 30 | }, 31 | 32 | // Allow to do `for (let key in cookies) { ... }` 33 | // getOwnPropertyDescriptor(target, prop) { 34 | // return { 35 | // enumerable: true, 36 | // configurable: true 37 | // }; 38 | // }, 39 | 40 | // Works, but cannot be solved since keys() is async 41 | // return ['a', 'b']; 42 | // Does not work, a promise will be evaluated as an empty array [] 43 | // return Promise.resolve(['a', 'b']); 44 | // ownKeys (target) { 45 | // return ...; 46 | // } 47 | }); 48 | 49 | 50 | export default db; 51 | -------------------------------------------------------------------------------- /src/db.test.js: -------------------------------------------------------------------------------- 1 | import db from './db'; 2 | global.indexedDB = global.indexedDB || require("fake-indexeddb"); 3 | 4 | describe('db', () => { 5 | it('is defined', () => { 6 | expect(db).toBeDefined(); 7 | }); 8 | 9 | it('can set, read and remove db', async () => { 10 | expect(await db.name).toBe(null); 11 | db.name = 'Francisco'; 12 | expect(await db.name).toBe('Francisco'); 13 | delete db.name; 14 | expect(await db.name).toBe(null); 15 | }); 16 | 17 | // All of those depend on "ownKeys", and "ownKeys" cannot return a Promise... 18 | it('can list the db', async () => { 19 | db.firstname = 'Francisco'; 20 | db.lastname = 'Presencia'; 21 | expect(Object.keys(await db())).toEqual(['firstname', 'lastname']); 22 | expect(Object.values(await db())).toEqual(['Francisco', 'Presencia']); 23 | expect(Object.entries(await db())).toEqual([['firstname', 'Francisco'], ['lastname', 'Presencia']]); 24 | delete db.firstname; 25 | delete db.lastname; 26 | }); 27 | 28 | // This again uses "ownKeys", so it cannot be reliably iterated 29 | it('can iterate with "in"', async () => { 30 | db.firstname = 'Francisco'; 31 | db.lastname = 'Presencia'; 32 | const keys = []; 33 | for (let key in await db()) { 34 | keys.push(key); 35 | } 36 | expect(keys).toEqual(['firstname', 'lastname']); 37 | delete db.firstname; 38 | delete db.lastname; 39 | }); 40 | 41 | it('retains the types', async () => { 42 | db.id = 1; 43 | db.accepted = true; 44 | db.name = 'Francisco'; 45 | db.friends = [3, 5]; 46 | db.user = { id: 1, accepted: true, name: 'Francisco' }; 47 | expect(typeof await db.id).toEqual('number'); 48 | expect(typeof await db.accepted).toEqual('boolean'); 49 | expect(typeof await db.name).toEqual('string'); 50 | expect(Array.isArray(await db.friends)).toEqual(true); 51 | expect(typeof await db.user).toEqual('object'); 52 | for (let key in await db()) { 53 | delete db[key]; 54 | } 55 | }); 56 | 57 | it('can iterate in many ways', async () => { 58 | db.firstname = 'Francisco'; 59 | db.lastname = 'Presencia'; 60 | db.age = '25'; 61 | const keys = []; 62 | const values = []; 63 | for (let key in await db()) { 64 | keys.push(key); 65 | values.push(await db[key]); 66 | } 67 | expect(keys).toEqual(['age', 'firstname', 'lastname']); 68 | expect(values).toEqual(['25', 'Francisco', 'Presencia']); 69 | expect(Object.keys(await db())).toEqual(['age', 'firstname', 'lastname']); 70 | expect(Object.values(await db())).toEqual(['25', 'Francisco', 'Presencia']); 71 | expect(Object.entries(await db())).toEqual([ 72 | ['age', '25'], 73 | ['firstname', 'Francisco'], 74 | ['lastname', 'Presencia'] 75 | ]); 76 | delete db.firstname; 77 | delete db.lastname; 78 | delete db.age; 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/local.js: -------------------------------------------------------------------------------- 1 | import subscriptions from './subscriptions'; 2 | import { pack, unpack } from './packer'; 3 | 4 | const getAll = () => { 5 | const all = {}; 6 | for (var key in localStorage){ 7 | if (local[key] !== null) { 8 | all[key] = local[key]; 9 | } 10 | } 11 | return all; 12 | }; 13 | 14 | const local = new Proxy({}, { 15 | get: (target, key) => { 16 | // For the `for (let key of value)` iteration 17 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of 18 | if (key === Symbol.iterator) { 19 | const all = Object.values(getAll()); 20 | return function* () { 21 | while(all.length) yield all.shift(); 22 | }; 23 | } 24 | return unpack(localStorage.getItem(key)); 25 | }, 26 | 27 | set: (target, key, value) => { 28 | localStorage.setItem(key, pack(value)); 29 | subscriptions.filter(sub => sub.key === key).forEach(({ check }) => check()); 30 | return true; 31 | }, 32 | 33 | deleteProperty: (target, key) => { 34 | localStorage.removeItem(key); 35 | subscriptions.filter(sub => sub.key === key).forEach(({ check }) => check()); 36 | return true; 37 | }, 38 | 39 | // Allow to do `for (let key in cookies) { ... }` 40 | getOwnPropertyDescriptor(k) { 41 | return { 42 | enumerable: true, 43 | configurable: true, 44 | }; 45 | }, 46 | 47 | ownKeys (target) { 48 | return Object.keys(getAll()); 49 | } 50 | }); 51 | 52 | export default local; 53 | -------------------------------------------------------------------------------- /src/local.test.js: -------------------------------------------------------------------------------- 1 | import local from './local'; 2 | import { pack } from './packer'; 3 | 4 | describe('local', () => { 5 | it('is defined', () => { 6 | expect(local).toBeDefined(); 7 | }); 8 | 9 | it('can set, read and remove local', () => { 10 | expect(local.name).toBe(null); 11 | local.name = 'Francisco'; 12 | expect(local.name).toBe('Francisco'); 13 | delete local.name; 14 | expect(local.name).toBe(null); 15 | }); 16 | 17 | it('does work with the underlying engine', () => { 18 | expect(localStorage.getItem('name')).toBe(null); 19 | local.name = 'Francisco'; 20 | expect(localStorage.getItem('name')).toBe(pack('Francisco')); 21 | delete local.name; 22 | expect(localStorage.getItem('name')).toBe(null); 23 | }); 24 | 25 | it('can list the local', () => { 26 | local.firstname = 'Francisco'; 27 | local.lastname = 'Presencia'; 28 | expect(Object.keys(local)).toEqual(['firstname', 'lastname']); 29 | expect(Object.values(local)).toEqual(['Francisco', 'Presencia']); 30 | expect(Object.entries(local)).toEqual([['firstname', 'Francisco'], ['lastname', 'Presencia']]); 31 | delete local.firstname; 32 | delete local.lastname; 33 | }); 34 | 35 | it('can iterate with "in"', () => { 36 | local.firstname = 'Francisco'; 37 | local.lastname = 'Presencia'; 38 | const keys = []; 39 | for (let key in local) { 40 | keys.push(key); 41 | } 42 | expect(keys).toEqual(['firstname', 'lastname']); 43 | delete local.firstname; 44 | delete local.lastname; 45 | }); 46 | 47 | it('throws for the iteration since it is not yet ready', () => { 48 | local.firstname = 'Francisco'; 49 | local.lastname = 'Presencia'; 50 | const values = []; 51 | for (let val of local) { 52 | values.push(val); 53 | } 54 | expect(values).toEqual(['Francisco', 'Presencia']); 55 | }); 56 | 57 | it('retains the types', () => { 58 | local.id = 1; 59 | local.accepted = true; 60 | local.name = 'Francisco'; 61 | local.friends = [3, 5]; 62 | local.user = { id: 1, accepted: true, name: 'Francisco' }; 63 | expect(typeof local.id).toEqual('number'); 64 | expect(typeof local.accepted).toEqual('boolean'); 65 | expect(typeof local.name).toEqual('string'); 66 | expect(Array.isArray(local.friends)).toEqual(true); 67 | expect(typeof local.user).toEqual('object'); 68 | for (let key in local) { 69 | delete local[key]; 70 | } 71 | }); 72 | 73 | it('can iterate with invalid items', () => { 74 | local.firstname = 'Francisco'; 75 | localStorage.setItem('lastname', 'Presencia'); 76 | localStorage.setItem('age', '25'); 77 | const keys = []; 78 | for (let key in local) { 79 | keys.push(key); 80 | } 81 | const values = []; 82 | for (let key of local) { 83 | values.push(key); 84 | } 85 | expect(keys).toEqual(['firstname', 'lastname', 'age']); 86 | expect(values).toEqual(['Francisco', 'Presencia', '25']); 87 | expect(Object.keys(local)).toEqual(['firstname', 'lastname', 'age']); 88 | expect(Object.values(local)).toEqual(['Francisco', 'Presencia', '25']); 89 | expect(Object.entries(local)).toEqual([ 90 | ['firstname', 'Francisco'], 91 | ['lastname', 'Presencia'], 92 | ['age', '25'] 93 | ]); 94 | delete local.firstname; 95 | delete local.lastname; 96 | delete local.age; 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | // Thanks Keith Cirkel! https://www.keithcirkel.co.uk/metaprogramming-in-es6-symbols/ 2 | export default Symbol('options'); 3 | -------------------------------------------------------------------------------- /src/packer.js: -------------------------------------------------------------------------------- 1 | import stringify from '../lib/stringify'; 2 | 3 | const js = 'JSSTR:'; 4 | 5 | export const pack = str => { 6 | // The storage techs can only store Strings, so convert anything else 7 | if (typeof str !== 'string') { 8 | str = js + stringify(str); 9 | } 10 | return str; 11 | }; 12 | 13 | export const unpack = str => { 14 | if (str && typeof str === 'string' && str.slice(0, js.length) === js) { 15 | return JSON.parse(str.slice(js.length)); 16 | } 17 | return str; 18 | }; 19 | -------------------------------------------------------------------------------- /src/packer.test.js: -------------------------------------------------------------------------------- 1 | import { pack, unpack } from './packer'; 2 | import smJson from '../data/small'; 3 | import mdJson from '../data/mid'; 4 | 5 | describe('lz', () => { 6 | it('can decompress itself', () => { 7 | expect(unpack(pack('Hello world'))).toBe('Hello world'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/session.js: -------------------------------------------------------------------------------- 1 | import subscriptions from './subscriptions'; 2 | import { pack, unpack } from './packer'; 3 | 4 | const getAll = () => { 5 | const all = {}; 6 | for (var key in sessionStorage){ 7 | if (local[key] !== null) { 8 | all[key] = local[key]; 9 | } 10 | } 11 | return all; 12 | }; 13 | 14 | const local = new Proxy({}, { 15 | get: (target, key) => { 16 | // For the `for (let key of value)` iteration 17 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of 18 | if (key === Symbol.iterator) { 19 | const all = Object.values(getAll()); 20 | return function* () { 21 | while(all.length) yield all.shift(); 22 | }; 23 | } 24 | return unpack(sessionStorage.getItem(key)); 25 | }, 26 | 27 | set: (target, key, value) => { 28 | sessionStorage.setItem(key, pack(value)); 29 | subscriptions.filter(sub => sub.key === key).forEach(({ check }) => check()); 30 | return true; 31 | }, 32 | 33 | deleteProperty: (target, key) => { 34 | sessionStorage.removeItem(key); 35 | subscriptions.filter(sub => sub.key === key).forEach(({ check }) => check()); 36 | return true; 37 | }, 38 | 39 | // Allow to do `for (let key in cookies) { ... }` 40 | getOwnPropertyDescriptor(k) { 41 | return { 42 | enumerable: true, 43 | configurable: true, 44 | }; 45 | }, 46 | 47 | ownKeys (target) { 48 | return Object.keys(getAll()); 49 | } 50 | }); 51 | 52 | export default local; 53 | -------------------------------------------------------------------------------- /src/session.test.js: -------------------------------------------------------------------------------- 1 | import session from './session'; 2 | import { pack } from './packer'; 3 | 4 | describe('session', () => { 5 | it('is defined', () => { 6 | expect(session).toBeDefined(); 7 | }); 8 | 9 | it('can set, read and remove session', () => { 10 | expect(session.name).toBe(null); 11 | session.name = 'Francisco'; 12 | expect(session.name).toBe('Francisco'); 13 | delete session.name; 14 | expect(session.name).toBe(null); 15 | }); 16 | 17 | it('does work with the underlying engine', () => { 18 | expect(sessionStorage.getItem('name')).toBe(null); 19 | session.name = 'Francisco'; 20 | expect(sessionStorage.getItem('name')).toBe(pack('Francisco')); 21 | delete session.name; 22 | expect(sessionStorage.getItem('name')).toBe(null); 23 | }); 24 | 25 | it('can list the session', () => { 26 | session.firstname = 'Francisco'; 27 | session.lastname = 'Presencia'; 28 | expect(Object.keys(session)).toEqual(['firstname', 'lastname']); 29 | expect(Object.values(session)).toEqual(['Francisco', 'Presencia']); 30 | expect(Object.entries(session)).toEqual([['firstname', 'Francisco'], ['lastname', 'Presencia']]); 31 | delete session.firstname; 32 | delete session.lastname; 33 | }); 34 | 35 | it('can iterate with "in"', () => { 36 | session.firstname = 'Francisco'; 37 | session.lastname = 'Presencia'; 38 | const keys = []; 39 | for (let key in session) { 40 | keys.push(key); 41 | } 42 | expect(keys).toEqual(['firstname', 'lastname']); 43 | delete session.firstname; 44 | delete session.lastname; 45 | }); 46 | 47 | it('throws for the iteration since it is not yet ready', () => { 48 | session.firstname = 'Francisco'; 49 | session.lastname = 'Presencia'; 50 | const values = []; 51 | for (let val of session) { 52 | values.push(val); 53 | } 54 | expect(values).toEqual(['Francisco', 'Presencia']); 55 | }); 56 | 57 | it('retains the types', () => { 58 | session.id = 1; 59 | session.accepted = true; 60 | session.name = 'Francisco'; 61 | session.friends = [3, 5]; 62 | session.user = { id: 1, accepted: true, name: 'Francisco' }; 63 | expect(typeof session.id).toEqual('number'); 64 | expect(typeof session.accepted).toEqual('boolean'); 65 | expect(typeof session.name).toEqual('string'); 66 | expect(Array.isArray(session.friends)).toEqual(true); 67 | expect(typeof session.user).toEqual('object'); 68 | for (let key in session) { 69 | delete session[key]; 70 | } 71 | }); 72 | 73 | it('can iterate with invalid items', () => { 74 | session.firstname = 'Francisco'; 75 | sessionStorage.setItem('lastname', 'Presencia'); 76 | sessionStorage.setItem('age', '25'); 77 | const keys = []; 78 | for (let key in session) { 79 | keys.push(key); 80 | } 81 | const values = []; 82 | for (let key of session) { 83 | values.push(key); 84 | } 85 | expect(keys).toEqual(['firstname', 'lastname', 'age']); 86 | expect(values).toEqual(['Francisco', 'Presencia', '25']); 87 | expect(Object.keys(session)).toEqual(['firstname', 'lastname', 'age']); 88 | expect(Object.values(session)).toEqual(['Francisco', 'Presencia', '25']); 89 | expect(Object.entries(session)).toEqual([ 90 | ['firstname', 'Francisco'], 91 | ['lastname', 'Presencia'], 92 | ['age', '25'] 93 | ]); 94 | delete session.firstname; 95 | delete session.lastname; 96 | delete session.age; 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/subscribe.js: -------------------------------------------------------------------------------- 1 | import subscriptions from './subscriptions'; 2 | import stringify from '../lib/stringify'; 3 | 4 | const isBasic = value => !value || ['boolean', 'number', 'string'].includes(typeof value); 5 | 6 | const clone = value => { 7 | if (isBasic(value)) return value; 8 | return JSON.parse(stringify(value)); 9 | }; 10 | 11 | export default (obj, key, cb) => { 12 | let prev = obj[key] && obj[key].then ? obj[key].then(clone) : clone(obj[key]); 13 | const check = () => { 14 | const value = obj[key]; 15 | if ((prev && prev.then) || (value && value.then)) { 16 | return Promise.all([prev, value]).then(([previous, value]) => { 17 | if (stringify(previous) === stringify(value)) return; 18 | cb(value, previous); 19 | prev = clone(value); 20 | }); 21 | } 22 | if (stringify(prev) === stringify(value)) return; 23 | cb(value, prev); 24 | prev = clone(value); 25 | }; 26 | const id = setInterval(check, 100); 27 | subscriptions.push({ id, key, check, cb }); 28 | return id; 29 | }; 30 | -------------------------------------------------------------------------------- /src/subscribe.test.js: -------------------------------------------------------------------------------- 1 | import subscribe from './subscribe'; 2 | import sinon from 'sinon'; 3 | import delay from 'delay'; 4 | 5 | describe('subscribe', () => { 6 | it('is defined', () => { 7 | expect(subscribe).toBeDefined(); 8 | }); 9 | 10 | it('can listen to any object', async () => { 11 | const obj = {}; 12 | const cb = sinon.spy(); 13 | subscribe(obj, 'id', cb); 14 | obj.id = 10; 15 | await delay(200); 16 | expect(cb.calledOnce).toBe(true); 17 | }); 18 | 19 | it('can listen on deletion as well', async () => { 20 | const obj = { id: 10 }; 21 | const cb = sinon.spy(); 22 | subscribe(obj, 'id', cb); 23 | delete obj.id; 24 | await delay(200); 25 | expect(cb.calledOnce).toBe(true); 26 | }); 27 | 28 | it('does well with undefined/null', async () => { 29 | const obj = {}; 30 | const cb = sinon.spy(); 31 | subscribe(obj, 'id', cb); 32 | obj.id = null; 33 | await delay(200); 34 | expect(cb.calledOnce).toBe(true); 35 | }); 36 | 37 | it('does well with string/number', async () => { 38 | const obj = { id: 10 }; 39 | const cb = sinon.spy(); 40 | subscribe(obj, 'id', cb); 41 | obj.id = '10'; 42 | await delay(200); 43 | expect(cb.calledOnce).toBe(true); 44 | }); 45 | 46 | it('does well with objects', async () => { 47 | const obj = { user: { id: 10 } }; 48 | const cb = sinon.spy(); 49 | subscribe(obj, 'user', cb); 50 | obj.user.id = 20; 51 | await delay(200); 52 | expect(cb.calledOnce).toBe(true); 53 | }); 54 | 55 | it('does well with promises without changes', async () => { 56 | const obj = { user: Promise.resolve({ id: 10 }) }; 57 | const cb = sinon.spy(); 58 | subscribe(obj, 'user', cb); 59 | obj.user = { id: 10 }; 60 | await delay(200); 61 | expect(cb.calledOnce).toBe(false); 62 | }); 63 | 64 | it('does well with promises', async () => { 65 | const obj = { user: Promise.resolve({ id: 10 }) }; 66 | const cb = sinon.spy(); 67 | subscribe(obj, 'user', cb); 68 | obj.user = { id: 20 }; 69 | await delay(200); 70 | expect(cb.calledOnce).toBe(true); 71 | }); 72 | 73 | it('will not trigger with the same object', async () => { 74 | const obj = { user: { id: 10, name: 'Francisco' } }; 75 | const cb = sinon.spy(); 76 | subscribe(obj, 'user', cb); 77 | obj.user.id = 10; 78 | await delay(200); 79 | expect(cb.calledOnce).toBe(false); 80 | }); 81 | 82 | // Note: this fails with JSON.stringify, but works with the deterministic one 83 | it('will not trigger with the same object but flipped', async () => { 84 | const obj = { user: { id: 10, name: 'Francisco' } }; 85 | const cb = sinon.spy(); 86 | subscribe(obj, 'user', cb); 87 | obj.user = { name: 'Francisco', id: 10 }; 88 | await delay(200); 89 | expect(cb.calledOnce).toBe(false); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/subscriptions.js: -------------------------------------------------------------------------------- 1 | export default []; 2 | -------------------------------------------------------------------------------- /src/unsubscribe.js: -------------------------------------------------------------------------------- 1 | import subscriptions from './subscriptions'; 2 | 3 | export default id => { 4 | if (typeof id === 'number') { 5 | return clearInterval(id); 6 | } 7 | 8 | return subscriptions 9 | .filter(({ cb }) => cb === id) 10 | .map(sub => clearInterval(sub.id)); 11 | }; 12 | -------------------------------------------------------------------------------- /src/unsubscribe.test.js: -------------------------------------------------------------------------------- 1 | import unsubscribe from './unsubscribe'; 2 | import subscribe from './subscribe'; 3 | import sinon from 'sinon'; 4 | import delay from 'delay'; 5 | 6 | describe('subscribe', () => { 7 | it('is defined', () => { 8 | expect(subscribe).toBeDefined(); 9 | }); 10 | 11 | it('can unsubscribe with the id', async () => { 12 | const obj = {}; 13 | const cb = sinon.spy(); 14 | const id = subscribe(obj, 'id', cb); 15 | obj.id = 10; 16 | await delay(200); 17 | unsubscribe(id); 18 | obj.id = 20; 19 | await delay(200); 20 | expect(cb.calledOnce).toBe(true); 21 | }); 22 | 23 | it('can unsubscribe with the callback', async () => { 24 | const obj = {}; 25 | const cb = sinon.spy(); 26 | subscribe(obj, 'id', cb); 27 | obj.id = 10; 28 | await delay(200); 29 | unsubscribe(cb); 30 | obj.id = 20; 31 | await delay(200); 32 | expect(cb.calledOnce).toBe(true); 33 | }); 34 | 35 | it('can unsubscribe with a callback', async () => { 36 | const obj = {}; 37 | const cb = sinon.spy(); 38 | subscribe(obj, 'id', cb); 39 | obj.id = 10; 40 | await delay(200); 41 | unsubscribe(cb); 42 | obj.id = 20; 43 | await delay(200); 44 | expect(cb.calledOnce).toBe(true); 45 | }); 46 | 47 | it('can unsubscribe multiple callbacks', async () => { 48 | const obj = {}; 49 | const cb = sinon.spy(); 50 | subscribe(obj, 'id', cb); 51 | subscribe(obj, 'key', cb); 52 | subscribe(obj, 'bla', cb); 53 | obj.id = 10; 54 | await delay(200); 55 | unsubscribe(cb); 56 | obj.id = 20; 57 | obj.key = 20; 58 | obj.bla = 20; 59 | await delay(200); 60 | expect(cb.calledOnce).toBe(true); 61 | }); 62 | }); 63 | --------------------------------------------------------------------------------