diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index 6e24d4ec0c..3e20588456 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/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/index.css b/packages/sdk/browser/example/index.css index 90dc2b50f1..894c557bfc 100644 --- a/packages/sdk/browser/example/index.css +++ b/packages/sdk/browser/example/index.css @@ -1,5 +1,6 @@ body { margin: 0; + padding: 20px; background: #373841; color: white; font-family: @@ -7,5 +8,62 @@ body { 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - text-align: center; +} + +#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/src/app.ts b/packages/sdk/browser/example/src/app.ts index 2e9e21c0d9..4e41fea77b 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -1,4 +1,4 @@ -import { createClient } from '@launchdarkly/js-client-sdk'; +import { basicLogger, createClient, type LDClient } from '@launchdarkly/js-client-sdk'; // Set clientSideID to your LaunchDarkly client-side ID const clientSideID = 'LD_CLIENT_SIDE_ID'; @@ -6,71 +6,245 @@ const clientSideID = 'LD_CLIENT_SIDE_ID'; // Set flagKey to the feature flag key you want to evaluate const flagKey = 'LD_FLAG_KEY'; -// Set up the evaluation context. This context should appear on your -// LaunchDarkly contexts dashboard soon after you run the demo. -const context = { - kind: 'user', - key: 'example-user-key', - name: 'Sandy', -}; +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' }, +]; -const div = document.createElement('div'); -const statusBox = document.createElement('div'); +let currentContextIndex = 0; +let eventHandlersRegistered = false; +let changeHandler: (() => void) | undefined; +let errorHandler: (() => void) | undefined; -document.body.appendChild(statusBox); -document.body.appendChild(div); +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; +} -div.appendChild(document.createTextNode('No flag evaluations yet')); -statusBox.appendChild(document.createTextNode('Initializing...')); +function text(s: string): Text { + return document.createTextNode(s); +} -const main = async () => { - const ldclient = createClient(clientSideID, context); - const render = () => { - const flagValue = ldclient.variation(flagKey, false); - const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`; - document.body.style.background = flagValue ? '#00844B' : '#373841'; - div.replaceChild(document.createTextNode(label), div.firstChild as Node); +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(); - ldclient.on('error', () => { - statusBox.replaceChild( - document.createTextNode('Error caught in client SDK'), - statusBox.firstChild as Node, - ); + const client = createClient(clientSideID, contexts[currentContextIndex], { + // @ts-ignore dataSystem is @internal — experimental FDv2 opt-in + dataSystem: {}, + logger: basicLogger({ level: 'debug' }), }); - // Listen for flag changes - ldclient.on('change', () => { - render(); + // 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); }); - ldclient.start(); - - const { status } = await ldclient.waitForInitialization(); - - if (status === 'complete') { - statusBox.replaceChild( - document.createTextNode(`Initialized with context: ${JSON.stringify(ldclient.getContext())}`), - statusBox.firstChild as Node, - ); - } else if (status === 'failed') { - statusBox.replaceChild( - document.createTextNode('Error identifying client'), - statusBox.firstChild as Node, - ); - } else if (status === 'timeout') { - statusBox.replaceChild( - document.createTextNode('Timeout identifying client'), - statusBox.firstChild as Node, - ); - } else { - statusBox.replaceChild( - document.createTextNode('Unknown error identifying client'), - statusBox.firstChild as Node, - ); - } + // 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)'); + }); - render(); + // 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/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/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..cf343eae7e --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -0,0 +1,588 @@ +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; + } + + if (payload.type === 'none') { + return; + } + + const context = identifiedContext; + if (!context) { + logger.warn(`${logTag} dataCallback called without an identified context.`); + return; + } + + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); + + if (payload.type === 'full') { + flagManager.init(context, descriptors); + } else { + Object.entries(descriptors).forEach(([key, descriptor]) => { + flagManager.upsert(context, key, descriptor); + }); + } + } + + /** + * 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; + // 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/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/index.ts b/packages/tooling/contract-test-utils/src/index.ts index 82c54b0dcb..45c5daae95 100644 --- a/packages/tooling/contract-test-utils/src/index.ts +++ b/packages/tooling/contract-test-utils/src/index.ts @@ -15,6 +15,11 @@ export { type SDKConfigHooksParams, type SDKConfigProxyParams, type SDKConfigWrapper, + type SDKConfigDataSystem, + type SDKConfigConnectionModeConfig, + type SDKConfigModeDefinition, + type SDKConfigDataInitializer, + type SDKConfigDataSynchronizer, } from './types/ConfigParams'; export { makeLogger } from './logging/makeLogger'; export { ClientPool } from './server-side/ClientPool'; diff --git a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts index 6727ff5e0b..0c7d57dbca 100644 --- a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts @@ -21,6 +21,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 SDKConfigTLSParams {