diff --git a/README.md b/README.md index 9649c88a..26fec80b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ This client library adds additional [functionality](https://learn.microsoft.com/ [Dynamic Configuration Tutorial](https://learn.microsoft.com/azure/azure-app-configuration/enable-dynamic-configuration-javascript): A tutorial about how to enable dynamic configuration in your JavaScript applications. +[Feature Overview](https://learn.microsoft.com/azure/azure-app-configuration/configuration-provider-overview#feature-development-status): This document provides a feature status overview. + [Feature Reference](https://learn.microsoft.com/azure/azure-app-configuration/reference-javascript-provider): This document provides a full feature rundown. ### Prerequisites diff --git a/package-lock.json b/package-lock.json index 5ce195df..da3474a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/app-configuration-provider", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "2.0.0", + "version": "2.0.1", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.6.1", diff --git a/package.json b/package.json index 04b4e526..7c757350 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "2.0.0", + "version": "2.0.1", "description": "The JavaScript configuration provider for Azure App Configuration", "main": "dist/index.js", "module": "./dist-esm/index.js", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 90e283f0..328d7620 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -9,7 +9,6 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js"; import { Disposable } from "./common/disposable.js"; -import { base64Helper, jsonSorter } from "./common/utils.js"; import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME, @@ -20,16 +19,9 @@ import { ETAG_KEY_NAME, FEATURE_FLAG_ID_KEY_NAME, FEATURE_FLAG_REFERENCE_KEY_NAME, - ALLOCATION_ID_KEY_NAME, ALLOCATION_KEY_NAME, - DEFAULT_WHEN_ENABLED_KEY_NAME, - PERCENTILE_KEY_NAME, - FROM_KEY_NAME, - TO_KEY_NAME, SEED_KEY_NAME, - VARIANT_KEY_NAME, VARIANTS_KEY_NAME, - CONFIGURATION_VALUE_KEY_NAME, CONDITIONS_KEY_NAME, CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; @@ -677,15 +669,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) { const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME]; - let allocationId = ""; - if (featureFlag[ALLOCATION_KEY_NAME] !== undefined) { - allocationId = await this.#generateAllocationId(featureFlag); - } featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = { [ETAG_KEY_NAME]: setting.etag, [FEATURE_FLAG_ID_KEY_NAME]: await this.#calculateFeatureFlagId(setting), [FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting), - ...(allocationId !== "" && { [ALLOCATION_ID_KEY_NAME]: allocationId }), ...(metadata || {}) }; } @@ -769,116 +756,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } return featureFlagReference; } - - async #generateAllocationId(featureFlag: any): Promise { - let rawAllocationId = ""; - // Only default variant when enabled and variants allocated by percentile involve in the experimentation - // The allocation id is genearted from default variant when enabled and percentile allocation - const variantsForExperimentation: string[] = []; - - rawAllocationId += `seed=${featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] ?? ""}\ndefault_when_enabled=`; - - if (featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]) { - variantsForExperimentation.push(featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]); - rawAllocationId += `${featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]}`; - } - - rawAllocationId += "\npercentiles="; - - const percentileList = featureFlag[ALLOCATION_KEY_NAME][PERCENTILE_KEY_NAME]; - if (percentileList) { - const sortedPercentileList = percentileList - .filter(p => - (p[FROM_KEY_NAME] !== undefined) && - (p[TO_KEY_NAME] !== undefined) && - (p[VARIANT_KEY_NAME] !== undefined) && - (p[FROM_KEY_NAME] !== p[TO_KEY_NAME])) - .sort((a, b) => a[FROM_KEY_NAME] - b[FROM_KEY_NAME]); - - const percentileAllocation: string[] = []; - for (const percentile of sortedPercentileList) { - variantsForExperimentation.push(percentile[VARIANT_KEY_NAME]); - percentileAllocation.push(`${percentile[FROM_KEY_NAME]},${base64Helper(percentile[VARIANT_KEY_NAME])},${percentile[TO_KEY_NAME]}`); - } - rawAllocationId += percentileAllocation.join(";"); - } - - if (variantsForExperimentation.length === 0 && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] === undefined) { - // All fields required for generating allocation id are missing, short-circuit and return empty string - return ""; - } - - rawAllocationId += "\nvariants="; - - if (variantsForExperimentation.length !== 0) { - const variantsList = featureFlag[VARIANTS_KEY_NAME]; - if (variantsList) { - const sortedVariantsList = variantsList - .filter(v => - (v[NAME_KEY_NAME] !== undefined) && - variantsForExperimentation.includes(v[NAME_KEY_NAME])) - .sort((a, b) => (a.name > b.name ? 1 : -1)); - - const variantConfiguration: string[] = []; - for (const variant of sortedVariantsList) { - const configurationValue = JSON.stringify(variant[CONFIGURATION_VALUE_KEY_NAME], jsonSorter) ?? ""; - variantConfiguration.push(`${base64Helper(variant[NAME_KEY_NAME])},${configurationValue}`); - } - rawAllocationId += variantConfiguration.join(";"); - } - } - - let crypto; - - // Check for browser environment - if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { - crypto = window.crypto; - } - // Check for Node.js environment - else if (typeof global !== "undefined" && global.crypto) { - crypto = global.crypto; - } - // Fallback to native Node.js crypto module - else { - try { - if (typeof module !== "undefined" && module.exports) { - crypto = require("crypto"); - } - else { - crypto = await import("crypto"); - } - } catch (error) { - console.error("Failed to load the crypto module:", error.message); - throw error; - } - } - - // Convert to UTF-8 encoded bytes - const data = new TextEncoder().encode(rawAllocationId); - - // In the browser, use crypto.subtle.digest - if (crypto.subtle) { - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = new Uint8Array(hashBuffer); - - // Only use the first 15 bytes - const first15Bytes = hashArray.slice(0, 15); - - // btoa/atob is also available in Node.js 18+ - const base64String = btoa(String.fromCharCode(...first15Bytes)); - const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - return base64urlString; - } - // In Node.js, use the crypto module's hash function - else { - const hash = crypto.createHash("sha256").update(data).digest(); - - // Only use the first 15 bytes - const first15Bytes = hash.slice(0, 15); - - return first15Bytes.toString("base64url"); - } - } } function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/ConfigurationClientManager.ts b/src/ConfigurationClientManager.ts index 7ad8e597..a241bd28 100644 --- a/src/ConfigurationClientManager.ts +++ b/src/ConfigurationClientManager.ts @@ -16,9 +16,11 @@ const ENDPOINT_KEY_NAME = "Endpoint"; const ID_KEY_NAME = "Id"; const SECRET_KEY_NAME = "Secret"; const TRUSTED_DOMAIN_LABELS = [".azconfig.", ".appconfig."]; -const FALLBACK_CLIENT_REFRESH_EXPIRE_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds -const MINIMAL_CLIENT_REFRESH_INTERVAL = 30 * 1000; // 30 seconds in milliseconds -const SRV_QUERY_TIMEOUT = 30 * 1000; // 30 seconds in milliseconds +const FALLBACK_CLIENT_EXPIRE_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds +const MINIMAL_CLIENT_REFRESH_INTERVAL = 30_000; // 30 seconds in milliseconds +const DNS_RESOLVER_TIMEOUT = 3_000; // 3 seconds in milliseconds, in most cases, dns resolution should be within 200 milliseconds +const DNS_RESOLVER_TRIES = 2; +const MAX_ALTNATIVE_SRV_COUNT = 10; export class ConfigurationClientManager { #isFailoverable: boolean; @@ -33,8 +35,8 @@ export class ConfigurationClientManager { #staticClients: ConfigurationClientWrapper[]; // there should always be only one static client #dynamicClients: ConfigurationClientWrapper[]; #replicaCount: number = 0; - #lastFallbackClientRefreshTime: number = 0; - #lastFallbackClientRefreshAttempt: number = 0; + #lastFallbackClientUpdateTime: number = 0; // enforce to discover fallback client when it is expired + #lastFallbackClientRefreshAttempt: number = 0; // avoid refreshing clients before the minimal refresh interval constructor ( connectionStringOrEndpoint?: string | URL, @@ -85,10 +87,11 @@ export class ConfigurationClientManager { this.#isFailoverable = false; return; } - if (this.#dns) { + if (this.#dns) { // dns module is already loaded return; } + // We can only know whether dns module is available during runtime. try { this.#dns = await import("dns/promises"); } catch (error) { @@ -116,8 +119,7 @@ export class ConfigurationClientManager { (!this.#dynamicClients || // All dynamic clients are in backoff means no client is available this.#dynamicClients.every(client => currentTime < client.backoffEndTime) || - currentTime >= this.#lastFallbackClientRefreshTime + FALLBACK_CLIENT_REFRESH_EXPIRE_INTERVAL)) { - this.#lastFallbackClientRefreshAttempt = currentTime; + currentTime >= this.#lastFallbackClientUpdateTime + FALLBACK_CLIENT_EXPIRE_INTERVAL)) { await this.#discoverFallbackClients(this.endpoint.hostname); return availableClients.concat(this.#dynamicClients); } @@ -135,27 +137,22 @@ export class ConfigurationClientManager { async refreshClients() { const currentTime = Date.now(); if (this.#isFailoverable && - currentTime >= new Date(this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL).getTime()) { - this.#lastFallbackClientRefreshAttempt = currentTime; + currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL) { await this.#discoverFallbackClients(this.endpoint.hostname); } } async #discoverFallbackClients(host: string) { - let result; - let timeout; + this.#lastFallbackClientRefreshAttempt = Date.now(); + let result: string[]; try { - result = await Promise.race([ - new Promise((_, reject) => timeout = setTimeout(() => reject(new Error("SRV record query timed out.")), SRV_QUERY_TIMEOUT)), - this.#querySrvTargetHost(host) - ]); + result = await this.#querySrvTargetHost(host); } catch (error) { - throw new Error(`Failed to build fallback clients, ${error.message}`); - } finally { - clearTimeout(timeout); + console.warn(`Failed to build fallback clients. ${error.message}`); + return; // swallow the error when srv query fails } - const srvTargetHosts = shuffleList(result) as string[]; + const srvTargetHosts = shuffleList(result); const newDynamicClients: ConfigurationClientWrapper[] = []; for (const host of srvTargetHosts) { if (isValidEndpoint(host, this.#validDomain)) { @@ -164,43 +161,36 @@ export class ConfigurationClientManager { continue; } const client = this.#credential ? - new AppConfigurationClient(targetEndpoint, this.#credential, this.#clientOptions) : - new AppConfigurationClient(buildConnectionString(targetEndpoint, this.#secret, this.#id), this.#clientOptions); + new AppConfigurationClient(targetEndpoint, this.#credential, this.#clientOptions) : + new AppConfigurationClient(buildConnectionString(targetEndpoint, this.#secret, this.#id), this.#clientOptions); newDynamicClients.push(new ConfigurationClientWrapper(targetEndpoint, client)); } } this.#dynamicClients = newDynamicClients; - this.#lastFallbackClientRefreshTime = Date.now(); + this.#lastFallbackClientUpdateTime = Date.now(); this.#replicaCount = this.#dynamicClients.length; } /** - * Query SRV records and return target hosts. + * Queries SRV records for the given host and returns the target hosts. */ async #querySrvTargetHost(host: string): Promise { const results: string[] = []; try { - // Look up SRV records for the origin host - const originRecords = await this.#dns.resolveSrv(`${TCP_ORIGIN_KEY_NAME}.${host}`); - if (originRecords.length === 0) { - return results; - } - - // Add the first origin record to results + // https://nodejs.org/api/dns.html#dnspromisesresolvesrvhostname + const resolver = new this.#dns.Resolver({timeout: DNS_RESOLVER_TIMEOUT, tries: DNS_RESOLVER_TRIES}); + // On success, resolveSrv() returns an array of SrvRecord + // On failure, resolveSrv() throws an error with code 'ENOTFOUND'. + const originRecords = await resolver.resolveSrv(`${TCP_ORIGIN_KEY_NAME}.${host}`); // look up SRV records for the origin host const originHost = originRecords[0].name; - results.push(originHost); + results.push(originHost); // add the first origin record to results - // Look up SRV records for alternate hosts let index = 0; - // eslint-disable-next-line no-constant-condition - while (true) { - const currentAlt = `${ALT_KEY_NAME}${index}`; - const altRecords = await this.#dns.resolveSrv(`${currentAlt}.${TCP_KEY_NAME}.${originHost}`); - if (altRecords.length === 0) { - break; // No more alternate records, exit loop - } + while (index < MAX_ALTNATIVE_SRV_COUNT) { + const currentAlt = `${ALT_KEY_NAME}${index}`; // look up SRV records for alternate hosts + const altRecords = await resolver.resolveSrv(`${currentAlt}.${TCP_KEY_NAME}.${originHost}`); altRecords.forEach(record => { const altHost = record.name; @@ -212,7 +202,8 @@ export class ConfigurationClientManager { } } catch (err) { if (err.code === "ENOTFOUND") { - return results; // No more SRV records found, return results + // No more SRV records found, return results. + return results; } else { throw new Error(`Failed to lookup SRV records: ${err.message}`); } diff --git a/src/common/utils.ts b/src/common/utils.ts index 8682484b..1d790808 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,28 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export function base64Helper(str: string): string { - const bytes = new TextEncoder().encode(str); // UTF-8 encoding - let chars = ""; - for (let i = 0; i < bytes.length; i++) { - chars += String.fromCharCode(bytes[i]); - } - return btoa(chars); -} - -export function jsonSorter(key, value) { - if (value === null) { - return null; - } - if (Array.isArray(value)) { - return value; - } - if (typeof value === "object") { - return Object.fromEntries(Object.entries(value).sort()); - } - return value; -} - export function shuffleList(array: T[]): T[] { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); diff --git a/src/version.ts b/src/version.ts index dba07670..e731cf4c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.0.0"; +export const VERSION = "2.0.1"; diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 2d6a7e02..906a15ff 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -339,64 +339,4 @@ describe("feature flags", function () { expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o"); expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); }); - - it("should not populate allocation id", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [ { keyFilter: "*" } ] - } - }); - expect(settings).not.undefined; - expect(settings.get("feature_management")).not.undefined; - const featureFlags = settings.get("feature_management").feature_flags; - expect(featureFlags).not.undefined; - - const NoPercentileAndSeed = (featureFlags as any[]).find(item => item.id === "NoPercentileAndSeed"); - expect(NoPercentileAndSeed).not.undefined; - expect(NoPercentileAndSeed?.telemetry.metadata.AllocationId).to.be.undefined; - }); - - it("should populate allocation id", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [ { keyFilter: "*" } ] - } - }); - expect(settings).not.undefined; - expect(settings.get("feature_management")).not.undefined; - const featureFlags = settings.get("feature_management").feature_flags; - expect(featureFlags).not.undefined; - - const SeedOnly = (featureFlags as any[]).find(item => item.id === "SeedOnly"); - expect(SeedOnly).not.undefined; - expect(SeedOnly?.telemetry.metadata.AllocationId).equals("qZApcKdfXscxpgn_8CMf"); - - const DefaultWhenEnabledOnly = (featureFlags as any[]).find(item => item.id === "DefaultWhenEnabledOnly"); - expect(DefaultWhenEnabledOnly).not.undefined; - expect(DefaultWhenEnabledOnly?.telemetry.metadata.AllocationId).equals("k486zJjud_HkKaL1C4qB"); - - const PercentileOnly = (featureFlags as any[]).find(item => item.id === "PercentileOnly"); - expect(PercentileOnly).not.undefined; - expect(PercentileOnly?.telemetry.metadata.AllocationId).equals("5YUbmP0P5s47zagO_LvI"); - - const SimpleConfigurationValue = (featureFlags as any[]).find(item => item.id === "SimpleConfigurationValue"); - expect(SimpleConfigurationValue).not.undefined; - expect(SimpleConfigurationValue?.telemetry.metadata.AllocationId).equals("QIOEOTQJr2AXo4dkFFqy"); - - const ComplexConfigurationValue = (featureFlags as any[]).find(item => item.id === "ComplexConfigurationValue"); - expect(ComplexConfigurationValue).not.undefined; - expect(ComplexConfigurationValue?.telemetry.metadata.AllocationId).equals("4Bes0AlwuO8kYX-YkBWs"); - - const TelemetryVariantPercentile = (featureFlags as any[]).find(item => item.id === "TelemetryVariantPercentile"); - expect(TelemetryVariantPercentile).not.undefined; - expect(TelemetryVariantPercentile?.telemetry.metadata.AllocationId).equals("YsdJ4pQpmhYa8KEhRLUn"); - - const Complete = (featureFlags as any[]).find(item => item.id === "Complete"); - expect(Complete).not.undefined; - expect(Complete?.telemetry.metadata.AllocationId).equals("DER2rF-ZYog95c4CBZoi"); - }); });