The Code
I've got things operating as desired so far and am hoping fresh eyes can point out any mistakes or bits that can be improved, a live demo is hosted and updated on GitHub Pages, Travis-CI build logs of Jest tests are available for review, and the source code is just a fork
or clone
away....
browser-storage.js
/**
* @author S0AndS0
* @copyright AGPL-3.0
* @example <caption>Quick Usage for Browser Storage</caption>
* // Initialize new class instance
* const storage = new Browser_Storage();
* if (!storage.storage_available) {
* console.error('No browser storage available!');
* } else {
* if (!storage.supports_local_storage) {
* console.warn('Falling back to cookies!');
* }
* // Do stuff with local storage of browser!
* storage.setItem('test__string', 'Spam!', 7);
* console.log("storage.getItem('test__string') -> " + storage.getItem('test__string'));
* }
*/
class Browser_Storage {
/**
* Sets properties used by other methods of this class
* @returns {none}
* @property {boolean} supports_local_storage - What `this.constructor.supportsLocalStorage()` had to say
* @property {boolean} supports_cookies - What `this.supportsCookies()` had to say
* @property {boolean} storage_available - If either of the above is `true`
* @this Browser_Storage
* @class
*/
constructor() {
this.supports_local_storage = this.constructor.supportsLocalStorage();
this.supports_cookies = this.supportsCookies();
this.storage_available = (this.supports_cookies || this.supports_local_storage) ? true : false;
}
/**
* Copy of `this.constructor` that should not throw `TypeError` when called
* @returns {none}
* @this Browser_Storage
*/
constructorRefresh() {
this.supports_local_storage = this.constructor.supportsLocalStorage();
this.supports_cookies = this.supportsCookies();
this.storage_available = (this.supports_cookies || this.supports_local_storage) ? true : false;
}
/**
* Tests and reports `boolean` if `localStorage` has `setItem` and `removeItem` methods
* @returns {boolean}
*/
static supportsLocalStorage() {
// Because Opera and may be other browsers `setItem`
// is available but with space set to _`0`_
try {
localStorage.setItem('test_key', true);
} catch (e) {
if (!(e instanceof ReferenceError)) throw e;
return false;
} finally {
localStorage.removeItem('test_key');
}
return true;
}
/**
* Reports if cookies are enabled. Note, use `this.supports_cookies` instead within tests.
* @returns {boolean}
* @this Browser_Storage
*/
supportsCookies() {
// Browser support detection must be interactive as some
// may be _full_ or not enabled without updating state!
if (this._setCookieItem('testcookie', '', 7)) {
return this._setCookieItem('testcookie', '', -7);
}
return false;
}
/**
* Use `this.setItem` instead. Attempts to set cookie
* @returns {boolean}
* @param {string|number} key - _variable name_ to store value under
* @param {*} value - stored either under localStorage or as a cookie
* @param {number} [days_to_live=false] - how long a browser is suggested to keep cookies
*/
_setCookieItem(key, value, days_to_live) {
const encoded_key = encodeURIComponent(key);
const encoded_value = encodeURIComponent(JSON.stringify(value));
let expires = '';
if (isNaN(days_to_live) == false) {
const date = new Date();
const now = date.getTime();
if (days_to_live == 0) {
date.setTime(now);
} else {
date.setTime(now + (days_to_live * 24 * 60 * 60 * 1000));
}
expires = '; expires=' + date.toGMTString();
}
try {
document.cookie = encoded_key + '=' + encoded_value + expires + '; path=/';
} catch (e) {
if (!(e instanceof ReferenceError)) throw e;
return false
} finally {
return true;
}
}
/**
* Use `this.getItem` instead. Attempts to get cookie by _key_ via `match`
* @returns {*}
* @param {string|number} key - Name of key to look up value for.
*/
_getCookieItem(key) {
const encoded_key = encodeURIComponent(key);
const cookie_data = document.cookie.match('(^|;) ?' + encoded_key + '=([^;]*)(;|$)');
if (cookie_data === null || cookie_data[2] === 'undefined') return undefined;
return JSON.parse(decodeURIComponent(cookie_data[2]));
}
/**
* Gets decoded/JSON value for given key
* @returns {*}
* @throws {ReferenceError} When no browser based storage is available
* @param {string|number} key - Name of key to look up value for.
* @this Browser_Storage
*/
getItem(key) {
if (this.supports_local_storage) {
const encoded_key = encodeURIComponent(key);
const raw_value = localStorage.getItem(encoded_key);
if (raw_value === null || raw_value === 'undefined') return undefined;
return JSON.parse(decodeURIComponent(raw_value));
} else if (this.supports_cookies) {
return this._getCookieItem(key);
}
throw new ReferenceError('Browser storage unavailable as of last constructorRefresh()');
}
/**
* Removes value by key from browser storage; cookies require page refresh
* @returns {boolean}
* @throws {ReferenceError} When no browser based storage is available
* @this Browser_Storage
*/
removeItem(key) {
if (this.supports_local_storage) {
localStorage.removeItem(key);
return true;
} else if (this.supports_cookies) {
// Note, unsetting and expiring in the past is how
// to remove one cookie upon the next page load.
return this.setItem(key, '', -7);
}
throw new ReferenceError('Browser storage unavailable as of last constructorRefresh()');
}
/**
* Stores encoded JSON within browser
* @returns {boolean}
* @throws {ReferenceError} When no browser based storage is available
* @param {string|number} key - _variable name_ to store value under
* @param {*} value - stored either under localStorage or as a cookie
* @param {number} [days_to_live=false] - how long a browser is suggested to keep cookies
* @this Browser_Storage
*/
setItem(key, value, days_to_live = false) {
if (this.supports_local_storage) {
localStorage.setItem(
encodeURIComponent(key),
encodeURIComponent(JSON.stringify(value))
);
return true;
} else if (this.supports_cookies) {
return this._setCookieItem(key, value, days_to_live);
}
throw new ReferenceError('Browser storage unavailable as of last constructorRefresh()');
}
/**
* Lists keys that may point to values
* @returns {boolean}
* @throws {ReferenceError} When no browser based storage is available
* @this Browser_Storage
*/
keys() {
if (this.supports_local_storage) {
return Object.keys(localStorage);
} else if (this.supports_cookies) {
let cookie_keys = [];
document.cookie.split(';').forEach((pare) => {
cookie_keys.push(pare.split('=')[0].trim());
});
return cookie_keys;
}
throw new ReferenceError('Browser storage unavailable as of last constructorRefresh()');
}
/**
* Clears **all** stored values from either localStorage or cookies
* @returns {boolean}
* @throws {ReferenceError} When no browser based storage is available
* @this Browser_Storage
*/
clear() {
if (this.supports_local_storage) {
localStorage.clear();
return true;
} else if (this.supports_cookies) {
document.cookie.split(';').forEach((cookie) => {
const decoded_key = decodeURIComponent(cookie.split('=')[0].trim());
this.removeItem(decoded_key);
});
return true;
}
throw new ReferenceError('Browser storage unavailable as of last constructorRefresh()');
}
/**
* Generates `{data.key: data.value}` JSON from localStorage or cookies
* @yields {stored_data}
* @this Browser_Storage
*/
*iterator() {
if (this.supports_local_storage) {
const keys = Object.keys(localStorage);
for (let i = 0; i < keys.length; i++) {
const encoded_key = keys[i];
const decoded_key = decodeURIComponent(encoded_key);
const raw_value = localStorage.getItem(encoded_key);
// Note, `JSON.pars()` has a _finicky appetite_
if (raw_value === null || raw_value === undefined || raw_value === 'undefined') {
yield {key: decoded_key, value: undefined};
} else {
yield {key: decoded_key, value: JSON.parse(decodeURIComponent(raw_value))};
}
}
} else if (this.supports_cookies) {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const decoded_key = decodeURIComponent(cookies[i].split('=')[0].trim());
yield {key: decoded_key, value: this._getCookieItem(decoded_key)};
}
}
else {
throw new ReferenceError('Browser storage unavailable as of last constructorRefresh()');
}
}
/**
* See `this.iterator()` method
* @returns {stored_data}
* @this Browser_Storage
*/
[Symbol.iterator]() {
return this.iterator();
}
}
/**
* Exports are for Jest who uses Node for Travis-CI tests on gh-pages branch
* https://javascript-utilities.github.io/browser-storage/
*/
if (typeof(module) !== 'undefined') module.exports = Browser_Storage;
/**
* @typedef stored_data
* @type {Object}
* @property {string|number|float} key - `data.key` from `localStorage` or cookies
* @property {*} value - `data.value` from `localStorage` or cookies
*/
main.css
/**
* 1. Center element
* 2. Re-justify text
*/
body {
text-align: center; /* 1 */
}
.container__main_content {
max-width: 80%;
max-width: 80vw;
display: inline-block; /* 1 */
text-align: left; /* 2 */
}
/**
* Privacy notice footer stuff
*/
.container__privacy_notice {
position: fixed;
bottom: 5px;
max-width: 80%;
max-width: 80vw;
background-color: lightgrey;
text-align: left; /* 2 */
}
/**
* 1. Prevent label text from being selectable
*/
.privacy_notice__label {
background-color: black;
color: white;
font-size: 1.5rem;
padding: 5px 10px;
float: right;
-webkit-user-select: none; /* 1 */
-moz-user-select: none; /* 1 */
-ms-user-select: none; /* 1 */
user-select: none; /* 1 */
}
.privacy_notice__label:hover {
color: lightgrey;
}
/* State of label when checkbox is unchecked */
.privacy_notice__label::after {
content: attr(value);
}
/**
* Styling test input/output elements
*/
.container__live_tests {
list-style: none;
text-align: left;
max-width: 80%;
max-width: 80vw;
margin-left: auto;
margin-right: auto;
}
.live_tests__output__get__value:empty::after {
content: attr(data-after);
display: block;
}
.mouse__no_touch {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.mouse__default {
cursor: default;
}
/**
* Checkbox hacks
*/
.hidden__input {
display: none;
visibility: hidden;
opacity: 0;
filter: alpha(opacity=0);
}
/* State updates for when checkbox is checked */
.privacy_notice__checkbox:checked ~ .container__privacy_notice {
box-shadow: unset;
max-width: 1.5rem;
max-height: 1.5rem;
}
.privacy_notice__checkbox:checked ~ .container__privacy_notice * {
visibility: hidden;
opacity: 0;
filter: alpha(opacity=0);
}
.privacy_notice__checkbox:checked ~ .container__privacy_notice .privacy_notice__label::after {
content: attr(checked);
}
/* State of label when checkbox is checked */
.privacy_notice__checkbox:checked ~ .container__privacy_notice::after {
content: '\1F56D';
position: absolute;
bottom: 5px;
left: 5px;
}
/* Combo of checked and hover state updates */
.privacy_notice__checkbox:checked ~ .container__privacy_notice:hover {
max-width: 80%;
max-width: 80vw;
max-height: unset;
}
.privacy_notice__checkbox:checked ~ .container__privacy_notice:hover::after {
display: none;
visibility: hidden;
opacity: 0;
filter: alpha(opacity=0);
}
.privacy_notice__checkbox:checked ~ .container__privacy_notice:hover * {
visibility: visible;
opacity: 1;
filter: alpha(opacity=100);
}
index.html
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<title>Browser_Storage Example</title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link type="text/css" href="assets/css/main.css" rel="stylesheet"/>
<script src="assets/javascript/modules/browser-storage/browser-storage.js" type="text/javascript"></script>
<script src="assets/javascript/main.js" type="text/javascript"></script>
</head>
<body onload="restore_settings();" onunload="store_settings();">
<div class="container__main_content">
<p>
This is a static site that provides the illusion of remembering a client.
Test by accepting the <i>privacy notice</i> and then refreshing or
closing and re-opening a tab pointing to the current URL.
</p>
<p>
<kbd>Ctrl</kbd> <kbd>R</kbd> or <kbd>Ctrl</kbd> <kbd>Shift</kbd> <kbd>R</kbd>
will refresh most desktop browsers.
</p>
<p>
Then reject the privacy notice by clicking on the Acceptable button
again to revert the behavior of this site.
</p>
<p>
Use <kbd>Ctrl</kbd> <kbd>Shift</kbd> <kbd>I</kbd> to open debugger on a
desktop browser, then navigate to the Storage tab/pane to observe
JavaScript doing things for the above mentioned actions.
</p>
</div>
<ul class="container__live_tests">
<h2>Test some features</h2>
<li>
<h3>Set a value under a key</h3>
<form class="">
<textarea name="set_key"
id="input_text__set__key"
class="live_tests__input_text__set__key"
rows="1"
>Input a Key</textarea>
<br>
<textarea name="set_value"
id="input_text__set__value"
class="live_tests__input_text__set__value"
rows="1"
>Input a Value</textarea>
<br>
<input type="button"
onclick="input_text_setItem('input_text__set__key', 'input_text__set__value');"
name="set"
value="Set">
</form>
</li>
<li>
<h3>Get some value from a key</h3>
<input class="live_tests__input__get__key"
id="input_text__get__key"
type="text"
name="key"
value="input a Key">
<input type="button"
onclick="input_text_get('input_text__get__key', 'output__value', false);"
name="get"
value="Get">
<p class="live_tests__output__get__value mouse__no_touch mouse__default"
id="output__value"
data-after="undefined"></p>
</li>
</ul>
<input type="checkbox"
name="checkbox__privacy_notice"
class="hidden__input privacy_notice__checkbox"
id="trigger__privacy_notice"
onchange="toggle_privacy_notice(this);">
<div class="container__privacy_notice">
<p>
This site uses JavaScript, and localStorage or cookies of your browser,
to enhance your experience here, however, this site currently does
<strong>not</strong> store any data about your activity severer-side.
</p>
<p>
Review the <a href="https://github.com/javascript-utilities/browser-storage#privacy">Privacy</a>
and <a href="https://github.com/javascript-utilities/browser-storage#security">Security</a>
notices regarding the
<a href="https://github.com/javascript-utilities/browser-storage/blob/master/browser-storage.js">
browser-storage.js
</a>, for more details on how client-side storage and scripting are utilized.
</p>
<label for="trigger__privacy_notice"
class="menu__trigger__label privacy_notice__label"
title="Click to toggle."
value="false"
checked="true"
>Acceptable: </label>
</div>
</body>
</html>
Note, I did try to use the web code option when writing this question but that caused various errors... probably for good reasons based on the kinds of errors, so see the live demo linked above in order to check it out in console without cloning.
browser-storage.test.js
Jest tests preformed on above questionable JavaScript code. Note, none of the string questions within tests are directed at readers; it's more of questions that the CI server asks of code revisions.
/**
* @author S0AndS0
* @copyright AGPL-3.0
* @example <caption>Jest Tests for Browser Storage</caption>
* // Initialize new class instance and run tests
* const test_storage = new Browser_Storage_Test();
* test_storage.runTests();
*/
class Browser_Storage_Test {
/**
* Sets properties used by other methods of this class
* @returns {none}
* @property {object} storage - Class instance of `Browser_Storage` to test
* @this Browser_Storage_Test
* @class
*/
constructor() {
this.storage = new (require('../assets/javascript/modules/browser-storage/browser-storage.js'))();
}
/**
* Runs the tests in an order that made since to someone at the time
* @returns {none}
* @this Browser_Storage_Test
*/
runTests() {
this.setAndGet();
this.setAndGet({supports_local_storage: false});
this.iterability();
this.iterability({supports_local_storage: true});
this.errorThrowers();
this.resetsAndWipes();
}
/**
* Sets properties for `this.storage` from key value pares
* @returns {none}
* @param {forced_states} states - JSON/_dictionary_ of states to force upon storage instance
* @this Browser_Storage_Test
*/
forceStorageState(states) {
Object.keys(states).forEach((key) => {
test('Forcing ' + this.storage.constructor.name + '[' + key + '] to be ' + states[key], () => {
this.storage[key] = states[key];
expect(this.storage[key]).toBe(states[key]);
});
});
}
/**
* Attempts to break `setItem()` and `getItem()` methods for `Browser_Storage` instance
* @returns {none}
* @param {forced_states} states - If object, passes to `this.forceStorageState`
* @this Browser_Storage_Test
*/
setAndGet(states = false) {
if (typeof(states) === 'object') this.forceStorageState(states);
const test_char = String.fromCharCode('9749');
const data_map = {
true: true,
false: false,
char: test_char,
list: [1, 'two', 3.5, ["inner", "and 'nested' strings", {obj_key: 'obj_value'}]],
inner_json: {'one more thing?': {or: 'perhaps', two: 'more?'}},
1: 'one',
2.0: 'float',
'&^': '?!',
'~': "*.+",
empty_sting: '',
bad_idea: null,
bad_idea2: 'null',
bad_idea3: 'undefined',
bad_idea4: undefined
}
test('Do all test key/value pares from `data_map` operate with `setItem` and `getItem` methods?', () => {
let days_to_live = 1;
Object.keys(data_map).forEach((key) => {
expect(this.storage.setItem(key, data_map[key], days_to_live)).toBe(true);
expect(this.storage.getItem(key)).toStrictEqual(data_map[key]);
days_to_live++
});
});
test('How about a CharCode key with `setItem()` and `getItem()` methods?', () => {
// `9842` is decimal for _`universal recycling`_
const test_char = String.fromCharCode('9842');
expect(this.storage.setItem(test_char, 'universal recycling', 3)).toBe(true);
expect(this.storage.getItem(test_char)).toBe('universal recycling');
});
test('Does `days_to_live=0` with `setItem()` return `true`?', () => {
expect(this.storage.setItem('Casper', 'Was never here?', 0)).toBe(true);
});
test('With `getItem("Casper")` is there any trace of the _ghoast_ data?', () => {
// Most browsers this would not be required
if (this.storage.supports_local_storage) {
expect(this.storage.keys()).toContain('Casper');
} else {
expect(this.storage.keys()).not.toContain('Casper');
}
});
}
/**
* Reports failures if things do not throw errors when they should
* @returns {none}
* @this Browser_Storage_Test
*/
errorThrowers() {
this.forceStorageState({supports_local_storage: false, supports_cookies: false});
test('Will `setItem()` throw a `ReferenceError` without storage support?', () => {
expect(() => {
this.storage.setItem('test__boolean', true, 3);
}).toThrow(ReferenceError);
});
test('Will `getItem()` throw a `ReferenceError` without storage support?', () => {
expect(() => {
this.storage.getItem('Anything?');
}).toThrow(ReferenceError);
});
test('Will `removeItem()` throw a `ReferenceError` without storage support?', () => {
expect(() => {
this.storage.removeItem('Anything?');
}).toThrow(ReferenceError);
});
test('Will `clear()` throw a `ReferenceError` without storage support?', () => {
expect(() => {
this.storage.clear();
}).toThrow(ReferenceError);
});
test('Will `keys()` throw a `ReferenceError` without storage support?', () => {
expect(() => {
this.storage.keys();
}).toThrow(ReferenceError);
});
test('Will `while (data = storage_iter.next().value)` throw a `ReferenceError` without storage support?', () => {
const storage_iter = this.storage.iterator();
expect(() => {
storage_iter.next().value;
}).toThrow(ReferenceError);
});
}
/**
* Tests ways class may be iterated with
* @returns {none}
* @this Browser_Storage_Test
*/
iterability(states = false) {
if (typeof(states) === 'object') this.forceStorageState(states);
test('What does `for of storage` do?', () => {
for (const data of this.storage) {
expect(this.storage.getItem(data.key)).toStrictEqual(data.value);
}
});
test('What does `while (data=storage_iter.next().value) do?`', () => {
const storage_iter = this.storage.iterator();
let data; while (data = storage_iter.next().value) {
expect(this.storage.getItem(data.key)).toStrictEqual(data.value);
}
});
}
/**
* Reports failures if any values _linger_ when wiping
* @returns {none}
* @this Browser_Storage_Test
*/
resetsAndWipes() {
test('Do supportsCookies and supportsLocalStorage return boolean', () => {
expect(this.storage.supportsCookies()).toBe(true);
expect(this.storage.constructor.supportsLocalStorage()).toBe(true);
});
test('Re-freshing class properties', () => {
expect(this.storage.constructorRefresh()).toBeUndefined();
this.storage.constructorRefresh();
expect(this.storage.supports_local_storage).toBe(true);
});
test('That values can be removed by key', () => {
this.storage.keys().forEach((key) => {
expect(this.storage.removeItem(key)).toBe(true);
expect(this.storage.getItem(key)).toBeUndefined();
});
expect(this.storage.keys()).toStrictEqual([]);
});
this.forceStorageState({supports_local_storage: false});
test('Clearing cookies returns true', () => {
expect(this.storage.clear()).toBe(true);
});
test('Are there any remaining values not `undefined`?', () => {
this.storage.keys().forEach((key) => {
expect(this.storage.getItem(key)).toBeUndefined();
});
});
this.forceStorageState({supports_local_storage: true});
test('Clearing localStorage returns true', () => {
expect(this.storage.clear()).toBe(true);
});
}
}
const test_storage = new Browser_Storage_Test();
test_storage.runTests();
/**
* @typedef forced_states
* @type {Object|boolean}
* @property {boolean} supports_local_storage - cached from `this.supportsLocalStorage()`
* @property {boolean} supports_cookies - cached from `this.supportsCookies()`
* @property {boolean} storage_available - cached on if either localStorage or cookies are supported
*/
Questions
In this day-n-age is there any point in using cookies for a local storage fallback?
is there any point in using the old syntax of getting OO behavior from JavaScript or will using the
class
andfunction
keywords be sufficient?I keep running across
npm
but that seems to be more focused on Node related JavaScript, should I consider issuingnpm init .
or would that pollute their package management system?
If projects like this are a good candidate for
npm
, then some tips on Operating System agnostic ways of issuingGit
commands would be excellent.
Currently I'm usingdecodeURIComponent
toJSON.parse
when returning browser stored values, however, that leads to requiring atry
/catch
for strings which seems messy, so is there a better way of retrieving values?
Never mind that last bit, got it sorted... mostly
Where have I messed up and/or how can code execution be improved without sacrificing readability?
One thing I find bothersome about using
checkbox hacks
is accessibility, are there any good ways of getting Tab and Space/Enter support?
Testing related questions
Currently lines 55
,56
...
static supportsLocalStorage() {
// Because Opera and may be other browsers `setItem`
// is available but with space set to _`0`_
try {
localStorage.setItem('test_key', true);
} catch (e) {
if (!(e instanceof ReferenceError)) throw e; // Line ~55
return false; // Line ~56
} finally {
localStorage.removeItem('test_key');
}
return true;
}
... and line 74
...
supportsCookies() {
if (navigator.cookieEnabled) return true;
if (this._setCookieItem('testcookie', '', 7)) {
return this._setCookieItem('testcookie', '', -7);
}
return false; // Line ~74
}
... and lines 103
through 104
...
_setCookieItem(key, value, days_to_live) {
// ... Trimmed
try {
document.cookie = encoded_key + '=' + encoded_value + expires + '; path=/';
} catch (e) {
if (!(e instanceof ReferenceError)) throw e; // Line ~103
return false // Line ~104
} finally {
return true;
}
}
... seem inaccessible to Jest without resorting to Mocking both storage options.
Additionally for Jests tests...
errorThrowers() {
// ... trimmed
test('Will `for (data of storage)` throw a `ReferenceError` without storage support?', () => {
expect(() => {
for (data of s) console.error('We should have not seen -> ' + data.key + ': ' + data.value);
}).toThrow(ReferenceError);
});
// ... above _lowers_ coverage score for `statements`
// where as the following does not...
test('Will `while (data = storage_iter.next().value)` throw a `ReferenceError` without storage support?', () => {
const storage_iter = this.storage.iterator();
expect(() => {
storage_iter.next().value;
}).toThrow(ReferenceError);
});
}
... how can I get Jest to know that the for loop's inner bits shouldn't ever get touched?
I do not expect one person to attempt to answer all of the above so feel free to focus on one, I'll up-vote and link to answers that I believe are valid. And if someone does attempt to answer the majority or remaining questions then I'll mark that as acceptable too.
caniuse.com
is always a useful reference. You can see that in early 2020,localStorage
has had near-universal support from desktop browsers for the best part of a decade or more and even East Asian mobile browsers are now finding ways to integratelocalStorage
. See: caniuse.com/#search=localstorage \$\endgroup\$