diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index f74c2e0796..59269c2cbb 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: 34000 + size_limit: 35000 # Contract Tests - name: Install contract test dependencies diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index e624a5a170..3339388e9f 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -86,6 +86,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { sendEvents: false, sendLDHeaders: true, useReport: false, + usePost: false, withReasons: true, privateAttributes: [], tags: new ApplicationTags({}), diff --git a/packages/sdk/electron/__tests__/ElectronDataManager.test.ts b/packages/sdk/electron/__tests__/ElectronDataManager.test.ts index ec9b2c47f4..97e2a63b6c 100644 --- a/packages/sdk/electron/__tests__/ElectronDataManager.test.ts +++ b/packages/sdk/electron/__tests__/ElectronDataManager.test.ts @@ -82,6 +82,7 @@ describe('given an ElectronDataManager with mocked dependencies', () => { sendEvents: false, sendLDHeaders: true, useReport: false, + usePost: false, withReasons: true, privateAttributes: [], tags: new ApplicationTags({}), diff --git a/packages/sdk/node-client/__tests__/NodeDataManager.test.ts b/packages/sdk/node-client/__tests__/NodeDataManager.test.ts index 050a65f104..f4a6ed9561 100644 --- a/packages/sdk/node-client/__tests__/NodeDataManager.test.ts +++ b/packages/sdk/node-client/__tests__/NodeDataManager.test.ts @@ -98,6 +98,7 @@ describe('given a NodeDataManager with mocked dependencies', () => { sendEvents: false, sendLDHeaders: true, useReport: false, + usePost: false, withReasons: true, privateAttributes: [], tags: new ApplicationTags({}), diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts index fdbf72ad35..d6cefb49ec 100644 --- a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -78,6 +78,7 @@ describe('given a MobileDataManager with mocked dependencies', () => { sendEvents: false, sendLDHeaders: true, useReport: false, + usePost: false, withReasons: true, privateAttributes: [], tags: new ApplicationTags({}), diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 482aea1e98..2319731d35 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -468,3 +468,134 @@ describe('sdk-client object', () => { expect(mockEventSource.closed).toBe(true); }); }); + +describe('usePost validation', () => { + it('warns and continues when dataSystem is configured with usePost but the EventSource lacks customMethod', async () => { + const platform = createBasicPlatform(); + platform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: false, + })); + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + // Construction succeeds. usePost still applies to polling, and only streaming + // degrades to GET. + const client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { usePost: true, dataSystem: {}, sendEvents: false, logger }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + // The warning names the GET fallback for streaming. + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/usePost.*GET/s)); + await client.close(); + }); + + it('does not warn when dataSystem is configured with usePost and the EventSource supports customMethod', async () => { + const platform = createBasicPlatform(); + platform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: true, + })); + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { usePost: true, dataSystem: {}, sendEvents: false, logger }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('usePost')); + await client.close(); + }); + + it('does not warn when usePost is set without dataSystem, even if the EventSource lacks customMethod (usePost only applies to FDv2)', async () => { + const platform = createBasicPlatform(); + platform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: false, + })); + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { usePost: true, sendEvents: false, logger }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('usePost')); + await client.close(); + }); +}); + +describe('useReport under dataSystem', () => { + it('ignores useReport and logs a warning when dataSystem is also configured', async () => { + const platform = createBasicPlatform(); + platform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: false, + })); + const warnLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { useReport: true, dataSystem: {}, sendEvents: false, logger: warnLogger }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + + expect(warnLogger.warn).toHaveBeenCalledWith(expect.stringContaining('useReport')); + await client.close(); + }); + + it('does not warn when useReport is set without dataSystem', async () => { + const platform = createBasicPlatform(); + const warnLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { useReport: true, sendEvents: false, logger: warnLogger }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + + expect(warnLogger.warn).not.toHaveBeenCalledWith(expect.stringContaining('useReport')); + await client.close(); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index 6e7d4240b5..03c9d75830 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -212,6 +212,29 @@ describe('dataSystem validation', () => { expect(config.dataSystem!.automaticModeSwitching).toBe(false); }); + it('forces useReport to false and warns when dataSystem is also set', () => { + const logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const config = new ConfigurationImpl( + // @ts-ignore dataSystem is @internal + { useReport: true, dataSystem: {}, logger }, + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + dataSystemDefaults: { + foregroundConnectionMode: 'one-shot', + automaticModeSwitching: false, + }, + }, + ); + expect(config.useReport).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('useReport')); + }); + it('validates dataSystem with user overrides applied over platform defaults', () => { const config = new ConfigurationImpl( // @ts-ignore dataSystem is @internal diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index cb3f4e3dbd..2c81f832b7 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -690,6 +690,30 @@ it('adds withReasons query param when config.withReasons is true', async () => { manager.close(); }); +it('passes usePost to the requestor and the source factory context', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + config: makeConfig({ usePost: true }), + sourceFactoryProvider, + }), + ); + + await identifyManager(manager); + + expect(mockMakeFDv2Requestor).toHaveBeenCalledTimes(1); + expect(mockMakeFDv2Requestor.mock.calls[0][7]).toBe(true); + + const factoryContexts = [ + ...(sourceFactoryProvider.createInitializerFactory as jest.Mock).mock.calls, + ...(sourceFactoryProvider.createSynchronizerSlot as jest.Mock).mock.calls, + ].map((call) => call[1]); + expect(factoryContexts.length).toBeGreaterThan(0); + factoryContexts.forEach((ctx) => expect(ctx.usePost).toBe(true)); + + manager.close(); +}); + it('closes data source and debounce manager on close', async () => { const manager = createFDv2DataManagerBase(makeBaseConfig()); await identifyManager(manager); @@ -1019,6 +1043,48 @@ it('appends a blocked FDv1 fallback synchronizer when fdv1Endpoints are configur manager.close(); }); +it('uses REPORT for the FDv1 fallback requestor when usePost is set', 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({ + config: makeConfig({ usePost: true }), + sourceFactoryProvider, + fdv1Endpoints, + foregroundMode: 'streaming', + }), + ); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + const fdv1Slot = dsConfig.synchronizerSlots[dsConfig.synchronizerSlots.length - 1]; + // Invoke the factory to trigger requestor creation. + fdv1Slot.factory.create(() => undefined); + + expect(makeRequestor).toHaveBeenCalledTimes(1); + // The useReport argument of makeRequestor controls the REPORT method. + expect((makeRequestor as jest.Mock).mock.calls[0][8]).toBe(true); + + manager.close(); +}); + it('uses per-mode fdv1Fallback pollInterval from MODE_TABLE for background mode', async () => { const sourceFactoryProvider = makeSourceFactoryProvider(); const fdv1Endpoints = { diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts index 5069fed7f6..d5b1951bab 100644 --- a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -66,7 +66,13 @@ function makePaths(): DataSourcePaths { function makeSourceFactoryContext(overrides?: Partial): SourceFactoryContext { return { requestor: { poll: jest.fn() } as unknown as FDv2Requestor, - requests: {} as Requests, + requests: { + getEventSourceCapabilities: jest.fn(() => ({ + customMethod: true, + readTimeout: true, + headers: true, + })), + } as unknown as Requests, encoding: {} as Encoding, serviceEndpoints: new ServiceEndpoints( 'https://stream.example.com', @@ -76,6 +82,7 @@ function makeSourceFactoryContext(overrides?: Partial): So baseHeaders: { authorization: 'sdk-key' }, queryParams: [], plainContextString: '{"kind":"user","key":"test-user"}', + usePost: false, logger: { debug: jest.fn(), info: jest.fn(), @@ -154,6 +161,64 @@ it('creates a StreamingInitializer for a streaming initializer entry', () => { ); }); +it('uses the post path and passes method/body overrides when usePost is true', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ usePost: true }); + const entry: InitializerEntry = { type: 'streaming' }; + + const factory = provider.createInitializerFactory(entry, ctx); + factory!.create(() => undefined); + + expect(ctx.streaming.paths.pathPost).toHaveBeenCalledWith(ctx.encoding, ctx.plainContextString); + expect(ctx.streaming.paths.pathGet).not.toHaveBeenCalled(); + expect(mockCreateStreamingBase).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + body: ctx.plainContextString, + }), + ); +}); + +it('uses the get path for the stream when usePost is true but the EventSource lacks customMethod', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ + usePost: true, + requests: { + getEventSourceCapabilities: jest.fn(() => ({ + customMethod: false, + readTimeout: false, + headers: false, + })), + } as unknown as Requests, + }); + const entry: InitializerEntry = { type: 'streaming' }; + + const factory = provider.createInitializerFactory(entry, ctx); + factory!.create(() => undefined); + + // The stream degrades to GET; the polling requestor keeps POST via the fetch transport. + expect(ctx.streaming.paths.pathGet).toHaveBeenCalledWith(ctx.encoding, ctx.plainContextString); + expect(ctx.streaming.paths.pathPost).not.toHaveBeenCalled(); + const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0]; + expect(streamingBaseArgs.method).toBeUndefined(); + expect(streamingBaseArgs.body).toBeUndefined(); +}); + +it('uses the get path and no method/body override when usePost is false', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ usePost: false }); + const entry: InitializerEntry = { type: 'streaming' }; + + const factory = provider.createInitializerFactory(entry, ctx); + factory!.create(() => undefined); + + expect(ctx.streaming.paths.pathGet).toHaveBeenCalledWith(ctx.encoding, ctx.plainContextString); + expect(ctx.streaming.paths.pathPost).not.toHaveBeenCalled(); + const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0]; + expect(streamingBaseArgs.method).toBeUndefined(); + expect(streamingBaseArgs.body).toBeUndefined(); +}); + it('creates a CacheInitializer for a cache initializer entry', () => { const provider = createDefaultSourceFactoryProvider(); const ctx = makeSourceFactoryContext(); @@ -269,6 +334,7 @@ it('creates a new requestor when polling entry has endpoint overrides', () => { ctx.encoding, ctx.baseHeaders, ctx.queryParams, + false, ); // Should use the new requestor, not the context one @@ -280,6 +346,29 @@ it('creates a new requestor when polling entry has endpoint overrides', () => { ); }); +it('passes usePost to the requestor created for a polling entry with endpoint overrides', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ usePost: true }); + const entry: InitializerEntry = { + type: 'polling', + endpoints: { pollingBaseUri: 'https://custom-poll.example.com' }, + }; + + const factory = provider.createInitializerFactory(entry, ctx); + factory!.create(() => undefined); + + expect(mockMakeFDv2Requestor).toHaveBeenCalledWith( + ctx.plainContextString, + expect.anything(), + ctx.polling.paths, + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + true, + ); +}); + it('uses per-entry pollInterval override for polling synchronizer', () => { const provider = createDefaultSourceFactoryProvider(); const ctx = makeSourceFactoryContext({ polling: { paths: makePaths(), intervalSeconds: 30 } }); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts index f29eac2695..723b9364d3 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts @@ -39,6 +39,36 @@ it('creates EventSource with correct URI and options', () => { ); }); +it('does not send a content-type header or method for the default GET request', () => { + const mockEventSource = createMockEventSource(); + const mockRequests = createMockRequests(mockEventSource); + const base = createBase(mockRequests, logger); + base.start(); + + const initDict = mockRequests.createEventSource.mock.calls[0][1]; + expect(initDict.method).toBeUndefined(); + expect(initDict.headers).not.toHaveProperty('content-type'); +}); + +it('sends a POST request with the context body and a content-type header when configured', () => { + const mockEventSource = createMockEventSource(); + const mockRequests = createMockRequests(mockEventSource); + const base = createBase(mockRequests, logger, { + method: 'POST', + body: '{"kind":"user","key":"test-user"}', + }); + base.start(); + + expect(mockRequests.createEventSource).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + body: '{"kind":"user","key":"test-user"}', + headers: expect.objectContaining({ 'content-type': 'application/json' }), + }), + ); +}); + it('produces a changeSet result for a full transfer', async () => { const mockEventSource = createMockEventSource(); const mockRequests = createMockRequests(mockEventSource); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts index 36b2eecd8a..1b6c2e44de 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/streamingTestHelpers.ts @@ -191,6 +191,8 @@ export function createBase( streamUriPath?: string; parameters?: { key: string; value: string }[]; selectorGetter?: () => string | undefined; + method?: string; + body?: string; } = {}, ) { return createStreamingBase({ @@ -203,5 +205,7 @@ export function createBase( initialRetryDelayMillis: 1000, logger, pingHandler: options.pingHandler, + method: options.method, + body: options.body, }); } diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index f14431ff2e..a71b157867 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -47,6 +47,7 @@ import { import { ensureKey } from './context/ensureKey'; import { DataManager, DataManagerFactory } from './DataManager'; import type { InternalDataSystemOptions } from './datasource/LDClientDataSystemOptions'; +import { supportsCustomMethod } from './datasource/SourceFactoryProvider'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import { createErrorEvaluationDetail, @@ -127,6 +128,20 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { } this._config = new ConfigurationImpl(options, internalOptions); + + // usePost applies to streaming only when the EventSource supports a custom HTTP method. + if ( + !!this._config.dataSystem && + this._config.usePost && + !supportsCustomMethod(platform.requests) + ) { + this._config.logger?.warn( + 'usePost is set, but the configured EventSource does not support custom HTTP methods. ' + + 'Streaming requests will use GET with the context in the URL path; polling requests ' + + 'will use POST.', + ); + } + this.logger = this._config.logger; this._requiresStart = internalOptions?.requiresStart ?? false; this.initialContext = internalOptions?.initialContext; diff --git a/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts b/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts new file mode 100644 index 0000000000..3ab755469d --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts @@ -0,0 +1,31 @@ +import type { + EventSource, + EventSourceCapabilities, + EventSourceInitDict, +} from '@launchdarkly/js-sdk-common'; + +/** + * Factory for the EventSource implementation a client-side SDK uses for its streaming connection. + * + * This should only be used when customizing the streaming transport. Typical usage of the SDK does + * not require implementing this. + */ +export interface LDEventSourceFactory { + /** + * Create an EventSource connected to the given URL with the given initialization options. + */ + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource; + + /** + * Capabilities of the event sources this factory produces. + * + * The SDK checks these before using a feature that not every transport supports -- for instance, + * it only issues a POST or REPORT streaming request when `customMethod` is true. When this is + * omitted, every capability is treated as unsupported. + * + * @remarks + * `customMethod` is currently the only capability that changes SDK behavior; declaring `headers: false` + * or `readTimeout: false` does not stop the SDK from supplying those init options. + */ + capabilities?: EventSourceCapabilities; +} diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index fc727c16c6..596e314c10 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -127,8 +127,8 @@ export interface LDOptions { * @remarks * Set a custom {@link LDLogger} if you want full control of logging behavior. * - * @defaultValue The default logging implementation will varybased on platform. For the browser - * the default logger will log "info" level and higher priorty messages and it will log messages to + * @defaultValue The default logging implementation will vary based on platform. For the browser + * the default logger will log "info" level and higher priority messages and it will log messages to * console.info, console.warn, and console.error. Other platforms may use a `BasicLogger` instance * also defaulted to the "info" level. */ @@ -206,9 +206,36 @@ export interface LDOptions { * * This setting applies both to requests to the streaming service, as well as flag requests when the SDK is in polling * mode. + * + * This option only applies to the legacy (non-`dataSystem`) data source. If the `dataSystem` + * option is also configured, this option has no effect and a warning is logged -- use + * `usePost` instead. */ useReport?: boolean; + /** + * Directs the SDK to use the POST method, with the evaluation context in the request body, + * for polling and streaming requests instead of GET with the context encoded in the URL path. + * + * @defaultValue `false` + * + * @remark + * This option only takes effect when the `dataSystem` option is also configured. + * + * For streaming specifically, this also requires an EventSource implementation that supports a + * custom HTTP method (the `customMethod` capability). Without that capability, streaming + * requests use GET, polling requests still use POST, and the SDK logs a warning. + * + * If the data system falls back to the legacy polling endpoints, this option directs those + * requests to use the REPORT method, so the context stays in the request body instead of + * the URL. + * + * This is not stable, and not subject to any backwards compatibility guarantees or semantic + * versioning. It is in early access. If you want access to this feature please join the EAP. + * https://launchdarkly.com/docs/sdk/features/data-saving-mode + */ + usePost?: boolean; + /** * Whether LaunchDarkly should provide additional information about how flag values were * calculated. @@ -310,7 +337,7 @@ export interface LDOptions { * * @remarks * Unlike setting {@link LDOptions.maxCachedContexts} to `0`, - * this does **not** remove previously cached data — existing entries are left + * this does **not** remove previously cached data. Existing entries are left * intact. This takes precedence over `maxCachedContexts`. * * @defaultValue false diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 3a9eedb8c3..a151deafc3 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -2,6 +2,7 @@ import ConnectionMode from './ConnectionMode'; export * from './LDOptions'; export * from './LDStorage'; +export * from './LDEventSourceFactory'; export * from './LDClient'; export * from './LDEvaluationDetail'; export * from './integrations'; diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 4a401c87f8..e314b3e7f8 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -56,6 +56,7 @@ export interface Configuration { readonly sendEvents: boolean; readonly sendLDHeaders: boolean; readonly useReport: boolean; + readonly usePost: boolean; readonly withReasons: boolean; readonly privateAttributes: string[]; readonly tags: ApplicationTags; @@ -99,7 +100,7 @@ export default class ConfigurationImpl implements Configuration { public readonly logger: LDLogger = createSafeLogger(); // Naming conventions is not followed for these lines because the config validation - // accesses members based on the keys of the options. (sdk-763) + // accesses members based on the keys of the options. // eslint-disable-next-line @typescript-eslint/naming-convention private readonly baseUri = DEFAULT_POLLING; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -122,6 +123,7 @@ export default class ConfigurationImpl implements Configuration { public readonly sendLDHeaders: boolean = true; public readonly useReport: boolean = false; + public readonly usePost: boolean = false; public readonly withReasons: boolean = false; public readonly privateAttributes: string[] = []; @@ -198,6 +200,15 @@ export default class ConfigurationImpl implements Configuration { pristineOptions.payloadFilterKey, ); this.useReport = pristineOptions.useReport ?? false; + // useReport is an FDv1 option with no effect in FDv2, where usePost is the + // equivalent. + if (this.dataSystem && this.useReport) { + this.logger?.warn( + 'The "useReport" configuration option has no effect when the "dataSystem" option is ' + + 'set. Use "usePost" instead.', + ); + this.useReport = false; + } this.tags = new ApplicationTags({ application: this.applicationInfo, logger: this.logger }); this.userAgentHeaderName = internalOptions.userAgentHeaderName ?? 'user-agent'; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 64f1629c36..e8936b902a 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -38,6 +38,8 @@ export default function createValidators( useReport: TypeValidators.Boolean, + usePost: TypeValidators.Boolean, + privateAttributes: TypeValidators.StringArray, disableCache: TypeValidators.Boolean, diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 41c092f10e..dd5a1b4251 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -314,7 +314,9 @@ export function createFDv2DataManagerBase( ctx.baseHeaders, ctx.queryParams, config.withReasons, - config.useReport, + // Map usePost to REPORT to keep the context out of the URL. + // useReport itself is always disabled under the data system. + config.usePost, ); const fdv1SyncFactory = { @@ -525,6 +527,7 @@ export function createFDv2DataManagerBase( platform.encoding!, baseHeaders, queryParams, + config.usePost, ); const environmentNamespace = await namespaceForEnvironment( @@ -547,6 +550,7 @@ export function createFDv2DataManagerBase( baseHeaders, queryParams, plainContextString, + usePost: config.usePost, logger, polling: { paths: pollingEndpoints, diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index 61ca8e7567..5d1d219289 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -21,6 +21,14 @@ import { createStreamingBase, PingHandler } from './fdv2/StreamingFDv2Base'; import { createStreamingInitializer } from './fdv2/StreamingInitializerFDv2'; import { createStreamingSynchronizer } from './fdv2/StreamingSynchronizerFDv2'; +/** + * True when the platform's event sources can send a custom HTTP method. + * usePost needs this capability to stream with POST. + */ +export function supportsCustomMethod(requests: Requests): boolean { + return requests.getEventSourceCapabilities().customMethod; +} + /** * Context needed to create concrete initializer/synchronizer factories * for a given identify call. Built once per identify and reused across @@ -41,6 +49,10 @@ export interface SourceFactoryContext { queryParams: { key: string; value: string }[]; /** JSON-serialized evaluation context. */ plainContextString: string; + /** + * Whether requests should use POST instead of GET. + */ + usePost: boolean; /** Logger. */ logger: LDLogger; @@ -147,6 +159,7 @@ function resolvePollingRequestor( ctx.encoding, ctx.baseHeaders, ctx.queryParams, + ctx.usePost, ); } @@ -162,7 +175,11 @@ function buildStreamingBase( ) { const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); const requestor = resolvePollingRequestor(ctx, entry.endpoints); - const streamUriPath = ctx.streaming.paths.pathGet(ctx.encoding, ctx.plainContextString); + // usePost applies to the stream only when the EventSource can send a custom HTTP method. + const streamPost = ctx.usePost && supportsCustomMethod(ctx.requests); + const streamUriPath = streamPost + ? ctx.streaming.paths.pathPost(ctx.encoding, ctx.plainContextString) + : ctx.streaming.paths.pathGet(ctx.encoding, ctx.plainContextString); return createStreamingBase({ requests: ctx.requests, serviceEndpoints: entryEndpoints, @@ -174,6 +191,7 @@ function buildStreamingBase( (entry.initialReconnectDelay ?? ctx.streaming.initialReconnectDelaySeconds) * 1000, logger: ctx.logger, pingHandler: createPingHandler(requestor, sg, ctx.logger), + ...(streamPost ? { method: 'POST' as const, body: ctx.plainContextString } : {}), }); } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts index a51c15616c..b3120ec53a 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts @@ -88,6 +88,10 @@ export function createStreamingBase(config: { logger?: LDLogger; diagnosticsManager?: internal.DiagnosticsManager; pingHandler?: PingHandler; + /** HTTP method override for the stream request (e.g. `'POST'`). Omit for the default GET. */ + method?: string; + /** Request body to send with `method`. Only meaningful together with `method`. */ + body?: string; }): StreamingFDv2Base { const resultQueue = createAsyncQueue(); const protocolHandler = internal.createProtocolHandler( @@ -352,8 +356,15 @@ export function createStreamingBase(config: { logConnectionAttempt(); + if (config.body !== undefined) { + // A request with a body needs a content type. + headers['content-type'] = 'application/json'; + } + const es = config.requests.createEventSource(buildStreamUri(), { headers, + method: config.method, + body: config.body, errorFilter: (error: HttpErrorResponse) => handleError(error), initialRetryDelayMillis: config.initialRetryDelayMillis, readTimeoutMillis: 5 * 60 * 1000, diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 5172d90a35..6134100be7 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -46,6 +46,7 @@ export type { LDContext, LDContextStrict, LDStorage, + LDEventSourceFactory, } from './api'; export { default as createSafeStorage } from './storage/createSafeStorage';