Skip to content

Commit 8f3d7c9

Browse files
committed
implement client manager
1 parent c288e85 commit 8f3d7c9

File tree

6 files changed

+339
-3
lines changed

6 files changed

+339
-3
lines changed

src/AzureAppConfigurationImpl.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAd
1414
import { RefreshTimer } from "./refresh/RefreshTimer";
1515
import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils";
1616
import { KeyFilter, LabelFilter, SettingSelector } from "./types";
17+
import { ConfigurationClientManager } from "./ConfigurationClientManager";
1718

1819
type PagedSettingSelector = SettingSelector & {
1920
/**
@@ -35,6 +36,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
3536
*/
3637
#sortedTrimKeyPrefixes: string[] | undefined;
3738
readonly #requestTracingEnabled: boolean;
39+
#clientManager: ConfigurationClientManager;
3840
#client: AppConfigurationClient;
3941
#options: AzureAppConfigurationOptions | undefined;
4042
#isInitialLoadCompleted: boolean = false;
@@ -575,4 +577,4 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
575577
} else {
576578
return getValidSelectors(selectors);
577579
}
578-
}
580+
}

src/AzureAppConfigurationOptions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export const MaxRetries = 2;
1111
export const MaxRetryDelayInMs = 60000;
1212

1313
export interface AzureAppConfigurationOptions {
14+
/**
15+
* Specifies whether enable replica discovery or not.
16+
*
17+
* @remarks
18+
* If not specified, the default value is true.
19+
*/
20+
replicaDiscoveryEnabled?: boolean;
21+
1422
/**
1523
* Specify what key-values to include in the configuration provider.
1624
*
@@ -47,4 +55,4 @@ export interface AzureAppConfigurationOptions {
4755
* Specifies options used to configure feature flags.
4856
*/
4957
featureFlagOptions?: FeatureFlagOptions;
50-
}
58+
}

