diff --git a/.changeset/tall-ligers-admire.md b/.changeset/tall-ligers-admire.md new file mode 100644 index 000000000..86475075b --- /dev/null +++ b/.changeset/tall-ligers-admire.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': minor +--- + +Creating universal storage layer and passing it to plugins diff --git a/packages/browser/package.json b/packages/browser/package.json index 99195b09f..d22f94993 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -43,7 +43,7 @@ "size-limit": [ { "path": "dist/umd/index.js", - "limit": "27.3 KB" + "limit": "28.0 KB" } ], "dependencies": { diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts index e05e68419..ccb366981 100644 --- a/packages/browser/src/browser/__tests__/integration.test.ts +++ b/packages/browser/src/browser/__tests__/integration.test.ts @@ -15,9 +15,12 @@ import * as SegmentPlugin from '../../plugins/segmentio' import jar from 'js-cookie' import { PriorityQueue } from '../../lib/priority-queue' import { getCDN, setGlobalCDNUrl } from '../../lib/parse-cdn' +import { UniversalStorage } from '../../core/user' import { clearAjsBrowserStorage } from '../../test-helpers/browser-storage' import { ActionDestination } from '@/plugins/remote-loader' +const storage = {} as UniversalStorage + let fetchCalls: Array[] = [] jest.mock('unfetch', () => { @@ -879,7 +882,8 @@ describe('retries', () => { throw new Error('aaay') }, }, - ajs + ajs, + storage ) // Dispatching an event will push it into the priority queue. @@ -907,7 +911,8 @@ describe('retries', () => { ready: () => Promise.resolve(true), track: (ctx) => ctx, }, - ajs + ajs, + storage ) // @ts-ignore ignore reassining function diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 150e74180..273ce0189 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -24,7 +24,15 @@ import { } from '../events' import { Plugin } from '../plugin' import { EventQueue } from '../queue/event-queue' -import { CookieOptions, Group, ID, User, UserOptions } from '../user' +import { + CookieOptions, + getAvailableStorageOptions, + Group, + ID, + UniversalStorage, + User, + UserOptions, +} from '../user' import autoBind from '../../lib/bind-all' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' import type { LegacyDestination } from '../../plugins/ajs-destination' @@ -102,6 +110,9 @@ export class Analytics private _group: Group private eventFactory: EventFactory private _debug = false + private _universalStorage: UniversalStorage<{ + [k: string]: unknown + }> initialized = false integrations: Integrations @@ -122,6 +133,14 @@ export class Analytics this.settings.timeout = this.settings.timeout ?? 300 this.queue = queue ?? createDefaultQueue(options?.retryQueue, disablePersistance) + + this._universalStorage = new UniversalStorage( + disablePersistance !== false + ? ['localStorage', 'cookie', 'memory'] + : ['memory'], + getAvailableStorageOptions(cookieOptions) + ) + this._user = user ?? new User( @@ -304,7 +323,7 @@ export class Analytics const ctx = Context.system() const registrations = plugins.map((xt) => - this.queue.register(ctx, xt, this) + this.queue.register(ctx, xt, this, this._universalStorage) ) await Promise.all(registrations) diff --git a/packages/browser/src/core/plugin/index.ts b/packages/browser/src/core/plugin/index.ts index 578ef256b..3dd455c6b 100644 --- a/packages/browser/src/core/plugin/index.ts +++ b/packages/browser/src/core/plugin/index.ts @@ -1,10 +1,12 @@ import { Analytics } from '../analytics' import { Context } from '../context' +import { UniversalStorage } from '../user' interface PluginConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any - options: any - priority: 'critical' | 'non-critical' // whether AJS should expect this plugin to be loaded before starting event delivery + options?: any + priority?: 'critical' | 'non-critical' // whether AJS should expect this plugin to be loaded before starting event delivery + storage?: UniversalStorage } // enrichment - modifies the event. Enrichment can happen in parallel, by reducing all changes in the final event. Failures in this stage could halt event delivery. diff --git a/packages/browser/src/core/queue/__tests__/event-queue.test.ts b/packages/browser/src/core/queue/__tests__/event-queue.test.ts index 8e024a190..2fb6f19b5 100644 --- a/packages/browser/src/core/queue/__tests__/event-queue.test.ts +++ b/packages/browser/src/core/queue/__tests__/event-queue.test.ts @@ -12,6 +12,9 @@ import { Plugin } from '../../plugin' import { EventQueue } from '../event-queue' import { pTimeout } from '../../callback' import { ActionDestination } from '../../../plugins/remote-loader' +import { UniversalStorage } from '../../user' + +const storage = {} as UniversalStorage async function flushAll(eq: EventQueue): Promise { const flushSpy = jest.spyOn(eq, 'flush') @@ -149,7 +152,8 @@ describe('Flushing', () => { return Promise.resolve(ctx) }, }, - ajs + ajs, + storage ) eq.dispatch(fruitBasket) @@ -219,7 +223,8 @@ describe('Flushing', () => { return Promise.resolve(ctx) }, }, - ajs + ajs, + storage ) eq.dispatch(fruitBasket) @@ -257,7 +262,8 @@ describe('Flushing', () => { return ctx }, }, - ajs + ajs, + storage ) const dispatches = [ @@ -294,7 +300,8 @@ describe('Flushing', () => { return ctx }, }, - ajs + ajs, + storage ) const context = await eq.dispatchSingle(fruitBasket) @@ -321,7 +328,8 @@ describe('Flushing', () => { return Promise.resolve(ctx) }, }, - ajs + ajs, + storage ) eq.dispatch(fruitBasket) @@ -362,7 +370,8 @@ describe('Flushing', () => { return Promise.resolve(ctx) }, }, - ajs + ajs, + storage ) const fruitBasketDelivery = eq.dispatch(fruitBasket) @@ -429,9 +438,9 @@ describe('Flushing', () => { const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), mixPanel, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) eq.dispatch(ctx) @@ -462,9 +471,9 @@ describe('Flushing', () => { const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), mixPanel, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) eq.dispatch(ctx) @@ -496,9 +505,9 @@ describe('Flushing', () => { const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), mixPanel, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) eq.dispatch(ctx) @@ -530,9 +539,9 @@ describe('Flushing', () => { const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), mixPanel, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) eq.dispatch(ctx) @@ -563,9 +572,9 @@ describe('Flushing', () => { const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), mixPanel, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) eq.dispatch(ctx) @@ -598,8 +607,8 @@ describe('Flushing', () => { const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) eq.dispatch(ctx) @@ -632,8 +641,8 @@ describe('Flushing', () => { const ctx = new Context(evt) - await eq.register(Context.system(), fullstory, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), fullstory, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) eq.dispatch(ctx) @@ -663,9 +672,9 @@ describe('Flushing', () => { } const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), mixPanel, ajs, storage) + await eq.register(Context.system(), segmentio, ajs, storage) await eq.dispatch(ctx) const skipAmplitudeAndSegment: MiddlewareFunction = ({ @@ -684,7 +693,8 @@ describe('Flushing', () => { await eq.register( Context.system(), sourceMiddlewarePlugin(skipAmplitudeAndSegment, {}), - ajs + ajs, + storage ) await eq.dispatch(ctx) @@ -702,7 +712,9 @@ describe('deregister', () => { const toBeRemoved = { ...testPlugin, name: 'remove-me' } const plugins = [testPlugin, toBeRemoved] - const promises = plugins.map((p) => eq.register(Context.system(), p, ajs)) + const promises = plugins.map((p) => + eq.register(Context.system(), p, ajs, storage) + ) await Promise.all(promises) await eq.deregister(Context.system(), toBeRemoved, ajs) @@ -715,7 +727,9 @@ describe('deregister', () => { const toBeRemoved = { ...testPlugin, name: 'remove-me', unload: jest.fn() } const plugins = [testPlugin, toBeRemoved] - const promises = plugins.map((p) => eq.register(Context.system(), p, ajs)) + const promises = plugins.map((p) => + eq.register(Context.system(), p, ajs, storage) + ) await Promise.all(promises) await eq.deregister(Context.system(), toBeRemoved, ajs) @@ -778,7 +792,8 @@ describe('dispatchSingle', () => { return Promise.resolve(ctx) }, }, - ajs + ajs, + storage ) expect(eq.queue.length).toBe(0) diff --git a/packages/browser/src/core/queue/__tests__/extension-flushing.test.ts b/packages/browser/src/core/queue/__tests__/extension-flushing.test.ts index bae1ddf73..1dd7bbed1 100644 --- a/packages/browser/src/core/queue/__tests__/extension-flushing.test.ts +++ b/packages/browser/src/core/queue/__tests__/extension-flushing.test.ts @@ -4,6 +4,7 @@ import { PriorityQueue } from '../../../lib/priority-queue' import { Context } from '../../context' import { Plugin } from '../../plugin' import { EventQueue } from '../event-queue' +import { UniversalStorage } from '../../user' const fruitBasket = new Context({ type: 'track', @@ -24,6 +25,7 @@ const testPlugin: Plugin = { } const ajs = {} as Analytics +const storage = {} as UniversalStorage describe('Registration', () => { test('can register plugins', async () => { @@ -39,9 +41,9 @@ describe('Registration', () => { } const ctx = Context.system() - await eq.register(ctx, plugin, ajs) + await eq.register(ctx, plugin, ajs, storage) - expect(load).toHaveBeenCalledWith(ctx, ajs) + expect(load).toHaveBeenCalledWith(ctx, ajs, { storage }) }) test('fails if plugin cant be loaded', async () => { @@ -57,7 +59,7 @@ describe('Registration', () => { const ctx = Context.system() await expect( - eq.register(ctx, plugin, ajs) + eq.register(ctx, plugin, ajs, storage) ).rejects.toThrowErrorMatchingInlineSnapshot(`"ðŸ‘ŧ"`) }) @@ -73,7 +75,7 @@ describe('Registration', () => { } const ctx = Context.system() - await eq.register(ctx, plugin, ajs) + await eq.register(ctx, plugin, ajs, storage) expect(ctx.logs()[0].level).toEqual('warn') expect(ctx.logs()[0].message).toEqual('Failed to load destination') @@ -93,7 +95,8 @@ describe('Plugin flushing', () => { ...testPlugin, type: 'before', }, - ajs + ajs, + storage ) const flushed = await eq.dispatch(fruitBasket) @@ -109,7 +112,8 @@ describe('Plugin flushing', () => { throw new Error('aaay') }, }, - ajs + ajs, + storage ) const failedFlush: Context = await eq @@ -137,7 +141,8 @@ describe('Plugin flushing', () => { throw new Error('aaay') }, }, - ajs + ajs, + storage ) const flushed = await eq.dispatch( @@ -168,8 +173,8 @@ describe('Plugin flushing', () => { type: 'destination', } - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), fullstory, ajs) + await eq.register(Context.system(), amplitude, ajs, storage) + await eq.register(Context.system(), fullstory, ajs, storage) const flushed = await eq.dispatch( new Context({ @@ -234,8 +239,8 @@ describe('Plugin flushing', () => { type: 'after', } - await eq.register(Context.system(), afterFailed, ajs) - await eq.register(Context.system(), after, ajs) + await eq.register(Context.system(), afterFailed, ajs, storage) + await eq.register(Context.system(), after, ajs, storage) const flushed = await eq.dispatch( new Context({ @@ -295,7 +300,8 @@ describe('Plugin flushing', () => { return ctx }, }, - ajs + ajs, + storage ) await eq.register( @@ -309,7 +315,8 @@ describe('Plugin flushing', () => { return ctx }, }, - ajs + ajs, + storage ) await eq.register( @@ -323,7 +330,8 @@ describe('Plugin flushing', () => { return ctx }, }, - ajs + ajs, + storage ) const flushed = await eq.dispatch( @@ -392,7 +400,7 @@ describe('Plugin flushing', () => { // shuffle plugins so we can verify order const plugins = shuffle([before, enrichment, enrichmentTwo, destination]) for (const xt of plugins) { - await eq.register(Context.system(), xt, ajs) + await eq.register(Context.system(), xt, ajs, storage) } await eq.dispatch( diff --git a/packages/browser/src/core/queue/event-queue.ts b/packages/browser/src/core/queue/event-queue.ts index b5e16d6c4..d9e1d2935 100644 --- a/packages/browser/src/core/queue/event-queue.ts +++ b/packages/browser/src/core/queue/event-queue.ts @@ -10,6 +10,7 @@ import { Plugin } from '../plugin' import { createTaskGroup, TaskGroup } from '../task/task-group' import { attempt, ensure } from './delivery' import { inspectorHost } from '../inspector' +import { UniversalStorage } from '../user' type PluginsByType = { before: Plugin[] @@ -43,9 +44,10 @@ export class EventQueue extends Emitter { async register( ctx: Context, plugin: Plugin, - instance: Analytics + instance: Analytics, + storage: UniversalStorage ): Promise { - await Promise.resolve(plugin.load(ctx, instance)) + await Promise.resolve(plugin.load(ctx, instance, { storage })) .then(() => { this.plugins.push(plugin) }) diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts index 0e044b790..1f39c438c 100644 --- a/packages/browser/src/core/user/__tests__/index.test.ts +++ b/packages/browser/src/core/user/__tests__/index.test.ts @@ -1,4 +1,12 @@ -import { User, LocalStorage, Cookie, Group } from '..' +import { + User, + LocalStorage, + Cookie, + Group, + UniversalStorage, + StoreType, + getAvailableStorageOptions, +} from '..' import jar from 'js-cookie' import assert from 'assert' @@ -734,10 +742,22 @@ describe('user', () => { it('load should preserve the original User cookie options', () => { user = new User(undefined, { - domain: 'foo', - }).load() - // @ts-ignore - we are testing the private properties here - expect(user.cookies['options'].domain).toEqual('foo') + domain: 'foo.com', + }) + const setCookieSpy = jest.spyOn(jar, 'set') + user.load().anonymousId('anon-id') + + expect(setCookieSpy).toHaveBeenLastCalledWith( + 'ajs_anonymous_id', + 'anon-id', + { + domain: 'foo.com', + expires: 365, + path: '/', + sameSite: 'Lax', + secure: undefined, + } + ) }) }) }) @@ -925,3 +945,157 @@ 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 c8c4e1cd3..799f2a96a 100644 --- a/packages/browser/src/core/user/index.ts +++ b/packages/browser/src/core/user/index.ts @@ -35,6 +35,10 @@ const defaults = { }, } +export type StoreType = 'cookie' | 'localStorage' | 'memory' + +type StorageObject = Record + class Store { private cache: Record = {} @@ -50,6 +54,9 @@ class Store { remove(key: string): void { delete this.cache[key] } + get type(): StoreType { + return 'memory' + } } const ONE_YEAR = 365 @@ -128,12 +135,10 @@ export class Cookie extends Store { remove(key: string): void { return jar.remove(key, this.opts()) } -} -class NullStorage extends Store { - get = (_key: string): null => null - set = (_key: string, _val: unknown): null => null - remove = (_key: string): void => {} + get type(): StoreType { + return 'cookie' + } } const localStorageWarning = (key: string, state: 'full' | 'unavailable') => { @@ -186,6 +191,10 @@ export class LocalStorage extends Store { localStorageWarning(key, 'unavailable') } } + + get type(): StoreType { + return 'localStorage' + } } export interface CookieOptions { @@ -196,18 +205,121 @@ export interface CookieOptions { 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 + */ + public getAndSync( + key: K, + storeTypes?: StoreType[] + ): Data[K] | null { + const val = this.get(key, storeTypes) + + return this.set( + key, + //@ts-ignore TODO: legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) + typeof val === 'number' ? val.toString() : val, + storeTypes + ) as Data[K] | null + } + + 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 + } + + public set( + key: K, + value: Data[K] | null, + storeTypes?: StoreType[] + ): Data[K] | null { + for (const store of this.getStores(storeTypes)) { + store.set(key, value) + } + return 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 - private cookies: Store - private localStorage: Store - private mem: Store - private idKey: string private traitsKey: string private anonKey: string private cookieOptions?: CookieOptions + private legacyUserStore: UniversalStorage<{ + [k: string]: + | { + id?: string + traits?: Traits + } + | string + }> + private traitsStore: UniversalStorage<{ + [k: string]: Traits + }> + + private identityStore: UniversalStorage<{ + [k: string]: string + }> + options: UserOptions = {} constructor(options: UserOptions = defaults, cookieOptions?: CookieOptions) { @@ -221,66 +333,56 @@ export class User { const isDisabled = options.disable === true const shouldPersist = options.persist !== false - this.localStorage = - isDisabled || - options.localStorageFallbackDisabled || - !shouldPersist || - !LocalStorage.available() - ? new NullStorage() - : new LocalStorage() + let defaultStorageTargets: StoreType[] = isDisabled + ? [] + : shouldPersist + ? ['localStorage', 'cookie', 'memory'] + : ['memory'] - this.cookies = - !isDisabled && shouldPersist && Cookie.available() - ? new Cookie(cookieOptions) - : new NullStorage() + const storageOptions = getAvailableStorageOptions(cookieOptions) + + if (options.localStorageFallbackDisabled) { + defaultStorageTargets = defaultStorageTargets.filter( + (t) => t !== 'localStorage' + ) + } - this.mem = isDisabled ? new NullStorage() : new Store() + this.identityStore = new UniversalStorage( + defaultStorageTargets, + storageOptions + ) + + // using only cookies for legacy user store + this.legacyUserStore = new UniversalStorage( + defaultStorageTargets.filter( + (t) => t !== 'localStorage' && t !== 'memory' + ), + storageOptions + ) - const legacyUser = this.cookies.get<{ id?: string; traits?: Traits }>( - defaults.cookie.oldKey + // using only localStorage / memory for traits store + this.traitsStore = new UniversalStorage( + defaultStorageTargets.filter((t) => t !== 'cookie'), + storageOptions ) - if (legacyUser) { + + const legacyUser = this.legacyUserStore.get(defaults.cookie.oldKey) + if (legacyUser && typeof legacyUser === 'object') { legacyUser.id && this.id(legacyUser.id) legacyUser.traits && this.traits(legacyUser.traits) } autoBind(this) } - private chainGet(key: string): T | null { - const val = - this.localStorage.get(key) ?? - this.cookies.get(key) ?? - this.mem.get(key) ?? - null - - return this.trySet( - key, - typeof val === 'number' ? val.toString() : val - ) as T | null - } - - private trySet(key: string, value: T): T | null { - this.localStorage.set(key, value) - this.cookies.set(key, value) - this.mem.set(key, value) - return value - } - - private chainClear(key: string): void { - this.localStorage.remove(key) - this.cookies.remove(key) - this.mem.remove(key) - } - id = (id?: ID): ID => { if (this.options.disable) { return null } - const prevId = this.chainGet(this.idKey) + const prevId = this.identityStore.getAndSync(this.idKey) if (id !== undefined) { - this.trySet(this.idKey, id) + this.identityStore.set(this.idKey, id) const changingIdentity = id !== prevId && prevId !== null && id !== null if (changingIdentity) { @@ -288,15 +390,15 @@ export class User { } } - return ( - this.chainGet(this.idKey) ?? - this.cookies.get(defaults.cookie.oldKey) ?? - null - ) + const retId = this.identityStore.getAndSync(this.idKey) + if (retId) return retId + + const retLeg = this.legacyUserStore.get(defaults.cookie.oldKey) + return retLeg ? (typeof retLeg === 'object' ? retLeg.id : retLeg) : null } private legacySIO(): [string, string] | null { - const val = this.cookies.get('_sio') as string + const val = this.legacyUserStore.get('_sio') as string if (!val) { return null } @@ -310,7 +412,8 @@ export class User { } if (id === undefined) { - const val = this.chainGet(this.anonKey) ?? this.legacySIO()?.[0] + const val = + this.identityStore.getAndSync(this.anonKey) ?? this.legacySIO()?.[0] if (val) { return val @@ -318,12 +421,12 @@ export class User { } if (id === null) { - this.trySet(this.anonKey, null) - return this.chainGet(this.anonKey) + this.identityStore.set(this.anonKey, null) + return this.identityStore.getAndSync(this.anonKey) } - this.trySet(this.anonKey, id ?? uuid()) - return this.chainGet(this.anonKey) + this.identityStore.set(this.anonKey, id ?? uuid()) + return this.identityStore.getAndSync(this.anonKey) } traits = (traits?: Traits | null): Traits | undefined => { @@ -336,15 +439,10 @@ export class User { } if (traits) { - this.mem.set(this.traitsKey, traits ?? {}) - this.localStorage.set(this.traitsKey, traits ?? {}) + this.traitsStore.set(this.traitsKey, traits ?? {}) } - return ( - this.localStorage.get(this.traitsKey) ?? - this.mem.get(this.traitsKey) ?? - {} - ) + return this.traitsStore.get(this.traitsKey) ?? {} } identify(id?: ID, traits?: Traits): void { @@ -377,9 +475,9 @@ export class User { reset(): void { this.logout() - this.chainClear(this.idKey) - this.chainClear(this.anonKey) - this.chainClear(this.traitsKey) + this.identityStore.clear(this.idKey) + this.identityStore.clear(this.anonKey) + this.traitsStore.clear(this.traitsKey) } load(): User { diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts index 93b9bfad9..b913c723b 100644 --- a/packages/browser/src/plugins/segmentio/normalize.ts +++ b/packages/browser/src/plugins/segmentio/normalize.ts @@ -7,6 +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' let cookieOptions: jar.CookieAttributes | undefined function getCookieOptions(): jar.CookieAttributes { @@ -94,11 +95,17 @@ function referrerId( ctx: SegmentEvent['context'], disablePersistance: boolean ): void { - let stored = jar.get('s:context.referrer') - let ad = ads(query) + const storage = new UniversalStorage<{ + 's:context.referrer': Ad + }>( + disablePersistance ? [] : ['cookie'], + getAvailableStorageOptions(getCookieOptions()) + ) - stored = stored ? JSON.parse(stored) : undefined - ad = ad ?? (stored as Ad | undefined) + const stored = storage.get('s:context.referrer') + let ad: Ad | undefined | null = ads(query) + + ad = ad ?? stored if (!ad) { return @@ -108,9 +115,7 @@ function referrerId( ctx.referrer = { ...ctx.referrer, ...ad } } - if (!disablePersistance) { - jar.set('s:context.referrer', JSON.stringify(ad), getCookieOptions()) - } + storage.set('s:context.referrer', ad) } export function normalize( diff --git a/packages/browser/src/plugins/validation/index.ts b/packages/browser/src/plugins/validation/index.ts index ae46e6e7f..1a123ecf9 100644 --- a/packages/browser/src/plugins/validation/index.ts +++ b/packages/browser/src/plugins/validation/index.ts @@ -14,9 +14,7 @@ export function isFunction(obj: unknown): obj is Function { return typeof obj === 'function' } -export function isPlainObject( - obj: unknown -): obj is Record { +export function isPlainObject(obj: unknown): obj is Record { return ( Object.prototype.toString.call(obj).slice(8, -1).toLowerCase() === 'object' ) diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json index 14e0c3e37..9820d2908 100644 --- a/packages/browser/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -11,7 +11,8 @@ "baseUrl": "./src", "paths": { "@/*": ["./*"] - } + }, + "keyofStringsOnly": true }, "exclude": ["node_modules", "dist"] }