From a1852fe4ed3ea7f96e10264fb6d3447e49af8d4a Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 24 Jul 2026 12:55:45 -0400 Subject: [PATCH 1/4] test: pin node server contract test to v3 This will update from a pinned version to follow the v3 release branch --- .github/workflows/server-node.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/server-node.yml b/.github/workflows/server-node.yml index ed39152449..2637f79383 100644 --- a/.github/workflows/server-node.yml +++ b/.github/workflows/server-node.yml @@ -53,7 +53,7 @@ jobs: with: test_service_port: 8000 token: ${{ secrets.GITHUB_TOKEN }} - version: v3.0.0-alpha.6 + version: v3 extra_params: '--skip-from=./packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt' run-custom-socks-proxy-example: From e075f7228a300e9918d89ea51808f1178a968e7d Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 24 Jul 2026 17:44:04 -0400 Subject: [PATCH 2/4] feat: adding baseuri override to server sdk --- .../contract-tests/src/sdkClientEntity.ts | 16 +--- .../LDClientImpl.dataSystemEndpoints.test.ts | 84 +++++++++++++++++++ .../StreamingProcessorFDv2.test.ts | 28 +++++++ .../shared/sdk-server/src/LDClientImpl.ts | 65 +++++++++++--- .../src/api/options/LDDataSystemOptions.ts | 13 +++ .../data_sources/StreamingProcessorFDv2.ts | 5 +- 6 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/LDClientImpl.dataSystemEndpoints.test.ts diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index f45cdc888c..1b1de43db1 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -110,11 +110,9 @@ export function makeSdkConfig(options: ServerSDKConfigParams, tag: string): LDOp if (options.dataSystem.initializers) { options.dataSystem.initializers.forEach((initializer) => { if (initializer.polling) { - cf.baseUri = initializer.polling.baseUri; - cf.pollInterval = maybeTime(initializer.polling.pollIntervalMs); - const initializerOptions: PollingDataSourceConfiguration = { type: 'polling', + baseUri: initializer.polling.baseUri, pollInterval: maybeTime(initializer.polling.pollIntervalMs), }; @@ -126,23 +124,17 @@ export function makeSdkConfig(options: ServerSDKConfigParams, tag: string): LDOp if (options.dataSystem.synchronizers) { options.dataSystem.synchronizers.forEach((synchronizer) => { if (synchronizer.streaming) { - cf.streamUri = synchronizer.streaming.baseUri; - const synchronizerOptions: StreamingDataSourceConfiguration = { type: 'streaming', + baseUri: synchronizer.streaming.baseUri, + streamInitialReconnectDelay: maybeTime(synchronizer.streaming.initialRetryDelayMs), }; - synchronizerOptions.streamInitialReconnectDelay = maybeTime( - synchronizer.streaming.initialRetryDelayMs, - ); - dataSourceOptions.synchronizers.push(synchronizerOptions); } else if (synchronizer.polling) { - cf.baseUri = synchronizer.polling.baseUri; - cf.pollInterval = maybeTime(synchronizer.polling.pollIntervalMs); - const synchronizerOptions: PollingDataSourceConfiguration = { type: 'polling', + baseUri: synchronizer.polling.baseUri, pollInterval: maybeTime(synchronizer.polling.pollIntervalMs), }; diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.dataSystemEndpoints.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.dataSystemEndpoints.test.ts new file mode 100644 index 0000000000..9b415467db --- /dev/null +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.dataSystemEndpoints.test.ts @@ -0,0 +1,84 @@ +import { LDOptions } from '../src/api/options/LDOptions'; +import LDClientImpl from '../src/LDClientImpl'; +import { createBasicPlatform } from './createBasicPlatform'; + +describe('given a custom FDv2 data system with per-source base URIs', () => { + const callbacks = { + onFailed: jest.fn(), + onError: jest.fn(), + onReady: jest.fn(), + onUpdate: jest.fn(), + hasEventListeners: jest.fn(), + }; + let platform: any; + let client: LDClientImpl; + + beforeEach(() => { + platform = createBasicPlatform(); + // Failing the request is fine - the Requestor records the URL synchronously, + // before awaiting the response, so we can still assert on the target URL. + platform.requests.fetch = jest.fn(async () => { + throw new Error('no network in test'); + }); + // Minimal event-source mock so a streaming source can open without throwing. + platform.requests.createEventSource = jest.fn(() => ({ + onopen: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onretrying: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), + })); + }); + + afterEach(() => { + client?.close(); + jest.resetAllMocks(); + }); + + it('routes a polling initializer to its per-source baseUri instead of the shared endpoint', async () => { + const options: LDOptions = { + sendEvents: false, + diagnosticOptOut: true, + dataSystem: { + dataSource: { + dataSourceOptionsType: 'custom', + initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], + synchronizers: [], + }, + }, + }; + client = new LDClientImpl('sdk-key', platform, options, callbacks); + await client.waitForInitialization({ timeout: 20 }).catch(() => {}); + + expect(platform.requests.fetch).toHaveBeenCalled(); + const urls: string[] = platform.requests.fetch.mock.calls.map((c: any[]) => c[0] as string); + expect(urls.some((u) => u.startsWith('https://custom-init.example.com'))).toBe(true); + }); + + it('routes a streaming synchronizer to its per-source baseUri instead of the shared endpoint', async () => { + const options: LDOptions = { + sendEvents: false, + diagnosticOptOut: true, + dataSystem: { + dataSource: { + dataSourceOptionsType: 'custom', + initializers: [], + synchronizers: [{ type: 'streaming', baseUri: 'https://custom-stream.example.com' }], + }, + }, + }; + client = new LDClientImpl('sdk-key', platform, options, callbacks); + // NOTE: `timeout` is in seconds (see LDWaitForInitializationOptions). Unlike the polling + // initializer test above, nothing here ever resolves/rejects initialization (the mocked + // createEventSource never fires onopen/onerror), so a `timeout: 20` (20 real seconds) would + // hang past jest's default per-test timeout. Use a short real timeout instead. + await client.waitForInitialization({ timeout: 0.02 }).catch(() => {}); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + const urls: string[] = platform.requests.createEventSource.mock.calls.map( + (c: any[]) => c[0] as string, + ); + expect(urls.some((u) => u.startsWith('https://custom-stream.example.com'))).toBe(true); + }); +}); diff --git a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessorFDv2.test.ts b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessorFDv2.test.ts index a1534cc669..2cf1aa0e7d 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessorFDv2.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessorFDv2.test.ts @@ -6,6 +6,7 @@ import { LDFlagDeliveryFallbackError, LDLogger, LDStreamingError, + ServiceEndpoints, subsystem, } from '@launchdarkly/js-sdk-common'; @@ -207,6 +208,33 @@ describe('given a stream processor with mock event source', () => { ); }); + it('prefers a per-source serviceEndpoints override over the clientContext streaming endpoint', () => { + const override = new ServiceEndpoints('https://override.stream.com', ''); + const processor = new StreamingProcessorFDv2( + { + basicConfiguration: getBasicConfiguration(logger), + platform: basicPlatform, + }, + '/all', + [], + { + authorization: 'my-sdk-key', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', + }, + diagnosticsManager, + 1, + override, + ); + processor.start(jest.fn(), jest.fn()); + + expect(basicPlatform.requests.createEventSource).toHaveBeenLastCalledWith( + 'https://override.stream.com/all', + expect.anything(), + ); + processor.close(); + }); + it('adds listeners', () => { expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( 1, diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 8836aaa580..14ec3b5c05 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -249,6 +249,25 @@ function constructFDv1( }; } +/** + * Build a copy of {@link base} with the streaming and/or polling base URIs overridden; every + * other field carries over unchanged. + */ +function scopedServiceEndpoints( + base: ServiceEndpoints, + overrides: { streaming?: string; polling?: string }, +): ServiceEndpoints { + return new ServiceEndpoints( + overrides.streaming ?? base.streaming, + overrides.polling ?? base.polling, + base.events, + base.analyticsEventPath, + base.diagnosticEventPath, + base.includeAuthorizationHeader, + base.payloadFilterKey, + ); +} + function constructFDv2( sdkKey: string, platform: Platform, @@ -368,10 +387,22 @@ function constructFDv2( break; } case 'polling': { + const pollingEndpoints = initializerConfig.baseUri + ? scopedServiceEndpoints(config.serviceEndpoints, { + polling: initializerConfig.baseUri, + }) + : undefined; initializers.push( () => new OneShotInitializerFDv2( - new Requestor(config, platform.requests, baseHeaders, '/sdk/poll', config.logger), + new Requestor( + config, + platform.requests, + baseHeaders, + '/sdk/poll', + config.logger, + pollingEndpoints, + ), config.logger, ), ); @@ -387,6 +418,11 @@ function constructFDv2( case 'streaming': { const { streamInitialReconnectDelay = DEFAULT_STREAM_RECONNECT_DELAY } = synchronizerConfig; + const streamingEndpoints = synchronizerConfig.baseUri + ? scopedServiceEndpoints(config.serviceEndpoints, { + streaming: synchronizerConfig.baseUri, + }) + : undefined; synchronizers.push( () => new StreamingProcessorFDv2( @@ -396,16 +432,29 @@ function constructFDv2( baseHeaders, diagnosticsManager, streamInitialReconnectDelay, + streamingEndpoints, ), ); break; } case 'polling': { const { pollInterval = DEFAULT_POLL_INTERVAL } = synchronizerConfig; + const pollingEndpoints = synchronizerConfig.baseUri + ? scopedServiceEndpoints(config.serviceEndpoints, { + polling: synchronizerConfig.baseUri, + }) + : undefined; synchronizers.push( () => new PollingProcessorFDv2( - new Requestor(config, platform.requests, baseHeaders, '/sdk/poll', config.logger), + new Requestor( + config, + platform.requests, + baseHeaders, + '/sdk/poll', + config.logger, + pollingEndpoints, + ), pollInterval, config.logger, ), @@ -473,15 +522,9 @@ function constructFDv2( const fdv1FallbackPollInterval = fdv1FallbackConfig?.pollInterval ?? config.pollInterval ?? DEFAULT_POLL_INTERVAL; const fdv1FallbackEndpoints = fdv1FallbackConfig?.baseUri - ? new ServiceEndpoints( - config.serviceEndpoints.streaming, - fdv1FallbackConfig.baseUri, - config.serviceEndpoints.events, - config.serviceEndpoints.analyticsEventPath, - config.serviceEndpoints.diagnosticEventPath, - config.serviceEndpoints.includeAuthorizationHeader, - config.serviceEndpoints.payloadFilterKey, - ) + ? scopedServiceEndpoints(config.serviceEndpoints, { + polling: fdv1FallbackConfig.baseUri, + }) : undefined; fdv1FallbackSynchronizers.push( () => diff --git a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts index 9cb81a40ad..dec0235491 100644 --- a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts @@ -120,6 +120,13 @@ export interface FileSystemDataSourceConfiguration { export interface StreamingDataSourceConfiguration { type: 'streaming'; + /** + * Optional per-source base URI for this streaming data source. When set, this source + * connects to the given URI instead of the SDK's shared streaming service endpoint. + * Defaults to the SDK's configured streaming base URI. + */ + baseUri?: string; + /** * Sets the initial reconnect delay for the streaming connection, in seconds. Default if omitted. * @@ -134,6 +141,12 @@ export interface StreamingDataSourceConfiguration { export interface PollingDataSourceConfiguration { type: 'polling'; + /** + * Optional per-source base URI for this polling data source. When set, this source + * polls the given URI instead of the SDK's shared polling service endpoint. + * Defaults to the SDK's configured polling base URI. + */ + baseUri?: string; /** * The time between polling requests, in seconds. Default if omitted. */ diff --git a/packages/shared/sdk-server/src/data_sources/StreamingProcessorFDv2.ts b/packages/shared/sdk-server/src/data_sources/StreamingProcessorFDv2.ts index 5fa17264bb..3b7edc8711 100644 --- a/packages/shared/sdk-server/src/data_sources/StreamingProcessorFDv2.ts +++ b/packages/shared/sdk-server/src/data_sources/StreamingProcessorFDv2.ts @@ -40,13 +40,16 @@ export default class StreamingProcessorFDv2 implements subsystemCommon.DataSourc baseHeaders: LDHeaders, private readonly _diagnosticsManager?: internal.DiagnosticsManager, private readonly _streamInitialReconnectDelay = 1, + // set when this source needs its own endpoint in a composite chain; leave undefined + // to fall back to the shared config.serviceEndpoints + serviceEndpointsOverride?: ServiceEndpoints, ) { const { basicConfiguration, platform } = clientContext; const { logger, serviceEndpoints } = basicConfiguration; const { requests } = platform; this._headers = { ...baseHeaders }; - this._serviceEndpoints = serviceEndpoints; + this._serviceEndpoints = serviceEndpointsOverride ?? serviceEndpoints; this._logger = logger; this._requests = requests; } From e8c399a46e903a451ea25e5eb1c3ccdae657e4de Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 27 Jul 2026 15:16:37 -0400 Subject: [PATCH 3/4] fix: configuration for standard datasources cannot take in custom uri --- .../__tests__/options/Configuration.test.ts | 86 +++++++++++++++++++ .../options/LDDataSystemOptions.test.ts | 66 ++++++++++++++ .../src/api/options/LDDataSystemOptions.ts | 14 ++- .../sdk-server/src/options/Configuration.ts | 15 ++++ 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts diff --git a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts index a8344b4df2..8fa8184199 100644 --- a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts +++ b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts @@ -688,3 +688,89 @@ describe('when setting different options', () => { ]); }); }); + +// baseUri is a valid top-level LDOptions field, but standard/streamingOnly/pollingOnly +// dataSource shapes never read it -- each already gets its endpoint from +// Configuration.serviceEndpoints. The validator drops it and warns, mirroring how fdv1Fallback +// rejects fields outside its own table; custom mode and the top-level baseUri/streamUri/eventsUri +// options are unaffected. +describe('dataSystem.dataSource baseUri is scoped to custom mode only', () => { + it.each([ + ['standard', { dataSourceOptionsType: 'standard', baseUri: 'https://example.com' }], + [ + 'streamingOnly', + { dataSourceOptionsType: 'streamingOnly', baseUri: 'https://example.com' }, + ], + ['pollingOnly', { dataSourceOptionsType: 'pollingOnly', baseUri: 'https://example.com' }], + ])('drops baseUri and warns on the %s dataSource shape', (_mode, dataSource) => { + const opts = withLogger({ + dataSystem: { dataSource: dataSource as unknown as DataSourceOptions }, + }); + const config = new Configuration(opts); + expect((config.dataSystem!.dataSource as any).baseUri).toBeUndefined(); + expect(logger(opts).getCount()).toEqual(1); + logger(opts).expectMessages([ + { + level: LogLevel.Warn, + matches: /Ignoring unknown config option "dataSystem.dataSource.baseUri"/, + }, + ]); + }); + + it.each([ + [ + 'standard', + { dataSourceOptionsType: 'standard', pollInterval: 60, streamInitialReconnectDelay: 5 }, + ], + ['streamingOnly', { dataSourceOptionsType: 'streamingOnly', streamInitialReconnectDelay: 5 }], + ['pollingOnly', { dataSourceOptionsType: 'pollingOnly', pollInterval: 60 }], + ])( + 'still validates pollInterval/streamInitialReconnectDelay on the %s dataSource shape', + (_mode, dataSource) => { + const opts = withLogger({ + dataSystem: { dataSource: dataSource as unknown as DataSourceOptions }, + }); + const config = new Configuration(opts); + const validated = config.dataSystem!.dataSource as any; + if ('pollInterval' in dataSource) { + expect(validated.pollInterval).toEqual((dataSource as any).pollInterval); + } + if ('streamInitialReconnectDelay' in dataSource) { + expect(validated.streamInitialReconnectDelay).toEqual( + (dataSource as any).streamInitialReconnectDelay, + ); + } + expect(logger(opts).getCount()).toEqual(0); + }, + ); + + it('does not affect baseUri inside a custom mode data source entry', () => { + const opts = withLogger({ + dataSystem: { + dataSource: { + dataSourceOptionsType: 'custom', + initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], + synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], + } as unknown as DataSourceOptions, + }, + }); + const config = new Configuration(opts); + expect(config.dataSystem!.dataSource).toEqual({ + dataSourceOptionsType: 'custom', + initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], + synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], + }); + expect(logger(opts).getCount()).toEqual(0); + }); + + it('does not affect the top-level baseUri option', () => { + const opts = withLogger({ + baseUri: 'https://example.com', + streamUri: 'https://stream.example.com', + eventsUri: 'https://events.example.com', + }); + const config = new Configuration(opts); + expect(config.serviceEndpoints.polling).toEqual('https://example.com'); + expect(logger(opts).getCount()).toEqual(0); + }); +}); diff --git a/packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts b/packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts new file mode 100644 index 0000000000..09f900793c --- /dev/null +++ b/packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts @@ -0,0 +1,66 @@ +import { + CustomDataSourceOptions, + PollingDataSourceConfiguration, + PollingDataSourceOptions, + StandardDataSourceOptions, + StreamingDataSourceConfiguration, + StreamingDataSourceOptions, +} from '../../src/api/options/LDDataSystemOptions'; + +describe('baseUri is scoped to custom-mode data source configuration only', () => { + it('does not allow baseUri on StandardDataSourceOptions', () => { + const opts: StandardDataSourceOptions = { + dataSourceOptionsType: 'standard', + // @ts-expect-error standard mode already gets its endpoints from + // Configuration.serviceEndpoints; there is no per-source override + baseUri: 'https://example.com', + }; + expect(opts).toBeTruthy(); + }); + + it('does not allow baseUri on StreamingDataSourceOptions', () => { + const opts: StreamingDataSourceOptions = { + dataSourceOptionsType: 'streamingOnly', + // @ts-expect-error streamingOnly already gets its endpoint from + // Configuration.serviceEndpoints.streaming; there is no per-source override + baseUri: 'https://example.com', + }; + expect(opts).toBeTruthy(); + }); + + it('does not allow baseUri on PollingDataSourceOptions', () => { + const opts: PollingDataSourceOptions = { + dataSourceOptionsType: 'pollingOnly', + // @ts-expect-error pollingOnly already gets its endpoint from + // Configuration.serviceEndpoints.polling; there is no per-source override + baseUri: 'https://example.com', + }; + expect(opts).toBeTruthy(); + }); + + it('still allows baseUri on the custom mode streaming synchronizer configuration', () => { + const config: StreamingDataSourceConfiguration = { + type: 'streaming', + baseUri: 'https://custom-stream.example.com', + }; + expect(config.baseUri).toBe('https://custom-stream.example.com'); + }); + + it('still allows baseUri on the custom mode polling initializer/synchronizer configuration', () => { + const config: PollingDataSourceConfiguration = { + type: 'polling', + baseUri: 'https://custom-poll.example.com', + }; + expect(config.baseUri).toBe('https://custom-poll.example.com'); + }); + + it('still allows per-source baseUri inside a CustomDataSourceOptions data source', () => { + const custom: CustomDataSourceOptions = { + dataSourceOptionsType: 'custom', + initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], + synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], + }; + expect(custom.initializers[0]).toMatchObject({ baseUri: 'https://custom-init.example.com' }); + expect(custom.synchronizers[0]).toMatchObject({ baseUri: 'https://custom-sync.example.com' }); + }); +}); diff --git a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts index dec0235491..c3e9f3826e 100644 --- a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts @@ -158,10 +158,12 @@ export interface PollingDataSourceConfiguration { * a combination of streaming and polling to initialize the SDK, provide real time updates, * and can switch between streaming and polling automatically to provide redundancy. */ +// baseUri is omitted here: standard mode always uses Configuration.serviceEndpoints and has +// no per-source override, unlike the same field on a CustomDataSourceOptions initializer. export interface StandardDataSourceOptions extends - Omit, - Omit { + Omit, + Omit { dataSourceOptionsType: 'standard'; } @@ -169,14 +171,18 @@ export interface StandardDataSourceOptions * This data source will make best effort to maintain a streaming connection to LaunchDarkly services * to provide real time data updates. */ -export interface StreamingDataSourceOptions extends Omit { +// baseUri omitted for the same reason as StandardDataSourceOptions above +export interface StreamingDataSourceOptions + extends Omit { dataSourceOptionsType: 'streamingOnly'; } /** * This data source will periodically make a request to LaunchDarkly services to retrieve updated data. */ -export interface PollingDataSourceOptions extends Omit { +// baseUri omitted for the same reason as StandardDataSourceOptions above +export interface PollingDataSourceOptions + extends Omit { dataSourceOptionsType: 'pollingOnly'; } diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index e9a43b74ce..4709fe0f2f 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -260,6 +260,18 @@ function validateFDv1FallbackOptions( return { errors, validatedOptions: validatedOptions as FDv1FallbackConfiguration }; } +// validateTypesAndNames shares its `validations` table with the top-level options, so baseUri +// passes type checking here too -- but standard/streamingOnly/pollingOnly never read it off +// dataSource; each already gets its endpoint from Configuration.serviceEndpoints. Reject it +// explicitly, mirroring how validateFDv1FallbackOptions rejects fields outside its own table. +function rejectDataSourceBaseUri(dataSource: Options, validatedDataSource: Options): string[] { + if (!Object.prototype.hasOwnProperty.call(dataSource, 'baseUri')) { + return []; + } + delete validatedDataSource.baseUri; + return [OptionMessages.unknownOption('dataSystem.dataSource.baseUri')]; +} + function validateDataSystemOptions(options: Options): { errors: string[]; validatedOptions: Options; @@ -310,16 +322,19 @@ function validateDataSystemOptions(options: Options): { options.dataSource, defaultStandardDataSourceOptions, )); + errors.push(...rejectDataSourceBaseUri(options.dataSource, validatedDataSourceOptions)); } else if (isStreamingOnlyOptions(options.dataSource)) { ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( options.dataSource, defaultStreamingDataSourceOptions, )); + errors.push(...rejectDataSourceBaseUri(options.dataSource, validatedDataSourceOptions)); } else if (isPollingOnlyOptions(options.dataSource)) { ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( options.dataSource, defaultPollingDataSourceOptions, )); + errors.push(...rejectDataSourceBaseUri(options.dataSource, validatedDataSourceOptions)); } else if (isCustomOptions(options.dataSource)) { validatedDataSourceOptions = options.dataSource; errors = []; From eb6d22190055dee928348d7be29deb21a8e7bb05 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 27 Jul 2026 16:34:03 -0400 Subject: [PATCH 4/4] chore: bot comments --- .../__tests__/options/Configuration.test.ts | 138 +++++++++--------- .../options/LDDataSystemOptions.test.ts | 100 +++++++------ 2 files changed, 117 insertions(+), 121 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts index 8fa8184199..8191e82c00 100644 --- a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts +++ b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts @@ -694,83 +694,81 @@ describe('when setting different options', () => { // Configuration.serviceEndpoints. The validator drops it and warns, mirroring how fdv1Fallback // rejects fields outside its own table; custom mode and the top-level baseUri/streamUri/eventsUri // options are unaffected. -describe('dataSystem.dataSource baseUri is scoped to custom mode only', () => { - it.each([ - ['standard', { dataSourceOptionsType: 'standard', baseUri: 'https://example.com' }], - [ - 'streamingOnly', - { dataSourceOptionsType: 'streamingOnly', baseUri: 'https://example.com' }, - ], - ['pollingOnly', { dataSourceOptionsType: 'pollingOnly', baseUri: 'https://example.com' }], - ])('drops baseUri and warns on the %s dataSource shape', (_mode, dataSource) => { - const opts = withLogger({ - dataSystem: { dataSource: dataSource as unknown as DataSourceOptions }, - }); - const config = new Configuration(opts); - expect((config.dataSystem!.dataSource as any).baseUri).toBeUndefined(); - expect(logger(opts).getCount()).toEqual(1); - logger(opts).expectMessages([ - { - level: LogLevel.Warn, - matches: /Ignoring unknown config option "dataSystem.dataSource.baseUri"/, - }, - ]); - }); - - it.each([ - [ - 'standard', - { dataSourceOptionsType: 'standard', pollInterval: 60, streamInitialReconnectDelay: 5 }, - ], - ['streamingOnly', { dataSourceOptionsType: 'streamingOnly', streamInitialReconnectDelay: 5 }], - ['pollingOnly', { dataSourceOptionsType: 'pollingOnly', pollInterval: 60 }], - ])( - 'still validates pollInterval/streamInitialReconnectDelay on the %s dataSource shape', - (_mode, dataSource) => { - const opts = withLogger({ - dataSystem: { dataSource: dataSource as unknown as DataSourceOptions }, - }); - const config = new Configuration(opts); - const validated = config.dataSystem!.dataSource as any; - if ('pollInterval' in dataSource) { - expect(validated.pollInterval).toEqual((dataSource as any).pollInterval); - } - if ('streamInitialReconnectDelay' in dataSource) { - expect(validated.streamInitialReconnectDelay).toEqual( - (dataSource as any).streamInitialReconnectDelay, - ); - } - expect(logger(opts).getCount()).toEqual(0); +it.each([ + ['standard', { dataSourceOptionsType: 'standard', baseUri: 'https://example.com' }], + [ + 'streamingOnly', + { dataSourceOptionsType: 'streamingOnly', baseUri: 'https://example.com' }, + ], + ['pollingOnly', { dataSourceOptionsType: 'pollingOnly', baseUri: 'https://example.com' }], +])('drops baseUri and warns on the %s dataSource shape', (_mode, dataSource) => { + const opts = withLogger({ + dataSystem: { dataSource: dataSource as unknown as DataSourceOptions }, + }); + const config = new Configuration(opts); + expect((config.dataSystem!.dataSource as any).baseUri).toBeUndefined(); + expect(logger(opts).getCount()).toEqual(1); + logger(opts).expectMessages([ + { + level: LogLevel.Warn, + matches: /Ignoring unknown config option "dataSystem.dataSource.baseUri"/, }, - ); + ]); +}); - it('does not affect baseUri inside a custom mode data source entry', () => { +it.each([ + [ + 'standard', + { dataSourceOptionsType: 'standard', pollInterval: 60, streamInitialReconnectDelay: 5 }, + ], + ['streamingOnly', { dataSourceOptionsType: 'streamingOnly', streamInitialReconnectDelay: 5 }], + ['pollingOnly', { dataSourceOptionsType: 'pollingOnly', pollInterval: 60 }], +])( + 'still validates pollInterval/streamInitialReconnectDelay on the %s dataSource shape', + (_mode, dataSource) => { const opts = withLogger({ - dataSystem: { - dataSource: { - dataSourceOptionsType: 'custom', - initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], - synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], - } as unknown as DataSourceOptions, - }, + dataSystem: { dataSource: dataSource as unknown as DataSourceOptions }, }); const config = new Configuration(opts); - expect(config.dataSystem!.dataSource).toEqual({ - dataSourceOptionsType: 'custom', - initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], - synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], - }); + const validated = config.dataSystem!.dataSource as any; + if ('pollInterval' in dataSource) { + expect(validated.pollInterval).toEqual((dataSource as any).pollInterval); + } + if ('streamInitialReconnectDelay' in dataSource) { + expect(validated.streamInitialReconnectDelay).toEqual( + (dataSource as any).streamInitialReconnectDelay, + ); + } expect(logger(opts).getCount()).toEqual(0); + }, +); + +it('does not affect baseUri inside a custom mode data source entry', () => { + const opts = withLogger({ + dataSystem: { + dataSource: { + dataSourceOptionsType: 'custom', + initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], + synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], + } as unknown as DataSourceOptions, + }, + }); + const config = new Configuration(opts); + expect(config.dataSystem!.dataSource).toEqual({ + dataSourceOptionsType: 'custom', + initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], + synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], }); + expect(logger(opts).getCount()).toEqual(0); +}); - it('does not affect the top-level baseUri option', () => { - const opts = withLogger({ - baseUri: 'https://example.com', - streamUri: 'https://stream.example.com', - eventsUri: 'https://events.example.com', - }); - const config = new Configuration(opts); - expect(config.serviceEndpoints.polling).toEqual('https://example.com'); - expect(logger(opts).getCount()).toEqual(0); +it('does not affect the top-level baseUri option', () => { + const opts = withLogger({ + baseUri: 'https://example.com', + streamUri: 'https://stream.example.com', + eventsUri: 'https://events.example.com', }); + const config = new Configuration(opts); + expect(config.serviceEndpoints.polling).toEqual('https://example.com'); + expect(logger(opts).getCount()).toEqual(0); }); diff --git a/packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts b/packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts index 09f900793c..b9d6fef7e7 100644 --- a/packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts +++ b/packages/shared/sdk-server/__tests__/options/LDDataSystemOptions.test.ts @@ -7,60 +7,58 @@ import { StreamingDataSourceOptions, } from '../../src/api/options/LDDataSystemOptions'; -describe('baseUri is scoped to custom-mode data source configuration only', () => { - it('does not allow baseUri on StandardDataSourceOptions', () => { - const opts: StandardDataSourceOptions = { - dataSourceOptionsType: 'standard', - // @ts-expect-error standard mode already gets its endpoints from - // Configuration.serviceEndpoints; there is no per-source override - baseUri: 'https://example.com', - }; - expect(opts).toBeTruthy(); - }); +it('does not allow baseUri on StandardDataSourceOptions', () => { + const opts: StandardDataSourceOptions = { + dataSourceOptionsType: 'standard', + // @ts-expect-error standard mode already gets its endpoints from + // Configuration.serviceEndpoints; there is no per-source override + baseUri: 'https://example.com', + }; + expect(opts).toBeTruthy(); +}); - it('does not allow baseUri on StreamingDataSourceOptions', () => { - const opts: StreamingDataSourceOptions = { - dataSourceOptionsType: 'streamingOnly', - // @ts-expect-error streamingOnly already gets its endpoint from - // Configuration.serviceEndpoints.streaming; there is no per-source override - baseUri: 'https://example.com', - }; - expect(opts).toBeTruthy(); - }); +it('does not allow baseUri on StreamingDataSourceOptions', () => { + const opts: StreamingDataSourceOptions = { + dataSourceOptionsType: 'streamingOnly', + // @ts-expect-error streamingOnly already gets its endpoint from + // Configuration.serviceEndpoints.streaming; there is no per-source override + baseUri: 'https://example.com', + }; + expect(opts).toBeTruthy(); +}); - it('does not allow baseUri on PollingDataSourceOptions', () => { - const opts: PollingDataSourceOptions = { - dataSourceOptionsType: 'pollingOnly', - // @ts-expect-error pollingOnly already gets its endpoint from - // Configuration.serviceEndpoints.polling; there is no per-source override - baseUri: 'https://example.com', - }; - expect(opts).toBeTruthy(); - }); +it('does not allow baseUri on PollingDataSourceOptions', () => { + const opts: PollingDataSourceOptions = { + dataSourceOptionsType: 'pollingOnly', + // @ts-expect-error pollingOnly already gets its endpoint from + // Configuration.serviceEndpoints.polling; there is no per-source override + baseUri: 'https://example.com', + }; + expect(opts).toBeTruthy(); +}); - it('still allows baseUri on the custom mode streaming synchronizer configuration', () => { - const config: StreamingDataSourceConfiguration = { - type: 'streaming', - baseUri: 'https://custom-stream.example.com', - }; - expect(config.baseUri).toBe('https://custom-stream.example.com'); - }); +it('still allows baseUri on the custom mode streaming synchronizer configuration', () => { + const config: StreamingDataSourceConfiguration = { + type: 'streaming', + baseUri: 'https://custom-stream.example.com', + }; + expect(config.baseUri).toBe('https://custom-stream.example.com'); +}); - it('still allows baseUri on the custom mode polling initializer/synchronizer configuration', () => { - const config: PollingDataSourceConfiguration = { - type: 'polling', - baseUri: 'https://custom-poll.example.com', - }; - expect(config.baseUri).toBe('https://custom-poll.example.com'); - }); +it('still allows baseUri on the custom mode polling initializer/synchronizer configuration', () => { + const config: PollingDataSourceConfiguration = { + type: 'polling', + baseUri: 'https://custom-poll.example.com', + }; + expect(config.baseUri).toBe('https://custom-poll.example.com'); +}); - it('still allows per-source baseUri inside a CustomDataSourceOptions data source', () => { - const custom: CustomDataSourceOptions = { - dataSourceOptionsType: 'custom', - initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], - synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], - }; - expect(custom.initializers[0]).toMatchObject({ baseUri: 'https://custom-init.example.com' }); - expect(custom.synchronizers[0]).toMatchObject({ baseUri: 'https://custom-sync.example.com' }); - }); +it('still allows per-source baseUri inside a CustomDataSourceOptions data source', () => { + const custom: CustomDataSourceOptions = { + dataSourceOptionsType: 'custom', + initializers: [{ type: 'polling', baseUri: 'https://custom-init.example.com' }], + synchronizers: [{ type: 'streaming', baseUri: 'https://custom-sync.example.com' }], + }; + expect(custom.initializers[0]).toMatchObject({ baseUri: 'https://custom-init.example.com' }); + expect(custom.synchronizers[0]).toMatchObject({ baseUri: 'https://custom-sync.example.com' }); });