From 39110d9bfce3733164bc99edba9a0a08a1426b98 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 18 Sep 2026 16:11:07 -0400 Subject: [PATCH 1/6] feat: Add eventSource factory and usePost options to the client-side SDK common --- .../__tests__/BrowserDataManager.test.ts | 1 + .../__tests__/ElectronDataManager.test.ts | 1 + .../__tests__/NodeDataManager.test.ts | 1 + .../__tests__/MobileDataManager.test.ts | 1 + .../sdk-client/__tests__/LDClientImpl.test.ts | 113 ++++++++++++++++++ .../datasource/SourceFactoryProvider.test.ts | 35 ++++++ .../datasource/fdv2/StreamingFDv2Base.test.ts | 19 +++ .../datasource/fdv2/streamingTestHelpers.ts | 4 + .../shared/sdk-client/src/LDClientImpl.ts | 19 +++ .../src/api/LDEventSourceFactory.ts | 35 ++++++ .../shared/sdk-client/src/api/LDOptions.ts | 29 ++++- packages/shared/sdk-client/src/api/index.ts | 1 + .../src/configuration/Configuration.ts | 16 ++- .../src/configuration/validators.ts | 2 + .../src/datasource/FDv2DataManagerBase.ts | 2 + .../src/datasource/SourceFactoryProvider.ts | 14 ++- .../src/datasource/fdv2/StreamingFDv2Base.ts | 11 ++ packages/shared/sdk-client/src/index.ts | 1 + 18 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 packages/shared/sdk-client/src/api/LDEventSourceFactory.ts 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 e28392c7ba..fe714c5f5d 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -468,3 +468,116 @@ describe('sdk-client object', () => { expect(mockEventSource.closed).toBe(true); }); }); + +describe('usePost validation', () => { + it('throws when dataSystem is configured with usePost but the EventSource lacks customMethod', () => { + const platform = createBasicPlatform(); + platform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: false, + })); + + expect( + () => + new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { usePost: true, dataSystem: {} }, + makeTestDataManagerFactory(testSdkKey, platform), + ), + ).toThrow(/usePost/); + }); + + it('does not throw 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, + })); + + let client: LDClientImpl | undefined; + expect(() => { + client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { usePost: true, dataSystem: {}, sendEvents: false }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + }).not.toThrow(); + await client?.close(); + }); + + it('does not throw 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, + })); + + let client: LDClientImpl | undefined; + expect(() => { + client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { usePost: true, sendEvents: false }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + }).not.toThrow(); + await client?.close(); + }); +}); + +describe('useReport under dataSystem (CSFDV2 Requirement 2.1.4)', () => { + 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__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts index 5069fed7f6..67f03655bc 100644 --- a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -76,6 +76,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 +155,39 @@ 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 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 +303,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 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..296d8b140a 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,25 @@ it('creates EventSource with correct URI and options', () => { ); }); +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..b291ef7d1c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -127,6 +127,25 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { } this._config = new ConfigurationImpl(options, internalOptions); + + // A dataSystem-configured client has no fallback for a transport that can't send POST -- + // unlike the legacy streaming path's useReport, which falls back to a ping-based GET instead + // of failing. This is a config-level check, not a mode-level one: it fires even if the + // resolved connection mode never opens a stream, since mode switching can start streaming + // later. + if ( + !!this._config.dataSystem && + this._config.usePost && + !platform.requests.getEventSourceCapabilities().customMethod + ) { + throw new Error( + 'usePost requires an EventSource that supports a custom HTTP method (the ' + + '"customMethod" capability) when the dataSystem option is configured. The default ' + + 'EventSource does not support this; supply an EventSource implementation that ' + + 'supports custom HTTP methods.', + ); + } + 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..22cd7c3d23 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts @@ -0,0 +1,35 @@ +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. + * + * `createEventSource` is called once per stream start (not once per connection attempt): the + * returned object owns its own reconnection and backoff for as long as the stream stays open (it + * should honor `errorFilter`/`initialRetryDelayMillis`/`retryResetIntervalMillis`/`urlBuilder` from + * the init dict -- in particular, `errorFilter` decides whether a given failure is retryable at + * all, so ignoring it risks retrying an unrecoverable error like a 401 forever -- and emit + * `onretrying` on each attempt), and connect immediately, which is what the platform EventSource + * contract requires. The SDK does not call `createEventSource` again after a drop -- only when it + * deliberately starts a new stream (e.g. on `identify()`). + */ +export interface LDEventSourceFactory { + 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 REPORT streaming request when `customMethod` is true. When this is omitted, + * every capability is treated as unsupported. Note that `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..d1f55ff640 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,32 @@ 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. + * (Default: `false`) + * + * This is the FDv2 equivalent of `useReport`, and 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) -- the platform's default EventSource + * generally does not. The SDK throws synchronously at construction time if `usePost` is set for + * streaming without a compatible EventSource. + * + * 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 +333,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..ec74b26c6b 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,18 @@ export default class ConfigurationImpl implements Configuration { pristineOptions.payloadFilterKey, ); this.useReport = pristineOptions.useReport ?? false; + this.usePost = pristineOptions.usePost ?? false; + + // CSFDV2 Requirement 2.1.4: useReport is an FDv1-era option. Under FDv2 (dataSystem + // configured), it has no effect -- usePost is the FDv2 equivalent -- so ignore it and warn + // rather than silently changing FDv2 behavior based on an option that doesn't apply there. + 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..c66fda623c 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -525,6 +525,7 @@ export function createFDv2DataManagerBase( platform.encoding!, baseHeaders, queryParams, + config.usePost, ); const environmentNamespace = await namespaceForEnvironment( @@ -547,6 +548,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..73495c04f9 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -41,6 +41,14 @@ export interface SourceFactoryContext { queryParams: { key: string; value: string }[]; /** JSON-serialized evaluation context. */ plainContextString: string; + /** + * Whether streaming should request via POST instead of GET (CSFDV2 Requirement 2.1.3). + * `buildStreamingBase` below acts on this flag unconditionally -- it does not check whether + * the platform's EventSource actually supports a custom HTTP method (the `customMethod` + * capability). The caller is responsible for ensuring `usePost` is only set when that + * capability is present. + */ + usePost: boolean; /** Logger. */ logger: LDLogger; @@ -147,6 +155,7 @@ function resolvePollingRequestor( ctx.encoding, ctx.baseHeaders, ctx.queryParams, + ctx.usePost, ); } @@ -162,7 +171,9 @@ function buildStreamingBase( ) { const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); const requestor = resolvePollingRequestor(ctx, entry.endpoints); - const streamUriPath = ctx.streaming.paths.pathGet(ctx.encoding, ctx.plainContextString); + const streamUriPath = ctx.usePost + ? 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 +185,7 @@ function buildStreamingBase( (entry.initialReconnectDelay ?? ctx.streaming.initialReconnectDelaySeconds) * 1000, logger: ctx.logger, pingHandler: createPingHandler(requestor, sg, ctx.logger), + ...(ctx.usePost ? { 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..b5d8107397 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.method) { + // POST includes a body, so content type is required. + 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'; From f3b4a19290722153734c2a1b77d42e66f31c30ba Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 29 Sep 2026 15:42:01 -0400 Subject: [PATCH 2/6] docs: fixing up comments --- packages/shared/sdk-client/src/LDClientImpl.ts | 6 +----- .../sdk-client/src/api/LDEventSourceFactory.ts | 16 +++++----------- packages/shared/sdk-client/src/api/LDOptions.ts | 6 +++--- .../src/configuration/Configuration.ts | 6 +++--- .../src/datasource/SourceFactoryProvider.ts | 6 +----- 5 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index b291ef7d1c..3eda80aa41 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -128,11 +128,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._config = new ConfigurationImpl(options, internalOptions); - // A dataSystem-configured client has no fallback for a transport that can't send POST -- - // unlike the legacy streaming path's useReport, which falls back to a ping-based GET instead - // of failing. This is a config-level check, not a mode-level one: it fires even if the - // resolved connection mode never opens a stream, since mode switching can start streaming - // later. + // A dataSystem-configured client has no fallback for a transport that can't send POST if ( !!this._config.dataSystem && this._config.usePost && diff --git a/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts b/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts index 22cd7c3d23..d9a020440e 100644 --- a/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts +++ b/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts @@ -10,14 +10,6 @@ import type { * This should only be used when customizing the streaming transport. Typical usage of the SDK does * not require implementing this. * - * `createEventSource` is called once per stream start (not once per connection attempt): the - * returned object owns its own reconnection and backoff for as long as the stream stays open (it - * should honor `errorFilter`/`initialRetryDelayMillis`/`retryResetIntervalMillis`/`urlBuilder` from - * the init dict -- in particular, `errorFilter` decides whether a given failure is retryable at - * all, so ignoring it risks retrying an unrecoverable error like a 401 forever -- and emit - * `onretrying` on each attempt), and connect immediately, which is what the platform EventSource - * contract requires. The SDK does not call `createEventSource` again after a drop -- only when it - * deliberately starts a new stream (e.g. on `identify()`). */ export interface LDEventSourceFactory { createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource; @@ -27,9 +19,11 @@ export interface LDEventSourceFactory { * * The SDK checks these before using a feature that not every transport supports -- for instance, * it only issues a REPORT streaming request when `customMethod` is true. When this is omitted, - * every capability is treated as unsupported. Note that `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. + * every capability is treated as unsupported + * + * @remark + * `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 d1f55ff640..f9d6300c24 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -222,9 +222,9 @@ export interface LDOptions { * option is also configured. * * For streaming specifically, this also requires an EventSource implementation that supports a - * custom HTTP method (the `customMethod` capability) -- the platform's default EventSource - * generally does not. The SDK throws synchronously at construction time if `usePost` is set for - * streaming without a compatible EventSource. + * custom HTTP method (the `customMethod` capability). If the capability + * does not exist the the SDK throws synchronously at construction time + * if `usePost` is set for streaming without a compatible EventSource. * * 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. diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index ec74b26c6b..9ed5c0bef7 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -202,9 +202,9 @@ export default class ConfigurationImpl implements Configuration { this.useReport = pristineOptions.useReport ?? false; this.usePost = pristineOptions.usePost ?? false; - // CSFDV2 Requirement 2.1.4: useReport is an FDv1-era option. Under FDv2 (dataSystem - // configured), it has no effect -- usePost is the FDv2 equivalent -- so ignore it and warn - // rather than silently changing FDv2 behavior based on an option that doesn't apply there. + // useReport is a FDv1 option and it has no effect in FDv2 (which has + // usePost as the equivilent). In the case where this option is used + // in a FDv2 datasytem, we will do nothing and warn. if (this.dataSystem && this.useReport) { this.logger?.warn( 'The "useReport" configuration option has no effect when the "dataSystem" option is ' + diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index 73495c04f9..fd945c78b6 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -42,11 +42,7 @@ export interface SourceFactoryContext { /** JSON-serialized evaluation context. */ plainContextString: string; /** - * Whether streaming should request via POST instead of GET (CSFDV2 Requirement 2.1.3). - * `buildStreamingBase` below acts on this flag unconditionally -- it does not check whether - * the platform's EventSource actually supports a custom HTTP method (the `customMethod` - * capability). The caller is responsible for ensuring `usePost` is only set when that - * capability is present. + * Whether streaming should request via POST instead of GET. */ usePost: boolean; /** Logger. */ From 7ce2eb2b70958f9f59c1d5720d10e2fbc182b020 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 29 Sep 2026 16:22:22 -0400 Subject: [PATCH 3/6] chore: Remove an internal spec reference from a test name --- packages/shared/sdk-client/__tests__/LDClientImpl.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index fe714c5f5d..70785a4f0f 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -533,7 +533,7 @@ describe('usePost validation', () => { }); }); -describe('useReport under dataSystem (CSFDV2 Requirement 2.1.4)', () => { +describe('useReport under dataSystem', () => { it('ignores useReport and logs a warning when dataSystem is also configured', async () => { const platform = createBasicPlatform(); platform.requests.getEventSourceCapabilities.mockImplementation(() => ({ From 4be388f23c86278ce448f938db0bfebac2282084 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 29 Sep 2026 16:29:58 -0400 Subject: [PATCH 4/6] fix: Allow usePost polling when the EventSource cannot send POST --- .../sdk-client/__tests__/LDClientImpl.test.ts | 32 +++++++++++------- .../datasource/SourceFactoryProvider.test.ts | 33 ++++++++++++++++++- .../shared/sdk-client/src/LDClientImpl.ts | 11 +++---- .../shared/sdk-client/src/api/LDOptions.ts | 5 ++- .../src/datasource/SourceFactoryProvider.ts | 8 +++-- 5 files changed, 65 insertions(+), 24 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 70785a4f0f..0c99f8dea6 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -470,24 +470,34 @@ describe('sdk-client object', () => { }); describe('usePost validation', () => { - it('throws when dataSystem is configured with usePost but the EventSource lacks customMethod', () => { + 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(), + }; - expect( - () => - new LDClientImpl( - testSdkKey, - AutoEnvAttributes.Enabled, - platform, - { usePost: true, dataSystem: {} }, - makeTestDataManagerFactory(testSdkKey, platform), - ), - ).toThrow(/usePost/); + let client: LDClientImpl | undefined; + // Construction succeeds: usePost still applies to polling, only streaming degrades to GET. + expect(() => { + client = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + platform, + { usePost: true, dataSystem: {}, sendEvents: false, logger }, + makeTestDataManagerFactory(testSdkKey, platform), + ); + }).not.toThrow(); + // The degrade is loud: the warning names the GET fallback for streaming. + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/usePost.*GET/s)); + await client?.close(); }); it('does not throw when dataSystem is configured with usePost and the EventSource supports customMethod', async () => { diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts index 67f03655bc..0167bdb0b4 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', @@ -173,6 +179,31 @@ it('uses the post path and passes method/body overrides when usePost is true', ( ); }); +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 }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 3eda80aa41..8e06f7aaf9 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -128,17 +128,16 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._config = new ConfigurationImpl(options, internalOptions); - // A dataSystem-configured client has no fallback for a transport that can't send POST + // usePost applies to streaming only when the EventSource supports a custom HTTP method. if ( !!this._config.dataSystem && this._config.usePost && !platform.requests.getEventSourceCapabilities().customMethod ) { - throw new Error( - 'usePost requires an EventSource that supports a custom HTTP method (the ' + - '"customMethod" capability) when the dataSystem option is configured. The default ' + - 'EventSource does not support this; supply an EventSource implementation that ' + - 'supports custom HTTP methods.', + 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.', ); } diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index f9d6300c24..66960c4fe2 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -222,9 +222,8 @@ export interface LDOptions { * option is also configured. * * For streaming specifically, this also requires an EventSource implementation that supports a - * custom HTTP method (the `customMethod` capability). If the capability - * does not exist the the SDK throws synchronously at construction time - * if `usePost` is set for streaming without a compatible EventSource. + * custom HTTP method (the `customMethod` capability). Without that capability, streaming + * requests use GET, polling requests still use POST, and the SDK logs a warning. * * 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. diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index fd945c78b6..51d36cb1cf 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -42,7 +42,7 @@ export interface SourceFactoryContext { /** JSON-serialized evaluation context. */ plainContextString: string; /** - * Whether streaming should request via POST instead of GET. + * Whether requests should use POST instead of GET. */ usePost: boolean; /** Logger. */ @@ -167,7 +167,9 @@ function buildStreamingBase( ) { const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); const requestor = resolvePollingRequestor(ctx, entry.endpoints); - const streamUriPath = ctx.usePost + // usePost applies to the stream only when the EventSource can send a custom HTTP method. + const streamPost = ctx.usePost && ctx.requests.getEventSourceCapabilities().customMethod; + const streamUriPath = streamPost ? ctx.streaming.paths.pathPost(ctx.encoding, ctx.plainContextString) : ctx.streaming.paths.pathGet(ctx.encoding, ctx.plainContextString); return createStreamingBase({ @@ -181,7 +183,7 @@ function buildStreamingBase( (entry.initialReconnectDelay ?? ctx.streaming.initialReconnectDelaySeconds) * 1000, logger: ctx.logger, pingHandler: createPingHandler(requestor, sg, ctx.logger), - ...(ctx.usePost ? { method: 'POST' as const, body: ctx.plainContextString } : {}), + ...(streamPost ? { method: 'POST' as const, body: ctx.plainContextString } : {}), }); } From dffa4bf9afd21b89bac50907ed5de33cd76c2feb Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 8 Oct 2026 15:33:45 -0400 Subject: [PATCH 5/6] chore: improve comments + test coverage --- .../sdk-client/__tests__/LDClientImpl.test.ts | 82 ++++++++++--------- .../configuration/Configuration.test.ts | 23 ++++++ .../datasource/FDv2DataManagerBase.test.ts | 24 ++++++ .../datasource/SourceFactoryProvider.test.ts | 23 ++++++ .../datasource/fdv2/StreamingFDv2Base.test.ts | 11 +++ .../src/api/LDEventSourceFactory.ts | 10 ++- .../shared/sdk-client/src/api/LDOptions.ts | 2 +- .../src/configuration/Configuration.ts | 7 +- .../src/datasource/fdv2/StreamingFDv2Base.ts | 4 +- 9 files changed, 137 insertions(+), 49 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 0c99f8dea6..333102eed4 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -484,62 +484,70 @@ describe('usePost validation', () => { error: jest.fn(), }; - let client: LDClientImpl | undefined; - // Construction succeeds: usePost still applies to polling, only streaming degrades to GET. - expect(() => { - client = new LDClientImpl( - testSdkKey, - AutoEnvAttributes.Enabled, - platform, - { usePost: true, dataSystem: {}, sendEvents: false, logger }, - makeTestDataManagerFactory(testSdkKey, platform), - ); - }).not.toThrow(); - // The degrade is loud: the warning names the GET fallback for streaming. + // 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(); + await client.close(); }); - it('does not throw when dataSystem is configured with usePost and the EventSource supports customMethod', async () => { + 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(), + }; - let client: LDClientImpl | undefined; - expect(() => { - client = new LDClientImpl( - testSdkKey, - AutoEnvAttributes.Enabled, - platform, - { usePost: true, dataSystem: {}, sendEvents: false }, - makeTestDataManagerFactory(testSdkKey, platform), - ); - }).not.toThrow(); - await client?.close(); + 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 throw when usePost is set without dataSystem, even if the EventSource lacks customMethod (usePost only applies to FDv2)', async () => { + 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(), + }; - let client: LDClientImpl | undefined; - expect(() => { - client = new LDClientImpl( - testSdkKey, - AutoEnvAttributes.Enabled, - platform, - { usePost: true, sendEvents: false }, - makeTestDataManagerFactory(testSdkKey, platform), - ); - }).not.toThrow(); - await client?.close(); + 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(); }); }); 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..45ac58548d 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); diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts index 0167bdb0b4..d5b1951bab 100644 --- a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -346,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 296d8b140a..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,17 @@ 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); diff --git a/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts b/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts index d9a020440e..3ab755469d 100644 --- a/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts +++ b/packages/shared/sdk-client/src/api/LDEventSourceFactory.ts @@ -9,19 +9,21 @@ import type { * * 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 REPORT streaming request when `customMethod` is true. When this is omitted, - * every capability is treated as unsupported + * it only issues a POST or REPORT streaming request when `customMethod` is true. When this is + * omitted, every capability is treated as unsupported. * - * @remark + * @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. */ diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 66960c4fe2..4004d2c07b 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -332,7 +332,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/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 9ed5c0bef7..87d7d010bb 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -200,11 +200,8 @@ export default class ConfigurationImpl implements Configuration { pristineOptions.payloadFilterKey, ); this.useReport = pristineOptions.useReport ?? false; - this.usePost = pristineOptions.usePost ?? false; - - // useReport is a FDv1 option and it has no effect in FDv2 (which has - // usePost as the equivilent). In the case where this option is used - // in a FDv2 datasytem, we will do nothing and warn. + // useReport is an FDv1 option with no effect in FDv2, where usePost is the + // equivalent. Ignore it under dataSystem and warn. if (this.dataSystem && this.useReport) { this.logger?.warn( 'The "useReport" configuration option has no effect when the "dataSystem" option is ' + diff --git a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts index b5d8107397..b3120ec53a 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts @@ -356,8 +356,8 @@ export function createStreamingBase(config: { logConnectionAttempt(); - if (config.method) { - // POST includes a body, so content type is required. + if (config.body !== undefined) { + // A request with a body needs a content type. headers['content-type'] = 'application/json'; } From fe5a2ffc186935924dc1bf1c6a8d50e7df2b0349 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 9 Oct 2026 11:14:39 -0400 Subject: [PATCH 6/6] fix: Use REPORT for FDv1 fallback requests when usePost is set --- .../datasource/FDv2DataManagerBase.test.ts | 42 +++++++++++++++++++ .../shared/sdk-client/src/api/LDOptions.ts | 4 ++ .../src/datasource/FDv2DataManagerBase.ts | 5 ++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index 45ac58548d..2c81f832b7 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -1043,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/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 4004d2c07b..90b710010e 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -225,6 +225,10 @@ export interface LDOptions { * 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 diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index c66fda623c..b5c1f337da 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -314,7 +314,10 @@ export function createFDv2DataManagerBase( ctx.baseHeaders, ctx.queryParams, config.withReasons, - config.useReport, + // The FDv1 fallback maps usePost to the REPORT method. This keeps + // the context in the request body instead of the URL. The useReport + // option is always disabled when the data system is configured. + config.usePost, ); const fdv1SyncFactory = {