diff --git a/.changeset/smooth-seahorses-unite.md b/.changeset/smooth-seahorses-unite.md new file mode 100644 index 000000000..ae8fa2dc9 --- /dev/null +++ b/.changeset/smooth-seahorses-unite.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': minor +--- + +Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) or use a custom implementation diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts index 64711a803..5e2045b15 100644 --- a/packages/browser/src/core/analytics/__tests__/integration.test.ts +++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts @@ -7,7 +7,9 @@ import { import { Context } from '../../context' import { Plugin } from '../../plugin' import { EventQueue } from '../../queue/event-queue' +import { StoreType } from '../../storage' import { Analytics } from '../index' +import jar from 'js-cookie' import { TestAfterPlugin, TestBeforePlugin, @@ -271,4 +273,30 @@ describe('Analytics', () => { expect(fn).toHaveBeenCalledTimes(1) }) }) + + describe('storage', () => { + beforeEach(() => { + clearAjsBrowserStorage() + }) + + it('handles custom priority storage', async () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const expected = 'CookieValue' + jar.set('ajs_anonymous_id', expected) + localStorage.setItem('ajs_anonymous_id', 'localStorageValue') + + const analytics = new Analytics( + { writeKey: '' }, + { + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + } + ) + + expect(analytics.user().anonymousId()).toEqual(expected) + + analytics.user().id('known-user') + expect(analytics.user().id()).toEqual('known-user') + expect(setCookieSpy).toHaveBeenCalled() + }) + }) }) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 0ffe4eb9b..d958e974f 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -24,15 +24,7 @@ import { } from '../events' import type { Plugin } from '../plugin' import { EventQueue } from '../queue/event-queue' -import { - CookieOptions, - getAvailableStorageOptions, - Group, - ID, - UniversalStorage, - User, - UserOptions, -} from '../user' +import { Group, ID, User, UserOptions } from '../user' import autoBind from '../../lib/bind-all' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' import type { LegacyDestination } from '../../plugins/ajs-destination' @@ -50,6 +42,18 @@ import { getGlobal } from '../../lib/get-global' import { AnalyticsClassic, AnalyticsCore } from './interfaces' import { HighEntropyHint } from '../../lib/client-hints/interfaces' import type { LegacySettings } from '../../browser' +import { + CookieOptions, + MemoryStorage, + UniversalStorage, + Storage, + StorageSettings, + StoreType, + applyCookieOptions, + initializeStorages, + isArrayOfStoreType, + isStorageObject, +} from '../storage' const deprecationWarning = 'This is being deprecated and will be not be available in future releases of Analytics JS' @@ -93,6 +97,7 @@ export interface InitOptions { disableAutoISOConversion?: boolean initialPageview?: boolean cookie?: CookieOptions + storage?: StorageSettings user?: UserOptions group?: UserOptions integrations?: Integrations @@ -133,9 +138,7 @@ export class Analytics private _group: Group private eventFactory: EventFactory private _debug = false - private _universalStorage: UniversalStorage<{ - [k: string]: unknown - }> + private _universalStorage: Storage initialized = false integrations: Integrations @@ -162,25 +165,33 @@ export class Analytics disablePersistance ) - this._universalStorage = new UniversalStorage( - disablePersistance ? ['memory'] : ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions(cookieOptions) + const storageSetting = options?.storage + this._universalStorage = this.createStore( + disablePersistance, + storageSetting, + cookieOptions ) this._user = user ?? new User( - disablePersistance - ? { ...options?.user, persist: false } - : options?.user, + { + persist: !disablePersistance, + storage: options?.storage, + // Any User specific options override everything else + ...options?.user, + }, cookieOptions ).load() this._group = group ?? new Group( - disablePersistance - ? { ...options?.group, persist: false } - : options?.group, + { + persist: !disablePersistance, + storage: options?.storage, + // Any group specific options override everything else + ...options?.group, + }, cookieOptions ).load() this.eventFactory = new EventFactory(this._user) @@ -194,7 +205,47 @@ export class Analytics return this._user } - get storage(): UniversalStorage { + /** + * Creates the storage system based on the settings received + * @returns Storage + */ + private createStore( + disablePersistance: boolean, + storageSetting: InitOptions['storage'], + cookieOptions?: CookieOptions | undefined + ): Storage { + // DisablePersistance option overrides all, no storage will be used outside of memory even if specified + if (disablePersistance) { + return new MemoryStorage() + } else { + if (storageSetting !== undefined && storageSetting !== null) { + if (isArrayOfStoreType(storageSetting)) { + // We will create the store with the priority for customer settings + return new UniversalStorage( + initializeStorages( + applyCookieOptions(storageSetting, cookieOptions) + ) + ) + } else if (isStorageObject(storageSetting)) { + // If it is an object we will use the customer provided storage + return storageSetting + } + } + } + // We default to our multi storage with priority + return new UniversalStorage( + initializeStorages([ + StoreType.LocalStorage, + { + name: StoreType.Cookie, + settings: cookieOptions, + }, + StoreType.Memory, + ]) + ) + } + + get storage(): Storage { return this._universalStorage } diff --git a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts new file mode 100644 index 000000000..da02beccc --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts @@ -0,0 +1,76 @@ +import { CookieStorage } from '../cookieStorage' +import jar from 'js-cookie' +import { disableCookies } from './test-helpers' + +describe('cookieStorage', () => { + function clearCookies() { + document.cookie.split(';').forEach(function (c) { + document.cookie = c + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/') + }) + } + + afterEach(() => { + clearCookies() + }) + + describe('#available', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('is available', () => { + const cookie = new CookieStorage() + expect(cookie.available).toBe(true) + }) + + it("is unavailable if can't write cookies", () => { + disableCookies() + const cookie = new CookieStorage() + expect(cookie.available).toBe(false) + }) + }) + + describe('cookie options', () => { + it('should have default cookie options', () => { + const cookie = new CookieStorage() + expect(cookie['options'].domain).toBe(undefined) + expect(cookie['options'].maxage).toBe(365) + expect(cookie['options'].path).toBe('/') + expect(cookie['options'].sameSite).toBe('Lax') + expect(cookie['options'].secure).toBe(undefined) + }) + + it('should set options properly', () => { + const cookie = new CookieStorage({ + domain: 'foo', + secure: true, + path: '/test', + }) + expect(cookie['options'].domain).toBe('foo') + expect(cookie['options'].secure).toBe(true) + expect(cookie['options'].path).toBe('/test') + expect(cookie['options'].secure).toBe(true) + }) + + it('should pass options when creating cookie', () => { + const jarSpy = jest.spyOn(jar, 'set') + const cookie = new CookieStorage({ + domain: 'foo', + secure: true, + path: '/test', + }) + + cookie.set('foo', 'bar') + + expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', { + domain: 'foo', + expires: 365, + path: '/test', + sameSite: 'Lax', + secure: true, + }) + }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/localStorage.test.ts b/packages/browser/src/core/storage/__tests__/localStorage.test.ts new file mode 100644 index 000000000..30c73a121 --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/localStorage.test.ts @@ -0,0 +1,70 @@ +import { LocalStorage } from '../localStorage' + +describe('LocalStorage', function () { + let store: LocalStorage + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. + store = new LocalStorage() + }) + + afterEach(() => { + localStorage.clear() + }) + + describe('#get', function () { + it('should return null if localStorage throws an error (or does not exist)', function () { + const getItemSpy = jest + .spyOn(global.Storage.prototype, 'getItem') + .mockImplementationOnce(() => { + throw new Error('getItem fail.') + }) + store.set('foo', 'some value') + expect(store.get('foo')).toBeNull() + expect(getItemSpy).toBeCalledTimes(1) + }) + + it('should not get an empty record', function () { + expect(store.get('abc')).toBe(null) + }) + + it('should get an existing record', function () { + store.set('x', { a: 'b' }) + store.set('a', 'hello world') + store.set('b', '') + store.set('c', false) + store.set('d', null) + store.set('e', undefined) + + expect(store.get('x')).toStrictEqual({ a: 'b' }) + expect(store.get('a')).toBe('hello world') + expect(store.get('b')).toBe('') + expect(store.get('c')).toBe(false) + expect(store.get('d')).toBe(null) + expect(store.get('e')).toBe('undefined') + }) + }) + + describe('#set', function () { + it('should be able to set a record', function () { + store.set('x', { a: 'b' }) + expect(store.get('x')).toStrictEqual({ a: 'b' }) + }) + + it('should catch localStorage quota exceeded errors', () => { + const val = 'x'.repeat(10 * 1024 * 1024) + store.set('foo', val) + + expect(store.get('foo')).toBe(null) + }) + }) + + describe('#clear', function () { + it('should be able to remove a record', function () { + store.set('x', { a: 'b' }) + expect(store.get('x')).toStrictEqual({ a: 'b' }) + store.clear('x') + expect(store.get('x')).toBe(null) + }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts b/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts new file mode 100644 index 000000000..f05f5ad9d --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts @@ -0,0 +1,159 @@ +import jar from 'js-cookie' +import { CookieStorage } from '../cookieStorage' +import { LocalStorage } from '../localStorage' +import { MemoryStorage } from '../memoryStorage' +import { UniversalStorage } from '../universalStorage' +describe('prioritizedListStorage', function () { + const defaultTargets = [ + new CookieStorage(), + new LocalStorage(), + new MemoryStorage(), + ] + const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '') + beforeEach(function () { + clear() + }) + + function clear(): void { + document.cookie.split(';').forEach(function (c) { + document.cookie = c + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/') + }) + localStorage.clear() + } + + describe('#get', function () { + it('picks data from cookies first', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage(defaultTargets) + expect(us.get('ajs_test_key')).toEqual('🍊') + }) + + it('picks data from localStorage if there is no cookie target', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()]) + expect(us.get('ajs_test_key')).toEqual('ðŸ’ū') + }) + + it('get data from memory', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage([new MemoryStorage()]) + expect(us.get('ajs_test_key')).toBeNull() + }) + + it('order of default targets matters!', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage(defaultTargets) + expect(us.get('ajs_test_key')).toEqual('🍊') + }) + + it('returns null if there are no storage targets', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage([]) + expect(us.get('ajs_test_key')).toBeNull() + }) + + // it('can override the default targets', function () { + // jar.set('ajs_test_key', '🍊') + // localStorage.setItem('ajs_test_key', 'ðŸ’ū') + // const us = new PrioritizedListStorage( + // defaultTargets + // ) + // expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðŸ’ū') + // expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðŸ’ū') + // expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('🍊') + // expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('🍊') + // expect(us.get('ajs_test_key', ['cookie'])).toEqual('🍊') + // expect(us.get('ajs_test_key', ['memory'])).toEqual(null) + // }) + }) + + describe('#set', function () { + it('set the data in all storage types', function () { + const us = new UniversalStorage<{ ajs_test_key: string }>(defaultTargets) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual('💰') + expect(getFromLS('ajs_test_key')).toEqual('💰') + }) + + it('skip saving data to localStorage', function () { + const us = new UniversalStorage([ + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual('💰') + expect(localStorage.getItem('ajs_test_key')).toEqual(null) + }) + + it('skip saving data to cookie', function () { + const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(getFromLS('ajs_test_key')).toEqual('💰') + }) + + it('can save and retrieve from memory when there is no other storage', function () { + const us = new UniversalStorage([new MemoryStorage()]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(localStorage.getItem('ajs_test_key')).toEqual(null) + expect(us.get('ajs_test_key')).toEqual('💰') + }) + + it('does not write to cookies when cookies are not available', function () { + const cookieStore = new CookieStorage() + jest.spyOn(cookieStore, 'available', 'get').mockReturnValueOnce(false) + const us = new UniversalStorage([ + new LocalStorage(), + cookieStore, + new MemoryStorage(), + ]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(getFromLS('ajs_test_key')).toEqual('💰') + expect(us.get('ajs_test_key')).toEqual('💰') + }) + + it('does not write to LS when LS is not available', function () { + const localStorage = new LocalStorage() + jest.spyOn(localStorage, 'available', 'get').mockReturnValueOnce(false) + const us = new UniversalStorage([ + localStorage, + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual('💰') + expect(localStorage.get('ajs_test_key')).toEqual(null) + expect(us.get('ajs_test_key')).toEqual('💰') + }) + + // it('can override the default targets', function () { + // const us = new UniversalStorage( + // defaultTargets, + // getAvailableStorageOptions() + // ) + // us.set('ajs_test_key', '💰', ['localStorage']) + // expect(jar.get('ajs_test_key')).toEqual(undefined) + // expect(getFromLS('ajs_test_key')).toEqual('💰') + // expect(us.get('ajs_test_key')).toEqual('💰') + + // us.set('ajs_test_key_2', 'ðŸĶī', ['cookie']) + // expect(jar.get('ajs_test_key_2')).toEqual('ðŸĶī') + // expect(localStorage.getItem('ajs_test_key_2')).toEqual(null) + // expect(us.get('ajs_test_key_2')).toEqual('ðŸĶī') + + // us.set('ajs_test_key_3', 'ðŸ‘ŧ', []) + // expect(jar.get('ajs_test_key_3')).toEqual(undefined) + // expect(localStorage.getItem('ajs_test_key_3')).toEqual(null) + // expect(us.get('ajs_test_key_3')).toEqual(null) + // }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts new file mode 100644 index 000000000..7bd5af18b --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts @@ -0,0 +1,19 @@ +/** + * Disables Cookies + * @returns jest spy + */ +export function disableCookies(): jest.SpyInstance { + return jest + .spyOn(window.navigator, 'cookieEnabled', 'get') + .mockReturnValue(false) +} + +/** + * Disables LocalStorage + * @returns jest spy + */ +export function disableLocalStorage(): jest.SpyInstance { + return jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error() + }) +} diff --git a/packages/browser/src/core/storage/cookieStorage.ts b/packages/browser/src/core/storage/cookieStorage.ts new file mode 100644 index 000000000..02f5eb50a --- /dev/null +++ b/packages/browser/src/core/storage/cookieStorage.ts @@ -0,0 +1,97 @@ +import { BaseStorage, StorageObject, StoreType } from './types' +import jar from 'js-cookie' +import { tld } from '../user/tld' + +const ONE_YEAR = 365 + +export interface CookieOptions { + maxage?: number + domain?: string + path?: string + secure?: boolean + sameSite?: string +} + +/** + * Data storage using browser cookies + */ +export class CookieStorage< + Data extends StorageObject = StorageObject +> extends BaseStorage { + get available(): boolean { + let cookieEnabled = window.navigator.cookieEnabled + + if (!cookieEnabled) { + jar.set('ajs:cookies', 'test') + cookieEnabled = document.cookie.includes('ajs:cookies') + jar.remove('ajs:cookies') + } + + return cookieEnabled + } + + get type() { + return StoreType.Cookie + } + + static get defaults(): CookieOptions { + return { + maxage: ONE_YEAR, + domain: tld(window.location.href), + path: '/', + sameSite: 'Lax', + } + } + + private options: Required + + constructor(options: CookieOptions = CookieStorage.defaults) { + super() + this.options = { + ...CookieStorage.defaults, + ...options, + } as Required + } + + private opts(): jar.CookieAttributes { + return { + sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'], + expires: this.options.maxage, + domain: this.options.domain, + path: this.options.path, + secure: this.options.secure, + } + } + + get(key: K): Data[K] | null { + try { + const value = jar.get(key) + + if (value === undefined || value === null) { + return null + } + + try { + return JSON.parse(value) ?? null + } catch (e) { + return (value ?? null) as unknown as Data[K] | null + } + } catch (e) { + return null + } + } + + set(key: K, value: Data[K] | null): void { + if (typeof value === 'string') { + jar.set(key, value, this.opts()) + } else if (value === null) { + jar.remove(key, this.opts()) + } else { + jar.set(key, JSON.stringify(value), this.opts()) + } + } + + clear(key: K): void { + return jar.remove(key, this.opts()) + } +} diff --git a/packages/browser/src/core/storage/index.ts b/packages/browser/src/core/storage/index.ts new file mode 100644 index 000000000..5793795b0 --- /dev/null +++ b/packages/browser/src/core/storage/index.ts @@ -0,0 +1,64 @@ +import { CookieOptions, CookieStorage } from './cookieStorage' +import { LocalStorage } from './localStorage' +import { MemoryStorage } from './memoryStorage' +import { isStoreTypeWithSettings } from './settings' +import { StoreType, Storage, InitializeStorageArgs } from './types' + +export * from './types' +export * from './localStorage' +export * from './cookieStorage' +export * from './memoryStorage' +export * from './universalStorage' +export * from './settings' + +/** + * Creates multiple storage systems from an array of StoreType and options + * @param args StoreType and options + * @returns Storage array + */ +export function initializeStorages(args: InitializeStorageArgs): Storage[] { + const storages = args.map((s) => { + let type: StoreType + let settings + + if (isStoreTypeWithSettings(s)) { + type = s.name + settings = s.settings + } else { + type = s + } + + switch (type) { + case StoreType.Cookie: + return new CookieStorage(settings) + case StoreType.LocalStorage: + return new LocalStorage() + case StoreType.Memory: + return new MemoryStorage() + default: + throw new Error(`Unknown Store Type: ${s}`) + } + }) + return storages +} + +/** + * Injects the CookieOptions into a the arguments for initializeStorage + * @param storeTypes list of storeType + * @param cookieOptions cookie Options + * @returns arguments for initializeStorage + */ +export function applyCookieOptions( + storeTypes: StoreType[], + cookieOptions?: CookieOptions +): InitializeStorageArgs { + return storeTypes.map((s) => { + if (cookieOptions && s === StoreType.Cookie) { + return { + name: s, + settings: cookieOptions, + } + } + return s + }) +} diff --git a/packages/browser/src/core/storage/localStorage.ts b/packages/browser/src/core/storage/localStorage.ts new file mode 100644 index 000000000..f5385f965 --- /dev/null +++ b/packages/browser/src/core/storage/localStorage.ts @@ -0,0 +1,60 @@ +import { BaseStorage, StorageObject, StoreType } from './types' + +/** + * Data storage using browser's localStorage + */ +export class LocalStorage< + Data extends StorageObject = StorageObject +> extends BaseStorage { + private localStorageWarning(key: keyof Data, state: 'full' | 'unavailable') { + console.warn(`Unable to access ${key}, localStorage may be ${state}`) + } + + get type() { + return StoreType.LocalStorage + } + + get available(): boolean { + const test = 'test' + try { + localStorage.setItem(test, test) + localStorage.removeItem(test) + return true + } catch (e) { + return false + } + } + + get(key: K): Data[K] | null { + try { + const val = localStorage.getItem(key) + if (val === null) { + return null + } + try { + return JSON.parse(val) ?? null + } catch (e) { + return (val ?? null) as unknown as Data[K] | null + } + } catch (err) { + this.localStorageWarning(key, 'unavailable') + return null + } + } + + set(key: K, value: Data[K] | null): void { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + this.localStorageWarning(key, 'full') + } + } + + clear(key: K): void { + try { + return localStorage.removeItem(key) + } catch (err) { + this.localStorageWarning(key, 'unavailable') + } + } +} diff --git a/packages/browser/src/core/storage/memoryStorage.ts b/packages/browser/src/core/storage/memoryStorage.ts new file mode 100644 index 000000000..fa9a3d580 --- /dev/null +++ b/packages/browser/src/core/storage/memoryStorage.ts @@ -0,0 +1,30 @@ +import { BaseStorage, StorageObject, StoreType } from './types' + +/** + * Data Storage using in memory object + */ +export class MemoryStorage< + Data extends StorageObject = StorageObject +> extends BaseStorage { + private cache: Record = {} + + get type() { + return StoreType.Memory + } + + get available(): boolean { + return true + } + + get(key: K): Data[K] | null { + return (this.cache[key] ?? null) as Data[K] | null + } + + set(key: K, value: Data[K] | null): void { + this.cache[key] = value + } + + clear(key: K): void { + delete this.cache[key] + } +} diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts new file mode 100644 index 000000000..2eab09876 --- /dev/null +++ b/packages/browser/src/core/storage/settings.ts @@ -0,0 +1,28 @@ +import { Storage, StoreType, StoreTypeWithSettings } from './types' + +export type StorageSettings = Storage | StoreType[] + +export function isArrayOfStoreType(s: StorageSettings): s is StoreType[] { + return ( + s !== undefined && + s !== null && + Array.isArray(s) && + s.every((e) => Object.values(StoreType).includes(e)) + ) +} + +export function isStorageObject(s: StorageSettings): s is Storage { + return ( + s !== undefined && + s !== null && + !Array.isArray(s) && + typeof s === 'object' && + s.get !== undefined + ) +} + +export function isStoreTypeWithSettings( + s: StoreTypeWithSettings | StoreType +): s is StoreTypeWithSettings { + return typeof s === 'object' && s.name !== undefined +} diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts new file mode 100644 index 000000000..18f3b0f26 --- /dev/null +++ b/packages/browser/src/core/storage/types.ts @@ -0,0 +1,93 @@ +import { CookieOptions } from './cookieStorage' + +/** + * Known Storage Types + * + * Convenience settings for storage systems that AJS includes support for + */ +export enum StoreType { + Cookie = 'cookie', + LocalStorage = 'localStorage', + Memory = 'memory', +} + +export type StorageObject = Record + +/** + * Defines a Storage object for use in AJS Client. + */ +export interface Storage { + /** + * Returns the kind of storage. + * @example cookie, localStorage, custom + */ + get type(): StoreType | string + + /** + * Tests if the storage is available for use in the current environment + */ + get available(): boolean + /** + * get value for the key from the stores. it will return the first value found in the stores + * @param key key for the value to be retrieved + * @returns value for the key or null if not found + */ + get(key: K): Data[K] | null + /* + This is to support few scenarios where: + - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them + - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format + */ + + /** + * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores + * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0 + * @param key key for the value to be retrieved + * @returns value for the key or null if not found + */ + getAndSync(key: K): Data[K] | null + /** + * it will set the value for the key in all the stores + * @param key key for the value to be stored + * @param value value to be stored + * @returns value that was stored + */ + set(key: K, value: Data[K] | null): void + /** + * remove the value for the key from all the stores + * @param key key for the value to be removed + * @param storeTypes optional array of store types to be used for removing the value + */ + clear(key: K): void +} + +/** + * Abstract class for creating basic storage systems + */ +export abstract class BaseStorage + implements Storage +{ + abstract get type(): StoreType | string + abstract get available(): boolean + abstract get(key: K): Data[K] | null + abstract set(key: K, value: Data[K] | null): void + abstract clear(key: K): void + /** + * By default a storage getAndSync will handle calls exactly as the + */ + getAndSync(key: K): Data[K] | null { + const val = this.get(key) + // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) + const coercedValue = (typeof val === 'number' ? val.toString() : val) as + | Data[K] + | null + return coercedValue + } +} + +export interface StoreTypeWithSettings { + name: T + settings?: T extends StoreType.Cookie ? CookieOptions : never +} + +export type InitializeStorageArgs = (StoreTypeWithSettings | StoreType)[] diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts new file mode 100644 index 000000000..9e16bac38 --- /dev/null +++ b/packages/browser/src/core/storage/universalStorage.ts @@ -0,0 +1,55 @@ +import { Storage, StorageObject } from './types' + +/** + * Uses multiple storages in a priority list to get/set values in the order they are specified. + */ +export class UniversalStorage + implements Storage +{ + private stores: Storage[] + + constructor(stores: Storage[]) { + this.stores = stores.filter((s) => s.available) + } + + get available(): boolean { + return this.stores.some((s) => s.available) + } + + get type(): string { + return 'PriorityListStorage' + } + + get(key: K): Data[K] | null { + let val: Data[K] | null = null + + for (const store of this.stores) { + val = store.get(key) as Data[K] | null + if (val !== undefined && val !== null) { + return val + } + } + return null + } + + set(key: K, value: Data[K] | null): void { + this.stores.forEach((s) => s.set(key, value)) + } + + clear(key: K): void { + this.stores.forEach((s) => s.clear(key)) + } + + getAndSync(key: K): Data[K] | null { + const val = this.get(key) + + // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) + const coercedValue = (typeof val === 'number' ? val.toString() : val) as + | Data[K] + | null + + this.set(key, coercedValue) + + return coercedValue + } +} diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts index 7f78deab7..045c3cedd 100644 --- a/packages/browser/src/core/user/__tests__/index.test.ts +++ b/packages/browser/src/core/user/__tests__/index.test.ts @@ -1,14 +1,12 @@ -import { - User, - LocalStorage, - Cookie, - Group, - UniversalStorage, - StoreType, - getAvailableStorageOptions, -} from '..' -import jar from 'js-cookie' import assert from 'assert' +import jar from 'js-cookie' +import { Group, User } from '..' +import { LocalStorage, StoreType, Storage } from '../../storage' +import { + disableCookies, + disableLocalStorage, +} from '../../storage/__tests__/test-helpers' +import { MemoryStorage } from '../../storage/memoryStorage' function clear(): void { document.cookie.split(';').forEach(function (c) { @@ -23,10 +21,11 @@ let store: LocalStorage beforeEach(function () { store = new LocalStorage() clear() + // Restore any cookie, localstorage disable + jest.restoreAllMocks() + jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. }) -jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. - describe('user', () => { const cookieKey = User.defaults.cookie.key const localStorageKey = User.defaults.localStorage.key @@ -76,7 +75,7 @@ describe('user', () => { describe('when cookies are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() user = new User() clear() @@ -140,8 +139,8 @@ describe('user', () => { describe('when cookies and localStorage are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) + disableCookies() + disableLocalStorage() user = new User() clear() @@ -161,10 +160,6 @@ describe('user', () => { assert(user.id() === 'id') }) - it('should be null by default', () => { - assert(user.id() === null) - }) - it('should not reset anonymousId if the user didnt have previous id', () => { const prev = user.anonymousId() user.id('foo') @@ -308,7 +303,7 @@ describe('user', () => { describe('when cookies are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() user = new User() }) @@ -336,8 +331,8 @@ describe('user', () => { describe('when cookies and localStorage are disabled', () => { beforeEach(() => { - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() + disableLocalStorage() user = new User() }) @@ -504,40 +499,6 @@ describe('user', () => { }) }) - describe('#options', () => { - it('should have default cookie options', () => { - const cookie = new Cookie() - expect(cookie['options'].domain).toBe(undefined) - expect(cookie['options'].maxage).toBe(365) - expect(cookie['options'].path).toBe('/') - expect(cookie['options'].sameSite).toBe('Lax') - expect(cookie['options'].secure).toBe(undefined) - }) - - it('should set options properly', () => { - const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' }) - expect(cookie['options'].domain).toBe('foo') - expect(cookie['options'].secure).toBe(true) - expect(cookie['options'].path).toBe('/test') - expect(cookie['options'].secure).toBe(true) - }) - - it('should pass options when creating cookie', () => { - const jarSpy = jest.spyOn(jar, 'set') - const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' }) - - cookie.set('foo', 'bar') - - expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', { - domain: 'foo', - expires: 365, - path: '/test', - sameSite: 'Lax', - secure: true, - }) - }) - }) - describe('#save', () => { let user: User @@ -762,6 +723,79 @@ describe('user', () => { ) }) }) + + describe('storage', () => { + it('allows custom storage priority', () => { + const expected = 'CookieValue' + // Set a cookie first + jar.set('ajs_anonymous_id', expected) + store.set('ajs_anonymous_id', 'localStorageValue') + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }) + expect(user.anonymousId()).toEqual(expected) + }) + + it('custom storage priority respects availability', () => { + const expected = 'localStorageValue' + // Set a cookie first + jar.set('ajs_anonymous_id', 'CookieValue') + disableCookies() + store.set('ajs_anonymous_id', expected) + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }) + expect(user.anonymousId()).toEqual(expected) + }) + + it('persist option overrides any custom storage', () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + persist: false, + }) + user.id('id') + + expect(user.id()).toBe('id') + expect(jar.get('ajs_user_id')).toBeFalsy() + expect(store.get('ajs_user_id')).toBeFalsy() + expect(setCookieSpy.mock.calls.length).toBe(0) + }) + + it('disable option overrides any custom storage', () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + disable: true, + }) + user.id('id') + + expect(user.id()).toBe(null) + expect(jar.get('ajs_user_id')).toBeFalsy() + expect(store.get('ajs_user_id')).toBeFalsy() + expect(setCookieSpy.mock.calls.length).toBe(0) + }) + + it('can use a fully custom storage object', () => { + const customStore: Storage = { + get type() { + return 'something' + }, + get available() { + return true + }, + get: jest.fn().mockReturnValue('custom'), + set: jest.fn(), + clear: jest.fn(), + getAndSync: jest.fn().mockReturnValue('custom'), + } + + const user = new User({ storage: customStore }) + user.id('id') + expect(customStore.set).toHaveBeenCalled() + expect(user.id()).toBe('custom') + }) + }) }) describe('group', () => { @@ -872,64 +906,6 @@ describe('group', () => { }) }) -describe('store', function () { - describe('#get', function () { - it('should return null if localStorage throws an error (or does not exist)', function () { - const getItemSpy = jest - .spyOn(global.Storage.prototype, 'getItem') - .mockImplementationOnce(() => { - throw new Error('getItem fail.') - }) - store.set('foo', 'some value') - expect(store.get('foo')).toBeNull() - expect(getItemSpy).toBeCalledTimes(1) - }) - - it('should not get an empty record', function () { - expect(store.get('abc')).toBe(null) - }) - - it('should get an existing record', function () { - store.set('x', { a: 'b' }) - store.set('a', 'hello world') - store.set('b', '') - store.set('c', false) - store.set('d', null) - store.set('e', undefined) - - expect(store.get('x')).toStrictEqual({ a: 'b' }) - expect(store.get('a')).toBe('hello world') - expect(store.get('b')).toBe('') - expect(store.get('c')).toBe(false) - expect(store.get('d')).toBe(null) - expect(store.get('e')).toBe('undefined') - }) - }) - - describe('#set', function () { - it('should be able to set a record', function () { - store.set('x', { a: 'b' }) - expect(store.get('x')).toStrictEqual({ a: 'b' }) - }) - - it('should catch localStorage quota exceeded errors', () => { - const val = 'x'.repeat(10 * 1024 * 1024) - store.set('foo', val) - - expect(store.get('foo')).toBe(null) - }) - }) - - describe('#remove', function () { - it('should be able to remove a record', function () { - store.set('x', { a: 'b' }) - expect(store.get('x')).toStrictEqual({ a: 'b' }) - store.remove('x') - expect(store.get('x')).toBe(null) - }) - }) -}) - describe('Custom cookie params', () => { it('allows for overriding keys', () => { const customUser = new User( @@ -947,157 +923,3 @@ describe('Custom cookie params', () => { expect(customUser.traits()).toEqual({ trait: true }) }) }) - -describe('universal storage', function () { - const defaultTargets = ['cookie', 'localStorage', 'memory'] as StoreType[] - const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '') - beforeEach(function () { - clear() - }) - - describe('#get', function () { - it('picks data from cookies first', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('🍊') - }) - - it('picks data from localStorage if there is no cookie target', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - ['localStorage', 'memory'], - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('ðŸ’ū') - }) - - it('get data from memory', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage(['memory'], getAvailableStorageOptions()) - expect(us.get('ajs_test_key')).toBeNull() - }) - - it('order of default targets matters!', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - ['cookie', 'localStorage', 'memory'], - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('🍊') - }) - - it('returns null if there are no storage targets', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage([], getAvailableStorageOptions()) - expect(us.get('ajs_test_key')).toBeNull() - }) - - it('can override the default targets', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðŸ’ū') - expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðŸ’ū') - expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('🍊') - expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('🍊') - expect(us.get('ajs_test_key', ['cookie'])).toEqual('🍊') - expect(us.get('ajs_test_key', ['memory'])).toEqual(null) - }) - }) - - describe('#set', function () { - it('set the data in all storage types', function () { - const us = new UniversalStorage<{ ajs_test_key: string }>( - defaultTargets, - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual('💰') - expect(getFromLS('ajs_test_key')).toEqual('💰') - }) - - it('skip saving data to localStorage', function () { - const us = new UniversalStorage( - ['cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual('💰') - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - }) - - it('skip saving data to cookie', function () { - const us = new UniversalStorage( - ['localStorage', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('💰') - }) - - it('can save and retrieve from memory when there is no other storage', function () { - const us = new UniversalStorage(['memory'], getAvailableStorageOptions()) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - expect(us.get('ajs_test_key')).toEqual('💰') - }) - - it('does not write to cookies when cookies are not available', function () { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) - const us = new UniversalStorage( - ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('💰') - expect(us.get('ajs_test_key')).toEqual('💰') - }) - - it('does not write to LS when LS is not available', function () { - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) - const us = new UniversalStorage( - ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual('💰') - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - expect(us.get('ajs_test_key')).toEqual('💰') - }) - - it('can override the default targets', function () { - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰', ['localStorage']) - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('💰') - expect(us.get('ajs_test_key')).toEqual('💰') - - us.set('ajs_test_key_2', 'ðŸĶī', ['cookie']) - expect(jar.get('ajs_test_key_2')).toEqual('ðŸĶī') - expect(localStorage.getItem('ajs_test_key_2')).toEqual(null) - expect(us.get('ajs_test_key_2')).toEqual('ðŸĶī') - - us.set('ajs_test_key_3', 'ðŸ‘ŧ', []) - expect(jar.get('ajs_test_key_3')).toEqual(undefined) - expect(localStorage.getItem('ajs_test_key_3')).toEqual(null) - expect(us.get('ajs_test_key_3')).toEqual(null) - }) - }) -}) diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts index a778ecd21..f62607dec 100644 --- a/packages/browser/src/core/user/index.ts +++ b/packages/browser/src/core/user/index.ts @@ -1,8 +1,20 @@ import { v4 as uuid } from '@lukeed/uuid' -import jar from 'js-cookie' -import { Traits } from '../events' -import { tld } from './tld' import autoBind from '../../lib/bind-all' +import { Traits } from '../events' +import { + CookieOptions, + UniversalStorage, + Storage, + StorageObject, + StorageSettings, + StoreType, + applyCookieOptions, + initializeStorages, + isArrayOfStoreType, + isStorageObject, +} from '../storage' +import { MemoryStorage } from '../storage/memoryStorage' +import {} from '../storage/settings' export type ID = string | null | undefined @@ -22,6 +34,12 @@ export interface UserOptions { localStorage?: { key: string } + + /** + * Storage system to use + * @example new MemoryStorage, [StoreType.Cookie, StoreType.Memory] + */ + storage?: StorageSettings } const defaults = { @@ -35,290 +53,6 @@ const defaults = { }, } -export type StoreType = 'cookie' | 'localStorage' | 'memory' - -type StorageObject = Record - -class Store { - private cache: Record = {} - - get(key: string): T | null { - return this.cache[key] as T | null - } - - set(key: string, value: T | null): void { - this.cache[key] = value - } - - remove(key: string): void { - delete this.cache[key] - } - get type(): StoreType { - return 'memory' - } -} - -const ONE_YEAR = 365 - -export class Cookie extends Store { - static available(): boolean { - let cookieEnabled = window.navigator.cookieEnabled - - if (!cookieEnabled) { - jar.set('ajs:cookies', 'test') - cookieEnabled = document.cookie.includes('ajs:cookies') - jar.remove('ajs:cookies') - } - - return cookieEnabled - } - - static get defaults(): CookieOptions { - return { - maxage: ONE_YEAR, - domain: tld(window.location.href), - path: '/', - sameSite: 'Lax', - } - } - - private options: Required - - constructor(options: CookieOptions = Cookie.defaults) { - super() - this.options = { - ...Cookie.defaults, - ...options, - } as Required - } - - private opts(): jar.CookieAttributes { - return { - sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'], - expires: this.options.maxage, - domain: this.options.domain, - path: this.options.path, - secure: this.options.secure, - } - } - - get(key: string): T | null { - try { - const value = jar.get(key) - - if (!value) { - return null - } - - try { - return JSON.parse(value) - } catch (e) { - return value as unknown as T - } - } catch (e) { - return null - } - } - - set(key: string, value: T): void { - if (typeof value === 'string') { - jar.set(key, value, this.opts()) - } else if (value === null) { - jar.remove(key, this.opts()) - } else { - jar.set(key, JSON.stringify(value), this.opts()) - } - } - - remove(key: string): void { - return jar.remove(key, this.opts()) - } - - get type(): StoreType { - return 'cookie' - } -} - -const localStorageWarning = (key: string, state: 'full' | 'unavailable') => { - console.warn(`Unable to access ${key}, localStorage may be ${state}`) -} - -export class LocalStorage extends Store { - static available(): boolean { - const test = 'test' - try { - localStorage.setItem(test, test) - localStorage.removeItem(test) - return true - } catch (e) { - return false - } - } - - get(key: string): T | null { - try { - const val = localStorage.getItem(key) - if (val === null) { - return null - } - try { - return JSON.parse(val) - } catch (e) { - return val as any as T - } - } catch (err) { - localStorageWarning(key, 'unavailable') - return null - } - } - - set(key: string, value: T): void { - try { - localStorage.setItem(key, JSON.stringify(value)) - } catch { - localStorageWarning(key, 'full') - } - } - - remove(key: string): void { - try { - return localStorage.removeItem(key) - } catch (err) { - localStorageWarning(key, 'unavailable') - } - } - - get type(): StoreType { - return 'localStorage' - } -} - -export interface CookieOptions { - maxage?: number - domain?: string - path?: string - secure?: boolean - sameSite?: string -} - -export class UniversalStorage { - private enabledStores: StoreType[] - private storageOptions: StorageOptions - - constructor(stores: StoreType[], storageOptions: StorageOptions) { - this.storageOptions = storageOptions - this.enabledStores = stores - } - - private getStores(storeTypes: StoreType[] | undefined): Store[] { - const stores: Store[] = [] - this.enabledStores - .filter((i) => !storeTypes || storeTypes?.includes(i)) - .forEach((storeType) => { - const storage = this.storageOptions[storeType] - if (storage !== undefined) { - stores.push(storage) - } - }) - - return stores - } - - /* - This is to support few scenarios where: - - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them - - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format - */ - - /** - * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores - * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0 - * @param key key for the value to be retrieved - * @param storeTypes optional array of store types to be used for performing get and sync - * @returns value for the key or null if not found - */ - public getAndSync( - key: K, - storeTypes?: StoreType[] - ): Data[K] | null { - const val = this.get(key, storeTypes) - - // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) - const coercedValue = (typeof val === 'number' ? val.toString() : val) as - | Data[K] - | null - - this.set(key, coercedValue, storeTypes) - - return coercedValue - } - - /** - * get value for the key from the stores. it will return the first value found in the stores - * @param key key for the value to be retrieved - * @param storeTypes optional array of store types to be used for retrieving the value - * @returns value for the key or null if not found - */ - public get( - key: K, - storeTypes?: StoreType[] - ): Data[K] | null { - let val = null - - for (const store of this.getStores(storeTypes)) { - val = store.get(key) - if (val) { - return val - } - } - return null - } - - /** - * it will set the value for the key in all the stores - * @param key key for the value to be stored - * @param value value to be stored - * @param storeTypes optional array of store types to be used for storing the value - * @returns value that was stored - */ - public set( - key: K, - value: Data[K] | null, - storeTypes?: StoreType[] - ): void { - for (const store of this.getStores(storeTypes)) { - store.set(key, value) - } - } - - /** - * remove the value for the key from all the stores - * @param key key for the value to be removed - * @param storeTypes optional array of store types to be used for removing the value - */ - public clear(key: K, storeTypes?: StoreType[]): void { - for (const store of this.getStores(storeTypes)) { - store.remove(key) - } - } -} - -type StorageOptions = { - cookie: Cookie | undefined - localStorage: LocalStorage | undefined - memory: Store -} - -export function getAvailableStorageOptions( - cookieOptions?: CookieOptions -): StorageOptions { - return { - cookie: Cookie.available() ? new Cookie(cookieOptions) : undefined, - localStorage: LocalStorage.available() ? new LocalStorage() : undefined, - memory: new Store(), - } -} - export class User { static defaults = defaults @@ -327,7 +61,7 @@ export class User { private anonKey: string private cookieOptions?: CookieOptions - private legacyUserStore: UniversalStorage<{ + private legacyUserStore: Storage<{ [k: string]: | { id?: string @@ -335,58 +69,38 @@ export class User { } | string }> - private traitsStore: UniversalStorage<{ + private traitsStore: Storage<{ [k: string]: Traits }> - private identityStore: UniversalStorage<{ + private identityStore: Storage<{ [k: string]: string }> options: UserOptions = {} constructor(options: UserOptions = defaults, cookieOptions?: CookieOptions) { - this.options = options + this.options = { ...defaults, ...options } this.cookieOptions = cookieOptions this.idKey = options.cookie?.key ?? defaults.cookie.key this.traitsKey = options.localStorage?.key ?? defaults.localStorage.key this.anonKey = 'ajs_anonymous_id' - const isDisabled = options.disable === true - const shouldPersist = options.persist !== false - - let defaultStorageTargets: StoreType[] = isDisabled - ? [] - : shouldPersist - ? ['localStorage', 'cookie', 'memory'] - : ['memory'] - - const storageOptions = getAvailableStorageOptions(cookieOptions) - - if (options.localStorageFallbackDisabled) { - defaultStorageTargets = defaultStorageTargets.filter( - (t) => t !== 'localStorage' - ) - } - - this.identityStore = new UniversalStorage( - defaultStorageTargets, - storageOptions - ) + this.identityStore = this.createStorage(this.options, cookieOptions) // using only cookies for legacy user store - this.legacyUserStore = new UniversalStorage( - defaultStorageTargets.filter( - (t) => t !== 'localStorage' && t !== 'memory' - ), - storageOptions + this.legacyUserStore = this.createStorage( + this.options, + cookieOptions, + (s) => s === StoreType.Cookie ) // using only localStorage / memory for traits store - this.traitsStore = new UniversalStorage( - defaultStorageTargets.filter((t) => t !== 'cookie'), - storageOptions + this.traitsStore = this.createStorage( + this.options, + cookieOptions, + (s) => s !== StoreType.Cookie ) const legacyUser = this.legacyUserStore.get(defaults.cookie.oldKey) @@ -510,6 +224,59 @@ export class User { save(): boolean { return true } + + /** + * Creates the right storage system applying all the user options, cookie options and particular filters + * @param options UserOptions + * @param cookieOpts CookieOptions + * @param filterStores filter function to apply to any StoreTypes (skipped if options specify using a custom storage) + * @returns a Storage object + */ + private createStorage( + options: UserOptions, + cookieOpts?: CookieOptions, + filterStores?: (value: StoreType) => boolean + ): Storage { + let stores: StoreType[] = [ + StoreType.LocalStorage, + StoreType.Cookie, + StoreType.Memory, + ] + + // If disabled we won't have any storage functionality + if (options.disable) { + return new UniversalStorage([]) + } + + // If persistance is disabled we will always fallback to Memory Storage + if (!options.persist) { + return new MemoryStorage() + } + + if (options.storage !== undefined && options.storage !== null) { + // If the user is sending its own storage implementation we will use that without any modifications + if (isStorageObject(options.storage)) { + return options.storage as Storage + } else if (isArrayOfStoreType(options.storage)) { + // If the user only specified order of stores we will still apply filters and transformations e.g. not using localStorage if localStorageFallbackDisabled + stores = options.storage + } + } + + // Disable LocalStorage + if (options.localStorageFallbackDisabled) { + stores = stores.filter((s) => s !== StoreType.LocalStorage) + } + + // Apply Additional filters + if (filterStores) { + stores = stores.filter(filterStores) + } + + return new UniversalStorage( + initializeStorages(applyCookieOptions(stores, cookieOpts)) + ) + } } const groupDefaults: UserOptions = { @@ -524,7 +291,7 @@ const groupDefaults: UserOptions = { export class Group extends User { constructor(options: UserOptions = groupDefaults, cookie?: CookieOptions) { - super(options, cookie) + super({ ...groupDefaults, ...options }, cookie) autoBind(this) } diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts index eb1b6ceed..288aa27de 100644 --- a/packages/browser/src/plugins/segmentio/normalize.ts +++ b/packages/browser/src/plugins/segmentio/normalize.ts @@ -7,7 +7,7 @@ import { tld } from '../../core/user/tld' import { SegmentFacade } from '../../lib/to-facade' import { SegmentioSettings } from './index' import { version } from '../../generated/version' -import { getAvailableStorageOptions, UniversalStorage } from '../../core/user' +import { CookieStorage, UniversalStorage } from '../../core/storage' let cookieOptions: jar.CookieAttributes | undefined function getCookieOptions(): jar.CookieAttributes { @@ -97,10 +97,7 @@ function referrerId( ): void { const storage = new UniversalStorage<{ 's:context.referrer': Ad - }>( - disablePersistance ? [] : ['cookie'], - getAvailableStorageOptions(getCookieOptions()) - ) + }>(disablePersistance ? [] : [new CookieStorage(getCookieOptions())]) const stored = storage.get('s:context.referrer') let ad: Ad | undefined | null = ads(query)