Skip to content

Commit

Permalink
feat: storage (#49)
Browse files Browse the repository at this point in the history
* feat: storage

* fix: safeParse JSON

* refactor: type LocalStoragePayload
  • Loading branch information
wangsijie authored Oct 25, 2021
1 parent 2e98b05 commit 07ae993
Show file tree
Hide file tree
Showing 2 changed files with 132 additions and 0 deletions.
61 changes: 61 additions & 0 deletions packages/client/src/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Since we have only one localStorage, all tests should share it
// fp rules are disabled for shared instance initialization in beforeAll
/* eslint-disable @silverhand/fp/no-mutation */
/* eslint-disable @silverhand/fp/no-let */
import { LocalStorage, SessionStorage } from './storage';

describe('LocalStorage', () => {
let s: LocalStorage;

beforeAll(() => {
s = new LocalStorage();
s.setItem('foo', { value: 'foz' });
s.setItem('string', 'foz');
s.setItem('expired', { value: 'foz' }, { secondsUntilExpire: 0 });
s.setItem('to-be-remove', { value: 'foz' });
});

test('saves object', () => {
expect(s.getItem('foo')).toMatchObject({ value: 'foz' });
});

test('saves string', () => {
expect(s.getItem('string')).toEqual('foz');
});

test('returns undefined when there is no object', () => {
expect(s.getItem('invalid-key')).toBeUndefined();
});

test('returns undefined when expired', () => {
expect(s.getItem('expired')).toBeUndefined();
});
});

describe('SessionStorage', () => {
let s: SessionStorage;

beforeAll(() => {
s = new SessionStorage();
s.setItem('foo', { value: 'foz' });
s.setItem('string', 'foz');
s.setItem('expired', { value: 'foz' }, { secondsUntilExpire: 0 });
s.setItem('to-be-remove', { value: 'foz' });
});

test('saves object', () => {
expect(s.getItem('foo')).toMatchObject({ value: 'foz' });
});

test('saves string', () => {
expect(s.getItem('string')).toEqual('foz');
});

test('returns undefined when there is no object', () => {
expect(s.getItem('invalid-key')).toBeUndefined();
});

test('returns undefined when expired', () => {
expect(s.getItem('expired')).toBeUndefined();
});
});
71 changes: 71 additions & 0 deletions packages/client/src/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { nowRoundToSec } from './utils';

const STORAGE_KEY_PREFIX = 'logto:';

const getKey = (key: string) => `${STORAGE_KEY_PREFIX}${key}`;

const safeParse = <T>(value: string): T | undefined => {
try {
return JSON.parse(value) as T;
} catch {
// Noop
}
};

interface ClientStorageOptions {
secondsUntilExpire: number;
}

export interface ClientStorage {
getItem<T>(key: string): T | undefined;
setItem(key: string, value: unknown, options?: ClientStorageOptions): void;
removeItem(key: string): void;
}

type LocalStoragePayload<T> = {
expiresAt?: number;
value: T;
};

export class LocalStorage implements ClientStorage {
storage: Storage = localStorage;

getItem<T>(key: string) {
const value = this.storage.getItem(getKey(key));
if (!value) {
return;
}

const payload = safeParse<LocalStoragePayload<T>>(value);
if (!payload) {
// When JSON parse failed, return undefined.
return;
}

if (payload.expiresAt && payload.expiresAt <= nowRoundToSec()) {
this.removeItem(key);
return;
}

return payload.value;
}

setItem(key: string, value: unknown, options?: ClientStorageOptions) {
const payload: LocalStoragePayload<unknown> = {
expiresAt:
typeof options?.secondsUntilExpire === 'number'
? nowRoundToSec() + options.secondsUntilExpire
: undefined,
value,
};
this.storage.setItem(getKey(key), JSON.stringify(payload));
}

removeItem(key: string) {
this.storage.removeItem(getKey(key));
}
}

export class SessionStorage extends LocalStorage {
storage: Storage = sessionStorage;
}

0 comments on commit 07ae993

Please sign in to comment.