Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions packages/sdk/browser/__tests__/BrowserClient.storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common';

import { makeClient } from '../src/BrowserClient';

const mockPlatformConstructor = jest.fn();

jest.mock('../src/platform/BrowserPlatform', () => ({
__esModule: true,
default: jest.fn().mockImplementation((logger, options, storage) => {
mockPlatformConstructor(logger, options, storage);
// eslint-disable-next-line global-require
const { makeBasicPlatform } = require('./BrowserClient.mocks');
const platform = makeBasicPlatform(options);
// Surface the storage the client wired in so we can assert on it, while
// keeping a working mock platform for the rest of construction.
if (storage) {
platform.storage = storage;
}
return platform;
}),
}));

let logger: LDLogger;

beforeEach(() => {
jest.clearAllMocks();
logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
});

function getWiredStorage() {
return mockPlatformConstructor.mock.calls[0][2];
}

it('wraps and passes a custom storage through to the platform', async () => {
const myStorage = {
get: jest.fn(async () => 'cached'),
set: jest.fn(async () => {}),
clear: jest.fn(async () => {}),
};

makeClient(
'client-side-id',
{ key: 'user-key', kind: 'user' },
AutoEnvAttributes.Disabled,
{ streaming: false, logger, diagnosticOptOut: true, storage: myStorage },
);

const wired = getWiredStorage();
expect(wired).toBeDefined();

// The wired storage delegates to the user's implementation.
await expect(wired.get('k')).resolves.toEqual('cached');
await wired.set('k', 'v');
await wired.clear('k');
expect(myStorage.get).toHaveBeenCalledWith('k');
expect(myStorage.set).toHaveBeenCalledWith('k', 'v');
expect(myStorage.clear).toHaveBeenCalledWith('k');
expect(logger.warn).not.toHaveBeenCalled();
});

it('does not pass a storage override when none is configured', () => {
makeClient(
'client-side-id',
{ key: 'user-key', kind: 'user' },
AutoEnvAttributes.Disabled,
{ streaming: false, logger, diagnosticOptOut: true },
);

expect(getWiredStorage()).toBeUndefined();
});

it('does not let a throwing custom storage escape the wrapper', async () => {
const throwingStorage = {
get: jest.fn(async () => {
throw new Error('boom');
}),
set: jest.fn(async () => {
throw new Error('boom');
}),
clear: jest.fn(async () => {
throw new Error('boom');
}),
};

makeClient(
'client-side-id',
{ key: 'user-key', kind: 'user' },
AutoEnvAttributes.Disabled,
{ streaming: false, logger, diagnosticOptOut: true, storage: throwingStorage },
);

const wired = getWiredStorage();
await expect(wired.get('k')).resolves.toBeNull();
await expect(wired.set('k', 'v')).resolves.toBeUndefined();
await expect(wired.clear('k')).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledTimes(3);
});
15 changes: 14 additions & 1 deletion packages/sdk/browser/__tests__/options.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { jest } from '@jest/globals';

import { LDLogger } from '@launchdarkly/js-client-sdk-common';
import { LDLogger, LDStorage } from '@launchdarkly/js-client-sdk-common';

import validateBrowserOptions, { filterToBaseOptionsWithDefaults } from '../src/options';

Expand All @@ -16,10 +16,23 @@ beforeEach(() => {
});

