Skip to content

Merge main to release v2 #177

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
123 changes: 0 additions & 123 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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 || {})
};
}
Expand Down Expand Up @@ -769,116 +756,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
return featureFlagReference;
}

async #generateAllocationId(featureFlag: any): Promise<string> {
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[] {
Expand Down
73 changes: 32 additions & 41 deletions src/ConfigurationClientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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)) {
Expand All @@ -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<string[]> {
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;
Expand All @@ -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}`);
}
Expand Down
22 changes: 0 additions & 22 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -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<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading