diff --git a/README.md b/README.md index 1ca15e4..c59e0d1 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/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", 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..0c31b1d 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', @@ -17,7 +19,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 +44,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 +56,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 +90,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 +108,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 +143,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 +158,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 +179,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 +198,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 +213,65 @@ 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) { + // 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}')`); + } + + const storage = window[storageTarget]; + + try { + const x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + + isStorageAvailableCache.set(storageTarget, true); + return true; + } catch (err) { + const result = + 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); + + isStorageAvailableCache.set(storageTarget, result); + + return result; + } + }, }; 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() {