it('logs no warnings when all configuration is valid', () => {
const storage: LDStorage = {
get(_key: string): Promise<string | null> {
throw new Error('Function not implemented.');
},
set(_key: string, _value: string): Promise<void> {
throw new Error('Function not implemented.');
},
clear(_key: string): Promise<void> {
throw new Error('Function not implemented.');
},
};

validateBrowserOptions(
{
fetchGoals: true,
eventUrlTransformer: (url: string) => url,
storage,
},
logger,
);
Expand Down
63 changes: 63 additions & 0 deletions packages/sdk/browser/__tests__/platform/BrowserPlatform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common';

import BrowserPlatform from '../../src/platform/BrowserPlatform';
import LocalStorage, { isLocalStorageSupported } from '../../src/platform/LocalStorage';

jest.mock('../../src/platform/LocalStorage', () => {
const actual = jest.requireActual('../../src/platform/LocalStorage');
return {
__esModule: true,
default: jest.fn(),
isLocalStorageSupported: jest.fn(() => true),
getAllStorageKeys: actual.getAllStorageKeys,
};
});

const mockLocalStorage = LocalStorage as jest.MockedClass<typeof LocalStorage>;
const mockIsSupported = isLocalStorageSupported as jest.MockedFunction<typeof isLocalStorageSupported>;

let logger: LDLogger;

beforeEach(() => {
jest.clearAllMocks();
mockIsSupported.mockReturnValue(true);
logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
});

function makeCustomStorage(): Storage {
return { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
}

it('uses the provided storage override and does not construct LocalStorage', () => {
const custom = makeCustomStorage();
const platform = new BrowserPlatform(logger, {}, custom);

expect(platform.storage).toBe(custom);
expect(mockLocalStorage).not.toHaveBeenCalled();
});

it('falls back to LocalStorage when no override is provided and localStorage is supported', () => {
const platform = new BrowserPlatform(logger, {});

expect(mockLocalStorage).toHaveBeenCalledTimes(1);
expect(platform.storage).toBe(mockLocalStorage.mock.instances[0]);
});

it('has no storage when no override is provided and localStorage is unsupported', () => {
mockIsSupported.mockReturnValue(false);

const platform = new BrowserPlatform(logger, {});

expect(platform.storage).toBeUndefined();
expect(mockLocalStorage).not.toHaveBeenCalled();
});

it('uses the provided storage override even when localStorage is unsupported', () => {
mockIsSupported.mockReturnValue(false);
const custom = makeCustomStorage();

const platform = new BrowserPlatform(logger, {}, custom);

expect(platform.storage).toBe(custom);
expect(mockLocalStorage).not.toHaveBeenCalled();
});
6 changes: 5 additions & 1 deletion packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Configuration,
createDefaultSourceFactoryProvider,
createFDv2DataManagerBase,
createSafeStorage,
FDv2ConnectionMode,
FlagManager,
Hook,
Expand Down Expand Up @@ -71,9 +72,12 @@ class BrowserClientImpl extends LDClientImpl {
// TODO: Use the already-configured baseUri from the SDK config. SDK-560
const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com';

const platform = overridePlatform ?? new BrowserPlatform(logger, options);
// Only the browser-specific options are in validatedBrowserOptions.
const validatedBrowserOptions = validateBrowserOptions(options, logger);
const safeStorage = validatedBrowserOptions.storage
? createSafeStorage(validatedBrowserOptions.storage, logger)
: undefined;
const platform = overridePlatform ?? new BrowserPlatform(logger, options, safeStorage);
// The base options are in baseOptionsWithDefaults.
const baseOptionsWithDefaults = filterToBaseOptionsWithDefaults({ ...options, logger });
const { eventUrlTransformer } = validatedBrowserOptions;
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/browser/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type {
LDInspection,
LDLogger,
LDLogLevel,
LDStorage,
LDMultiKindContext,
LDSingleKindContext,
TrackSeriesContext,
Expand Down
21 changes: 21 additions & 0 deletions packages/sdk/browser/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
LDClientDataSystemOptions,
LDLogger,
LDOptions as LDOptionsBase,
LDStorage,
ManualModeSwitching,
OptionMessages,
TypeValidator,
Expand Down Expand Up @@ -96,6 +97,23 @@ export interface BrowserOptions extends Omit<LDOptionsBase, 'initialConnectionMo
* A list of plugins to be used with the SDK.
*/
plugins?: LDPlugin[];

/**
* Custom storage implementation.
*
* Storage is used for caching flag values for a context as well as persisting
* generated identifiers.
*
* By default the browser SDK uses `window.localStorage`.
*
* @remarks
* If there is an issue with the storage implementation, then generated keys
* and context caches may not be persisted. This will cause the SDK to generate
* new keys and context caches on every startup. Implementations should not
* throw; if one does, the SDK logs the error and degrades gracefully rather
* than crashing the application.
*/
storage?: LDStorage;
}

export interface ValidatedOptions {
Expand All @@ -104,20 +122,23 @@ export interface ValidatedOptions {
streaming?: boolean;
automaticBackgroundHandling?: boolean;
plugins: LDPlugin[];
storage?: LDStorage;
}

const optDefaults = {
fetchGoals: true,
eventUrlTransformer: (url: string) => url,
streaming: undefined,
plugins: [],
storage: undefined,
};

const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = {
fetchGoals: TypeValidators.Boolean,
eventUrlTransformer: TypeValidators.Function,
streaming: TypeValidators.Boolean,
plugins: TypeValidators.createTypeArray('LDPlugin', {}),
storage: TypeValidators.Object,
};

function withBrowserDefaults(opts: BrowserOptions): BrowserOptions {
Expand Down
6 changes: 2 additions & 4 deletions packages/sdk/browser/src/platform/BrowserPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ export default class BrowserPlatform implements Platform {
requests: Requests = new BrowserRequests();
storage?: Storage;

constructor(logger: LDLogger, options: BrowserOptions) {
if (isLocalStorageSupported()) {
this.storage = new LocalStorage(logger);
}
constructor(logger: LDLogger, options: BrowserOptions, storage?: Storage) {
this.storage = storage ?? (isLocalStorageSupported() ? new LocalStorage(logger) : undefined);
this.info = new BrowserInfo(options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { LDStorage } from '../../src/client';
import type { LDReactClientOptions } from '../../src/client/LDOptions';

it('accepts a custom storage override on the react client options', () => {
const storage: LDStorage = {
get: async () => null,
set: async () => {},
clear: async () => {},
};

const options: LDReactClientOptions = { storage };

expect(options.storage).toBe(storage);
});
Loading
Loading