From e2950cdf8cafd74e140100de01f6cb8b022c65bd Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Fri, 4 Oct 2019 11:31:21 -0600 Subject: [PATCH 1/6] Add 'isStorageAvailable' feature check --- README.md | 24 +++++++++++-- src/index.umd.ts | 4 +-- src/lib.ts | 91 +++++++++++++++++++++++++++++++++++------------- 3 files changed, 90 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 1ca15e4..42c2ff2 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,8 @@ const myLocalStorage = StorageProxy.createLocalStorage('my-namespace', { }, }); -console.log(myLocalStorage.one.two.three) // => "three" -myLocalStorage.one.four.five = 'six'; // Works! +console.log(myLocalStorage.one.two) // => "three" +myLocalStorage.one.four.five = 'six'; // Works! ``` In TypeScript, you can define the shape of your stored data by passing a [generic type parameter](https://www.typescriptlang.org/docs/handbook/generics.html) to the factory function: @@ -68,3 +68,23 @@ myStorage.foo // Works! myStorage.bar.baz // Works! myStorage.yolo // Compiler error! ``` + +## Utilities + +For convenience, `StorageProxy` also provides several lightweight utilities for interacting with web storage. + +### `StorageProxy.verifyCache(storageProxy: StorageProxyObject, seed: string)` + +Checks a cache key in the given `StorageProxyObject` and verifies whether the cache integrity is sound. This is handy for cache-busting `localStorage` and `sessionStorage`. + +### `StorageProxy.clearStorage(storageProxy: StorageProxyObject)` + +Clear the given web storage proxy object from `localStorage` or `sessionStorage`. Only keys under the namespace indicated by the `StorageProxyObject` are removed from the web storage caches. + +### `StorageProxy.restoreDefaults(storageProxy: StorageProxyObject)` + +Restores the default values given to `StorageProxy.createLocalStorage()` and `StorageProxy.createSessionStorage()`. However, unlike when the `StorageProxyObject` was initially created, this function privelages the default values _over_ what is currently in `WebStorage`. + +### `StorageProxy.isStorageAvailable(storageTarget?: StorageTarget)` + +Asserts whether the supplied `WebStorage` type is available. The `storageTarget` parameter defaults to `localStorage`. `StorageProxy` uses this utility internally to prevent raising errors in incompatible browser environments. This means you are protected from `WebStorage` permissions issues, but also counts as an important **gotcha!** It's crucial that your application works **with or without `WebStorage`**, so please try to _gracefully degrade functionality_ in such occurrences. This utility is exposed for that very purpose. Use it to your advantage! diff --git a/src/index.umd.ts b/src/index.umd.ts index ebcbe61..dce2a0b 100644 --- a/src/index.umd.ts +++ b/src/index.umd.ts @@ -1,2 +1,2 @@ -import { StorageProxy } from './lib'; -export default StorageProxy; +import { StorageProxyObject } from './lib'; +export default StorageProxyObject; diff --git a/src/lib.ts b/src/lib.ts index 6eda80a..4fbbaa8 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -17,7 +17,7 @@ export enum StorageTarget { } /** The object type created by `StorageProxy.createLocalStorage()` and `StorageProxy.createSessionStorage()`. */ -export type StorageProxy = Partial & { +export type StorageProxyObject = Partial & { readonly [namespaceSymbol]: string; readonly [isStorageProxy]: true; readonly [storageTargetSymbol]: StorageTarget; @@ -42,10 +42,9 @@ function isUndefined(value: any): value is undefined { * Asserts that the given argument is a valid `StorageProxy` object, otherwise * raising an error. * - * @param value - Any value to test for validity as a `StorageProxy` object. + * @param value - Any value to test for validity as a `StorageProxyObject`. */ function enforceStorageProxy(value?: any) { - // Argument must be a `StorageProxy` object. if (!value || !value[isStorageProxy]) { throw new TypeError('[storage-proxy] Supplied argument is not a `StorageProxy` object.'); } @@ -55,25 +54,31 @@ function enforceStorageProxy(value?: any) { * Initializes the web storage interface. If no storage exists, we save an empty * object. * - * @param namespace - The namespace of the `StorageProxy` object. + * @param namespace - The namespace of the `StorageProxyObject`. * @param storageTarget - The web storage target (`localStorage` or `sessionStorage`). */ function initDataStorage(namespace: string, storageTarget: StorageTarget) { - const data = window[storageTarget].getItem(namespace); - if (!data) window[storageTarget].setItem(namespace, JSON.stringify({})); + if (StorageProxy.isStorageAvailable(storageTarget)) { + const data = window[storageTarget].getItem(namespace); + if (!data) window[storageTarget].setItem(namespace, JSON.stringify({})); + } } /** * Gets and parses data from web storage at the provided namespace. * - * @param namespace - The namespace of the `StorageProxy` object. + * @param namespace - The namespace of the `StorageProxyObject`. * @param storageTarget - The web storage target (`localStorage` or `sessionStorage`). * * @return An object of arbitrary data from web storage. */ function getDataFromStorage(namespace: string, storageTarget: StorageTarget) { - const data = window[storageTarget].getItem(namespace); - return !!data ? JSON.parse(data) : {}; + if (StorageProxy.isStorageAvailable(storageTarget)) { + const data = window[storageTarget].getItem(namespace); + return !!data ? JSON.parse(data) : {}; + } + + return {}; } /** @@ -83,13 +88,13 @@ function getDataFromStorage(namespace: string, storageTarget: StorageTarget) { * @param storageTarget - Target `localStorage` or `sessionStorage` with the proxy. * @param namespace - An optional namespace to use. * - * @return A `StorageProxy` object. + * @return A `StorageProxyObject` type. */ function createProxy( storageTarget: StorageTarget, namespace: string, defaults?: Partial, -): StorageProxy { +): StorageProxyObject { if (!namespace) throw new Error('[storage-proxy] Namespace cannot be an empty `string`, `undefined`, or `null`.'); initDataStorage(namespace, storageTarget); @@ -101,7 +106,7 @@ function createProxy( [storageTargetSymbol]: storageTarget, }; const proxyData = onChange(data, (_path, value, prevValue) => { - if (value === prevValue) return; + if (value === prevValue || !StorageProxy.isStorageAvailable(storageTarget)) return; window[storageTarget].setItem(namespace, JSON.stringify(proxyData)); }); @@ -136,14 +141,14 @@ export const StorageProxy = { * Creates a `localStorage` proxy object that can be used like a plain JS object. * * @param namespace - A namespace to prefix `localStorage` keys with. - * @param defaults - Optional default values for this `StorageProxy` object. + * @param defaults - Optional default values for this `StorageProxyObject`. * - * @return a `StorageProxy` object targeting `localStorage`. + * @return a `StorageProxyObject` targeting `localStorage`. */ createLocalStorage( namespace: string, defaults?: Partial, - ): StorageProxy { + ): StorageProxyObject { return createProxy(StorageTarget.Local, namespace, defaults); }, @@ -151,19 +156,19 @@ export const StorageProxy = { * Creates a `sessionStorage` proxy object that can be used like a plain JS object. * * @param namespace - A namespace to prefix `sessionStorage` keys with. - * @param defaults - Optional default values for this `StorageProxy` object. + * @param defaults - Optional default values for this `StorageProxyObject`. * - * @return a `StorageProxy` object targeting `sessionStorage`. + * @return a `StorageProxyObject` targeting `sessionStorage`. */ createSessionStorage( namespace: string, defaults?: Partial, - ): StorageProxy { + ): StorageProxyObject { return createProxy(StorageTarget.Session, namespace, defaults); }, /** - * Checks a cache key in the given `StorageProxy` object and verifies whether + * Checks a cache key in the given `StorageProxyObject` and verifies whether * the cache integrity is sound. This is handy for cache-busting * `localStorage` and `sessionStorage`. * @@ -172,7 +177,7 @@ export const StorageProxy = { * * @return `boolean` indicating whether the cache integrity is sound. */ - verifyCache>(storageProxy: TStorageProxy, seed: string) { + verifyCache>(storageProxy: TStorageProxy, seed: string) { enforceStorageProxy(storageProxy); // Get a seed from the raw web storage data and decode it. @@ -191,11 +196,11 @@ export const StorageProxy = { /** * Clear the given web storage proxy object from `localStorage` or * `sessionStorage`. Only keys under the namespace indicated by the - * `StorageProxy` object are removed from the web storage caches. + * `StorageProxyObject` are removed from the web storage caches. * * @param storageProxy - The storage proxy object to clear. */ - clearStorage>(storageProxy: TStorageProxy) { + clearStorage>(storageProxy: TStorageProxy) { enforceStorageProxy(storageProxy); for (const key of Object.keys(storageProxy)) { @@ -206,16 +211,52 @@ export const StorageProxy = { /** * Restores the default values given to `StorageProxy.createLocalStorage()` * and `StorageProxy.createSessionStorage()`. However, unlike when the - * `StorageProxy` was initially created, this function privelages the default - * values _over_ what is currently in `WebStorage`. + * `StorageProxyObject` was initially created, this function privelages the + * default values _over_ what is currently in `WebStorage`. * * @param storageProxy - The storage proxy object to restore to a default state. */ - restoreDefaults>(storageProxy: TStorageProxy) { + restoreDefaults>(storageProxy: TStorageProxy) { enforceStorageProxy(storageProxy); for (const [key, value] of Object.entries(storageProxy[defaultValuesSymbol])) { (storageProxy as any)[key] = value; } }, + + /** + * Asserts whether the supplied `WebStorage` type is available. + * + * This implementation is based on an example from MDN (Mozilla Developer Network): + * https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#Feature-detecting_localStorage + * + * @param storageTarget - The web storage target (`localStorage` or `sessionStorage`). + * @returns `boolean` indicating whether the specified storage is available or not. + */ + isStorageAvailable(storageTarget: StorageTarget = StorageTarget.Local) { + const storage = window[storageTarget]; + + try { + const x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + + return true; + } catch (err) { + return ( + err && + // everything except Firefox + (err.code === 22 || + // Firefox + err.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + err.name === 'QuotaExceededError' || + // Firefox + err.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + // acknowledge QuotaExceededError only if there's something already stored + (storage && storage.length !== 0) + ); + } + }, }; From 92fdfdf3a695f994870578be2797a8efe669af74 Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Fri, 4 Oct 2019 11:38:51 -0600 Subject: [PATCH 2/6] Adjust README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 42c2ff2..c59e0d1 100644 --- a/README.md +++ b/README.md @@ -73,18 +73,18 @@ myStorage.yolo // Compiler error! For convenience, `StorageProxy` also provides several lightweight utilities for interacting with web storage. -### `StorageProxy.verifyCache(storageProxy: StorageProxyObject, seed: string)` +#### `StorageProxy.verifyCache(storageProxy: StorageProxyObject, seed: string)` Checks a cache key in the given `StorageProxyObject` and verifies whether the cache integrity is sound. This is handy for cache-busting `localStorage` and `sessionStorage`. -### `StorageProxy.clearStorage(storageProxy: StorageProxyObject)` +#### `StorageProxy.clearStorage(storageProxy: StorageProxyObject)` Clear the given web storage proxy object from `localStorage` or `sessionStorage`. Only keys under the namespace indicated by the `StorageProxyObject` are removed from the web storage caches. -### `StorageProxy.restoreDefaults(storageProxy: StorageProxyObject)` +#### `StorageProxy.restoreDefaults(storageProxy: StorageProxyObject)` Restores the default values given to `StorageProxy.createLocalStorage()` and `StorageProxy.createSessionStorage()`. However, unlike when the `StorageProxyObject` was initially created, this function privelages the default values _over_ what is currently in `WebStorage`. -### `StorageProxy.isStorageAvailable(storageTarget?: StorageTarget)` +#### `StorageProxy.isStorageAvailable(storageTarget?: StorageTarget)` Asserts whether the supplied `WebStorage` type is available. The `storageTarget` parameter defaults to `localStorage`. `StorageProxy` uses this utility internally to prevent raising errors in incompatible browser environments. This means you are protected from `WebStorage` permissions issues, but also counts as an important **gotcha!** It's crucial that your application works **with or without `WebStorage`**, so please try to _gracefully degrade functionality_ in such occurrences. This utility is exposed for that very purpose. Use it to your advantage! From 3f10b134e1ed5fa22b1b425b33bac7358661953c Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Fri, 4 Oct 2019 11:41:01 -0600 Subject: [PATCH 3/6] Fix tests --- test/src/storage-proxy.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/src/storage-proxy.spec.ts b/test/src/storage-proxy.spec.ts index 3844cbf..9aebb59 100644 --- a/test/src/storage-proxy.spec.ts +++ b/test/src/storage-proxy.spec.ts @@ -3,7 +3,7 @@ import './mocks/browser'; import { Expect, SetupFixture, Test, TestFixture } from 'alsatian'; -import { StorageProxy, StorageTarget } from '../../src/lib'; +import { StorageProxy, StorageProxyObject, StorageTarget } from '../../src/lib'; // -------------------------------------------------------------------------- // @@ -45,8 +45,8 @@ function getItem(storageTarget: StorageTarget, path: string) { @TestFixture('StorageProxy Tests') export class StorageProxyTestFixture { - lStore: StorageProxy; - sStore: StorageProxy; + lStore: StorageProxyObject; + sStore: StorageProxyObject; @SetupFixture public setupFixture() { From 596d1fd4d51d9cde668f9f048655984e43fc7a67 Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Thu, 10 Oct 2019 11:44:26 -0600 Subject: [PATCH 4/6] Add optimization to 'isStorageAvailable' implementation --- src/lib.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/lib.ts b/src/lib.ts index 4fbbaa8..0383d44 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -10,6 +10,8 @@ const storageTargetSymbol = Symbol('storageTargetSymbol'); const defaultValuesSymbol = Symbol('defaultValuesSymbol'); const storageProxyIntegrityKey = '__storageProxyIntegrity'; +const isStorageAvailableCache: Map = new Map(); + /** Web storage targets: `localStorage` and `sessionStorage`. */ export enum StorageTarget { Local = 'localStorage', @@ -234,6 +236,15 @@ export const StorageProxy = { * @returns `boolean` indicating whether the specified storage is available or not. */ isStorageAvailable(storageTarget: StorageTarget = StorageTarget.Local) { + // Disallow non-existant storage targets! + if (storageTarget !== StorageTarget.Local && storageTarget !== StorageTarget.Session) { + // tslint:disable-next-line:prettier + throw new TypeError(`[storage-target] Expected \`WebStorage\` target to be one of: ('${StorageTarget.Local}', '${StorageTarget.Session}')`); + } + + // Optimization: return the memoized value, if present. + if (isStorageAvailableCache.has(storageTarget)) return isStorageAvailableCache.get(storageTarget); + const storage = window[storageTarget]; try { @@ -241,9 +252,10 @@ export const StorageProxy = { storage.setItem(x, x); storage.removeItem(x); + isStorageAvailableCache.set(storageTarget, true); return true; } catch (err) { - return ( + const result = err && // everything except Firefox (err.code === 22 || @@ -255,8 +267,11 @@ export const StorageProxy = { // Firefox err.name === 'NS_ERROR_DOM_QUOTA_REACHED') && // acknowledge QuotaExceededError only if there's something already stored - (storage && storage.length !== 0) - ); + (storage && storage.length !== 0); + + isStorageAvailableCache.set(storageTarget, result); + + return result; } }, }; From b825db4c06690b238a3c625e8e186d4315f33ce9 Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Thu, 10 Oct 2019 11:45:24 -0600 Subject: [PATCH 5/6] Re-order optimization code in 'isStorageAvailable' implementation --- src/lib.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib.ts b/src/lib.ts index 0383d44..0c31b1d 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -236,15 +236,15 @@ export const StorageProxy = { * @returns `boolean` indicating whether the specified storage is available or not. */ isStorageAvailable(storageTarget: StorageTarget = StorageTarget.Local) { + // Optimization: return the memoized value, if present. + if (isStorageAvailableCache.has(storageTarget)) return isStorageAvailableCache.get(storageTarget); + // Disallow non-existant storage targets! if (storageTarget !== StorageTarget.Local && storageTarget !== StorageTarget.Session) { // tslint:disable-next-line:prettier throw new TypeError(`[storage-target] Expected \`WebStorage\` target to be one of: ('${StorageTarget.Local}', '${StorageTarget.Session}')`); } - // Optimization: return the memoized value, if present. - if (isStorageAvailableCache.has(storageTarget)) return isStorageAvailableCache.get(storageTarget); - const storage = window[storageTarget]; try { From 16317cfd18fbcc40bef68dc7d37b8fe3a7012daa Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Sat, 9 Nov 2019 12:24:53 -0500 Subject: [PATCH 6/6] v1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 50a82a0..c8fbc54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storage-proxy", - "version": "1.0.0", + "version": "1.1.0", "description": "Use web storage (localStorage/sessionStorage) just like plain objects using ES6 Proxies.", "author": "Ian K Smith ", "license": "MIT",