diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index be1be7e032..3cccf15cae 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -41,7 +41,7 @@ jobs: target_file: 'packages/sdk/browser/dist/index.js' package_name: '@launchdarkly/js-client-sdk' pr_number: ${{ github.event.number }} - size_limit: 25000 + size_limit: 34000 # Contract Tests - name: Install contract test dependencies diff --git a/.github/workflows/combined-browser.yml b/.github/workflows/combined-browser.yml index 159a6f9e7a..e83eb4b87b 100644 --- a/.github/workflows/combined-browser.yml +++ b/.github/workflows/combined-browser.yml @@ -41,4 +41,4 @@ jobs: target_file: 'packages/sdk/combined-browser/dist/index.js' package_name: '@launchdarkly/browser' pr_number: ${{ github.event.number }} - size_limit: 200000 + size_limit: 194000 diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index f0161d845d..9034131d1d 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -35,4 +35,4 @@ jobs: target_file: 'packages/shared/common/dist/esm/index.mjs' package_name: '@launchdarkly/js-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 26000 + size_limit: 29000 diff --git a/.github/workflows/sdk-client.yml b/.github/workflows/sdk-client.yml index 8b3ba882b1..3f3d91f7cb 100644 --- a/.github/workflows/sdk-client.yml +++ b/.github/workflows/sdk-client.yml @@ -32,4 +32,4 @@ jobs: target_file: 'packages/shared/sdk-client/dist/esm/index.mjs' package_name: '@launchdarkly/js-client-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 24000 + size_limit: 38000 diff --git a/.gitignore b/.gitignore index 8451001607..6f7b40da26 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ stats.html .env .env.local .env.*.local +.claude/worktrees diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index f105205f8a..1eaa579f92 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -872,4 +872,72 @@ describe('given a mock platform for a BrowserClient', () => { // Verify that no fetch calls were made expect(platform.requests.fetch.mock.calls.length).toBe(0); }); + + it('uses FDv1 endpoints when dataSystem is not set', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + await client.start(); + + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/evalx/'); + expect(fetchUrl).not.toContain('/sdk/poll/eval'); + }); + + it('uses FDv2 endpoints when dataSystem is set', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + // @ts-ignore dataSystem is @internal + dataSystem: {}, + }, + platform, + ); + + await client.start(); + + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/poll/eval/'); + }); + + it('validates dataSystem options and applies browser defaults', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + // @ts-ignore dataSystem is @internal + dataSystem: { initialConnectionMode: 'invalid-mode' }, + }, + platform, + ); + + // Invalid mode should produce a warning + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('dataSystem.initialConnectionMode'), + ); + + await client.start(); + + // Should still use FDv2 — invalid sub-fields fall back to defaults, not disable FDv2 + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/poll/eval/'); + }); }); diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index da8165a9af..34c96d9936 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -4,6 +4,9 @@ import { CommandType, CreateInstanceParams, makeLogger, + SDKConfigDataInitializer, + SDKConfigDataSynchronizer, + SDKConfigModeDefinition, SDKConfigParams, ClientSideTestHook as TestHook, ValueType, @@ -12,6 +15,59 @@ import { export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); +function translateInitializer(init: SDKConfigDataInitializer): any | undefined { + if (init.polling) { + return { + type: 'polling', + ...(init.polling.pollIntervalMs !== undefined && { + pollInterval: init.polling.pollIntervalMs / 1000, + }), + ...(init.polling.baseUri && { + endpoints: { pollingBaseUri: init.polling.baseUri }, + }), + }; + } + return undefined; +} + +function translateSynchronizer(sync: SDKConfigDataSynchronizer): any | undefined { + if (sync.streaming) { + return { + type: 'streaming', + ...(sync.streaming.initialRetryDelayMs !== undefined && { + initialReconnectDelay: sync.streaming.initialRetryDelayMs / 1000, + }), + ...(sync.streaming.baseUri && { + endpoints: { streamingBaseUri: sync.streaming.baseUri }, + }), + }; + } + if (sync.polling) { + return { + type: 'polling', + ...(sync.polling.pollIntervalMs !== undefined && { + pollInterval: sync.polling.pollIntervalMs / 1000, + }), + ...(sync.polling.baseUri && { + endpoints: { pollingBaseUri: sync.polling.baseUri }, + }), + }; + } + return undefined; +} + +function translateModeDefinition(modeDef: SDKConfigModeDefinition): any { + const initializers = (modeDef.initializers ?? []) + .map(translateInitializer) + .filter((x: any) => x !== undefined); + + const synchronizers = (modeDef.synchronizers ?? []) + .map(translateSynchronizer) + .filter((x: any) => x !== undefined); + + return { initializers, synchronizers }; +} + function makeSdkConfig(options: SDKConfigParams, tag: string) { if (!options.clientSide) { throw new Error('configuration did not include clientSide options'); @@ -23,30 +79,65 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { const cf: LDOptions = { withReasons: options.clientSide.evaluationReasons, logger: makeLogger(`${tag}.sdk`), - useReport: options.clientSide.useReport, + useReport: options.clientSide.useReport ?? undefined, }; - if (options.serviceEndpoints) { - cf.streamUri = options.serviceEndpoints.streaming; - cf.baseUri = options.serviceEndpoints.polling; - cf.eventsUri = options.serviceEndpoints.events; - } + if (options.dataSystem?.connectionModeConfig) { + const connMode = options.dataSystem.connectionModeConfig; + const dataSystem: any = { + initialConnectionMode: connMode.initialConnectionMode, + automaticModeSwitching: false, + }; + + if (connMode.customConnectionModes) { + const connectionModes: Record = {}; + Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => { + connectionModes[modeName] = translateModeDefinition(modeDef); - if (options.polling) { - if (options.polling.baseUri) { - cf.baseUri = options.polling.baseUri; + // Also set global endpoint URIs for compatibility with ServiceEndpoints. + (modeDef.synchronizers ?? []).forEach((sync) => { + if (sync.streaming?.baseUri) { + cf.streamUri = sync.streaming.baseUri; + cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs); + } + if (sync.polling?.baseUri) { + cf.baseUri = sync.polling.baseUri; + } + }); + (modeDef.initializers ?? []).forEach((init) => { + if (init.polling?.baseUri) { + cf.baseUri = init.polling.baseUri; + } + }); + }); + dataSystem.connectionModes = connectionModes; } - } - // Can contain streaming and polling, if streaming is set override the initial connection - // mode. This can be removed when we add JS specific initialization that uses polling - // and then streaming. - if (options.streaming) { - if (options.streaming.baseUri) { - cf.streamUri = options.streaming.baseUri; + (cf as any).dataSystem = dataSystem; + + if (options.dataSystem.payloadFilter) { + cf.payloadFilterKey = options.dataSystem.payloadFilter; + } + } else { + if (options.serviceEndpoints) { + cf.streamUri = options.serviceEndpoints.streaming; + cf.baseUri = options.serviceEndpoints.polling; + cf.eventsUri = options.serviceEndpoints.events; + } + + if (options.polling) { + if (options.polling.baseUri) { + cf.baseUri = options.polling.baseUri; + } + } + + if (options.streaming) { + if (options.streaming.baseUri) { + cf.streamUri = options.streaming.baseUri; + } + cf.streaming = true; + cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } - cf.streaming = true; - cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } if (options.events) { diff --git a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt new file mode 100644 index 0000000000..37a64e0802 --- /dev/null +++ b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt @@ -0,0 +1,45 @@ +streaming/requests/method and headers/REPORT/http +streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT +streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +streaming/requests/query parameters/evaluationReasons set to [none]/REPORT +streaming/requests/query parameters/evaluationReasons set to false/REPORT +streaming/requests/query parameters/evaluationReasons set to true/REPORT +streaming/requests/context properties/single kind minimal/REPORT +streaming/requests/context properties/single kind with all attributes/REPORT +streaming/requests/context properties/multi-kind/REPORT +polling/requests/method and headers/REPORT/http +polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT +polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +polling/requests/query parameters/evaluationReasons set to [none]/REPORT +polling/requests/query parameters/evaluationReasons set to false/REPORT +polling/requests/query parameters/evaluationReasons set to true/REPORT +polling/requests/context properties/single kind minimal/REPORT +polling/requests/context properties/single kind with all attributes/REPORT +polling/requests/context properties/multi-kind/REPORT +tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"","applicationVersion":null} +tags/stream requests/{"applicationId":"","applicationVersion":""} +tags/stream requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":null} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":""} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":null} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":""} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} +tags/disallowed characters diff --git a/packages/sdk/browser/example-fdv2/.env.template b/packages/sdk/browser/example-fdv2/.env.template new file mode 100644 index 0000000000..75b8c17e11 --- /dev/null +++ b/packages/sdk/browser/example-fdv2/.env.template @@ -0,0 +1,5 @@ +# Set LAUNCHDARKLY_CLIENT_SIDE_ID to your LaunchDarkly client-side ID +LAUNCHDARKLY_CLIENT_SIDE_ID= + +# Set LAUNCHDARKLY_FLAG_KEY to the feature flag key you want to evaluate +LAUNCHDARKLY_FLAG_KEY= diff --git a/packages/sdk/browser/example-fdv2/README.md b/packages/sdk/browser/example-fdv2/README.md new file mode 100644 index 0000000000..9dce3c0b38 --- /dev/null +++ b/packages/sdk/browser/example-fdv2/README.md @@ -0,0 +1,44 @@ +# LaunchDarkly sample javascript application + +We've built a simple browser application that demonstrates how this LaunchDarkly SDK works. + +Below, you'll find the build procedure. For more comprehensive instructions, you can visit your [Quickstart page](https://app.launchdarkly.com/quickstart#/) or +the [{name of SDK} reference guide](https://docs.launchdarkly.com/sdk/client-side/javascript). + +## Prerequisites + +Nodejs 20.6.0 or later + +## Build instructions + +1. Make a copy of the `.env.template` and name it `.env` + ``` + cp .env.template .env + ``` + +2. Set the variables in `.env` to your specific LD values + ``` + # Set LAUNCHDARKLY_CLIENT_SIDE_ID to your LaunchDarkly client-side ID + LAUNCHDARKLY_CLIENT_SIDE_ID= + + # Set LAUNCHDARKLY_FLAG_KEY to the feature flag key you want to evaluate + LAUNCHDARKLY_FLAG_KEY= + ``` + > [!NOTE] + > Setting these values is equivilent to modifying the `clientSideID` and `flagKey` + > in [app.ts](./src/app.ts). + +3. Install and build the project: + ```bash + yarn && yarn build + ``` + +4. On the command line, run `yarn start` + ```bash + yarn start + ``` + > [!NOTE] + > The `yarn start` script simply runs `open index.html`. If that is not working for you, + > you can open the `index.html` file in a browser for the same results. + +The application will run continuously and react to the flag changes in LaunchDarkly. diff --git a/packages/sdk/browser/example-fdv2/e2e/verify.spec.ts b/packages/sdk/browser/example-fdv2/e2e/verify.spec.ts new file mode 100644 index 0000000000..9f1ac18322 --- /dev/null +++ b/packages/sdk/browser/example-fdv2/e2e/verify.spec.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { expect, test as it } from '@playwright/test'; + +it('evaluates the feature flag to true', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('body')).toContainText('feature flag evaluates to true', { + timeout: 20_000, + }); +}); diff --git a/packages/sdk/browser/example-fdv2/index.css b/packages/sdk/browser/example-fdv2/index.css new file mode 100644 index 0000000000..894c557bfc --- /dev/null +++ b/packages/sdk/browser/example-fdv2/index.css @@ -0,0 +1,69 @@ +body { + margin: 0; + padding: 20px; + background: #373841; + color: white; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', + 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#status { + padding: 10px; + margin-bottom: 10px; + background: rgba(255,255,255,0.1); + border-radius: 4px; +} + +#flag { + font-size: 1.4em; + padding: 15px; + margin-bottom: 20px; + background: rgba(255,255,255,0.05); + border-radius: 4px; +} + +#controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} + +#controls > div { + padding: 10px; + background: rgba(255,255,255,0.05); + border-radius: 4px; +} + +#controls h3 { + margin: 0 0 8px 0; + font-size: 0.9em; + text-transform: uppercase; + opacity: 0.7; +} + +button { + padding: 6px 14px; + margin: 3px 2px; + border: 1px solid rgba(255,255,255,0.3); + border-radius: 4px; + background: rgba(255,255,255,0.1); + color: white; + cursor: pointer; + font-size: 0.9em; +} + +button:hover { + background: rgba(255,255,255,0.2); +} + +#log { + max-height: 200px; + overflow-y: auto; + font-family: monospace; + font-size: 0.8em; + line-height: 1.5; + opacity: 0.8; +} diff --git a/packages/sdk/browser/example-fdv2/index.html b/packages/sdk/browser/example-fdv2/index.html new file mode 100644 index 0000000000..26e38b21d5 --- /dev/null +++ b/packages/sdk/browser/example-fdv2/index.html @@ -0,0 +1,11 @@ + + + + + + LaunchDarkly tutorial + + + + + diff --git a/packages/sdk/browser/example-fdv2/package.json b/packages/sdk/browser/example-fdv2/package.json new file mode 100644 index 0000000000..c9805dbadf --- /dev/null +++ b/packages/sdk/browser/example-fdv2/package.json @@ -0,0 +1,29 @@ +{ + "name": "@launchdarkly/browser-example-fdv2", + "version": "0.0.0", + "private": true, + "description": "LaunchDarkly example for JavaScript Browser SDK", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/browser/example", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "packageManager": "yarn@3.4.1", + "type": "module", + "scripts": { + "start": "open index.html", + "clean": "rm -rf dist dist-static", + "build": "npm run clean && tsdown", + "test": "playwright test" + }, + "dependencies": { + "@launchdarkly/js-client-sdk": "workspace:^" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "playwright": "^1.49.1", + "tsdown": "^0.17.0-beta.4", + "typescript": "^5.9.3" + } +} diff --git a/packages/sdk/browser/example-fdv2/playwright.config.ts b/packages/sdk/browser/example-fdv2/playwright.config.ts new file mode 100644 index 0000000000..9d23f96aac --- /dev/null +++ b/packages/sdk/browser/example-fdv2/playwright.config.ts @@ -0,0 +1,16 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + reporter: [['list']], + use: { + baseURL: 'http://localhost:4173', + }, + webServer: { + command: 'npx http-server . -p 4173 --silent', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/sdk/browser/example-fdv2/src/app.ts b/packages/sdk/browser/example-fdv2/src/app.ts new file mode 100644 index 0000000000..4e41fea77b --- /dev/null +++ b/packages/sdk/browser/example-fdv2/src/app.ts @@ -0,0 +1,250 @@ +import { basicLogger, createClient, type LDClient } from '@launchdarkly/js-client-sdk'; + +// Set clientSideID to your LaunchDarkly client-side ID +const clientSideID = 'LD_CLIENT_SIDE_ID'; + +// Set flagKey to the feature flag key you want to evaluate +const flagKey = 'LD_FLAG_KEY'; + +const contexts = [ + { kind: 'user', key: 'user-1', name: 'Sandy' }, + { kind: 'user', key: 'user-2', name: 'Alex' }, + { kind: 'user', key: 'user-3', name: 'Jordan' }, + { kind: 'org', key: 'org-1', name: 'Acme Corp' }, +]; + +let currentContextIndex = 0; +let eventHandlersRegistered = false; +let changeHandler: (() => void) | undefined; +let errorHandler: (() => void) | undefined; + +function el(tag: string, attrs?: Record): HTMLElement { + const e = document.createElement(tag); + if (attrs) { + Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v)); + } + return e; +} + +function text(s: string): Text { + return document.createTextNode(s); +} + +function formatContext(ctx: (typeof contexts)[0]): string { + return `${ctx.kind}:${ctx.key} (${ctx.name})`; +} + +function buildUI() { + const container = el('div', { id: 'app' }); + + // Status + const statusBox = el('div', { id: 'status' }); + statusBox.appendChild(text('Initializing...')); + container.appendChild(statusBox); + + // Flag value + const flagBox = el('div', { id: 'flag' }); + flagBox.appendChild(text('No flag evaluations yet')); + container.appendChild(flagBox); + + // Controls + const controls = el('div', { id: 'controls' }); + + // Context switcher + const ctxSection = el('div'); + ctxSection.appendChild(el('h3')); + ctxSection.querySelector('h3')!.textContent = 'Context'; + const ctxLabel = el('span', { id: 'ctx-label' }); + ctxLabel.textContent = formatContext(contexts[0]); + ctxSection.appendChild(ctxLabel); + ctxSection.appendChild(text(' ')); + const ctxBtn = el('button', { id: 'btn-ctx' }); + ctxBtn.textContent = 'Switch Context'; + ctxSection.appendChild(ctxBtn); + controls.appendChild(ctxSection); + + // Event handlers + const evtSection = el('div'); + evtSection.appendChild(el('h3')); + evtSection.querySelector('h3')!.textContent = 'Event Handlers'; + const evtStatus = el('span', { id: 'evt-status' }); + evtStatus.textContent = 'Not registered'; + evtSection.appendChild(evtStatus); + evtSection.appendChild(text(' ')); + const evtBtn = el('button', { id: 'btn-evt' }); + evtBtn.textContent = 'Register'; + evtSection.appendChild(evtBtn); + controls.appendChild(evtSection); + + // Streaming control + const streamSection = el('div'); + streamSection.appendChild(el('h3')); + streamSection.querySelector('h3')!.textContent = 'Streaming'; + const streamStatus = el('span', { id: 'stream-status' }); + streamStatus.textContent = 'undefined (automatic)'; + streamSection.appendChild(streamStatus); + streamSection.appendChild(el('br')); + const btnTrue = el('button', { id: 'btn-stream-true' }); + btnTrue.textContent = 'Force On'; + const btnFalse = el('button', { id: 'btn-stream-false' }); + btnFalse.textContent = 'Force Off'; + const btnUndef = el('button', { id: 'btn-stream-undef' }); + btnUndef.textContent = 'Automatic'; + streamSection.appendChild(btnTrue); + streamSection.appendChild(text(' ')); + streamSection.appendChild(btnFalse); + streamSection.appendChild(text(' ')); + streamSection.appendChild(btnUndef); + controls.appendChild(streamSection); + + // Log + const logSection = el('div'); + logSection.appendChild(el('h3')); + logSection.querySelector('h3')!.textContent = 'Event Log'; + const logBox = el('div', { id: 'log' }); + logSection.appendChild(logBox); + controls.appendChild(logSection); + + container.appendChild(controls); + document.body.appendChild(container); +} + +function log(msg: string) { + const logBox = document.getElementById('log')!; + const entry = el('div'); + const time = new Date().toLocaleTimeString(); + entry.textContent = `[${time}] ${msg}`; + logBox.insertBefore(entry, logBox.firstChild); + // Keep last 50 entries + while (logBox.children.length > 50) { + logBox.removeChild(logBox.lastChild!); + } +} + +function renderFlag(client: LDClient) { + const flagValue = client.variation(flagKey, false); + const flagBox = document.getElementById('flag')!; + flagBox.textContent = `${flagKey} = ${JSON.stringify(flagValue)}`; + document.body.style.background = flagValue ? '#00844B' : '#373841'; +} + +function updateStatus(msg: string) { + document.getElementById('status')!.textContent = msg; +} + +function updateCtxLabel() { + document.getElementById('ctx-label')!.textContent = formatContext(contexts[currentContextIndex]); +} + +function updateEvtStatus() { + const evtStatus = document.getElementById('evt-status')!; + const btn = document.getElementById('btn-evt')!; + if (eventHandlersRegistered) { + evtStatus.textContent = 'Registered (change + error)'; + btn.textContent = 'Unregister'; + } else { + evtStatus.textContent = 'Not registered'; + btn.textContent = 'Register'; + } +} + +function updateStreamStatus(value: boolean | undefined) { + const label = document.getElementById('stream-status')!; + if (value === true) { + label.textContent = 'true (forced on)'; + } else if (value === false) { + label.textContent = 'false (forced off)'; + } else { + label.textContent = 'undefined (automatic)'; + } +} + +function registerHandlers(client: LDClient) { + if (eventHandlersRegistered) return; + + changeHandler = () => { + log('change event received'); + renderFlag(client); + }; + errorHandler = () => { + log('error event received'); + }; + + client.on('change', changeHandler); + client.on('error', errorHandler); + eventHandlersRegistered = true; + updateEvtStatus(); + log('Event handlers registered'); +} + +function unregisterHandlers(client: LDClient) { + if (!eventHandlersRegistered) return; + + if (changeHandler) { + client.off('change', changeHandler); + changeHandler = undefined; + } + if (errorHandler) { + client.off('error', errorHandler); + errorHandler = undefined; + } + eventHandlersRegistered = false; + updateEvtStatus(); + log('Event handlers unregistered'); +} + +const main = async () => { + buildUI(); + + const client = createClient(clientSideID, contexts[currentContextIndex], { + // @ts-ignore dataSystem is @internal — experimental FDv2 opt-in + dataSystem: {}, + logger: basicLogger({ level: 'debug' }), + }); + + // Context switching + document.getElementById('btn-ctx')!.addEventListener('click', async () => { + currentContextIndex = (currentContextIndex + 1) % contexts.length; + const ctx = contexts[currentContextIndex]; + updateCtxLabel(); + log(`Identifying as ${formatContext(ctx)}...`); + const result = await client.identify(ctx); + log(`Identify result: ${result.status}`); + renderFlag(client); + }); + + // Event handler toggle + document.getElementById('btn-evt')!.addEventListener('click', () => { + if (eventHandlersRegistered) { + unregisterHandlers(client); + } else { + registerHandlers(client); + } + }); + + // Streaming controls + document.getElementById('btn-stream-true')!.addEventListener('click', () => { + client.setStreaming(true); + updateStreamStatus(true); + log('setStreaming(true)'); + }); + document.getElementById('btn-stream-false')!.addEventListener('click', () => { + client.setStreaming(false); + updateStreamStatus(false); + log('setStreaming(false)'); + }); + document.getElementById('btn-stream-undef')!.addEventListener('click', () => { + client.setStreaming(undefined); + updateStreamStatus(undefined); + log('setStreaming(undefined)'); + }); + + // Start + client.start(); + const { status } = await client.waitForInitialization(); + updateStatus(`Initialized (${status}) - ${formatContext(contexts[currentContextIndex])}`); + log(`Initialization: ${status}`); + renderFlag(client); +}; + +main(); diff --git a/packages/sdk/browser/example-fdv2/tsconfig.json b/packages/sdk/browser/example-fdv2/tsconfig.json new file mode 100644 index 0000000000..a1aec48ce3 --- /dev/null +++ b/packages/sdk/browser/example-fdv2/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "lib": ["ES2017", "dom"], + "module": "ESNext", + "moduleResolution": "node", + "noImplicitOverride": true, + "resolveJsonModule": true, + "rootDir": ".", + "outDir": "dist", + "skipLibCheck": true, + "sourceMap": true, + "inlineSources": true, + "strict": true, + "stripInternal": true, + "target": "ES2017", + "allowJs": true + }, + "include": ["src"] +} diff --git a/packages/sdk/browser/example-fdv2/tsdown.config.ts b/packages/sdk/browser/example-fdv2/tsdown.config.ts new file mode 100644 index 0000000000..fb961a1fd4 --- /dev/null +++ b/packages/sdk/browser/example-fdv2/tsdown.config.ts @@ -0,0 +1,37 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import fs from 'node:fs'; +import path from 'node:path'; +import { loadEnvFile } from 'node:process'; +import { defineConfig } from 'tsdown'; + +if (fs.existsSync('.env')) { + loadEnvFile('.env'); +} + +const ENTRY_FILE = path.join('src', 'app.ts'); +const OUTPUT_FILE = path.join('dist', 'app.js'); +const { LAUNCHDARKLY_CLIENT_SIDE_ID, LAUNCHDARKLY_FLAG_KEY } = process.env; + +const CLIENT_SIDE_ID_PLACEHOLDER = 'LD_CLIENT_SIDE_ID'; +const FLAG_KEY_PLACEHOLDER = 'LD_FLAG_KEY'; + +export default defineConfig({ + entry: ENTRY_FILE, + platform: 'browser', + outDir: 'dist', + noExternal: ['@launchdarkly/js-client-sdk'], + hooks(hooks) { + hooks.hook('build:done', () => { + if (LAUNCHDARKLY_CLIENT_SIDE_ID) { + const content = fs.readFileSync(OUTPUT_FILE).toString(); + fs.writeFileSync( + OUTPUT_FILE, + content.replaceAll(CLIENT_SIDE_ID_PLACEHOLDER, LAUNCHDARKLY_CLIENT_SIDE_ID), + ); + } + const flagKey = LAUNCHDARKLY_FLAG_KEY || 'sample-feature'; + const content = fs.readFileSync(OUTPUT_FILE).toString(); + fs.writeFileSync(OUTPUT_FILE, content.replaceAll(FLAG_KEY_PLACEHOLDER, flagKey)); + }); + }, +}); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index a9de2937c1..678c3c1b26 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -2,11 +2,16 @@ import { AutoEnvAttributes, BasicLogger, BROWSER_DATA_SYSTEM_DEFAULTS, + BROWSER_TRANSITION_TABLE, browserFdv1Endpoints, Configuration, + createDefaultSourceFactoryProvider, + createFDv2DataManagerBase, + FDv2ConnectionMode, FlagManager, Hook, internal, + LDIdentifyOptions as LDBaseIdentifyOptions, LDClientImpl, LDContext, LDEmitter, @@ -17,6 +22,7 @@ import { LDPluginEnvironmentMetadata, LDWaitForInitializationOptions, LDWaitForInitializationResult, + MODE_TABLE, Platform, readFlagsFromBootstrap, safeRegisterDebugOverridePlugins, @@ -78,57 +84,83 @@ class BrowserClientImpl extends LDClientImpl { const { eventUrlTransformer } = validatedBrowserOptions; const endpoints = browserFdv1Endpoints(clientSideId); - super( - clientSideId, - autoEnvAttributes, - platform, - baseOptionsWithDefaults, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new BrowserDataManager( + const dataManagerFactory = ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => { + if (configuration.dataSystem) { + const initialForegroundMode: FDv2ConnectionMode = + (configuration.dataSystem.initialConnectionMode as FDv2ConnectionMode) ?? 'one-shot'; + + return createFDv2DataManagerBase({ platform, flagManager, - clientSideId, - configuration, - validatedBrowserOptions, - endpoints.polling, - endpoints.streaming, + credential: clientSideId, + config: configuration, baseHeaders, emitter, - diagnosticsManager, + transitionTable: BROWSER_TRANSITION_TABLE, + initialForegroundMode, + backgroundMode: undefined, + modeTable: MODE_TABLE, + sourceFactoryProvider: createDefaultSourceFactoryProvider(), + fdv1Endpoints: browserFdv1Endpoints(clientSideId), + buildQueryParams: (identifyOptions?: LDBaseIdentifyOptions) => { + const params: { key: string; value: string }[] = [{ key: 'auth', value: clientSideId }]; + const browserOpts = identifyOptions as LDIdentifyOptions | undefined; + if (browserOpts?.hash) { + params.push({ key: 'h', value: browserOpts.hash }); + } + return params; + }, + }); + } + + return new BrowserDataManager( + platform, + flagManager, + clientSideId, + configuration, + validatedBrowserOptions, + endpoints.polling, + endpoints.streaming, + baseHeaders, + emitter, + diagnosticsManager, + ); + }; + + super(clientSideId, autoEnvAttributes, platform, baseOptionsWithDefaults, dataManagerFactory, { + // This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js + getLegacyStorageKeys: () => + getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)), + analyticsEventPath: `/events/bulk/${clientSideId}`, + diagnosticEventPath: `/events/diagnostic/${clientSideId}`, + includeAuthorizationHeader: false, + highTimeoutThreshold: 5, + userAgentHeaderName: 'x-launchdarkly-user-agent', + dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS, + trackEventModifier: (event: internal.InputCustomEvent) => + new internal.InputCustomEvent( + event.context, + event.key, + event.data, + event.metricValue, + event.samplingRatio, + eventUrlTransformer(getHref()), ), - { - // This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js - getLegacyStorageKeys: () => - getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)), - analyticsEventPath: `/events/bulk/${clientSideId}`, - diagnosticEventPath: `/events/diagnostic/${clientSideId}`, - includeAuthorizationHeader: false, - highTimeoutThreshold: 5, - userAgentHeaderName: 'x-launchdarkly-user-agent', - dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS, - trackEventModifier: (event: internal.InputCustomEvent) => - new internal.InputCustomEvent( - event.context, - event.key, - event.data, - event.metricValue, - event.samplingRatio, - eventUrlTransformer(getHref()), - ), - getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => - internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), - credentialType: 'clientSideId', - }, - ); + getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => + internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), + credentialType: 'clientSideId', + }); this.setEventSendingEnabled(true, false); + this.dataManager.setFlushCallback?.(() => this.flush()); + this._plugins = validatedBrowserOptions.plugins; if (validatedBrowserOptions.fetchGoals) { @@ -281,18 +313,14 @@ class BrowserClientImpl extends LDClientImpl { } 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; - browserDataManager.setForcedStreaming(streaming); + this.dataManager.setForcedStreaming?.(streaming); } private _updateAutomaticStreamingState() { - const browserDataManager = this.dataManager as BrowserDataManager; const hasListeners = this.emitter .eventNames() .some((name) => name.startsWith('change:') || name === 'change'); - browserDataManager.setAutomaticStreamingState(hasListeners); + this.dataManager.setAutomaticStreamingState?.(hasListeners); } override on(eventName: LDEmitterEventName, listener: Function): void { diff --git a/packages/shared/common/src/internal/fdv2/protocolHandler.ts b/packages/shared/common/src/internal/fdv2/protocolHandler.ts index 5d13654ebf..3051c262ce 100644 --- a/packages/shared/common/src/internal/fdv2/protocolHandler.ts +++ b/packages/shared/common/src/internal/fdv2/protocolHandler.ts @@ -1,4 +1,5 @@ import { LDLogger } from '../../api'; +import { isNullish } from '../../validators'; import { DeleteObject, FDv2Event, @@ -111,7 +112,10 @@ export function createProtocolHandler( } function processIntentNone(intent: PayloadIntent): ProtocolAction { - if (!intent.id || !intent.target) { + if (!intent.id || isNullish(intent.target)) { + logger?.warn( + `Ignoring 'none' intent with missing fields: id=${intent.id}, target=${intent.target}`, + ); return ACTION_NONE; } @@ -164,14 +168,15 @@ export function createProtocolHandler( } function processPutObject(data: PutObject): ProtocolAction { - if ( - protocolState === 'inactive' || - !tempId || - !data.kind || - !data.key || - !data.version || - !data.object - ) { + if (protocolState === 'inactive' || !tempId) { + logger?.warn('Received put-object before server-intent was established. Ignoring.'); + return ACTION_NONE; + } + + if (!data.kind || !data.key || isNullish(data.version) || !data.object) { + logger?.warn( + `Ignoring put-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`, + ); return ACTION_NONE; } @@ -191,7 +196,15 @@ export function createProtocolHandler( } function processDeleteObject(data: DeleteObject): ProtocolAction { - if (protocolState === 'inactive' || !tempId || !data.kind || !data.key || !data.version) { + if (protocolState === 'inactive' || !tempId) { + logger?.warn('Received delete-object before server-intent was established. Ignoring.'); + return ACTION_NONE; + } + + if (!data.kind || !data.key || isNullish(data.version)) { + logger?.warn( + `Ignoring delete-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`, + ); return ACTION_NONE; } @@ -214,7 +227,10 @@ export function createProtocolHandler( }; } - if (!tempId || data.state === null || data.state === undefined || !data.version) { + if (!tempId || isNullish(data.state) || isNullish(data.version)) { + logger?.warn( + `Ignoring payload-transferred with missing fields: state=${data.state}, version=${data.version}`, + ); resetAll(); return ACTION_NONE; } diff --git a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts index b2422620dc..25ae203897 100644 --- a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts @@ -257,6 +257,44 @@ describe('given entries with invalid type field', () => { }); }); +describe('given cache entries in synchronizers', () => { + it('discards a cache entry from synchronizers and warns', () => { + const result = validateModeDefinition( + { initializers: [], synchronizers: [{ type: 'cache' }] }, + 'testMode', + logger, + ); + + expect(result.synchronizers).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('got cache')); + }); + + it('keeps valid synchronizer entries and discards cache', () => { + const result = validateModeDefinition( + { + initializers: [], + synchronizers: [{ type: 'polling' }, { type: 'cache' }, { type: 'streaming' }], + }, + 'testMode', + logger, + ); + + expect(result.synchronizers).toEqual([{ type: 'polling' }, { type: 'streaming' }]); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('allows cache as an initializer', () => { + const result = validateModeDefinition( + { initializers: [{ type: 'cache' }], synchronizers: [] }, + 'testMode', + logger, + ); + + expect(result.initializers).toEqual([{ type: 'cache' }]); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + describe('given polling entries with invalid config', () => { it('drops pollInterval when it is a string and warns', () => { const result = validateModeDefinition( diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts new file mode 100644 index 0000000000..6dde24cd1f --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -0,0 +1,971 @@ +import { Context, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; + +import { MODE_TABLE } from '../../src/datasource/ConnectionModeConfig'; +import { createFDv1PollingSynchronizer } from '../../src/datasource/fdv2/FDv1PollingSynchronizer'; +import { createFDv2DataSource } from '../../src/datasource/fdv2/FDv2DataSource'; +import { makeFDv2Requestor } from '../../src/datasource/fdv2/FDv2Requestor'; +import { createSynchronizerSlot } from '../../src/datasource/fdv2/SourceManager'; +import { + createFDv2DataManagerBase, + FDv2DataManagerBaseConfig, + FDv2DataManagerControl, +} from '../../src/datasource/FDv2DataManagerBase'; +import { BROWSER_TRANSITION_TABLE } from '../../src/datasource/ModeResolver'; +import { makeRequestor } from '../../src/datasource/Requestor'; +import { + createStateDebounceManager, + PendingState, +} from '../../src/datasource/StateDebounceManager'; +import { namespaceForEnvironment } from '../../src/storage/namespaceUtils'; + +jest.mock('../../src/datasource/fdv2/FDv2DataSource'); +jest.mock('../../src/datasource/StateDebounceManager'); +jest.mock('../../src/storage/namespaceUtils'); +jest.mock('../../src/datasource/fdv2/FDv2Requestor'); +jest.mock('../../src/datasource/Requestor'); +jest.mock('../../src/datasource/fdv2/FDv1PollingSynchronizer'); + +const mockCreateFDv2DataSource = createFDv2DataSource as jest.MockedFunction< + typeof createFDv2DataSource +>; +const mockCreateStateDebounceManager = createStateDebounceManager as jest.MockedFunction< + typeof createStateDebounceManager +>; +const mockNamespaceForEnvironment = namespaceForEnvironment as jest.MockedFunction< + typeof namespaceForEnvironment +>; +const mockMakeFDv2Requestor = makeFDv2Requestor as jest.MockedFunction; + +function makeLogger() { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +} + +function makePlatform() { + return { + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: { + btoa: jest.fn((s: string) => Buffer.from(s).toString('base64')), + }, + crypto: { + createHash: jest.fn(() => ({ + update: jest.fn().mockReturnThis(), + asyncDigest: jest.fn().mockResolvedValue('hashed'), + })), + randomUUID: jest.fn(() => 'test-uuid'), + }, + storage: undefined, + } as any; +} + +function makeConfig(overrides: Partial = {}) { + return { + logger: makeLogger(), + serviceEndpoints: new ServiceEndpoints('https://stream', 'https://poll', 'https://events'), + withReasons: false, + useReport: false, + streamInitialReconnectDelay: 1, + pollInterval: 300, + dataSystem: undefined, + ...overrides, + } as any; +} + +function makeFlagManager() { + return { + init: jest.fn(), + upsert: jest.fn(), + applyChanges: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as any; +} + +function makeSourceFactoryProvider() { + return { + createInitializerFactory: jest.fn((_entry: any) => jest.fn()), + createSynchronizerSlot: jest.fn((_entry: any) => createSynchronizerSlot(jest.fn())), + }; +} + +// Captured config and callbacks from mocks. +let capturedDataSourceConfigs: any[]; +let capturedOnReconcile: ((pendingState: PendingState) => void) | undefined; +let mockDataSource: { start: jest.Mock; close: jest.Mock }; +let mockDebounceManager: { + setNetworkState: jest.Mock; + setLifecycleState: jest.Mock; + setRequestedMode: jest.Mock; + close: jest.Mock; +}; + +function makeBaseConfig( + overrides: Partial = {}, +): FDv2DataManagerBaseConfig { + return { + platform: makePlatform(), + flagManager: makeFlagManager(), + credential: 'test-credential', + config: makeConfig(), + baseHeaders: { authorization: 'test-credential' }, + emitter: { emit: jest.fn(), on: jest.fn(), off: jest.fn() } as any, + transitionTable: BROWSER_TRANSITION_TABLE, + initialForegroundMode: 'one-shot', + backgroundMode: undefined, + modeTable: MODE_TABLE, + sourceFactoryProvider: makeSourceFactoryProvider(), + buildQueryParams: jest.fn(() => []), + ...overrides, + }; +} + +function makeContext() { + return Context.fromLDContext({ kind: 'user', key: 'test-key' }); +} + +beforeEach(() => { + jest.clearAllMocks(); + + capturedDataSourceConfigs = []; + capturedOnReconcile = undefined; + + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + mockDebounceManager = { + setNetworkState: jest.fn(), + setLifecycleState: jest.fn(), + setRequestedMode: jest.fn(), + close: jest.fn(), + }; + + mockCreateStateDebounceManager.mockImplementation((cfg: any) => { + capturedOnReconcile = cfg.onReconcile; + return mockDebounceManager; + }); + + mockNamespaceForEnvironment.mockResolvedValue('test-namespace'); + mockMakeFDv2Requestor.mockReturnValue({} as any); +}); + +async function identifyManager( + manager: FDv2DataManagerControl, + identifyOptions?: any, +): Promise<{ resolve: jest.Mock; reject: jest.Mock }> { + const resolve = jest.fn(); + const reject = jest.fn(); + await manager.identify(resolve, reject, makeContext(), identifyOptions); + // Flush microtasks so the start() promise resolves. + await Promise.resolve(); + await Promise.resolve(); + return { resolve, reject }; +} + +it('creates a data source for the resolved mode on identify', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + expect(mockDataSource.start).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('tears down the previous data source on re-identify', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + const firstDataSource = mockDataSource; + const firstDebounceManager = mockDebounceManager; + + // Create new mocks for second identify. + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + + await identifyManager(manager); + + expect(firstDataSource.close).toHaveBeenCalledTimes(1); + expect(firstDebounceManager.close).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('resolves identify immediately when bootstrap is provided', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const { resolve } = await identifyManager(manager, { bootstrap: {} }); + + expect(resolve).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('does not create a data source when bootstrap is used with one-shot mode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + await identifyManager(manager, { bootstrap: {} }); + + // one-shot has no synchronizers, so no data source should be created after bootstrap. + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('starts synchronizers when bootstrap is used with streaming mode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + await identifyManager(manager, { bootstrap: {} }); + + // streaming has synchronizers, so a data source should be created. + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + // But with no initializers (bootstrap already provided data). + const dsConfig = capturedDataSourceConfigs[0]; + expect(dsConfig.initializerFactories).toHaveLength(0); + expect(dsConfig.synchronizerSlots.length).toBeGreaterThan(0); + + manager.close(); +}); + +it('includes initializers on mode switch when no selector has been obtained', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + await identifyManager(manager); + + // Reset mock to capture second data source creation. + mockCreateFDv2DataSource.mockClear(); + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + // Simulate mode switch via reconcile: one-shot -> streaming. + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + const dsConfig = capturedDataSourceConfigs[capturedDataSourceConfigs.length - 1]; + // Should include initializers because no selector yet. + expect(dsConfig.initializerFactories.length).toBeGreaterThan(0); + expect(dsConfig.synchronizerSlots.length).toBeGreaterThan(0); + + manager.close(); +}); + +it('closes data source on mode switch from streaming to one-shot and updates current mode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + await identifyManager(manager); + + const firstDataSource = mockDataSource; + + mockCreateFDv2DataSource.mockClear(); + // one-shot post-init has no sources, so createFDv2DataSource won't be called again + // because includeInitializers will be true (no selector) but the factories will be built. + // Actually, one-shot has initializers and no synchronizers. Since no selector, initializers included. + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'one-shot', + }); + + expect(firstDataSource.close).toHaveBeenCalledTimes(1); + expect(manager.getCurrentMode()).toBe('one-shot'); + + manager.close(); +}); + +it('does nothing on mode switch when mode is unchanged', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + await identifyManager(manager); + + mockCreateFDv2DataSource.mockClear(); + + // Reconcile with same mode — should be a no-op. + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'one-shot', + }); + + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('uses only synchronizers on mode switch after selector has been obtained', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + await identifyManager(manager); + + // Simulate that a selector was obtained via dataCallback. + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ type: 'full', updates: [], state: 'selector-123' }); + + mockCreateFDv2DataSource.mockClear(); + capturedDataSourceConfigs = []; + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + // Mode switch to streaming. + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + const newDsConfig = capturedDataSourceConfigs[0]; + // No initializers because selector is present. + expect(newDsConfig.initializerFactories).toHaveLength(0); + expect(newDsConfig.synchronizerSlots.length).toBeGreaterThan(0); + + manager.close(); +}); + +describe('given a manager with streaming as the initial foreground mode', () => { + let manager: FDv2DataManagerControl; + + beforeEach(() => { + manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + }); + + afterEach(() => { + manager.close(); + }); + + it('resolves to streaming when setForcedStreaming is called with true', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + }); + + it('resolves to streaming when setForcedStreaming is undefined and automatic is true', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(undefined); + manager.setAutomaticStreamingState!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenLastCalledWith('streaming'); + }); + + it('resolves to configured mode when setForcedStreaming is undefined and automatic is false', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(undefined); + manager.setAutomaticStreamingState!(false); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenLastCalledWith('streaming'); + }); +}); + +describe('given a manager with one-shot as the initial foreground mode', () => { + let manager: FDv2DataManagerControl; + + beforeEach(() => { + manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + }); + + afterEach(() => { + manager.close(); + }); + + it('resolves to streaming when setForcedStreaming is called with true', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + }); + + it('resolves to one-shot when setForcedStreaming is called with false', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(false); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('one-shot'); + }); + + it('resolves to streaming when automatic streaming is true and forced is undefined', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setAutomaticStreamingState!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + }); + + it('resolves to one-shot when automatic streaming is false and forced is undefined', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setAutomaticStreamingState!(false); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('one-shot'); + }); +}); + +it('falls back to one-shot when setForcedStreaming is false and configured mode is streaming', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(false); + + // forced=false and configured=streaming -> falls back to one-shot. + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('one-shot'); + + manager.close(); +}); + +it('triggers flush callback when lifecycle transitions to background', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const flushCallback = jest.fn(); + manager.setFlushCallback(flushCallback); + + await identifyManager(manager); + + manager.setLifecycleState('background'); + + expect(flushCallback).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('does not trigger flush callback when lifecycle is already background', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const flushCallback = jest.fn(); + manager.setFlushCallback(flushCallback); + + await identifyManager(manager); + + manager.setLifecycleState('background'); + flushCallback.mockClear(); + + // Setting background again should not flush. + manager.setLifecycleState('background'); + expect(flushCallback).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('delegates setNetworkState to debounce manager', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.setNetworkState('unavailable'); + + expect(mockDebounceManager.setNetworkState).toHaveBeenCalledWith('unavailable'); + + manager.close(); +}); + +it('delegates setLifecycleState to debounce manager', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.setLifecycleState('background'); + + expect(mockDebounceManager.setLifecycleState).toHaveBeenCalledWith('background'); + + manager.close(); +}); + +it('delegates setRequestedMode to debounce manager', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.setRequestedMode('streaming'); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + + manager.close(); +}); + +it('skips cache initializer on mode switch when bootstrapped', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + initialForegroundMode: 'streaming', + sourceFactoryProvider, + }), + ); + + await identifyManager(manager, { bootstrap: {} }); + + // After bootstrap identify, the data source was created for streaming + // synchronizers only (no initializers). + sourceFactoryProvider.createInitializerFactory.mockClear(); + + // Now simulate a mode switch that would include initializers. + mockCreateFDv2DataSource.mockClear(); + capturedDataSourceConfigs = []; + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + // Switch to polling (which has cache initializer). + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'polling', + }); + + // Verify that 'cache' type was NOT passed to createInitializerFactory. + const cacheInitCalls = sourceFactoryProvider.createInitializerFactory.mock.calls.filter( + (call: any[]) => call[0].type === 'cache', + ); + expect(cacheInitCalls).toHaveLength(0); + + manager.close(); +}); + +it('adds withReasons query param when config.withReasons is true', async () => { + const buildQueryParams = jest.fn(() => [{ key: 'auth', value: 'test-credential' }]); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + config: makeConfig({ withReasons: true }), + buildQueryParams, + }), + ); + + await identifyManager(manager); + + // The requestor should have been created with withReasons param. + // Check that makeFDv2Requestor was called and the queryParams include withReasons. + expect(mockMakeFDv2Requestor).toHaveBeenCalledTimes(1); + const queryParams = mockMakeFDv2Requestor.mock.calls[0][6]; + expect(queryParams).toContainEqual({ key: 'withReasons', value: 'true' }); + + manager.close(); +}); + +it('closes data source and debounce manager on close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.close(); + + expect(mockDataSource.close).toHaveBeenCalledTimes(1); + expect(mockDebounceManager.close).toHaveBeenCalledTimes(1); +}); + +it('does not create data source after close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.close(); + mockCreateFDv2DataSource.mockClear(); + + // Attempt to identify after close. + const resolve = jest.fn(); + const reject = jest.fn(); + await manager.identify(resolve, reject, makeContext()); + + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); +}); + +it('resolves identify when data source start completes', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const { resolve, reject } = await identifyManager(manager); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(reject).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('rejects identify when data source start fails', async () => { + const error = new Error('start failed'); + mockDataSource.start.mockRejectedValueOnce(error); + + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const resolve = jest.fn(); + const reject = jest.fn(); + await manager.identify(resolve, reject, makeContext()); + // Flush microtasks for the rejected promise. + await Promise.resolve(); + await Promise.resolve(); + + expect(reject).toHaveBeenCalledTimes(1); + expect(reject).toHaveBeenCalledWith(error); + + manager.close(); +}); + +it('exposes configuredForegroundMode from the initial config', () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'polling' })); + + expect(manager.configuredForegroundMode).toBe('polling'); + + manager.close(); +}); + +it('reports the initial resolved mode via getCurrentMode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + await identifyManager(manager); + + expect(manager.getCurrentMode()).toBe('one-shot'); + + manager.close(); +}); + +it('does not reconcile after close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.close(); + mockCreateFDv2DataSource.mockClear(); + + // Calling onReconcile after close should be a no-op. + capturedOnReconcile?.({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); +}); + +it('resolves to offline when network is unavailable via reconcile', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + await identifyManager(manager); + + const firstDataSource = mockDataSource; + mockCreateFDv2DataSource.mockClear(); + + capturedOnReconcile!({ + networkState: 'unavailable', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + // Should close previous data source. + expect(firstDataSource.close).toHaveBeenCalledTimes(1); + // Offline mode resolves via the browser transition table. + expect(manager.getCurrentMode()).toBe('offline'); + + manager.close(); +}); + +it('sets up debounce manager with correct initial state after identify', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + await identifyManager(manager); + + expect(mockCreateStateDebounceManager).toHaveBeenCalledTimes(1); + const config = mockCreateStateDebounceManager.mock.calls[0][0]; + expect(config.initialState).toEqual({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + expect(config.onReconcile).toBeInstanceOf(Function); + + manager.close(); +}); + +it('calls flagManager.applyChanges with type full for a full payload', async () => { + const flagManager = makeFlagManager(); + const manager = createFDv2DataManagerBase(makeBaseConfig({ flagManager })); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ + type: 'full', + updates: [{ kind: 'flag-eval', key: 'flag1', version: 1, object: { value: true } }], + state: 'selector-1', + }); + + expect(flagManager.applyChanges).toHaveBeenCalledTimes(1); + expect(flagManager.applyChanges).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'full', + ); + + manager.close(); +}); + +it('calls flagManager.applyChanges with type partial for a partial payload', async () => { + const flagManager = makeFlagManager(); + const manager = createFDv2DataManagerBase(makeBaseConfig({ flagManager })); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ + type: 'partial', + updates: [{ kind: 'flag-eval', key: 'flag1', version: 2, object: { value: false } }], + state: 'selector-2', + }); + + expect(flagManager.applyChanges).toHaveBeenCalledTimes(1); + expect(flagManager.applyChanges).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'partial', + ); + + manager.close(); +}); + +it('calls flagManager.applyChanges with type none on none payload to update freshness', async () => { + const flagManager = makeFlagManager(); + const manager = createFDv2DataManagerBase(makeBaseConfig({ flagManager })); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ + type: 'none', + updates: [], + state: 'selector-3', + }); + + // Spec 5.2.2: transfer-none confirms data is still current. + // applyChanges with type none persists cache (updating freshness). + expect(flagManager.applyChanges).toHaveBeenCalledTimes(1); + expect(flagManager.applyChanges).toHaveBeenCalledWith(expect.anything(), {}, 'none'); + + manager.close(); +}); + +it('stores selector from payload state for subsequent data source creations', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + await identifyManager(manager); + + // Deliver a payload with a selector. + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ type: 'none', updates: [], state: 'my-selector' }); + + // Now switch mode. Since selector exists, no initializers. + mockCreateFDv2DataSource.mockClear(); + capturedDataSourceConfigs = []; + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + if (capturedDataSourceConfigs.length > 0) { + expect(capturedDataSourceConfigs[0].initializerFactories).toHaveLength(0); + } + + manager.close(); +}); + +it('warns and skips unsupported initializer entry types', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + // Return undefined for one entry to trigger the warning path. + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createInitializerFactory.mockImplementation((entry: any) => + entry.type === 'polling' ? jest.fn() : undefined, + ); + const cfg = makeConfig(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + config: cfg, + sourceFactoryProvider, + // Use streaming mode which has cache + polling initializers. + initialForegroundMode: 'streaming', + }), + ); + await identifyManager(manager); + + // cache entry returns undefined → warning logged. + expect(cfg.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Unsupported initializer type'), + ); + + manager.close(); +}); + +it('warns and skips unsupported synchronizer entry types', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + // Return undefined for all synchronizer entries to trigger the warning path. + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createSynchronizerSlot.mockReturnValue(undefined); + const cfg = makeConfig(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + config: cfg, + sourceFactoryProvider, + // streaming mode has streaming + polling synchronizers. + initialForegroundMode: 'streaming', + }), + ); + await identifyManager(manager); + + expect(cfg.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Unsupported synchronizer type'), + ); + + manager.close(); +}); + +it('appends a blocked FDv1 fallback synchronizer when fdv1Endpoints are configured', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + const fdv1Endpoints = { + polling: jest.fn(() => ({ + pathGet: jest.fn(), + pathReport: jest.fn(), + pathPost: jest.fn(), + pathPing: jest.fn(), + })), + streaming: jest.fn(() => ({ + pathGet: jest.fn(), + pathReport: jest.fn(), + pathPost: jest.fn(), + pathPing: jest.fn(), + })), + }; + + (makeRequestor as jest.Mock).mockReturnValue({}); + (createFDv1PollingSynchronizer as jest.Mock).mockReturnValue({ close: jest.fn() }); + + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + sourceFactoryProvider, + fdv1Endpoints, + // streaming mode has synchronizers, so FDv1 fallback will be appended. + initialForegroundMode: 'streaming', + }), + ); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + // The last synchronizer slot should be the FDv1 fallback (blocked). + const lastSlot = dsConfig.synchronizerSlots[dsConfig.synchronizerSlots.length - 1]; + expect(lastSlot.isFDv1Fallback).toBe(true); + expect(lastSlot.state).toBe('blocked'); + + manager.close(); +}); + +it('resolves identify immediately when initial mode has no sources', async () => { + // Use a custom mode table where the initial mode has empty initializers and synchronizers. + const sourceFactoryProvider = makeSourceFactoryProvider(); + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createInitializerFactory.mockReturnValue(undefined); + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createSynchronizerSlot.mockReturnValue(undefined); + + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + sourceFactoryProvider, + // offline mode: [cache] initializer, [] synchronizers. + // With provider returning undefined for cache, both arrays are empty. + initialForegroundMode: 'offline', + }), + ); + + const { resolve } = await identifyManager(manager); + + // Should resolve immediately — offline with no sources. + expect(resolve).toHaveBeenCalledTimes(1); + // No data source should have been created. + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('does not identify after close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + manager.close(); + + const cfg = makeConfig(); + // Re-create with our logger to check debug message. + const manager2 = createFDv2DataManagerBase(makeBaseConfig({ config: cfg })); + await identifyManager(manager2); + manager2.close(); + + // Now close and try to identify. + mockCreateFDv2DataSource.mockClear(); + const resolve = jest.fn(); + const reject = jest.fn(); + await manager2.identify(resolve, reject, makeContext()); + + // After close, identify should be a no-op. + expect(resolve).not.toHaveBeenCalled(); + expect(cfg.logger.debug).toHaveBeenCalledWith( + expect.stringContaining('Identify called after close'), + ); +}); + +it('exposes the selectorGetter in the factory context that reads current selector', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + let capturedCtx: any; + // @ts-ignore - mock captures ctx argument + sourceFactoryProvider.createInitializerFactory.mockImplementation((_entry: any, ctx: any) => { + capturedCtx = ctx; + return jest.fn(); + }); + + const manager = createFDv2DataManagerBase(makeBaseConfig({ sourceFactoryProvider })); + await identifyManager(manager); + + // Initially selector is undefined. + expect(capturedCtx.selectorGetter()).toBeUndefined(); + + // Deliver a payload with a selector via dataCallback. + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ type: 'full', updates: [], state: 'new-selector' }); + + // Now selectorGetter should return the selector. + expect(capturedCtx.selectorGetter()).toBe('new-selector'); + + manager.close(); +}); diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts new file mode 100644 index 0000000000..2faeec0b8c --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -0,0 +1,310 @@ +import { + Context, + Crypto, + Encoding, + LDLogger, + Requests, + ServiceEndpoints, +} from '@launchdarkly/js-sdk-common'; + +import { InitializerEntry, SynchronizerEntry } from '../../src/api/datasource'; +import { DataSourcePaths } from '../../src/datasource/DataSourceConfig'; +import { createCacheInitializerFactory } from '../../src/datasource/fdv2/CacheInitializer'; +import { FDv2Requestor, makeFDv2Requestor } from '../../src/datasource/fdv2/FDv2Requestor'; +import { createPollingInitializer } from '../../src/datasource/fdv2/PollingInitializer'; +import { createPollingSynchronizer } from '../../src/datasource/fdv2/PollingSynchronizer'; +import { createSynchronizerSlot } from '../../src/datasource/fdv2/SourceManager'; +import { createStreamingBase } from '../../src/datasource/fdv2/StreamingFDv2Base'; +import { createStreamingInitializer } from '../../src/datasource/fdv2/StreamingInitializerFDv2'; +import { createStreamingSynchronizer } from '../../src/datasource/fdv2/StreamingSynchronizerFDv2'; +import { + createDefaultSourceFactoryProvider, + SourceFactoryContext, +} from '../../src/datasource/SourceFactoryProvider'; + +jest.mock('../../src/datasource/fdv2/PollingInitializer'); +jest.mock('../../src/datasource/fdv2/PollingSynchronizer'); +jest.mock('../../src/datasource/fdv2/StreamingFDv2Base'); +jest.mock('../../src/datasource/fdv2/StreamingInitializerFDv2'); +jest.mock('../../src/datasource/fdv2/StreamingSynchronizerFDv2'); +jest.mock('../../src/datasource/fdv2/CacheInitializer'); +jest.mock('../../src/datasource/fdv2/FDv2Requestor'); +jest.mock('../../src/datasource/fdv2/PollingBase'); + +const mockCreatePollingInitializer = createPollingInitializer as jest.Mock; +const mockCreatePollingSynchronizer = createPollingSynchronizer as jest.Mock; +const mockCreateStreamingBase = createStreamingBase as jest.Mock; +const mockCreateStreamingInitializer = createStreamingInitializer as jest.Mock; +const mockCreateStreamingSynchronizer = createStreamingSynchronizer as jest.Mock; +const mockCreateCacheInitializerFactory = createCacheInitializerFactory as jest.Mock; +const mockMakeFDv2Requestor = makeFDv2Requestor as jest.Mock; +const mockCreateSynchronizerSlot = createSynchronizerSlot as jest.Mock; + +jest.mock('../../src/datasource/fdv2/SourceManager', () => ({ + createSynchronizerSlot: jest.fn((factory: any) => ({ + factory, + isFDv1Fallback: false, + state: 'available', + })), +})); + +function makeContext(): Context { + return Context.fromLDContext({ kind: 'user', key: 'test-user' }); +} + +function makePaths(): DataSourcePaths { + return { + pathGet: jest.fn().mockReturnValue('/eval/test-path'), + pathReport: jest.fn().mockReturnValue('/eval/report-path'), + pathPost: jest.fn().mockReturnValue('/eval/post-path'), + pathPing: jest.fn().mockReturnValue('/eval/ping-path'), + }; +} + +function makeSourceFactoryContext(overrides?: Partial): SourceFactoryContext { + return { + requestor: { poll: jest.fn() } as unknown as FDv2Requestor, + requests: {} as Requests, + encoding: {} as Encoding, + serviceEndpoints: new ServiceEndpoints( + 'https://stream.example.com', + 'https://poll.example.com', + 'https://events.example.com', + ), + pollingPaths: makePaths(), + streamingPaths: makePaths(), + baseHeaders: { authorization: 'sdk-key' }, + queryParams: [], + plainContextString: '{"kind":"user","key":"test-user"}', + selectorGetter: () => undefined, + streamInitialReconnectDelay: 1, + pollInterval: 30, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as LDLogger, + storage: undefined, + crypto: {} as Crypto, + environmentNamespace: 'test-env', + context: makeContext(), + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockCreatePollingInitializer.mockReturnValue({ close: jest.fn() }); + mockCreatePollingSynchronizer.mockReturnValue({ close: jest.fn() }); + mockCreateStreamingBase.mockReturnValue({ + start: jest.fn(), + close: jest.fn(), + takeResult: jest.fn(), + }); + mockCreateStreamingInitializer.mockReturnValue({ close: jest.fn() }); + mockCreateStreamingSynchronizer.mockReturnValue({ close: jest.fn() }); + mockCreateCacheInitializerFactory.mockReturnValue(jest.fn()); + mockMakeFDv2Requestor.mockReturnValue({ poll: jest.fn() }); +}); + +// --- createInitializerFactory --- + +it('creates a PollingInitializer for a polling initializer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { type: 'polling' }; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(factory).toBeDefined(); + const selectorGetter = () => 'some-selector'; + factory!(selectorGetter); + expect(mockCreatePollingInitializer).toHaveBeenCalledWith( + ctx.requestor, + ctx.logger, + selectorGetter, + ); +}); + +it('creates a StreamingInitializer for a streaming initializer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { type: 'streaming' }; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(factory).toBeDefined(); + const selectorGetter = () => 'some-selector'; + factory!(selectorGetter); + expect(mockCreateStreamingBase).toHaveBeenCalledWith( + expect.objectContaining({ + requests: ctx.requests, + serviceEndpoints: ctx.serviceEndpoints, + initialRetryDelayMillis: ctx.streamInitialReconnectDelay * 1000, + }), + ); + expect(mockCreateStreamingInitializer).toHaveBeenCalledWith( + mockCreateStreamingBase.mock.results[0].value, + ); +}); + +it('creates a CacheInitializer for a cache initializer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { type: 'cache' }; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(mockCreateCacheInitializerFactory).toHaveBeenCalledWith({ + storage: ctx.storage, + crypto: ctx.crypto, + environmentNamespace: ctx.environmentNamespace, + context: ctx.context, + logger: ctx.logger, + }); + expect(factory).toBe(mockCreateCacheInitializerFactory.mock.results[0].value); +}); + +it('returns undefined for an unknown initializer entry type', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry = { type: 'unknown' } as unknown as InitializerEntry; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(factory).toBeUndefined(); +}); + +// --- createSynchronizerSlot --- + +it('creates a PollingSynchronizer slot for a polling synchronizer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: SynchronizerEntry = { type: 'polling' }; + + const slot = provider.createSynchronizerSlot(entry, ctx); + + expect(slot).toBeDefined(); + expect(mockCreateSynchronizerSlot).toHaveBeenCalled(); + + // Invoke the factory that was passed to createSynchronizerSlot + const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; + const selectorGetter = () => 'sel'; + factoryArg(selectorGetter); + expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith( + ctx.requestor, + ctx.logger, + selectorGetter, + ctx.pollInterval * 1000, + ); +}); + +it('creates a StreamingSynchronizer slot for a streaming synchronizer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: SynchronizerEntry = { type: 'streaming' }; + + const slot = provider.createSynchronizerSlot(entry, ctx); + + expect(slot).toBeDefined(); + expect(mockCreateSynchronizerSlot).toHaveBeenCalled(); + + // Invoke the factory that was passed to createSynchronizerSlot + const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; + const selectorGetter = () => 'sel'; + factoryArg(selectorGetter); + expect(mockCreateStreamingBase).toHaveBeenCalledWith( + expect.objectContaining({ + requests: ctx.requests, + serviceEndpoints: ctx.serviceEndpoints, + initialRetryDelayMillis: ctx.streamInitialReconnectDelay * 1000, + }), + ); + expect(mockCreateStreamingSynchronizer).toHaveBeenCalledWith( + mockCreateStreamingBase.mock.results[0].value, + ); +}); + +it('returns undefined for an unknown synchronizer entry type', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry = { type: 'unknown' } as unknown as SynchronizerEntry; + + const slot = provider.createSynchronizerSlot(entry, ctx); + + expect(slot).toBeUndefined(); +}); + +// --- per-entry overrides --- + +it('creates a new requestor when polling entry has endpoint overrides', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { + type: 'polling', + endpoints: { pollingBaseUri: 'https://custom-poll.example.com' }, + }; + + const factory = provider.createInitializerFactory(entry, ctx); + expect(factory).toBeDefined(); + + const selectorGetter = () => undefined; + factory!(selectorGetter); + + expect(mockMakeFDv2Requestor).toHaveBeenCalledWith( + ctx.plainContextString, + expect.objectContaining({ + polling: 'https://custom-poll.example.com', + streaming: 'https://stream.example.com', + }), + ctx.pollingPaths, + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + ); + + // Should use the new requestor, not the context one + const newRequestor = mockMakeFDv2Requestor.mock.results[0].value; + expect(mockCreatePollingInitializer).toHaveBeenCalledWith( + newRequestor, + ctx.logger, + selectorGetter, + ); +}); + +it('uses per-entry pollInterval override for polling synchronizer', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ pollInterval: 30 }); + const entry: SynchronizerEntry = { type: 'polling', pollInterval: 60 }; + + provider.createSynchronizerSlot(entry, ctx); + + const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; + const selectorGetter = () => undefined; + factoryArg(selectorGetter); + + expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith( + ctx.requestor, + ctx.logger, + selectorGetter, + 60000, + ); +}); + +it('uses per-entry initialReconnectDelay override for streaming initializer', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ streamInitialReconnectDelay: 1 }); + const entry: InitializerEntry = { type: 'streaming', initialReconnectDelay: 5 }; + + const factory = provider.createInitializerFactory(entry, ctx); + expect(factory).toBeDefined(); + factory!(() => undefined); + + expect(mockCreateStreamingBase).toHaveBeenCalledWith( + expect.objectContaining({ + initialRetryDelayMillis: 5000, + }), + ); +}); diff --git a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts index 5cb1fb0c76..314cd31279 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -3,6 +3,7 @@ import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import DefaultFlagManager from '../../src/flag-manager/FlagManager'; import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater'; import { + makeIncrementingStamper, makeMemoryStorage, makeMockCrypto, makeMockItemDescriptor, @@ -177,3 +178,67 @@ describe('FlagManager override tests', () => { expect(allFlags['override-only-flag'].flag.value).toBe('override-value'); }); }); + +describe('given a flag manager with storage', () => { + let flagManager: DefaultFlagManager; + let mockPlatform: Platform; + let mockLogger: LDLogger; + let storage: ReturnType; + + beforeEach(() => { + mockLogger = makeMockLogger(); + storage = makeMemoryStorage(); + mockPlatform = makeMockPlatform(storage, makeMockCrypto()); + flagManager = new DefaultFlagManager( + mockPlatform, + TEST_SDK_KEY, + TEST_MAX_CACHED_CONTEXTS, + false, + mockLogger, + makeIncrementingStamper(), + ); + }); + + it('replaces all flags when applyChanges is called with type full', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + existing: makeMockItemDescriptor(1, 'old'), + }); + + await flagManager.applyChanges( + context, + { 'new-flag': makeMockItemDescriptor(2, 'new') }, + 'full', + ); + + // type=full replaces, so existing flag should be gone. + expect(flagManager.get('existing')).toBeUndefined(); + expect(flagManager.get('new-flag')?.flag.value).toBe('new'); + }); + + it('upserts individual flags when applyChanges is called with type partial', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + existing: makeMockItemDescriptor(1, 'old'), + }); + + await flagManager.applyChanges(context, { added: makeMockItemDescriptor(2, 'new') }, 'partial'); + + // type=partial upserts, so existing flag should remain. + expect(flagManager.get('existing')?.flag.value).toBe('old'); + expect(flagManager.get('added')?.flag.value).toBe('new'); + }); + + it('persists cache when applyChanges is called with type none', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + flag1: makeMockItemDescriptor(1, 'value'), + }); + + // applyChanges with type none should still persist (updating freshness). + await flagManager.applyChanges(context, {}, 'none'); + + // Flag should still be present (no changes). + expect(flagManager.get('flag1')?.flag.value).toBe('value'); + }); +}); diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index d379952c31..644b06d399 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -53,6 +53,33 @@ export interface DataManager { * Closes the data manager. Any active connections are closed. */ close(): void; + + /** + * Force streaming on or off. When `true`, the data manager should + * maintain a streaming connection. When `false`, streaming is disabled. + * When `undefined`, the forced state is cleared and automatic behavior + * takes over. + * + * Optional — only browser data managers implement this. + */ + setForcedStreaming?(streaming?: boolean): void; + + /** + * Update the automatic streaming state based on whether change listeners + * are registered. When `true` and forced streaming is not set, the data + * manager should activate streaming. + * + * Optional — only browser data managers implement this. + */ + setAutomaticStreamingState?(streaming: boolean): void; + + /** + * Set a callback to flush pending analytics events. Called immediately + * (not debounced) when the lifecycle transitions to background. + * + * Optional — only FDv2 data managers implement this. + */ + setFlushCallback?(callback: () => void): void; } /** diff --git a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts index 873576edb9..0385fe6156 100644 --- a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts +++ b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts @@ -14,6 +14,7 @@ export interface EndpointConfig { /** * Configuration for a cache data source entry. + * Cache is only valid as an initializer (not a synchronizer). */ export interface CacheDataSourceEntry { readonly type: 'cache'; @@ -45,6 +46,21 @@ export interface StreamingDataSourceEntry { readonly endpoints?: EndpointConfig; } +/** + * An entry in the initializers list of a mode definition. Initializers + * can be cache, polling, or streaming sources. + */ +export type InitializerEntry = + | CacheDataSourceEntry + | PollingDataSourceEntry + | StreamingDataSourceEntry; + +/** + * An entry in the synchronizers list of a mode definition. Synchronizers + * can be polling or streaming sources (not cache). + */ +export type SynchronizerEntry = PollingDataSourceEntry | StreamingDataSourceEntry; + /** * A data source entry in a mode table. Each entry identifies a data source type * and carries type-specific configuration overrides. diff --git a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts index 470a481326..bcd45c1fc4 100644 --- a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts @@ -1,4 +1,5 @@ import FDv2ConnectionMode from './FDv2ConnectionMode'; +import { ModeDefinition } from './ModeDefinition'; // When FDv2 becomes the default, this should be integrated into the // main LDOptions interface (api/LDOptions.ts). @@ -48,8 +49,27 @@ export interface LDClientDataSystemOptions { */ automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig; - // Req 5.3.5 TBD — custom named modes reserved for future use. - // customModes?: Record; + /** + * Override the data source pipeline for specific connection modes. + * + * Each key is a connection mode name (`'streaming'`, `'polling'`, `'offline'`, + * `'one-shot'`, `'background'`). The value defines the initializers and + * synchronizers for that mode, replacing the built-in defaults. + * + * Only the modes you specify are overridden — unspecified modes retain + * their built-in definitions. + * + * @example + * ``` + * connectionModes: { + * streaming: { + * initializers: [{ type: 'polling' }], + * synchronizers: [{ type: 'streaming' }], + * }, + * } + * ``` + */ + connectionModes?: Partial>; } /** diff --git a/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts b/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts index 475eb4fb9b..97d221f923 100644 --- a/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts +++ b/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts @@ -1,4 +1,4 @@ -import { DataSourceEntry } from './DataSourceEntry'; +import { InitializerEntry, SynchronizerEntry } from './DataSourceEntry'; /** * Defines the data pipeline for a connection mode: which data sources @@ -10,7 +10,7 @@ export interface ModeDefinition { * Sources are tried in order; the first that successfully provides a full * data set transitions the SDK out of the initialization phase. */ - readonly initializers: ReadonlyArray; + readonly initializers: ReadonlyArray; /** * Ordered list of data sources for ongoing synchronization after @@ -18,5 +18,5 @@ export interface ModeDefinition { * failover to the next source if the primary fails. * An empty array means no synchronization occurs (e.g., offline, one-shot). */ - readonly synchronizers: ReadonlyArray; + readonly synchronizers: ReadonlyArray; } diff --git a/packages/shared/sdk-client/src/api/datasource/index.ts b/packages/shared/sdk-client/src/api/datasource/index.ts index e9a50e129d..95a688808b 100644 --- a/packages/shared/sdk-client/src/api/datasource/index.ts +++ b/packages/shared/sdk-client/src/api/datasource/index.ts @@ -4,6 +4,8 @@ export type { CacheDataSourceEntry, PollingDataSourceEntry, StreamingDataSourceEntry, + InitializerEntry, + SynchronizerEntry, DataSourceEntry, } from './DataSourceEntry'; export type { ModeDefinition } from './ModeDefinition'; diff --git a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts index 03b0eb7082..51573af7e3 100644 --- a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts +++ b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts @@ -42,15 +42,20 @@ const streamingEntryValidators = { endpoints: validatorOf(endpointValidators), }; -const dataSourceEntryArrayValidator = arrayOf('type', { +const initializerEntryArrayValidator = arrayOf('type', { cache: cacheEntryValidators, polling: pollingEntryValidators, streaming: streamingEntryValidators, }); +const synchronizerEntryArrayValidator = arrayOf('type', { + polling: pollingEntryValidators, + streaming: streamingEntryValidators, +}); + const modeDefinitionValidators = { - initializers: dataSourceEntryArrayValidator, - synchronizers: dataSourceEntryArrayValidator, + initializers: initializerEntryArrayValidator, + synchronizers: synchronizerEntryArrayValidator, }; const MODE_DEFINITION_DEFAULTS: Record = { diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts new file mode 100644 index 0000000000..df9200c473 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -0,0 +1,585 @@ +import { Context, internal, LDHeaders, Platform } from '@launchdarkly/js-sdk-common'; + +import { + FDv2ConnectionMode, + ModeDefinition, + ModeResolutionTable, + ModeState, +} from '../api/datasource'; +import { LDIdentifyOptions } from '../api/LDIdentifyOptions'; +import { Configuration } from '../configuration/Configuration'; +import { DataManager } from '../DataManager'; +import { FlagManager } from '../flag-manager/FlagManager'; +import LDEmitter from '../LDEmitter'; +import { namespaceForEnvironment } from '../storage/namespaceUtils'; +import { ModeTable } from './ConnectionModeConfig'; +import { createDataSourceStatusManager, DataSourceStatusManager } from './DataSourceStatusManager'; +import { DataSourceEndpoints, fdv2Endpoints } from './Endpoints'; +import { createFDv1PollingSynchronizer } from './fdv2/FDv1PollingSynchronizer'; +import { createFDv2DataSource, FDv2DataSource } from './fdv2/FDv2DataSource'; +import { makeFDv2Requestor } from './fdv2/FDv2Requestor'; +import { createSynchronizerSlot, InitializerFactory, SynchronizerSlot } from './fdv2/SourceManager'; +import { flagEvalPayloadToItemDescriptors } from './flagEvalMapper'; +import { resolveConnectionMode } from './ModeResolver'; +import { makeRequestor } from './Requestor'; +import { SourceFactoryContext, SourceFactoryProvider } from './SourceFactoryProvider'; +import { + createStateDebounceManager, + LifecycleState, + NetworkState, + PendingState, + StateDebounceManager, +} from './StateDebounceManager'; + +const logTag = '[FDv2DataManagerBase]'; + +/** + * Configuration for creating an {@link FDv2DataManagerControl}. + */ +export interface FDv2DataManagerBaseConfig { + platform: Platform; + flagManager: FlagManager; + credential: string; + config: Configuration; + baseHeaders: LDHeaders; + emitter: LDEmitter; + + /** Mode resolution table for this platform. */ + transitionTable: ModeResolutionTable; + /** The initial foreground connection mode. */ + initialForegroundMode: FDv2ConnectionMode; + /** The background connection mode, if any. */ + backgroundMode: FDv2ConnectionMode | undefined; + /** The mode table mapping modes to data source definitions. */ + modeTable: ModeTable; + /** Provider that converts DataSourceEntry descriptors to concrete factories. */ + sourceFactoryProvider: SourceFactoryProvider; + /** + * Platform-specific function to build query params for each identify call. + * Browser returns `[{ key: 'auth', value: credential }]` + optional hash. + * Mobile returns `[]` (uses Authorization header instead). + */ + buildQueryParams: (identifyOptions?: LDIdentifyOptions) => { key: string; value: string }[]; + + /** + * FDv1 endpoint factory for fallback. When provided, a blocked FDv1 + * polling synchronizer slot is automatically appended to every data + * source. It is activated when an FDv2 response includes the + * `x-ld-fd-fallback` header. + * + * Browser: `browserFdv1Endpoints(clientSideId)` + * Mobile: `mobileFdv1Endpoints()` + */ + fdv1Endpoints?: DataSourceEndpoints; + + /** Fallback condition timeout in ms (default 120s). */ + fallbackTimeoutMs?: number; + /** Recovery condition timeout in ms (default 300s). */ + recoveryTimeoutMs?: number; +} + +/** + * The public interface returned by {@link createFDv2DataManagerBase}. + * Extends {@link DataManager} with mode control methods. + */ +export interface FDv2DataManagerControl extends DataManager { + /** Update the pending network state. Goes through debounce. */ + setNetworkState(state: NetworkState): void; + /** Update the pending lifecycle state. Goes through debounce. */ + setLifecycleState(state: LifecycleState): void; + /** Update the requested connection mode. Goes through debounce. */ + setRequestedMode(mode: FDv2ConnectionMode): void; + /** + * Set the effective foreground mode directly. Used by browser + * listener-driven streaming to promote/demote the foreground mode. + * Goes through debounce. + */ + setForegroundMode(mode: FDv2ConnectionMode): void; + /** Get the currently resolved connection mode. */ + getCurrentMode(): FDv2ConnectionMode; + /** The configured default foreground mode (from config, not auto-promoted). */ + readonly configuredForegroundMode: FDv2ConnectionMode; + /** + * Set a callback to flush pending analytics events. Called immediately + * (not debounced) when the lifecycle transitions to background. + */ + setFlushCallback(callback: () => void): void; +} + +/** + * Creates a shared FDv2 data manager that owns mode resolution, debouncing, + * selector state, and FDv2DataSource lifecycle. Platform SDKs (browser, RN) + * wrap this with platform-specific config and event wiring. + */ +export function createFDv2DataManagerBase( + baseConfig: FDv2DataManagerBaseConfig, +): FDv2DataManagerControl { + const { + platform, + flagManager, + config, + baseHeaders, + emitter, + transitionTable, + initialForegroundMode, + backgroundMode, + modeTable, + sourceFactoryProvider, + buildQueryParams, + fdv1Endpoints, + fallbackTimeoutMs, + recoveryTimeoutMs, + } = baseConfig; + + const { logger } = config; + const statusManager: DataSourceStatusManager = createDataSourceStatusManager(emitter); + const endpoints = fdv2Endpoints(); + + // Merge user-provided connection mode overrides into the mode table. + const effectiveModeTable: ModeTable = config.dataSystem?.connectionModes + ? { ...modeTable, ...config.dataSystem.connectionModes } + : modeTable; + + // --- Mutable state --- + let selector: string | undefined; + let currentResolvedMode: FDv2ConnectionMode = initialForegroundMode; + let foregroundMode: FDv2ConnectionMode = initialForegroundMode; + let dataSource: FDv2DataSource | undefined; + let debounceManager: StateDebounceManager | undefined; + let identifiedContext: Context | undefined; + let factoryContext: SourceFactoryContext | undefined; + let initialized = false; + let bootstrapped = false; + let closed = false; + let flushCallback: (() => void) | undefined; + + // Forced/automatic streaming state for browser listener-driven streaming. + let forcedStreaming: boolean | undefined; + let automaticStreamingState = false; + + // Outstanding identify promise callbacks — needed so that mode switches + // during identify can wire the new data source's completion to the + // original identify promise. + let pendingIdentifyResolve: (() => void) | undefined; + let pendingIdentifyReject: ((err: Error) => void) | undefined; + + // Current debounce input state. + let networkState: NetworkState = 'available'; + let lifecycleState: LifecycleState = 'foreground'; + + // --- Helpers --- + + function getModeDefinition(mode: FDv2ConnectionMode): ModeDefinition { + return effectiveModeTable[mode]; + } + + function buildModeState(): ModeState { + return { + lifecycle: lifecycleState, + networkAvailable: networkState === 'available', + foregroundMode, + backgroundMode: backgroundMode ?? 'offline', + }; + } + + function resolveMode(): FDv2ConnectionMode { + return resolveConnectionMode(transitionTable, buildModeState()); + } + + /** + * Determine the foreground mode based on forced/automatic streaming state. + * + * +-----------+-----------+---------------------------+ + * | forced | automatic | result | + * +-----------+-----------+---------------------------+ + * | true | any | 'streaming' | + * | false | any | configured, never streaming| + * | undefined | true | 'streaming' | + * | undefined | false | configured mode | + * +-----------+-----------+---------------------------+ + */ + function resolveStreamingMode(): FDv2ConnectionMode { + if (forcedStreaming === true) { + return 'streaming'; + } + if (forcedStreaming === false) { + // Explicitly forced off — use configured mode, but never streaming. + return initialForegroundMode === 'streaming' ? 'one-shot' : initialForegroundMode; + } + // forcedStreaming === undefined — automatic behavior. + return automaticStreamingState ? 'streaming' : initialForegroundMode; + } + + /** + * Convert a ModeDefinition's entries into concrete InitializerFactory[] + * and SynchronizerSlot[] using the source factory provider. + */ + function buildFactories( + modeDef: ModeDefinition, + ctx: SourceFactoryContext, + includeInitializers: boolean, + ): { + initializerFactories: InitializerFactory[]; + synchronizerSlots: SynchronizerSlot[]; + } { + const initializerFactories: InitializerFactory[] = []; + if (includeInitializers) { + modeDef.initializers + // Skip cache when bootstrapped — bootstrap data was applied to the + // flag store before identify, so the cache would only load older data. + .filter((entry) => !(bootstrapped && entry.type === 'cache')) + .forEach((entry) => { + const factory = sourceFactoryProvider.createInitializerFactory(entry, ctx); + if (factory) { + initializerFactories.push(factory); + } else { + logger.warn( + `${logTag} Unsupported initializer type '${entry.type}'. It will be skipped.`, + ); + } + }); + } + + const synchronizerSlots: SynchronizerSlot[] = []; + modeDef.synchronizers.forEach((entry) => { + const slot = sourceFactoryProvider.createSynchronizerSlot(entry, ctx); + if (slot) { + synchronizerSlots.push(slot); + } else { + logger.warn(`${logTag} Unsupported synchronizer type '${entry.type}'. It will be skipped.`); + } + }); + + // Append a blocked FDv1 fallback synchronizer when configured and + // when there are FDv2 synchronizers to fall back from. + if (fdv1Endpoints && synchronizerSlots.length > 0) { + const fdv1RequestorFactory = () => + makeRequestor( + ctx.plainContextString, + ctx.serviceEndpoints, + fdv1Endpoints.polling(), + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + config.withReasons, + config.useReport, + ); + + const fdv1SyncFactory = () => + createFDv1PollingSynchronizer(fdv1RequestorFactory(), config.pollInterval * 1000, logger); + + synchronizerSlots.push(createSynchronizerSlot(fdv1SyncFactory, { isFDv1Fallback: true })); + } + + return { initializerFactories, synchronizerSlots }; + } + + /** + * The data callback shared across all FDv2DataSource instances for + * the current identify. Handles selector tracking and flag updates. + */ + function dataCallback(payload: internal.Payload): void { + logger.debug( + `${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`, + ); + + if (payload.state) { + selector = payload.state; + } + + const context = identifiedContext; + if (!context) { + logger.warn(`${logTag} dataCallback called without an identified context.`); + return; + } + + if (payload.type === 'none') { + // Spec 5.2.2: transfer-none confirms data is still current. + // Persist cache to update freshness timestamp without changing flags. + flagManager.applyChanges(context, {}, 'none'); + return; + } + + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); + flagManager.applyChanges(context, descriptors, payload.type); + } + + /** + * Create and start a new FDv2DataSource for the given mode. + * + * @param mode The connection mode to use. + * @param includeInitializers Whether to include initializers (true on + * first identify, false on mode switch after initialization). + */ + function createAndStartDataSource(mode: FDv2ConnectionMode, includeInitializers: boolean): void { + if (!factoryContext) { + logger.warn(`${logTag} Cannot create data source without factory context.`); + return; + } + + const modeDef = getModeDefinition(mode); + const { initializerFactories, synchronizerSlots } = buildFactories( + modeDef, + factoryContext, + includeInitializers, + ); + + currentResolvedMode = mode; + + // If there are no sources at all (e.g., offline or one-shot mode + // post-initialization), don't create a data source. + if (initializerFactories.length === 0 && synchronizerSlots.length === 0) { + logger.debug(`${logTag} Mode '${mode}' has no sources. No data source created.`); + if (!initialized && pendingIdentifyResolve) { + // Offline mode during initial identify — resolve immediately. + // The SDK will use cached data if any. + initialized = true; + pendingIdentifyResolve(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + return; + } + + const selectorGetter = () => selector; + + dataSource = createFDv2DataSource({ + initializerFactories, + synchronizerSlots, + dataCallback, + statusManager, + selectorGetter, + logger, + fallbackTimeoutMs, + recoveryTimeoutMs, + }); + + dataSource + .start() + .then(() => { + initialized = true; + if (pendingIdentifyResolve) { + pendingIdentifyResolve(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + }) + .catch((err) => { + if (pendingIdentifyReject) { + pendingIdentifyReject(err instanceof Error ? err : new Error(String(err))); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + }); + } + + /** + * Reconciliation callback invoked when the debounce timer fires. + * Resolves the new mode and switches data sources if needed. + */ + function onReconcile(pendingState: PendingState): void { + if (closed || !factoryContext) { + return; + } + + // Update local state from the debounced pending state. + networkState = pendingState.networkState; + lifecycleState = pendingState.lifecycleState; + foregroundMode = pendingState.requestedMode; + + const newMode = resolveMode(); + + if (newMode === currentResolvedMode) { + logger.debug(`${logTag} Reconcile: mode unchanged (${newMode}). No action.`); + return; + } + + logger.debug( + `${logTag} Reconcile: mode switching from '${currentResolvedMode}' to '${newMode}'.`, + ); + + // Close the current data source. + dataSource?.close(); + dataSource = undefined; + + // Include initializers if we don't have a selector yet. This covers: + // - Not yet initialized (normal case) + // - Initialized from bootstrap (no selector) — need initializers to + // get a full payload via poll before starting synchronizers + // When we have a selector, only synchronizers change (spec 5.3.8). + const includeInitializers = !selector; + + createAndStartDataSource(newMode, includeInitializers); + } + + // --- Public interface --- + + return { + get configuredForegroundMode(): FDv2ConnectionMode { + return initialForegroundMode; + }, + + async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + if (closed) { + logger.debug(`${logTag} Identify called after close.`); + return; + } + + // Tear down previous state. + dataSource?.close(); + dataSource = undefined; + debounceManager?.close(); + debounceManager = undefined; + selector = undefined; + initialized = false; + bootstrapped = false; + identifiedContext = context; + pendingIdentifyResolve = identifyResolve; + pendingIdentifyReject = identifyReject; + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const queryParams = buildQueryParams(identifyOptions); + if (config.withReasons) { + queryParams.push({ key: 'withReasons', value: 'true' }); + } + const streamingEndpoints = endpoints.streaming(); + const pollingEndpoints = endpoints.polling(); + + const requestor = makeFDv2Requestor( + plainContextString, + config.serviceEndpoints, + pollingEndpoints, + platform.requests, + platform.encoding!, + baseHeaders, + queryParams, + ); + + const environmentNamespace = await namespaceForEnvironment( + platform.crypto, + baseConfig.credential, + ); + + factoryContext = { + requestor, + requests: platform.requests, + encoding: platform.encoding!, + serviceEndpoints: config.serviceEndpoints, + pollingPaths: pollingEndpoints, + streamingPaths: streamingEndpoints, + baseHeaders, + queryParams, + plainContextString, + selectorGetter: () => selector, + streamInitialReconnectDelay: config.streamInitialReconnectDelay, + pollInterval: config.pollInterval, + logger, + storage: platform.storage, + crypto: platform.crypto, + environmentNamespace, + context, + }; + + // Resolve the initial mode. + const mode = resolveMode(); + logger.debug(`${logTag} Identify: initial mode resolved to '${mode}'.`); + + bootstrapped = !!(identifyOptions as any)?.bootstrap; + + if (bootstrapped) { + // Bootstrap data was already applied to the flag store by the + // caller (BrowserClient.start → presetFlags) before identify + // was called. Resolve immediately — flag evaluations will use + // the bootstrap data synchronously. + initialized = true; + statusManager.requestStateUpdate('VALID'); + // selector remains undefined — bootstrap data has no selector. + pendingIdentifyResolve?.(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + + // Only create a data source if the mode has synchronizers. + // For one-shot (no synchronizers), there's nothing more to do. + const modeDef = getModeDefinition(mode); + if (modeDef.synchronizers.length > 0) { + // Start synchronizers without initializers — we already have + // data from bootstrap. Initializers will run on mode switches + // if selector is still undefined (see onReconcile). + createAndStartDataSource(mode, false); + } + } else { + // Normal identify — create and start the data source with full pipeline. + createAndStartDataSource(mode, true); + } + + // Set up debouncing for subsequent state changes. + debounceManager = createStateDebounceManager({ + initialState: { + networkState, + lifecycleState, + requestedMode: foregroundMode, + }, + onReconcile, + }); + }, + + close(): void { + closed = true; + dataSource?.close(); + dataSource = undefined; + debounceManager?.close(); + debounceManager = undefined; + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + }, + + setNetworkState(state: NetworkState): void { + networkState = state; + debounceManager?.setNetworkState(state); + }, + + setLifecycleState(state: LifecycleState): void { + // Flush immediately when going to background — the app may be + // about to close. This is not debounced (CONNMODE spec 3.3.1). + if (state === 'background' && lifecycleState !== 'background') { + flushCallback?.(); + } + lifecycleState = state; + debounceManager?.setLifecycleState(state); + }, + + setRequestedMode(mode: FDv2ConnectionMode): void { + foregroundMode = mode; + debounceManager?.setRequestedMode(mode); + }, + + setForegroundMode(mode: FDv2ConnectionMode): void { + foregroundMode = mode; + debounceManager?.setRequestedMode(mode); + }, + + getCurrentMode(): FDv2ConnectionMode { + return currentResolvedMode; + }, + + setFlushCallback(callback: () => void): void { + flushCallback = callback; + }, + + setForcedStreaming(streaming?: boolean): void { + forcedStreaming = streaming; + this.setForegroundMode(resolveStreamingMode()); + }, + + setAutomaticStreamingState(streaming: boolean): void { + automaticStreamingState = streaming; + this.setForegroundMode(resolveStreamingMode()); + }, + }; +} diff --git a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts index c1595dcb22..639b875176 100644 --- a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts @@ -2,7 +2,7 @@ import { TypeValidators } from '@launchdarkly/js-sdk-common'; import type { PlatformDataSystemDefaults } from '../api/datasource'; import { anyOf, validatorOf } from '../configuration/validateOptions'; -import { connectionModeValidator } from './ConnectionModeConfig'; +import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig'; const modeSwitchingValidators = { lifecycle: TypeValidators.Boolean, @@ -13,6 +13,7 @@ const dataSystemValidators = { initialConnectionMode: connectionModeValidator, backgroundConnectionMode: connectionModeValidator, automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)), + connectionModes: connectionModesValidator, }; /** diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts new file mode 100644 index 0000000000..5ca2a3a679 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -0,0 +1,233 @@ +import { + Context, + Crypto, + Encoding, + LDHeaders, + LDLogger, + Requests, + ServiceEndpoints, + Storage, +} from '@launchdarkly/js-sdk-common'; + +import { EndpointConfig, InitializerEntry, SynchronizerEntry } from '../api/datasource'; +import { DataSourcePaths } from './DataSourceConfig'; +import { createCacheInitializerFactory } from './fdv2/CacheInitializer'; +import { FDv2Requestor, makeFDv2Requestor } from './fdv2/FDv2Requestor'; +import { poll as fdv2Poll } from './fdv2/PollingBase'; +import { createPollingInitializer } from './fdv2/PollingInitializer'; +import { createPollingSynchronizer } from './fdv2/PollingSynchronizer'; +import { createSynchronizerSlot, InitializerFactory, SynchronizerSlot } from './fdv2/SourceManager'; +import { createStreamingBase, PingHandler } from './fdv2/StreamingFDv2Base'; +import { createStreamingInitializer } from './fdv2/StreamingInitializerFDv2'; +import { createStreamingSynchronizer } from './fdv2/StreamingSynchronizerFDv2'; + +/** + * Context needed to create concrete initializer/synchronizer factories + * for a given identify call. Built once per identify and reused across + * mode switches. + */ +export interface SourceFactoryContext { + /** The FDv2 requestor for polling requests. */ + requestor: FDv2Requestor; + /** Platform request abstraction. */ + requests: Requests; + /** Platform encoding abstraction. */ + encoding: Encoding; + /** Service endpoint configuration. */ + serviceEndpoints: ServiceEndpoints; + /** The polling endpoint paths. */ + pollingPaths: DataSourcePaths; + /** The streaming endpoint paths. */ + streamingPaths: DataSourcePaths; + /** Default HTTP headers. */ + baseHeaders: LDHeaders; + /** Query parameters for requests (e.g., auth, secure mode hash). */ + queryParams: { key: string; value: string }[]; + /** JSON-serialized evaluation context. */ + plainContextString: string; + /** Getter for the current selector (basis) string. */ + selectorGetter: () => string | undefined; + /** Initial reconnect delay for streaming, in seconds. */ + streamInitialReconnectDelay: number; + /** Poll interval in seconds. */ + pollInterval: number; + /** Logger. */ + logger: LDLogger; + + // Cache-related fields (needed for cache initializer). + /** Platform storage for reading cached data. */ + storage: Storage | undefined; + /** Platform crypto for computing storage keys. */ + crypto: Crypto; + /** Environment namespace (hashed SDK key). */ + environmentNamespace: string; + /** The context being identified. */ + context: Context; +} + +/** + * Converts declarative {@link InitializerEntry} and {@link SynchronizerEntry} + * descriptors from the mode table into concrete {@link InitializerFactory} + * and {@link SynchronizerSlot} instances that the {@link FDv2DataSource} + * orchestrator can use. + */ +export interface SourceFactoryProvider { + /** + * Create an initializer factory from an initializer entry descriptor. + * Returns `undefined` if the entry type is not supported. + */ + createInitializerFactory( + entry: InitializerEntry, + ctx: SourceFactoryContext, + ): InitializerFactory | undefined; + + /** + * Create a synchronizer slot from a synchronizer entry descriptor. + * Returns `undefined` if the entry type is not supported. + */ + createSynchronizerSlot( + entry: SynchronizerEntry, + ctx: SourceFactoryContext, + ): SynchronizerSlot | undefined; +} + +function createPingHandler(ctx: SourceFactoryContext): PingHandler { + return { + handlePing: () => fdv2Poll(ctx.requestor, ctx.selectorGetter(), false, ctx.logger), + }; +} + +/** + * Create a {@link ServiceEndpoints} with per-entry endpoint overrides applied. + * Returns the original endpoints if no overrides are specified. + */ +function resolveEndpoints(ctx: SourceFactoryContext, endpoints?: EndpointConfig): ServiceEndpoints { + if (!endpoints?.pollingBaseUri && !endpoints?.streamingBaseUri) { + return ctx.serviceEndpoints; + } + return new ServiceEndpoints( + endpoints.streamingBaseUri ?? ctx.serviceEndpoints.streaming, + endpoints.pollingBaseUri ?? ctx.serviceEndpoints.polling, + ctx.serviceEndpoints.events, + ctx.serviceEndpoints.analyticsEventPath, + ctx.serviceEndpoints.diagnosticEventPath, + ctx.serviceEndpoints.includeAuthorizationHeader, + ctx.serviceEndpoints.payloadFilterKey, + ); +} + +/** + * Get the FDv2 requestor for a polling entry. If the entry has custom + * endpoints, creates a new requestor targeting those endpoints. Otherwise + * returns the shared requestor from the context. + */ +function resolvePollingRequestor( + ctx: SourceFactoryContext, + endpoints?: EndpointConfig, +): FDv2Requestor { + if (!endpoints?.pollingBaseUri) { + return ctx.requestor; + } + const overriddenEndpoints = resolveEndpoints(ctx, endpoints); + return makeFDv2Requestor( + ctx.plainContextString, + overriddenEndpoints, + ctx.pollingPaths, + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + ); +} + +/** + * Creates a {@link SourceFactoryProvider} that handles `cache`, `polling`, + * and `streaming` data source entries. + */ +export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { + return { + createInitializerFactory( + entry: InitializerEntry, + ctx: SourceFactoryContext, + ): InitializerFactory | undefined { + switch (entry.type) { + case 'polling': { + const requestor = resolvePollingRequestor(ctx, entry.endpoints); + return (sg: () => string | undefined) => + createPollingInitializer(requestor, ctx.logger, sg); + } + + case 'streaming': { + const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); + return (sg: () => string | undefined) => { + const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); + const base = createStreamingBase({ + requests: ctx.requests, + serviceEndpoints: entryEndpoints, + streamUriPath, + parameters: ctx.queryParams, + selectorGetter: sg, + headers: ctx.baseHeaders, + initialRetryDelayMillis: + (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, + logger: ctx.logger, + pingHandler: createPingHandler(ctx), + }); + return createStreamingInitializer(base); + }; + } + + case 'cache': + return createCacheInitializerFactory({ + storage: ctx.storage, + crypto: ctx.crypto, + environmentNamespace: ctx.environmentNamespace, + context: ctx.context, + logger: ctx.logger, + }); + + default: + return undefined; + } + }, + + createSynchronizerSlot( + entry: SynchronizerEntry, + ctx: SourceFactoryContext, + ): SynchronizerSlot | undefined { + switch (entry.type) { + case 'polling': { + const intervalMs = (entry.pollInterval ?? ctx.pollInterval) * 1000; + const requestor = resolvePollingRequestor(ctx, entry.endpoints); + const factory = (sg: () => string | undefined) => + createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs); + return createSynchronizerSlot(factory); + } + + case 'streaming': { + const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); + const factory = (sg: () => string | undefined) => { + const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); + const base = createStreamingBase({ + requests: ctx.requests, + serviceEndpoints: entryEndpoints, + streamUriPath, + parameters: ctx.queryParams, + selectorGetter: sg, + headers: ctx.baseHeaders, + initialRetryDelayMillis: + (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, + logger: ctx.logger, + pingHandler: createPingHandler(ctx), + }); + return createStreamingSynchronizer(base); + }; + return createSynchronizerSlot(factory); + } + + default: + return undefined; + } + }, + }; +} diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index d8c122c908..6585163fc3 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -208,6 +208,10 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour recoveryTimeoutMs, ); + if (conditions.promise) { + logger?.warn('Fallback condition active for current synchronizer.'); + } + // try/finally ensures conditions are closed on all code paths. let synchronizerRunning = true; try { diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 273b623a08..0ae031594c 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -1,4 +1,4 @@ -import { Context, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import { namespaceForEnvironment } from '../storage/namespaceUtils'; import FlagPersistence from './FlagPersistence'; @@ -55,6 +55,21 @@ export interface FlagManager { */ setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void; + /** + * Applies a changeset to the flag store. + * - `'full'`: replaces all flags (like {@link init}). + * - `'partial'`: upserts individual flags (like calling {@link upsert} for each entry). + * - `'none'`: persists cache (updating freshness) without changing any flags. + * + * Designed for the FDv2 data path where init/upsert semantics, selector + * tracking, and freshness updates are all handled in one call. + */ + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise; + /** * Register a flag change callback. */ @@ -217,6 +232,14 @@ export default class DefaultFlagManager implements FlagManager { return (await this._flagPersistencePromise).loadCached(context); } + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise { + return (await this._flagPersistencePromise).applyChanges(context, updates, type); + } + on(callback: FlagsChangeCallback): void { this._flagUpdater.on(callback); } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts index df41882309..54c69eba93 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -1,4 +1,4 @@ -import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import { FRESHNESS_SUFFIX, FreshnessRecord, hashContext } from '../storage/freshness'; import { loadCachedFlags } from '../storage/loadCachedFlags'; @@ -59,6 +59,30 @@ export default class FlagPersistence { return false; } + /** + * Applies a changeset to the flag store. + * - `'full'`: replaces all flags via {@link FlagUpdater.init}. + * - `'partial'`: upserts individual flags via {@link FlagUpdater.upsert}. + * - `'none'`: no flag changes, only persists cache to update freshness. + * + * Always persists to cache afterwards, which updates the freshness timestamp + * even when no flags change (e.g., transfer-none). + */ + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise { + if (type === 'full') { + this._flagUpdater.init(context, updates); + } else if (type === 'partial') { + Object.entries(updates).forEach(([key, descriptor]) => { + this._flagUpdater.upsert(context, key, descriptor); + }); + } + await this._storeCache(context); + } + /** * Loads the flags from persistence for the provided context and gives those to the * {@link FlagUpdater} this {@link FlagPersistence} was constructed with. diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 192f812dba..496ed1acb7 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -81,6 +81,8 @@ export type { CacheDataSourceEntry, PollingDataSourceEntry, StreamingDataSourceEntry, + InitializerEntry, + SynchronizerEntry, DataSourceEntry, ModeDefinition, LDClientDataSystemOptions, @@ -94,6 +96,10 @@ export type { ModeResolutionTable, } from './api/datasource'; +// FDv2 data source status manager. +export { createDataSourceStatusManager } from './datasource/DataSourceStatusManager'; +export type { DataSourceStatusManager } from './datasource/DataSourceStatusManager'; + // FDv2 data system validators and platform defaults. export { dataSystemValidators, @@ -104,9 +110,32 @@ export { // FDv2 connection mode type system — internal implementation. export type { ModeTable } from './datasource/ConnectionModeConfig'; +export { MODE_TABLE } from './datasource/ConnectionModeConfig'; export { resolveConnectionMode, MOBILE_TRANSITION_TABLE, BROWSER_TRANSITION_TABLE, DESKTOP_TRANSITION_TABLE, } from './datasource/ModeResolver'; + +// FDv2 shared data manager — mode switching, debouncing, and data source lifecycle. +export type { + FDv2DataManagerBaseConfig, + FDv2DataManagerControl, +} from './datasource/FDv2DataManagerBase'; +export { createFDv2DataManagerBase } from './datasource/FDv2DataManagerBase'; +export type { + SourceFactoryContext, + SourceFactoryProvider, +} from './datasource/SourceFactoryProvider'; +export { createDefaultSourceFactoryProvider } from './datasource/SourceFactoryProvider'; + +// State debounce manager. +export type { + StateDebounceManager, + StateDebounceManagerConfig, + NetworkState, + PendingState, + ReconciliationCallback, +} from './datasource/StateDebounceManager'; +export { createStateDebounceManager } from './datasource/StateDebounceManager'; diff --git a/packages/shared/sdk-client/src/types/index.ts b/packages/shared/sdk-client/src/types/index.ts index 9ffe3dbe31..19be292b8b 100644 --- a/packages/shared/sdk-client/src/types/index.ts +++ b/packages/shared/sdk-client/src/types/index.ts @@ -21,10 +21,10 @@ export type DeleteFlag = Pick; /** * Represents a pre-evaluated flag result for a specific context, as delivered - * by the FDv2 protocol via `put-object` events with `kind: 'flag_eval'`. + * by the FDv2 protocol via `put-object` events with `kind: 'flag-eval'`. * * This is the shape of the `object` field in a `put-object` event with - * `kind: 'flag_eval'`. It contains all the same fields as {@link Flag} except + * `kind: 'flag-eval'`. It contains all the same fields as {@link Flag} except * `version`, which is provided separately in the `put-object` envelope. * * There is no aggregate payload-level version field; per-flag versioning is diff --git a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts index 971aa67bf0..4fbcd32652 100644 --- a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts @@ -20,6 +20,34 @@ export interface SDKConfigParams { hooks?: SDKConfigHooksParams; wrapper?: SDKConfigWrapper; proxy?: SDKConfigProxyParams; + dataSystem?: SDKConfigDataSystem; +} + +export interface SDKConfigDataSystem { + useDefaultDataSystem?: boolean; + initializers?: SDKConfigDataInitializer[]; + synchronizers?: SDKConfigDataSynchronizer[]; + payloadFilter?: string; + connectionModeConfig?: SDKConfigConnectionModeConfig; +} + +export interface SDKConfigConnectionModeConfig { + initialConnectionMode?: string; + customConnectionModes?: Record; +} + +export interface SDKConfigModeDefinition { + initializers?: SDKConfigDataInitializer[]; + synchronizers?: SDKConfigDataSynchronizer[]; +} + +export interface SDKConfigDataInitializer { + polling?: SDKConfigPollingParams; +} + +export interface SDKConfigDataSynchronizer { + streaming?: SDKConfigStreamingParams; + polling?: SDKConfigPollingParams; } export interface ServerSDKConfigParams extends SDKConfigParams {