src/ConfigurationClientManager.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration";
5+
import { ConfigurationClientWrapper } from "./ConfigurationClientWrapper"
6+
import { TokenCredential } from "@azure/identity";
7+
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions";
8+
import { getClientOptions } from "./load";
9+
10+
const TCP_ORIGIN = "_origin._tcp";
11+
const ALT = "_alt";
12+
const EndpointSection = "Endpoint";
13+
const IdSection = "Id";
14+
const SecretSection = "Secret";
15+
const AzConfigDomainLabel = ".azconfig."
16+
const AppConfigDomainLabel = ".appconfig."
17+
const FallbackClientRefreshExpireInterval = 60 * 60 * 1000; // 1 hour in milliseconds
18+
const MinimalClientRefreshInterval = 30 * 1000; // 30 seconds in milliseconds
19+
const MaxBackoffDuration = 10 * 60 * 1000; // 10 minutes in milliseconds
20+
const MinBackoffDuration = 30 * 1000; // 30 seconds in milliseconds
21+
const dns = require('dns').promises;
22+
23+
interface IConfigurationClientManager {
24+
getClients(): ConfigurationClientWrapper[];
25+
refreshClients(): Promise<void>;
26+
}
27+
28+
export class ConfigurationClientManager {
29+
#isFailoverable: boolean;
30+
#endpoint: string;
31+
#secret : string;
32+
#id : string;
33+
#credential: TokenCredential;
34+
#clientOptions: AppConfigurationClientOptions | undefined;
35+
#validDomain: string;
36+
#staticClients: ConfigurationClientWrapper[];
37+
#dynamicClients: ConfigurationClientWrapper[];
38+
#lastFallbackClientRefreshTime: number;
39+
#lastFallbackClientRefreshAttempt: number;
40+
41+
42+
constructor (
43+
connectionStringOrEndpoint?: string | URL,
44+
credentialOrOptions?: TokenCredential | AzureAppConfigurationOptions,
45+
appConfigOptions?: AzureAppConfigurationOptions
46+
) {
47+
let staticClient: AppConfigurationClient;
48+
let options: AzureAppConfigurationOptions;
49+
50+
if (typeof connectionStringOrEndpoint === "string") {
51+
const connectionString = connectionStringOrEndpoint;
52+
options = credentialOrOptions as AzureAppConfigurationOptions;
53+
this.#clientOptions = getClientOptions(options);
54+
staticClient = new AppConfigurationClient(connectionString, this.#clientOptions);
55+
this.#secret = parseConnectionString(connectionString, SecretSection);
56+
this.#id = parseConnectionString(connectionString, IdSection);
57+
// TODO: need to check if it's CDN or not
58+
this.#endpoint = parseConnectionString(connectionString, EndpointSection);
59+
60+
} else if (connectionStringOrEndpoint instanceof URL) {
61+
const credential = credentialOrOptions as TokenCredential;
62+
options = appConfigOptions as AzureAppConfigurationOptions;
63+
this.#clientOptions = getClientOptions(options);
64+
staticClient = new AppConfigurationClient(connectionStringOrEndpoint.toString(), credential, this.#clientOptions);
65+
this.#endpoint = connectionStringOrEndpoint.toString();
66+
this.#credential = credential;
67+
} else {
68+
throw new Error("Invalid endpoint URL.");
69+
}
70+
71+
this.#staticClients = [new ConfigurationClientWrapper(this.#endpoint, staticClient)];
72+
this.#validDomain = getValidDomain(this.#endpoint);
73+
74+
}
75+
76+
async getClients() {
77+
if (!this.#isFailoverable) {
78+
return this.#staticClients;
79+
}
80+
81+
const currentTime = Date.now();
82+
if (this.#isFallbackClientDiscoveryDue(currentTime)) {
83+
this.#lastFallbackClientRefreshAttempt = currentTime;
84+
await this.#discoverFallbackClients(this.#endpoint);
85+
}
86+
87+
// Filter static clients where BackoffEndTime is less than or equal to now
88+
let availableClients = this.#staticClients.filter(client => client.backoffEndTime <= currentTime);
89+
// If there are dynamic clients, filter and concatenate them
90+
if (this.#dynamicClients && this.#dynamicClients.length > 0) {
91+
availableClients = availableClients.concat(
92+
this.#dynamicClients
93+
.filter(client => client.backoffEndTime <= currentTime));
94+
}
95+
96+
return availableClients
97+
}
98+
99+
async refreshClients() {
100+
const currentTime = Date.now();
101+
if (this.#isFailoverable &&
102+
currentTime > new Date(this.#lastFallbackClientRefreshAttempt + MinimalClientRefreshInterval).getTime()) {
103+
this.#lastFallbackClientRefreshAttempt = currentTime;
104+
const url = new URL(this.#endpoint);
105+
await this.#discoverFallbackClients(url.hostname);
106+
}
107+
}
108+
109+
async #discoverFallbackClients(host) {
110+
const timeout = setTimeout(() => {
111+
}, 10000); // 10 seconds
112+
const srvResults = querySrvTargetHost(host);
113+
114+
try {
115+
const result = await Promise.race([srvResults, timeout]);
116+
117+
if (result === timeout) {
118+
throw new Error("SRV record query timed out.");
119+
}
120+
121+
const srvTargetHosts = result as string[];
122+
// Shuffle the list of SRV target hosts
123+
for (let i = srvTargetHosts.length - 1; i > 0; i--) {
124+
const j = Math.floor(Math.random() * (i + 1));
125+
[srvTargetHosts[i], srvTargetHosts[j]] = [srvTargetHosts[j], srvTargetHosts[i]];
126+
}
127+
128+
const newDynamicClients: ConfigurationClientWrapper[] = [];
129+
for (const host of srvTargetHosts) {
130+
if (isValidEndpoint(host, this.#validDomain)) {
131+
const targetEndpoint = `https://${host}`;
132+
if (targetEndpoint.toLowerCase() === this.#endpoint.toLowerCase()) {
133+
continue;
134+
}
135+
const client = this.#newConfigurationClient(targetEndpoint);
136+
newDynamicClients.push(new ConfigurationClientWrapper(targetEndpoint, client));
137+
}
138+
}
139+
140+
this.#dynamicClients = newDynamicClients;
141+
this.#lastFallbackClientRefreshTime = Date.now();
142+
} catch (err) {
143+
console.warn(`Fail to build fallback clients, ${err.message}`);
144+
}
145+
}
146+
147+
#newConfigurationClient(endpoint) {
148+
if (this.#credential) {
149+
return new AppConfigurationClient(endpoint, this.#credential, this.#clientOptions);
150+
}
151+
152+
const connectionStr = buildConnectionString(endpoint, this.#secret, this.#id);
153+
return new AppConfigurationClient(connectionStr, this.#clientOptions);
154+
}
155+
156+
#isFallbackClientDiscoveryDue(dateTime) {
157+
return dateTime >= this.#lastFallbackClientRefreshAttempt + MinimalClientRefreshInterval
158+
&& (!this.#dynamicClients
159+
|| this.#dynamicClients.every(client => dateTime < client.backoffEndTime)
160+
|| dateTime >= this.#lastFallbackClientRefreshTime + FallbackClientRefreshExpireInterval);
161+
}
162+
}
163+
164+
/**
165+
* Query SRV records and return target hosts.
166+
* @param {string} host - The host to query.
167+
* @returns {Promise<string[]>} - A promise that resolves to an array of target hosts.
168+
*/
169+
async function querySrvTargetHost(host) {
170+
const results: string[] = [];
171+
172+
try {
173+
// Look up SRV records for the origin host
174+
const originRecords = await dns.resolveSrv(`${TCP_ORIGIN}.${host}`);
175+
if (originRecords.length === 0) {
176+
return results;
177+
}
178+
179+
// Add the first origin record to results
180+
const originHost = originRecords[0].name
181+
results.push(originHost);
182+
183+
// Look up SRV records for alternate hosts
184+
let index = 0;
185+
while (true) {
186+
const currentAlt = `${ALT}${index}`;
187+
try {
188+
const altRecords = await dns.resolveSrv(`_${currentAlt}._tcp.${originHost}`);
189+
if (altRecords.length === 0) {
190+
break; // No more alternate records, exit loop
191+
}
192+
193+
altRecords.forEach(record => {
194+
const altHost = record.name;
195+
if (altHost) {
196+
results.push(altHost);
197+
}
198+
});
199+
index++;
200+
} catch (err) {
201+
if (err.code === 'ENOTFOUND') {
202+
break; // No more alternate records, exit loop
203+
} else {
204+
throw new Error(`Failed to lookup alternate SRV records: ${err.message}`);
205+
}
206+
}
207+
}
208+
} catch (err) {
209+
throw new Error(`Failed to lookup origin SRV records: ${err.message}`);
210+
}
211+
212+
return results;
213+
}
214+
215+
/**
216+
* Parses the connection string to extract the value associated with a specific token.
217+
*
218+
* @param {string} connectionString - The connection string containing tokens.
219+
* @param {string} token - The token whose value needs to be extracted.
220+
* @returns {string} The value associated with the token, or an empty string if not found.
221+
* @throws {Error} If the connection string is empty or the token is not found.
222+
*/
223+
function parseConnectionString(connectionString, token) {
224+
if (!connectionString) {
225+
throw new Error("connectionString is empty");
226+
}
227+
228+
// Token format is "token="
229+
const searchToken = `${token}=`;
230+
const startIndex = connectionString.indexOf(searchToken);
231+
if (startIndex === -1) {
232+
throw new Error(`Token ${token} not found in connectionString`);
233+
}
234+
235+
// Move startIndex to the beginning of the token value
236+
const valueStartIndex = startIndex + searchToken.length;
237+
const endIndex = connectionString.indexOf(';', valueStartIndex);
238+
const valueEndIndex = endIndex === -1 ? connectionString.length : endIndex;
239+
240+
// Extract and return the token value
241+
return connectionString.substring(valueStartIndex, valueEndIndex);
242+
}
243+
244+
/**
245+
* Builds a connection string from the given endpoint, secret, and id.
246+
* Returns an empty string if either secret or id is empty.
247+
*
248+
* @param {string} endpoint - The endpoint to include in the connection string.
249+
* @param {string} secret - The secret to include in the connection string.
250+
* @param {string} id - The ID to include in the connection string.
251+
* @returns {string} - The formatted connection string or an empty string if invalid input.
252+
*/
253+
function buildConnectionString(endpoint, secret, id) {
254+
if (!secret || !id) {
255+
return '';
256+
}
257+
258+
return `${EndpointSection}=${endpoint};${IdSection}=${id};${SecretSection}=${secret}`;
259+
}
260+
261+
/**
262+
* Extracts a valid domain from the given endpoint URL based on trusted domain labels.
263+
*
264+
* @param {string} endpoint - The endpoint URL.
265+
* @returns {string} - The valid domain or an empty string if no valid domain is found.
266+
*/
267+
function getValidDomain(endpoint) {
268+
try {
269+
const url = new URL(endpoint);
270+
const trustedDomainLabels = [AzConfigDomainLabel, AppConfigDomainLabel];
271+
const host = url.hostname.toLowerCase();
272+
273+
for (const label of trustedDomainLabels) {
274+
const index = host.lastIndexOf(label);
275+
if (index !== -1) {
276+
return host.substring(index);
277+
}
278+
}
279+
} catch (error) {
280+
console.error("Error parsing URL:", error.message);
281+
}
282+
283+
return "";
284+
}
285+
286+
/**
287+
* Checks if the given host ends with the valid domain.
288+
*
289+
* @param {string} host - The host to be validated.
290+
* @param {string} validDomain - The valid domain to check against.
291+
* @returns {boolean} - True if the host ends with the valid domain, false otherwise.
292+
*/
293+
function isValidEndpoint(host, validDomain) {
294+
if (!validDomain) {
295+
return false;
296+
}
297+
298+
return host.toLowerCase().endsWith(validDomain.toLowerCase());
299+
}
300+
301+
302+

src/ConfigurationClientWrapper.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { AppConfigurationClient } from "@azure/app-configuration";
5+
6+
export class ConfigurationClientWrapper {
7+
endpoint: string;
8+
client: AppConfigurationClient;
9+
backoffEndTime: number;
10+
failedAttempts: number = 0;
11+
12+
constructor(endpoint: string, client: AppConfigurationClient) {
13+
this.endpoint = endpoint;
14+
this.client = client;
15+
}
16+
}

src/load.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ function instanceOfTokenCredential(obj: unknown) {
8282
return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function";
8383
}
8484

85-
function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurationClientOptions | undefined {
85+
export function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurationClientOptions | undefined {
8686
// user-agent
8787
let userAgentPrefix = RequestTracing.USER_AGENT_PREFIX; // Default UA for JavaScript Provider
8888
const userAgentOptions = options?.clientOptions?.userAgentOptions;

src/requestTracing/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,11 @@ function isWebWorker() {
165165

166166
return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected;
167167
}
168+
169+
export function isFailoverableEnv() {
170+
if (isBrowser() || isWebWorker()) {
171+
return false;
172+
}
173+
174+
return true;
175+
}

0 commit comments

Comments
 (0)