From de024f7d0c3f99729a75d638b24e7306d69d3133 Mon Sep 17 00:00:00 2001 From: netanelC Date: Mon, 1 Jun 2026 11:47:26 +0300 Subject: [PATCH 1/3] feat: add disableHotReload param --- README.md | 9 +++++++++ src/config.ts | 15 +++++++++----- src/errors.ts | 1 + src/metrics.ts | 2 +- src/options.ts | 5 +++++ src/rollout/ChangeDetector.ts | 2 +- src/types.ts | 23 +++++++++++++-------- tests/config.spec.ts | 8 ++++++++ tests/rollout.spec.ts | 38 +++++++++++++++++++++++++++++++++++ 9 files changed, 88 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6c5bf82..a86d02b 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ This package allows you to configure various options for loading and managing co ### `ignoreServerIsOlderVersionError` - **Type**: `boolean` - **Optional**: `true` +- **Default**: `false` - **Description**: Indicates whether to ignore the error when the server version is older than the requested version. - **Environment Variable**: `CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR` @@ -129,6 +130,13 @@ This package allows you to configure various options for loading and managing co - **Default**: `./config` - **Description**: The path to the local configuration folder. +### `disableHotReload` +- **Type**: `boolean` +- **Optional**: `true` +- **Default**: `false` +- **Description**: Indicates whether hot-reloading should be disabled. If true, the SDK fetches the remote configuration exactly once upon startup and never starts the background polling loop. +- **Environment Variable**: `CONFIG_DISABLE_HOT_RELOAD` + ### `pollIntervalMs` - **Type**: `number` - **Optional**: `true` @@ -151,6 +159,7 @@ The following environment variables can be used to configure the options: - `CONFIG_OFFLINE_MODE`: Sets the `offlineMode` option. - `CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR`: Sets the `ignoreServerIsOlderVersionError` option. - `CONFIG_POLL_INTERVAL_MS`: Sets the `pollIntervalMs` option. +- `CONFIG_DISABLE_HOT_RELOAD`: Sets the `disableHotReload` option. ## Configuration Merging and Validation diff --git a/src/config.ts b/src/config.ts index 967e4d3..012e32a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,7 +41,7 @@ export async function config( debug('config called with options: %j', { ...options, schema: options.schema.$id }); const { schema: baseSchema, metricsRegistry, onChange, ...unvalidatedOptions } = options; const initOptions = initializeOptions(unvalidatedOptions); - const { configName, offlineMode, version, ignoreServerIsOlderVersionError } = initOptions; + const { configName, offlineMode, version, ignoreServerIsOlderVersionError, disableHotReload } = initOptions; // Load Local and Env Configs First (Independent of remote state) const dereferencedSchema = await loadSchema(baseSchema); @@ -77,7 +77,12 @@ export async function config( let validatedConfig: ReturnType; // Handle Remote Config and Polling - if (offlineMode !== true) { + if (!offlineMode) { + if (!disableHotReload && onChange === undefined) { + debug('Hot reload is enabled but no onChange callback was provided'); + throw createConfigError('onChangeCallbackMissingError', `Hot reload is enabled but no 'onChange' callback was provided`, {}); + } + debug('handling fetching remote data'); // check if the server is using an older version of the schemas package const capabilitiesResponse = await getServerCapabilities(); @@ -90,7 +95,7 @@ export async function config( satisfies: semverSatisfies, }); } - if (ignoreServerIsOlderVersionError !== true && gt(LOCAL_SCHEMAS_PACKAGE_VERSION, capabilitiesResponse.schemasPackageVersion)) { + if (!ignoreServerIsOlderVersionError && gt(LOCAL_SCHEMAS_PACKAGE_VERSION, capabilitiesResponse.schemasPackageVersion)) { debug( 'server is using an older version of the schemas package. local: %s, remote: %s', LOCAL_SCHEMAS_PACKAGE_VERSION, @@ -129,7 +134,7 @@ export async function config( validatedConfig = mergeAndValidate(remoteConfig); // Setup polling - if (onChange) { + if (!disableHotReload) { changeDetector = new ChangeDetector( baseSchema.$id, initOptions, @@ -137,7 +142,7 @@ export async function config( const newlyValidatedConfig = mergeAndValidate(newRemoteConfig); validatedConfig = newlyValidatedConfig; remoteConfig = newRemoteConfig; - await onChange(newlyValidatedConfig); + await onChange!(newlyValidatedConfig); }, currentEtag ); diff --git a/src/errors.ts b/src/errors.ts index bcb1d37..d0732a9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -11,6 +11,7 @@ const configErrors = { schemaVersionMismatchError: { code: 7, payload: {} as { remoteSchemaVersion: string; localSchemaVersion: string } }, promClientNotInstalledError: { code: 8, payload: {} as { message: string } }, serverVersionMismatchError: { code: 9, payload: {} as { remoteServerVersion: string; localServerVersion: string; satisfies: string } }, + onChangeCallbackMissingError: { code: 10, payload: {} }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record; diff --git a/src/metrics.ts b/src/metrics.ts index d45b686..cc8547b 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -46,7 +46,7 @@ export function initializeMetrics(registry: Registry, schemaId: string, actualVe name: configName, request_version: version, actual_version: actualVersion, - offline_mode: String(offlineMode ?? false), + offline_mode: String(offlineMode), schemas_package_version: LOCAL_SCHEMAS_PACKAGE_VERSION, package_version: PACKAGE_VERSION, schema_id: schemaId, diff --git a/src/options.ts b/src/options.ts index 81459fe..421b74c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -12,6 +12,10 @@ const defaultOptions: BaseOptions = { configServerUrl: 'http://localhost:8080', version: 'latest', pollIntervalMs: 30000, + offlineMode: false, + ignoreServerIsOlderVersionError: false, + localConfigPath: './config', + disableHotReload: false, }; const envOptions: Partial> = { @@ -21,6 +25,7 @@ const envOptions: Partial> = { offlineMode: process.env.CONFIG_OFFLINE_MODE, ignoreServerIsOlderVersionError: process.env.CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR, pollIntervalMs: process.env.CONFIG_POLL_INTERVAL_MS, + disableHotReload: process.env.CONFIG_DISABLE_HOT_RELOAD, }; // in order to merge correctly the keys should not exist, undefined is not enough diff --git a/src/rollout/ChangeDetector.ts b/src/rollout/ChangeDetector.ts index 90e3301..2d8ad58 100644 --- a/src/rollout/ChangeDetector.ts +++ b/src/rollout/ChangeDetector.ts @@ -38,7 +38,7 @@ export class ChangeDetector { if (this.timer) { clearTimeout(this.timer); } - const baseInterval = this.options.pollIntervalMs!; + const baseInterval = this.options.pollIntervalMs; const jitter = baseInterval * JITTER_PERCENTAGE; // eslint-disable-next-line @typescript-eslint/no-magic-numbers const randomJitter = (Math.random() * 2 - 1) * jitter; diff --git a/src/types.ts b/src/types.ts index e6c4eaa..4d4acaa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,21 +56,27 @@ export interface BaseOptions { /** * Indicates whether the configuration should be loaded in offline mode. */ - offlineMode?: boolean; + offlineMode: boolean; /** * Indicates whether to ignore the error when the server version is older than the requested version. */ - ignoreServerIsOlderVersionError?: boolean; + ignoreServerIsOlderVersionError: boolean; /** * The path to the local configuration folder. * @default './config' */ - localConfigPath?: string; + localConfigPath: string; /** * The polling interval in milliseconds. * @default 30000 */ - pollIntervalMs?: number; + pollIntervalMs: number; + /** + * Indicates whether hot-reloading should be disabled. + * If true, the SDK fetches the remote configuration exactly once upon startup. + * @default false + */ + disableHotReload: boolean; } /** @@ -107,10 +113,11 @@ export const optionsSchema: JSONSchemaType = { ], }, configServerUrl: { type: 'string' }, - offlineMode: { type: 'boolean', nullable: true }, - ignoreServerIsOlderVersionError: { type: 'boolean', nullable: true }, - localConfigPath: { type: 'string', default: './config', nullable: true }, - pollIntervalMs: { type: 'integer', default: 30000, nullable: true }, + offlineMode: { type: 'boolean' }, + ignoreServerIsOlderVersionError: { type: 'boolean' }, + localConfigPath: { type: 'string', default: './config' }, + pollIntervalMs: { type: 'integer', default: 30000 }, + disableHotReload: { type: 'boolean', default: false }, }, }; diff --git a/tests/config.spec.ts b/tests/config.spec.ts index fb4b411..a85bfab 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -40,6 +40,7 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', + onChange: async () => {}, }); const conf = configInstance.getAll(); @@ -125,6 +126,7 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', + onChange: async () => {}, }); const conf = configInstance.getAll(); @@ -185,6 +187,7 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', + onChange: async () => {}, }); const parts = configInstance.getConfigParts(); @@ -222,6 +225,8 @@ describe('config', () => { localConfigPath: './tests/config', offlineMode: true, pollIntervalMs: 3000, + disableHotReload: false, + ignoreServerIsOlderVersionError: false, }); }); @@ -249,6 +254,7 @@ describe('config', () => { schema: commonS3PartialV1, configServerUrl: URL, localConfigPath: './tests/config', + onChange: async () => {}, }); await expect(promise).rejects.toThrow('The schema version of the remote config does not match the schema version of the local config'); @@ -280,6 +286,7 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', + onChange: async () => {}, }); await expect(promise).rejects.toThrow('Config validation error'); @@ -378,6 +385,7 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', + onChange: async () => {}, }); await expect(promise).rejects.toThrow('The server version does not satisfy the required version.'); diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index 046e858..be36cdd 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -228,4 +228,42 @@ describe('Continuous Polling (ChangeDetector)', () => { setTimeoutSpy.mockRestore(); }); + + it('should not start polling if disableHotReload is true', async () => { + // Arrange + const initialConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'initial-host' }, + createdAt: 0, + }; + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + const onChangeMock = vi.fn(); + + // Act + await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + configServerUrl: URL, + localConfigPath: './tests/config', + pollIntervalMs: DEFAULT_POLL_INTERVAL, + onChange: onChangeMock, + disableHotReload: true, + }); + + // Advance time beyond the polling interval + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL * (1 + JITTER_PERCENTAGE) + 1); + + // Assert + expect(onChangeMock).not.toHaveBeenCalled(); + }); }); From 92f4826df5543fc209d918fb538b9e67fa230a5f Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 18 Jun 2026 14:20:55 +0300 Subject: [PATCH 2/3] chore: fix words --- tests/rollout.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index d64bb7a..ce3e495 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -24,7 +24,7 @@ describe('Continuous Polling (ChangeDetector)', () => { vi.restoreAllMocks(); }); - it('should trigger onChange and exit when polling returns a new config (200 OK)', async () => { + it('should trigger onChange when polling returns a new config (200 OK)', async () => { // Arrange const initialConfigData = { configName: 'name', From 3f7c3dab63f9ee071f99ba7626c87d869e9b94ae Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 18 Jun 2026 17:43:33 +0300 Subject: [PATCH 3/3] test: improve tests --- tests/config.spec.ts | 6 ------ tests/rollout.spec.ts | 12 +----------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 64e16fa..753bab8 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -37,7 +37,6 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', - onChange: async () => {}, }); const conf = configInstance.getAll(); @@ -123,7 +122,6 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', - onChange: async () => {}, }); const conf = configInstance.getAll(); @@ -180,7 +178,6 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', - onChange: async () => {}, }); const parts = configInstance.getConfigParts(); @@ -244,7 +241,6 @@ describe('config', () => { schema: commonS3PartialV1, configServerUrl: URL, localConfigPath: './tests/config', - onChange: async () => {}, }); await expect(promise).rejects.toThrow('The schema version of the remote config does not match the schema version of the local config'); @@ -276,7 +272,6 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', - onChange: async () => {}, }); await expect(promise).rejects.toThrow('Config validation error'); @@ -375,7 +370,6 @@ describe('config', () => { schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', - onChange: async () => {}, }); await expect(promise).rejects.toThrow('The server version does not satisfy the required version.'); diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index 495323c..04c0ff3 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -292,13 +292,7 @@ describe('Continuous Polling (ChangeDetector)', () => { it('should not start polling if disableHotReload is true', async () => { // Arrange - const initialConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'initial-host' }, - createdAt: 0, - }; + const initialConfigData = createMockConfigData(); client .intercept({ path: '/capabilities', method: 'GET' }) @@ -307,16 +301,12 @@ describe('Continuous Polling (ChangeDetector)', () => { .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); - const onChangeMock = vi.fn(); - // Act await config({ configName: 'name', version: 1, schema: commonDbPartialV1, - configServerUrl: URL, localConfigPath: './tests/config', - pollIntervalMs: DEFAULT_POLL_INTERVAL, onChange: onChangeMock, disableHotReload: true, });