From cf3131ada28b4f383c460d281f0656be458ce84e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:13:42 -0700 Subject: [PATCH 01/50] WIP on goals. --- .../goals/GoalTracker.matchesUrl.test.ts | 115 ++++++++++++++++++ .../__tests__/goals/GoalTracker.test.ts | 0 .../__tests__/platform/LocalStorage.test.ts | 4 +- packages/sdk/browser/jest.config.js | 11 +- packages/sdk/browser/package.json | 6 +- packages/sdk/browser/src/goals/GoalManager.ts | 61 ++++++++++ packages/sdk/browser/src/goals/GoalTracker.ts | 103 ++++++++++++++++ packages/sdk/browser/src/goals/Goals.ts | 44 +++++++ .../sdk/browser/src/goals/LocationWatcher.ts | 59 +++++++++ .../src/internal/events/InputClickEvent.ts | 11 ++ .../common/src/internal/events/InputEvent.ts | 10 +- .../src/internal/events/InputPageViewEvent.ts | 10 ++ 12 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts create mode 100644 packages/sdk/browser/__tests__/goals/GoalTracker.test.ts create mode 100644 packages/sdk/browser/src/goals/GoalManager.ts create mode 100644 packages/sdk/browser/src/goals/GoalTracker.ts create mode 100644 packages/sdk/browser/src/goals/Goals.ts create mode 100644 packages/sdk/browser/src/goals/LocationWatcher.ts create mode 100644 packages/shared/common/src/internal/events/InputClickEvent.ts create mode 100644 packages/shared/common/src/internal/events/InputPageViewEvent.ts diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts new file mode 100644 index 0000000000..a26b433599 --- /dev/null +++ b/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts @@ -0,0 +1,115 @@ +import { Matcher } from '../../src/goals/Goals'; +import { matchesUrl } from '../../src/goals/GoalTracker'; + +it.each([ + ['https://example.com', '', '', 'https://example.com'], + [ + 'https://example.com?potato=true#hash', + '?potato=true', + '#hash', + 'https://example.com?potato=true#hash', + ], +])('returns true for exact match with "exact" matcher kind', (href, query, hash, matcherUrl) => { + const matcher: Matcher = { kind: 'exact', url: matcherUrl }; + const result = matchesUrl(matcher, href, query, hash); + expect(result).toBe(true); +}); + +it.each([ + ['https://example.com/potato', '', '', 'https://example.com'], + [ + 'https://example.com?potato=true#hash', + '?potato=true', + '#hash', + 'https://example.com?potato=true#brown', + ], +])('returns false for non-matching "exact" matcher kind', (href, query, hash, matcherUrl) => { + const matcher: Matcher = { kind: 'exact', url: 'https://example.com' }; + const result = matchesUrl(matcher, 'https://different.com', '', ''); + expect(result).toBe(false); +}); + +it('returns true for canonical match with "canonical" matcher kind', () => { + // For this type of match the hash and query parameters are not included. + const matcher: Matcher = { kind: 'canonical', url: 'https://example.com/some-path' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(true); +}); + +it('returns true for substring match with "substring" matcher kind', () => { + const matcher: Matcher = { kind: 'substring', substring: 'example' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(true); +}); + +it('returns false for non-matching substring with "substring" matcher kind', () => { + const matcher: Matcher = { kind: 'substring', substring: 'nonexistent' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(false); +}); + +it('returns true for regex match with "regex" matcher kind', () => { + const matcher: Matcher = { kind: 'regex', pattern: 'example\\.com' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(true); +}); + +it('returns false for non-matching regex with "regex" matcher kind', () => { + const matcher: Matcher = { kind: 'regex', pattern: 'nonexistent\\.com' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(false); +}); + +it('includes the hash for "path-like" hashes for "substring" matchers', () => { + const matcher: Matcher = { kind: 'substring', substring: 'example' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#/hash/path', + '?query=1', + '#/hash/path', + ); + expect(result).toBe(true); +}); + +it('includes the hash for "path-like" hashes for "regex" matchers', () => { + const matcher: Matcher = { kind: 'regex', pattern: 'hash' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#/hash/path', + '?query=1', + '#/hash/path', + ); + expect(result).toBe(true); +}); + +it('returns false for unsupported matcher kind', () => { + // @ts-expect-error + const matcher: Matcher = { kind: 'unsupported' }; + const result = matchesUrl(matcher, 'https://example.com', '', ''); + expect(result).toBe(false); +}); diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts b/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts index 477b348f09..df51c2e2dc 100644 --- a/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts +++ b/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts @@ -1,3 +1,5 @@ +import { jest } from '@jest/globals'; + import LocalStorage from '../../src/platform/LocalStorage'; it('can set values', async () => { @@ -16,7 +18,7 @@ it('can set values', async () => { expect(spy).toHaveBeenCalledWith('test-key', 'test-value'); expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); +expect(logger.info).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); }); diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 364918be3a..6414e210bc 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -1,12 +1,11 @@ export default { - preset: 'ts-jest', + extensionsToTreatAsEsm: ['.ts'], + verbose: true, + preset: 'ts-jest/presets/default-esm', testEnvironment: 'jest-environment-jsdom', transform: { - "^.+\\.tsx?$": "ts-jest" - // process `*.tsx` files with `ts-jest` - }, - moduleNameMapper: { - '\\.(gif|ttf|eot|svg|png)$': '/test/__ mocks __/fileMock.js', + '^.+\\.tsx?$': ['ts-jest', { useESM: true }] }, + testPathIgnorePatterns: ['./dist'] } diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 3f6f646b60..367528e2b4 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -30,14 +30,16 @@ "build": "rollup -c rollup.config.js", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", - "test": "jest", + "test": "NODE_OPTIONS=--experimental-vm-modules npx jest", "coverage": "yarn test --coverage", "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.7.0" + "@launchdarkly/js-client-sdk-common": "1.7.0", + "escape-string-regexp": "^5.0.0" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@launchdarkly/private-js-mocks": "0.0.1", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts new file mode 100644 index 0000000000..f4be77224b --- /dev/null +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -0,0 +1,61 @@ +import { + LDLogger, + LDUnexpectedResponseError, + Requests, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import { Goal } from './Goals'; +import GoalTracker from './GoalTracker'; +import { DefaultLocationWatcher, LocationWatcher } from './LocationWatcher'; + +export default class GoalManager { + private goals?: Goal[] = []; + private url: string; + private watcher?: LocationWatcher; + private tracker?: GoalTracker; + + constructor( + private readonly requests: Requests, + private readonly serviceEndpoints: ServiceEndpoints, + private readonly reportError: (err: Error) => void, + private readonly reportGoal: (url: string, goal: Goal) => void, + private readonly logger?: LDLogger, + + locationWatcherFactory: (cb: () => void) => LocationWatcher = (cb) => + new DefaultLocationWatcher(cb), + ) { + // TODO: Generate URL in a better way. + this.url = `${this.serviceEndpoints}/sdk/goals/${Credential}`; + + this.watcher = locationWatcherFactory(() => { + this.createTracker(); + }); + } + + public async initialize(): Promise { + await this.fetchGoals(); + this.createTracker(); + } + + private createTracker() { + if (this.goals && this.goals.length) { + this.tracker = new GoalTracker(this.goals, (goal) => { + this.reportGoal(window.location.href, goal); + }); + } + } + + private async fetchGoals(): Promise { + try { + const res = await this.requests.fetch(this.url); + this.goals = await res.json(); + } catch (err) { + this.reportError(new LDUnexpectedResponseError(`Encountered error fetching goals: ${err}`)); + } + } + + close(): void { + this.watcher?.close(); + } +} diff --git a/packages/sdk/browser/src/goals/GoalTracker.ts b/packages/sdk/browser/src/goals/GoalTracker.ts new file mode 100644 index 0000000000..c22a25ac13 --- /dev/null +++ b/packages/sdk/browser/src/goals/GoalTracker.ts @@ -0,0 +1,103 @@ +import escapeStringRegexp from 'escape-string-regexp'; + +import { ClickGoal, Goal, Matcher } from './Goals'; + +type EventHandler = (goal: Goal) => void; + +export function matchesUrl(matcher: Matcher, href: string, search: string, hash: string) { + /** + * Hash fragments are included when they include forward slashes to allow for applications that + * use path-like hashes. (http://example.com/url/path#/additional/path) + * + * When they do not include a forward slash they are considered anchors and are not included + * in matching. + */ + const keepHash = (matcher.kind === 'substring' || matcher.kind === 'regex') && hash.includes('/'); + // For most matching purposes we want the "canonical" URL, which in this context means the + // excluding the query parameters and hash (unless the hash is path-like). + const canonicalUrl = (keepHash ? href : href.replace(hash, '')).replace(search, ''); + + switch (matcher.kind) { + case 'exact': + return new RegExp(`^${escapeStringRegexp(matcher.url)}/?$`).test(href); + case 'canonical': + return new RegExp(`^${escapeStringRegexp(matcher.url)}/?$`).test(canonicalUrl); + case 'substring': + return new RegExp(`.*${escapeStringRegexp(matcher.substring)}.*$`).test(canonicalUrl); + case 'regex': + return new RegExp(matcher.pattern).test(canonicalUrl); + default: + return false; + } +} + +function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) { + const matches: ClickGoal[] = []; + + clickGoals.forEach((goal) => { + let target: Node | null = event.target as Node; + const { selector } = goal; + const elements = document.querySelectorAll(selector); + + // Traverse from the target of the event up the page hierarchy. + // If there are no element that match the selector, then no need to check anything. + while (target && elements.length) { + // The elements are a NodeList, so it doesn't have the array functions. For performance we + // do not convert it to an array. + for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) { + if (target === elements[elementIndex]) { + matches.push(goal); + // The same element should not be in the list multiple times. + // Multiple objects in the hierarchy can match the selector, so we don't break the outer + // loop. + break; + } + } + target = target.parentNode as Node; + } + }); + + return matches; +} + +/** + * Tracks the goals on an individual "page" (combination of route, query params, and hash). + */ +export default class GoalTracker { + private clickHandler?: (event: Event) => void; + constructor(goals: Goal[], onEvent: EventHandler) { + const pageviewGoals = goals.filter((goal) => goal.kind === 'pageview'); + const clickGoals = goals.filter((goal) => goal.kind === 'click'); + + pageviewGoals.forEach((goal) => { + const urlMatchers = goal.urls ?? []; + urlMatchers.forEach((matcher) => { + if ( + matchesUrl(matcher, window.location.href, window.location.search, window.location.hash) + ) { + onEvent(goal); + } + }); + }); + + if (clickGoals.length) { + // Click handler is not a member function in order to avoid having to bind it for the event + // handler and then track a reference to that bound handler. + this.clickHandler = (event: Event) => { + findGoalsForClick(event, clickGoals).forEach((clickGoal) => { + onEvent(clickGoal); + }); + }; + document.addEventListener('click', this.clickHandler); + } + } + + /** + * Close the tracker which stops listening to any events. + */ + close() { + if (this.clickHandler) { + document.removeEventListener('click', this.clickHandler); + } + } +} diff --git a/packages/sdk/browser/src/goals/Goals.ts b/packages/sdk/browser/src/goals/Goals.ts new file mode 100644 index 0000000000..6b74a43dc2 --- /dev/null +++ b/packages/sdk/browser/src/goals/Goals.ts @@ -0,0 +1,44 @@ +export type GoalKind = 'click' | 'pageview'; + +export type MatcherKind = 'exact' | 'canonical' | 'substring' | 'regex'; + +export interface ExactMatcher { + kind: 'exact'; + url: string; +} + +export interface SubstringMatcher { + kind: 'substring'; + substring: string; +} + +export interface CanonicalMatcher { + kind: 'canonical'; + url: string; +} + +export interface RegexMatcher { + kind: 'regex'; + pattern: string; +} + +export type Matcher = ExactMatcher | SubstringMatcher | CanonicalMatcher | RegexMatcher; + +export interface PageViewGoal { + key: string; + kind: 'pageview'; + urls?: Matcher[]; +} + +export interface ClickGoal { + key: string; + kind: 'click'; + urls?: Matcher[]; + selector: string; +} + +export type Goal = PageViewGoal | ClickGoal; + +export function isClick(goal: Goal): goal is ClickGoal { + return goal.kind === 'click'; +} diff --git a/packages/sdk/browser/src/goals/LocationWatcher.ts b/packages/sdk/browser/src/goals/LocationWatcher.ts new file mode 100644 index 0000000000..a02307ee3b --- /dev/null +++ b/packages/sdk/browser/src/goals/LocationWatcher.ts @@ -0,0 +1,59 @@ +export const LOCATION_WATCHER_INTERVAL = 300; + +// Using any for the timer handle because the type is not the same for all +// platforms and we only need to use it opaquely. +export type IntervalHandle = any; + +export interface LocationWatcher { + close(): void; +} + +/** + * Watches the browser URL and detects changes. + * + * This is used to detect URL changes for generating pageview events. + * + * @internal + */ +export class DefaultLocationWatcher { + private previousLocation?: string; + private watcherHandle: IntervalHandle; + private cleanupListeners?: () => void; + + /** + * @param callback Callback that is executed whenever a URL change is detected. + */ + constructor(callback: () => void) { + const checkUrl = () => { + const currentLocation = window.location.href; + + if (currentLocation !== this.previousLocation) { + this.previousLocation = currentLocation; + callback(); + } + }; + /** The location is watched via polling and popstate events because it is possible to miss + * navigation at certain points with just popstate. It is also to miss events with polling + * because they can happen within the polling interval. + * Details on when popstate is called: + * https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event#when_popstate_is_sent + */ + this.watcherHandle = setInterval(checkUrl, LOCATION_WATCHER_INTERVAL); + + window.addEventListener('popstate', checkUrl); + + this.cleanupListeners = () => { + window.removeEventListener('popstate', checkUrl); + }; + } + + /** + * Stop watching for location changes. + */ + close(): void { + if (this.watcherHandle) { + clearInterval(this.watcherHandle); + } + this.cleanupListeners?.(); + } +} diff --git a/packages/shared/common/src/internal/events/InputClickEvent.ts b/packages/shared/common/src/internal/events/InputClickEvent.ts new file mode 100644 index 0000000000..0d80782100 --- /dev/null +++ b/packages/shared/common/src/internal/events/InputClickEvent.ts @@ -0,0 +1,11 @@ +import Context from '../../Context'; + +export default interface InputIdentifyEvent { + kind: 'click'; + samplingRatio: number; + key: string; + url: string; + creationDate: number; + context: Context; + selector: string; +} diff --git a/packages/shared/common/src/internal/events/InputEvent.ts b/packages/shared/common/src/internal/events/InputEvent.ts index 2ffa15f519..9a1ab7e4c0 100644 --- a/packages/shared/common/src/internal/events/InputEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvent.ts @@ -1,7 +1,15 @@ +import InputClickEvent from './InputClickEvent'; import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; +import InputPageViewEvent from './InputPageViewEvent'; -type InputEvent = InputEvalEvent | InputCustomEvent | InputIdentifyEvent | InputMigrationEvent; +type InputEvent = + | InputEvalEvent + | InputCustomEvent + | InputIdentifyEvent + | InputMigrationEvent + | InputClickEvent + | InputPageViewEvent; export default InputEvent; diff --git a/packages/shared/common/src/internal/events/InputPageViewEvent.ts b/packages/shared/common/src/internal/events/InputPageViewEvent.ts new file mode 100644 index 0000000000..b49981a897 --- /dev/null +++ b/packages/shared/common/src/internal/events/InputPageViewEvent.ts @@ -0,0 +1,10 @@ +import Context from '../../Context'; + +export default interface InputIdentifyEvent { + kind: 'pageview'; + samplingRatio: number; + key: string; + url: string; + creationDate: number; + context: Context; +} From 543cfc9fbdd9999f4dcb91206ea4d8fe4128d3f5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:32:15 -0700 Subject: [PATCH 02/50] Add tests for GoalTracker. --- .../__tests__/goals/GoalTracker.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts index e69de29bb2..336ffb5cc9 100644 --- a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts +++ b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-new */ +// The tracker depends on side effects to test, so we need to disable no-new. +// The URL matching functionality is tested in GoalTracker.urlMatches.test.ts so this file does not +// exhaustively test URL matching. It instead tests the functionality of the tracker. +import { jest } from '@jest/globals'; + +import { Goal } from '../../src/goals/Goals'; +import GoalTracker from '../../src/goals/GoalTracker'; + +let mockOnEvent: jest.Mock; + +beforeEach(() => { + mockOnEvent = jest.fn(); + jest.spyOn(document, 'addEventListener'); + jest.spyOn(document, 'removeEventListener'); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('should trigger pageview goals on initialization', () => { + const goals: Goal[] = [ + { key: 'page1', kind: 'pageview', urls: [{ kind: 'exact', url: 'http://example.com' }] }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + expect(mockOnEvent).toHaveBeenCalledWith(goals[0]); +}); + +it('should not trigger pageview goals for non-matching URLs', () => { + const goals: Goal[] = [ + { key: 'page1', kind: 'pageview', urls: [{ kind: 'exact', url: 'http://example.com' }] }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://other.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + expect(mockOnEvent).not.toHaveBeenCalled(); +}); + +it('should add click event listener for click goals', () => { + const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.button' }]; + + new GoalTracker(goals, mockOnEvent); + + expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); +}); + +it('should not add click event listener if no click goals', () => { + const goals: Goal[] = [ + { key: 'page1', kind: 'pageview', urls: [{ kind: 'exact', url: 'http://example.com' }] }, + ]; + + new GoalTracker(goals, mockOnEvent); + + expect(document.addEventListener).not.toHaveBeenCalled(); +}); + +it('should trigger click goals when matching element is clicked', () => { + const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.button' }]; + + new GoalTracker(goals, mockOnEvent); + + const button = document.createElement('button'); + button.className = 'button'; + document.body.appendChild(button); + button.click(); + + expect(mockOnEvent).toHaveBeenCalledWith(goals[0]); + + document.body.removeChild(button); +}); + +it('should remove click event listener on close', () => { + const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.button' }]; + + const tracker = new GoalTracker(goals, mockOnEvent); + tracker.close(); + + expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); +}); + +it('should trigger the click goal for parent elements which match the selector', () => { + const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.my-selector' }]; + + new GoalTracker(goals, mockOnEvent); + + const parent = document.createElement('div'); + parent.className = 'my-selector'; + document.body.appendChild(parent); + + const button = document.createElement('button'); + button.className = 'my-selector'; + parent.appendChild(button); + + button.click(); + + expect(mockOnEvent).toHaveBeenCalledTimes(2); +}); From 0288923fbddf3e6e7371869f2dc7987968bc2cf5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:16:31 -0700 Subject: [PATCH 03/50] Add goal manager tests. --- .../__tests__/goals/GoalManager.test.ts | 127 ++++++++++++++++++ packages/sdk/browser/src/goals/GoalManager.ts | 7 +- 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/browser/__tests__/goals/GoalManager.test.ts diff --git a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts new file mode 100644 index 0000000000..0475186d8b --- /dev/null +++ b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts @@ -0,0 +1,127 @@ +import { jest } from '@jest/globals'; + +import { + LDUnexpectedResponseError, + Requests, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import GoalManager from '../../src/goals/GoalManager'; +import { Goal } from '../../src/goals/Goals'; +import { LocationWatcher } from '../../src/goals/LocationWatcher'; + +describe('given a GoalManager with mocked dependencies', () => { + let mockRequests: jest.Mocked; + let mockServiceEndpoints: ServiceEndpoints; + let mockReportError: jest.Mock; + let mockReportGoal: jest.Mock; + let mockLocationWatcherFactory: () => { cb?: () => void } & LocationWatcher; + let mockLocationWatcher: { cb?: () => void } & LocationWatcher; + let goalManager: GoalManager; + const mockCredential = 'test-credential'; + + beforeEach(() => { + mockRequests = { fetch: jest.fn() } as any; + mockServiceEndpoints = new ServiceEndpoints('streaming', 'polling', 'events'); + mockReportError = jest.fn(); + mockReportGoal = jest.fn(); + mockLocationWatcher = { close: jest.fn() }; + // @ts-expect-error The type is correct, but TS cannot handle the jest.fn typing + mockLocationWatcherFactory = jest.fn((cb: () => void) => { + mockLocationWatcher.cb = cb; + return mockLocationWatcher; + }); + + goalManager = new GoalManager( + mockCredential, + mockRequests, + mockServiceEndpoints, + mockReportError, + mockReportGoal, + mockLocationWatcherFactory, + ); + }); + + describe('when initializing with successful goal fetch', () => { + const mockGoals: Goal[] = [ + { key: 'goal1', kind: 'click', selector: '#button1' }, + { key: 'goal2', kind: 'click', selector: '#button2' }, + ]; + + beforeEach(() => { + mockRequests.fetch.mockResolvedValue({ + json: () => Promise.resolve(mockGoals), + } as any); + }); + + it('should fetch goals and set up the location watcher', async () => { + await goalManager.initialize(); + + expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential'); + expect(mockLocationWatcherFactory).toHaveBeenCalled(); + }); + }); + + describe('when initializing with a failed goal fetch', () => { + const error = new Error('Fetch failed'); + + beforeEach(() => { + mockRequests.fetch.mockRejectedValue(error); + }); + + it('Then it should report an unexpected response error', async () => { + await goalManager.initialize(); + + expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError)); + }); + }); + + it('should close the watcher and tracker when closed', () => { + goalManager.close(); + + expect(mockLocationWatcher.close).toHaveBeenCalled(); + }); + + describe('goal emission based on URL changes', () => { + const mockGoals: Goal[] = [ + { + key: 'goal1', + kind: 'pageview', + urls: [ + { + kind: 'exact', + url: 'https://example.com/target', + }, + ], + }, + ]; + + beforeEach(async () => { + mockRequests.fetch.mockResolvedValue({ + json: () => Promise.resolve(mockGoals), + } as any); + await goalManager.initialize(); + }); + + it('should not emit a goal on initial load, but emit after URL change', () => { + // Check that no goal was emitted on initial load + expect(mockReportGoal).not.toHaveBeenCalled(); + + // Simulate URL change to match the goal + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/target' }, + writable: true, + }); + + // Trigger the location change callback + mockLocationWatcher.cb?.(); + + // Check that the goal was emitted after URL change + expect(mockReportGoal).toHaveBeenCalledWith('https://example.com/target', { + key: 'goal1', + kind: 'pageview', + urls: [{ kind: 'exact', url: 'https://example.com/target' }], + }); + }); + }); +}); diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts index f4be77224b..f53327411c 100644 --- a/packages/sdk/browser/src/goals/GoalManager.ts +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -1,5 +1,4 @@ import { - LDLogger, LDUnexpectedResponseError, Requests, ServiceEndpoints, @@ -16,17 +15,16 @@ export default class GoalManager { private tracker?: GoalTracker; constructor( + private readonly credential: string, private readonly requests: Requests, private readonly serviceEndpoints: ServiceEndpoints, private readonly reportError: (err: Error) => void, private readonly reportGoal: (url: string, goal: Goal) => void, - private readonly logger?: LDLogger, - locationWatcherFactory: (cb: () => void) => LocationWatcher = (cb) => new DefaultLocationWatcher(cb), ) { // TODO: Generate URL in a better way. - this.url = `${this.serviceEndpoints}/sdk/goals/${Credential}`; + this.url = `${this.serviceEndpoints.polling}/sdk/goals/${credential}`; this.watcher = locationWatcherFactory(() => { this.createTracker(); @@ -57,5 +55,6 @@ export default class GoalManager { close(): void { this.watcher?.close(); + this.tracker?.close(); } } From 56684f7cc03a496a8562dc728b042430bab45333 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:10:51 -0700 Subject: [PATCH 04/50] Add options for goals and fix bugs. --- .../__tests__/goals/GoalManager.test.ts | 10 +- .../__tests__/goals/GoalTracker.test.ts | 103 +++++++++++++++++- .../sdk/browser/__tests__/options.test.ts | 77 +++++++++++++ packages/sdk/browser/src/BrowserClient.ts | 70 +++++++++++- packages/sdk/browser/src/goals/GoalManager.ts | 8 +- packages/sdk/browser/src/goals/GoalTracker.ts | 21 ++-- .../sdk/browser/src/goals/LocationWatcher.ts | 1 + packages/sdk/browser/src/index.ts | 6 +- packages/sdk/browser/src/options.ts | 71 ++++++++++++ .../src/internal/events/EventProcessor.ts | 42 +++++++ .../src/internal/events/InputClickEvent.ts | 2 +- .../src/internal/events/InputPageViewEvent.ts | 2 +- .../shared/sdk-client/src/LDClientImpl.ts | 8 ++ 13 files changed, 387 insertions(+), 34 deletions(-) create mode 100644 packages/sdk/browser/__tests__/options.test.ts create mode 100644 packages/sdk/browser/src/options.ts diff --git a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts index 0475186d8b..98b6e6fe48 100644 --- a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts +++ b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts @@ -1,10 +1,6 @@ import { jest } from '@jest/globals'; -import { - LDUnexpectedResponseError, - Requests, - ServiceEndpoints, -} from '@launchdarkly/js-client-sdk-common'; +import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common'; import GoalManager from '../../src/goals/GoalManager'; import { Goal } from '../../src/goals/Goals'; @@ -12,7 +8,6 @@ import { LocationWatcher } from '../../src/goals/LocationWatcher'; describe('given a GoalManager with mocked dependencies', () => { let mockRequests: jest.Mocked; - let mockServiceEndpoints: ServiceEndpoints; let mockReportError: jest.Mock; let mockReportGoal: jest.Mock; let mockLocationWatcherFactory: () => { cb?: () => void } & LocationWatcher; @@ -22,7 +17,6 @@ describe('given a GoalManager with mocked dependencies', () => { beforeEach(() => { mockRequests = { fetch: jest.fn() } as any; - mockServiceEndpoints = new ServiceEndpoints('streaming', 'polling', 'events'); mockReportError = jest.fn(); mockReportGoal = jest.fn(); mockLocationWatcher = { close: jest.fn() }; @@ -35,7 +29,7 @@ describe('given a GoalManager with mocked dependencies', () => { goalManager = new GoalManager( mockCredential, mockRequests, - mockServiceEndpoints, + 'polling', mockReportError, mockReportGoal, mockLocationWatcherFactory, diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts index 336ffb5cc9..23491abf29 100644 --- a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts +++ b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts @@ -58,7 +58,23 @@ it('should not trigger pageview goals for non-matching URLs', () => { }); it('should add click event listener for click goals', () => { - const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.button' }]; + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); new GoalTracker(goals, mockOnEvent); @@ -76,7 +92,23 @@ it('should not add click event listener if no click goals', () => { }); it('should trigger click goals when matching element is clicked', () => { - const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.button' }]; + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); new GoalTracker(goals, mockOnEvent); @@ -90,8 +122,55 @@ it('should trigger click goals when matching element is clicked', () => { document.body.removeChild(button); }); +it('should not trigger click goals when matching element is clicked but URL does not match', () => { + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://other.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + const button = document.createElement('button'); + button.className = 'button'; + document.body.appendChild(button); + button.click(); + + expect(mockOnEvent).not.toHaveBeenCalled(); + + document.body.removeChild(button); +}); + it('should remove click event listener on close', () => { - const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.button' }]; + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); const tracker = new GoalTracker(goals, mockOnEvent); tracker.close(); @@ -100,7 +179,23 @@ it('should remove click event listener on close', () => { }); it('should trigger the click goal for parent elements which match the selector', () => { - const goals: Goal[] = [{ key: 'click1', kind: 'click', selector: '.my-selector' }]; + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.my-selector', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); new GoalTracker(goals, mockOnEvent); diff --git a/packages/sdk/browser/__tests__/options.test.ts b/packages/sdk/browser/__tests__/options.test.ts new file mode 100644 index 0000000000..bbeee2fde8 --- /dev/null +++ b/packages/sdk/browser/__tests__/options.test.ts @@ -0,0 +1,77 @@ +import { jest } from '@jest/globals'; + +import { LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import validateOptions, { filterToBaseOptions } from '../src/options'; + +let logger: LDLogger; + +beforeEach(() => { + logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +}); + +it('logs no warnings when all configuration is valid', () => { + validateOptions( + { + fetchGoals: true, + eventUrlTransformer: (url: string) => url, + }, + logger, + ); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('warns for invalid configuration', () => { + validateOptions( + { + // @ts-ignore + fetchGoals: 'yes', + // @ts-ignore + eventUrlTransformer: 'not a function', + }, + logger, + ); + + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "fetchGoals" should be of type boolean, got string, using default value', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "eventUrlTransformer" should be of type function, got string, using default value', + ); +}); + +it('applies default options', () => { + const opts = validateOptions({}, logger); + + expect(opts.fetchGoals).toBe(true); + expect(opts.eventUrlTransformer).toBeUndefined(); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('filters to base options', () => { + const opts = { + debug: false, + fetchGoals: true, + eventUrlTransformer: (url: string) => url, + }; + + const baseOpts = filterToBaseOptions(opts); + expect(baseOpts.debug).toBe(false); + expect(Object.keys(baseOpts).length).toEqual(1); + expect(baseOpts).not.toHaveProperty('fetchGoals'); + expect(baseOpts).not.toHaveProperty('eventUrlTransformer'); +}); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 6bb99c8b1d..a625ea2748 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -1,12 +1,17 @@ import { AutoEnvAttributes, base64UrlEncode, + BasicLogger, LDClient as CommonClient, + Context, + internal, LDClientImpl, LDContext, - LDOptions, } from '@launchdarkly/js-client-sdk-common'; +import GoalManager from './goals/GoalManager'; +import { Goal, isClick } from './goals/Goals'; +import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; import BrowserPlatform from './platform/BrowserPlatform'; /** @@ -15,18 +20,77 @@ import BrowserPlatform from './platform/BrowserPlatform'; export type LDClient = Omit; export class BrowserClient extends LDClientImpl { + private readonly goalManager?: GoalManager; constructor( private readonly clientSideId: string, autoEnvAttributes: AutoEnvAttributes, - options: LDOptions = {}, + options: BrowserOptions = {}, ) { - super(clientSideId, autoEnvAttributes, new BrowserPlatform(options), options, { + const { logger: customLogger, debug } = options; + const logger = + customLogger ?? + new BasicLogger({ + level: debug ? 'debug' : 'info', + // eslint-disable-next-line no-console + destination: console.log, + }); + + // TODO: Use the already-configured baseUri from the SDK config. SDK-560 + const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; + + const platform = new BrowserPlatform(options); + const ValidatedBrowserOptions = validateOptions(options, logger); + super(clientSideId, autoEnvAttributes, platform, filterToBaseOptions(options), { analyticsEventPath: `/events/bulk/${clientSideId}`, diagnosticEventPath: `/events/diagnostic/${clientSideId}`, includeAuthorizationHeader: false, highTimeoutThreshold: 5, userAgentHeaderName: 'x-launchdarkly-user-agent', }); + + if (ValidatedBrowserOptions.fetchGoals) { + this.goalManager = new GoalManager( + clientSideId, + platform.requests, + baseUrl, + (err) => { + // TODO: May need to emit. SDK-561 + logger.error(err.message); + }, + (url: string, goal: Goal) => { + const context = this.getInternalContext(); + if (!context) { + return; + } + if (isClick(goal)) { + this.sendEvent({ + kind: 'click', + url, + samplingRatio: 1, + key: goal.key, + creationDate: Date.now(), + context, + selector: goal.selector, + }); + } else { + this.sendEvent({ + kind: 'pageview', + url, + samplingRatio: 1, + key: goal.key, + creationDate: Date.now(), + context, + }); + } + }, + ); + + // This is intentionally not awaited. If we want to add a "goalsready" event, or + // "waitForGoalsReady", then we would make an async immediately invoked function expression + // which emits the event, and assign its promise to a member. The "waitForGoalsReady" function + // would return that promise. + this.goalManager.initialize(); + } } private encodeContext(context: LDContext) { diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts index f53327411c..a2f96a293b 100644 --- a/packages/sdk/browser/src/goals/GoalManager.ts +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -15,16 +15,16 @@ export default class GoalManager { private tracker?: GoalTracker; constructor( - private readonly credential: string, + credential: string, private readonly requests: Requests, - private readonly serviceEndpoints: ServiceEndpoints, + baseUrl: string, private readonly reportError: (err: Error) => void, private readonly reportGoal: (url: string, goal: Goal) => void, locationWatcherFactory: (cb: () => void) => LocationWatcher = (cb) => new DefaultLocationWatcher(cb), ) { // TODO: Generate URL in a better way. - this.url = `${this.serviceEndpoints.polling}/sdk/goals/${credential}`; + this.url = `${baseUrl}/sdk/goals/${credential}`; this.watcher = locationWatcherFactory(() => { this.createTracker(); @@ -37,6 +37,8 @@ export default class GoalManager { } private createTracker() { + console.log('createTracker'); + this.tracker?.close(); if (this.goals && this.goals.length) { this.tracker = new GoalTracker(this.goals, (goal) => { this.reportGoal(window.location.href, goal); diff --git a/packages/sdk/browser/src/goals/GoalTracker.ts b/packages/sdk/browser/src/goals/GoalTracker.ts index c22a25ac13..cac85079c6 100644 --- a/packages/sdk/browser/src/goals/GoalTracker.ts +++ b/packages/sdk/browser/src/goals/GoalTracker.ts @@ -66,19 +66,16 @@ function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) { export default class GoalTracker { private clickHandler?: (event: Event) => void; constructor(goals: Goal[], onEvent: EventHandler) { - const pageviewGoals = goals.filter((goal) => goal.kind === 'pageview'); - const clickGoals = goals.filter((goal) => goal.kind === 'click'); + const goalsMatchingUrl = goals.filter((goal) => + goal.urls?.some((matcher) => + matchesUrl(matcher, window.location.href, window.location.search, window.location.hash), + ), + ); - pageviewGoals.forEach((goal) => { - const urlMatchers = goal.urls ?? []; - urlMatchers.forEach((matcher) => { - if ( - matchesUrl(matcher, window.location.href, window.location.search, window.location.hash) - ) { - onEvent(goal); - } - }); - }); + const pageviewGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'pageview'); + const clickGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'click'); + + pageviewGoals.forEach((event) => onEvent(event)); if (clickGoals.length) { // Click handler is not a member function in order to avoid having to bind it for the event diff --git a/packages/sdk/browser/src/goals/LocationWatcher.ts b/packages/sdk/browser/src/goals/LocationWatcher.ts index a02307ee3b..4343f644e3 100644 --- a/packages/sdk/browser/src/goals/LocationWatcher.ts +++ b/packages/sdk/browser/src/goals/LocationWatcher.ts @@ -24,6 +24,7 @@ export class DefaultLocationWatcher { * @param callback Callback that is executed whenever a URL change is detected. */ constructor(callback: () => void) { + this.previousLocation = window.location.href; const checkUrl = () => { const currentLocation = window.location.href; diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 5e25241ec9..4f8dd2b27c 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -7,17 +7,18 @@ import { LDLogger, LDLogLevel, LDMultiKindContext, - LDOptions, LDSingleKindContext, } from '@launchdarkly/js-client-sdk-common'; +// The exported LDClient and LDOptions are the browser specific implementations. +// These shadow the common implementations. import { BrowserClient, LDClient } from './BrowserClient'; +import { BrowserOptions as LDOptions } from './options'; // TODO: Export and use browser specific options. export { LDClient, AutoEnvAttributes, - LDOptions, LDFlagSet, LDContext, LDContextCommon, @@ -26,6 +27,7 @@ export { LDSingleKindContext, LDLogLevel, LDLogger, + LDOptions, }; export function init( diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts new file mode 100644 index 0000000000..c0d62c549c --- /dev/null +++ b/packages/sdk/browser/src/options.ts @@ -0,0 +1,71 @@ +import { + LDLogger, + LDOptions as LDOptionsBase, + OptionMessages, + TypeValidator, + TypeValidators, +} from '@launchdarkly/js-client-sdk-common'; + +/** + * Initialization options for the LaunchDarkly browser SDK. + */ +export interface BrowserOptions extends LDOptionsBase { + /** + * Whether the client should make a request to LaunchDarkly for Experimentation metrics (goals). + * + * This is true by default, meaning that this request will be made on every page load. + * Set it to false if you are not using Experimentation and want to skip the request. + */ + fetchGoals?: boolean; + + /** + * A function which, if present, can change the URL in analytics events to something other + * than the actual browser URL. It will be called with the current browser URL as a parameter, + * and returns the value that should be stored in the event's `url` property. + */ + eventUrlTransformer?: (url: string) => string; +} + +export interface ValidatedOptions { + fetchGoals: boolean; + eventUrlTransformer?: (url: string) => string; +} + +const optDefaults = { + fetchGoals: true, + eventUrlTransformer: undefined, +}; + +const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { + fetchGoals: TypeValidators.Boolean, + eventUrlTransformer: TypeValidators.Function, +}; + +export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { + const baseOptions: LDOptionsBase = { ...opts }; + + // Remove any browser specific configuration keys so we don't get warnings from + // the base implementation for unknown configuration. + Object.keys(optDefaults).forEach((key) => { + delete (baseOptions as any)[key]; + }); + return baseOptions; +} + +export default function validateOptions(opts: BrowserOptions, logger: LDLogger): ValidatedOptions { + const output: ValidatedOptions = { ...optDefaults }; + + Object.entries(validators).forEach((entry) => { + const [key, validator] = entry as [keyof BrowserOptions, TypeValidator]; + const value = opts[key]; + if (value !== undefined) { + if (validator.is(value)) { + output[key as keyof ValidatedOptions] = value as any; + } else { + logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); + } + } + }); + + return output; +} diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 8743494214..7d28830acc 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -10,9 +10,11 @@ import { DiagnosticsManager } from '../diagnostics'; import EventSender from './EventSender'; import EventSummarizer, { SummarizedFlagsEvent } from './EventSummarizer'; import { isFeature, isIdentify, isMigration } from './guards'; +import InputClickEvent from './InputClickEvent'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; +import InputPageViewEvent from './InputPageViewEvent'; import LDInvalidSDKKeyError from './LDInvalidSDKKeyError'; import shouldSample from './sampling'; @@ -57,6 +59,25 @@ interface IndexInputEvent extends Omit { kind: 'index'; } +interface ClickOutputEvent { + kind: 'click'; + key: string; + url: string; + creationDate: number; + contextKeys: Record; + selector: string; + samplingRatio?: number; +} + +interface PageviewOutputEvent { + kind: 'pageview'; + key: string; + url: string; + creationDate: number; + contextKeys: Record; + samplingRatio?: number; +} + /** * The event processor doesn't need to know anything about the shape of the * diagnostic events. @@ -327,6 +348,27 @@ export default class EventProcessor implements LDEventProcessor { return out; } + case 'click': { + const out: ClickOutputEvent = { + kind: 'click', + creationDate: event.creationDate, + contextKeys: event.context.kindsAndKeys, + key: event.key, + url: event.url, + selector: event.selector, + }; + return out; + } + case 'pageview': { + const out: PageviewOutputEvent = { + kind: 'pageview', + creationDate: event.creationDate, + contextKeys: event.context.kindsAndKeys, + key: event.key, + url: event.url, + }; + return out; + } default: // This would happen during the addition of a new event type to the SDK. return event; diff --git a/packages/shared/common/src/internal/events/InputClickEvent.ts b/packages/shared/common/src/internal/events/InputClickEvent.ts index 0d80782100..a5812176d3 100644 --- a/packages/shared/common/src/internal/events/InputClickEvent.ts +++ b/packages/shared/common/src/internal/events/InputClickEvent.ts @@ -1,6 +1,6 @@ import Context from '../../Context'; -export default interface InputIdentifyEvent { +export default interface InputClickEvent { kind: 'click'; samplingRatio: number; key: string; diff --git a/packages/shared/common/src/internal/events/InputPageViewEvent.ts b/packages/shared/common/src/internal/events/InputPageViewEvent.ts index b49981a897..f015007427 100644 --- a/packages/shared/common/src/internal/events/InputPageViewEvent.ts +++ b/packages/shared/common/src/internal/events/InputPageViewEvent.ts @@ -1,6 +1,6 @@ import Context from '../../Context'; -export default interface InputIdentifyEvent { +export default interface InputPageViewEvent { kind: 'pageview'; samplingRatio: number; key: string; diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index d5baaeb2c6..10a8bb8701 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -213,6 +213,10 @@ export default class LDClientImpl implements LDClient { return this.uncheckedContext ? clone(this.uncheckedContext) : undefined; } + protected getInternalContext(): Context | undefined { + return this.checkedContext; + } + private createStreamListeners( context: Context, identifyResolve: any, @@ -672,4 +676,8 @@ export default class LDClientImpl implements LDClient { this.eventProcessor?.close(); } } + + protected sendEvent(event: internal.InputEvent): void { + this.eventProcessor?.sendEvent(event); + } } From f30141a3f45d7f7efeaefdb6d53a0388a1ad29c8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:27:06 -0700 Subject: [PATCH 05/50] Simplify tests. --- .../__tests__/goals/GoalManager.test.ts | 75 +++++++++---------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts index 98b6e6fe48..819528aa69 100644 --- a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts +++ b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts @@ -36,38 +36,30 @@ describe('given a GoalManager with mocked dependencies', () => { ); }); - describe('when initializing with successful goal fetch', () => { + it('should fetch goals and set up the location watcher', async () => { const mockGoals: Goal[] = [ { key: 'goal1', kind: 'click', selector: '#button1' }, { key: 'goal2', kind: 'click', selector: '#button2' }, ]; - beforeEach(() => { - mockRequests.fetch.mockResolvedValue({ - json: () => Promise.resolve(mockGoals), - } as any); - }); + mockRequests.fetch.mockResolvedValue({ + json: () => Promise.resolve(mockGoals), + } as any); - it('should fetch goals and set up the location watcher', async () => { - await goalManager.initialize(); + await goalManager.initialize(); - expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential'); - expect(mockLocationWatcherFactory).toHaveBeenCalled(); - }); + expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential'); + expect(mockLocationWatcherFactory).toHaveBeenCalled(); }); - describe('when initializing with a failed goal fetch', () => { + it('should handle failed initial fetch by reporting an unexpected response error', async () => { const error = new Error('Fetch failed'); - beforeEach(() => { - mockRequests.fetch.mockRejectedValue(error); - }); + mockRequests.fetch.mockRejectedValue(error); - it('Then it should report an unexpected response error', async () => { - await goalManager.initialize(); + await goalManager.initialize(); - expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError)); - }); + expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError)); }); it('should close the watcher and tracker when closed', () => { @@ -76,7 +68,7 @@ describe('given a GoalManager with mocked dependencies', () => { expect(mockLocationWatcher.close).toHaveBeenCalled(); }); - describe('goal emission based on URL changes', () => { + it('should not emit a goal on initial for a non-matching URL, but should emit after URL change to a matching URL', async () => { const mockGoals: Goal[] = [ { key: 'goal1', @@ -90,32 +82,33 @@ describe('given a GoalManager with mocked dependencies', () => { }, ]; - beforeEach(async () => { - mockRequests.fetch.mockResolvedValue({ - json: () => Promise.resolve(mockGoals), - } as any); - await goalManager.initialize(); + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/not-target' }, + writable: true, }); - it('should not emit a goal on initial load, but emit after URL change', () => { - // Check that no goal was emitted on initial load - expect(mockReportGoal).not.toHaveBeenCalled(); + mockRequests.fetch.mockResolvedValue({ + json: () => Promise.resolve(mockGoals), + } as any); + await goalManager.initialize(); - // Simulate URL change to match the goal - Object.defineProperty(window, 'location', { - value: { href: 'https://example.com/target' }, - writable: true, - }); + // Check that no goal was emitted on initial load + expect(mockReportGoal).not.toHaveBeenCalled(); - // Trigger the location change callback - mockLocationWatcher.cb?.(); + // Simulate URL change to match the goal + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/target' }, + writable: true, + }); - // Check that the goal was emitted after URL change - expect(mockReportGoal).toHaveBeenCalledWith('https://example.com/target', { - key: 'goal1', - kind: 'pageview', - urls: [{ kind: 'exact', url: 'https://example.com/target' }], - }); + // Trigger the location change callback + mockLocationWatcher.cb?.(); + + // Check that the goal was emitted after URL change + expect(mockReportGoal).toHaveBeenCalledWith('https://example.com/target', { + key: 'goal1', + kind: 'pageview', + urls: [{ kind: 'exact', url: 'https://example.com/target' }], }); }); }); From 40f78fe9b02a84b1dabe8c184ce1150a5f6193bd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:37:19 -0700 Subject: [PATCH 06/50] Lint browser package. --- .../browser/__tests__/goals/GoalTracker.matchesUrl.test.ts | 4 ++-- packages/sdk/browser/src/goals/GoalManager.ts | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts index a26b433599..08d18410e8 100644 --- a/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts +++ b/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts @@ -24,8 +24,8 @@ it.each([ 'https://example.com?potato=true#brown', ], ])('returns false for non-matching "exact" matcher kind', (href, query, hash, matcherUrl) => { - const matcher: Matcher = { kind: 'exact', url: 'https://example.com' }; - const result = matchesUrl(matcher, 'https://different.com', '', ''); + const matcher: Matcher = { kind: 'exact', url: matcherUrl }; + const result = matchesUrl(matcher, href, query, hash); expect(result).toBe(false); }); diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts index a2f96a293b..eecd21f920 100644 --- a/packages/sdk/browser/src/goals/GoalManager.ts +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -1,8 +1,4 @@ -import { - LDUnexpectedResponseError, - Requests, - ServiceEndpoints, -} from '@launchdarkly/js-client-sdk-common'; +import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common'; import { Goal } from './Goals'; import GoalTracker from './GoalTracker'; @@ -37,7 +33,6 @@ export default class GoalManager { } private createTracker() { - console.log('createTracker'); this.tracker?.close(); if (this.goals && this.goals.length) { this.tracker = new GoalTracker(this.goals, (goal) => { From 2ff006c86d5f25c70523b867d77d7f664805ba5f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:38:57 -0700 Subject: [PATCH 07/50] Lint --- packages/sdk/browser/jest.config.js | 6 +++--- .../shared/common/src/internal/events/EventProcessor.ts | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 622c8db186..523d4a99d5 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -4,7 +4,7 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jest-environment-jsdom', transform: { - '^.+\\.tsx?$': ['ts-jest', { useESM: true }] + '^.+\\.tsx?$': ['ts-jest', { useESM: true }], }, - testPathIgnorePatterns: ['./dist'] -} + testPathIgnorePatterns: ['./dist'], +}; diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 7d28830acc..ef4a6e490a 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -10,11 +10,9 @@ import { DiagnosticsManager } from '../diagnostics'; import EventSender from './EventSender'; import EventSummarizer, { SummarizedFlagsEvent } from './EventSummarizer'; import { isFeature, isIdentify, isMigration } from './guards'; -import InputClickEvent from './InputClickEvent'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; -import InputPageViewEvent from './InputPageViewEvent'; import LDInvalidSDKKeyError from './LDInvalidSDKKeyError'; import shouldSample from './sampling'; From a11e73d1dfefcc4bbfb455809108fb7a76e3909a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:54:38 -0700 Subject: [PATCH 08/50] Add tests for location watcher. --- .../__tests__/goals/LocationWatcher.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts diff --git a/packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts b/packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts new file mode 100644 index 0000000000..561419d07a --- /dev/null +++ b/packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts @@ -0,0 +1,87 @@ +import { jest } from '@jest/globals'; + +import { DefaultLocationWatcher, LOCATION_WATCHER_INTERVAL } from '../../src/goals/LocationWatcher'; + +let mockCallback: jest.Mock; + +beforeEach(() => { + mockCallback = jest.fn(); + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +it('should call callback when URL changes', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/new-page' }, + writable: true, + }); + jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL); + + expect(mockCallback).toHaveBeenCalledTimes(1); + + watcher.close(); +}); + +it('should not call callback when URL remains the same', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL * 2); + + expect(mockCallback).not.toHaveBeenCalled(); + + watcher.close(); +}); + +it('should call callback on popstate event', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/new-page' }, + writable: true, + }); + window.dispatchEvent(new Event('popstate')); + + expect(mockCallback).toHaveBeenCalledTimes(1); + + watcher.close(); +}); + +it('should stop watching when close is called', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + watcher.close(); + + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/new-page' }, + writable: true, + }); + jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL); + window.dispatchEvent(new Event('popstate')); + + expect(mockCallback).not.toHaveBeenCalled(); +}); From 7067a28691f4020aaebf1691655eaf123d06008d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 18 Sep 2024 13:28:32 -0700 Subject: [PATCH 09/50] WIP: Implement support for event URLs. --- packages/sdk/browser/src/BrowserClient.ts | 14 ++++++++++++++ .../common/src/internal/events/EventProcessor.ts | 5 +++++ .../common/src/internal/events/InputCustomEvent.ts | 2 ++ 3 files changed, 21 insertions(+) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index f248119012..a4ccd548eb 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -5,6 +5,7 @@ import { LDClient as CommonClient, DataSourcePaths, Encoding, + internal, LDClientImpl, LDContext, } from '@launchdarkly/js-client-sdk-common'; @@ -120,4 +121,17 @@ export class BrowserClient extends LDClientImpl { }, }; } + + override track(key: string, data?: any, metricValue?: number): void { + const context = this.getInternalContext(); + if (!context || !context) { + this.logger.warn(internal.ClientMessages.missingContextKeyNoEvent); + return; + } + + this.sendEvent({ + this.eventProcessor?.sendEvent( + this.eventFactoryDefault.customEvent(key, context, data, metricValue), + ); + } } diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index ef4a6e490a..4e6acd128e 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -36,6 +36,7 @@ interface CustomOutputEvent { data?: any; metricValue?: number; samplingRatio?: number; + url?: string; } interface FeatureOutputEvent { @@ -344,6 +345,10 @@ export default class EventProcessor implements LDEventProcessor { out.metricValue = event.metricValue; } + if (event.url !== undefined) { + out.url = event.url; + } + return out; } case 'click': { diff --git a/packages/shared/common/src/internal/events/InputCustomEvent.ts b/packages/shared/common/src/internal/events/InputCustomEvent.ts index 1c0c4a2b39..a8e29364f6 100644 --- a/packages/shared/common/src/internal/events/InputCustomEvent.ts +++ b/packages/shared/common/src/internal/events/InputCustomEvent.ts @@ -13,6 +13,8 @@ export default class InputCustomEvent { // Currently custom events are not sampled, but this is here to make the handling // code more uniform. public readonly samplingRatio: number = 1, + // Browser SDKs can include a URL for custom events. + public readonly url?: string, ) { this.creationDate = Date.now(); this.context = context; From fdf4a437c56446715ef583f57e87180a08f12771 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:51:12 -0700 Subject: [PATCH 10/50] feat: Add URLs for custom events and URL filtering. --- jest.config.js | 2 +- .../browser/__tests__/BrowserClient.test.ts | 272 ++++++++++++++++++ .../__tests__/goals/GoalManager.test.ts | 3 + .../sdk/browser/__tests__/options.test.ts | 2 +- packages/sdk/browser/jest.config.js | 2 +- packages/sdk/browser/package.json | 2 +- packages/sdk/browser/src/BrowserClient.ts | 33 ++- packages/sdk/browser/src/goals/GoalManager.ts | 11 + packages/sdk/browser/src/goals/GoalTracker.ts | 4 +- packages/sdk/browser/src/index.ts | 10 +- packages/sdk/browser/src/options.ts | 4 +- .../sdk/browser/src/platform/BrowserInfo.ts | 6 +- .../browser/src/platform/BrowserPlatform.ts | 6 +- packages/sdk/browser/tsconfig.json | 2 +- packages/sdk/browser/tsconfig.test.json | 14 - .../src/internal/events/LDInternalOptions.ts | 2 + .../shared/sdk-client/src/LDClientImpl.ts | 7 +- .../src/configuration/Configuration.ts | 11 +- packages/shared/sdk-client/src/index.ts | 3 +- 19 files changed, 342 insertions(+), 54 deletions(-) create mode 100644 packages/sdk/browser/__tests__/BrowserClient.test.ts delete mode 100644 packages/sdk/browser/tsconfig.test.json diff --git a/jest.config.js b/jest.config.js index c9b229c3d7..2c937ca53c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ module.exports = { transform: { '^.+\\.ts?$': 'ts-jest' }, testMatch: ['**/__tests__/**/*test.ts?(x)'], testEnvironment: 'node', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node', 'json'], collectCoverageFrom: [ 'packages/sdk/server-node/src/**/*.ts', 'packages/shared/common/src/**/*.ts', diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts new file mode 100644 index 0000000000..b32bbac169 --- /dev/null +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -0,0 +1,272 @@ +import { jest } from '@jest/globals'; + +import { + AutoEnvAttributes, + EventSourceCapabilities, + EventSourceInitDict, + Hasher, + LDLogger, + PlatformData, + Requests, + SdkData, +} from '@launchdarkly/js-client-sdk-common'; + +import { BrowserClient } from '../src/BrowserClient'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + // @ts-ignore + get: jest.fn(), + // @ts-ignore + keys: jest.fn(), + // @ts-ignore + values: jest.fn(), + // @ts-ignore + entries: jest.fn(), + // @ts-ignore + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + // @ts-ignore + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +function makeRequests(): Requests { + return { + // @ts-ignore + fetch: jest.fn((url: string, _options: any) => { + if (url.includes('/sdk/goals/')) { + return mockFetch( + JSON.stringify([ + { + key: 'pageview', + kind: 'pageview', + urls: [{ kind: 'exact', url: 'http://browserclientintegration.com' }], + }, + { + key: 'click', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://browserclientintegration.com' }], + }, + ]), + 200, + )(); + } + return mockFetch('{ "flagA": true }', 200)(); + }), + // @ts-ignore + createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { + throw new Error('Function not implemented.'); + }, + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + }, + }; +} + +class MockHasher implements Hasher { + update(_data: string): Hasher { + return this; + } + digest?(_encoding: string): string { + return 'hashed'; + } + async asyncDigest?(_encoding: string): Promise { + return 'hashed'; + } +} + +describe('given a mock platform for a BrowserClient', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + let platform: any; + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { href: 'http://browserclientintegration.com' }, + writable: true, + }); + jest.useFakeTimers().setSystemTime(new Date('2024-09-19')); + platform = { + requests: makeRequests(), + info: { + platformData(): PlatformData { + return { + name: 'node', + }; + }, + sdkData(): SdkData { + return { + name: 'browser-sdk', + version: '1.0.0', + }; + }, + }, + crypto: { + createHash: () => new MockHasher(), + randomUUID: () => '123', + }, + storage: { + get: async (_key: string) => null, + set: async (_key: string, _value: string) => {}, + clear: async (_key: string) => {}, + }, + encoding: { + btoa: (str: string) => str, + }, + }; + }); + + it('includes urls in custom events', async () => { + const client = new BrowserClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { + initialConnectionMode: 'polling', + logger, + diagnosticOptOut: true, + }, + platform, + ); + await client.identify({ key: 'user-key', kind: 'user' }); + await client.flush(); + client.track('user-key', undefined, 1); + await client.flush(); + + expect(JSON.parse(platform.requests.fetch.mock.calls[3][1].body)[0]).toMatchObject({ + kind: 'custom', + creationDate: 1726704000000, + key: 'user-key', + contextKeys: { + user: 'user-key', + }, + metricValue: 1, + url: 'http://browserclientintegration.com', + }); + }); + + it('can filter URLs in custom events', async () => { + const client = new BrowserClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { + initialConnectionMode: 'polling', + logger, + diagnosticOptOut: true, + eventUrlTransformer: (url: string) => + url.replace('http://browserclientintegration.com', 'http://filtered.org'), + }, + platform, + ); + await client.identify({ key: 'user-key', kind: 'user' }); + await client.flush(); + client.track('user-key', undefined, 1); + await client.flush(); + + const events = JSON.parse(platform.requests.fetch.mock.calls[3][1].body); + const customEvent = events.find((e: any) => e.kind === 'custom'); + + expect(customEvent).toMatchObject({ + kind: 'custom', + creationDate: 1726704000000, + key: 'user-key', + contextKeys: { + user: 'user-key', + }, + metricValue: 1, + url: 'http://filtered.org', + }); + }); + + it('can filter URLs in click events', async () => { + const client = new BrowserClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { + initialConnectionMode: 'polling', + logger, + diagnosticOptOut: true, + eventUrlTransformer: (url: string) => + url.replace('http://browserclientintegration.com', 'http://filtered.org'), + }, + platform, + ); + await client.identify({ key: 'user-key', kind: 'user' }); + await client.flush(); + + // Simulate a click event + const button = document.createElement('button'); + button.className = 'button'; + document.body.appendChild(button); + button.click(); + + while (platform.requests.fetch.mock.calls.length < 4) { + // eslint-disable-next-line no-await-in-loop + await client.flush(); + jest.runAllTicks(); + } + + const events = JSON.parse(platform.requests.fetch.mock.calls[3][1].body); + const clickEvent = events.find((e: any) => e.kind === 'click'); + expect(clickEvent).toMatchObject({ + kind: 'click', + creationDate: 1726704000000, + key: 'click', + contextKeys: { + user: 'user-key', + }, + url: 'http://filtered.org', + }); + + document.body.removeChild(button); + }); + + it('can filter URLs in pageview events', async () => { + const client = new BrowserClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { + initialConnectionMode: 'polling', + logger, + diagnosticOptOut: true, + eventUrlTransformer: (url: string) => + url.replace('http://browserclientintegration.com', 'http://filtered.com'), + }, + platform, + ); + + await client.identify({ key: 'user-key', kind: 'user' }); + await client.flush(); + + const events = JSON.parse(platform.requests.fetch.mock.calls[2][1].body); + const pageviewEvent = events.find((e: any) => e.kind === 'pageview'); + expect(pageviewEvent).toMatchObject({ + kind: 'pageview', + creationDate: 1726704000000, + key: 'pageview', + contextKeys: { + user: 'user-key', + }, + url: 'http://filtered.com', + }); + }); +}); diff --git a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts index 819528aa69..acdd6e5b1f 100644 --- a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts +++ b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts @@ -47,6 +47,7 @@ describe('given a GoalManager with mocked dependencies', () => { } as any); await goalManager.initialize(); + goalManager.startTracking(); expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential'); expect(mockLocationWatcherFactory).toHaveBeenCalled(); @@ -58,6 +59,7 @@ describe('given a GoalManager with mocked dependencies', () => { mockRequests.fetch.mockRejectedValue(error); await goalManager.initialize(); + goalManager.startTracking(); expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError)); }); @@ -91,6 +93,7 @@ describe('given a GoalManager with mocked dependencies', () => { json: () => Promise.resolve(mockGoals), } as any); await goalManager.initialize(); + goalManager.startTracking(); // Check that no goal was emitted on initial load expect(mockReportGoal).not.toHaveBeenCalled(); diff --git a/packages/sdk/browser/__tests__/options.test.ts b/packages/sdk/browser/__tests__/options.test.ts index bbeee2fde8..cb1d84a67f 100644 --- a/packages/sdk/browser/__tests__/options.test.ts +++ b/packages/sdk/browser/__tests__/options.test.ts @@ -54,7 +54,7 @@ it('applies default options', () => { const opts = validateOptions({}, logger); expect(opts.fetchGoals).toBe(true); - expect(opts.eventUrlTransformer).toBeUndefined(); + expect(opts.eventUrlTransformer).toBeDefined(); expect(logger.debug).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 523d4a99d5..21105621a4 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -4,7 +4,7 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jest-environment-jsdom', transform: { - '^.+\\.tsx?$': ['ts-jest', { useESM: true }], + '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }], }, testPathIgnorePatterns: ['./dist'], }; diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 367528e2b4..c03638f278 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -30,7 +30,7 @@ "build": "rollup -c rollup.config.js", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", - "test": "NODE_OPTIONS=--experimental-vm-modules npx jest", + "test": "NODE_OPTIONS=--experimental-vm-modules npx jest --runInBand", "coverage": "yarn test --coverage", "check": "yarn prettier && yarn lint && yarn build && yarn test" }, diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index a4ccd548eb..a275fddccd 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -8,6 +8,7 @@ import { internal, LDClientImpl, LDContext, + Platform, } from '@launchdarkly/js-client-sdk-common'; import GoalManager from './goals/GoalManager'; @@ -26,6 +27,7 @@ export class BrowserClient extends LDClientImpl { private readonly clientSideId: string, autoEnvAttributes: AutoEnvAttributes, options: BrowserOptions = {}, + overridePlatform?: Platform, ) { const { logger: customLogger, debug } = options; const logger = @@ -39,14 +41,24 @@ export class BrowserClient extends LDClientImpl { // TODO: Use the already-configured baseUri from the SDK config. SDK-560 const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; - const platform = new BrowserPlatform(options); + const platform = overridePlatform ?? new BrowserPlatform(logger); const ValidatedBrowserOptions = validateOptions(options, logger); + const { eventUrlTransformer } = ValidatedBrowserOptions; super(clientSideId, autoEnvAttributes, platform, filterToBaseOptions(options), { analyticsEventPath: `/events/bulk/${clientSideId}`, diagnosticEventPath: `/events/diagnostic/${clientSideId}`, includeAuthorizationHeader: false, highTimeoutThreshold: 5, userAgentHeaderName: 'x-launchdarkly-user-agent', + trackEventModifier: (event: internal.InputCustomEvent) => + new internal.InputCustomEvent( + event.context, + event.key, + event.data, + event.metricValue, + event.samplingRatio, + eventUrlTransformer(window.location.href), + ), }); if (ValidatedBrowserOptions.fetchGoals) { @@ -63,10 +75,11 @@ export class BrowserClient extends LDClientImpl { if (!context) { return; } + const transformedUrl = eventUrlTransformer(url); if (isClick(goal)) { this.sendEvent({ kind: 'click', - url, + url: transformedUrl, samplingRatio: 1, key: goal.key, creationDate: Date.now(), @@ -76,7 +89,7 @@ export class BrowserClient extends LDClientImpl { } else { this.sendEvent({ kind: 'pageview', - url, + url: transformedUrl, samplingRatio: 1, key: goal.key, creationDate: Date.now(), @@ -122,16 +135,8 @@ export class BrowserClient extends LDClientImpl { }; } - override track(key: string, data?: any, metricValue?: number): void { - const context = this.getInternalContext(); - if (!context || !context) { - this.logger.warn(internal.ClientMessages.missingContextKeyNoEvent); - return; - } - - this.sendEvent({ - this.eventProcessor?.sendEvent( - this.eventFactoryDefault.customEvent(key, context, data, metricValue), - ); + override async identify(context: LDContext): Promise { + await super.identify(context); + this.goalManager?.startTracking(); } } diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts index eecd21f920..50932dd0cd 100644 --- a/packages/sdk/browser/src/goals/GoalManager.ts +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -9,6 +9,7 @@ export default class GoalManager { private url: string; private watcher?: LocationWatcher; private tracker?: GoalTracker; + private isTracking = false; constructor( credential: string, @@ -29,10 +30,20 @@ export default class GoalManager { public async initialize(): Promise { await this.fetchGoals(); + // If tracking has been started before goal fetching completes, we need to + // create the tracker so it can start watching for events. + this.createTracker(); + } + + public startTracking() { + this.isTracking = true; this.createTracker(); } private createTracker() { + if (!this.isTracking) { + return; + } this.tracker?.close(); if (this.goals && this.goals.length) { this.tracker = new GoalTracker(this.goals, (goal) => { diff --git a/packages/sdk/browser/src/goals/GoalTracker.ts b/packages/sdk/browser/src/goals/GoalTracker.ts index cac85079c6..ee4f232588 100644 --- a/packages/sdk/browser/src/goals/GoalTracker.ts +++ b/packages/sdk/browser/src/goals/GoalTracker.ts @@ -75,7 +75,9 @@ export default class GoalTracker { const pageviewGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'pageview'); const clickGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'click'); - pageviewGoals.forEach((event) => onEvent(event)); + pageviewGoals.forEach((event) => { + onEvent(event); + }); if (clickGoals.length) { // Click handler is not a member function in order to avoid having to bind it for the event diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 26015a6744..bc68e7d3c6 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -21,7 +21,6 @@ import { BrowserOptions as LDOptions } from './options'; // TODO: Export and use browser specific options. export { LDClient, - AutoEnvAttributes, LDFlagSet, LDContext, LDContextCommon, @@ -36,10 +35,7 @@ export { LDEvaluationReason, }; -export function init( - clientSideId: string, - autoEnvAttributes: AutoEnvAttributes, - options?: LDOptions, -): LDClient { - return new BrowserClient(clientSideId, autoEnvAttributes, options); +export function init(clientSideId: string, options?: LDOptions): LDClient { + // AutoEnvAttributes are not supported yet in the browser SDK. + return new BrowserClient(clientSideId, AutoEnvAttributes.Disabled, options); } diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index c0d62c549c..d08eb53b2a 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -28,12 +28,12 @@ export interface BrowserOptions extends LDOptionsBase { export interface ValidatedOptions { fetchGoals: boolean; - eventUrlTransformer?: (url: string) => string; + eventUrlTransformer: (url: string) => string; } const optDefaults = { fetchGoals: true, - eventUrlTransformer: undefined, + eventUrlTransformer: (url: string) => url, }; const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { diff --git a/packages/sdk/browser/src/platform/BrowserInfo.ts b/packages/sdk/browser/src/platform/BrowserInfo.ts index 637ebad50b..3a2c064fba 100644 --- a/packages/sdk/browser/src/platform/BrowserInfo.ts +++ b/packages/sdk/browser/src/platform/BrowserInfo.ts @@ -1,7 +1,5 @@ import { Info, PlatformData, SdkData } from '@launchdarkly/js-client-sdk-common'; -import { name, version } from '../../package.json'; - export default class BrowserInfo implements Info { platformData(): PlatformData { return { @@ -10,8 +8,8 @@ export default class BrowserInfo implements Info { } sdkData(): SdkData { return { - name, - version, + name: '@launchdarkly/js-client-sdk', + version: '0.0.0', // x-release-please-version userAgentBase: 'JSClient', }; } diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts index 34dfccaefa..33b5b10248 100644 --- a/packages/sdk/browser/src/platform/BrowserPlatform.ts +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -2,7 +2,7 @@ import { Crypto, Encoding, Info, - LDOptions, + LDLogger, Platform, Requests, Storage, @@ -22,9 +22,9 @@ export default class BrowserPlatform implements Platform { requests: Requests = new BrowserRequests(); storage?: Storage; - constructor(options: LDOptions) { + constructor(logger: LDLogger) { if (isLocalStorageSupported()) { - this.storage = new LocalStorage(options.logger); + this.storage = new LocalStorage(logger); } } } diff --git a/packages/sdk/browser/tsconfig.json b/packages/sdk/browser/tsconfig.json index 0a219e1516..2f2d034948 100644 --- a/packages/sdk/browser/tsconfig.json +++ b/packages/sdk/browser/tsconfig.json @@ -30,7 +30,7 @@ "node_modules", "contract-tests", "babel.config.js", - "jest.config.ts", + "jest.config.js", "jestSetupFile.ts", "**/*.test.ts*" ] diff --git a/packages/sdk/browser/tsconfig.test.json b/packages/sdk/browser/tsconfig.test.json deleted file mode 100644 index 2c617dcaa7..0000000000 --- a/packages/sdk/browser/tsconfig.test.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "esModuleInterop": true, - "jsx": "react-jsx", - "lib": ["es6", "dom"], - "module": "ES6", - "moduleResolution": "node", - "resolveJsonModule": true, - "rootDir": ".", - "strict": true, - "types": ["jest", "node"] - }, - "exclude": ["dist", "node_modules", "__tests__", "example"] -} diff --git a/packages/shared/common/src/internal/events/LDInternalOptions.ts b/packages/shared/common/src/internal/events/LDInternalOptions.ts index 54f6b91c54..916443eda6 100644 --- a/packages/shared/common/src/internal/events/LDInternalOptions.ts +++ b/packages/shared/common/src/internal/events/LDInternalOptions.ts @@ -1,3 +1,5 @@ +import InputCustomEvent from './InputCustomEvent'; + /** * This is for internal use only. * diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 1392db206a..8ce5538590 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -23,6 +23,7 @@ import { ConnectionMode, LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; import Configuration from './configuration'; +import { LDClientInternalOptions } from './configuration/Configuration'; import { addAutoEnv } from './context/addAutoEnv'; import { ensureKey } from './context/ensureKey'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; @@ -72,7 +73,7 @@ export default class LDClientImpl implements LDClient { public readonly autoEnvAttributes: AutoEnvAttributes, public readonly platform: Platform, options: LDOptions, - internalOptions?: internal.LDInternalOptions, + internalOptions?: LDClientInternalOptions, ) { if (!sdkKey) { throw new Error('You must configure the client with a client-side SDK key'); @@ -502,7 +503,9 @@ export default class LDClientImpl implements LDClient { } this.eventProcessor?.sendEvent( - this.eventFactoryDefault.customEvent(key, this.checkedContext!, data, metricValue), + this.config.trackEventModifier( + this.eventFactoryDefault.customEvent(key, this.checkedContext!, data, metricValue), + ), ); } diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index e68dd9f746..64bb9867e2 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -14,6 +14,10 @@ import validators from './validators'; const DEFAULT_POLLING_INTERVAL: number = 60 * 5; +export interface LDClientInternalOptions extends internal.LDInternalOptions { + trackEventModifier?: (event: internal.InputCustomEvent) => internal.InputCustomEvent; +} + export default class Configuration { public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; @@ -66,10 +70,14 @@ export default class Configuration { public readonly userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent'; + public readonly trackEventModifier: ( + event: internal.InputCustomEvent, + ) => internal.InputCustomEvent; + // Allow indexing Configuration by a string [index: string]: any; - constructor(pristineOptions: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) { + constructor(pristineOptions: LDOptions = {}, internalOptions: LDClientInternalOptions = {}) { const errors = this.validateTypesAndNames(pristineOptions); errors.forEach((e: string) => this.logger.warn(e)); @@ -86,6 +94,7 @@ export default class Configuration { this.tags = new ApplicationTags({ application: this.applicationInfo, logger: this.logger }); this.userAgentHeaderName = internalOptions.userAgentHeaderName ?? 'user-agent'; + this.trackEventModifier = internalOptions.trackEventModifier ?? ((event) => event); } validateTypesAndNames(pristineOptions: LDOptions): string[] { diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index a4186185d4..d5b3e293d4 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -1,3 +1,4 @@ +import { LDClientInternalOptions } from './configuration/Configuration'; import LDClientImpl from './LDClientImpl'; export * from '@launchdarkly/js-sdk-common'; @@ -18,4 +19,4 @@ export type { export { DataSourcePaths } from './streaming'; -export { LDClientImpl }; +export { LDClientImpl, LDClientInternalOptions }; From 2f730ab446f099e3fab562f19d5abfc2e7ffb7a1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:57:49 -0700 Subject: [PATCH 11/50] Revert jest.config.js --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 2c937ca53c..c9b229c3d7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ module.exports = { transform: { '^.+\\.ts?$': 'ts-jest' }, testMatch: ['**/__tests__/**/*test.ts?(x)'], testEnvironment: 'node', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node', 'json'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: [ 'packages/sdk/server-node/src/**/*.ts', 'packages/shared/common/src/**/*.ts', From dbfe4314f57ef6c3dfec42ac88fcce512296a503 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Sep 2024 11:04:13 -0700 Subject: [PATCH 12/50] Remove duplicate merged code. --- .../src/internal/events/EventProcessor.ts | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 144b725506..4e6acd128e 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -372,27 +372,6 @@ export default class EventProcessor implements LDEventProcessor { }; return out; } - case 'click': { - const out: ClickOutputEvent = { - kind: 'click', - creationDate: event.creationDate, - contextKeys: event.context.kindsAndKeys, - key: event.key, - url: event.url, - selector: event.selector, - }; - return out; - } - case 'pageview': { - const out: PageviewOutputEvent = { - kind: 'pageview', - creationDate: event.creationDate, - contextKeys: event.context.kindsAndKeys, - key: event.key, - url: event.url, - }; - return out; - } default: // This would happen during the addition of a new event type to the SDK. return event; From 57e835d6d6a3a2ac2f0a9b9aeee5596b1a1cf92f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Sep 2024 11:06:28 -0700 Subject: [PATCH 13/50] Remove unused import. --- packages/shared/common/src/internal/events/LDInternalOptions.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/shared/common/src/internal/events/LDInternalOptions.ts b/packages/shared/common/src/internal/events/LDInternalOptions.ts index 916443eda6..54f6b91c54 100644 --- a/packages/shared/common/src/internal/events/LDInternalOptions.ts +++ b/packages/shared/common/src/internal/events/LDInternalOptions.ts @@ -1,5 +1,3 @@ -import InputCustomEvent from './InputCustomEvent'; - /** * This is for internal use only. * From bcb3c4d444a4e3571d9f329f05212523cc6a19f0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Sep 2024 16:00:08 -0700 Subject: [PATCH 14/50] WIP: Refactor data handling. --- packages/shared/sdk-client/src/DataManager.ts | 280 ++++++++++++++++++ .../shared/sdk-client/src/LDClientImpl.ts | 4 +- 2 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 packages/shared/sdk-client/src/DataManager.ts diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts new file mode 100644 index 0000000000..1900e60f4a --- /dev/null +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -0,0 +1,280 @@ +import { + Context, + EventName, + internal, + LDContext, + LDHeaders, + LDLogger, + Platform, + ProcessStreamResponse, + subsystem, +} from '@launchdarkly/js-sdk-common'; + +import ConnectionMode from './api/ConnectionMode'; +import { LDIdentifyOptions } from './api/LDIdentifyOptions'; +import Configuration from './configuration'; +import FlagManager from './flag-manager/FlagManager'; +import { ItemDescriptor } from './flag-manager/ItemDescriptor'; +import LDEmitter from './LDEmitter'; +import PollingProcessor from './polling/PollingProcessor'; +import { DataSourcePaths, StreamingProcessor } from './streaming'; +import { DeleteFlag, Flags, PatchFlag } from './types'; + +export interface DataManager { + identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise; + + setConnectionMode(mode: ConnectionMode): Promise; +} + +export interface DataManagerFactory { + ( + platform: Platform, + flagManager: FlagManager, + credential: string, + configuration: Configuration, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + ): DataManager; +} + +export class DefaultDataManager implements DataManager { + private updateProcessor?: subsystem.LDStreamProcessor; + private readonly logger: LDLogger; + private connectionMode: ConnectionMode = 'streaming'; + private context?: Context; + private identifyTimeout?: number; + + constructor( + private readonly platform: Platform, + private readonly flagManager: FlagManager, + private readonly credential: string, + private readonly config: Configuration, + private readonly getPollingPaths: () => DataSourcePaths, + private readonly getStreamingPaths: () => DataSourcePaths, + private readonly baseHeaders: LDHeaders, + private readonly emitter: LDEmitter, + private readonly diagnosticsManager: internal.DiagnosticsManager, + ) { + this.logger = config.logger; + this.connectionMode = config.initialConnectionMode; + } + + setConnectionMode(mode: ConnectionMode): Promise { + if (this.connectionMode === mode) { + this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); + return Promise.resolve(); + } + + this.connectionMode = mode; + this.logger.debug(`setConnectionMode ${mode}.`); + + switch (mode) { + case 'offline': + this.updateProcessor?.close(); + break; + case 'polling': + case 'streaming': + if (this.context) { + // identify will start the update processor + this.setupConnection(this.context); + } + + break; + default: + this.logger.warn( + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, + ); + break; + } + + return Promise.resolve(); + } + + async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ) { + this.identifyTimeout = identifyOptions?.timeout; + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !identifyOptions?.waitForNetworkResults) { + this.logger.debug('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && identifyOptions?.waitForNetworkResults) { + this.logger.debug( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + if (this.connectionMode === 'offline') { + if (loadedFromCache) { + this.logger.debug('Offline identify - using cached flags.'); + } else { + this.logger.debug( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + identifyResolve(); + } + } else { + // Context has been validated in LDClientImpl.identify + this.setupConnection(context, identifyResolve, identifyReject); + } + } + + private setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + this.updateProcessor?.close(); + switch (this.connectionMode) { + case 'streaming': + this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); + break; + case 'polling': + this.createPollingProcessor(rawContext, context, identifyResolve, identifyReject); + break; + default: + break; + } + this.updateProcessor!.start(); + } + + private createPollingProcessor( + context: LDContext, + checkedContext: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + this.updateProcessor = new PollingProcessor( + JSON.stringify(context), + { + credential: this.credential, + serviceEndpoints: this.config.serviceEndpoints, + paths: this.getPollingPaths(), + baseHeaders: this.baseHeaders, + pollInterval: this.config.pollInterval, + withReasons: this.config.withReasons, + useReport: this.config.useReport, + }, + this.platform.requests, + this.platform.encoding!, + async (flags) => { + this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); + + // mapping flags to item descriptors + const descriptors = Object.entries(flags).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + + await this.flagManager.init(checkedContext, descriptors); + identifyResolve?.(); + }, + (err) => { + identifyReject?.(err); + this.emitter.emit('error', context, err); + }, + ); + } + + private createStreamingProcessor( + context: LDContext, + checkedContext: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + this.updateProcessor = new StreamingProcessor( + JSON.stringify(context), + { + credential: this.credential, + serviceEndpoints: this.config.serviceEndpoints, + paths: this.getStreamingPaths(), + baseHeaders: this.baseHeaders, + initialRetryDelayMillis: this.config.streamInitialReconnectDelay * 1000, + withReasons: this.config.withReasons, + useReport: this.config.useReport, + }, + this.createStreamListeners(checkedContext, identifyResolve), + this.platform.requests, + this.platform.encoding!, + this.diagnosticsManager, + (e) => { + identifyReject?.(e); + this.emitter.emit('error', context, e); + }, + ); + } + + private createStreamListeners( + context: Context, + identifyResolve?: () => void, + ): Map { + const listeners = new Map(); + + listeners.set('put', { + deserializeData: JSON.parse, + processJson: async (evalResults: Flags) => { + this.logger.debug(`Stream PUT: ${Object.keys(evalResults)}`); + + // mapping flags to item descriptors + const descriptors = Object.entries(evalResults).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + await this.flagManager.init(context, descriptors); + identifyResolve?.(); + }, + }); + + listeners.set('patch', { + deserializeData: JSON.parse, + processJson: async (patchFlag: PatchFlag) => { + this.logger.debug(`Stream PATCH ${JSON.stringify(patchFlag, null, 2)}`); + this.flagManager.upsert(context, patchFlag.key, { + version: patchFlag.version, + flag: patchFlag, + }); + }, + }); + + listeners.set('delete', { + deserializeData: JSON.parse, + processJson: async (deleteFlag: DeleteFlag) => { + this.logger.debug(`Stream DELETE ${JSON.stringify(deleteFlag, null, 2)}`); + + this.flagManager.upsert(context, deleteFlag.key, { + version: deleteFlag.version, + flag: { + ...deleteFlag, + deleted: true, + // props below are set to sensible defaults. they are irrelevant + // because this flag has been deleted. + flagVersion: 0, + value: undefined, + variation: 0, + trackEvents: false, + }, + }); + }, + }); + + return listeners; + } +} diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 8ce5538590..272ccddb45 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -14,10 +14,10 @@ import { Platform, ProcessStreamResponse, EventName as StreamEventName, + subsystem, timedPromise, TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { LDStreamProcessor } from '@launchdarkly/js-sdk-common/dist/api/subsystem'; import { ConnectionMode, LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; @@ -51,7 +51,7 @@ export default class LDClientImpl implements LDClient { private eventProcessor?: internal.EventProcessor; private identifyTimeout: number = 5; readonly logger: LDLogger; - private updateProcessor?: LDStreamProcessor; + private updateProcessor?: subsystem.LDStreamProcessor; private readonly highTimeoutThreshold: number = 15; From 0695c74600c03b1fce62f9902bcbe078b9693ea1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Sep 2024 09:50:51 -0700 Subject: [PATCH 15/50] WIP: JS style initialization. --- packages/sdk/browser/src/BrowserClient.ts | 63 +++++-- .../react-native/src/ReactNativeLDClient.ts | 28 +++ packages/shared/sdk-client/src/DataManager.ts | 27 ++- .../sdk-client/src/IdentityProcessor.ts | 30 ++++ .../shared/sdk-client/src/LDClientImpl.ts | 167 +++--------------- .../src/configuration/Configuration.ts | 46 ++++- .../sdk-client/src/configuration/index.ts | 4 +- .../sdk-client/src/context/addAutoEnv.ts | 6 +- .../createDiagnosticsInitConfig.ts | 8 +- .../diagnostics/createDiagnosticsManager.ts | 4 +- .../src/events/createEventProcessor.ts | 4 +- .../src/flag-manager/FlagManager.ts | 12 +- packages/shared/sdk-client/src/index.ts | 8 + 13 files changed, 225 insertions(+), 182 deletions(-) create mode 100644 packages/shared/sdk-client/src/IdentityProcessor.ts diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index a275fddccd..136077fe6f 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -3,11 +3,16 @@ import { base64UrlEncode, BasicLogger, LDClient as CommonClient, + Configuration, DataSourcePaths, + DefaultDataManager, Encoding, + FlagManager, internal, LDClientImpl, LDContext, + LDEmitter, + LDHeaders, Platform, } from '@launchdarkly/js-client-sdk-common'; @@ -44,22 +49,50 @@ export class BrowserClient extends LDClientImpl { const platform = overridePlatform ?? new BrowserPlatform(logger); const ValidatedBrowserOptions = validateOptions(options, logger); const { eventUrlTransformer } = ValidatedBrowserOptions; - super(clientSideId, autoEnvAttributes, platform, filterToBaseOptions(options), { - analyticsEventPath: `/events/bulk/${clientSideId}`, - diagnosticEventPath: `/events/diagnostic/${clientSideId}`, - includeAuthorizationHeader: false, - highTimeoutThreshold: 5, - userAgentHeaderName: 'x-launchdarkly-user-agent', - trackEventModifier: (event: internal.InputCustomEvent) => - new internal.InputCustomEvent( - event.context, - event.key, - event.data, - event.metricValue, - event.samplingRatio, - eventUrlTransformer(window.location.href), + super( + clientSideId, + autoEnvAttributes, + platform, + filterToBaseOptions(options), + ( + inPlatform: Platform, + flagManager: FlagManager, + credential: string, + configuration: Configuration, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new DefaultDataManager( + inPlatform, + flagManager, + credential, + configuration, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, ), - }); + { + analyticsEventPath: `/events/bulk/${clientSideId}`, + diagnosticEventPath: `/events/diagnostic/${clientSideId}`, + includeAuthorizationHeader: false, + highTimeoutThreshold: 5, + userAgentHeaderName: 'x-launchdarkly-user-agent', + trackEventModifier: (event: internal.InputCustomEvent) => + new internal.InputCustomEvent( + event.context, + event.key, + event.data, + event.metricValue, + event.samplingRatio, + eventUrlTransformer(window.location.href), + ), + }, + ); if (ValidatedBrowserOptions.fetchGoals) { this.goalManager = new GoalManager( diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index a0ade06ede..1e47369e3d 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -3,12 +3,18 @@ import { AutoEnvAttributes, base64UrlEncode, BasicLogger, + type Configuration, ConnectionMode, DataSourcePaths, + DefaultDataManager, Encoding, + FlagManager, internal, LDClientImpl, type LDContext, + LDEmitter, + LDHeaders, + type Platform, } from '@launchdarkly/js-client-sdk-common'; import validateOptions, { filterToBaseOptions } from './options'; @@ -65,6 +71,28 @@ export default class ReactNativeLDClient extends LDClientImpl { autoEnvAttributes, createPlatform(logger, validatedRnOptions.storage), { ...filterToBaseOptions(options), logger }, + ( + platform: Platform, + flagManager: FlagManager, + credential: string, + configuration: Configuration, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new DefaultDataManager( + platform, + flagManager, + credential, + configuration, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ), internalOptions, ); diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 1900e60f4a..4cb44bae08 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -12,8 +12,8 @@ import { import ConnectionMode from './api/ConnectionMode'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; -import Configuration from './configuration'; -import FlagManager from './flag-manager/FlagManager'; +import { Configuration } from './configuration/Configuration'; +import { FlagManager } from './flag-manager/FlagManager'; import { ItemDescriptor } from './flag-manager/ItemDescriptor'; import LDEmitter from './LDEmitter'; import PollingProcessor from './polling/PollingProcessor'; @@ -29,6 +29,8 @@ export interface DataManager { ): Promise; setConnectionMode(mode: ConnectionMode): Promise; + + setNetworkAvailability(available: boolean): void; } export interface DataManagerFactory { @@ -41,6 +43,7 @@ export interface DataManagerFactory { getStreamingPaths: () => DataSourcePaths, baseHeaders: LDHeaders, emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, ): DataManager; } @@ -49,7 +52,8 @@ export class DefaultDataManager implements DataManager { private readonly logger: LDLogger; private connectionMode: ConnectionMode = 'streaming'; private context?: Context; - private identifyTimeout?: number; + // Not implemented yet. + private networkAvailable: boolean = true; constructor( private readonly platform: Platform, @@ -60,12 +64,16 @@ export class DefaultDataManager implements DataManager { private readonly getStreamingPaths: () => DataSourcePaths, private readonly baseHeaders: LDHeaders, private readonly emitter: LDEmitter, - private readonly diagnosticsManager: internal.DiagnosticsManager, + private readonly diagnosticsManager?: internal.DiagnosticsManager, ) { this.logger = config.logger; this.connectionMode = config.initialConnectionMode; } + setNetworkAvailability(available: boolean): void { + this.networkAvailable = available; + } + setConnectionMode(mode: ConnectionMode): Promise { if (this.connectionMode === mode) { this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); @@ -102,14 +110,17 @@ export class DefaultDataManager implements DataManager { identifyReject: (err: Error) => void, context: Context, identifyOptions?: LDIdentifyOptions, - ) { - this.identifyTimeout = identifyOptions?.timeout; + ): Promise { + const offline = this.connectionMode === 'offline'; + // In offline mode we do not support waiting for results. + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + const loadedFromCache = await this.flagManager.loadCached(context); - if (loadedFromCache && !identifyOptions?.waitForNetworkResults) { + if (loadedFromCache && !waitForNetworkResults) { this.logger.debug('Identify completing with cached flags'); identifyResolve(); } - if (loadedFromCache && identifyOptions?.waitForNetworkResults) { + if (loadedFromCache && waitForNetworkResults) { this.logger.debug( 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', ); diff --git a/packages/shared/sdk-client/src/IdentityProcessor.ts b/packages/shared/sdk-client/src/IdentityProcessor.ts new file mode 100644 index 0000000000..91b649c136 --- /dev/null +++ b/packages/shared/sdk-client/src/IdentityProcessor.ts @@ -0,0 +1,30 @@ +import { Context, LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDStreamProcessor } from '@launchdarkly/js-sdk-common/dist/api/subsystem'; + +import ConnectionMode from './api/ConnectionMode'; +import DefaultFlagManager from './flag-manager/FlagManager'; + +export type IdentifyProcessor = (params: { + waitForNetworkResults: boolean; + isOffline: () => boolean; + getConnectionMode: () => ConnectionMode; + logger: LDLogger; + updateProcessor: LDStreamProcessor | undefined; + createStreamingProcessor: ( + context: LDContext, + checkedContext: Context, + identifyResolve: any, + identifyReject: any, + ) => void; + createPollingProcessor: ( + context: LDContext, + checkedContext: Context, + identifyResolve: any, + identifyReject: any, + ) => void; + context: LDContext; + checkedContext: Context; + identifyResolve: () => void; + identifyReject: (error: any) => void; + flagManager: DefaultFlagManager; +}) => Promise; diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 272ccddb45..45768067ff 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -22,10 +22,11 @@ import { import { ConnectionMode, LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; -import Configuration from './configuration'; +import ConfigurationImpl from './configuration'; import { LDClientInternalOptions } from './configuration/Configuration'; import { addAutoEnv } from './context/addAutoEnv'; import { ensureKey } from './context/ensureKey'; +import { DataManager, DataManagerFactory } from './DataManager'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import { createErrorEvaluationDetail, @@ -33,18 +34,16 @@ import { } from './evaluation/evaluationDetail'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; -import FlagManager from './flag-manager/FlagManager'; +import DefaultFlagManager from './flag-manager/FlagManager'; import { ItemDescriptor } from './flag-manager/ItemDescriptor'; import LDEmitter, { EventName } from './LDEmitter'; -import PollingProcessor from './polling/PollingProcessor'; -import { StreamingProcessor } from './streaming'; import { DataSourcePaths } from './streaming/DataSourceConfig'; import { DeleteFlag, Flags, PatchFlag } from './types'; const { ClientMessages, ErrorKinds } = internal; export default class LDClientImpl implements LDClient { - private readonly config: Configuration; + private readonly config: ConfigurationImpl; private uncheckedContext?: LDContext; private checkedContext?: Context; private readonly diagnosticsManager?: internal.DiagnosticsManager; @@ -58,12 +57,13 @@ export default class LDClientImpl implements LDClient { private eventFactoryDefault = new EventFactory(false); private eventFactoryWithReasons = new EventFactory(true); private emitter: LDEmitter; - private flagManager: FlagManager; + private flagManager: DefaultFlagManager; private eventSendingEnabled: boolean = true; private networkAvailable: boolean = true; private connectionMode: ConnectionMode; private baseHeaders: LDHeaders; + private dataManager: DataManager; /** * Creates the client object synchronously. No async, no network calls. @@ -73,6 +73,7 @@ export default class LDClientImpl implements LDClient { public readonly autoEnvAttributes: AutoEnvAttributes, public readonly platform: Platform, options: LDOptions, + dataManagerFactory: DataManagerFactory, internalOptions?: LDClientInternalOptions, ) { if (!sdkKey) { @@ -83,7 +84,7 @@ export default class LDClientImpl implements LDClient { throw new Error('Platform must implement Encoding because btoa is required.'); } - this.config = new Configuration(options, internalOptions); + this.config = new ConfigurationImpl(options, internalOptions); this.connectionMode = this.config.initialConnectionMode; this.logger = this.config.logger; @@ -95,7 +96,7 @@ export default class LDClientImpl implements LDClient { this.config.userAgentHeaderName, ); - this.flagManager = new FlagManager( + this.flagManager = new DefaultFlagManager( this.platform, sdkKey, this.config.maxCachedContexts, @@ -122,6 +123,18 @@ export default class LDClientImpl implements LDClient { const ldContext = Context.toLDContext(context); this.emitter.emit('change', ldContext, flagKeys); }); + + this.dataManager = dataManagerFactory( + this.platform, + this.flagManager, + this.sdkKey, + this.config, + this.getPollingPaths, + this.getStreamingPaths, + this.baseHeaders, + this.emitter, + this.diagnosticsManager, + ); } /** @@ -130,34 +143,9 @@ export default class LDClientImpl implements LDClient { * @param mode - One of supported {@link ConnectionMode}. Default is 'streaming'. */ async setConnectionMode(mode: ConnectionMode): Promise { - if (this.connectionMode === mode) { - this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); - return Promise.resolve(); - } - - this.connectionMode = mode; - this.logger.debug(`setConnectionMode ${mode}.`); - - switch (mode) { - case 'offline': - this.updateProcessor?.close(); - break; - case 'polling': - case 'streaming': - if (this.uncheckedContext) { - // identify will start the update processor - return this.identify(this.uncheckedContext, { timeout: this.identifyTimeout }); - } - - break; - default: - this.logger.warn( - `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, - ); - break; - } - - return Promise.resolve(); + // TODO: Set connection mode should have a timeout. It doesn't make sense for it to be the + // timeout from the most recent identify call. Or it needs to not be async. + return this.dataManager.setConnectionMode(mode); } /** @@ -341,9 +329,6 @@ export default class LDClientImpl implements LDClient { * 3. A network error is encountered during initialization. */ async identify(pristineContext: LDContext, identifyOptions?: LDIdentifyOptions): Promise { - // In offline mode we do not support waiting for results. - const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !this.isOffline(); - if (identifyOptions?.timeout) { this.identifyTimeout = identifyOptions.timeout; } @@ -377,112 +362,11 @@ export default class LDClientImpl implements LDClient { ); this.logger.debug(`Identifying ${JSON.stringify(this.checkedContext)}`); - const loadedFromCache = await this.flagManager.loadCached(this.checkedContext); - if (loadedFromCache && !waitForNetworkResults) { - this.logger.debug('Identify completing with cached flags'); - identifyResolve(); - } - if (loadedFromCache && waitForNetworkResults) { - this.logger.debug( - 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', - ); - } - - if (this.isOffline()) { - if (loadedFromCache) { - this.logger.debug('Offline identify - using cached flags.'); - } else { - this.logger.debug( - 'Offline identify - no cached flags, using defaults or already loaded flags.', - ); - identifyResolve(); - } - } else { - this.updateProcessor?.close(); - switch (this.getConnectionMode()) { - case 'streaming': - this.createStreamingProcessor(context, checkedContext, identifyResolve, identifyReject); - break; - case 'polling': - this.createPollingProcessor(context, checkedContext, identifyResolve, identifyReject); - break; - default: - break; - } - this.updateProcessor!.start(); - } + await this.dataManager.identify(context, identifyResolve, identifyReject); return identifyPromise; } - private createPollingProcessor( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) { - this.updateProcessor = new PollingProcessor( - JSON.stringify(context), - { - credential: this.sdkKey, - serviceEndpoints: this.config.serviceEndpoints, - paths: this.getPollingPaths(), - baseHeaders: this.baseHeaders, - pollInterval: this.config.pollInterval, - withReasons: this.config.withReasons, - useReport: this.config.useReport, - }, - this.platform.requests, - this.platform.encoding!, - async (flags) => { - this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); - - // mapping flags to item descriptors - const descriptors = Object.entries(flags).reduce( - (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { - acc[key] = { version: flag.version, flag }; - return acc; - }, - {}, - ); - - await this.flagManager.init(checkedContext, descriptors).then(identifyResolve()); - }, - (err) => { - identifyReject(err); - this.emitter.emit('error', context, err); - }, - ); - } - - private createStreamingProcessor( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) { - this.updateProcessor = new StreamingProcessor( - JSON.stringify(context), - { - credential: this.sdkKey, - serviceEndpoints: this.config.serviceEndpoints, - paths: this.getStreamingPaths(), - baseHeaders: this.baseHeaders, - initialRetryDelayMillis: this.config.streamInitialReconnectDelay * 1000, - withReasons: this.config.withReasons, - useReport: this.config.useReport, - }, - this.createStreamListeners(checkedContext, identifyResolve), - this.platform.requests, - this.platform.encoding!, - this.diagnosticsManager, - (e) => { - identifyReject(e); - this.emitter.emit('error', context, e); - }, - ); - } - off(eventName: EventName, listener: Function): void { this.emitter.off(eventName, listener); } @@ -653,6 +537,7 @@ export default class LDClientImpl implements LDClient { protected setNetworkAvailability(available: boolean): void { this.networkAvailable = available; // Not yet supported. + this.dataManager.setNetworkAvailability(available); } /** diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 64bb9867e2..f4a265e1f2 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -18,15 +18,53 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions { trackEventModifier?: (event: internal.InputCustomEvent) => internal.InputCustomEvent; } -export default class Configuration { +export interface Configuration { + readonly logger: ReturnType; + readonly baseUri: string; + readonly eventsUri: string; + readonly streamUri: string; + readonly maxCachedContexts: number; + readonly capacity: number; + readonly diagnosticRecordingInterval: number; + readonly flushInterval: number; + readonly streamInitialReconnectDelay: number; + readonly allAttributesPrivate: boolean; + readonly debug: boolean; + readonly diagnosticOptOut: boolean; + readonly sendEvents: boolean; + readonly sendLDHeaders: boolean; + readonly useReport: boolean; + readonly withReasons: boolean; + readonly privateAttributes: string[]; + readonly initialConnectionMode: ConnectionMode; + readonly tags: ApplicationTags; + readonly applicationInfo?: { + id?: string; + version?: string; + name?: string; + versionName?: string; + }; + readonly bootstrap?: LDFlagSet; + readonly requestHeaderTransform?: (headers: Map) => Map; + readonly stream?: boolean; + readonly hash?: string; + readonly wrapperName?: string; + readonly wrapperVersion?: string; + readonly serviceEndpoints: ServiceEndpoints; + readonly pollInterval: number; + readonly userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent'; + readonly trackEventModifier: (event: internal.InputCustomEvent) => internal.InputCustomEvent; +} + +export default class ConfigurationImpl implements Configuration { public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; public readonly logger = createSafeLogger(); - public readonly baseUri = Configuration.DEFAULT_POLLING; + public readonly baseUri = ConfigurationImpl.DEFAULT_POLLING; public readonly eventsUri = ServiceEndpoints.DEFAULT_EVENTS; - public readonly streamUri = Configuration.DEFAULT_STREAM; + public readonly streamUri = ConfigurationImpl.DEFAULT_STREAM; public readonly maxCachedContexts = 5; @@ -97,7 +135,7 @@ export default class Configuration { this.trackEventModifier = internalOptions.trackEventModifier ?? ((event) => event); } - validateTypesAndNames(pristineOptions: LDOptions): string[] { + private validateTypesAndNames(pristineOptions: LDOptions): string[] { const errors: string[] = []; Object.entries(pristineOptions).forEach(([k, v]) => { diff --git a/packages/shared/sdk-client/src/configuration/index.ts b/packages/shared/sdk-client/src/configuration/index.ts index b22c3ea49f..496ab796e1 100644 --- a/packages/shared/sdk-client/src/configuration/index.ts +++ b/packages/shared/sdk-client/src/configuration/index.ts @@ -1,3 +1,3 @@ -import Configuration from './Configuration'; +import ConfigurationImpl from './Configuration'; -export default Configuration; +export default ConfigurationImpl; diff --git a/packages/shared/sdk-client/src/context/addAutoEnv.ts b/packages/shared/sdk-client/src/context/addAutoEnv.ts index f66f61b01e..85666f6403 100644 --- a/packages/shared/sdk-client/src/context/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/context/addAutoEnv.ts @@ -11,7 +11,7 @@ import { Platform, } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import ConfigurationImpl from '../configuration'; import digest from '../crypto/digest'; import { getOrGenerateKey } from '../storage/getOrGenerateKey'; import { namespaceForGeneratedContextKey } from '../storage/namespaceUtils'; @@ -39,7 +39,7 @@ export const toMulti = (c: LDSingleKindContext) => { */ export const addApplicationInfo = async ( { crypto, info }: Platform, - { applicationInfo }: Configuration, + { applicationInfo }: ConfigurationImpl, ): Promise => { const { ld_application } = info.platformData(); let app = deepCompact(ld_application) ?? ({} as LDApplication); @@ -103,7 +103,7 @@ export const addDeviceInfo = async (platform: Platform) => { return undefined; }; -export const addAutoEnv = async (context: LDContext, platform: Platform, config: Configuration) => { +export const addAutoEnv = async (context: LDContext, platform: Platform, config: ConfigurationImpl) => { // LDUser is not supported for auto env reporting if (isLegacyUser(context)) { return context as LDUser; diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts index aeb8bf3c53..603cbefdcf 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts @@ -1,6 +1,6 @@ import { secondsToMillis, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import ConfigurationImpl from '../configuration'; export type DiagnosticsInitConfig = { // client & server common properties @@ -17,9 +17,9 @@ export type DiagnosticsInitConfig = { usingSecureMode: boolean; bootstrapMode: boolean; }; -const createDiagnosticsInitConfig = (config: Configuration): DiagnosticsInitConfig => ({ - customBaseURI: config.baseUri !== Configuration.DEFAULT_POLLING, - customStreamURI: config.streamUri !== Configuration.DEFAULT_STREAM, +const createDiagnosticsInitConfig = (config: ConfigurationImpl): DiagnosticsInitConfig => ({ + customBaseURI: config.baseUri !== ConfigurationImpl.DEFAULT_POLLING, + customStreamURI: config.streamUri !== ConfigurationImpl.DEFAULT_STREAM, customEventsURI: config.eventsUri !== ServiceEndpoints.DEFAULT_EVENTS, eventsCapacity: config.capacity, eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts index c1ed9928ad..22b397b593 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts @@ -1,11 +1,11 @@ import { internal, Platform } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import ConfigurationImpl from '../configuration'; import createDiagnosticsInitConfig from './createDiagnosticsInitConfig'; const createDiagnosticsManager = ( clientSideID: string, - config: Configuration, + config: ConfigurationImpl, platform: Platform, ) => { if (config.sendEvents && !config.diagnosticOptOut) { diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts index 6d45036db0..47839a5276 100644 --- a/packages/shared/sdk-client/src/events/createEventProcessor.ts +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -1,10 +1,10 @@ import { ClientContext, internal, LDHeaders, Platform } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import ConfigurationImpl from '../configuration'; const createEventProcessor = ( clientSideID: string, - config: Configuration, + config: ConfigurationImpl, platform: Platform, baseHeaders: LDHeaders, diagnosticsManager?: internal.DiagnosticsManager, diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 267895b656..1a780f759b 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -6,13 +6,23 @@ import { DefaultFlagStore } from './FlagStore'; import FlagUpdater, { FlagsChangeCallback } from './FlagUpdater'; import { ItemDescriptor } from './ItemDescriptor'; +export interface FlagManager { + get(key: string): ItemDescriptor | undefined; + getAll(): { [key: string]: ItemDescriptor }; + init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise; + upsert(context: Context, key: string, item: ItemDescriptor): Promise; + loadCached(context: Context): Promise; + on(callback: FlagsChangeCallback): void; + off(callback: FlagsChangeCallback): void; +} + /** * Top level manager of flags for the client. LDClient should be using this * class and not any of the specific instances managed by it. Updates from * data sources should be directed to the [init] and [upsert] methods of this * class. */ -export default class FlagManager { +export default class DefaultFlagManager implements FlagManager { private flagStore = new DefaultFlagStore(); private flagUpdater: FlagUpdater; private flagPersistencePromise: Promise; diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index d5b3e293d4..904e20ad32 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -1,5 +1,6 @@ import { LDClientInternalOptions } from './configuration/Configuration'; import LDClientImpl from './LDClientImpl'; +import LDEmitter from './LDEmitter'; export * from '@launchdarkly/js-sdk-common'; @@ -17,6 +18,13 @@ export type { ConnectionMode, } from './api'; +export type { DataManager, DataManagerFactory } from './DataManager'; +export type { FlagManager } from './flag-manager/FlagManager'; +export type { Configuration } from './configuration/Configuration'; + +export type { LDEmitter }; + export { DataSourcePaths } from './streaming'; +export { DefaultDataManager } from './DataManager'; export { LDClientImpl, LDClientInternalOptions }; From 14392589eb6e3788370f5751d716c2403a09951e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Sep 2024 10:16:22 -0700 Subject: [PATCH 16/50] Add correct typing to createIdentifyPromise. --- packages/shared/sdk-client/src/DataManager.ts | 2 ++ packages/shared/sdk-client/src/LDClientImpl.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 4cb44bae08..84ecd261fc 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -111,6 +111,7 @@ export class DefaultDataManager implements DataManager { context: Context, identifyOptions?: LDIdentifyOptions, ): Promise { + this.context = context; const offline = this.connectionMode === 'offline'; // In offline mode we do not support waiting for results. const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; @@ -147,6 +148,7 @@ export class DefaultDataManager implements DataManager { identifyReject?: (err: Error) => void, ) { const rawContext = Context.toLDContext(context)!; + console.log("RAW CONTEXT", JSON.stringify(rawContext, null, 2)); this.updateProcessor?.close(); switch (this.connectionMode) { case 'streaming': diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 45768067ff..a1975a7350 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -293,7 +293,11 @@ export default class LDClientImpl implements LDClient { }; } - private createIdentifyPromise(timeout: number) { + private createIdentifyPromise(timeout: number): { + identifyPromise: Promise; + identifyResolve: () => void; + identifyReject: (err: Error) => void; + } { let res: any; let rej: any; @@ -362,7 +366,12 @@ export default class LDClientImpl implements LDClient { ); this.logger.debug(`Identifying ${JSON.stringify(this.checkedContext)}`); - await this.dataManager.identify(context, identifyResolve, identifyReject); + await this.dataManager.identify( + identifyResolve, + identifyReject, + checkedContext, + identifyOptions, + ); return identifyPromise; } From 312fb49da8bee399cb763622fc04957b9de376a8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Sep 2024 10:35:17 -0700 Subject: [PATCH 17/50] Basic data manager functioning. --- packages/sdk/browser/src/BrowserClient.ts | 48 +++++++------------ .../react-native/src/ReactNativeLDClient.ts | 46 +++++++----------- packages/shared/sdk-client/src/DataManager.ts | 2 - .../shared/sdk-client/src/LDClientImpl.ts | 32 ------------- 4 files changed, 32 insertions(+), 96 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 136077fe6f..dcc8682193 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -59,8 +59,6 @@ export class BrowserClient extends LDClientImpl { flagManager: FlagManager, credential: string, configuration: Configuration, - getPollingPaths: () => DataSourcePaths, - getStreamingPaths: () => DataSourcePaths, baseHeaders: LDHeaders, emitter: LDEmitter, diagnosticsManager?: internal.DiagnosticsManager, @@ -70,8 +68,22 @@ export class BrowserClient extends LDClientImpl { flagManager, credential, configuration, - getPollingPaths, - getStreamingPaths, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}`; + }, + }), baseHeaders, emitter, diagnosticsManager, @@ -140,34 +152,6 @@ export class BrowserClient extends LDClientImpl { } } - private encodeContext(context: LDContext) { - return base64UrlEncode(JSON.stringify(context), this.platform.encoding!); - } - - override getStreamingPaths(): DataSourcePaths { - const parentThis = this; - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/eval/${parentThis.clientSideId}/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/eval/${parentThis.clientSideId}`; - }, - }; - } - - override getPollingPaths(): DataSourcePaths { - const parentThis = this; - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/sdk/evalx/${parentThis.clientSideId}/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/sdk/evalx/${parentThis.clientSideId}/context`; - }, - }; - } - override async identify(context: LDContext): Promise { await super.identify(context); this.goalManager?.startTracking(); diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 1e47369e3d..2f15dd66e2 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -76,8 +76,6 @@ export default class ReactNativeLDClient extends LDClientImpl { flagManager: FlagManager, credential: string, configuration: Configuration, - getPollingPaths: () => DataSourcePaths, - getStreamingPaths: () => DataSourcePaths, baseHeaders: LDHeaders, emitter: LDEmitter, diagnosticsManager?: internal.DiagnosticsManager, @@ -87,8 +85,22 @@ export default class ReactNativeLDClient extends LDClientImpl { flagManager, credential, configuration, - getPollingPaths, - getStreamingPaths, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + }), baseHeaders, emitter, diagnosticsManager, @@ -129,32 +141,6 @@ export default class ReactNativeLDClient extends LDClientImpl { super.setConnectionMode(mode); } - private encodeContext(context: LDContext) { - return base64UrlEncode(JSON.stringify(context), this.platform.encoding!); - } - - override getStreamingPaths(): DataSourcePaths { - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/meval`; - }, - }; - } - - override getPollingPaths(): DataSourcePaths { - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }; - } - override async setConnectionMode(mode: ConnectionMode): Promise { // Set the connection mode before setting offline, in case there is any mode transition work // such as flushing on entering the background. diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 84ecd261fc..48cd3b9269 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -39,8 +39,6 @@ export interface DataManagerFactory { flagManager: FlagManager, credential: string, configuration: Configuration, - getPollingPaths: () => DataSourcePaths, - getStreamingPaths: () => DataSourcePaths, baseHeaders: LDHeaders, emitter: LDEmitter, diagnosticsManager?: internal.DiagnosticsManager, diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index a1975a7350..e8befbefea 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -129,8 +129,6 @@ export default class LDClientImpl implements LDClient { this.flagManager, this.sdkKey, this.config, - this.getPollingPaths, - this.getStreamingPaths, this.baseHeaders, this.emitter, this.diagnosticsManager, @@ -263,36 +261,6 @@ export default class LDClientImpl implements LDClient { return listeners; } - protected getStreamingPaths(): DataSourcePaths { - return { - pathGet(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getStreamingPaths not implemented. Client sdks must implement getStreamingPaths for streaming with GET to work.', - ); - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getStreamingPaths not implemented. Client sdks must implement getStreamingPaths for streaming with REPORT to work.', - ); - }, - }; - } - - protected getPollingPaths(): DataSourcePaths { - return { - pathGet(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getPollingPaths not implemented. Client sdks must implement getPollingPaths for polling with GET to work.', - ); - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - throw new Error( - 'getPollingPaths not implemented. Client sdks must implement getPollingPaths for polling with REPORT to work.', - ); - }, - }; - } - private createIdentifyPromise(timeout: number): { identifyPromise: Promise; identifyResolve: () => void; From 4318393b1cc5426c71ae662f05582032c013ecef Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Sep 2024 15:40:18 -0700 Subject: [PATCH 18/50] Basic functionality. --- packages/sdk/browser/src/BrowserClient.ts | 40 +++++-- .../sdk/browser/src/BrowserDataManager.ts | 106 ++++++++++++++++++ packages/sdk/browser/src/options.ts | 16 ++- .../react-native/src/ReactNativeLDClient.ts | 10 +- packages/shared/sdk-client/src/DataManager.ts | 46 ++++---- .../shared/sdk-client/src/LDClientImpl.ts | 4 +- packages/shared/sdk-client/src/api/index.ts | 1 + packages/shared/sdk-client/src/index.ts | 3 + .../sdk-client/src/polling/Requestor.ts | 2 - 9 files changed, 178 insertions(+), 50 deletions(-) create mode 100644 packages/sdk/browser/src/BrowserDataManager.ts diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index dcc8682193..361d4b5285 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -4,7 +4,6 @@ import { BasicLogger, LDClient as CommonClient, Configuration, - DataSourcePaths, DefaultDataManager, Encoding, FlagManager, @@ -16,6 +15,7 @@ import { Platform, } from '@launchdarkly/js-client-sdk-common'; +import BrowserDataManager from './BrowserDataManager'; import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; @@ -23,8 +23,14 @@ import BrowserPlatform from './platform/BrowserPlatform'; /** * We are not supporting dynamically setting the connection mode on the LDClient. + * The SDK does not support offline mode. Instead bootstrap data can be used. */ -export type LDClient = Omit; +export type LDClient = Omit< + CommonClient, + 'setConnectionMode' | 'getConnectionMode' | 'getOffline' +> & { + setStreaming(streaming: boolean): void; +}; export class BrowserClient extends LDClientImpl { private readonly goalManager?: GoalManager; @@ -47,33 +53,32 @@ export class BrowserClient extends LDClientImpl { const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; const platform = overridePlatform ?? new BrowserPlatform(logger); - const ValidatedBrowserOptions = validateOptions(options, logger); - const { eventUrlTransformer } = ValidatedBrowserOptions; + const validatedBrowserOptions = validateOptions(options, logger); + const { eventUrlTransformer } = validatedBrowserOptions; super( clientSideId, autoEnvAttributes, platform, filterToBaseOptions(options), ( - inPlatform: Platform, flagManager: FlagManager, - credential: string, configuration: Configuration, baseHeaders: LDHeaders, emitter: LDEmitter, diagnosticsManager?: internal.DiagnosticsManager, ) => - new DefaultDataManager( - inPlatform, + new BrowserDataManager( + platform, flagManager, - credential, + clientSideId, configuration, + validatedBrowserOptions, () => ({ pathGet(encoding: Encoding, _plainContextString: string): string { - return `/eval/${clientSideId}/${base64UrlEncode(_plainContextString, encoding)}`; + return `/sdk/evalx/${clientSideId}/contexts/${base64UrlEncode(_plainContextString, encoding)}`; }, pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/eval/${clientSideId}`; + return `/sdk/evalx/${clientSideId}/context`; }, }), () => ({ @@ -106,7 +111,7 @@ export class BrowserClient extends LDClientImpl { }, ); - if (ValidatedBrowserOptions.fetchGoals) { + if (validatedBrowserOptions.fetchGoals) { this.goalManager = new GoalManager( clientSideId, platform.requests, @@ -156,4 +161,15 @@ export class BrowserClient extends LDClientImpl { await super.identify(context); this.goalManager?.startTracking(); } + + setStreaming(streaming: boolean): void { + const browserDataManager = this.dataManager as BrowserDataManager; + if (streaming) { + browserDataManager.startDataSource(); + } else { + browserDataManager.stopDataSource(); + } + } + + // TODO: Setup event listeners. } diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts new file mode 100644 index 0000000000..1d1084c5e2 --- /dev/null +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -0,0 +1,106 @@ +import { + Configuration, + Context, + DataSourcePaths, + DefaultDataManager, + FlagManager, + getPollingUri, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + Platform, + Requestor, +} from '@launchdarkly/js-client-sdk-common'; + +import { ValidatedOptions } from './options'; + +export default class BrowserDataManager extends DefaultDataManager { + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly browserConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + } + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + _identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + await this.flagManager.loadCached(context); + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const requestor = this.getRequestor(plainContextString); + + try { + const payload = await requestor.requestPayload(); + const listeners = this.createStreamListeners(context, identifyResolve); + const putListener = listeners.get('put'); + putListener!.processJson(putListener!.deserializeData(payload)); + } catch (e: any) { + identifyReject(e); + } + + if (this.browserConfig.stream) { + this.setupConnection(context); + } + } + + stopDataSource() { + this.updateProcessor?.close(); + this.updateProcessor = undefined; + } + + startDataSource() { + // Should always be streaming for browser SDKs for now. + if (this.connectionMode !== 'streaming') { + this.setConnectionMode('streaming'); + } else if (this.context && !this.updateProcessor) { + this.setupConnection(this.context); + } + } + + private getRequestor(plainContextString: string): Requestor { + const paths = this.getPollingPaths(); + const path = this.config.useReport + ? paths.pathReport(this.platform.encoding!, plainContextString) + : paths.pathGet(this.platform.encoding!, plainContextString); + + const parameters: { key: string; value: string }[] = []; + if (this.config.withReasons) { + parameters.push({ key: 'withReasons', value: 'true' }); + } + + const headers: { [key: string]: string } = { ...this.baseHeaders }; + let body; + let method = 'GET'; + if (this.config.useReport) { + method = 'REPORT'; + headers['content-type'] = 'application/json'; + body = plainContextString; // context is in body for REPORT + } + + const uri = getPollingUri(this.config.serviceEndpoints, path, parameters); + return new Requestor(this.platform.requests, uri, headers, method, body); + } + // TODO: Automatically start streaming if event handlers are registered. +} diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index d08eb53b2a..f04a9ec93a 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -9,7 +9,7 @@ import { /** * Initialization options for the LaunchDarkly browser SDK. */ -export interface BrowserOptions extends LDOptionsBase { +export interface BrowserOptions extends Omit { /** * Whether the client should make a request to LaunchDarkly for Experimentation metrics (goals). * @@ -24,21 +24,35 @@ export interface BrowserOptions extends LDOptionsBase { * and returns the value that should be stored in the event's `url` property. */ eventUrlTransformer?: (url: string) => string; + + /** + * Whether or not to open a streaming connection to LaunchDarkly for live flag updates. + * + * If this is true, the client will always attempt to maintain a streaming connection; if false, + * it never will. If you leave the value undefined (the default), the client will open a streaming + * connection if you subscribe to `"change"` or `"change:flag-key"` events. + * + * This is equivalent to calling `client.setStreaming()` with the same value. + */ + stream?: boolean; } export interface ValidatedOptions { fetchGoals: boolean; eventUrlTransformer: (url: string) => string; + stream?: boolean; } const optDefaults = { fetchGoals: true, eventUrlTransformer: (url: string) => url, + stream: undefined, }; const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { fetchGoals: TypeValidators.Boolean, eventUrlTransformer: TypeValidators.Function, + stream: TypeValidators.Boolean, }; export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 2f15dd66e2..c6c91166b1 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -5,16 +5,13 @@ import { BasicLogger, type Configuration, ConnectionMode, - DataSourcePaths, DefaultDataManager, Encoding, FlagManager, internal, LDClientImpl, - type LDContext, LDEmitter, LDHeaders, - type Platform, } from '@launchdarkly/js-client-sdk-common'; import validateOptions, { filterToBaseOptions } from './options'; @@ -65,16 +62,15 @@ export default class ReactNativeLDClient extends LDClientImpl { }; const validatedRnOptions = validateOptions(options, logger); + const platform = createPlatform(logger, validatedRnOptions.storage) super( sdkKey, autoEnvAttributes, - createPlatform(logger, validatedRnOptions.storage), + platform, { ...filterToBaseOptions(options), logger }, ( - platform: Platform, flagManager: FlagManager, - credential: string, configuration: Configuration, baseHeaders: LDHeaders, emitter: LDEmitter, @@ -83,7 +79,7 @@ export default class ReactNativeLDClient extends LDClientImpl { new DefaultDataManager( platform, flagManager, - credential, + sdkKey, configuration, () => ({ pathGet(encoding: Encoding, _plainContextString: string): string { diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 48cd3b9269..9fbd9bcb5f 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -35,9 +35,7 @@ export interface DataManager { export interface DataManagerFactory { ( - platform: Platform, flagManager: FlagManager, - credential: string, configuration: Configuration, baseHeaders: LDHeaders, emitter: LDEmitter, @@ -46,23 +44,23 @@ export interface DataManagerFactory { } export class DefaultDataManager implements DataManager { - private updateProcessor?: subsystem.LDStreamProcessor; - private readonly logger: LDLogger; - private connectionMode: ConnectionMode = 'streaming'; - private context?: Context; + protected updateProcessor?: subsystem.LDStreamProcessor; + protected readonly logger: LDLogger; + protected connectionMode: ConnectionMode = 'streaming'; + protected context?: Context; // Not implemented yet. - private networkAvailable: boolean = true; + protected networkAvailable: boolean = true; constructor( - private readonly platform: Platform, - private readonly flagManager: FlagManager, - private readonly credential: string, - private readonly config: Configuration, - private readonly getPollingPaths: () => DataSourcePaths, - private readonly getStreamingPaths: () => DataSourcePaths, - private readonly baseHeaders: LDHeaders, - private readonly emitter: LDEmitter, - private readonly diagnosticsManager?: internal.DiagnosticsManager, + protected readonly platform: Platform, + protected readonly flagManager: FlagManager, + protected readonly credential: string, + protected readonly config: Configuration, + protected readonly getPollingPaths: () => DataSourcePaths, + protected readonly getStreamingPaths: () => DataSourcePaths, + protected readonly baseHeaders: LDHeaders, + protected readonly emitter: LDEmitter, + protected readonly diagnosticsManager?: internal.DiagnosticsManager, ) { this.logger = config.logger; this.connectionMode = config.initialConnectionMode; @@ -72,10 +70,10 @@ export class DefaultDataManager implements DataManager { this.networkAvailable = available; } - setConnectionMode(mode: ConnectionMode): Promise { + async setConnectionMode(mode: ConnectionMode): Promise { if (this.connectionMode === mode) { this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); - return Promise.resolve(); + return; } this.connectionMode = mode; @@ -99,8 +97,6 @@ export class DefaultDataManager implements DataManager { ); break; } - - return Promise.resolve(); } async identify( @@ -140,13 +136,13 @@ export class DefaultDataManager implements DataManager { } } - private setupConnection( + protected setupConnection( context: Context, identifyResolve?: () => void, identifyReject?: (err: Error) => void, ) { const rawContext = Context.toLDContext(context)!; - console.log("RAW CONTEXT", JSON.stringify(rawContext, null, 2)); + this.updateProcessor?.close(); switch (this.connectionMode) { case 'streaming': @@ -161,7 +157,7 @@ export class DefaultDataManager implements DataManager { this.updateProcessor!.start(); } - private createPollingProcessor( + protected createPollingProcessor( context: LDContext, checkedContext: Context, identifyResolve?: () => void, @@ -202,7 +198,7 @@ export class DefaultDataManager implements DataManager { ); } - private createStreamingProcessor( + protected createStreamingProcessor( context: LDContext, checkedContext: Context, identifyResolve?: () => void, @@ -230,7 +226,7 @@ export class DefaultDataManager implements DataManager { ); } - private createStreamListeners( + protected createStreamListeners( context: Context, identifyResolve?: () => void, ): Map { diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index e8befbefea..9f561ae624 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -63,7 +63,7 @@ export default class LDClientImpl implements LDClient { private networkAvailable: boolean = true; private connectionMode: ConnectionMode; private baseHeaders: LDHeaders; - private dataManager: DataManager; + protected dataManager: DataManager; /** * Creates the client object synchronously. No async, no network calls. @@ -125,9 +125,7 @@ export default class LDClientImpl implements LDClient { }); this.dataManager = dataManagerFactory( - this.platform, this.flagManager, - this.sdkKey, this.config, this.baseHeaders, this.emitter, diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 24c6c13ce3..5440396ba1 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -5,3 +5,4 @@ export * from './LDClient'; export * from './LDEvaluationDetail'; export { ConnectionMode }; +export * from './LDIdentifyOptions'; diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 904e20ad32..aefe44563a 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -1,6 +1,7 @@ import { LDClientInternalOptions } from './configuration/Configuration'; import LDClientImpl from './LDClientImpl'; import LDEmitter from './LDEmitter'; +import Requestor from './polling/Requestor'; export * from '@launchdarkly/js-sdk-common'; @@ -16,6 +17,7 @@ export type { LDClient, LDOptions, ConnectionMode, + LDIdentifyOptions, } from './api'; export type { DataManager, DataManagerFactory } from './DataManager'; @@ -26,5 +28,6 @@ export type { LDEmitter }; export { DataSourcePaths } from './streaming'; export { DefaultDataManager } from './DataManager'; +export { Requestor }; export { LDClientImpl, LDClientInternalOptions }; diff --git a/packages/shared/sdk-client/src/polling/Requestor.ts b/packages/shared/sdk-client/src/polling/Requestor.ts index 48ba95b26c..2798f74742 100644 --- a/packages/shared/sdk-client/src/polling/Requestor.ts +++ b/packages/shared/sdk-client/src/polling/Requestor.ts @@ -18,8 +18,6 @@ export class LDRequestError extends Error implements HttpErrorResponse { /** * Note: The requestor is implemented independently from polling such that it can be used to * make a one-off request. - * - * @internal */ export default class Requestor { constructor( From dd0e5fb41908e37343c17236f2c99a8a169723af Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:44:45 -0700 Subject: [PATCH 19/50] fix: Ensure browser contract tests run during top-level build. --- .../sdk/browser/contract-tests/entity/package.json | 1 + .../contract-tests/entity/src/ClientEntity.ts | 14 ++------------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/sdk/browser/contract-tests/entity/package.json b/packages/sdk/browser/contract-tests/entity/package.json index 90b9433a14..f68b645b9f 100644 --- a/packages/sdk/browser/contract-tests/entity/package.json +++ b/packages/sdk/browser/contract-tests/entity/package.json @@ -6,6 +6,7 @@ "description": "Contract test service implementation for @launchdarkly/js-client-sdk", "scripts": { "start": "vite --open=true", + "build": "tsc --noEmit && vite build", "lint": "eslint ./src", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" }, diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index e7cbd756ca..a08cc84f67 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -1,10 +1,4 @@ -import { - AutoEnvAttributes, - init, - LDClient, - LDLogger, - LDOptions, -} from '@launchdarkly/js-client-sdk'; +import { init, LDClient, LDLogger, LDOptions } from '@launchdarkly/js-client-sdk'; import { CommandParams, CommandType, ValueType } from './CommandParams'; import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; @@ -205,11 +199,7 @@ export async function newSdkClientEntity(options: CreateInstanceParams) { options.configuration.clientSide?.initialUser || options.configuration.clientSide?.initialContext || makeDefaultInitialContext(); - const client = init( - options.configuration.credential || 'unknown-env-id', - AutoEnvAttributes.Disabled, // TODO: Determine capability. - sdkConfig, - ); + const client = init(options.configuration.credential || 'unknown-env-id', sdkConfig); let failed = false; try { await Promise.race([ From f35f8046af9c6785b0ae2d99c76a94a117f1ec31 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:51:20 -0700 Subject: [PATCH 20/50] WIP: js-style-initialization --- .../contract-tests/entity/src/ClientEntity.ts | 17 ++------ .../sdk/browser/src/BrowserDataManager.ts | 1 + .../sdk/react-native/src/MobileDataManager.ts | 40 +++++++++++++++++++ .../react-native/src/ReactNativeLDClient.ts | 4 +- packages/shared/sdk-client/src/DataManager.ts | 36 ++--------------- .../sdk-client/src/api/ConnectionMode.ts | 5 ++- packages/shared/sdk-client/src/index.ts | 2 +- 7 files changed, 54 insertions(+), 51 deletions(-) create mode 100644 packages/sdk/react-native/src/MobileDataManager.ts diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index e7cbd756ca..ab11b49cc2 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -1,10 +1,4 @@ -import { - AutoEnvAttributes, - init, - LDClient, - LDLogger, - LDOptions, -} from '@launchdarkly/js-client-sdk'; +import { init, LDClient, LDLogger, LDOptions } from '@launchdarkly/js-client-sdk'; import { CommandParams, CommandType, ValueType } from './CommandParams'; import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; @@ -35,7 +29,6 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { } if (options.polling) { - cf.initialConnectionMode = 'polling'; if (options.polling.baseUri) { cf.baseUri = options.polling.baseUri; } @@ -48,7 +41,7 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { if (options.streaming.baseUri) { cf.streamUri = options.streaming.baseUri; } - cf.initialConnectionMode = 'streaming'; + cf.stream = true; cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } @@ -205,11 +198,7 @@ export async function newSdkClientEntity(options: CreateInstanceParams) { options.configuration.clientSide?.initialUser || options.configuration.clientSide?.initialContext || makeDefaultInitialContext(); - const client = init( - options.configuration.credential || 'unknown-env-id', - AutoEnvAttributes.Disabled, // TODO: Determine capability. - sdkConfig, - ); + const client = init(options.configuration.credential || 'unknown-env-id', sdkConfig); let failed = false; try { await Promise.race([ diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 1d1084c5e2..58df8f73fe 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -40,6 +40,7 @@ export default class BrowserDataManager extends DefaultDataManager { diagnosticsManager, ); } + override async identify( identifyResolve: () => void, identifyReject: (err: Error) => void, diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts new file mode 100644 index 0000000000..e8525b8a03 --- /dev/null +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -0,0 +1,40 @@ +import { Context, DefaultDataManager, LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common'; + +export default class MobileDataManager extends DefaultDataManager { + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + const offline = this.connectionMode === 'offline'; + // In offline mode we do not support waiting for results. + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults) { + this.logger.debug('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this.logger.debug( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + if (this.connectionMode === 'offline') { + if (loadedFromCache) { + this.logger.debug('Offline identify - using cached flags.'); + } else { + this.logger.debug( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + identifyResolve(); + } + } else { + // Context has been validated in LDClientImpl.identify + this.setupConnection(context, identifyResolve, identifyReject); + } + } +} diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index c6c91166b1..11f88b8c37 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -5,7 +5,6 @@ import { BasicLogger, type Configuration, ConnectionMode, - DefaultDataManager, Encoding, FlagManager, internal, @@ -19,6 +18,7 @@ import createPlatform from './platform'; import { ConnectionDestination, ConnectionManager } from './platform/ConnectionManager'; import LDOptions from './RNOptions'; import RNStateDetector from './RNStateDetector'; +import MobileDataManager from './MobileDataManager'; /** * The React Native LaunchDarkly client. Instantiate this class to create an @@ -76,7 +76,7 @@ export default class ReactNativeLDClient extends LDClientImpl { emitter: LDEmitter, diagnosticsManager?: internal.DiagnosticsManager, ) => - new DefaultDataManager( + new MobileDataManager( platform, flagManager, sdkKey, diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 9fbd9bcb5f..6da1a6ce5c 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -43,7 +43,7 @@ export interface DataManagerFactory { ): DataManager; } -export class DefaultDataManager implements DataManager { +export abstract class BaseDataManager implements DataManager { protected updateProcessor?: subsystem.LDStreamProcessor; protected readonly logger: LDLogger; protected connectionMode: ConnectionMode = 'streaming'; @@ -99,42 +99,12 @@ export class DefaultDataManager implements DataManager { } } - async identify( + abstract identify( identifyResolve: () => void, identifyReject: (err: Error) => void, context: Context, identifyOptions?: LDIdentifyOptions, - ): Promise { - this.context = context; - const offline = this.connectionMode === 'offline'; - // In offline mode we do not support waiting for results. - const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; - - const loadedFromCache = await this.flagManager.loadCached(context); - if (loadedFromCache && !waitForNetworkResults) { - this.logger.debug('Identify completing with cached flags'); - identifyResolve(); - } - if (loadedFromCache && waitForNetworkResults) { - this.logger.debug( - 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', - ); - } - - if (this.connectionMode === 'offline') { - if (loadedFromCache) { - this.logger.debug('Offline identify - using cached flags.'); - } else { - this.logger.debug( - 'Offline identify - no cached flags, using defaults or already loaded flags.', - ); - identifyResolve(); - } - } else { - // Context has been validated in LDClientImpl.identify - this.setupConnection(context, identifyResolve, identifyReject); - } - } + ): Promise; protected setupConnection( context: Context, diff --git a/packages/shared/sdk-client/src/api/ConnectionMode.ts b/packages/shared/sdk-client/src/api/ConnectionMode.ts index b0e84906a7..d25e203a01 100644 --- a/packages/shared/sdk-client/src/api/ConnectionMode.ts +++ b/packages/shared/sdk-client/src/api/ConnectionMode.ts @@ -10,7 +10,10 @@ * streaming - The SDK will use a streaming connection to receive updates from LaunchDarkly. * * polling - The SDK will make polling requests to receive updates from LaunchDarkly. + * + * automatic - The SDK will handle the connection automatically and may make polling or streaming + * requests. */ -type ConnectionMode = 'offline' | 'streaming' | 'polling'; +type ConnectionMode = 'offline' | 'streaming' | 'polling' | 'automatic'; export default ConnectionMode; diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index aefe44563a..2f677e6485 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -27,7 +27,7 @@ export type { Configuration } from './configuration/Configuration'; export type { LDEmitter }; export { DataSourcePaths } from './streaming'; -export { DefaultDataManager } from './DataManager'; +export { BaseDataManager as DefaultDataManager } from './DataManager'; export { Requestor }; export { LDClientImpl, LDClientInternalOptions }; From 80b5b61eaffe296ff65b1e8f1fc3af537c75022a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:05:12 -0700 Subject: [PATCH 21/50] Disable goals. --- packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index a08cc84f67..5e0fbf664e 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -66,6 +66,8 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { }; } + cf.fetchGoals = false; + return cf; } From a83184dfca2ba1d51382174f5770fe94f71acabb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:44:23 -0700 Subject: [PATCH 22/50] Fix identify options. --- packages/sdk/browser/contract-tests/entity/package.json | 2 +- packages/sdk/browser/src/BrowserClient.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/sdk/browser/contract-tests/entity/package.json b/packages/sdk/browser/contract-tests/entity/package.json index f68b645b9f..1355a1a1f8 100644 --- a/packages/sdk/browser/contract-tests/entity/package.json +++ b/packages/sdk/browser/contract-tests/entity/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "Contract test service implementation for @launchdarkly/js-client-sdk", "scripts": { - "start": "vite --open=true", + "start": "tsc --noEmit && vite --open=true", "build": "tsc --noEmit && vite build", "lint": "eslint ./src", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index a275fddccd..f7eb14be81 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -15,6 +15,7 @@ import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; import BrowserPlatform from './platform/BrowserPlatform'; +import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions'; /** * We are not supporting dynamically setting the connection mode on the LDClient. @@ -135,8 +136,8 @@ export class BrowserClient extends LDClientImpl { }; } - override async identify(context: LDContext): Promise { - await super.identify(context); + override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { + await super.identify(context, identifyOptions); this.goalManager?.startTracking(); } } From bba88a10a9aeaf73570bb66170151ff4bf8e1a53 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 11:02:12 -0700 Subject: [PATCH 23/50] Add todo. --- packages/sdk/browser/src/BrowserDataManager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 58df8f73fe..9231a12fd9 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -52,6 +52,8 @@ export default class BrowserDataManager extends DefaultDataManager { const plainContextString = JSON.stringify(Context.toLDContext(context)); const requestor = this.getRequestor(plainContextString); + // TODO: Handle wait for network results in a meaningful way. SDK-707 + try { const payload = await requestor.requestPayload(); const listeners = this.createStreamListeners(context, identifyResolve); From 22b89a9ea67cb03abb874b707bda9b17c3af3e9c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:32:01 -0700 Subject: [PATCH 24/50] WIP --- packages/sdk/browser/package.json | 2 +- packages/sdk/browser/src/BrowserClient.ts | 48 ++----------- .../sdk/browser/src/BrowserDataManager.ts | 18 +++-- .../sdk/react-native/src/MobileDataManager.ts | 69 ++++++++++++++++++- packages/sdk/react-native/src/RNOptions.ts | 12 +++- .../react-native/src/ReactNativeLDClient.ts | 25 ++++--- packages/sdk/react-native/src/options.ts | 16 ++++- .../src/internal/events/EventProcessor.ts | 5 -- packages/shared/sdk-client/src/DataManager.ts | 63 ----------------- .../shared/sdk-client/src/LDClientImpl.ts | 43 +----------- .../sdk-client/src/api/ConnectionMode.ts | 5 +- .../shared/sdk-client/src/api/LDClient.ts | 15 ---- .../shared/sdk-client/src/api/LDOptions.ts | 10 --- .../src/configuration/Configuration.ts | 5 +- .../src/configuration/validators.ts | 11 --- .../src/events/createEventProcessor.ts | 2 - 16 files changed, 135 insertions(+), 214 deletions(-) diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index c03638f278..70ab13df7b 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -27,7 +27,7 @@ ], "scripts": { "clean": "rimraf dist", - "build": "rollup -c rollup.config.js", + "build": "tsc --noEmit && rollup -c rollup.config.js", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", "test": "NODE_OPTIONS=--experimental-vm-modules npx jest --runInBand", diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 571adcfc08..ae29af5561 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -13,13 +13,13 @@ import { LDHeaders, Platform, } from '@launchdarkly/js-client-sdk-common'; +import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions'; import BrowserDataManager from './BrowserDataManager'; import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; import BrowserPlatform from './platform/BrowserPlatform'; -import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions'; /** * We are not supporting dynamically setting the connection mode on the LDClient. @@ -53,7 +53,6 @@ export class BrowserClient extends LDClientImpl { const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; const platform = overridePlatform ?? new BrowserPlatform(logger); -<<<<<<< HEAD const validatedBrowserOptions = validateOptions(options, logger); const { eventUrlTransformer } = validatedBrowserOptions; super( @@ -111,26 +110,8 @@ export class BrowserClient extends LDClientImpl { ), }, ); -======= - const ValidatedBrowserOptions = validateOptions(options, logger); - const { eventUrlTransformer } = ValidatedBrowserOptions; - super(clientSideId, autoEnvAttributes, platform, filterToBaseOptions(options), { - analyticsEventPath: `/events/bulk/${clientSideId}`, - diagnosticEventPath: `/events/diagnostic/${clientSideId}`, - includeAuthorizationHeader: false, - highTimeoutThreshold: 5, - userAgentHeaderName: 'x-launchdarkly-user-agent', - trackEventModifier: (event: internal.InputCustomEvent) => - new internal.InputCustomEvent( - event.context, - event.key, - event.data, - event.metricValue, - event.samplingRatio, - eventUrlTransformer(window.location.href), - ), - }); ->>>>>>> origin/rlamb/fix-browser-contract-test-build + + this.setEventSendingEnabled(true, false); if (validatedBrowserOptions.fetchGoals) { this.goalManager = new GoalManager( @@ -178,8 +159,8 @@ export class BrowserClient extends LDClientImpl { } } - override async identify(context: LDContext): Promise { - await super.identify(context); + override async identify(context: LDContext, identifyOptions: LDIdentifyOptions): Promise { + await super.identify(context, identifyOptions); this.goalManager?.startTracking(); } @@ -192,24 +173,5 @@ export class BrowserClient extends LDClientImpl { } } -<<<<<<< HEAD // TODO: Setup event listeners. -======= - override getPollingPaths(): DataSourcePaths { - const parentThis = this; - return { - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/sdk/evalx/${parentThis.clientSideId}/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/sdk/evalx/${parentThis.clientSideId}/context`; - }, - }; - } - - override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { - await super.identify(context, identifyOptions); - this.goalManager?.startTracking(); - } ->>>>>>> origin/rlamb/fix-browser-contract-test-build } diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 9231a12fd9..7a6f925914 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -74,14 +74,24 @@ export default class BrowserDataManager extends DefaultDataManager { } startDataSource() { - // Should always be streaming for browser SDKs for now. - if (this.connectionMode !== 'streaming') { - this.setConnectionMode('streaming'); - } else if (this.context && !this.updateProcessor) { + if (this.context && !this.updateProcessor) { this.setupConnection(this.context); } } + private setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); + + this.updateProcessor!.start(); + } + private getRequestor(plainContextString: string): Requestor { const paths = this.getPollingPaths(); const path = this.config.useReport diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts index e8525b8a03..623814ef6e 100644 --- a/packages/sdk/react-native/src/MobileDataManager.ts +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -1,6 +1,15 @@ -import { Context, DefaultDataManager, LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common'; +import { + ConnectionMode, + Context, + DefaultDataManager, + LDIdentifyOptions, +} from '@launchdarkly/js-client-sdk-common'; export default class MobileDataManager extends DefaultDataManager { + // Not implemented yet. + protected networkAvailable: boolean = true; + protected connectionMode: ConnectionMode = 'streaming'; + override async identify( identifyResolve: () => void, identifyReject: (err: Error) => void, @@ -37,4 +46,62 @@ export default class MobileDataManager extends DefaultDataManager { this.setupConnection(context, identifyResolve, identifyReject); } } + + private setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + switch (this.connectionMode) { + case 'streaming': + this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); + break; + case 'polling': + this.createPollingProcessor(rawContext, context, identifyResolve, identifyReject); + break; + default: + break; + } + this.updateProcessor!.start(); + } + + setNetworkAvailability(available: boolean): void { + this.networkAvailable = available; + } + + async setConnectionMode(mode: ConnectionMode): Promise { + if (this.connectionMode === mode) { + this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); + return; + } + + this.connectionMode = mode; + this.logger.debug(`setConnectionMode ${mode}.`); + + switch (mode) { + case 'offline': + this.updateProcessor?.close(); + break; + case 'polling': + case 'streaming': + if (this.context) { + // identify will start the update processor + this.setupConnection(this.context); + } + + break; + default: + this.logger.warn( + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, + ); + break; + } + } + + getConnectionMode(): ConnectionMode { + return this.connectionMode; + } } diff --git a/packages/sdk/react-native/src/RNOptions.ts b/packages/sdk/react-native/src/RNOptions.ts index c48c22e3ae..4df3134415 100644 --- a/packages/sdk/react-native/src/RNOptions.ts +++ b/packages/sdk/react-native/src/RNOptions.ts @@ -1,4 +1,4 @@ -import { LDOptions } from '@launchdarkly/js-client-sdk-common'; +import { ConnectionMode, LDOptions } from '@launchdarkly/js-client-sdk-common'; /** * Interface for providing custom storage implementations for react Native. @@ -95,6 +95,16 @@ export interface RNSpecificOptions { * Defaults to @react-native-async-storage/async-storage. */ readonly storage?: RNStorage; + + /** + * Sets the mode to use for connections when the SDK is initialized. + * + * @remarks + * Possible values are offline or streaming. See {@link ConnectionMode} for more information. + * + * @defaultValue streaming. + */ + initialConnectionMode?: ConnectionMode; } export default interface RNOptions extends LDOptions, RNSpecificOptions {} diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 15dc1de5c8..75eacf0c54 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -104,9 +104,10 @@ export default class ReactNativeLDClient extends LDClientImpl { internalOptions, ); + const dataManager = this.dataManager as MobileDataManager; const destination: ConnectionDestination = { setNetworkAvailability: (available: boolean) => { - this.setNetworkAvailability(available); + dataManager.setNetworkAvailability(available); }, setEventSendingEnabled: (enabled: boolean, flush: boolean) => { this.setEventSendingEnabled(enabled, flush); @@ -114,7 +115,7 @@ export default class ReactNativeLDClient extends LDClientImpl { setConnectionMode: async (mode: ConnectionMode) => { // Pass the connection mode to the base implementation. // The RN implementation will pass the connection mode through the connection manager. - this.baseSetConnectionMode(mode); + dataManager.setConnectionMode(mode); }, }; @@ -132,16 +133,24 @@ export default class ReactNativeLDClient extends LDClientImpl { ); } - private baseSetConnectionMode(mode: ConnectionMode) { - // Jest had problems with calls to super from nested arrow functions, so this method proxies the call. - super.setConnectionMode(mode); - } - - override async setConnectionMode(mode: ConnectionMode): Promise { + async setConnectionMode(mode: ConnectionMode): Promise { // Set the connection mode before setting offline, in case there is any mode transition work // such as flushing on entering the background. this.connectionManager.setConnectionMode(mode); // For now the data source connection and the event processing state are connected. this.connectionManager.setOffline(mode === 'offline'); } + + /** + * Gets the SDK connection mode. + */ + getConnectionMode(): ConnectionMode { + const dataManager = this.dataManager as MobileDataManager; + return dataManager.getConnectionMode(); + } + + isOffline() { + const dataManager = this.dataManager as MobileDataManager; + return dataManager.getConnectionMode() === 'offline'; + } } diff --git a/packages/sdk/react-native/src/options.ts b/packages/sdk/react-native/src/options.ts index ffc2b57e31..e981b4a31e 100644 --- a/packages/sdk/react-native/src/options.ts +++ b/packages/sdk/react-native/src/options.ts @@ -1,4 +1,5 @@ import { + ConnectionMode, LDLogger, LDOptions, OptionMessages, @@ -8,18 +9,29 @@ import { import RNOptions, { RNStorage } from './RNOptions'; +class ConnectionModeValidator implements TypeValidator { + is(u: unknown): u is ConnectionMode { + return u === 'offline' || u === 'streaming' || u === 'polling'; + } + getType(): string { + return 'ConnectionMode (offline | streaming | polling)'; + } +} + export interface ValidatedOptions { runInBackground: boolean; automaticNetworkHandling: boolean; automaticBackgroundHandling: boolean; storage?: RNStorage; + initialConnectionMode: ConnectionMode; } -const optDefaults = { +const optDefaults: ValidatedOptions = { runInBackground: false, automaticNetworkHandling: true, automaticBackgroundHandling: true, storage: undefined, + initialConnectionMode: 'streaming', }; const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = { @@ -27,6 +39,7 @@ const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = automaticNetworkHandling: TypeValidators.Boolean, automaticBackgroundHandling: TypeValidators.Boolean, storage: TypeValidators.Object, + initialConnectionMode: new ConnectionModeValidator(), }; export function filterToBaseOptions(opts: RNOptions): LDOptions { @@ -48,6 +61,7 @@ export default function validateOptions(opts: RNOptions, logger: LDLogger): Vali const value = opts[key]; if (value !== undefined) { if (validator.is(value)) { + // @ts-ignore The type inference has some problems here. output[key as keyof ValidatedOptions] = value as any; } else { logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 4e6acd128e..95aeb21890 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -130,7 +130,6 @@ export default class EventProcessor implements LDEventProcessor { baseHeaders: LDHeaders, private readonly contextDeduplicator?: LDContextDeduplicator, private readonly diagnosticsManager?: DiagnosticsManager, - start: boolean = true, ) { this.capacity = config.eventsCapacity; this.logger = clientContext.basicConfiguration.logger; @@ -140,10 +139,6 @@ export default class EventProcessor implements LDEventProcessor { config.allAttributesPrivate, config.privateAttributes.map((ref) => new AttributeReference(ref)), ); - - if (start) { - this.start(); - } } start() { diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 6da1a6ce5c..ee67a05dd3 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -10,7 +10,6 @@ import { subsystem, } from '@launchdarkly/js-sdk-common'; -import ConnectionMode from './api/ConnectionMode'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; import { Configuration } from './configuration/Configuration'; import { FlagManager } from './flag-manager/FlagManager'; @@ -27,10 +26,6 @@ export interface DataManager { context: Context, identifyOptions?: LDIdentifyOptions, ): Promise; - - setConnectionMode(mode: ConnectionMode): Promise; - - setNetworkAvailability(available: boolean): void; } export interface DataManagerFactory { @@ -46,10 +41,7 @@ export interface DataManagerFactory { export abstract class BaseDataManager implements DataManager { protected updateProcessor?: subsystem.LDStreamProcessor; protected readonly logger: LDLogger; - protected connectionMode: ConnectionMode = 'streaming'; protected context?: Context; - // Not implemented yet. - protected networkAvailable: boolean = true; constructor( protected readonly platform: Platform, @@ -63,40 +55,6 @@ export abstract class BaseDataManager implements DataManager { protected readonly diagnosticsManager?: internal.DiagnosticsManager, ) { this.logger = config.logger; - this.connectionMode = config.initialConnectionMode; - } - - setNetworkAvailability(available: boolean): void { - this.networkAvailable = available; - } - - async setConnectionMode(mode: ConnectionMode): Promise { - if (this.connectionMode === mode) { - this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); - return; - } - - this.connectionMode = mode; - this.logger.debug(`setConnectionMode ${mode}.`); - - switch (mode) { - case 'offline': - this.updateProcessor?.close(); - break; - case 'polling': - case 'streaming': - if (this.context) { - // identify will start the update processor - this.setupConnection(this.context); - } - - break; - default: - this.logger.warn( - `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, - ); - break; - } } abstract identify( @@ -106,27 +64,6 @@ export abstract class BaseDataManager implements DataManager { identifyOptions?: LDIdentifyOptions, ): Promise; - protected setupConnection( - context: Context, - identifyResolve?: () => void, - identifyReject?: (err: Error) => void, - ) { - const rawContext = Context.toLDContext(context)!; - - this.updateProcessor?.close(); - switch (this.connectionMode) { - case 'streaming': - this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); - break; - case 'polling': - this.createPollingProcessor(rawContext, context, identifyResolve, identifyReject); - break; - default: - break; - } - this.updateProcessor!.start(); - } - protected createPollingProcessor( context: LDContext, checkedContext: Context, diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index b712877051..88c964a797 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -18,7 +18,7 @@ import { TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { ConnectionMode, LDClient, type LDOptions } from './api'; +import { LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; import ConfigurationImpl from './configuration'; @@ -57,9 +57,7 @@ export default class LDClientImpl implements LDClient { private emitter: LDEmitter; private flagManager: DefaultFlagManager; - private eventSendingEnabled: boolean = true; - private networkAvailable: boolean = true; - private connectionMode: ConnectionMode; + private eventSendingEnabled: boolean = false; private baseHeaders: LDHeaders; protected dataManager: DataManager; @@ -83,7 +81,6 @@ export default class LDClientImpl implements LDClient { } this.config = new ConfigurationImpl(options, internalOptions); - this.connectionMode = this.config.initialConnectionMode; this.logger = this.config.logger; this.baseHeaders = defaultHeaders( @@ -107,7 +104,6 @@ export default class LDClientImpl implements LDClient { platform, this.baseHeaders, this.diagnosticsManager, - !this.isOffline(), ); this.emitter = new LDEmitter(); this.emitter.on('change', (c: LDContext, changedKeys: string[]) => { @@ -131,28 +127,6 @@ export default class LDClientImpl implements LDClient { ); } - /** - * Sets the SDK connection mode. - * - * @param mode - One of supported {@link ConnectionMode}. Default is 'streaming'. - */ - async setConnectionMode(mode: ConnectionMode): Promise { - // TODO: Set connection mode should have a timeout. It doesn't make sense for it to be the - // timeout from the most recent identify call. Or it needs to not be async. - return this.dataManager.setConnectionMode(mode); - } - - /** - * Gets the SDK connection mode. - */ - getConnectionMode(): ConnectionMode { - return this.connectionMode; - } - - isOffline() { - return this.connectionMode === 'offline'; - } - allFlags(): LDFlagSet { // extracting all flag values const result = Object.entries(this.flagManager.getAll()).reduce( @@ -500,19 +474,6 @@ export default class LDClientImpl implements LDClient { return this.variationDetail(key, defaultValue); } - /** - * Inform the client of the network state. Can be used to modify connection behavior. - * - * For instance the implementation may choose to suppress errors from connections if the client - * knows that there is no network available. - * @param _available True when there is an available network. - */ - protected setNetworkAvailability(available: boolean): void { - this.networkAvailable = available; - // Not yet supported. - this.dataManager.setNetworkAvailability(available); - } - /** * Enable/Disable event sending. * @param enabled True to enable event processing, false to disable. diff --git a/packages/shared/sdk-client/src/api/ConnectionMode.ts b/packages/shared/sdk-client/src/api/ConnectionMode.ts index d25e203a01..b0e84906a7 100644 --- a/packages/shared/sdk-client/src/api/ConnectionMode.ts +++ b/packages/shared/sdk-client/src/api/ConnectionMode.ts @@ -10,10 +10,7 @@ * streaming - The SDK will use a streaming connection to receive updates from LaunchDarkly. * * polling - The SDK will make polling requests to receive updates from LaunchDarkly. - * - * automatic - The SDK will handle the connection automatically and may make polling or streaming - * requests. */ -type ConnectionMode = 'offline' | 'streaming' | 'polling' | 'automatic'; +type ConnectionMode = 'offline' | 'streaming' | 'polling'; export default ConnectionMode; diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 8f26ad3993..d0ae19b962 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -75,14 +75,6 @@ export interface LDClient { */ flush(): Promise<{ error?: Error; result: boolean }>; - /** - * Gets the SDK connection mode. - * - * @remarks - * Possible values are offline or streaming. See {@link ConnectionMode} for more information. - */ - getConnectionMode(): ConnectionMode; - /** * Returns the client's current context. * @@ -231,13 +223,6 @@ export interface LDClient { */ on(key: string, callback: (...args: any[]) => void): void; - /** - * Sets the SDK connection mode. - * - * @param mode - One of supported {@link ConnectionMode}. By default, the SDK uses streaming. - */ - setConnectionMode(mode: ConnectionMode): void; - /** * Determines the string variation of a feature flag. * diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 70e6599bc7..9edd108c40 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -119,16 +119,6 @@ export interface LDOptions { */ flushInterval?: number; - /** - * Sets the mode to use for connections when the SDK is initialized. - * - * @remarks - * Possible values are offline or streaming. See {@link ConnectionMode} for more information. - * - * @defaultValue streaming. - */ - initialConnectionMode?: ConnectionMode; - /** * An object that will perform logging for the client. * diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index f4a265e1f2..9ffa8ba1b3 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -9,7 +9,7 @@ import { TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { ConnectionMode, type LDOptions } from '../api'; +import { type LDOptions } from '../api'; import validators from './validators'; const DEFAULT_POLLING_INTERVAL: number = 60 * 5; @@ -36,7 +36,6 @@ export interface Configuration { readonly useReport: boolean; readonly withReasons: boolean; readonly privateAttributes: string[]; - readonly initialConnectionMode: ConnectionMode; readonly tags: ApplicationTags; readonly applicationInfo?: { id?: string; @@ -84,8 +83,6 @@ export default class ConfigurationImpl implements Configuration { public readonly privateAttributes: string[] = []; - public readonly initialConnectionMode: ConnectionMode = 'streaming'; - public readonly tags: ApplicationTags; public readonly applicationInfo?: { id?: string; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 17851bff13..4ec0a449a3 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -3,18 +3,7 @@ import { TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; import { type LDOptions } from '../api'; -class ConnectionModeValidator implements TypeValidator { - is(u: unknown): boolean { - return u === 'offline' || u === 'streaming' || u === 'polling'; - } - - getType(): string { - return `offline | streaming | polling`; - } -} - const validators: Record = { - initialConnectionMode: new ConnectionModeValidator(), logger: TypeValidators.Object, maxCachedContexts: TypeValidators.numberWithMin(0), diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts index 47839a5276..87f8ddd3e9 100644 --- a/packages/shared/sdk-client/src/events/createEventProcessor.ts +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -8,7 +8,6 @@ const createEventProcessor = ( platform: Platform, baseHeaders: LDHeaders, diagnosticsManager?: internal.DiagnosticsManager, - start: boolean = false, ): internal.EventProcessor | undefined => { if (config.sendEvents) { return new internal.EventProcessor( @@ -17,7 +16,6 @@ const createEventProcessor = ( baseHeaders, undefined, diagnosticsManager, - start, ); } From d1a59c83eeff4af65352396321a6e561d933ddb9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:34:59 -0700 Subject: [PATCH 25/50] Lint --- .../configuration/Configuration.test.ts | 22 +++++++++---------- .../shared/sdk-client/src/api/LDClient.ts | 1 - .../shared/sdk-client/src/api/LDOptions.ts | 2 -- .../sdk-client/src/context/addAutoEnv.ts | 6 ++++- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index 6ce2922953..f8af0b79cc 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import Configuration from '../../src/configuration/Configuration'; +import ConfigurationImpl from '../../src/configuration/Configuration'; describe('Configuration', () => { beforeEach(() => { @@ -8,7 +8,7 @@ describe('Configuration', () => { }); it('has valid default values', () => { - const config = new Configuration(); + const config = new ConfigurationImpl(); expect(config).toMatchObject({ allAttributesPrivate: false, @@ -37,13 +37,13 @@ describe('Configuration', () => { }); it('allows specifying valid wrapperName', () => { - const config = new Configuration({ wrapperName: 'test' }); + const config = new ConfigurationImpl({ wrapperName: 'test' }); expect(config).toMatchObject({ wrapperName: 'test' }); }); it('warns and ignored invalid keys', () => { // @ts-ignore - const config = new Configuration({ baseballUri: 1 }); + const config = new ConfigurationImpl({ baseballUri: 1 }); expect(config.baseballUri).toBeUndefined(); expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unknown config option')); @@ -51,7 +51,7 @@ describe('Configuration', () => { it('converts boolean types', () => { // @ts-ignore - const config = new Configuration({ sendEvents: 0 }); + const config = new ConfigurationImpl({ sendEvents: 0 }); expect(config.sendEvents).toBeFalsy(); expect(console.error).toHaveBeenCalledWith( @@ -61,7 +61,7 @@ describe('Configuration', () => { it('ignores wrong type for number and logs appropriately', () => { // @ts-ignore - const config = new Configuration({ capacity: true }); + const config = new ConfigurationImpl({ capacity: true }); expect(config.capacity).toEqual(100); expect(console.error).toHaveBeenCalledWith( @@ -70,7 +70,7 @@ describe('Configuration', () => { }); it('enforces minimum flushInterval', () => { - const config = new Configuration({ flushInterval: 1 }); + const config = new ConfigurationImpl({ flushInterval: 1 }); expect(config.flushInterval).toEqual(2); expect(console.error).toHaveBeenNthCalledWith( @@ -80,14 +80,14 @@ describe('Configuration', () => { }); it('allows setting a valid maxCachedContexts', () => { - const config = new Configuration({ maxCachedContexts: 3 }); + const config = new ConfigurationImpl({ maxCachedContexts: 3 }); expect(config.maxCachedContexts).toBeDefined(); expect(console.error).not.toHaveBeenCalled(); }); it('enforces minimum maxCachedContext', () => { - const config = new Configuration({ maxCachedContexts: -1 }); + const config = new ConfigurationImpl({ maxCachedContexts: -1 }); expect(config.maxCachedContexts).toBeDefined(); expect(console.error).toHaveBeenNthCalledWith( @@ -103,7 +103,7 @@ describe('Configuration', () => { ['kebab-case-works'], ['snake_case_works'], ])('allow setting valid payload filter keys', (filter) => { - const config = new Configuration({ payloadFilterKey: filter }); + const config = new ConfigurationImpl({ payloadFilterKey: filter }); expect(config.payloadFilterKey).toEqual(filter); expect(console.error).toHaveBeenCalledTimes(0); }); @@ -111,7 +111,7 @@ describe('Configuration', () => { it.each([['invalid-@-filter'], ['_invalid-filter'], ['-invalid-filter']])( 'ignores invalid filters and logs a warning', (filter) => { - const config = new Configuration({ payloadFilterKey: filter }); + const config = new ConfigurationImpl({ payloadFilterKey: filter }); expect(config.payloadFilterKey).toBeUndefined(); expect(console.error).toHaveBeenNthCalledWith( 1, diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index d0ae19b962..710eef8140 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -1,6 +1,5 @@ import { LDContext, LDFlagSet, LDFlagValue, LDLogger } from '@launchdarkly/js-sdk-common'; -import ConnectionMode from './ConnectionMode'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './LDEvaluationDetail'; import { LDIdentifyOptions } from './LDIdentifyOptions'; diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 9edd108c40..5e2c115135 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -1,7 +1,5 @@ import type { LDLogger } from '@launchdarkly/js-sdk-common'; -import ConnectionMode from './ConnectionMode'; - export interface LDOptions { /** * Whether all context attributes (except the context key) should be marked as private, and diff --git a/packages/shared/sdk-client/src/context/addAutoEnv.ts b/packages/shared/sdk-client/src/context/addAutoEnv.ts index 85666f6403..1a39d70576 100644 --- a/packages/shared/sdk-client/src/context/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/context/addAutoEnv.ts @@ -103,7 +103,11 @@ export const addDeviceInfo = async (platform: Platform) => { return undefined; }; -export const addAutoEnv = async (context: LDContext, platform: Platform, config: ConfigurationImpl) => { +export const addAutoEnv = async ( + context: LDContext, + platform: Platform, + config: ConfigurationImpl, +) => { // LDUser is not supported for auto env reporting if (isLegacyUser(context)) { return context as LDUser; From 60ea015aa89e97693e77af962ad8b38082fe9361 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:44:05 -0700 Subject: [PATCH 26/50] Fix tests. --- .../browser/__tests__/BrowserClient.test.ts | 8 +- packages/sdk/browser/src/BrowserClient.ts | 2 +- .../sdk/browser/src/BrowserDataManager.ts | 8 +- .../sdk/react-native/src/MobileDataManager.ts | 39 +++- .../react-native/src/ReactNativeLDClient.ts | 3 + .../__tests__/LDClientImpl.events.test.ts | 56 ++++- .../__tests__/LDClientImpl.storage.test.ts | 110 +++++++-- .../sdk-client/__tests__/LDClientImpl.test.ts | 208 +++++++++++++++--- .../__tests__/LDClientImpl.timeout.test.ts | 97 +++++++- .../__tests__/LDClientImpl.variation.test.ts | 60 ++++- .../sdk-client/__tests__/TestDataManager.ts | 43 ++++ packages/shared/sdk-client/src/index.ts | 2 +- 12 files changed, 545 insertions(+), 91 deletions(-) create mode 100644 packages/shared/sdk-client/__tests__/TestDataManager.ts diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index b32bbac169..5cc4c5724d 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -141,7 +141,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + stream: false, logger, diagnosticOptOut: true, }, @@ -169,7 +169,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + stream: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => @@ -202,7 +202,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + stream: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => @@ -245,7 +245,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - initialConnectionMode: 'polling', + stream: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index ae29af5561..9d0b45a8ee 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -159,7 +159,7 @@ export class BrowserClient extends LDClientImpl { } } - override async identify(context: LDContext, identifyOptions: LDIdentifyOptions): Promise { + override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { await super.identify(context, identifyOptions); this.goalManager?.startTracking(); } diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 7a6f925914..8f0e627ceb 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -1,8 +1,8 @@ import { + BaseDataManager, Configuration, Context, DataSourcePaths, - DefaultDataManager, FlagManager, getPollingUri, internal, @@ -15,7 +15,7 @@ import { import { ValidatedOptions } from './options'; -export default class BrowserDataManager extends DefaultDataManager { +export default class BrowserDataManager extends BaseDataManager { constructor( platform: Platform, flagManager: FlagManager, @@ -48,7 +48,9 @@ export default class BrowserDataManager extends DefaultDataManager { _identifyOptions?: LDIdentifyOptions, ): Promise { this.context = context; - await this.flagManager.loadCached(context); + if (await this.flagManager.loadCached(context)) { + this.logger.debug('Identify - Flags loaded from cache. Continuing to initialize via a poll.'); + } const plainContextString = JSON.stringify(Context.toLDContext(context)); const requestor = this.getRequestor(plainContextString); diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts index 623814ef6e..5ea8c90b07 100644 --- a/packages/sdk/react-native/src/MobileDataManager.ts +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -1,15 +1,50 @@ import { + BaseDataManager, + Configuration, ConnectionMode, Context, - DefaultDataManager, + DataSourcePaths, + FlagManager, + internal, + LDEmitter, + LDHeaders, LDIdentifyOptions, + Platform, } from '@launchdarkly/js-client-sdk-common'; -export default class MobileDataManager extends DefaultDataManager { +import { ValidatedOptions } from './options'; + +export default class MobileDataManager extends BaseDataManager { // Not implemented yet. protected networkAvailable: boolean = true; protected connectionMode: ConnectionMode = 'streaming'; + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly rnConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + this.connectionMode = rnConfig.initialConnectionMode; + } + override async identify( identifyResolve: () => void, identifyReject: (err: Error) => void, diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 75eacf0c54..b4cc0db2f8 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -81,6 +81,7 @@ export default class ReactNativeLDClient extends LDClientImpl { flagManager, sdkKey, configuration, + validatedRnOptions, () => ({ pathGet(encoding: Encoding, _plainContextString: string): string { return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; @@ -104,6 +105,8 @@ export default class ReactNativeLDClient extends LDClientImpl { internalOptions, ); + this.setEventSendingEnabled(!this.isOffline(), false); + const dataManager = this.dataManager as MobileDataManager; const destination: ConnectionDestination = { setNetworkAvailability: (available: boolean) => { diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts index 093fcaef3a..70ef3c9b9c 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts @@ -1,10 +1,12 @@ import { AutoEnvAttributes, + base64UrlEncode, ClientContext, clone, Encoding, internal, LDContext, + LDHeaders, subsystem, } from '@launchdarkly/js-sdk-common'; import { @@ -13,10 +15,14 @@ import { MockEventProcessor, } from '@launchdarkly/private-js-mocks'; +import { Configuration } from '../src/configuration/Configuration'; +import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; +import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import TestDataManager from './TestDataManager'; type InputCustomEvent = internal.InputCustomEvent; type InputIdentifyEvent = internal.InputIdentifyEvent; @@ -80,18 +86,46 @@ describe('sdk-client object', () => { mockPlatform.crypto.randomUUID.mockReturnValue('random1'); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - }); - - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, }, - }); + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + ); }); afterEach(() => { diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts index 976dd2092d..882fa982d4 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -1,12 +1,23 @@ -import { AutoEnvAttributes, clone, Encoding, type LDContext } from '@launchdarkly/js-sdk-common'; +import { + AutoEnvAttributes, + base64UrlEncode, + clone, + Encoding, + internal, + type LDContext, + LDHeaders, +} from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; +import { Configuration } from '../src/configuration/Configuration'; import { toMulti } from '../src/context/addAutoEnv'; +import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; import LDEmitter from '../src/LDEmitter'; import { Flags, PatchFlag } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import TestDataManager from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -51,19 +62,47 @@ describe('sdk-client storage', () => { } }); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - }); - - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + inEmitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/get'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + }), + baseHeaders, + inEmitter, + diagnosticsManager, + ), + ); // @ts-ignore emitter = ldc.emitter; @@ -120,10 +159,47 @@ describe('sdk-client storage', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + inEmitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/get'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + }), + baseHeaders, + inEmitter, + diagnosticsManager, + ), + ); // @ts-ignore emitter = ldc.emitter; jest.spyOn(emitter as LDEmitter, 'emit'); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 4e732d114b..68f85c7cb9 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -1,10 +1,23 @@ -import { AutoEnvAttributes, clone, Encoding, Hasher, LDContext } from '@launchdarkly/js-sdk-common'; +import { + AutoEnvAttributes, + base64UrlEncode, + clone, + Encoding, + Hasher, + internal, + LDContext, + LDHeaders, +} from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; +import { Configuration } from '../src/configuration/Configuration'; +import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; +import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import TestDataManager from './TestDataManager'; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; @@ -43,15 +56,6 @@ describe('sdk-client object', () => { }; mockPlatform.crypto.createHash.mockReturnValue(hasher); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/get'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/report'; - }, - }); - simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; mockPlatform.requests.getEventSourceCapabilities.mockImplementation(() => ({ readTimeout: true, @@ -66,10 +70,47 @@ describe('sdk-client object', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/get'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + ); }); afterEach(async () => { @@ -135,11 +176,48 @@ describe('sdk-client object', () => { }); mockPlatform.requests.createEventSource = mockCreateEventSource; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - withReasons: true, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + withReasons: true, + }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + ); await ldc.identify(carContext); @@ -160,11 +238,48 @@ describe('sdk-client object', () => { }); mockPlatform.requests.createEventSource = mockCreateEventSource; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - useReport: true, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + useReport: true, + }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/get'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + ); await ldc.identify(carContext); @@ -179,10 +294,47 @@ describe('sdk-client object', () => { simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; const carContext: LDContext = { kind: 'car', key: 'test-car' }; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + ); await ldc.identify(carContext); const c = ldc.getContext(); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts index bfe2a5df74..af9c3f8a39 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts @@ -1,11 +1,23 @@ -import { AutoEnvAttributes, clone, Encoding, LDContext } from '@launchdarkly/js-sdk-common'; +import { + AutoEnvAttributes, + base64UrlEncode, + clone, + Encoding, + internal, + LDContext, + LDHeaders, +} from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; +import { Configuration } from '../src/configuration/Configuration'; import { toMulti } from '../src/context/addAutoEnv'; +import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; +import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import TestDataManager from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -41,18 +53,47 @@ describe('sdk-client identify timeout', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, }, - }); + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + ); }); afterEach(() => { @@ -129,6 +170,38 @@ describe('sdk-client identify timeout', () => { logger, sendEvents: false, }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), { highTimeoutThreshold }, ); const customTimeout = 10; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts index 664a54ca2f..1d88afa9b5 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts @@ -1,16 +1,23 @@ import { AutoEnvAttributes, + base64UrlEncode, clone, Context, Encoding, + internal, LDContext, + LDHeaders, } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; +import { Configuration } from '../src/configuration/Configuration'; +import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; +import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import TestDataManager from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -31,14 +38,6 @@ let defaultPutResponse: Flags; describe('sdk-client object', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); - jest.spyOn(LDClientImpl.prototype as any, 'getStreamingPaths').mockReturnValue({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - }); simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; mockPlatform.requests.createEventSource.mockImplementation( @@ -49,10 +48,47 @@ describe('sdk-client object', () => { }, ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + mockPlatform, + flagManager, + testSdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + ); }); afterEach(() => { diff --git a/packages/shared/sdk-client/__tests__/TestDataManager.ts b/packages/shared/sdk-client/__tests__/TestDataManager.ts new file mode 100644 index 0000000000..0d46013685 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/TestDataManager.ts @@ -0,0 +1,43 @@ +import { Context } from '@launchdarkly/js-sdk-common'; +import { LDIdentifyOptions } from '../src/api'; +import { BaseDataManager } from '../src/DataManager'; + + +export default class TestDataManager extends BaseDataManager { + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults) { + this.logger.debug('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this.logger.debug( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + this.setupConnection(context, identifyResolve, identifyReject); + } + + private setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + + this.createStreamingProcessor(rawContext, context, identifyResolve, identifyReject); + + this.updateProcessor!.start(); + } +} diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 2f677e6485..038221ba5d 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -27,7 +27,7 @@ export type { Configuration } from './configuration/Configuration'; export type { LDEmitter }; export { DataSourcePaths } from './streaming'; -export { BaseDataManager as DefaultDataManager } from './DataManager'; +export { BaseDataManager } from './DataManager'; export { Requestor }; export { LDClientImpl, LDClientInternalOptions }; From 2bdc298a10492455dcedd0a4a62cc19bf67cfe34 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:55:42 -0700 Subject: [PATCH 27/50] Testing progress. --- .../__tests__/BrowserDataManager.test.ts | 194 ++++++++++++ .../__tests__/MobileDataManager.test.ts | 296 ++++++++++++++++++ .../src/configuration/Configuration.ts | 5 +- 3 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/browser/__tests__/BrowserDataManager.test.ts create mode 100644 packages/sdk/react-native/__tests__/MobileDataManager.test.ts diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts new file mode 100644 index 0000000000..c9dc63d72a --- /dev/null +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -0,0 +1,194 @@ +import { + ApplicationTags, + Configuration, + Context, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + LDLogger, + Platform, + Response, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import BrowserDataManager from '../src/BrowserDataManager'; +import { ValidatedOptions } from '../src/options'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +describe('given a BrowserDataManager with mocked dependencies', () => { + let platform: jest.Mocked; + let flagManager: jest.Mocked; + let config: Configuration; + let browserConfig: ValidatedOptions; + let baseHeaders: LDHeaders; + let emitter: jest.Mocked; + let diagnosticsManager: jest.Mocked; + let browserDataManager: BrowserDataManager; + let logger: LDLogger; + + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + config = { + logger, + baseUri: 'string', + eventsUri: 'string', + streamUri: 'string', + maxCachedContexts: 5, + capacity: 100, + diagnosticRecordingInterval: 1000, + flushInterval: 1000, + streamInitialReconnectDelay: 1000, + allAttributesPrivate: false, + debug: true, + diagnosticOptOut: false, + sendEvents: false, + sendLDHeaders: true, + useReport: false, + withReasons: true, + privateAttributes: [], + tags: new ApplicationTags({}), + serviceEndpoints: new ServiceEndpoints('', ''), + pollInterval: 1000, + userAgentHeaderName: 'user-agent', + trackEventModifier: (event) => event, + } + const mockedFetch = mockFetch('{"flagA": true}', 200); + platform = { + requests: { + fetch: mockedFetch, + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + } as unknown as jest.Mocked; + + flagManager = { + loadCached: jest.fn(), + } as unknown as jest.Mocked; + + browserConfig = { stream: true } as ValidatedOptions; + baseHeaders = {}; + emitter = { + emit: jest.fn(), + } as unknown as jest.Mocked; + diagnosticsManager = {} as unknown as jest.Mocked; + + browserDataManager = new BrowserDataManager( + platform, + flagManager, + 'test-credential', + config, + browserConfig, + () => ({ + pathGet: jest.fn(), + pathReport: jest.fn(), + }), + () => ({ + pathGet: jest.fn(), + pathReport: jest.fn(), + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should load cached flags and continue to initialize via a poll', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await browserDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + 'Identify - Flags loaded from cache. Continuing to initialize via a poll.', + ); + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(platform.requests.fetch).toHaveBeenCalled(); + }); + + it('should set up streaming connection if stream is enabled', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await browserDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + }); + + it('should not set up streaming connection if stream is disabled', async () => { + browserConfig.stream = false; + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await browserDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + }); + + // it('should stop the data source', () => { + // const mockClose = jest.fn(); + // browserDataManager.updateProcessor = { close: mockClose } as any; + + // browserDataManager.stopDataSource(); + + // expect(mockClose).toHaveBeenCalled(); + // expect(browserDataManager.updateProcessor).toBeUndefined(); + // }); + + // it('should start the data source if context exists', () => { + // const mockSetupConnection = jest.spyOn(browserDataManager as any, 'setupConnection'); + // browserDataManager.context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + + // browserDataManager.startDataSource(); + + // expect(mockSetupConnection).toHaveBeenCalled(); + // }); + + // it('should not start the data source if context does not exist', () => { + // const mockSetupConnection = jest.spyOn(browserDataManager as any, 'setupConnection'); + // browserDataManager.context = undefined; + + // browserDataManager.startDataSource(); + + // expect(mockSetupConnection).not.toHaveBeenCalled(); + // }); +}); diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts new file mode 100644 index 0000000000..9f576c85f5 --- /dev/null +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -0,0 +1,296 @@ +import { + ApplicationTags, + base64UrlEncode, + Configuration, + Context, + Encoding, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + LDLogger, + Platform, + Response, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import MobileDataManager from '../src/MobileDataManager'; +import { ValidatedOptions } from '../src/options'; +import PlatformCrypto from '../src/platform/crypto'; +import PlatformEncoding from '../src/platform/PlatformEncoding'; +import PlatformInfo from '../src/platform/PlatformInfo'; +import PlatformStorage from '../src/platform/PlatformStorage'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +/** + * Mocks fetch. Returns the fetch jest.Mock object. + * @param remoteJson + * @param statusCode + */ +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +describe('given a MobileDataManager with mocked dependencies', () => { + let platform: jest.Mocked; + let flagManager: jest.Mocked; + let config: Configuration; + let rnConfig: ValidatedOptions; + let baseHeaders: LDHeaders; + let emitter: jest.Mocked; + let diagnosticsManager: jest.Mocked; + let mobileDataManager: MobileDataManager; + let logger: LDLogger; + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + config = { + logger, + baseUri: 'string', + eventsUri: 'string', + streamUri: 'string', + maxCachedContexts: 5, + capacity: 100, + diagnosticRecordingInterval: 1000, + flushInterval: 1000, + streamInitialReconnectDelay: 1000, + allAttributesPrivate: false, + debug: true, + diagnosticOptOut: false, + sendEvents: false, + sendLDHeaders: true, + useReport: false, + withReasons: true, + privateAttributes: [], + tags: new ApplicationTags({}), + serviceEndpoints: new ServiceEndpoints('', ''), + pollInterval: 1000, + userAgentHeaderName: 'user-agent', + trackEventModifier: (event) => event, + }; + const mockedFetch = mockFetch('{"flagA": true}', 200); + platform = { + crypto: new PlatformCrypto(), + info: new PlatformInfo(config.logger), + requests: { + createEventSource: jest.fn((streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), + })), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + storage: new PlatformStorage(config.logger), + encoding: new PlatformEncoding(), + } as unknown as jest.Mocked; + + flagManager = { + loadCached: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + init: jest.fn(), + upsert: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as jest.Mocked; + + rnConfig = { initialConnectionMode: 'streaming' } as ValidatedOptions; + baseHeaders = {}; + emitter = { + emit: jest.fn(), + } as unknown as jest.Mocked; + diagnosticsManager = {} as unknown as jest.Mocked; + + mobileDataManager = new MobileDataManager( + platform, + flagManager, + 'test-credential', + config, + rnConfig, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should initialize with the correct initial connection mode', () => { + expect(mobileDataManager.getConnectionMode()).toBe('streaming'); + }); + + it('should set and get connection mode', async () => { + await mobileDataManager.setConnectionMode('polling'); + expect(mobileDataManager.getConnectionMode()).toBe('polling'); + + await mobileDataManager.setConnectionMode('streaming'); + expect(mobileDataManager.getConnectionMode()).toBe('streaming'); + + await mobileDataManager.setConnectionMode('offline'); + expect(mobileDataManager.getConnectionMode()).toBe('offline'); + }); + + it('should log when connection mode remains the same', async () => { + const initialMode = mobileDataManager.getConnectionMode(); + await mobileDataManager.setConnectionMode(initialMode); + expect(logger.debug).toHaveBeenCalledWith( + `setConnectionMode ignored. Mode is already '${initialMode}'.`, + ); + expect(mobileDataManager.getConnectionMode()).toBe(initialMode); + }); + + it('uses streaming when the connection mode is streaming', async () => { + mobileDataManager.setConnectionMode('streaming'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('uses polling when the connection mode is polling', async () => { + mobileDataManager.setConnectionMode('polling'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).toHaveBeenCalled(); + }); + + it('makes no connection when offline', async () => { + mobileDataManager.setConnectionMode('offline'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('should load cached flags and resolve the identify', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith('Identify completing with cached flags'); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + }); + + it('should log that it loaded cached values, but is waiting for the network result', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: true }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).not.toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify without cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(false); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify with cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith('Offline identify - using cached flags.'); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 9ffa8ba1b3..b155c433f7 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -3,6 +3,7 @@ import { createSafeLogger, internal, LDFlagSet, + LDLogger, NumberWithMinimum, OptionMessages, ServiceEndpoints, @@ -19,7 +20,7 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions { } export interface Configuration { - readonly logger: ReturnType; + readonly logger: LDLogger; readonly baseUri: string; readonly eventsUri: string; readonly streamUri: string; @@ -59,7 +60,7 @@ export default class ConfigurationImpl implements Configuration { public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; - public readonly logger = createSafeLogger(); + public readonly logger: LDLogger = createSafeLogger(); public readonly baseUri = ConfigurationImpl.DEFAULT_POLLING; public readonly eventsUri = ServiceEndpoints.DEFAULT_EVENTS; From 16b7732b4b8a7b1a826c225f08420059373bfaa9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 09:38:26 -0700 Subject: [PATCH 28/50] More tests --- .../browser/__tests__/BrowserClient.test.ts | 14 +- .../__tests__/BrowserDataManager.test.ts | 201 ++++++++++++++---- packages/sdk/browser/__tests__/MockHasher.ts | 13 ++ .../sdk/browser/src/BrowserDataManager.ts | 21 +- 4 files changed, 186 insertions(+), 63 deletions(-) create mode 100644 packages/sdk/browser/__tests__/MockHasher.ts diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 5cc4c5724d..11d88888c1 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -4,7 +4,6 @@ import { AutoEnvAttributes, EventSourceCapabilities, EventSourceInitDict, - Hasher, LDLogger, PlatformData, Requests, @@ -12,6 +11,7 @@ import { } from '@launchdarkly/js-client-sdk-common'; import { BrowserClient } from '../src/BrowserClient'; +import { MockHasher } from './MockHasher'; function mockResponse(value: string, statusCode: number) { const response: Response = { @@ -79,18 +79,6 @@ function makeRequests(): Requests { }; } -class MockHasher implements Hasher { - update(_data: string): Hasher { - return this; - } - digest?(_encoding: string): string { - return 'hashed'; - } - async asyncDigest?(_encoding: string): Promise { - return 'hashed'; - } -} - describe('given a mock platform for a BrowserClient', () => { const logger: LDLogger = { debug: jest.fn(), diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index c9dc63d72a..806ef0814f 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -1,7 +1,12 @@ +import { jest } from '@jest/globals'; +import { TextEncoder } from 'node:util'; + import { ApplicationTags, + base64UrlEncode, Configuration, Context, + Encoding, FlagManager, internal, LDEmitter, @@ -14,15 +19,26 @@ import { } from '@launchdarkly/js-client-sdk-common'; import BrowserDataManager from '../src/BrowserDataManager'; -import { ValidatedOptions } from '../src/options'; +import validateOptions, { ValidatedOptions } from '../src/options'; +import BrowserEncoding from '../src/platform/BrowserEncoding'; +import BrowserInfo from '../src/platform/BrowserInfo'; +import LocalStorage from '../src/platform/LocalStorage'; +import { MockHasher } from './MockHasher'; + +global.TextEncoder = TextEncoder; function mockResponse(value: string, statusCode: number) { const response: Response = { headers: { + // @ts-ignore get: jest.fn(), + // @ts-ignore keys: jest.fn(), + // @ts-ignore values: jest.fn(), + // @ts-ignore entries: jest.fn(), + // @ts-ignore has: jest.fn(), }, status: statusCode, @@ -32,8 +48,14 @@ function mockResponse(value: string, statusCode: number) { return Promise.resolve(response); } +/** + * Mocks fetch. Returns the fetch jest.Mock object. + * @param remoteJson + * @param statusCode + */ function mockFetch(value: string, statusCode: number = 200) { const f = jest.fn(); + // @ts-ignore f.mockResolvedValue(mockResponse(value, statusCode)); return f; } @@ -46,9 +68,8 @@ describe('given a BrowserDataManager with mocked dependencies', () => { let baseHeaders: LDHeaders; let emitter: jest.Mocked; let diagnosticsManager: jest.Mocked; - let browserDataManager: BrowserDataManager; + let dataManager: BrowserDataManager; let logger: LDLogger; - beforeEach(() => { logger = { error: jest.fn(), @@ -79,40 +100,67 @@ describe('given a BrowserDataManager with mocked dependencies', () => { pollInterval: 1000, userAgentHeaderName: 'user-agent', trackEventModifier: (event) => event, - } + }; const mockedFetch = mockFetch('{"flagA": true}', 200); platform = { + crypto: { + createHash: () => new MockHasher(), + randomUUID: () => '123', + }, + info: new BrowserInfo(), requests: { + createEventSource: jest.fn((streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), + })), fetch: mockedFetch, - createEventSource: jest.fn(), getEventSourceCapabilities: jest.fn(), }, + storage: new LocalStorage(config.logger), + encoding: new BrowserEncoding(), } as unknown as jest.Mocked; flagManager = { loadCached: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + init: jest.fn(), + upsert: jest.fn(), + on: jest.fn(), + off: jest.fn(), } as unknown as jest.Mocked; - browserConfig = { stream: true } as ValidatedOptions; + browserConfig = validateOptions({ stream: false }, logger); baseHeaders = {}; emitter = { emit: jest.fn(), } as unknown as jest.Mocked; diagnosticsManager = {} as unknown as jest.Mocked; - browserDataManager = new BrowserDataManager( + dataManager = new BrowserDataManager( platform, flagManager, 'test-credential', config, browserConfig, () => ({ - pathGet: jest.fn(), - pathReport: jest.fn(), + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, }), () => ({ - pathGet: jest.fn(), - pathReport: jest.fn(), + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, }), baseHeaders, emitter, @@ -124,71 +172,130 @@ describe('given a BrowserDataManager with mocked dependencies', () => { jest.resetAllMocks(); }); - it('should load cached flags and continue to initialize via a poll', async () => { + it('creates an event source when stream is true', async () => { + dataManager = new BrowserDataManager( + platform, + flagManager, + 'test-credential', + config, + validateOptions({ stream: true }, logger), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + }); + + it('should load cached flags and continue to poll to complete identify', async () => { const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); - const identifyOptions: LDIdentifyOptions = {}; + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; const identifyResolve = jest.fn(); const identifyReject = jest.fn(); flagManager.loadCached.mockResolvedValue(true); - await browserDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); expect(logger.debug).toHaveBeenCalledWith( - 'Identify - Flags loaded from cache. Continuing to initialize via a poll.', + '[BrowserDataManager] Identify - Flags loaded from cache. Continuing to initialize via a poll.', ); + expect(flagManager.loadCached).toHaveBeenCalledWith(context); - expect(platform.requests.fetch).toHaveBeenCalled(); + expect(identifyResolve).toHaveBeenCalled(); + expect(flagManager.init).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ flagA: { flag: true, version: undefined } }), + ); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); }); - it('should set up streaming connection if stream is enabled', async () => { + it('should identify from polling when there are no cached flags', async () => { const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); - const identifyOptions: LDIdentifyOptions = {}; + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; const identifyResolve = jest.fn(); const identifyReject = jest.fn(); - await browserDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + flagManager.loadCached.mockResolvedValue(false); - expect(platform.requests.createEventSource).toHaveBeenCalled(); + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).not.toHaveBeenCalledWith( + 'Identify - Flags loaded from cache. Continuing to initialize via a poll.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(flagManager.init).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ flagA: { flag: true, version: undefined } }), + ); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); }); - it('should not set up streaming connection if stream is disabled', async () => { - browserConfig.stream = false; + it('creates a stream when streaming is enabled after construction', async () => { const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); - const identifyOptions: LDIdentifyOptions = {}; + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; const identifyResolve = jest.fn(); const identifyReject = jest.fn(); - await browserDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.startDataSource(); + expect(platform.requests.createEventSource).toHaveBeenCalled(); }); - // it('should stop the data source', () => { - // const mockClose = jest.fn(); - // browserDataManager.updateProcessor = { close: mockClose } as any; - - // browserDataManager.stopDataSource(); - - // expect(mockClose).toHaveBeenCalled(); - // expect(browserDataManager.updateProcessor).toBeUndefined(); - // }); - - // it('should start the data source if context exists', () => { - // const mockSetupConnection = jest.spyOn(browserDataManager as any, 'setupConnection'); - // browserDataManager.context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); - - // browserDataManager.startDataSource(); + it('does not re-create the stream if it already running', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); - // expect(mockSetupConnection).toHaveBeenCalled(); - // }); + flagManager.loadCached.mockResolvedValue(false); - // it('should not start the data source if context does not exist', () => { - // const mockSetupConnection = jest.spyOn(browserDataManager as any, 'setupConnection'); - // browserDataManager.context = undefined; + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); - // browserDataManager.startDataSource(); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.startDataSource(); + dataManager.startDataSource(); + expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Update processor already active. Not changing state.', + ); + }); - // expect(mockSetupConnection).not.toHaveBeenCalled(); - // }); + it('does not start a stream if identify has not been called', async () => { + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.startDataSource(); + expect(platform.requests.createEventSource).not.toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Context not set, not starting update processor.', + ); + }); }); diff --git a/packages/sdk/browser/__tests__/MockHasher.ts b/packages/sdk/browser/__tests__/MockHasher.ts new file mode 100644 index 0000000000..c8da071294 --- /dev/null +++ b/packages/sdk/browser/__tests__/MockHasher.ts @@ -0,0 +1,13 @@ +import { Hasher } from '@launchdarkly/js-client-sdk-common'; + +export class MockHasher implements Hasher { + update(_data: string): Hasher { + return this; + } + digest?(_encoding: string): string { + return 'hashed'; + } + async asyncDigest?(_encoding: string): Promise { + return 'hashed'; + } +} diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 8f0e627ceb..748faa8758 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -15,6 +15,8 @@ import { import { ValidatedOptions } from './options'; +const logTag = '[BrowserDataManager]'; + export default class BrowserDataManager extends BaseDataManager { constructor( platform: Platform, @@ -41,6 +43,10 @@ export default class BrowserDataManager extends BaseDataManager { ); } + private debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + override async identify( identifyResolve: () => void, identifyReject: (err: Error) => void, @@ -49,7 +55,7 @@ export default class BrowserDataManager extends BaseDataManager { ): Promise { this.context = context; if (await this.flagManager.loadCached(context)) { - this.logger.debug('Identify - Flags loaded from cache. Continuing to initialize via a poll.'); + this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.'); } const plainContextString = JSON.stringify(Context.toLDContext(context)); const requestor = this.getRequestor(plainContextString); @@ -76,9 +82,18 @@ export default class BrowserDataManager extends BaseDataManager { } startDataSource() { - if (this.context && !this.updateProcessor) { - this.setupConnection(this.context); + if (this.updateProcessor) { + this.debugLog('Update processor already active. Not changing state.'); + return; } + + if (!this.context) { + this.debugLog('Context not set, not starting update processor.'); + return; + } + + this.debugLog('Starting update processor.'); + this.setupConnection(this.context); } private setupConnection( From fda525e46a67a6f6616e4704126f200d1d13c177 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 09:40:36 -0700 Subject: [PATCH 29/50] Lint. --- packages/shared/sdk-client/__tests__/TestDataManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/TestDataManager.ts b/packages/shared/sdk-client/__tests__/TestDataManager.ts index 0d46013685..35ca02425d 100644 --- a/packages/shared/sdk-client/__tests__/TestDataManager.ts +++ b/packages/shared/sdk-client/__tests__/TestDataManager.ts @@ -1,8 +1,8 @@ import { Context } from '@launchdarkly/js-sdk-common'; + import { LDIdentifyOptions } from '../src/api'; import { BaseDataManager } from '../src/DataManager'; - export default class TestDataManager extends BaseDataManager { override async identify( identifyResolve: () => void, @@ -30,7 +30,7 @@ export default class TestDataManager extends BaseDataManager { private setupConnection( context: Context, identifyResolve?: () => void, - identifyReject?: (err: Error) => void + identifyReject?: (err: Error) => void, ) { const rawContext = Context.toLDContext(context)!; From 772264138d046cae04b6a52f36751e1e4bfab9e1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:03:32 -0700 Subject: [PATCH 30/50] Cleanup sdk client tests. --- .../__tests__/LDClientImpl.events.test.ts | 36 +---- .../__tests__/LDClientImpl.storage.test.ts | 68 +-------- .../sdk-client/__tests__/LDClientImpl.test.ts | 134 +----------------- .../__tests__/LDClientImpl.timeout.test.ts | 68 +-------- .../__tests__/LDClientImpl.variation.test.ts | 35 +---- .../sdk-client/__tests__/TestDataManager.ts | 49 ++++++- 6 files changed, 63 insertions(+), 327 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts index 70ef3c9b9c..10373ec23e 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts @@ -16,13 +16,14 @@ import { } from '@launchdarkly/private-js-mocks'; import { Configuration } from '../src/configuration/Configuration'; +import { DataManagerFactory } from '../src/DataManager'; import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager from './TestDataManager'; +import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; type InputCustomEvent = internal.InputCustomEvent; type InputIdentifyEvent = internal.InputIdentifyEvent; @@ -93,38 +94,7 @@ describe('sdk-client object', () => { { logger, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); }); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts index 882fa982d4..bcebaec73a 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -17,7 +17,7 @@ import LDEmitter from '../src/LDEmitter'; import { Flags, PatchFlag } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager from './TestDataManager'; +import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -70,38 +70,7 @@ describe('sdk-client storage', () => { logger, sendEvents: false, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - inEmitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/get'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/report'; - }, - }), - baseHeaders, - inEmitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); // @ts-ignore @@ -167,38 +136,7 @@ describe('sdk-client storage', () => { logger, sendEvents: false, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - inEmitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/get'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/report'; - }, - }), - baseHeaders, - inEmitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); // @ts-ignore emitter = ldc.emitter; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 68f85c7cb9..b4e7b15d9f 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -17,7 +17,7 @@ import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager from './TestDataManager'; +import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; @@ -78,38 +78,7 @@ describe('sdk-client object', () => { logger, sendEvents: false, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/get'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/report'; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); }); @@ -185,38 +154,7 @@ describe('sdk-client object', () => { sendEvents: false, withReasons: true, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/meval`; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); await ldc.identify(carContext); @@ -247,38 +185,7 @@ describe('sdk-client object', () => { sendEvents: false, useReport: true, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/get'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path/report'; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); await ldc.identify(carContext); @@ -302,38 +209,7 @@ describe('sdk-client object', () => { logger, sendEvents: false, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/meval`; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); await ldc.identify(carContext); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts index af9c3f8a39..2e003fe684 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts @@ -17,7 +17,7 @@ import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager from './TestDataManager'; +import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -61,38 +61,7 @@ describe('sdk-client identify timeout', () => { logger, sendEvents: false, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/meval`; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); }); @@ -170,38 +139,7 @@ describe('sdk-client identify timeout', () => { logger, sendEvents: false, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), { highTimeoutThreshold }, ); const customTimeout = 10; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts index 1d88afa9b5..3f9972b57d 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts @@ -17,7 +17,7 @@ import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager from './TestDataManager'; +import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; @@ -56,38 +56,7 @@ describe('sdk-client object', () => { logger, sendEvents: false, }, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new TestDataManager( - mockPlatform, - flagManager, - testSdkKey, - configuration, - () => ({ - pathGet(encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return `/msdk/evalx/context`; - }, - }), - () => ({ - pathGet(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - pathReport(_encoding: Encoding, _plainContextString: string): string { - return '/stream/path'; - }, - }), - baseHeaders, - emitter, - diagnosticsManager, - ), + makeTestDataManagerFactory(testSdkKey, mockPlatform), ); }); diff --git a/packages/shared/sdk-client/__tests__/TestDataManager.ts b/packages/shared/sdk-client/__tests__/TestDataManager.ts index 35ca02425d..7976a1329a 100644 --- a/packages/shared/sdk-client/__tests__/TestDataManager.ts +++ b/packages/shared/sdk-client/__tests__/TestDataManager.ts @@ -1,7 +1,17 @@ -import { Context } from '@launchdarkly/js-sdk-common'; +import { + base64UrlEncode, + Context, + Encoding, + internal, + LDHeaders, + Platform, +} from '@launchdarkly/js-sdk-common'; import { LDIdentifyOptions } from '../src/api'; -import { BaseDataManager } from '../src/DataManager'; +import { Configuration } from '../src/configuration/Configuration'; +import { BaseDataManager, DataManagerFactory } from '../src/DataManager'; +import { FlagManager } from '../src/flag-manager/FlagManager'; +import LDEmitter from '../src/LDEmitter'; export default class TestDataManager extends BaseDataManager { override async identify( @@ -41,3 +51,38 @@ export default class TestDataManager extends BaseDataManager { this.updateProcessor!.start(); } } + +export function makeTestDataManagerFactory(sdkKey: string, platform: Platform): DataManagerFactory { + return ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + platform, + flagManager, + sdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); +} From 045a784c6d9980a586f7f614e80d27e7fc67079e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:12:52 -0700 Subject: [PATCH 31/50] Allow shared test code. --- packages/sdk/browser/jest.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 21105621a4..2b9f69f51b 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -6,5 +6,6 @@ export default { transform: { '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }], }, - testPathIgnorePatterns: ['./dist'], + testPathIgnorePatterns: ['./dist', './src'], + testMatch: ["**.test.ts"] }; From b09703825406d515e9a11664aacda2164350acfb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:18:54 -0700 Subject: [PATCH 32/50] Cleanup imports --- .../__tests__/LDClientImpl.events.test.ts | 9 +-------- .../__tests__/LDClientImpl.storage.test.ts | 14 ++------------ .../sdk-client/__tests__/LDClientImpl.test.ts | 16 ++-------------- .../__tests__/LDClientImpl.timeout.test.ts | 15 ++------------- .../__tests__/LDClientImpl.variation.test.ts | 16 ++-------------- 5 files changed, 9 insertions(+), 61 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts index 10373ec23e..3b6c6a084f 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts @@ -1,12 +1,9 @@ import { AutoEnvAttributes, - base64UrlEncode, ClientContext, clone, - Encoding, internal, LDContext, - LDHeaders, subsystem, } from '@launchdarkly/js-sdk-common'; import { @@ -15,15 +12,11 @@ import { MockEventProcessor, } from '@launchdarkly/private-js-mocks'; -import { Configuration } from '../src/configuration/Configuration'; -import { DataManagerFactory } from '../src/DataManager'; -import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; -import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; +import { makeTestDataManagerFactory } from './TestDataManager'; type InputCustomEvent = internal.InputCustomEvent; type InputIdentifyEvent = internal.InputIdentifyEvent; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts index bcebaec73a..186bbafb08 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -1,23 +1,13 @@ -import { - AutoEnvAttributes, - base64UrlEncode, - clone, - Encoding, - internal, - type LDContext, - LDHeaders, -} from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, type LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; -import { Configuration } from '../src/configuration/Configuration'; import { toMulti } from '../src/context/addAutoEnv'; -import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; import LDEmitter from '../src/LDEmitter'; import { Flags, PatchFlag } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index b4e7b15d9f..4eda5c715c 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -1,23 +1,11 @@ -import { - AutoEnvAttributes, - base64UrlEncode, - clone, - Encoding, - Hasher, - internal, - LDContext, - LDHeaders, -} from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, Hasher, LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; -import { Configuration } from '../src/configuration/Configuration'; -import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; -import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; +import { makeTestDataManagerFactory } from './TestDataManager'; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts index 2e003fe684..fd2bc60f5e 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts @@ -1,23 +1,12 @@ -import { - AutoEnvAttributes, - base64UrlEncode, - clone, - Encoding, - internal, - LDContext, - LDHeaders, -} from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; -import { Configuration } from '../src/configuration/Configuration'; import { toMulti } from '../src/context/addAutoEnv'; -import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; -import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts index 3f9972b57d..c360e33bdf 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts @@ -1,23 +1,11 @@ -import { - AutoEnvAttributes, - base64UrlEncode, - clone, - Context, - Encoding, - internal, - LDContext, - LDHeaders, -} from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, Context, LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; -import { Configuration } from '../src/configuration/Configuration'; -import { FlagManager } from '../src/flag-manager/FlagManager'; import LDClientImpl from '../src/LDClientImpl'; -import LDEmitter from '../src/LDEmitter'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; import { MockEventSource } from './streaming/LDClientImpl.mocks'; -import TestDataManager, { makeTestDataManagerFactory } from './TestDataManager'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; let logger: ReturnType; From 72139de135efa286e5ddec0c15a49fd633c6a36b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:29:59 -0700 Subject: [PATCH 33/50] Revert event processor changes and disable auto-start for client SDKs. --- packages/shared/common/src/internal/events/EventProcessor.ts | 5 +++++ .../shared/sdk-client/src/events/createEventProcessor.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 95aeb21890..4e6acd128e 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -130,6 +130,7 @@ export default class EventProcessor implements LDEventProcessor { baseHeaders: LDHeaders, private readonly contextDeduplicator?: LDContextDeduplicator, private readonly diagnosticsManager?: DiagnosticsManager, + start: boolean = true, ) { this.capacity = config.eventsCapacity; this.logger = clientContext.basicConfiguration.logger; @@ -139,6 +140,10 @@ export default class EventProcessor implements LDEventProcessor { config.allAttributesPrivate, config.privateAttributes.map((ref) => new AttributeReference(ref)), ); + + if (start) { + this.start(); + } } start() { diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts index 87f8ddd3e9..fbeec26b1e 100644 --- a/packages/shared/sdk-client/src/events/createEventProcessor.ts +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -16,6 +16,7 @@ const createEventProcessor = ( baseHeaders, undefined, diagnosticsManager, + false, ); } From 6951966164cd99f31f3e1a636e810f56925391ca Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:52:09 -0700 Subject: [PATCH 34/50] Add log tag for mobile data manager. --- .../__tests__/MobileDataManager.test.ts | 14 +++++++++----- .../sdk/react-native/src/MobileDataManager.ts | 18 ++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts index 9f576c85f5..6a71774e0f 100644 --- a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -177,7 +177,7 @@ describe('given a MobileDataManager with mocked dependencies', () => { const initialMode = mobileDataManager.getConnectionMode(); await mobileDataManager.setConnectionMode(initialMode); expect(logger.debug).toHaveBeenCalledWith( - `setConnectionMode ignored. Mode is already '${initialMode}'.`, + `[MobileDataManager] setConnectionMode ignored. Mode is already '${initialMode}'.`, ); expect(mobileDataManager.getConnectionMode()).toBe(initialMode); }); @@ -231,7 +231,9 @@ describe('given a MobileDataManager with mocked dependencies', () => { await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); - expect(logger.debug).toHaveBeenCalledWith('Identify completing with cached flags'); + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Identify completing with cached flags', + ); expect(flagManager.loadCached).toHaveBeenCalledWith(context); expect(identifyResolve).toHaveBeenCalled(); @@ -248,7 +250,7 @@ describe('given a MobileDataManager with mocked dependencies', () => { await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); expect(logger.debug).toHaveBeenCalledWith( - 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + '[MobileDataManager] Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', ); expect(flagManager.loadCached).toHaveBeenCalledWith(context); @@ -268,7 +270,7 @@ describe('given a MobileDataManager with mocked dependencies', () => { await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); expect(logger.debug).toHaveBeenCalledWith( - 'Offline identify - no cached flags, using defaults or already loaded flags.', + '[MobileDataManager] Offline identify - no cached flags, using defaults or already loaded flags.', ); expect(flagManager.loadCached).toHaveBeenCalledWith(context); @@ -287,7 +289,9 @@ describe('given a MobileDataManager with mocked dependencies', () => { await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); - expect(logger.debug).toHaveBeenCalledWith('Offline identify - using cached flags.'); + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Offline identify - using cached flags.', + ); expect(flagManager.loadCached).toHaveBeenCalledWith(context); expect(identifyResolve).toHaveBeenCalled(); diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts index 5ea8c90b07..0285af2bdc 100644 --- a/packages/sdk/react-native/src/MobileDataManager.ts +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -14,6 +14,8 @@ import { import { ValidatedOptions } from './options'; +const logTag = '[MobileDataManager]'; + export default class MobileDataManager extends BaseDataManager { // Not implemented yet. protected networkAvailable: boolean = true; @@ -45,6 +47,10 @@ export default class MobileDataManager extends BaseDataManager { this.connectionMode = rnConfig.initialConnectionMode; } + private debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + override async identify( identifyResolve: () => void, identifyReject: (err: Error) => void, @@ -58,20 +64,20 @@ export default class MobileDataManager extends BaseDataManager { const loadedFromCache = await this.flagManager.loadCached(context); if (loadedFromCache && !waitForNetworkResults) { - this.logger.debug('Identify completing with cached flags'); + this.debugLog('Identify completing with cached flags'); identifyResolve(); } if (loadedFromCache && waitForNetworkResults) { - this.logger.debug( + this.debugLog( 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', ); } if (this.connectionMode === 'offline') { if (loadedFromCache) { - this.logger.debug('Offline identify - using cached flags.'); + this.debugLog('Offline identify - using cached flags.'); } else { - this.logger.debug( + this.debugLog( 'Offline identify - no cached flags, using defaults or already loaded flags.', ); identifyResolve(); @@ -109,12 +115,12 @@ export default class MobileDataManager extends BaseDataManager { async setConnectionMode(mode: ConnectionMode): Promise { if (this.connectionMode === mode) { - this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); + this.debugLog(`setConnectionMode ignored. Mode is already '${mode}'.`); return; } this.connectionMode = mode; - this.logger.debug(`setConnectionMode ${mode}.`); + this.debugLog(`setConnectionMode ${mode}.`); switch (mode) { case 'offline': From 3f1bb242c2014d5415b29b5e79e1826b2c9bb0bc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:53:11 -0700 Subject: [PATCH 35/50] Remove pointless docs. --- packages/sdk/browser/__tests__/BrowserDataManager.test.ts | 5 ----- .../sdk/react-native/__tests__/MobileDataManager.test.ts | 5 ----- 2 files changed, 10 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 806ef0814f..752b6fa72a 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -48,11 +48,6 @@ function mockResponse(value: string, statusCode: number) { return Promise.resolve(response); } -/** - * Mocks fetch. Returns the fetch jest.Mock object. - * @param remoteJson - * @param statusCode - */ function mockFetch(value: string, statusCode: number = 200) { const f = jest.fn(); // @ts-ignore diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts index 6a71774e0f..1e54ed5b54 100644 --- a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -38,11 +38,6 @@ function mockResponse(value: string, statusCode: number) { return Promise.resolve(response); } -/** - * Mocks fetch. Returns the fetch jest.Mock object. - * @param remoteJson - * @param statusCode - */ function mockFetch(value: string, statusCode: number = 200) { const f = jest.fn(); f.mockResolvedValue(mockResponse(value, statusCode)); From aa33fa46325b5159342fade75ae03ae4207b72b5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:56:57 -0700 Subject: [PATCH 36/50] Correct docs. --- packages/sdk/react-native/src/RNOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/react-native/src/RNOptions.ts b/packages/sdk/react-native/src/RNOptions.ts index 4df3134415..ba62644406 100644 --- a/packages/sdk/react-native/src/RNOptions.ts +++ b/packages/sdk/react-native/src/RNOptions.ts @@ -100,7 +100,7 @@ export interface RNSpecificOptions { * Sets the mode to use for connections when the SDK is initialized. * * @remarks - * Possible values are offline or streaming. See {@link ConnectionMode} for more information. + * Possible values are offline, streaming, or polling. See {@link ConnectionMode} for more information. * * @defaultValue streaming. */ From 0da9d31b5e916b2fbe185252a5b279c1d39a9ca0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:26:20 -0700 Subject: [PATCH 37/50] Change option to streaming. --- packages/sdk/browser/__tests__/BrowserClient.test.ts | 8 ++++---- packages/sdk/browser/__tests__/BrowserDataManager.test.ts | 4 ++-- .../sdk/browser/contract-tests/entity/src/ClientEntity.ts | 2 +- packages/sdk/browser/src/BrowserDataManager.ts | 2 +- packages/sdk/browser/src/options.ts | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 11d88888c1..7a8dc3ff24 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -129,7 +129,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - stream: false, + streaming: false, logger, diagnosticOptOut: true, }, @@ -157,7 +157,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - stream: false, + streaming: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => @@ -190,7 +190,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - stream: false, + streaming: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => @@ -233,7 +233,7 @@ describe('given a mock platform for a BrowserClient', () => { 'client-side-id', AutoEnvAttributes.Disabled, { - stream: false, + streaming: false, logger, diagnosticOptOut: true, eventUrlTransformer: (url: string) => diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 752b6fa72a..7a95f1edf6 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -128,7 +128,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { off: jest.fn(), } as unknown as jest.Mocked; - browserConfig = validateOptions({ stream: false }, logger); + browserConfig = validateOptions({ streaming: false }, logger); baseHeaders = {}; emitter = { emit: jest.fn(), @@ -173,7 +173,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { flagManager, 'test-credential', config, - validateOptions({ stream: true }, logger), + validateOptions({ streaming: true }, logger), () => ({ pathGet(encoding: Encoding, _plainContextString: string): string { return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 07a3e2e04c..2a5bf284c6 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -41,7 +41,7 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { if (options.streaming.baseUri) { cf.streamUri = options.streaming.baseUri; } - cf.stream = true; + cf.streaming = true; cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 748faa8758..a259f068fc 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -71,7 +71,7 @@ export default class BrowserDataManager extends BaseDataManager { identifyReject(e); } - if (this.browserConfig.stream) { + if (this.browserConfig.streaming) { this.setupConnection(context); } } diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index f04a9ec93a..7a6acce143 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -34,25 +34,25 @@ export interface BrowserOptions extends Omit string; - stream?: boolean; + streaming?: boolean; } const optDefaults = { fetchGoals: true, eventUrlTransformer: (url: string) => url, - stream: undefined, + streaming: undefined, }; const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { fetchGoals: TypeValidators.Boolean, eventUrlTransformer: TypeValidators.Function, - stream: TypeValidators.Boolean, + streaming: TypeValidators.Boolean, }; export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { From 1ef31268eb364366f62595f21bc70cbea2d28c1d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:46:20 -0700 Subject: [PATCH 38/50] feat: Automatically start streaming based on event handlers. --- .../__tests__/BrowserDataManager.test.ts | 49 +++++++++++++++++-- packages/sdk/browser/src/BrowserClient.ts | 40 ++++++++++++--- .../sdk/browser/src/BrowserDataManager.ts | 35 ++++++++++++- .../shared/sdk-client/src/LDClientImpl.ts | 2 +- 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 7a95f1edf6..3541bbd156 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -128,7 +128,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { off: jest.fn(), } as unknown as jest.Mocked; - browserConfig = validateOptions({ streaming: false }, logger); + browserConfig = validateOptions({}, logger); baseHeaders = {}; emitter = { emit: jest.fn(), @@ -262,7 +262,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); expect(platform.requests.createEventSource).not.toHaveBeenCalled(); - dataManager.startDataSource(); + dataManager.setForcedStreaming(true); expect(platform.requests.createEventSource).toHaveBeenCalled(); }); @@ -277,8 +277,8 @@ describe('given a BrowserDataManager with mocked dependencies', () => { await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); expect(platform.requests.createEventSource).not.toHaveBeenCalled(); - dataManager.startDataSource(); - dataManager.startDataSource(); + dataManager.setForcedStreaming(true); + dataManager.setForcedStreaming(true); expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith( '[BrowserDataManager] Update processor already active. Not changing state.', @@ -287,10 +287,49 @@ describe('given a BrowserDataManager with mocked dependencies', () => { it('does not start a stream if identify has not been called', async () => { expect(platform.requests.createEventSource).not.toHaveBeenCalled(); - dataManager.startDataSource(); + dataManager.setForcedStreaming(true); expect(platform.requests.createEventSource).not.toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith( '[BrowserDataManager] Context not set, not starting update processor.', ); }); + + it('starts a stream on demand when not forced on/off', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.setAutomaticStreamingState(true); + expect(platform.requests.createEventSource).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith('[BrowserDataManager] Starting update processor.'); + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Updating streaming state. forced(undefined) automatic(true)', + ); + }); + + it('does not start a stream when forced off', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + dataManager.setForcedStreaming(false); + + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + dataManager.setAutomaticStreamingState(true); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Updating streaming state. forced(false) automatic(true)', + ); + }); }); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index e63360768c..5e3d966980 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -14,6 +14,7 @@ import { Platform, } from '@launchdarkly/js-client-sdk-common'; import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions'; +import { EventName } from '@launchdarkly/js-client-sdk-common/dist/LDEmitter'; import BrowserDataManager from './BrowserDataManager'; import GoalManager from './goals/GoalManager'; @@ -29,11 +30,21 @@ export type LDClient = Omit< CommonClient, 'setConnectionMode' | 'getConnectionMode' | 'getOffline' > & { - setStreaming(streaming: boolean): void; + /** + * Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates. + * + * If this is true, the client will always attempt to maintain a streaming connection; if false, + * it never will. If you leave the value undefined (the default), the client will open a streaming + * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}). + * + * This can also be set as the `streaming` property of {@link LDOptions}. + */ + setStreaming(streaming?: boolean): void; }; export class BrowserClient extends LDClientImpl { private readonly goalManager?: GoalManager; + constructor( private readonly clientSideId: string, autoEnvAttributes: AutoEnvAttributes, @@ -164,12 +175,27 @@ export class BrowserClient extends LDClientImpl { this.goalManager?.startTracking(); } - setStreaming(streaming: boolean): void { + setStreaming(streaming?: boolean): void { + // With FDv2 we may want to consider if we support connection mode directly. + // Maybe with an extension to connection mode for 'automatic'. const browserDataManager = this.dataManager as BrowserDataManager; - if (streaming) { - browserDataManager.startDataSource(); - } else { - browserDataManager.stopDataSource(); - } + browserDataManager.setForcedStreaming(streaming); + } + + private updateAutomaticStreamingState() { + const browserDataManager = this.dataManager as BrowserDataManager; + // This will need changed if support for listening to individual flag change + // events it added. + browserDataManager.setAutomaticStreamingState(!!this.emitter.listenerCount('change')); + } + + override on(eventName: EventName, listener: Function): void { + super.on(eventName, listener); + this.updateAutomaticStreamingState(); + } + + override off(eventName: EventName, listener: Function): void { + super.off(eventName, listener); + this.updateAutomaticStreamingState(); } } diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index a259f068fc..31479f07f4 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -18,6 +18,11 @@ import { ValidatedOptions } from './options'; const logTag = '[BrowserDataManager]'; export default class BrowserDataManager extends BaseDataManager { + // If streaming is forced on or off, then we follow that setting. + // Otherwise we automatically manage streaming state. + private forcedStreaming?: boolean = undefined; + private automaticStreamingState?: boolean = false; + constructor( platform: Platform, flagManager: FlagManager, @@ -41,6 +46,7 @@ export default class BrowserDataManager extends BaseDataManager { emitter, diagnosticsManager, ); + this.forcedStreaming = browserConfig.streaming; } private debugLog(message: any, ...args: any[]) { @@ -76,12 +82,37 @@ export default class BrowserDataManager extends BaseDataManager { } } - stopDataSource() { + setForcedStreaming(streaming?: boolean) { + this.forcedStreaming = streaming; + this.updateStreamingState(); + } + + setAutomaticStreamingState(streaming: boolean) { + this.automaticStreamingState = streaming; + this.updateStreamingState(); + } + + private updateStreamingState() { + const shouldBeStreaming = + this.forcedStreaming || (this.automaticStreamingState && this.forcedStreaming === undefined); + + this.debugLog( + `Updating streaming state. forced(${this.forcedStreaming}) automatic(${this.automaticStreamingState})`, + ); + + if (shouldBeStreaming) { + this.startDataSource(); + } else { + this.stopDataSource(); + } + } + + private stopDataSource() { this.updateProcessor?.close(); this.updateProcessor = undefined; } - startDataSource() { + private startDataSource() { if (this.updateProcessor) { this.debugLog('Update processor already active. Not changing state.'); return; diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 88c964a797..1173119052 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -54,7 +54,7 @@ export default class LDClientImpl implements LDClient { private eventFactoryDefault = new EventFactory(false); private eventFactoryWithReasons = new EventFactory(true); - private emitter: LDEmitter; + protected emitter: LDEmitter; private flagManager: DefaultFlagManager; private eventSendingEnabled: boolean = false; From 6d29928409df833eb2487ad0283c8793322d9f26 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:58:06 -0700 Subject: [PATCH 39/50] Add truth table. --- packages/sdk/browser/src/BrowserDataManager.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 31479f07f4..027e941af7 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -21,7 +21,18 @@ export default class BrowserDataManager extends BaseDataManager { // If streaming is forced on or off, then we follow that setting. // Otherwise we automatically manage streaming state. private forcedStreaming?: boolean = undefined; - private automaticStreamingState?: boolean = false; + private automaticStreamingState: boolean = false; + + // +-----------+-----------+---------------+ + // | forced | automatic | state | + // +-----------+-----------+---------------+ + // | true | false | streaming | + // | true | true | streaming | + // | false | true | not streaming | + // | false | false | not streaming | + // | undefined | true | streaming | + // | undefined | false | not streaming | + // +-----------+-----------+---------------+ constructor( platform: Platform, From dc354d42808d4549e98984c63eddf098844069b9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:59:04 -0700 Subject: [PATCH 40/50] Automatic update streaming state on identify. --- packages/sdk/browser/src/BrowserDataManager.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 027e941af7..02abcf6c27 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -88,9 +88,7 @@ export default class BrowserDataManager extends BaseDataManager { identifyReject(e); } - if (this.browserConfig.streaming) { - this.setupConnection(context); - } + this.updateStreamingState(); } setForcedStreaming(streaming?: boolean) { From cced478512d1dd8834f0b843c16e00971fc0feac Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:01:08 -0700 Subject: [PATCH 41/50] Add more tests. --- .../__tests__/BrowserDataManager.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 3541bbd156..fee37b29b5 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -332,4 +332,21 @@ describe('given a BrowserDataManager with mocked dependencies', () => { '[BrowserDataManager] Updating streaming state. forced(false) automatic(true)', ); }); + + it('starts streaming on identify if the automatic state is true', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + dataManager.setForcedStreaming(undefined); + dataManager.setAutomaticStreamingState(true); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + }); }); From 10c48e7d60cba831eed050e400b7665fb9464320 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:07:52 -0700 Subject: [PATCH 42/50] Remove todo --- packages/sdk/browser/src/BrowserDataManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 02abcf6c27..0e5e38ac60 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -172,5 +172,4 @@ export default class BrowserDataManager extends BaseDataManager { const uri = getPollingUri(this.config.serviceEndpoints, path, parameters); return new Requestor(this.platform.requests, uri, headers, method, body); } - // TODO: Automatically start streaming if event handlers are registered. } From df9dd9d815b7f0331d05d6e9bd1c5a916f26b9b0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:39:08 -0700 Subject: [PATCH 43/50] Remove event handler in base implementation. --- packages/shared/sdk-client/src/LDClientImpl.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 1173119052..996bf4e4d1 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -106,15 +106,13 @@ export default class LDClientImpl implements LDClient { this.diagnosticsManager, ); this.emitter = new LDEmitter(); - this.emitter.on('change', (c: LDContext, changedKeys: string[]) => { - this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`); - }); this.emitter.on('error', (c: LDContext, err: any) => { this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`); }); this.flagManager.on((context, flagKeys) => { const ldContext = Context.toLDContext(context); + this.logger.debug(`change: context: ${JSON.stringify(ldContext)}, flags: ${flagKeys}`); this.emitter.emit('change', ldContext, flagKeys); }); From 5a3c8b4b8ba2007d107c23fa9420acf69c289b1b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:01:48 -0700 Subject: [PATCH 44/50] Fix logger. --- packages/sdk/browser/src/BrowserClient.ts | 15 +++++++++++---- packages/sdk/browser/src/BrowserDataManager.ts | 3 +++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 5e3d966980..2424cfa7f5 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -4,6 +4,7 @@ import { BasicLogger, LDClient as CommonClient, Configuration, + createSafeLogger, Encoding, FlagManager, internal, @@ -52,12 +53,18 @@ export class BrowserClient extends LDClientImpl { overridePlatform?: Platform, ) { const { logger: customLogger, debug } = options; + // Overrides the default logger from the common implementation. const logger = customLogger ?? - new BasicLogger({ - level: debug ? 'debug' : 'info', + createSafeLogger({ // eslint-disable-next-line no-console - destination: console.log, + debug: debug ? console.debug : () => {}, + // eslint-disable-next-line no-console + info: console.info, + // eslint-disable-next-line no-console + warn: console.warn, + // eslint-disable-next-line no-console + error: console.error, }); // TODO: Use the already-configured baseUri from the SDK config. SDK-560 @@ -70,7 +77,7 @@ export class BrowserClient extends LDClientImpl { clientSideId, autoEnvAttributes, platform, - filterToBaseOptions(options), + filterToBaseOptions({ ...options, logger }), ( flagManager: FlagManager, configuration: Configuration, diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 0e5e38ac60..f1d121baf6 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -117,6 +117,9 @@ export default class BrowserDataManager extends BaseDataManager { } private stopDataSource() { + if (this.updateProcessor) { + this.debugLog('Stopping update processor.'); + } this.updateProcessor?.close(); this.updateProcessor = undefined; } From 1dedc7cbc950f2365d855614f2ee2fd2011f826a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:37:23 -0700 Subject: [PATCH 45/50] Lint --- packages/shared/sdk-client/src/LDClientImpl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index c268d16316..3c8b66c909 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -21,7 +21,7 @@ import { import { LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; -import { Configuration, ConfigurationImpl, LDClientInternalOptions } from './configuration'; +import { ConfigurationImpl, LDClientInternalOptions } from './configuration'; import { addAutoEnv } from './context/addAutoEnv'; import { ensureKey } from './context/ensureKey'; import { DataManager, DataManagerFactory } from './DataManager'; @@ -54,7 +54,7 @@ export default class LDClientImpl implements LDClient { private eventFactoryDefault = new EventFactory(false); private eventFactoryWithReasons = new EventFactory(true); protected emitter: LDEmitter; - private flagManager: DefaultFlagManager; + private flagManager: FlagManager; private eventSendingEnabled: boolean = false; private baseHeaders: LDHeaders; From 71caed59e95a0edcee85e43206d81d2c0e54daba Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:42:12 -0700 Subject: [PATCH 46/50] Fix event tests. Add developer notes. --- packages/shared/sdk-client/__tests__/LDClientImpl.test.ts | 4 +++- packages/shared/sdk-client/src/LDEmitter.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 4eda5c715c..0f48149fc8 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -268,7 +268,9 @@ describe('sdk-client object', () => { const carContext2: LDContext = { kind: 'car', key: 'test-car-2' }; await ldc.identify(carContext2); - expect(emitter.listenerCount('change')).toEqual(1); + // No default listeners. This is important for clients to be able to determine if there are + // any listeners and act on that information. + expect(emitter.listenerCount('change')).toEqual(0); expect(emitter.listenerCount('error')).toEqual(1); }); diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index 1b00b5a8fd..3703ae3227 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -2,6 +2,12 @@ import { LDLogger } from '@launchdarkly/js-sdk-common'; export type EventName = 'error' | 'change'; +/** + * Implementation Note: There should not be any default listeners for change events in a client + * implementation. Default listeners mean a client cannot determine when there are actual + * application developer provided listeners. If we require default listeners, then we should add + * a system to allow listeners which have counts independent of the primary listener counts. + */ export default class LDEmitter { private listeners: Map = new Map(); From 816a594b53679d609cff03e6e0d6b1e2524f7440 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:47:01 -0700 Subject: [PATCH 47/50] Revert unintentional merge change. --- packages/shared/sdk-client/src/LDClientImpl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 3c8b66c909..71f65d7df9 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -21,7 +21,7 @@ import { import { LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; -import { ConfigurationImpl, LDClientInternalOptions } from './configuration'; +import { Configuration, ConfigurationImpl, LDClientInternalOptions } from './configuration'; import { addAutoEnv } from './context/addAutoEnv'; import { ensureKey } from './context/ensureKey'; import { DataManager, DataManagerFactory } from './DataManager'; @@ -40,7 +40,7 @@ import { DeleteFlag, Flags, PatchFlag } from './types'; const { ClientMessages, ErrorKinds } = internal; export default class LDClientImpl implements LDClient { - private readonly config: ConfigurationImpl; + private readonly config: Configuration; private uncheckedContext?: LDContext; private checkedContext?: Context; private readonly diagnosticsManager?: internal.DiagnosticsManager; From d13b8e4243e214db2adb4ad5893406f9db29232e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:50:09 -0700 Subject: [PATCH 48/50] Remove file that was re-created in merge. --- .../sdk-client/src/IdentityProcessor.ts | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 packages/shared/sdk-client/src/IdentityProcessor.ts diff --git a/packages/shared/sdk-client/src/IdentityProcessor.ts b/packages/shared/sdk-client/src/IdentityProcessor.ts deleted file mode 100644 index 91b649c136..0000000000 --- a/packages/shared/sdk-client/src/IdentityProcessor.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Context, LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; -import { LDStreamProcessor } from '@launchdarkly/js-sdk-common/dist/api/subsystem'; - -import ConnectionMode from './api/ConnectionMode'; -import DefaultFlagManager from './flag-manager/FlagManager'; - -export type IdentifyProcessor = (params: { - waitForNetworkResults: boolean; - isOffline: () => boolean; - getConnectionMode: () => ConnectionMode; - logger: LDLogger; - updateProcessor: LDStreamProcessor | undefined; - createStreamingProcessor: ( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) => void; - createPollingProcessor: ( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) => void; - context: LDContext; - checkedContext: Context; - identifyResolve: () => void; - identifyReject: (error: any) => void; - flagManager: DefaultFlagManager; -}) => Promise; From fdd63311e420dcf4844143b8c740b7836e650891 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:50:53 -0700 Subject: [PATCH 49/50] Revert another merge change. --- packages/sdk/browser/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 2b9f69f51b..8c4841c257 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -7,5 +7,5 @@ export default { '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }], }, testPathIgnorePatterns: ['./dist', './src'], - testMatch: ["**.test.ts"] + testMatch: ['**.test.ts'] }; From b4e0aa4361d9f178fcd6a122ff3c6478624dfcd0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:51:13 -0700 Subject: [PATCH 50/50] Revert another merge change. --- packages/sdk/browser/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 8c4841c257..5d6bf1a518 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -7,5 +7,5 @@ export default { '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }], }, testPathIgnorePatterns: ['./dist', './src'], - testMatch: ['**.test.ts'] + testMatch: ['**.test.ts'], };