Skip to content

Commit

Permalink
Merge branch 'development' of github.com:smithki/storage-proxy into d…
Browse files Browse the repository at this point in the history
…evelopment
  • Loading branch information
smithki committed Nov 9, 2019
2 parents 16317cf + c266fc1 commit 4b8ecb5
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 32 deletions.
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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!
4 changes: 2 additions & 2 deletions src/index.umd.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { StorageProxy } from './lib';
export default StorageProxy;
import { StorageProxyObject } from './lib';
export default StorageProxyObject;
106 changes: 81 additions & 25 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ const storageTargetSymbol = Symbol('storageTargetSymbol');
const defaultValuesSymbol = Symbol('defaultValuesSymbol');
const storageProxyIntegrityKey = '__storageProxyIntegrity';

const isStorageAvailableCache: Map<StorageTarget, boolean> = new Map();

/** Web storage targets: `localStorage` and `sessionStorage`. */
export enum StorageTarget {
Local = 'localStorage',
Session = 'sessionStorage',
}

/** The object type created by `StorageProxy.createLocalStorage()` and `StorageProxy.createSessionStorage()`. */
export type StorageProxy<TStorageDefinitions> = Partial<TStorageDefinitions> & {
export type StorageProxyObject<TStorageDefinitions> = Partial<TStorageDefinitions> & {
readonly [namespaceSymbol]: string;
readonly [isStorageProxy]: true;
readonly [storageTargetSymbol]: StorageTarget;
Expand All @@ -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.');
}
Expand All @@ -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 {};
}

/**
Expand All @@ -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<TStorageDefinitions extends any>(
storageTarget: StorageTarget,
namespace: string,
defaults?: Partial<TStorageDefinitions>,
): StorageProxy<TStorageDefinitions> {
): StorageProxyObject<TStorageDefinitions> {
if (!namespace) throw new Error('[storage-proxy] Namespace cannot be an empty `string`, `undefined`, or `null`.');

initDataStorage(namespace, storageTarget);
Expand All @@ -101,7 +108,7 @@ function createProxy<TStorageDefinitions extends any>(
[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));
});

Expand Down Expand Up @@ -136,34 +143,34 @@ 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<TStorageDefinitions extends any>(
namespace: string,
defaults?: Partial<TStorageDefinitions>,
): StorageProxy<TStorageDefinitions> {
): StorageProxyObject<TStorageDefinitions> {
return createProxy<TStorageDefinitions>(StorageTarget.Local, namespace, defaults);
},

/**
* 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<TStorageDefinitions extends any>(
namespace: string,
defaults?: Partial<TStorageDefinitions>,
): StorageProxy<TStorageDefinitions> {
): StorageProxyObject<TStorageDefinitions> {
return createProxy<TStorageDefinitions>(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`.
*
Expand All @@ -172,7 +179,7 @@ export const StorageProxy = {
*
* @return `boolean` indicating whether the cache integrity is sound.
*/
verifyCache<TStorageProxy extends StorageProxy<any>>(storageProxy: TStorageProxy, seed: string) {
verifyCache<TStorageProxy extends StorageProxyObject<any>>(storageProxy: TStorageProxy, seed: string) {
enforceStorageProxy(storageProxy);

// Get a seed from the raw web storage data and decode it.
Expand All @@ -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<TStorageProxy extends StorageProxy<any>>(storageProxy: TStorageProxy) {
clearStorage<TStorageProxy extends StorageProxyObject<any>>(storageProxy: TStorageProxy) {
enforceStorageProxy(storageProxy);

for (const key of Object.keys(storageProxy)) {
Expand All @@ -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<TStorageProxy extends StorageProxy<any>>(storageProxy: TStorageProxy) {
restoreDefaults<TStorageProxy extends StorageProxyObject<any>>(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;
}
},
};
6 changes: 3 additions & 3 deletions test/src/storage-proxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// -------------------------------------------------------------------------- //

Expand Down Expand Up @@ -45,8 +45,8 @@ function getItem(storageTarget: StorageTarget, path: string) {

@TestFixture('StorageProxy Tests')
export class StorageProxyTestFixture {
lStore: StorageProxy<TestStorage>;
sStore: StorageProxy<TestStorage>;
lStore: StorageProxyObject<TestStorage>;
sStore: StorageProxyObject<TestStorage>;

@SetupFixture
public setupFixture() {
Expand Down

0 comments on commit 4b8ecb5

Please sign in to comment.