diff --git a/package-lock.json b/package-lock.json index 1b99f9c3..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": { @@ -5319,9 +5319,9 @@ } }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "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 b61d93b3..b6207f68 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,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/afd/afdRequestPipelinePolicy.ts b/src/afd/afdRequestPipelinePolicy.ts new file mode 100644 index 00000000..08ca0340 --- /dev/null +++ b/src/afd/afdRequestPipelinePolicy.ts @@ -0,0 +1,35 @@ +// 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); + } +} + +/** + * 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/afd/constants.ts b/src/afd/constants.ts new file mode 100644 index 00000000..9a6493e2 --- /dev/null +++ b/src/afd/constants.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const X_MS_DATE_HEADER = "x-ms-date"; diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 18155a8b..7ee6ccee 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -14,9 +14,10 @@ import { GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions, 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"; @@ -67,6 +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 { X_MS_DATE_HEADER } from "./afd/constants.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds @@ -128,12 +130,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Load balancing #lastSuccessfulEndpoint: string = ""; + // Azure Front Door + #isAfdUsed: boolean = false; + constructor( clientManager: ConfigurationClientManager, options: AzureAppConfigurationOptions | undefined, + isAfdUsed: boolean ) { this.#options = options; this.#clientManager = clientManager; + this.#isAfdUsed = isAfdUsed; // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); @@ -221,7 +228,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { isFailoverRequest: this.#isFailoverRequest, featureFlagTracing: this.#featureFlagTracing, fmVersion: this.#fmVersion, - aiConfigurationTracing: this.#aiConfigurationTracing + aiConfigurationTracing: this.#aiConfigurationTracing, + isAfdUsed: this.#isAfdUsed }; } @@ -490,7 +498,7 @@ 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; // 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. @@ -509,6 +517,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { tagsFilter: selector.tagFilters }; const { items, pageWatchers } = await this.#listConfigurationSettings(listOptions); + selector.pageWatchers = pageWatchers; settings = items; } else { // snapshot selector @@ -675,7 +684,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } - const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); + let needRefresh = false; + needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); + if (needRefresh) { await this.#loadFeatureFlags(); } @@ -718,21 +729,38 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, - tagsFilter: selector.tagFilters, - pageEtags: pageWatchers.map(w => w.etag ?? "") + tagsFilter: selector.tagFilters }; + if (!this.#isAfdUsed) { + // if AFD is not used, add page etags to the listOptions to send conditional request + listOptions.pageEtags = pageWatchers.map(w => w.etag ?? "") ; + } + const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, client, listOptions ).byPage(); + let i = 0; for await (const page of pageIterator) { - // when conditional request is sent, the response will be 304 if not changed - if (page._response.status === 200) { // created or changed + const serverResponseTime: Date = this.#getMsDateHeader(page); + if (i >= pageWatchers.length) { return true; } + + const lastServerResponseTime = pageWatchers[i].lastServerResponseTime; + let isResponseFresh = false; + if (lastServerResponseTime !== undefined) { + isResponseFresh = serverResponseTime > lastServerResponseTime; + } + if (isResponseFresh && + page._response.status === 200 && // conditional request returns 304 if not changed + page.etag !== pageWatchers[i].etag) { + return true; + } + i++; } } return false; @@ -743,7 +771,7 @@ 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 undefined instead of throwing an error. */ async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions): Promise { const funcToExecute = async (client) => { @@ -779,7 +807,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const items: ConfigurationSetting[] = []; for await (const page of pageIterator) { - pageWatchers.push({ etag: page.etag }); + pageWatchers.push({ etag: page.etag, lastServerResponseTime: this.#getMsDateHeader(page) }); items.push(...page.items); } return { items, pageWatchers }; @@ -1107,6 +1135,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return first15Bytes.toString("base64url"); } } + + /** + * Extracts the response timestamp (x-ms-date) from the response headers. If not found, returns the current time. + */ + #getMsDateHeader(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date { + let header: string | undefined; + if (isRestError(response)) { + header = response.response?.headers?.get(X_MS_DATE_HEADER); + } else { + header = response._response?.headers?.get(X_MS_DATE_HEADER); + } + if (header !== undefined) { + const date = new Date(header); + if (!isNaN(date.getTime())) { + return date; + } + } + return new Date(); + } } function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/common/errorMessages.ts b/src/common/errorMessages.ts index e1f9f658..29332174 100644 --- a/src/common/errorMessages.ts +++ b/src/common/errorMessages.ts @@ -20,6 +20,9 @@ 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://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." } 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..c4e1a1c9 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, RemoveSyncTokenPipelinePolicy } from "./afd/afdRequestPipelinePolicy.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. @@ -19,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. */ @@ -42,7 +50,8 @@ export async function load( } try { - const appConfiguration = new AzureAppConfigurationImpl(clientManager, options); + const isAfdUsed: boolean = credentialOrOptions === emptyTokenCredential; + const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, isAfdUsed); await appConfiguration.load(); return appConfiguration; } catch (error) { @@ -56,3 +65,38 @@ export async function load( throw error; } } + +/** + * 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; + +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); + } + 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 = { + ...appConfigOptions.clientOptions, + additionalPolicies: [ + ...(appConfigOptions.clientOptions?.additionalPolicies || []), + { policy: new AnonymousRequestPipelinePolicy(), position: "perRetry" }, + { policy: new RemoveSyncTokenPipelinePolicy(), position: "perRetry" } + ] + }; + + return await load(endpoint, emptyTokenCredential, appConfigOptions); +} diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 6f9311b4..6db765c4 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 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 e9949505..739c7291 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, + AFD_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; + isAfdUsed: 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) } }; @@ -113,7 +117,7 @@ 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 @@ -181,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 { @@ -195,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/src/types.ts b/src/types.ts index 7ef804a8..d32c93a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,7 @@ export type WatchedSetting = { export type SettingWatcher = { etag?: string; + lastServerResponseTime?: Date; } export type PagedSettingsWatcher = SettingSelector & { diff --git a/test/afd.test.ts b/test/afd.test.ts new file mode 100644 index 00000000..e807a38e --- /dev/null +++ b/test/afd.test.ts @@ -0,0 +1,272 @@ +// 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 { 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 { 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() === X_MS_DATE_HEADER ? value : undefined + }; +} + +describe("loadFromAzureFrontDoor", function() { + + afterEach(() => { + 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"; + const clientOptions = { + retryOptions: { + maxRetries: 0 + }, + additionalPolicies: [{ + policy: headerPolicy, + position + }], + syncTokens: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addSyncTokenFromHeaderValue: (syncTokenHeaderValue) => {}, + getSyncTokenHeaderValue: () => { return "mockedSyncToken"; } + } + }; + + try { + 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 + } + }); + } catch { /* empty */ } + + expect(headerPolicy.headers).not.undefined; + 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("Sync-Token")).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" }); + 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 appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { + 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:01Z") } } + ])); + stub.onCall(2).returns(getCachedIterator([ + { 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:03Z") } } + ])); + stub.onCall(4).returns(getCachedIterator([ + { 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:05Z") } } + ])); + stub.onCall(6).returns(getCachedIterator([ + { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:05Z") } } + ])); + + const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { + selectors: [{ keyFilter: "app.*" }], + refreshOptions: { + 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(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(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(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"); + }); + + 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:03Z") } } + ])); + stub.onCall(3).returns(getCachedIterator([ + { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } + ])); + + const appConfig = await loadFromAzureFrontDoor(createMockedAzureFrontDoorEndpoint(), { + 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(1500); + 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 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 */ 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/test/requestTracing.test.ts b/test/requestTracing.test.ts index 6a69698e..f3aadf70 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -9,7 +9,8 @@ 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 } from "../src/index.js"; +import { load, loadFromAzureFrontDoor } from "../src/index.js"; +import { isBrowser } from "../src/requestTracing/utils.js"; const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; @@ -44,7 +45,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 () => { @@ -112,6 +121,37 @@ describe("request tracing", function () { sinon.restore(); }); + it("should have AFD 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("AFD")).eq(true); + }); + + it("should not have AFD 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("AFD")).eq(false); + }); + it("should detect env in correlation-context header", async () => { process.env.NODE_ENV = "development"; try { diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index b4f06dd3..5fd18638 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -111,6 +111,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. @@ -241,7 +284,9 @@ function restoreMocks() { const createMockedEndpoint = (name = "azure") => `https://${name}.azconfig.io`; -const createMockedConnectionString = (endpoint = createMockedEndpoint(), secret = "secret", id = "1123456") => { +const createMockedAzureFrontDoorEndpoint = (name = "appconfig") => `https://${name}.b01.azurefd.net`; + +const createMockedConnectionString = (endpoint = createMockedEndpoint(), secret = "secret", id = "123456") => { return `Endpoint=${endpoint};Id=${id};Secret=${secret}`; }; @@ -320,9 +365,11 @@ export { mockAppConfigurationClientLoadBalanceMode, mockConfigurationManagerGetClients, mockSecretClientGetSecret, + getCachedIterator, restoreMocks, createMockedEndpoint, + createMockedAzureFrontDoorEndpoint, createMockedConnectionString, createMockedTokenCredential, createMockedKeyVaultReference, diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 011e0f93..73f95f34 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"], + 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/afd.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 +});