From a0a551e7f7c7ee28d8e0df55c3f14c1d8f26ab73 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Sun, 7 Sep 2025 19:20:10 +0800 Subject: [PATCH 01/21] wip --- package.json | 2 +- src/appConfigurationImpl.ts | 125 +++++++++++++++++++++------- src/cdn/cdnRequestPipelinePolicy.ts | 20 +++++ src/cdn/constants.ts | 4 + src/common/errorMessages.ts | 2 + src/index.ts | 2 +- src/load.ts | 42 +++++++++- src/requestTracing/constants.ts | 1 + src/requestTracing/utils.ts | 8 ++ test/requestTracing.test.ts | 33 +++++++- 10 files changed, 205 insertions(+), 34 deletions(-) create mode 100644 src/cdn/cdnRequestPipelinePolicy.ts create mode 100644 src/cdn/constants.ts diff --git a/package.json b/package.json index 4dcf4418..559e074c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev": "rollup --config --watch", "lint": "eslint src/ test/ examples/ --ext .js,.ts,.mjs", "fix-lint": "eslint src/ test/ examples/ --fix --ext .js,.ts,.mjs", - "test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel" + "test": "mocha out/esm/test/load.test.js out/commonjs/test/load.test.js --parallel" }, "repository": { "type": "git", diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index ae3a85fd..f5bc1932 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -13,9 +13,10 @@ import { isSecretReference, GetSnapshotOptions, GetSnapshotResponse, - KnownSnapshotComposition + KnownSnapshotComposition, + ListConfigurationSettingPage } from "@azure/app-configuration"; -import { isRestError } from "@azure/core-rest-pipeline"; +import { isRestError, RestError } from "@azure/core-rest-pipeline"; import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./appConfiguration.js"; import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js"; import { IKeyValueAdapter } from "./keyValueAdapter.js"; @@ -66,6 +67,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; +import { TIMESTAMP_HEADER } from "./cdn/constants.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds @@ -106,12 +108,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #watchAll: boolean = false; #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #kvRefreshTimer: RefreshTimer; + #lastKvChangeDetected: Date = new Date(0); + #kvRefreshIncompleted: boolean = false; // Feature flags #featureFlagEnabled: boolean = false; #featureFlagRefreshEnabled: boolean = false; #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #ffRefreshTimer: RefreshTimer; + #lastFfChangeDetected: Date = new Date(0); + #ffRefreshIncompleted: boolean = false; // Key Vault references #secretRefreshEnabled: boolean = false; @@ -131,12 +137,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Load balancing #lastSuccessfulEndpoint: string = ""; + // CDN + #isCdnUsed: boolean = false; + constructor( clientManager: ConfigurationClientManager, options: AzureAppConfigurationOptions | undefined, + isCdnUsed: boolean ) { this.#options = options; this.#clientManager = clientManager; + this.#isCdnUsed = isCdnUsed; // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); @@ -224,7 +235,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { isFailoverRequest: this.#isFailoverRequest, featureFlagTracing: this.#featureFlagTracing, fmVersion: this.#fmVersion, - aiConfigurationTracing: this.#aiConfigurationTracing + aiConfigurationTracing: this.#aiConfigurationTracing, + isCdnUsed: this.#isCdnUsed }; } @@ -498,6 +510,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { JSON.stringify(selectors) ); + let upToDate: boolean = true; for (const selector of selectorsToUpdate) { if (selector.snapshotName === undefined) { const listOptions: ListConfigurationSettingsOptions = { @@ -519,6 +532,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { loadedSettings.set(setting.key, setting); } } + const timestamp = this.#getResponseTimestamp(page); + // all pages must be later than last change detected to be considered up-to-date + upToDate &&= (timestamp > (loadFeatureFlag ? this.#lastFfChangeDetected : this.#lastKvChangeDetected)); } selector.pageEtags = pageEtags; } else { // snapshot selector @@ -547,8 +563,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (loadFeatureFlag) { this.#ffSelectors = selectorsToUpdate; + this.#ffRefreshIncompleted = !upToDate; } else { this.#kvSelectors = selectorsToUpdate; + this.#kvRefreshIncompleted = !upToDate; } return Array.from(loadedSettings.values()); }; @@ -605,11 +623,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } else { // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing const { key, label } = sentinel; - const response = await this.#getConfigurationSetting({ key, label }); - if (response) { - sentinel.etag = response.etag; - } else { + const response = await this.#getConfigurationSetting({ key, label }, { onlyIfChanged: false }); + if (isRestError(response)) { // watched key not found sentinel.etag = undefined; + } else { + sentinel.etag = response.etag; } } } @@ -661,22 +679,36 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let needRefresh = false; if (this.#watchAll) { needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); - } - for (const sentinel of this.#sentinels.values()) { - const response = await this.#getConfigurationSetting(sentinel, { - onlyIfChanged: true - }); - - if (response?.statusCode === 200 // created or changed - || (response === undefined && sentinel.etag !== undefined) // deleted - ) { - sentinel.etag = response?.etag;// update etag of the sentinel - needRefresh = true; - break; + } else { + const getOptions: GetConfigurationSettingOptions = { + // send conditional request only when CDN is not used + onlyIfChanged: !this.#isCdnUsed + }; + for (const sentinel of this.#sentinels.values()) { + const response: GetConfigurationSettingResponse | RestError = + await this.#getConfigurationSetting(sentinel, getOptions); + + if (isRestError(response)) { // sentinel key not found + if (sentinel.etag !== undefined) { + // previously existed, now deleted + sentinel.etag = undefined; + const timestamp = this.#getResponseTimestamp(response); + if (timestamp > this.#lastKvChangeDetected) { + this.#lastKvChangeDetected = timestamp; + } + needRefresh = true; + break; + } + } else if (response.statusCode === 200 && sentinel.etag !== response?.etag) { + // change detected + sentinel.etag = response?.etag;// update etag of the sentinel + needRefresh = true; + break; + } } } - if (needRefresh) { + if (needRefresh || this.#kvRefreshIncompleted) { for (const adapter of this.#adapters) { await adapter.onChangeDetected(); } @@ -697,8 +729,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } - const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); - if (needRefresh) { + const refreshFeatureFlag = true; + const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors, refreshFeatureFlag); + if (needRefresh || this.#ffRefreshIncompleted) { await this.#loadFeatureFlags(); } @@ -730,7 +763,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * @param selectors - The @see PagedSettingSelector of the kev-value collection. * @returns true if key-value collection has changed, false otherwise. */ - async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { + async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[], refreshFeatureFlag: boolean = false): Promise { const funcToExecute = async (client) => { for (const selector of selectors) { if (selector.snapshotName) { // skip snapshot selector @@ -739,20 +772,41 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, - tagsFilter: selector.tagFilters, - pageEtags: selector.pageEtags + tagsFilter: selector.tagFilters }; + if (!this.#isCdnUsed) { + // if CDN is not used, add page etags to the listOptions to send conditional request + listOptions.pageEtags = selector.pageEtags; + } + const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, client, listOptions ).byPage(); + if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { + return true; // no etag is retrieved from previous request, always refresh + } + + let i = 0; for await (const page of pageIterator) { - if (page._response.status === 200) { // created or changed + if (i >= selector.pageEtags.length || // new page + (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed + const timestamp = this.#getResponseTimestamp(page); + if (refreshFeatureFlag) { + if (timestamp > this.#lastFfChangeDetected) { + this.#lastFfChangeDetected = timestamp; + } + } else { + if (timestamp > this.#lastKvChangeDetected) { + this.#lastKvChangeDetected = timestamp; + } + } return true; } + i++; } } return false; @@ -763,9 +817,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error. + * Gets a configuration setting by key and label. If the setting is not found, return the error instead of throwing it. */ - async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { + async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { const funcToExecute = async (client) => { return getConfigurationSettingWithTrace( this.#requestTraceOptions, @@ -775,12 +829,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ); }; - let response: GetConfigurationSettingResponse | undefined; + let response: GetConfigurationSettingResponse | RestError; try { response = await this.#executeWithFailoverPolicy(funcToExecute); } catch (error) { if (isRestError(error) && error.statusCode === 404) { - response = undefined; + // configuration setting not found, return the error + return error; } else { throw error; } @@ -1088,6 +1143,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return first15Bytes.toString("base64url"); } } + + #getResponseTimestamp(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date { + let header: string | undefined; + if (isRestError(response)) { + header = response.response?.headers.get(TIMESTAMP_HEADER) ?? undefined; + } else { + header = response._response.headers.get(TIMESTAMP_HEADER) ?? undefined; + } + return header ? new Date(header) : new Date(); + } } function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/cdn/cdnRequestPipelinePolicy.ts b/src/cdn/cdnRequestPipelinePolicy.ts new file mode 100644 index 00000000..34c9c8d3 --- /dev/null +++ b/src/cdn/cdnRequestPipelinePolicy.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelinePolicy } from "@azure/core-rest-pipeline"; + +/** + * The pipeline policy that remove the authorization header from the request to allow anonymous access to the Azure Front Door. + * @remarks + * The policy position should be perRetry, since it should be executed after the "Sign" phase: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/core/core-client/src/serviceClient.ts + */ +export class AnonymousRequestPipelinePolicy implements PipelinePolicy { + name: string = "AppConfigurationAnonymousRequestPolicy"; + + async sendRequest(request, next) { + if (request.headers.has("authorization")) { + request.headers.delete("authorization"); + } + return next(request); + } +} diff --git a/src/cdn/constants.ts b/src/cdn/constants.ts new file mode 100644 index 00000000..65888c43 --- /dev/null +++ b/src/cdn/constants.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const TIMESTAMP_HEADER = "x-ms-date"; diff --git a/src/common/errorMessages.ts b/src/common/errorMessages.ts index e1f9f658..930ee066 100644 --- a/src/common/errorMessages.ts +++ b/src/common/errorMessages.ts @@ -20,6 +20,8 @@ export const enum ErrorMessages { INVALID_LABEL_FILTER = "The characters '*' and ',' are not supported in label filters.", INVALID_TAG_FILTER = "Tag filter must follow the format 'tagName=tagValue'", CONNECTION_STRING_OR_ENDPOINT_MISSED = "A connection string or an endpoint with credential must be specified to create a client.", + REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door.", + LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door." } export const enum KeyVaultReferenceErrorMessages { diff --git a/src/index.ts b/src/index.ts index 9317abe7..61a2980d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,6 @@ export { AzureAppConfiguration } from "./appConfiguration.js"; export { Disposable } from "./common/disposable.js"; -export { load } from "./load.js"; +export { load, loadFromAzureFrontDoor } from "./load.js"; export { KeyFilter, LabelFilter } from "./types.js"; export { VERSION } from "./version.js"; diff --git a/src/load.ts b/src/load.ts index cc05183f..d826c27c 100644 --- a/src/load.ts +++ b/src/load.ts @@ -6,10 +6,18 @@ import { AzureAppConfiguration } from "./appConfiguration.js"; import { AzureAppConfigurationImpl } from "./appConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js"; import { ConfigurationClientManager } from "./configurationClientManager.js"; +import { AnonymousRequestPipelinePolicy } from "./cdn/cdnRequestPipelinePolicy.js"; import { instanceOfTokenCredential } from "./common/utils.js"; +import { ArgumentError } from "./common/errors.js"; +import { ErrorMessages } from "./common/errorMessages.js"; const MIN_DELAY_FOR_UNHANDLED_ERROR_IN_MS: number = 5_000; +// Empty token credential to be used when loading from Azure Front Door +const emptyTokenCredential: TokenCredential = { + getToken: async () => ({ token: "", expiresOnTimestamp: Number.MAX_SAFE_INTEGER }) +}; + /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. * @param connectionString The connection string for the App Configuration store. @@ -42,7 +50,8 @@ export async function load( } try { - const appConfiguration = new AzureAppConfigurationImpl(clientManager, options); + const isCdnUsed: boolean = credentialOrOptions === emptyTokenCredential; + const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, isCdnUsed); await appConfiguration.load(); return appConfiguration; } catch (error) { @@ -56,3 +65,34 @@ export async function load( throw error; } } + +/** + * Loads the data from Azure Front Door (CDN) and returns an instance of AzureAppConfiguration. + * @param endpoint The URL to the Azure Front Door. + * @param appConfigOptions Optional parameters. + */ +export async function loadFromAzureFrontDoor(endpoint: URL | string, options?: AzureAppConfigurationOptions): Promise; + +export async function loadFromAzureFrontDoor( + endpoint: string | URL, + appConfigOptions: AzureAppConfigurationOptions = {} +): Promise { + if (appConfigOptions.replicaDiscoveryEnabled) { + throw new ArgumentError(ErrorMessages.REPLICA_DISCOVERY_NOT_SUPPORTED); + } + if (appConfigOptions.loadBalancingEnabled) { + throw new ArgumentError(ErrorMessages.LOAD_BALANCING_NOT_SUPPORTED); + } + appConfigOptions.replicaDiscoveryEnabled = false; // Disable replica discovery when loading from Azure Front Door + + appConfigOptions.clientOptions = { + ...appConfigOptions.clientOptions, + // Add etag url policy to append etag to the request url for breaking CDN cache + additionalPolicies: [ + ...(appConfigOptions.clientOptions?.additionalPolicies || []), + { policy: new AnonymousRequestPipelinePolicy(), position: "perRetry" } + ] + }; + + return await load(endpoint, emptyTokenCredential, appConfigOptions); +} diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 6f9311b4..060cf8b3 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -51,6 +51,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount"; export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault"; export const FAILOVER_REQUEST_TAG = "Failover"; +export const CDN_USED_TAG = "CDN"; // Compact feature tags export const FEATURES_KEY = "Features"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index e9949505..2a62f53c 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -27,6 +27,7 @@ import { HostType, KEY_VAULT_CONFIGURED_TAG, KEY_VAULT_REFRESH_CONFIGURED_TAG, + CDN_USED_TAG, KUBERNETES_ENV_VAR, NODEJS_DEV_ENV_VAL, NODEJS_ENV_VAR, @@ -50,6 +51,7 @@ export interface RequestTracingOptions { initialLoadCompleted: boolean; replicaCount: number; isFailoverRequest: boolean; + isCdnUsed: boolean; featureFlagTracing: FeatureFlagTracingOptions | undefined; fmVersion: string | undefined; aiConfigurationTracing: AIConfigurationTracingOptions | undefined; @@ -99,7 +101,9 @@ function applyRequestTracing(requestTracingOptions: const actualOptions = { ...operationOptions }; if (requestTracingOptions.enabled) { actualOptions.requestOptions = { + ...actualOptions.requestOptions, customHeaders: { + ...actualOptions.requestOptions?.customHeaders, [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) } }; @@ -119,6 +123,7 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt FFFeatures: Seed+Telemetry UsersKeyVault Failover + CDN */ const keyValues = new Map(); const tags: string[] = []; @@ -150,6 +155,9 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt if (requestTracingOptions.isFailoverRequest) { tags.push(FAILOVER_REQUEST_TAG); } + if (requestTracingOptions.isCdnUsed) { + tags.push(CDN_USED_TAG); + } if (requestTracingOptions.replicaCount > 0) { keyValues.set(REPLICA_COUNT_KEY, requestTracingOptions.replicaCount.toString()); } diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index b61441b1..bc75faf8 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -8,7 +8,7 @@ chai.use(chaiAsPromised); const expect = chai.expect; import { MAX_TIME_OUT, HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; import { ConfigurationClientManager } from "../src/configurationClientManager.js"; -import { load } from "../src/index.js"; +import { load, loadFromAzureFrontDoor } from "../src/index.js"; const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; @@ -112,6 +112,37 @@ describe("request tracing", function () { sinon.restore(); }); + it("should have cdn tag in correlation-context header when loadFromAzureFrontDoor is used", async () => { + try { + await loadFromAzureFrontDoor(fakeEndpoint, { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("CDN")).eq(true); + }); + + it("should not have cdn tag in correlation-context header when load is used", async () => { + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("CDN")).eq(false); + }); + it("should detect env in correlation-context header", async () => { process.env.NODE_ENV = "development"; try { From d0ecd09c6f7591adf2d00e99ca7d28cbb0e485c4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 8 Sep 2025 01:42:11 +0800 Subject: [PATCH 02/21] load from azure front door --- package.json | 2 +- src/appConfigurationImpl.ts | 37 ++++--- test/cdn.test.ts | 214 ++++++++++++++++++++++++++++++++++++ test/utils/testHelper.ts | 47 ++++++++ 4 files changed, 282 insertions(+), 18 deletions(-) create mode 100644 test/cdn.test.ts diff --git a/package.json b/package.json index 559e074c..4dcf4418 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev": "rollup --config --watch", "lint": "eslint src/ test/ examples/ --ext .js,.ts,.mjs", "fix-lint": "eslint src/ test/ examples/ --fix --ext .js,.ts,.mjs", - "test": "mocha out/esm/test/load.test.js out/commonjs/test/load.test.js --parallel" + "test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel" }, "repository": { "type": "git", diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index f5bc1932..d7af6e5e 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -67,7 +67,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; -import { TIMESTAMP_HEADER } from "./cdn/constants.js"; +import { TIMESTAMP_HEADER } from "./cdn/constants.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds @@ -534,7 +534,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } const timestamp = this.#getResponseTimestamp(page); // all pages must be later than last change detected to be considered up-to-date - upToDate &&= (timestamp > (loadFeatureFlag ? this.#lastFfChangeDetected : this.#lastKvChangeDetected)); + if (loadFeatureFlag) { + upToDate &&= (timestamp > this.#lastFfChangeDetected); + } else { + const temp = this.#lastKvChangeDetected; + upToDate &&= (timestamp > temp); + } } selector.pageEtags = pageEtags; } else { // snapshot selector @@ -688,20 +693,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const response: GetConfigurationSettingResponse | RestError = await this.#getConfigurationSetting(sentinel, getOptions); - if (isRestError(response)) { // sentinel key not found - if (sentinel.etag !== undefined) { - // previously existed, now deleted - sentinel.etag = undefined; - const timestamp = this.#getResponseTimestamp(response); - if (timestamp > this.#lastKvChangeDetected) { - this.#lastKvChangeDetected = timestamp; - } - needRefresh = true; - break; + const isDeleted = isRestError(response) && sentinel.etag !== undefined; // previously existed, now deleted + const isChanged = + !isRestError(response) && + response.statusCode === 200 && + sentinel.etag !== response.etag; // etag changed + + if (isDeleted || isChanged) { + sentinel.etag = isChanged ? (response as GetConfigurationSettingResponse).etag : undefined; + const timestamp = this.#getResponseTimestamp(response); + if (timestamp > this.#lastKvChangeDetected) { + this.#lastKvChangeDetected = timestamp; } - } else if (response.statusCode === 200 && sentinel.etag !== response?.etag) { - // change detected - sentinel.etag = response?.etag;// update etag of the sentinel needRefresh = true; break; } @@ -1147,9 +1150,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #getResponseTimestamp(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date { let header: string | undefined; if (isRestError(response)) { - header = response.response?.headers.get(TIMESTAMP_HEADER) ?? undefined; + header = response.response?.headers?.get(TIMESTAMP_HEADER) ?? undefined; } else { - header = response._response.headers.get(TIMESTAMP_HEADER) ?? undefined; + header = response._response.headers?.get(TIMESTAMP_HEADER) ?? undefined; } return header ? new Date(header) : new Date(); } diff --git a/test/cdn.test.ts b/test/cdn.test.ts new file mode 100644 index 00000000..b7b8723e --- /dev/null +++ b/test/cdn.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { AppConfigurationClient } from "@azure/app-configuration"; +import { loadFromAzureFrontDoor } from "../src/index.js"; +import { createMockedKeyValue, createMockedFeatureFlag, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs, MAX_TIME_OUT } from "./utils/testHelper.js"; +import { TIMESTAMP_HEADER } from "../src/cdn/constants.js"; + +function createTimestampHeaders(timestamp: string | Date) { + const value = timestamp instanceof Date ? timestamp.toUTCString() : new Date(timestamp).toUTCString(); + return { + get: (name: string) => name.toLowerCase() === TIMESTAMP_HEADER ? value : undefined + }; +} + +describe("loadFromAzureFrontDoor", function() { + this.timeout(MAX_TIME_OUT); + + afterEach(() => { + restoreMocks(); + }); + + it("should load key-values and feature flags", async () => { + const kv1 = createMockedKeyValue({ key: "app.color", value: "red" }); + const kv2 = createMockedKeyValue({ key: "app.size", value: "large" }); + const ff = createMockedFeatureFlag("Beta"); + + const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + + stub.onCall(0).returns(getCachedIterator([ + { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + stub.onCall(1).returns(getCachedIterator([ + { items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + + const endpoint = createMockedAzureFrontDoorEndpoint(); + const appConfig = await loadFromAzureFrontDoor(endpoint, { + selectors: [{ keyFilter: "app.*" }], + featureFlagOptions: { + enabled: true + } + }); + + expect(appConfig.get("app.color")).to.equal("red"); + expect(appConfig.get("app.size")).to.equal("large"); + expect((appConfig.get("feature_management").feature_flags as any[]).find(ff => ff.id === "Beta")).not.undefined; + }); + + it("should refresh key-values if any page changes", async () => { + const kv1 = createMockedKeyValue({ key: "app.key1", value: "value1" }); + const kv2 = createMockedKeyValue({ key: "app.key2", value: "value2" }); + const kv2_updated = createMockedKeyValue({ key: "app.key2", value: "value2-updated" }); + const kv3 = createMockedKeyValue({ key: "app.key3", value: "value3" }); + + const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + + stub.onCall(0).returns(getCachedIterator([ + { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + + stub.onCall(1).returns(getCachedIterator([ + { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + stub.onCall(2).returns(getCachedIterator([ + { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + + stub.onCall(3).returns(getCachedIterator([ + { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + stub.onCall(4).returns(getCachedIterator([ + { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + + stub.onCall(5).returns(getCachedIterator([ + { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + stub.onCall(6).returns(getCachedIterator([ + { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + + const endpoint = createMockedAzureFrontDoorEndpoint(); + const appConfig = await loadFromAzureFrontDoor(endpoint, { + selectors: [{ keyFilter: "app.*" }], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000 + } + }); + + expect(appConfig.get("app.key1")).to.equal("value1"); + expect(appConfig.get("app.key2")).to.equal("value2"); + + await sleepInMs(1000); + await appConfig.refresh(); + + expect(appConfig.get("app.key2")).to.equal("value2-updated"); + + await sleepInMs(1000); + await appConfig.refresh(); + + expect(appConfig.get("app.key2")).to.be.undefined; + + await sleepInMs(1000); + await appConfig.refresh(); + + expect(appConfig.get("app.key3")).to.equal("value3"); + }); + + it("should refresh feature flags if any page changes", async () => { + const ff = createMockedFeatureFlag("Beta"); + const ff_updated = createMockedFeatureFlag("Beta", { enabled: false }); + + const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + + stub.onCall(0).returns(getCachedIterator([ + { items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + stub.onCall(1).returns(getCachedIterator([ + { items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + + stub.onCall(2).returns(getCachedIterator([ + { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + stub.onCall(3).returns(getCachedIterator([ + { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + + const endpoint = createMockedAzureFrontDoorEndpoint(); + const appConfig = await loadFromAzureFrontDoor(endpoint, { + featureFlagOptions: { + enabled: true, + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + let featureFlags = appConfig.get("feature_management").feature_flags; + expect(featureFlags[0].id).to.equal("Beta"); + expect(featureFlags[0].enabled).to.equal(true); + + await sleepInMs(1000); + await appConfig.refresh(); + + featureFlags = appConfig.get("feature_management").feature_flags; + expect(featureFlags[0].id).to.equal("Beta"); + expect(featureFlags[0].enabled).to.equal(false); + }); + + it("should keep refreshing key value until cache expires", async () => { + const sentinel = createMockedKeyValue({ key: "sentinel", value: "initial value" }); + const sentinel_updated = createMockedKeyValue({ key: "sentinel", value: "updated value" }); + const kv1 = createMockedKeyValue({ key: "app.key1", value: "value1" }); + const kv2 = createMockedKeyValue({ key: "app.key2", value: "value2" }); + const kv2_updated = createMockedKeyValue({ key: "app.key2", value: "value2-updated" }); + + const getStub = sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting"); + const listStub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + + getStub.onCall(0).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:00Z") }, ...sentinel } as any)); + getStub.onCall(1).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); + getStub.onCall(2).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); + + getStub.onCall(3).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); + getStub.onCall(4).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); + + listStub.onCall(0).returns(getCachedIterator([ + { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + ])); + listStub.onCall(1).returns(getCachedIterator([ + { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } // cache has not expired + ])); + listStub.onCall(2).returns(getCachedIterator([ + { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } // cache has expired + ])); + + const endpoint = createMockedAzureFrontDoorEndpoint(); + const appConfig = await loadFromAzureFrontDoor(endpoint, { + selectors: [{ keyFilter: "app.*" }], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000, + watchedSettings: [ + { key: "sentinel" } + ] + } + }); + + expect(appConfig.get("app.key2")).to.equal("value2"); + + await sleepInMs(1000); + await appConfig.refresh(); + + // cdn cache hasn't expired, even if the sentinel key changed, key2 should still return the old value + expect(appConfig.get("app.key2")).to.equal("value2"); + + await sleepInMs(1000); + await appConfig.refresh(); + + // cdn cache has expired, key2 should return the updated value even if sentinel remains the same + expect(appConfig.get("app.key2")).to.equal("value2-updated"); + }); +}); +/* eslint-enable @typescript-eslint/no-unused-expressions */ diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 9d9cc93e..90d33fe9 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -102,6 +102,49 @@ function getMockedIterator(pages: ConfigurationSetting[][], kvs: ConfigurationSe return mockIterator as any; } +function getCachedIterator(pages: Array<{ + items: ConfigurationSetting[]; + response?: any; +}>) { + const iterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + }, + next() { + while (pages.length > 0) { + pages.shift(); + } + if (pages.length === 0) { + return Promise.resolve({ done: true, value: undefined }); + } + const value = pages[0].items.shift(); + return Promise.resolve({ done: !value, value }); + }, + byPage(): AsyncIterableIterator { + return { + [Symbol.asyncIterator](): AsyncIterableIterator { return this; }, + next() { + const page = pages.shift(); + if (!page) { + return Promise.resolve({ done: true, value: undefined }); + } + const etag = _sha256(JSON.stringify(page.items)); + + return Promise.resolve({ + done: false, + value: { + items: page.items, + etag, + _response: page.response + } + }); + } + }; + } + }; + return iterator as any; +} + /** * Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting. * E.g. @@ -232,6 +275,8 @@ function restoreMocks() { const createMockedEndpoint = (name = "azure") => `https://${name}.azconfig.io`; +const createMockedAzureFrontDoorEndpoint = (name = "appconfig") => `https://${name}.b01.azurefd.net`; + const createMockedConnectionString = (endpoint = createMockedEndpoint(), secret = "secret", id = "b1d9b31") => { const toEncodeAsBytes = Buffer.from(secret); const returnValue = toEncodeAsBytes.toString("base64"); @@ -313,9 +358,11 @@ export { mockAppConfigurationClientLoadBalanceMode, mockConfigurationManagerGetClients, mockSecretClientGetSecret, + getCachedIterator, restoreMocks, createMockedEndpoint, + createMockedAzureFrontDoorEndpoint, createMockedConnectionString, createMockedTokenCredential, createMockedKeyVaultReference, From 8d06357da54d9e913dad09c9699397398d25d15d Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 8 Sep 2025 10:50:32 +0800 Subject: [PATCH 03/21] fix bug --- src/appConfigurationImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index d7af6e5e..2c3ff5e6 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -1152,7 +1152,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (isRestError(response)) { header = response.response?.headers?.get(TIMESTAMP_HEADER) ?? undefined; } else { - header = response._response.headers?.get(TIMESTAMP_HEADER) ?? undefined; + header = response._response?.headers?.get(TIMESTAMP_HEADER) ?? undefined; } return header ? new Date(header) : new Date(); } From 3310171b124a37c7f311eb440298df3703ebaf11 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 8 Sep 2025 13:13:09 +0800 Subject: [PATCH 04/21] add more test --- test/cdn.test.ts | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/test/cdn.test.ts b/test/cdn.test.ts index b7b8723e..daf1231a 100644 --- a/test/cdn.test.ts +++ b/test/cdn.test.ts @@ -9,7 +9,7 @@ const expect = chai.expect; import { AppConfigurationClient } from "@azure/app-configuration"; import { loadFromAzureFrontDoor } from "../src/index.js"; -import { createMockedKeyValue, createMockedFeatureFlag, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs, MAX_TIME_OUT } from "./utils/testHelper.js"; +import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs, MAX_TIME_OUT } from "./utils/testHelper.js"; import { TIMESTAMP_HEADER } from "../src/cdn/constants.js"; function createTimestampHeaders(timestamp: string | Date) { @@ -26,6 +26,35 @@ describe("loadFromAzureFrontDoor", function() { restoreMocks(); }); + it("should not include authorization headers", async () => { + const headerPolicy = new HttpRequestHeadersPolicy(); + const position: "perCall" | "perRetry" = "perCall"; + const clientOptions = { + retryOptions: { + maxRetries: 0 // save time + }, + additionalPolicies: [{ + policy: headerPolicy, + position + }] + }; + + const endpoint = createMockedAzureFrontDoorEndpoint(); + try { + await loadFromAzureFrontDoor(endpoint, { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch { /* empty */ } + + expect(headerPolicy.headers).not.undefined; + expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); + expect(headerPolicy.headers.get("authorization")).to.be.undefined; + expect(headerPolicy.headers.get("Authorization")).to.be.undefined; + }); + it("should load key-values and feature flags", async () => { const kv1 = createMockedKeyValue({ key: "app.color", value: "red" }); const kv2 = createMockedKeyValue({ key: "app.size", value: "large" }); @@ -211,4 +240,4 @@ describe("loadFromAzureFrontDoor", function() { expect(appConfig.get("app.key2")).to.equal("value2-updated"); }); }); -/* eslint-enable @typescript-eslint/no-unused-expressions */ +/* eslint-ensable @typescript-eslint/no-unused-expressions */ From 9af1749c2c4502d23fc482ad0c0c16dba494c9bc Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 8 Sep 2025 13:58:52 +0800 Subject: [PATCH 05/21] add browser test --- vitest.browser.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 011e0f93..9d5deb07 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ { browser: "chromium" }, ], }, - include: ["out/esm/test/load.test.js", "out/esm/test/refresh.test.js", "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js"], + include: ["out/esm/test/load.test.js", "out/esm/test/refresh.test.js", "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js", "out/esm/test/cdn.test.js"], testTimeout: 200_000, hookTimeout: 200_000, reporters: "default", From f97ae3dbeb3dc37a4b7e1883d51714374afe5a74 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 8 Sep 2025 14:22:37 +0800 Subject: [PATCH 06/21] update --- test/cdn.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/cdn.test.ts b/test/cdn.test.ts index daf1231a..bfeef8c5 100644 --- a/test/cdn.test.ts +++ b/test/cdn.test.ts @@ -9,7 +9,7 @@ const expect = chai.expect; import { AppConfigurationClient } from "@azure/app-configuration"; import { loadFromAzureFrontDoor } from "../src/index.js"; -import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs, MAX_TIME_OUT } from "./utils/testHelper.js"; +import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; import { TIMESTAMP_HEADER } from "../src/cdn/constants.js"; function createTimestampHeaders(timestamp: string | Date) { @@ -20,7 +20,6 @@ function createTimestampHeaders(timestamp: string | Date) { } describe("loadFromAzureFrontDoor", function() { - this.timeout(MAX_TIME_OUT); afterEach(() => { restoreMocks(); From 264113db532bd114a9d02c7689b57c6a6641148c Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 8 Sep 2025 14:38:04 +0800 Subject: [PATCH 07/21] update --- test/cdn.test.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/test/cdn.test.ts b/test/cdn.test.ts index bfeef8c5..39f20fc3 100644 --- a/test/cdn.test.ts +++ b/test/cdn.test.ts @@ -94,24 +94,24 @@ describe("loadFromAzureFrontDoor", function() { ])); stub.onCall(1).returns(getCachedIterator([ - { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:01Z") } } ])); stub.onCall(2).returns(getCachedIterator([ - { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:01Z") } } ])); stub.onCall(3).returns(getCachedIterator([ - { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); stub.onCall(4).returns(getCachedIterator([ - { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); stub.onCall(5).returns(getCachedIterator([ - { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:05Z") } } ])); stub.onCall(6).returns(getCachedIterator([ - { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:05Z") } } ])); const endpoint = createMockedAzureFrontDoorEndpoint(); @@ -121,23 +121,23 @@ describe("loadFromAzureFrontDoor", function() { enabled: true, refreshIntervalInMs: 1000 } - }); + }); // 1 call listConfigurationSettings expect(appConfig.get("app.key1")).to.equal("value1"); expect(appConfig.get("app.key2")).to.equal("value2"); - await sleepInMs(1000); - await appConfig.refresh(); + await sleepInMs(1500); // key2 updated + await appConfig.refresh(); // 1 call listConfigurationSettings for watching changes and 1 call for reloading expect(appConfig.get("app.key2")).to.equal("value2-updated"); - await sleepInMs(1000); - await appConfig.refresh(); + await sleepInMs(1500); // key2 deleted + await appConfig.refresh(); // 1 call listConfigurationSettings for watching changes and 1 call for reloading expect(appConfig.get("app.key2")).to.be.undefined; - await sleepInMs(1000); - await appConfig.refresh(); + await sleepInMs(1500); // key3 added + await appConfig.refresh(); // 1 call listConfigurationSettings for watching changes and 1 call for reloading expect(appConfig.get("app.key3")).to.equal("value3"); }); @@ -177,7 +177,7 @@ describe("loadFromAzureFrontDoor", function() { expect(featureFlags[0].id).to.equal("Beta"); expect(featureFlags[0].enabled).to.equal(true); - await sleepInMs(1000); + await sleepInMs(1500); await appConfig.refresh(); featureFlags = appConfig.get("feature_management").feature_flags; @@ -226,13 +226,13 @@ describe("loadFromAzureFrontDoor", function() { expect(appConfig.get("app.key2")).to.equal("value2"); - await sleepInMs(1000); + await sleepInMs(1500); await appConfig.refresh(); // cdn cache hasn't expired, even if the sentinel key changed, key2 should still return the old value expect(appConfig.get("app.key2")).to.equal("value2"); - await sleepInMs(1000); + await sleepInMs(1500); await appConfig.refresh(); // cdn cache has expired, key2 should return the updated value even if sentinel remains the same From 58d1f2e7e5524d7f12db2021bb1d8161ee135778 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 8 Sep 2025 16:38:11 +0800 Subject: [PATCH 08/21] fix test --- test/cdn.test.ts | 11 ++++++++++- test/requestTracing.test.ts | 11 ++++++++++- vitest.browser.config.ts | 11 +++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/test/cdn.test.ts b/test/cdn.test.ts index 39f20fc3..ac87a420 100644 --- a/test/cdn.test.ts +++ b/test/cdn.test.ts @@ -11,6 +11,7 @@ import { AppConfigurationClient } from "@azure/app-configuration"; import { loadFromAzureFrontDoor } from "../src/index.js"; import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; import { TIMESTAMP_HEADER } from "../src/cdn/constants.js"; +import { isBrowser } from "../src/requestTracing/utils.js"; function createTimestampHeaders(timestamp: string | Date) { const value = timestamp instanceof Date ? timestamp.toUTCString() : new Date(timestamp).toUTCString(); @@ -49,7 +50,15 @@ describe("loadFromAzureFrontDoor", function() { } catch { /* empty */ } expect(headerPolicy.headers).not.undefined; - expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); + let userAgent; + // https://github.com/Azure/azure-sdk-for-js/pull/6528 + if (isBrowser()) { + userAgent = headerPolicy.headers.get("x-ms-useragent"); + } else { + userAgent = headerPolicy.headers.get("User-Agent"); + } + + expect(userAgent).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); expect(headerPolicy.headers.get("authorization")).to.be.undefined; expect(headerPolicy.headers.get("Authorization")).to.be.undefined; }); diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 37175152..688c548a 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -9,6 +9,7 @@ const expect = chai.expect; import { HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; import { ConfigurationClientManager } from "../src/configurationClientManager.js"; import { load, loadFromAzureFrontDoor } from "../src/index.js"; +import { isBrowser } from "../src/requestTracing/utils.js"; const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; @@ -43,7 +44,15 @@ describe("request tracing", function () { }); } catch { /* empty */ } expect(headerPolicy.headers).not.undefined; - expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); + let userAgent; + // https://github.com/Azure/azure-sdk-for-js/pull/6528 + if (isBrowser()) { + userAgent = headerPolicy.headers.get("x-ms-useragent"); + } else { + userAgent = headerPolicy.headers.get("User-Agent"); + } + + expect(userAgent).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); }); it("should have request type in correlation-context header", async () => { diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 9d5deb07..ba52f4ed 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -10,7 +10,14 @@ export default defineConfig({ { browser: "chromium" }, ], }, - include: ["out/esm/test/load.test.js", "out/esm/test/refresh.test.js", "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js", "out/esm/test/cdn.test.js"], + include: [ + "out/esm/test/load.test.js", + "out/esm/test/refresh.test.js", + "out/esm/test/featureFlag.test.js", + "out/esm/test/json.test.js", + "out/esm/test/startup.test.js", + "out/esm/test/cdn.test.js" + ], testTimeout: 200_000, hookTimeout: 200_000, reporters: "default", @@ -18,4 +25,4 @@ export default defineConfig({ // Provide Mocha-style hooks as globals setupFiles: ["./vitest.setup.mjs"], }, -}); \ No newline at end of file +}); From 725dd6350daf57a39f08bba83f901bed85a54623 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 9 Sep 2025 13:23:24 +0800 Subject: [PATCH 09/21] remove sync-token header --- package.json | 2 +- src/cdn/cdnRequestPipelinePolicy.ts | 15 ++++++++++ src/load.ts | 6 ++-- test/cdn.test.ts | 45 +++++++++++++++++++---------- 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index f3b21a75..ec38d240 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev": "rollup --config --watch", "lint": "eslint src/ test/ examples/ --ext .js,.ts,.mjs", "fix-lint": "eslint src/ test/ examples/ --fix --ext .js,.ts,.mjs", - "test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel --timeout 100000", + "test": "mocha out/esm/test/cdn.test.js out/commonjs/test/cdn.test.js --parallel --timeout 100000", "test-browser": "npx playwright install chromium && vitest --config vitest.browser.config.ts run" }, "repository": { diff --git a/src/cdn/cdnRequestPipelinePolicy.ts b/src/cdn/cdnRequestPipelinePolicy.ts index 34c9c8d3..08ca0340 100644 --- a/src/cdn/cdnRequestPipelinePolicy.ts +++ b/src/cdn/cdnRequestPipelinePolicy.ts @@ -18,3 +18,18 @@ export class AnonymousRequestPipelinePolicy implements PipelinePolicy { return next(request); } } + +/** + * The pipeline policy that remove the "sync-token" header from the request. + * The policy position should be perRetry. It should be executed after the SyncTokenPolicy in @azure/app-configuration, which is executed after retry phase: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts#L198 + */ +export class RemoveSyncTokenPipelinePolicy implements PipelinePolicy { + name: string = "AppConfigurationRemoveSyncTokenPolicy"; + + async sendRequest(request, next) { + if (request.headers.has("sync-token")) { + request.headers.delete("sync-token"); + } + return next(request); + } +} diff --git a/src/load.ts b/src/load.ts index d826c27c..f3ec84b0 100644 --- a/src/load.ts +++ b/src/load.ts @@ -6,7 +6,7 @@ import { AzureAppConfiguration } from "./appConfiguration.js"; import { AzureAppConfigurationImpl } from "./appConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js"; import { ConfigurationClientManager } from "./configurationClientManager.js"; -import { AnonymousRequestPipelinePolicy } from "./cdn/cdnRequestPipelinePolicy.js"; +import { AnonymousRequestPipelinePolicy, RemoveSyncTokenPipelinePolicy } from "./cdn/cdnRequestPipelinePolicy.js"; import { instanceOfTokenCredential } from "./common/utils.js"; import { ArgumentError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; @@ -87,10 +87,10 @@ export async function loadFromAzureFrontDoor( appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, - // Add etag url policy to append etag to the request url for breaking CDN cache additionalPolicies: [ ...(appConfigOptions.clientOptions?.additionalPolicies || []), - { policy: new AnonymousRequestPipelinePolicy(), position: "perRetry" } + { policy: new AnonymousRequestPipelinePolicy(), position: "perRetry" }, + { policy: new RemoveSyncTokenPipelinePolicy(), position: "perRetry" } ] }; diff --git a/test/cdn.test.ts b/test/cdn.test.ts index ac87a420..267fd81c 100644 --- a/test/cdn.test.ts +++ b/test/cdn.test.ts @@ -8,8 +8,8 @@ chai.use(chaiAsPromised); const expect = chai.expect; import { AppConfigurationClient } from "@azure/app-configuration"; -import { loadFromAzureFrontDoor } from "../src/index.js"; -import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; +import { load, loadFromAzureFrontDoor } from "../src/index.js"; +import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedConnectionString, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; import { TIMESTAMP_HEADER } from "../src/cdn/constants.js"; import { isBrowser } from "../src/requestTracing/utils.js"; @@ -26,22 +26,39 @@ describe("loadFromAzureFrontDoor", function() { restoreMocks(); }); - it("should not include authorization headers", async () => { + it("should not include authorization and sync-token header when loading from Azure Front Door", async () => { const headerPolicy = new HttpRequestHeadersPolicy(); const position: "perCall" | "perRetry" = "perCall"; const clientOptions = { retryOptions: { - maxRetries: 0 // save time + maxRetries: 0 }, additionalPolicies: [{ policy: headerPolicy, position - }] + }], + syncTokens: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addSyncTokenFromHeaderValue: (syncTokenHeaderValue) => {}, + getSyncTokenHeaderValue: () => { return "mockedSyncToken"; } + } }; - const endpoint = createMockedAzureFrontDoorEndpoint(); try { - await loadFromAzureFrontDoor(endpoint, { + await load(createMockedConnectionString(), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch { /* empty */ } + + expect(headerPolicy.headers).not.undefined; + expect(headerPolicy.headers.get("Authorization")).not.undefined; + expect(headerPolicy.headers.get("Sync-Token")).to.equal("mockedSyncToken"); + + try { + await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { clientOptions, startupOptions: { timeoutInMs: 1 @@ -59,8 +76,8 @@ describe("loadFromAzureFrontDoor", function() { } expect(userAgent).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); - expect(headerPolicy.headers.get("authorization")).to.be.undefined; expect(headerPolicy.headers.get("Authorization")).to.be.undefined; + expect(headerPolicy.headers.get("Sync-Token")).to.be.undefined; }); it("should load key-values and feature flags", async () => { @@ -77,8 +94,7 @@ describe("loadFromAzureFrontDoor", function() { { items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } ])); - const endpoint = createMockedAzureFrontDoorEndpoint(); - const appConfig = await loadFromAzureFrontDoor(endpoint, { + const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { selectors: [{ keyFilter: "app.*" }], featureFlagOptions: { enabled: true @@ -123,8 +139,7 @@ describe("loadFromAzureFrontDoor", function() { { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:05Z") } } ])); - const endpoint = createMockedAzureFrontDoorEndpoint(); - const appConfig = await loadFromAzureFrontDoor(endpoint, { + const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { selectors: [{ keyFilter: "app.*" }], refreshOptions: { enabled: true, @@ -171,8 +186,7 @@ describe("loadFromAzureFrontDoor", function() { { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } ])); - const endpoint = createMockedAzureFrontDoorEndpoint(); - const appConfig = await loadFromAzureFrontDoor(endpoint, { + const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { featureFlagOptions: { enabled: true, refresh: { @@ -221,8 +235,7 @@ describe("loadFromAzureFrontDoor", function() { { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } // cache has expired ])); - const endpoint = createMockedAzureFrontDoorEndpoint(); - const appConfig = await loadFromAzureFrontDoor(endpoint, { + const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { selectors: [{ keyFilter: "app.*" }], refreshOptions: { enabled: true, From 665af4cc4e9e844ccbb74b68d198497d75447f9d Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 9 Sep 2025 17:18:23 +0800 Subject: [PATCH 10/21] wip --- src/appConfigurationImpl.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 99a08730..c5f605d4 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -795,6 +795,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let i = 0; for await (const page of pageIterator) { + // when conditional request is sent, the response will be 304 if not changed if (i >= selector.pageEtags.length || // new page (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed const timestamp = this.#getResponseTimestamp(page); From 50872061f801ea32e2aa5a2ef7038bc1c00879e3 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 10 Sep 2025 17:52:19 +0800 Subject: [PATCH 11/21] update --- src/appConfigurationImpl.ts | 186 ++++++++++++++++++---------------- src/refresh/refreshOptions.ts | 2 +- src/types.ts | 28 ++++- src/watchedSetting.ts | 18 ---- test/cdn.test.ts | 20 +++- test/refresh.test.ts | 32 +++--- test/requestTracing.test.ts | 13 ++- 7 files changed, 171 insertions(+), 128 deletions(-) delete mode 100644 src/watchedSetting.ts diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index d172670d..3cf41c4b 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -62,7 +62,7 @@ import { } from "./requestTracing/utils.js"; import { FeatureFlagTracingOptions } from "./requestTracing/featureFlagTracingOptions.js"; import { AIConfigurationTracingOptions } from "./requestTracing/aiConfigurationTracingOptions.js"; -import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; +import { KeyFilter, LabelFilter, SettingWatcher, SettingSelector, PagedSettingsWatcher, WatchedSetting } from "./types.js"; import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js"; @@ -71,10 +71,6 @@ import { TIMESTAMP_HEADER } from "./cdn/constants.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds -type PagedSettingSelector = SettingSelector & { - pageEtags?: string[]; -}; - export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Hosting key-value pairs in the configuration store. @@ -104,20 +100,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Aka watched settings. */ #refreshEnabled: boolean = false; - #sentinels: ConfigurationSettingId[] = []; + #sentinels: Map = new Map(); #watchAll: boolean = false; #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #kvRefreshTimer: RefreshTimer; - #lastKvChangeDetected: Date = new Date(0); - #kvRefreshIncompleted: boolean = false; + #lastKvChangeDetectedTime: Date = new Date(0); + #isKvStale: boolean = false; // Feature flags #featureFlagEnabled: boolean = false; #featureFlagRefreshEnabled: boolean = false; #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #ffRefreshTimer: RefreshTimer; - #lastFfChangeDetected: Date = new Date(0); - #ffRefreshIncompleted: boolean = false; + #lastFfChangeDetectedTime: Date = new Date(0); + #isFfStale: boolean = false; // Key Vault references #secretRefreshEnabled: boolean = false; @@ -128,11 +124,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors */ - #kvSelectors: PagedSettingSelector[] = []; + #kvSelectors: PagedSettingsWatcher[] = []; /** * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors */ - #ffSelectors: PagedSettingSelector[] = []; + #ffSelectors: PagedSettingsWatcher[] = []; // Load balancing #lastSuccessfulEndpoint: string = ""; @@ -176,7 +172,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (setting.label?.includes("*") || setting.label?.includes(",")) { throw new ArgumentError(ErrorMessages.INVALID_WATCHED_SETTINGS_LABEL); } - this.#sentinels.push(setting); + this.#sentinels.set(setting, { etag: undefined, timestamp: new Date(0) }); } } @@ -406,7 +402,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let postAttempts = 0; do { // at least try to load once try { - await this.#loadSelectedAndWatchedKeyValues(); + if (this.#refreshEnabled && !this.#watchAll) { + await this.#loadWatchedSettings(); + } + + await this.#loadSelectedKeyValues(); + if (this.#featureFlagEnabled) { await this.#loadFeatureFlags(); } @@ -500,17 +501,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * If false, loads key-value using the key-value selectors. Defaults to false. */ async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { - const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; + const selectors: PagedSettingsWatcher[] = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; const funcToExecute = async (client) => { // Use a Map to deduplicate configuration settings by key. When multiple selectors return settings with the same key, // the configuration setting loaded by the later selector in the iteration order will override the one from the earlier selector. const loadedSettings: Map = new Map(); // deep copy selectors to avoid modification if current client fails - const selectorsToUpdate = JSON.parse( + const selectorsToUpdate: PagedSettingsWatcher[] = JSON.parse( JSON.stringify(selectors) ); - let upToDate: boolean = true; for (const selector of selectorsToUpdate) { if (selector.snapshotName === undefined) { const listOptions: ListConfigurationSettingsOptions = { @@ -518,7 +518,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { labelFilter: selector.labelFilter, tagsFilter: selector.tagFilters }; - const pageEtags: string[] = []; + const pageWatchers: SettingWatcher[] = []; const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, client, @@ -526,7 +526,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ).byPage(); for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); + pageWatchers.push({etag: page.etag, timestamp: this.#getResponseTimestamp(page)}); for (const setting of page.items) { if (loadFeatureFlag === isFeatureFlag(setting)) { loadedSettings.set(setting.key, setting); @@ -535,13 +535,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const timestamp = this.#getResponseTimestamp(page); // all pages must be later than last change detected to be considered up-to-date if (loadFeatureFlag) { - upToDate &&= (timestamp > this.#lastFfChangeDetected); + this.#isFfStale = timestamp < this.#lastFfChangeDetectedTime; + if (this.#isFfStale) { + return []; + } } else { - const temp = this.#lastKvChangeDetected; - upToDate &&= (timestamp > temp); + this.#isKvStale = timestamp < this.#lastKvChangeDetectedTime; + if (this.#isKvStale) { + return []; + } } } - selector.pageEtags = pageEtags; + selector.pageWatchers = pageWatchers; } else { // snapshot selector const snapshot = await this.#getSnapshot(selector.snapshotName); if (snapshot === undefined) { @@ -568,10 +573,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (loadFeatureFlag) { this.#ffSelectors = selectorsToUpdate; - this.#ffRefreshIncompleted = !upToDate; } else { this.#kvSelectors = selectorsToUpdate; - this.#kvRefreshIncompleted = !upToDate; } return Array.from(loadedSettings.values()); }; @@ -580,14 +583,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. + * Loads selected key-values from App Configuration to the local configuration. */ - async #loadSelectedAndWatchedKeyValues() { + async #loadSelectedKeyValues() { this.#secretReferences = []; // clear all cached key vault reference configuration settings const keyValues: [key: string, value: unknown][] = []; const loadedSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(); - if (this.#refreshEnabled && !this.#watchAll) { - await this.#updateWatchedKeyValuesEtag(loadedSettings); + + if (loadedSettings.length === 0) { + return; } if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { @@ -618,22 +622,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Updates etag of watched settings from loaded data. If a watched setting is not covered by any selector, a request will be sent to retrieve it. + * Loads watched settings (sentinels) for refresh from App Configuration to the local configuration. */ - async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { - const updatedSentinels: ConfigurationSettingId[] = []; - for (const sentinel of this.#sentinels) { - const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); - if (matchedSetting) { - updatedSentinels.push( {...sentinel, etag: matchedSetting.etag} ); - } else { - // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing - const { key, label } = sentinel; - const response = await this.#getConfigurationSetting({ key, label }, { onlyIfChanged: false }); - updatedSentinels.push( {...sentinel, etag: isRestError(response) ? undefined : response.etag} ); - } + async #loadWatchedSettings(): Promise { + for (const watchedSetting of this.#sentinels.keys()) { + const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label }; + const response = await this.#getConfigurationSetting(configurationSettingId, { onlyIfChanged: false }); + this.#sentinels.set(watchedSetting, { + etag: isRestError(response) ? undefined : response.etag, + timestamp: this.#getResponseTimestamp(response) + }); } - this.#sentinels = updatedSentinels; } /** @@ -680,43 +679,54 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // try refresh if any of watched settings is changed. let needRefresh = false; - if (this.#watchAll) { - needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); + let changedSentinel; + let changedSentinelWatcher; + if (this.#isKvStale) { + needRefresh = true; + // skip checking changes } else { - const getOptions: GetConfigurationSettingOptions = { - // send conditional request only when CDN is not used - onlyIfChanged: !this.#isCdnUsed - }; - for (const sentinel of this.#sentinels.values()) { - const response: GetConfigurationSettingResponse | RestError = - await this.#getConfigurationSetting(sentinel, getOptions); - - const isDeleted = isRestError(response) && sentinel.etag !== undefined; // previously existed, now deleted - const isChanged = - !isRestError(response) && - response.statusCode === 200 && - sentinel.etag !== response.etag; // etag changed + if (this.#watchAll) { + needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); + } else { + const getOptions: GetConfigurationSettingOptions = { + // send conditional request only when CDN is not used + onlyIfChanged: !this.#isCdnUsed + }; + for (const watchedSetting of this.#sentinels.keys()) { + const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label }; + const response: GetConfigurationSettingResponse | RestError = + await this.#getConfigurationSetting(configurationSettingId, getOptions); - if (isDeleted || isChanged) { const timestamp = this.#getResponseTimestamp(response); - if (timestamp > this.#lastKvChangeDetected) { - this.#lastKvChangeDetected = timestamp; + const watcher = this.#sentinels.get(watchedSetting); + const isUpToDate = watcher?.timestamp ? timestamp > watcher.timestamp : true; + const isDeleted = isRestError(response) && watcher?.etag !== undefined; // previously existed, now deleted + const isChanged = + !isRestError(response) && + response.statusCode === 200 && + watcher?.etag !== response.etag; // etag changed + + if (isUpToDate && (isDeleted || isChanged)) { + this.#lastKvChangeDetectedTime = timestamp; + changedSentinel = watchedSetting; + changedSentinelWatcher = watcher; + needRefresh = true; + break; } - needRefresh = true; - break; } } } - if (needRefresh || this.#kvRefreshIncompleted) { + if (needRefresh) { for (const adapter of this.#adapters) { await adapter.onChangeDetected(); } - await this.#loadSelectedAndWatchedKeyValues(); + await this.#loadSelectedKeyValues(); + this.#sentinels.set(changedSentinel, changedSentinelWatcher); // update the changed sentinel's watcher } this.#kvRefreshTimer.reset(); - return Promise.resolve(needRefresh); + return Promise.resolve(needRefresh && !this.#isKvStale); } /** @@ -729,14 +739,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } - const refreshFeatureFlag = true; - const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors, refreshFeatureFlag); - if (needRefresh || this.#ffRefreshIncompleted) { + let needRefresh = false; + if (this.#isFfStale) { + needRefresh = true; + // skip checking changes + } else { + const refreshFeatureFlag = true; + needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors, refreshFeatureFlag); + } + + if (needRefresh) { await this.#loadFeatureFlags(); } this.#ffRefreshTimer.reset(); - return Promise.resolve(needRefresh); + return Promise.resolve(needRefresh && !this.#isFfStale); } async #refreshSecrets(): Promise { @@ -763,7 +780,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * @param selectors - The @see PagedSettingSelector of the kev-value collection. * @returns true if key-value collection has changed, false otherwise. */ - async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[], refreshFeatureFlag: boolean = false): Promise { + async #checkConfigurationSettingsChange(selectors: PagedSettingsWatcher[], refreshFeatureFlag: boolean = false): Promise { const funcToExecute = async (client) => { for (const selector of selectors) { if (selector.snapshotName) { // skip snapshot selector @@ -775,9 +792,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { tagsFilter: selector.tagFilters }; + const pageWatchers: SettingWatcher[] = selector.pageWatchers ?? []; + if (!this.#isCdnUsed) { // if CDN is not used, add page etags to the listOptions to send conditional request - listOptions.pageEtags = selector.pageEtags; + listOptions.pageEtags = pageWatchers.map(w => w.etag ?? "") ; } const pageIterator = listConfigurationSettingsWithTrace( @@ -786,24 +805,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { listOptions ).byPage(); - if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { - return true; // no etag is retrieved from previous request, always refresh - } - let i = 0; for await (const page of pageIterator) { + const timestamp = this.#getResponseTimestamp(page); // when conditional request is sent, the response will be 304 if not changed - if (i >= selector.pageEtags.length || // new page - (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed - const timestamp = this.#getResponseTimestamp(page); + if (i >= pageWatchers.length || // new page + (timestamp > pageWatchers[i].timestamp && // up to date + page._response.status === 200 && // page changed + page.etag !== pageWatchers[i].etag)) { if (refreshFeatureFlag) { - if (timestamp > this.#lastFfChangeDetected) { - this.#lastFfChangeDetected = timestamp; - } + this.#lastFfChangeDetectedTime = timestamp; } else { - if (timestamp > this.#lastKvChangeDetected) { - this.#lastKvChangeDetected = timestamp; - } + this.#lastKvChangeDetectedTime = timestamp; } return true; } @@ -1145,6 +1158,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + /** + * Extracts the response timestamp from the headers. If not found, returns the current time. + */ #getResponseTimestamp(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date { let header: string | undefined; if (isRestError(response)) { diff --git a/src/refresh/refreshOptions.ts b/src/refresh/refreshOptions.ts index d08d88d6..1dfaf4ee 100644 --- a/src/refresh/refreshOptions.ts +++ b/src/refresh/refreshOptions.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { WatchedSetting } from "../watchedSetting.js"; +import { WatchedSetting } from "../types.js"; export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30_000; export const MIN_REFRESH_INTERVAL_IN_MS = 1_000; diff --git a/src/types.ts b/src/types.ts index 21ce23f0..d2b75eff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { ConfigurationSettingId } from "@azure/app-configuration"; + /** * SettingSelector is used to select key-values from Azure App Configuration based on keys and labels. */ @@ -58,7 +60,7 @@ export enum KeyFilter { * Matches all key-values. */ Any = "*" -} +}; /** * LabelFilter is used to filter key-values based on labels. @@ -68,7 +70,7 @@ export enum LabelFilter { * Matches key-values without a label. */ Null = "\0" -} +}; /** * TagFilter is used to filter key-values based on tags. @@ -78,4 +80,26 @@ export enum TagFilter { * Represents empty tag value. */ Null = "" +}; + +export type WatchedSetting = { + /** + * The key for this setting. + */ + key: string; + + /** + * The label for this setting. + * Leaving this undefined means this setting does not have a label. + */ + label?: string; } + +export type SettingWatcher = { + etag?: string; + timestamp: Date; +} + +export type PagedSettingsWatcher = SettingSelector & { + pageWatchers?: SettingWatcher[] +}; diff --git a/src/watchedSetting.ts b/src/watchedSetting.ts deleted file mode 100644 index 6c05da3d..00000000 --- a/src/watchedSetting.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -/** - * Fields that uniquely identify a watched configuration setting. - */ -export interface WatchedSetting { - /** - * The key for this setting. - */ - key: string; - - /** - * The label for this setting. - * Leaving this undefined means this setting does not have a label. - */ - label?: string; -} diff --git a/test/cdn.test.ts b/test/cdn.test.ts index 267fd81c..bd9393e6 100644 --- a/test/cdn.test.ts +++ b/test/cdn.test.ts @@ -180,10 +180,10 @@ describe("loadFromAzureFrontDoor", function() { ])); stub.onCall(2).returns(getCachedIterator([ - { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); stub.onCall(3).returns(getCachedIterator([ - { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } + { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { @@ -209,6 +209,7 @@ describe("loadFromAzureFrontDoor", function() { }); it("should keep refreshing key value until cache expires", async () => { + let refreshSuccessfulCount = 0; const sentinel = createMockedKeyValue({ key: "sentinel", value: "initial value" }); const sentinel_updated = createMockedKeyValue({ key: "sentinel", value: "updated value" }); const kv1 = createMockedKeyValue({ key: "app.key1", value: "value1" }); @@ -221,9 +222,7 @@ describe("loadFromAzureFrontDoor", function() { getStub.onCall(0).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:00Z") }, ...sentinel } as any)); getStub.onCall(1).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); getStub.onCall(2).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); - - getStub.onCall(3).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); - getStub.onCall(4).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); + getStub.onCall(4).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:00Z") }, ...sentinel } as any)); // server old value from another cache server listStub.onCall(0).returns(getCachedIterator([ { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } @@ -246,6 +245,10 @@ describe("loadFromAzureFrontDoor", function() { } }); + appConfig.onRefresh(() => { + refreshSuccessfulCount++; + }); + expect(appConfig.get("app.key2")).to.equal("value2"); await sleepInMs(1500); @@ -253,12 +256,19 @@ describe("loadFromAzureFrontDoor", function() { // cdn cache hasn't expired, even if the sentinel key changed, key2 should still return the old value expect(appConfig.get("app.key2")).to.equal("value2"); + expect(refreshSuccessfulCount).to.equal(0); await sleepInMs(1500); await appConfig.refresh(); + expect(refreshSuccessfulCount).to.equal(1); // cdn cache has expired, key2 should return the updated value even if sentinel remains the same expect(appConfig.get("app.key2")).to.equal("value2-updated"); + + await sleepInMs(1500); + // even if the sentinel is different from previous value, but the timestamp is older, it should not trigger refresh + await appConfig.refresh(); + expect(refreshSuccessfulCount).to.equal(1); }); }); /* eslint-ensable @typescript-eslint/no-unused-expressions */ diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 6d4aea89..9b9f3aca 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -144,7 +144,7 @@ describe("dynamic refresh", function () { } }); expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); + expect(getKvRequestCount).eq(1); expect(settings).not.undefined; expect(settings.get("app.settings.fontColor")).eq("red"); expect(settings.get("app.settings.fontSize")).eq("40"); @@ -156,13 +156,13 @@ describe("dynamic refresh", function () { await settings.refresh(); expect(settings.get("app.settings.fontColor")).eq("red"); expect(listKvRequestCount).eq(1); // no more request should be sent during the refresh interval - expect(getKvRequestCount).eq(0); // no more request should be sent during the refresh interval + expect(getKvRequestCount).eq(1); // no more request should be sent during the refresh interval // after refreshInterval, should really refresh await sleepInMs(2 * 1000 + 1); await settings.refresh(); expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(1); + expect(getKvRequestCount).eq(2); expect(settings.get("app.settings.fontColor")).eq("blue"); }); @@ -178,7 +178,7 @@ describe("dynamic refresh", function () { } }); expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); + expect(getKvRequestCount).eq(1); expect(settings).not.undefined; expect(settings.get("app.settings.fontColor")).eq("red"); expect(settings.get("app.settings.fontSize")).eq("40"); @@ -192,7 +192,7 @@ describe("dynamic refresh", function () { await sleepInMs(2 * 1000 + 1); await settings.refresh(); expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(2); // one conditional request to detect change and one request as part of loading all kvs (because app.settings.fontColor doesn't exist in the response of listKv request) + expect(getKvRequestCount).eq(2); // one conditional request to detect change expect(settings.get("app.settings.fontColor")).eq(undefined); }); @@ -208,7 +208,7 @@ describe("dynamic refresh", function () { } }); expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); + expect(getKvRequestCount).eq(1); expect(settings).not.undefined; expect(settings.get("app.settings.fontColor")).eq("red"); expect(settings.get("app.settings.fontSize")).eq("40"); @@ -217,7 +217,7 @@ describe("dynamic refresh", function () { await sleepInMs(2 * 1000 + 1); await settings.refresh(); expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(1); + expect(getKvRequestCount).eq(2); expect(settings.get("app.settings.fontSize")).eq("40"); }); @@ -234,19 +234,19 @@ describe("dynamic refresh", function () { } }); expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); + expect(getKvRequestCount).eq(2); // two getKv requests for two watched settings expect(settings).not.undefined; expect(settings.get("app.settings.fontColor")).eq("red"); expect(settings.get("app.settings.fontSize")).eq("40"); // change setting addSetting("app.settings.bgColor", "white"); - updateSetting("app.settings.fontSize", "50"); + updateSetting("app.settings.fontColor", "blue"); await sleepInMs(2 * 1000 + 1); await settings.refresh(); expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(2); // two getKv request for two watched settings - expect(settings.get("app.settings.fontSize")).eq("50"); + expect(getKvRequestCount).eq(3); + expect(settings.get("app.settings.fontColor")).eq("blue"); expect(settings.get("app.settings.bgColor")).eq("white"); }); @@ -284,7 +284,7 @@ describe("dynamic refresh", function () { await sleepInMs(2 * 1000 + 1); await settings.refresh(); // should continue to refresh even if sentinel key doesn't change now expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(4); + expect(getKvRequestCount).eq(3); expect(settings.get("app.settings.bgColor")).eq("white"); }); @@ -370,7 +370,7 @@ describe("dynamic refresh", function () { } }); expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(1); // app.settings.bgColor doesn't exist in the response of listKv request, so an additional getKv request is made to get it. + expect(getKvRequestCount).eq(1); expect(settings).not.undefined; expect(settings.get("app.settings.fontColor")).eq("red"); expect(settings.get("app.settings.fontSize")).eq("40"); @@ -464,7 +464,7 @@ describe("dynamic refresh", function () { } }); expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); + expect(getKvRequestCount).eq(1); expect(settings).not.undefined; expect(settings.get("app.settings.fontColor")).eq("red"); @@ -477,12 +477,12 @@ describe("dynamic refresh", function () { settings.refresh(); // refresh "concurrently" } expect(listKvRequestCount).to.be.at.most(2); - expect(getKvRequestCount).to.be.at.most(1); + expect(getKvRequestCount).to.be.at.most(2); await sleepInMs(1000); // wait for all 5 refresh attempts to finish expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(1); + expect(getKvRequestCount).eq(2); expect(settings.get("app.settings.fontColor")).eq("blue"); }); diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 688c548a..5b80e27b 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -6,10 +6,12 @@ import * as chai from "chai"; import chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; +import { AppConfigurationClient } from "@azure/app-configuration"; import { HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; import { ConfigurationClientManager } from "../src/configurationClientManager.js"; import { load, loadFromAzureFrontDoor } from "../src/index.js"; import { isBrowser } from "../src/requestTracing/utils.js"; +import { get } from "http"; const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; @@ -211,16 +213,25 @@ describe("request tracing", function () { value: "red" }].map(createMockedKeyValue)]); + const sentinel = createMockedKeyValue({ key: "sentinel", value: "initial value" }); + const getStub = sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting"); + getStub.onCall(0).returns(Promise.resolve({ statusCode: 200, ...sentinel } as any)); + const settings = await load(createMockedConnectionString(fakeEndpoint), { clientOptions, refreshOptions: { enabled: true, refreshIntervalInMs: 1_000, watchedSettings: [{ - key: "app.settings.fontColor" + key: "sentinel" }] } }); + + expect(settings.get("app.settings.fontColor")).eq("red"); // initial load should succeed + // we only mocked getConfigurationSetting for initial load, so the watch request during refresh will still use the SDK's pipeline, then the headerPolicy can capture the headers + getStub.restore(); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); From 339b3fa06dd404ee62c5bc086153fad87b523ef6 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 10 Sep 2025 17:53:26 +0800 Subject: [PATCH 12/21] fix lint --- src/types.ts | 2 -- test/requestTracing.test.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/types.ts b/src/types.ts index d2b75eff..3cab34da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { ConfigurationSettingId } from "@azure/app-configuration"; - /** * SettingSelector is used to select key-values from Azure App Configuration based on keys and labels. */ diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 5b80e27b..6d007861 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -11,7 +11,6 @@ import { HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKey import { ConfigurationClientManager } from "../src/configurationClientManager.js"; import { load, loadFromAzureFrontDoor } from "../src/index.js"; import { isBrowser } from "../src/requestTracing/utils.js"; -import { get } from "http"; const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; From 4480562bab42058889388b1da343562a75abf19d Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 14 Oct 2025 16:21:51 +0800 Subject: [PATCH 13/21] update CDN tag --- src/appConfigurationImpl.ts | 2 +- src/requestTracing/constants.ts | 2 +- src/requestTracing/utils.ts | 10 +++++----- test/requestTracing.test.ts | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 21a446a3..e9074fbd 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -233,7 +233,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { featureFlagTracing: this.#featureFlagTracing, fmVersion: this.#fmVersion, aiConfigurationTracing: this.#aiConfigurationTracing, - isCdnUsed: this.#isCdnUsed + isAfdUsed: this.#isCdnUsed }; } diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 060cf8b3..6db765c4 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -51,7 +51,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount"; export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault"; export const FAILOVER_REQUEST_TAG = "Failover"; -export const CDN_USED_TAG = "CDN"; +export const AFD_USED_TAG = "AFD"; // Compact feature tags export const FEATURES_KEY = "Features"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 2a62f53c..1f0955ea 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -27,7 +27,7 @@ import { HostType, KEY_VAULT_CONFIGURED_TAG, KEY_VAULT_REFRESH_CONFIGURED_TAG, - CDN_USED_TAG, + AFD_USED_TAG, KUBERNETES_ENV_VAR, NODEJS_DEV_ENV_VAL, NODEJS_ENV_VAR, @@ -51,7 +51,7 @@ export interface RequestTracingOptions { initialLoadCompleted: boolean; replicaCount: number; isFailoverRequest: boolean; - isCdnUsed: boolean; + isAfdUsed: boolean; featureFlagTracing: FeatureFlagTracingOptions | undefined; fmVersion: string | undefined; aiConfigurationTracing: AIConfigurationTracingOptions | undefined; @@ -123,7 +123,7 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt FFFeatures: Seed+Telemetry UsersKeyVault Failover - CDN + AFD */ const keyValues = new Map(); const tags: string[] = []; @@ -155,8 +155,8 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt if (requestTracingOptions.isFailoverRequest) { tags.push(FAILOVER_REQUEST_TAG); } - if (requestTracingOptions.isCdnUsed) { - tags.push(CDN_USED_TAG); + if (requestTracingOptions.isAfdUsed) { + tags.push(AFD_USED_TAG); } if (requestTracingOptions.replicaCount > 0) { keyValues.set(REPLICA_COUNT_KEY, requestTracingOptions.replicaCount.toString()); diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 6d007861..f3aadf70 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -121,7 +121,7 @@ describe("request tracing", function () { sinon.restore(); }); - it("should have cdn tag in correlation-context header when loadFromAzureFrontDoor is used", async () => { + it("should have AFD tag in correlation-context header when loadFromAzureFrontDoor is used", async () => { try { await loadFromAzureFrontDoor(fakeEndpoint, { clientOptions, @@ -134,10 +134,10 @@ describe("request tracing", function () { expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); const correlationContext = headerPolicy.headers.get("Correlation-Context"); expect(correlationContext).not.undefined; - expect(correlationContext.includes("CDN")).eq(true); + expect(correlationContext.includes("AFD")).eq(true); }); - it("should not have cdn tag in correlation-context header when load is used", async () => { + it("should not have AFD tag in correlation-context header when load is used", async () => { try { await load(createMockedConnectionString(fakeEndpoint), { clientOptions, @@ -149,7 +149,7 @@ describe("request tracing", function () { expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); expect(correlationContext).not.undefined; - expect(correlationContext.includes("CDN")).eq(false); + expect(correlationContext.includes("AFD")).eq(false); }); it("should detect env in correlation-context header", async () => { From 414a8c8c2d57e6e7c0fddbae23bd35fe091a3439 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 5 Nov 2025 22:42:34 +0800 Subject: [PATCH 14/21] disallow sentinel key refresh for AFD --- src/appConfigurationImpl.ts | 100 ++++++++++-------------------- src/common/errorMessages.ts | 3 +- src/load.ts | 4 ++ src/types.ts | 2 +- test/{cdn.test.ts => afd.test.ts} | 85 +++++++------------------ test/refresh.test.ts | 2 +- vitest.browser.config.ts | 2 +- 7 files changed, 63 insertions(+), 135 deletions(-) rename test/{cdn.test.ts => afd.test.ts} (73%) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index e9074fbd..574cff94 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -105,16 +105,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #watchAll: boolean = false; #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #kvRefreshTimer: RefreshTimer; - #lastKvChangeDetectedTime: Date = new Date(0); - #isKvStale: boolean = false; // Feature flags #featureFlagEnabled: boolean = false; #featureFlagRefreshEnabled: boolean = false; #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #ffRefreshTimer: RefreshTimer; - #lastFfChangeDetectedTime: Date = new Date(0); - #isFfStale: boolean = false; // Key Vault references #secretRefreshEnabled: boolean = false; @@ -173,7 +169,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (setting.label?.includes("*") || setting.label?.includes(",")) { throw new ArgumentError(ErrorMessages.INVALID_WATCHED_SETTINGS_LABEL); } - this.#sentinels.set(setting, { etag: undefined, timestamp: new Date(0) }); + this.#sentinels.set(setting, { etag: undefined, lastServerResponseTime: new Date(0) }); } } @@ -522,19 +518,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; const { items, pageWatchers } = await this.#listConfigurationSettings(listOptions); - for (const pageWatcher of pageWatchers) { - if (loadFeatureFlag) { - this.#isFfStale = pageWatcher.timestamp < this.#lastFfChangeDetectedTime; - if (this.#isFfStale) { - return []; - } - } else { - this.#isKvStale = pageWatcher.timestamp < this.#lastKvChangeDetectedTime; - if (this.#isKvStale) { - return []; - } - } - } selector.pageWatchers = pageWatchers; settings = items; } else { // snapshot selector @@ -611,7 +594,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const response = await this.#getConfigurationSetting(configurationSettingId, { onlyIfChanged: false }); this.#sentinels.set(watchedSetting, { etag: isRestError(response) ? undefined : response.etag, - timestamp: this.#getResponseTimestamp(response) + lastServerResponseTime: this.#getResponseTimestamp(response) }); } } @@ -666,38 +649,26 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let needRefresh = false; let changedSentinel; let changedSentinelWatcher; - if (this.#isKvStale) { - needRefresh = true; - // skip checking changes + if (this.#watchAll) { + needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); } else { - if (this.#watchAll) { - needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); - } else { - const getOptions: GetConfigurationSettingOptions = { - // send conditional request only when CDN is not used - onlyIfChanged: !this.#isCdnUsed - }; - for (const watchedSetting of this.#sentinels.keys()) { - const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label }; - const response: GetConfigurationSettingResponse | RestError = - await this.#getConfigurationSetting(configurationSettingId, getOptions); - - const timestamp = this.#getResponseTimestamp(response); - const watcher = this.#sentinels.get(watchedSetting); - const isUpToDate = watcher?.timestamp ? timestamp > watcher.timestamp : true; - const isDeleted = isRestError(response) && watcher?.etag !== undefined; // previously existed, now deleted - const isChanged = - !isRestError(response) && - response.statusCode === 200 && - watcher?.etag !== response.etag; // etag changed - - if (isUpToDate && (isDeleted || isChanged)) { - this.#lastKvChangeDetectedTime = timestamp; - changedSentinel = watchedSetting; - changedSentinelWatcher = watcher; - needRefresh = true; - break; - } + for (const watchedSetting of this.#sentinels.keys()) { + const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label }; + const response: GetConfigurationSettingResponse | RestError = + await this.#getConfigurationSetting(configurationSettingId, { onlyIfChanged: true }); + + const watcher: SettingWatcher = this.#sentinels.get(watchedSetting)!; // watcher should always exist for sentinels + const isDeleted = isRestError(response) && watcher.etag !== undefined; // previously existed, now deleted + const isChanged = + !isRestError(response) && + response.statusCode === 200 && + watcher.etag !== response.etag; // etag changed + + if (isDeleted || isChanged) { + changedSentinel = watchedSetting; + changedSentinelWatcher = { etag: isChanged ? response.etag : undefined }; + needRefresh = true; + break; } } } @@ -707,11 +678,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { await adapter.onChangeDetected(); } await this.#loadSelectedKeyValues(); - this.#sentinels.set(changedSentinel, changedSentinelWatcher); // update the changed sentinel's watcher + + if (changedSentinel && changedSentinelWatcher) { + // update the changed sentinel's watcher after loading new values, this can ensure a failed refresh will retry on next refresh + this.#sentinels.set(changedSentinel, changedSentinelWatcher); + } } this.#kvRefreshTimer.reset(); - return Promise.resolve(needRefresh && !this.#isKvStale); + return Promise.resolve(needRefresh); } /** @@ -725,20 +700,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } let needRefresh = false; - if (this.#isFfStale) { - needRefresh = true; - // skip checking changes - } else { - const refreshFeatureFlag = true; - needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors, refreshFeatureFlag); - } + needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); if (needRefresh) { await this.#loadFeatureFlags(); } this.#ffRefreshTimer.reset(); - return Promise.resolve(needRefresh && !this.#isFfStale); + return Promise.resolve(needRefresh); } async #refreshSecrets(): Promise { @@ -765,7 +734,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * @param selectors - The @see PagedSettingSelector of the kev-value collection. * @returns true if key-value collection has changed, false otherwise. */ - async #checkConfigurationSettingsChange(selectors: PagedSettingsWatcher[], refreshFeatureFlag: boolean = false): Promise { + async #checkConfigurationSettingsChange(selectors: PagedSettingsWatcher[]): Promise { const funcToExecute = async (client) => { for (const selector of selectors) { if (selector.snapshotName) { // skip snapshot selector @@ -794,14 +763,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const timestamp = this.#getResponseTimestamp(page); // when conditional request is sent, the response will be 304 if not changed if (i >= pageWatchers.length || // new page - (timestamp > pageWatchers[i].timestamp && // up to date + (timestamp > pageWatchers[i].lastServerResponseTime && // up to date page._response.status === 200 && // page changed page.etag !== pageWatchers[i].etag)) { - if (refreshFeatureFlag) { - this.#lastFfChangeDetectedTime = timestamp; - } else { - this.#lastKvChangeDetectedTime = timestamp; - } return true; } i++; @@ -852,7 +816,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const items: ConfigurationSetting[] = []; for await (const page of pageIterator) { - pageWatchers.push({ etag: page.etag, timestamp: this.#getResponseTimestamp(page) }); + pageWatchers.push({ etag: page.etag, lastServerResponseTime: this.#getResponseTimestamp(page) }); items.push(...page.items); } return { items, pageWatchers }; diff --git a/src/common/errorMessages.ts b/src/common/errorMessages.ts index 930ee066..d335813f 100644 --- a/src/common/errorMessages.ts +++ b/src/common/errorMessages.ts @@ -21,7 +21,8 @@ export const enum ErrorMessages { INVALID_TAG_FILTER = "Tag filter must follow the format 'tagName=tagValue'", CONNECTION_STRING_OR_ENDPOINT_MISSED = "A connection string or an endpoint with credential must be specified to create a client.", REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door.", - LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door." + LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door.", + WATCHED_SETTINGS_NOT_SUPPORTED = "Watched settings are not supported when loading from Azure Front Door." } export const enum KeyVaultReferenceErrorMessages { diff --git a/src/load.ts b/src/load.ts index f3ec84b0..27c9775f 100644 --- a/src/load.ts +++ b/src/load.ts @@ -83,6 +83,10 @@ export async function loadFromAzureFrontDoor( if (appConfigOptions.loadBalancingEnabled) { throw new ArgumentError(ErrorMessages.LOAD_BALANCING_NOT_SUPPORTED); } + if (appConfigOptions.refreshOptions?.watchedSettings && appConfigOptions.refreshOptions.watchedSettings.length > 0) { + throw new ArgumentError(ErrorMessages.WATCHED_SETTINGS_NOT_SUPPORTED); + } + appConfigOptions.replicaDiscoveryEnabled = false; // Disable replica discovery when loading from Azure Front Door appConfigOptions.clientOptions = { diff --git a/src/types.ts b/src/types.ts index 3cab34da..2c14b6d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,7 @@ export type WatchedSetting = { export type SettingWatcher = { etag?: string; - timestamp: Date; + lastServerResponseTime: Date; } export type PagedSettingsWatcher = SettingSelector & { diff --git a/test/cdn.test.ts b/test/afd.test.ts similarity index 73% rename from test/cdn.test.ts rename to test/afd.test.ts index bd9393e6..804e704e 100644 --- a/test/cdn.test.ts +++ b/test/afd.test.ts @@ -9,6 +9,7 @@ const expect = chai.expect; import { AppConfigurationClient } from "@azure/app-configuration"; import { load, loadFromAzureFrontDoor } from "../src/index.js"; +import { ErrorMessages } from "../src/common/errorMessages.js"; import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedConnectionString, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; import { TIMESTAMP_HEADER } from "../src/cdn/constants.js"; import { isBrowser } from "../src/requestTracing/utils.js"; @@ -26,6 +27,27 @@ describe("loadFromAzureFrontDoor", function() { restoreMocks(); }); + it("should throw if watched settings are provided", async () => { + await expect(loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { + refreshOptions: { + enabled: true, + watchedSettings: [{ key: "sentinel" }] + } + })).to.be.rejectedWith(ErrorMessages.WATCHED_SETTINGS_NOT_SUPPORTED); + }); + + it("should throw if replica discovery is enabled", async () => { + await expect(loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { + replicaDiscoveryEnabled: true + })).to.be.rejectedWith(ErrorMessages.REPLICA_DISCOVERY_NOT_SUPPORTED); + }); + + it("should throw if load balancing is enabled", async () => { + await expect(loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { + loadBalancingEnabled: true + })).to.be.rejectedWith(ErrorMessages.LOAD_BALANCING_NOT_SUPPORTED); + }); + it("should not include authorization and sync-token header when loading from Azure Front Door", async () => { const headerPolicy = new HttpRequestHeadersPolicy(); const position: "perCall" | "perRetry" = "perCall"; @@ -207,68 +229,5 @@ describe("loadFromAzureFrontDoor", function() { expect(featureFlags[0].id).to.equal("Beta"); expect(featureFlags[0].enabled).to.equal(false); }); - - it("should keep refreshing key value until cache expires", async () => { - let refreshSuccessfulCount = 0; - const sentinel = createMockedKeyValue({ key: "sentinel", value: "initial value" }); - const sentinel_updated = createMockedKeyValue({ key: "sentinel", value: "updated value" }); - const kv1 = createMockedKeyValue({ key: "app.key1", value: "value1" }); - const kv2 = createMockedKeyValue({ key: "app.key2", value: "value2" }); - const kv2_updated = createMockedKeyValue({ key: "app.key2", value: "value2-updated" }); - - const getStub = sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting"); - const listStub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); - - getStub.onCall(0).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:00Z") }, ...sentinel } as any)); - getStub.onCall(1).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); - getStub.onCall(2).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any)); - getStub.onCall(4).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:00Z") }, ...sentinel } as any)); // server old value from another cache server - - listStub.onCall(0).returns(getCachedIterator([ - { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } - ])); - listStub.onCall(1).returns(getCachedIterator([ - { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } // cache has not expired - ])); - listStub.onCall(2).returns(getCachedIterator([ - { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } // cache has expired - ])); - - const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { - selectors: [{ keyFilter: "app.*" }], - refreshOptions: { - enabled: true, - refreshIntervalInMs: 1000, - watchedSettings: [ - { key: "sentinel" } - ] - } - }); - - appConfig.onRefresh(() => { - refreshSuccessfulCount++; - }); - - expect(appConfig.get("app.key2")).to.equal("value2"); - - await sleepInMs(1500); - await appConfig.refresh(); - - // cdn cache hasn't expired, even if the sentinel key changed, key2 should still return the old value - expect(appConfig.get("app.key2")).to.equal("value2"); - expect(refreshSuccessfulCount).to.equal(0); - - await sleepInMs(1500); - await appConfig.refresh(); - expect(refreshSuccessfulCount).to.equal(1); - - // cdn cache has expired, key2 should return the updated value even if sentinel remains the same - expect(appConfig.get("app.key2")).to.equal("value2-updated"); - - await sleepInMs(1500); - // even if the sentinel is different from previous value, but the timestamp is older, it should not trigger refresh - await appConfig.refresh(); - expect(refreshSuccessfulCount).to.equal(1); - }); }); /* eslint-ensable @typescript-eslint/no-unused-expressions */ diff --git a/test/refresh.test.ts b/test/refresh.test.ts index fa1efbac..7bab4652 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -275,7 +275,7 @@ describe("dynamic refresh", function () { updateSetting("sentinel", "updatedValue"); failNextListKv = true; // force next listConfigurationSettings request to fail await sleepInMs(2 * 1000 + 1); - await settings.refresh(); // even if the provider detects the sentinel key change, this refresh will fail, so we won't get the updated value of sentinel + await settings.refresh(); // even if the provider detects the sentinel key change, this refresh will fail, so we won't get the updated value expect(listKvRequestCount).eq(1); expect(getKvRequestCount).eq(2); expect(settings.get("app.settings.bgColor")).to.be.undefined; diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index ba52f4ed..73f95f34 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js", - "out/esm/test/cdn.test.js" + "out/esm/test/afd.test.js" ], testTimeout: 200_000, hookTimeout: 200_000, From bebf54904e6bce16008f93f6ea22e3b286d04034 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 5 Nov 2025 23:21:15 +0800 Subject: [PATCH 15/21] update --- package-lock.json | 30 +++++++++---------- package.json | 2 +- src/appConfigurationImpl.ts | 59 +++++++++++++++++-------------------- src/cdn/constants.ts | 2 +- src/load.ts | 4 +-- src/types.ts | 2 +- test/afd.test.ts | 43 +++++++++++++++++++++++++-- 7 files changed, 88 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87cf916a..850dbb76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.2.0", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.9.0", + "@azure/app-configuration": "^1.9.2", "@azure/core-rest-pipeline": "^1.6.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", @@ -74,9 +74,9 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.9.0.tgz", - "integrity": "sha512-X0AVDQygL4AGLtplLYW+W0QakJpJ417sQldOacqwcBQ882tAPdUVs6V3mZ4jUjwVsgr+dV1v9zMmijvsp6XBxA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.9.2.tgz", + "integrity": "sha512-5oVxTEhJ0dws12aQIyeZskO7BIBEPRtfog/EXoCC4K1Ahjvx5vr3p+4nWa2qxWP4xe/i3Cx8Y+dCSq7lj3Ge9A==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", @@ -92,7 +92,7 @@ "tslib": "^2.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/core-auth": { @@ -4359,13 +4359,13 @@ } }, "node_modules/playwright": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -4378,9 +4378,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5319,9 +5319,9 @@ } }, "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.0.tgz", + "integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index f3b21a75..302fcd6e 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "playwright": "^1.55.0" }, "dependencies": { - "@azure/app-configuration": "^1.9.0", + "@azure/app-configuration": "^1.9.2", "@azure/core-rest-pipeline": "^1.6.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 574cff94..685aba1e 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -68,7 +68,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; -import { TIMESTAMP_HEADER } from "./cdn/constants.js"; +import { SERVER_TIMESTAMP_HEADER } from "./cdn/constants.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds @@ -130,17 +130,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Load balancing #lastSuccessfulEndpoint: string = ""; - // CDN - #isCdnUsed: boolean = false; + // Azure Front Door + #isAfdUsed: boolean = false; constructor( clientManager: ConfigurationClientManager, options: AzureAppConfigurationOptions | undefined, - isCdnUsed: boolean + isAfdUsed: boolean ) { this.#options = options; this.#clientManager = clientManager; - this.#isCdnUsed = isCdnUsed; + this.#isAfdUsed = isAfdUsed; // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); @@ -169,7 +169,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (setting.label?.includes("*") || setting.label?.includes(",")) { throw new ArgumentError(ErrorMessages.INVALID_WATCHED_SETTINGS_LABEL); } - this.#sentinels.set(setting, { etag: undefined, lastServerResponseTime: new Date(0) }); + this.#sentinels.set(setting, { etag: undefined }); } } @@ -229,7 +229,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { featureFlagTracing: this.#featureFlagTracing, fmVersion: this.#fmVersion, aiConfigurationTracing: this.#aiConfigurationTracing, - isAfdUsed: this.#isCdnUsed + isAfdUsed: this.#isAfdUsed }; } @@ -592,10 +592,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { for (const watchedSetting of this.#sentinels.keys()) { const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label }; const response = await this.#getConfigurationSetting(configurationSettingId, { onlyIfChanged: false }); - this.#sentinels.set(watchedSetting, { - etag: isRestError(response) ? undefined : response.etag, - lastServerResponseTime: this.#getResponseTimestamp(response) - }); + this.#sentinels.set(watchedSetting, { etag: response?.etag }); } } @@ -647,22 +644,19 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // try refresh if any of watched settings is changed. let needRefresh = false; - let changedSentinel; - let changedSentinelWatcher; + let changedSentinel: WatchedSetting | undefined; + let changedSentinelWatcher: SettingWatcher | undefined; if (this.#watchAll) { needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); } else { for (const watchedSetting of this.#sentinels.keys()) { const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label }; - const response: GetConfigurationSettingResponse | RestError = + const response: GetConfigurationSettingResponse | undefined = await this.#getConfigurationSetting(configurationSettingId, { onlyIfChanged: true }); const watcher: SettingWatcher = this.#sentinels.get(watchedSetting)!; // watcher should always exist for sentinels - const isDeleted = isRestError(response) && watcher.etag !== undefined; // previously existed, now deleted - const isChanged = - !isRestError(response) && - response.statusCode === 200 && - watcher.etag !== response.etag; // etag changed + const isDeleted = response === undefined && watcher.etag !== undefined; // previously existed, now deleted + const isChanged = response && response.statusCode === 200 && watcher.etag !== response.etag; // etag changed if (isDeleted || isChanged) { changedSentinel = watchedSetting; @@ -747,7 +741,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { tagsFilter: selector.tagFilters }; - if (!this.#isCdnUsed) { + if (!this.#isAfdUsed) { // if CDN is not used, add page etags to the listOptions to send conditional request listOptions.pageEtags = pageWatchers.map(w => w.etag ?? "") ; } @@ -761,11 +755,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let i = 0; for await (const page of pageIterator) { const timestamp = this.#getResponseTimestamp(page); - // when conditional request is sent, the response will be 304 if not changed - if (i >= pageWatchers.length || // new page - (timestamp > pageWatchers[i].lastServerResponseTime && // up to date - page._response.status === 200 && // page changed - page.etag !== pageWatchers[i].etag)) { + if (i >= pageWatchers.length) { + return true; + } + + const lastServerResponseTime = pageWatchers[i].lastServerResponseTime; + const isUpToDate = lastServerResponseTime ? timestamp > lastServerResponseTime : true; + if (isUpToDate && page._response.status === 200 && page.etag !== pageWatchers[i].etag) { return true; } i++; @@ -779,9 +775,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Gets a configuration setting by key and label. If the setting is not found, return the error instead of throwing it. + * Gets a configuration setting by key and label. If the setting is not found, return undefined instead of throwing an error. */ - async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions): Promise { + async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions): Promise { const funcToExecute = async (client) => { return getConfigurationSettingWithTrace( this.#requestTraceOptions, @@ -791,13 +787,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ); }; - let response: GetConfigurationSettingResponse | RestError; + let response: GetConfigurationSettingResponse | undefined; try { response = await this.#executeWithFailoverPolicy(funcToExecute); } catch (error) { if (isRestError(error) && error.statusCode === 404) { - // configuration setting not found, return the error - return error; + response = undefined; } else { throw error; } @@ -1151,9 +1146,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #getResponseTimestamp(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date { let header: string | undefined; if (isRestError(response)) { - header = response.response?.headers?.get(TIMESTAMP_HEADER) ?? undefined; + header = response.response?.headers?.get(SERVER_TIMESTAMP_HEADER) ?? undefined; } else { - header = response._response?.headers?.get(TIMESTAMP_HEADER) ?? undefined; + header = response._response?.headers?.get(SERVER_TIMESTAMP_HEADER) ?? undefined; } return header ? new Date(header) : new Date(); } diff --git a/src/cdn/constants.ts b/src/cdn/constants.ts index 65888c43..97bd97a7 100644 --- a/src/cdn/constants.ts +++ b/src/cdn/constants.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const TIMESTAMP_HEADER = "x-ms-date"; +export const SERVER_TIMESTAMP_HEADER = "x-ms-date"; diff --git a/src/load.ts b/src/load.ts index 27c9775f..94533fa7 100644 --- a/src/load.ts +++ b/src/load.ts @@ -50,8 +50,8 @@ export async function load( } try { - const isCdnUsed: boolean = credentialOrOptions === emptyTokenCredential; - const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, isCdnUsed); + const isAfdUsed: boolean = credentialOrOptions === emptyTokenCredential; + const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, isAfdUsed); await appConfiguration.load(); return appConfiguration; } catch (error) { diff --git a/src/types.ts b/src/types.ts index 2c14b6d6..d32c93a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,7 @@ export type WatchedSetting = { export type SettingWatcher = { etag?: string; - lastServerResponseTime: Date; + lastServerResponseTime?: Date; } export type PagedSettingsWatcher = SettingSelector & { diff --git a/test/afd.test.ts b/test/afd.test.ts index 804e704e..a67e0d7a 100644 --- a/test/afd.test.ts +++ b/test/afd.test.ts @@ -11,13 +11,13 @@ import { AppConfigurationClient } from "@azure/app-configuration"; import { load, loadFromAzureFrontDoor } from "../src/index.js"; import { ErrorMessages } from "../src/common/errorMessages.js"; import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedConnectionString, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; -import { TIMESTAMP_HEADER } from "../src/cdn/constants.js"; +import { SERVER_TIMESTAMP_HEADER } from "../src/cdn/constants.js"; import { isBrowser } from "../src/requestTracing/utils.js"; function createTimestampHeaders(timestamp: string | Date) { const value = timestamp instanceof Date ? timestamp.toUTCString() : new Date(timestamp).toUTCString(); return { - get: (name: string) => name.toLowerCase() === TIMESTAMP_HEADER ? value : undefined + get: (name: string) => name.toLowerCase() === SERVER_TIMESTAMP_HEADER ? value : undefined }; } @@ -229,5 +229,44 @@ describe("loadFromAzureFrontDoor", function() { expect(featureFlags[0].id).to.equal("Beta"); expect(featureFlags[0].enabled).to.equal(false); }); + + it("should not refresh if the response is stale", async () => { + const kv1 = createMockedKeyValue({ key: "app.key1", value: "value1" }); + const kv1_stale = createMockedKeyValue({ key: "app.key1", value: "stale-value" }); + const kv1_new = createMockedKeyValue({ key: "app.key1", value: "new-value" }); + + const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + stub.onCall(0).returns(getCachedIterator([ + { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:01Z") } } + ])); + + stub.onCall(1).returns(getCachedIterator([ + { items: [kv1_stale], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } // stale response, should not trigger refresh + ])); + stub.onCall(2).returns(getCachedIterator([ + { items: [kv1_new], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } // new response, should trigger refresh + ])); + stub.onCall(3).returns(getCachedIterator([ + { items: [kv1_new], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } + ])); + + const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { + selectors: [{ keyFilter: "app.*" }], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000 + } + }); // 1 call listConfigurationSettings + + expect(appConfig.get("app.key1")).to.equal("value1"); + + await sleepInMs(1500); + await appConfig.refresh(); // 1 call listConfigurationSettings for watching changes + expect(appConfig.get("app.key1")).to.equal("value1"); // value should not be updated + + await sleepInMs(1500); + await appConfig.refresh(); // 1 call listConfigurationSettings for watching changes and 1 call for reloading + expect(appConfig.get("app.key1")).to.equal("new-value"); + }); }); /* eslint-ensable @typescript-eslint/no-unused-expressions */ From 564f16a7778db2b5dea6e989b03bc94bfecde53d Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 6 Nov 2025 15:57:20 +0800 Subject: [PATCH 16/21] update --- .../afdRequestPipelinePolicy.ts} | 0 src/{cdn => afd}/constants.ts | 0 src/appConfigurationImpl.ts | 14 +++----------- src/common/errorMessages.ts | 2 +- src/load.ts | 8 ++++---- test/afd.test.ts | 2 +- test/featureFlag.test.ts | 4 +++- 7 files changed, 12 insertions(+), 18 deletions(-) rename src/{cdn/cdnRequestPipelinePolicy.ts => afd/afdRequestPipelinePolicy.ts} (100%) rename src/{cdn => afd}/constants.ts (100%) diff --git a/src/cdn/cdnRequestPipelinePolicy.ts b/src/afd/afdRequestPipelinePolicy.ts similarity index 100% rename from src/cdn/cdnRequestPipelinePolicy.ts rename to src/afd/afdRequestPipelinePolicy.ts diff --git a/src/cdn/constants.ts b/src/afd/constants.ts similarity index 100% rename from src/cdn/constants.ts rename to src/afd/constants.ts diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 685aba1e..68d3eecc 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -68,7 +68,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; -import { SERVER_TIMESTAMP_HEADER } from "./cdn/constants.js"; +import { SERVER_TIMESTAMP_HEADER } from "./afd/constants.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds @@ -554,10 +554,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const keyValues: [key: string, value: unknown][] = []; const loadedSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(); - if (loadedSettings.length === 0) { - return; - } - if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { // reset old AI configuration tracing in order to track the information present in the current response from server this.#aiConfigurationTracing.reset(); @@ -614,10 +610,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const loadFeatureFlag = true; const featureFlagSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(loadFeatureFlag); - if (featureFlagSettings.length === 0) { - return; - } - if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { // Reset old feature flag tracing in order to track the information present in the current response from server. this.#featureFlagTracing.reset(); @@ -650,7 +642,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); } else { for (const watchedSetting of this.#sentinels.keys()) { - const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label }; + const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label, etag: this.#sentinels.get(watchedSetting)?.etag }; const response: GetConfigurationSettingResponse | undefined = await this.#getConfigurationSetting(configurationSettingId, { onlyIfChanged: true }); @@ -742,7 +734,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; if (!this.#isAfdUsed) { - // if CDN is not used, add page etags to the listOptions to send conditional request + // if AFD is not used, add page etags to the listOptions to send conditional request listOptions.pageEtags = pageWatchers.map(w => w.etag ?? "") ; } diff --git a/src/common/errorMessages.ts b/src/common/errorMessages.ts index d335813f..5362dc62 100644 --- a/src/common/errorMessages.ts +++ b/src/common/errorMessages.ts @@ -22,7 +22,7 @@ export const enum ErrorMessages { CONNECTION_STRING_OR_ENDPOINT_MISSED = "A connection string or an endpoint with credential must be specified to create a client.", REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door.", LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door.", - WATCHED_SETTINGS_NOT_SUPPORTED = "Watched settings are not supported when loading from Azure Front Door." + WATCHED_SETTINGS_NOT_SUPPORTED = "Specifying watched settings is not supported when loading from Azure Front Door. If refresh is enabled, all loaded configuration settings will be watched automatically." } export const enum KeyVaultReferenceErrorMessages { diff --git a/src/load.ts b/src/load.ts index 94533fa7..c4e1a1c9 100644 --- a/src/load.ts +++ b/src/load.ts @@ -6,7 +6,7 @@ import { AzureAppConfiguration } from "./appConfiguration.js"; import { AzureAppConfigurationImpl } from "./appConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js"; import { ConfigurationClientManager } from "./configurationClientManager.js"; -import { AnonymousRequestPipelinePolicy, RemoveSyncTokenPipelinePolicy } from "./cdn/cdnRequestPipelinePolicy.js"; +import { AnonymousRequestPipelinePolicy, RemoveSyncTokenPipelinePolicy } from "./afd/afdRequestPipelinePolicy.js"; import { instanceOfTokenCredential } from "./common/utils.js"; import { ArgumentError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; @@ -27,7 +27,7 @@ export async function load(connectionString: string, options?: AzureAppConfigura /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. - * @param endpoint The URL to the App Configuration store. + * @param endpoint The App Configuration store endpoint. * @param credential The credential to use to connect to the App Configuration store. * @param options Optional parameters. */ @@ -67,8 +67,8 @@ export async function load( } /** - * Loads the data from Azure Front Door (CDN) and returns an instance of AzureAppConfiguration. - * @param endpoint The URL to the Azure Front Door. + * Loads the data from Azure Front Door and returns an instance of AzureAppConfiguration. + * @param endpoint The Azure Front Door endpoint. * @param appConfigOptions Optional parameters. */ export async function loadFromAzureFrontDoor(endpoint: URL | string, options?: AzureAppConfigurationOptions): Promise; diff --git a/test/afd.test.ts b/test/afd.test.ts index a67e0d7a..fdb1d32e 100644 --- a/test/afd.test.ts +++ b/test/afd.test.ts @@ -11,7 +11,7 @@ import { AppConfigurationClient } from "@azure/app-configuration"; import { load, loadFromAzureFrontDoor } from "../src/index.js"; import { ErrorMessages } from "../src/common/errorMessages.js"; import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedConnectionString, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; -import { SERVER_TIMESTAMP_HEADER } from "../src/cdn/constants.js"; +import { SERVER_TIMESTAMP_HEADER } from "../src/afd/constants.js"; import { isBrowser } from "../src/requestTracing/utils.js"; function createTimestampHeaders(timestamp: string | Date) { diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 500ccf1c..d6f96d0e 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -468,7 +468,9 @@ describe("feature flags", function () { } }); - expect(settingsWithNonExistentTag.get("feature_management")).to.be.undefined; + featureFlags = settingsWithNonExistentTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(0); }); it("should load feature flags from snapshot", async () => { From b5f26c4f919f8d7bdc6134588e61e14daa856a6a Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 7 Nov 2025 16:36:29 +0800 Subject: [PATCH 17/21] update --- src/afd/constants.ts | 2 +- src/appConfigurationImpl.ts | 27 ++++++++++++++++++--------- src/requestTracing/utils.ts | 12 ++++++------ test/afd.test.ts | 4 ++-- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/afd/constants.ts b/src/afd/constants.ts index 97bd97a7..9a6493e2 100644 --- a/src/afd/constants.ts +++ b/src/afd/constants.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const SERVER_TIMESTAMP_HEADER = "x-ms-date"; +export const X_MS_DATE_HEADER = "x-ms-date"; diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index f812f8bd..f408dc95 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -68,7 +68,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; -import { SERVER_TIMESTAMP_HEADER } from "./afd/constants.js"; +import { X_MS_DATE_HEADER } from "./afd/constants.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds @@ -745,13 +745,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let i = 0; for await (const page of pageIterator) { - const timestamp = this.#getResponseTimestamp(page); + const serverResponseTime = this.#getMsDateHeader(page); if (i >= pageWatchers.length) { return true; } const lastServerResponseTime = pageWatchers[i].lastServerResponseTime; - const isUpToDate = lastServerResponseTime ? timestamp > lastServerResponseTime : true; + let isUpToDate = true; + if (lastServerResponseTime !== undefined && serverResponseTime !== undefined) { + isUpToDate = serverResponseTime > lastServerResponseTime; + } if (isUpToDate && page._response.status === 200 && page.etag !== pageWatchers[i].etag) { return true; } @@ -802,7 +805,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const items: ConfigurationSetting[] = []; for await (const page of pageIterator) { - pageWatchers.push({ etag: page.etag, lastServerResponseTime: this.#getResponseTimestamp(page) }); + pageWatchers.push({ etag: page.etag, lastServerResponseTime: this.#getMsDateHeader(page) }); items.push(...page.items); } return { items, pageWatchers }; @@ -1132,16 +1135,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Extracts the response timestamp from the headers. If not found, returns the current time. + * Extracts the response timestamp (x-ms-date) from the response headers. If not found, returns the current time. */ - #getResponseTimestamp(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date { + #getMsDateHeader(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date { let header: string | undefined; if (isRestError(response)) { - header = response.response?.headers?.get(SERVER_TIMESTAMP_HEADER) ?? undefined; + header = response.response?.headers?.get(X_MS_DATE_HEADER); } else { - header = response._response?.headers?.get(SERVER_TIMESTAMP_HEADER) ?? undefined; + header = response._response?.headers?.get(X_MS_DATE_HEADER); + } + if (header !== undefined) { + const date = new Date(header); + if (!isNaN(date.getTime())) { + return date; + } } - return header ? new Date(header) : new Date(); + return new Date(); } } diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 1f0955ea..739c7291 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -117,13 +117,12 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt Host: identify with defined envs Env: identify by env `NODE_ENV` which is a popular but not standard. Usually, the value can be "development", "production". ReplicaCount: identify how many replicas are found - Features: LB + Features: LB+AI+AICC+AFD Filter: CSTM+TIME+TRGT MaxVariants: identify the max number of variants feature flag uses FFFeatures: Seed+Telemetry UsersKeyVault Failover - AFD */ const keyValues = new Map(); const tags: string[] = []; @@ -155,9 +154,6 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt if (requestTracingOptions.isFailoverRequest) { tags.push(FAILOVER_REQUEST_TAG); } - if (requestTracingOptions.isAfdUsed) { - tags.push(AFD_USED_TAG); - } if (requestTracingOptions.replicaCount > 0) { keyValues.set(REPLICA_COUNT_KEY, requestTracingOptions.replicaCount.toString()); } @@ -189,7 +185,8 @@ export function requestTracingEnabled(): boolean { function usesAnyTracingFeature(requestTracingOptions: RequestTracingOptions): boolean { return (requestTracingOptions.appConfigOptions?.loadBalancingEnabled ?? false) || - (requestTracingOptions.aiConfigurationTracing?.usesAnyTracingFeature() ?? false); + (requestTracingOptions.aiConfigurationTracing?.usesAnyTracingFeature() ?? false) || + requestTracingOptions.isAfdUsed; } function createFeaturesString(requestTracingOptions: RequestTracingOptions): string { @@ -203,6 +200,9 @@ function createFeaturesString(requestTracingOptions: RequestTracingOptions): str if (requestTracingOptions.aiConfigurationTracing?.usesAIChatCompletionConfiguration) { tags.push(AI_CHAT_COMPLETION_CONFIGURATION_TAG); } + if (requestTracingOptions.isAfdUsed) { + tags.push(AFD_USED_TAG); + } return tags.join(DELIMITER); } diff --git a/test/afd.test.ts b/test/afd.test.ts index fdb1d32e..e807a38e 100644 --- a/test/afd.test.ts +++ b/test/afd.test.ts @@ -11,13 +11,13 @@ import { AppConfigurationClient } from "@azure/app-configuration"; import { load, loadFromAzureFrontDoor } from "../src/index.js"; import { ErrorMessages } from "../src/common/errorMessages.js"; import { createMockedKeyValue, createMockedFeatureFlag, HttpRequestHeadersPolicy, getCachedIterator, sinon, restoreMocks, createMockedConnectionString, createMockedAzureFrontDoorEndpoint, sleepInMs } from "./utils/testHelper.js"; -import { SERVER_TIMESTAMP_HEADER } from "../src/afd/constants.js"; +import { X_MS_DATE_HEADER } from "../src/afd/constants.js"; import { isBrowser } from "../src/requestTracing/utils.js"; function createTimestampHeaders(timestamp: string | Date) { const value = timestamp instanceof Date ? timestamp.toUTCString() : new Date(timestamp).toUTCString(); return { - get: (name: string) => name.toLowerCase() === SERVER_TIMESTAMP_HEADER ? value : undefined + get: (name: string) => name.toLowerCase() === X_MS_DATE_HEADER ? value : undefined }; } From 5b09aee9d64cd408c8c2300dfbaea46659e38c6a Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 7 Nov 2025 21:14:41 +0800 Subject: [PATCH 18/21] update --- src/appConfigurationImpl.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index f408dc95..ca296892 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -745,17 +745,19 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let i = 0; for await (const page of pageIterator) { - const serverResponseTime = this.#getMsDateHeader(page); + const serverResponseTime: Date = this.#getMsDateHeader(page); if (i >= pageWatchers.length) { return true; } const lastServerResponseTime = pageWatchers[i].lastServerResponseTime; let isUpToDate = true; - if (lastServerResponseTime !== undefined && serverResponseTime !== undefined) { + if (lastServerResponseTime !== undefined) { isUpToDate = serverResponseTime > lastServerResponseTime; } - if (isUpToDate && page._response.status === 200 && page.etag !== pageWatchers[i].etag) { + if (isUpToDate && + page._response.status === 200 && // conditional request returns 304 if not changed + page.etag !== pageWatchers[i].etag) { return true; } i++; From dcf58147a0dd9c72517b116822c02ef087a7453f Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Sat, 8 Nov 2025 14:58:50 +0800 Subject: [PATCH 19/21] resolve merge conflict --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ec1dccf..850dbb76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5319,15 +5319,9 @@ } }, "node_modules/vite": { -<<<<<<< HEAD "version": "7.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.0.tgz", "integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==", -======= - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", ->>>>>>> a50b506880482ed1104c1a5f3113fbe492dc3299 "dev": true, "license": "MIT", "dependencies": { From 5d9477bbdab5bac65c012244c03a9767e42ddb33 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Sun, 9 Nov 2025 00:17:12 +0800 Subject: [PATCH 20/21] update error message --- src/common/errorMessages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/errorMessages.ts b/src/common/errorMessages.ts index 5362dc62..96c193b2 100644 --- a/src/common/errorMessages.ts +++ b/src/common/errorMessages.ts @@ -20,8 +20,8 @@ export const enum ErrorMessages { INVALID_LABEL_FILTER = "The characters '*' and ',' are not supported in label filters.", INVALID_TAG_FILTER = "Tag filter must follow the format 'tagName=tagValue'", CONNECTION_STRING_OR_ENDPOINT_MISSED = "A connection string or an endpoint with credential must be specified to create a client.", - REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door.", - LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door.", + REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://learn.microsoft.com/azure/azure-app-configuration/howto-geo-replication#use-geo-replica-with-azure-front-door", + LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://learn.microsoft.com/azure/azure-app-configuration/howto-geo-replication#use-geo-replica-with-azure-front-door", WATCHED_SETTINGS_NOT_SUPPORTED = "Specifying watched settings is not supported when loading from Azure Front Door. If refresh is enabled, all loaded configuration settings will be watched automatically." } From 29f7958134d55ba46d677cf592b6106b1d2f04e1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Sun, 9 Nov 2025 16:03:22 +0800 Subject: [PATCH 21/21] update --- src/appConfigurationImpl.ts | 6 +++--- src/common/errorMessages.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index ca296892..7ee6ccee 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -751,11 +751,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } const lastServerResponseTime = pageWatchers[i].lastServerResponseTime; - let isUpToDate = true; + let isResponseFresh = false; if (lastServerResponseTime !== undefined) { - isUpToDate = serverResponseTime > lastServerResponseTime; + isResponseFresh = serverResponseTime > lastServerResponseTime; } - if (isUpToDate && + if (isResponseFresh && page._response.status === 200 && // conditional request returns 304 if not changed page.etag !== pageWatchers[i].etag) { return true; diff --git a/src/common/errorMessages.ts b/src/common/errorMessages.ts index 96c193b2..29332174 100644 --- a/src/common/errorMessages.ts +++ b/src/common/errorMessages.ts @@ -20,8 +20,8 @@ export const enum ErrorMessages { INVALID_LABEL_FILTER = "The characters '*' and ',' are not supported in label filters.", INVALID_TAG_FILTER = "Tag filter must follow the format 'tagName=tagValue'", CONNECTION_STRING_OR_ENDPOINT_MISSED = "A connection string or an endpoint with credential must be specified to create a client.", - REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://learn.microsoft.com/azure/azure-app-configuration/howto-geo-replication#use-geo-replica-with-azure-front-door", - LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://learn.microsoft.com/azure/azure-app-configuration/howto-geo-replication#use-geo-replica-with-azure-front-door", + REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd", + LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd", WATCHED_SETTINGS_NOT_SUPPORTED = "Specifying watched settings is not supported when loading from Azure Front Door. If refresh is enabled, all loaded configuration settings will be watched automatically." }