From ce9f550a5983c59691c8a4d4e14f2a0707d06f75 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 5 Nov 2024 04:05:55 +0800 Subject: [PATCH 01/52] use pipeline policy to ensure cdn request uses correct api version --- src/load.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/load.ts b/src/load.ts index f5e2075d..55927e5a 100644 --- a/src/load.ts +++ b/src/load.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration"; +import { PipelinePolicy, PipelineRequest, SendRequest } from "@azure/core-rest-pipeline"; import { TokenCredential } from "@azure/identity"; import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; @@ -96,6 +97,27 @@ export async function loadFromCdn( const emptyTokenCredential: TokenCredential = { getToken: async () => ({ token: "", expiresOnTimestamp: 0 }) }; + // the api version supports sas token authentication + const apiVersion = "2024-09-01-preview"; + const policyName = "CdnRequestApiVersionPolicy"; + + const apiVersionPolicy: PipelinePolicy = { + name: policyName, + sendRequest: async (request: PipelineRequest, next: SendRequest) => { + const url = new URL(request.url); + url.searchParams.set("api-version", apiVersion); + request.url = url.toString(); + return next(request); + }, + }; + + if (appConfigOptions === undefined) { + appConfigOptions = { clientOptions: {}}; + } + const policies = appConfigOptions.clientOptions?.additionalPolicies || []; + policies.push({policy: apiVersionPolicy, position: "perCall"}); + appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, additionalPolicies: policies}; + return await load(cdnEndpoint, emptyTokenCredential, appConfigOptions); } From 2adadc70e3e44cdd4f589e3ce0b3f54ab50f62f4 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 5 Nov 2024 04:10:45 +0800 Subject: [PATCH 02/52] fix lint & add comments --- src/load.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/load.ts b/src/load.ts index 55927e5a..fd2ca54b 100644 --- a/src/load.ts +++ b/src/load.ts @@ -97,26 +97,27 @@ export async function loadFromCdn( const emptyTokenCredential: TokenCredential = { getToken: async () => ({ token: "", expiresOnTimestamp: 0 }) }; + // the api version supports sas token authentication const apiVersion = "2024-09-01-preview"; const policyName = "CdnRequestApiVersionPolicy"; - const apiVersionPolicy: PipelinePolicy = { name: policyName, sendRequest: async (request: PipelineRequest, next: SendRequest) => { const url = new URL(request.url); url.searchParams.set("api-version", apiVersion); - request.url = url.toString(); + request.url = url.toString(); return next(request); }, }; - if (appConfigOptions === undefined) { appConfigOptions = { clientOptions: {}}; } const policies = appConfigOptions.clientOptions?.additionalPolicies || []; - policies.push({policy: apiVersionPolicy, position: "perCall"}); - appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, additionalPolicies: policies}; + // The policy position should be perRetry so that the policy will be processed after the api version policy added by the SDK. + // https://learn.microsoft.com/en-us/dotnet/api/azure.core.httppipelineposition?view=azure-dotnet#fields + policies.push({policy: apiVersionPolicy, position: "perRetry"}); + appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, additionalPolicies: policies}; return await load(cdnEndpoint, emptyTokenCredential, appConfigOptions); } From 04d8a2c8bc00d04b54be39e2e9b21338cca9f17a Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 7 Nov 2024 13:31:33 +0800 Subject: [PATCH 03/52] update --- package-lock.json | 32 +++++++++++++++++++++++++++----- package.json | 2 +- src/load.ts | 19 ++----------------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac1204cd..a1afebcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.2", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" }, @@ -57,11 +57,11 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.1.tgz", - "integrity": "sha512-pk8zyG/8Nc6VN7uDA9QY19UFhTXneUbnB+5IcW9uuPyVDXU17TcXBI4xY1ZBm7hmhn0yh3CeZK4kOxa/tjsMqQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.8.0.tgz", + "integrity": "sha512-RO4IGZMa3hI1yVhvb5rPr+r+UDxe4VDxbntFZIc5fsUPGqZbKzmGR2wABEtlrC2SU5YX6tL+NS3xWb4vf1M9lQ==", "dependencies": { - "@azure/abort-controller": "^1.0.0", + "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", "@azure/core-http-compat": "^2.0.0", @@ -77,6 +77,17 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.0.1.tgz", @@ -90,6 +101,17 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat/node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@azure/core-auth": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.5.0.tgz", diff --git a/package.json b/package.json index 0b493ea3..0dffefe1 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "uuid": "^9.0.1" }, "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" } diff --git a/src/load.ts b/src/load.ts index fd2ca54b..b36175f3 100644 --- a/src/load.ts +++ b/src/load.ts @@ -98,26 +98,11 @@ export async function loadFromCdn( getToken: async () => ({ token: "", expiresOnTimestamp: 0 }) }; - // the api version supports sas token authentication - const apiVersion = "2024-09-01-preview"; - const policyName = "CdnRequestApiVersionPolicy"; - const apiVersionPolicy: PipelinePolicy = { - name: policyName, - sendRequest: async (request: PipelineRequest, next: SendRequest) => { - const url = new URL(request.url); - url.searchParams.set("api-version", apiVersion); - request.url = url.toString(); - return next(request); - }, - }; if (appConfigOptions === undefined) { appConfigOptions = { clientOptions: {}}; } - const policies = appConfigOptions.clientOptions?.additionalPolicies || []; - // The policy position should be perRetry so that the policy will be processed after the api version policy added by the SDK. - // https://learn.microsoft.com/en-us/dotnet/api/azure.core.httppipelineposition?view=azure-dotnet#fields - policies.push({policy: apiVersionPolicy, position: "perRetry"}); - appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, additionalPolicies: policies}; + // Specify the api version that supports sas token authentication + appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, apiVersion: "2024-09-01-preview"}; return await load(cdnEndpoint, emptyTokenCredential, appConfigOptions); } From bf99b318173fd176d7eb0f82722b2e3b384b03cb Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 7 Nov 2024 14:01:53 +0800 Subject: [PATCH 04/52] fix lint --- src/load.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/load.ts b/src/load.ts index b36175f3..be5a3679 100644 --- a/src/load.ts +++ b/src/load.ts @@ -2,7 +2,6 @@ // Licensed under the MIT license. import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration"; -import { PipelinePolicy, PipelineRequest, SendRequest } from "@azure/core-rest-pipeline"; import { TokenCredential } from "@azure/identity"; import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; From 62ac2b9e837c17e3f28c4ceda4c86cbd73c14bb6 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Mon, 11 Nov 2024 01:33:48 +0800 Subject: [PATCH 05/52] add request tracing for cdn --- package-lock.json | 2 +- src/AzureAppConfigurationImpl.ts | 6 +++++- src/load.ts | 11 ++++++----- src/requestTracing/constants.ts | 1 + src/requestTracing/utils.ts | 16 +++++++++++----- test/exportedApi.ts | 2 +- test/requestTracing.test.ts | 14 +++++++++++++- 7 files changed, 38 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45ddd2a1..8c1a7fbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "1.1.2", + "version": "2.0.0-preview.1", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.8.0", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 977872a8..4d7841d3 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -59,6 +59,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #client: AppConfigurationClient; #clientEndpoint: string | undefined; #options: AzureAppConfigurationOptions | undefined; + #isCdnUsed: boolean; #isInitialLoadCompleted: boolean = false; // Refresh @@ -80,11 +81,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { constructor( client: AppConfigurationClient, clientEndpoint: string | undefined, - options: AzureAppConfigurationOptions | undefined + options: AzureAppConfigurationOptions | undefined, + isCdnUsed: boolean ) { this.#client = client; this.#clientEndpoint = clientEndpoint; this.#options = options; + this.#isCdnUsed = isCdnUsed; // Enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); @@ -197,6 +200,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return { requestTracingEnabled: this.#requestTracingEnabled, initialLoadCompleted: this.#isInitialLoadCompleted, + isCdnUsed: this.#isCdnUsed, appConfigOptions: this.#options }; } diff --git a/src/load.ts b/src/load.ts index be5a3679..42502599 100644 --- a/src/load.ts +++ b/src/load.ts @@ -10,6 +10,11 @@ import * as RequestTracing from "./requestTracing/constants.js"; const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5000; // 5 seconds +// Empty token credential to be used when loading from CDN +const emptyTokenCredential: TokenCredential = { + getToken: async () => ({ token: "", expiresOnTimestamp: 0 }) +}; + /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. * @param connectionString The connection string for the App Configuration store. @@ -67,7 +72,7 @@ export async function load( } try { - const appConfiguration = new AzureAppConfigurationImpl(client, clientEndpoint, options); + const appConfiguration = new AzureAppConfigurationImpl(client, clientEndpoint, options, credentialOrOptions === emptyTokenCredential); await appConfiguration.load(); return appConfiguration; } catch (error) { @@ -93,10 +98,6 @@ export async function loadFromCdn( cdnEndpoint: string | URL, appConfigOptions?: AzureAppConfigurationOptions ): Promise { - const emptyTokenCredential: TokenCredential = { - getToken: async () => ({ token: "", expiresOnTimestamp: 0 }) - }; - if (appConfigOptions === undefined) { appConfigOptions = { clientOptions: {}}; } diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index d46cdfda..9bd64c15 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -46,3 +46,4 @@ export enum RequestType { // Tag names export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; +export const CDN_USED_TAG = "CDN"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index de335737..99a748f0 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -13,6 +13,7 @@ import { HOST_TYPE_KEY, HostType, KEY_VAULT_CONFIGURED_TAG, + CDN_USED_TAG, KUBERNETES_ENV_VAR, NODEJS_DEV_ENV_VAL, NODEJS_ENV_VAR, @@ -27,18 +28,19 @@ export function listConfigurationSettingsWithTrace( requestTracingOptions: { requestTracingEnabled: boolean; initialLoadCompleted: boolean; + isCdnUsed: boolean; appConfigOptions: AzureAppConfigurationOptions | undefined; }, client: AppConfigurationClient, listOptions: ListConfigurationSettingsOptions ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions } = requestTracingOptions; + const { requestTracingEnabled, initialLoadCompleted, isCdnUsed, appConfigOptions } = requestTracingOptions; const actualListOptions = { ...listOptions }; if (requestTracingEnabled) { actualListOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed) } }; } @@ -50,19 +52,20 @@ export function getConfigurationSettingWithTrace( requestTracingOptions: { requestTracingEnabled: boolean; initialLoadCompleted: boolean; + isCdnUsed: boolean; appConfigOptions: AzureAppConfigurationOptions | undefined; }, client: AppConfigurationClient, configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions, ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions } = requestTracingOptions; + const { requestTracingEnabled, initialLoadCompleted, isCdnUsed, appConfigOptions } = requestTracingOptions; const actualGetOptions = { ...getOptions }; if (requestTracingEnabled) { actualGetOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed) } }; } @@ -70,7 +73,7 @@ export function getConfigurationSettingWithTrace( return client.getConfigurationSetting(configurationSettingId, actualGetOptions); } -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean): string { +export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean, isCdnUsed: boolean): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -89,6 +92,9 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt tags.push(KEY_VAULT_CONFIGURED_TAG); } } + if (isCdnUsed) { + tags.push(CDN_USED_TAG); + } const contextParts: string[] = []; for (const [k, v] of keyValues) { diff --git a/test/exportedApi.ts b/test/exportedApi.ts index c49bc1a3..ea39e7ce 100644 --- a/test/exportedApi.ts +++ b/test/exportedApi.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { load } from "../src"; +export { load, loadFromCdn } from "../src"; diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index d4e7edcf..2d873ad5 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { createMockedConnectionString, createMockedKeyValue, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sleepInMs } from "./utils/testHelper.js"; -import { load } from "./exportedApi.js"; +import { load, loadFromCdn } from "./exportedApi.js"; class HttpRequestHeadersPolicy { headers: any; @@ -77,6 +77,18 @@ describe("request tracing", function () { expect(correlationContext.includes("UsesKeyVault")).eq(true); }); + it("should have cdn tag in correlation-context header", async () => { + try { + await loadFromCdn(fakeEndpoint, { + clientOptions + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("CDN")).eq(true); + }); + it("should detect env in correlation-context header", async () => { process.env.NODE_ENV = "development"; try { From bd1c8757da048b6d62eb0375fa24449453c4b061 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 14 Nov 2024 13:39:02 +0800 Subject: [PATCH 06/52] only send conditional request when cdn is not used --- src/AzureAppConfigurationImpl.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 4d7841d3..1970cc05 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -421,11 +421,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let needRefresh = false; for (const sentinel of this.#sentinels.values()) { const response = await this.#getConfigurationSetting(sentinel, { - onlyIfChanged: true + onlyIfChanged: !this.#isCdnUsed // if CDN is used, do not send conditional request }); - if (response?.statusCode === 200 // created or changed - || (response === undefined && sentinel.etag !== undefined) // deleted + if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || + (response === undefined && sentinel.etag !== undefined) // deleted ) { sentinel.etag = response?.etag;// update etag of the sentinel needRefresh = true; From 08567078a0313d9c0a66cc0e3dacd33e4ed3c5a3 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 14 Nov 2024 13:44:58 +0800 Subject: [PATCH 07/52] add testcase --- test/requestTracing.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 2d873ad5..57908ca1 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -77,7 +77,7 @@ describe("request tracing", function () { expect(correlationContext.includes("UsesKeyVault")).eq(true); }); - it("should have cdn tag in correlation-context header", async () => { + it("should have cdn tag in correlation-context header when loadFromCdn is used", async () => { try { await loadFromCdn(fakeEndpoint, { clientOptions @@ -89,6 +89,18 @@ describe("request tracing", function () { 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 + }); + } catch (e) { /* 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 2b78b27ac42b2322793e0e7b296e4a8a793c6867 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 15 Nov 2024 11:58:03 +0800 Subject: [PATCH 08/52] fix lint --- src/AzureAppConfigurationImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 1970cc05..74f3dca5 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -424,7 +424,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { onlyIfChanged: !this.#isCdnUsed // if CDN is used, do not send conditional request }); - if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || + if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || (response === undefined && sentinel.etag !== undefined) // deleted ) { sentinel.etag = response?.etag;// update etag of the sentinel From fdd30e229746b0aa3f580b7f070916cf275bf8c0 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 19 Nov 2024 01:11:38 +0800 Subject: [PATCH 09/52] refresh based on page etag --- rollup.config.mjs | 2 +- src/AzureAppConfigurationImpl.ts | 159 +++++++++++++++---------------- src/RefreshOptions.ts | 5 + 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 1cd15dfc..8ad51640 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,7 +4,7 @@ import dts from "rollup-plugin-dts"; export default [ { - external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto"], + external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto", "dns/promises"], input: "src/index.ts", output: [ { diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 08f3bb3e..99e46bfe 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -76,6 +76,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #featureFlagRefreshTimer: RefreshTimer; // selectors + #keyValueSelectors: PagedSettingSelector[] = []; #featureFlagSelectors: PagedSettingSelector[] = []; constructor( @@ -93,35 +94,38 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (options?.refreshOptions?.enabled) { - const { watchedSettings, refreshIntervalInMs } = options.refreshOptions; - // validate watched settings - if (watchedSettings === undefined || watchedSettings.length === 0) { - throw new Error("Refresh is enabled but no watched settings are specified."); + const { watchedSettings, refreshIntervalInMs, watchAll } = options.refreshOptions; + // validate refresh options + if (watchAll !== true) { + if (watchedSettings === undefined || watchedSettings.length === 0) { + throw new Error("Refresh is enabled but no watched settings are specified."); + } else { + for (const setting of watchedSettings) { + if (setting.key.includes("*") || setting.key.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in key of watched settings."); + } + if (setting.label?.includes("*") || setting.label?.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in label of watched settings."); + } + this.#sentinels.push(setting); + } + } + } else if (watchedSettings && watchedSettings.length > 0) { + throw new Error("Watched settings should not be specified when registerAll is enabled."); } - // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { this.#refreshInterval = refreshIntervalInMs; } } - - for (const setting of watchedSettings) { - if (setting.key.includes("*") || setting.key.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in key of watched settings."); - } - if (setting.label?.includes("*") || setting.label?.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label of watched settings."); - } - this.#sentinels.push(setting); - } - this.#refreshTimer = new RefreshTimer(this.#refreshInterval); } + this.#keyValueSelectors = getValidKeyValueSelectors(options?.selectors); + // feature flag options if (options?.featureFlagOptions?.enabled) { // validate feature flag selectors @@ -184,6 +188,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return !!this.#options?.refreshOptions?.enabled; } + get #watchAll(): boolean { + return !!this.#options?.refreshOptions?.watchAll; + } + get #featureFlagEnabled(): boolean { return !!this.#options?.featureFlagOptions?.enabled; } @@ -228,29 +236,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { throw new Error("All clients failed to get configuration settings."); } - async #loadSelectedKeyValues(): Promise { - // validate selectors - const selectors = getValidKeyValueSelectors(this.#options?.selectors); - + async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { + const selectors = loadFeatureFlag ? this.#featureFlagSelectors : this.#keyValueSelectors; const funcToExecute = async (client) => { const loadedSettings: ConfigurationSetting[] = []; - for (const selector of selectors) { + // deep copy selectors to avoid modification if current client fails + const selectorsToUpdate = JSON.parse( + JSON.stringify(selectors) + ); + + for (const selector of selectorsToUpdate) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter }; - const settings = listConfigurationSettingsWithTrace( + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, client, listOptions - ); - - for await (const setting of settings) { - if (!isFeatureFlag(setting)) { // exclude feature flags - loadedSettings.push(setting); + ).byPage(); + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } } } + selector.pageEtags = pageEtags; + } + + if (loadFeatureFlag) { + this.#featureFlagSelectors = selectorsToUpdate; + } else { + this.#keyValueSelectors = selectorsToUpdate; } return loadedSettings; }; @@ -262,10 +283,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Update 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. */ async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { - if (!this.#refreshEnabled) { - return; - } - for (const sentinel of this.#sentinels) { const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); if (matchedSetting) { @@ -285,8 +302,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #loadSelectedAndWatchedKeyValues() { const keyValues: [key: string, value: unknown][] = []; - const loadedSettings = await this.#loadSelectedKeyValues(); - await this.#updateWatchedKeyValuesEtag(loadedSettings); + const loadedSettings = await this.#loadConfigurationSettings(); + if (this.#refreshEnabled && !this.#watchAll) { + await this.#updateWatchedKeyValuesEtag(loadedSettings); + } // process key-values, watched settings have higher priority for (const setting of loadedSettings) { @@ -309,42 +328,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } async #loadFeatureFlags() { - // Temporary map to store feature flags, key is the key of the setting, value is the raw value of the setting - const funcToExecute = async (client) => { - const featureFlagSettings: ConfigurationSetting[] = []; - // deep copy selectors to avoid modification if current client fails - const selectors = JSON.parse( - JSON.stringify(this.#featureFlagSelectors) - ); - - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, - labelFilter: selector.labelFilter - }; - - const pageEtags: string[] = []; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - client, - listOptions - ).byPage(); - for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); - for (const setting of page.items) { - if (isFeatureFlag(setting)) { - featureFlagSettings.push(setting); - } - } - } - selector.pageEtags = pageEtags; - } - - this.#featureFlagSelectors = selectors; - return featureFlagSettings; - }; - - const featureFlagSettings = await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; + const loadFeatureFlag = true; + const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); // parse feature flags const featureFlags = await Promise.all( @@ -458,6 +443,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // try refresh if any of watched settings is changed. let needRefresh = false; + if (this.#watchAll) { + needRefresh = await this.#checkKeyValueCollectionChanged(this.#keyValueSelectors); + } for (const sentinel of this.#sentinels.values()) { const response = await this.#getConfigurationSetting(sentinel, { onlyIfChanged: true @@ -490,11 +478,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } - // check if any feature flag is changed + const needRefresh = await this.#checkKeyValueCollectionChanged(this.#featureFlagSelectors); + if (needRefresh) { + await this.#loadFeatureFlags(); + } + + this.#featureFlagRefreshTimer.reset(); + return Promise.resolve(needRefresh); + } + + async #checkKeyValueCollectionChanged(selectors: PagedSettingSelector[]): Promise { const funcToExecute = async (client) => { - for (const selector of this.#featureFlagSelectors) { + for (const selector of selectors) { const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, + keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, pageEtags: selector.pageEtags }; @@ -514,13 +511,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return false; }; - const needRefresh: boolean = await this.#executeWithFailoverPolicy(funcToExecute); - if (needRefresh) { - await this.#loadFeatureFlags(); - } - - this.#featureFlagRefreshTimer.reset(); - return Promise.resolve(needRefresh); + const isChanged = await this.#executeWithFailoverPolicy(funcToExecute); + return isChanged; } onRefresh(listener: () => any, thisArg?: any): Disposable { @@ -813,7 +805,7 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { } function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (!selectors || selectors.length === 0) { + if (selectors === undefined || selectors.length === 0) { // Default selector: key: *, label: \0 return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; } @@ -821,10 +813,13 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect } function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (!selectors || selectors.length === 0) { + if (selectors === undefined || selectors.length === 0) { // selectors must be explicitly provided. throw new Error("Feature flag selectors must be provided."); } else { + selectors.forEach(selector => { + selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; + }); return getValidSelectors(selectors); } } diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts index 37425112..af51ba2b 100644 --- a/src/RefreshOptions.ts +++ b/src/RefreshOptions.ts @@ -24,6 +24,11 @@ export interface RefreshOptions { * Any modifications to watched settings will refresh all settings loaded by the configuration provider when refresh() is called. */ watchedSettings?: WatchedSetting[]; + + /** + * Specifies whether all configuration settings will be watched for changes on the server. + */ + watchAll?: boolean; } export interface FeatureFlagRefreshOptions { From b4afe7ab0ea13be25a1f2cbb8779ffc83cdfc0f8 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 19 Nov 2024 01:59:46 +0800 Subject: [PATCH 10/52] merge preview --- src/AzureAppConfigurationImpl.ts | 4 ++-- src/load.ts | 2 +- src/requestTracing/utils.ts | 39 ++++++++------------------------ 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index d92d1af9..6dcae817 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -200,8 +200,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { requestTracingEnabled: this.#requestTracingEnabled, initialLoadCompleted: this.#isInitialLoadCompleted, isCdnUsed: this.#isCdnUsed, - appConfigOptions: this.#options, - isFailoverRequest: this.#isFailoverRequest + isFailoverRequest: this.#isFailoverRequest, + appConfigOptions: this.#options }; } diff --git a/src/load.ts b/src/load.ts index 35b443ee..2df15f62 100644 --- a/src/load.ts +++ b/src/load.ts @@ -79,4 +79,4 @@ export async function loadFromCdn( appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, apiVersion: "2024-09-01-preview"}; return await load(cdnEndpoint, emptyTokenCredential, appConfigOptions); -} \ No newline at end of file +} diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 64b405c2..8a94ae16 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -30,27 +30,19 @@ export function listConfigurationSettingsWithTrace( requestTracingEnabled: boolean; initialLoadCompleted: boolean; isCdnUsed: boolean; - appConfigOptions: AzureAppConfigurationOptions | undefined; isFailoverRequest: boolean; + appConfigOptions: AzureAppConfigurationOptions | undefined; }, client: AppConfigurationClient, listOptions: ListConfigurationSettingsOptions ) { -<<<<<<< HEAD - const { requestTracingEnabled, initialLoadCompleted, isCdnUsed, appConfigOptions } = requestTracingOptions; -======= - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, isFailoverRequest } = requestTracingOptions; ->>>>>>> 477f18de35107c6ec7ac6d6b9f4df0a32925082d + const { requestTracingEnabled, initialLoadCompleted, isCdnUsed, isFailoverRequest, appConfigOptions } = requestTracingOptions; const actualListOptions = { ...listOptions }; if (requestTracingEnabled) { actualListOptions.requestOptions = { customHeaders: { -<<<<<<< HEAD - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed) -======= - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isFailoverRequest) ->>>>>>> 477f18de35107c6ec7ac6d6b9f4df0a32925082d + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed, isFailoverRequest) } }; } @@ -70,21 +62,13 @@ export function getConfigurationSettingWithTrace( configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions, ) { -<<<<<<< HEAD - const { requestTracingEnabled, initialLoadCompleted, isCdnUsed, appConfigOptions } = requestTracingOptions; -======= - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, isFailoverRequest } = requestTracingOptions; ->>>>>>> 477f18de35107c6ec7ac6d6b9f4df0a32925082d + const { requestTracingEnabled, initialLoadCompleted, isCdnUsed, isFailoverRequest, appConfigOptions } = requestTracingOptions; const actualGetOptions = { ...getOptions }; if (requestTracingEnabled) { actualGetOptions.requestOptions = { customHeaders: { -<<<<<<< HEAD - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed) -======= - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isFailoverRequest) ->>>>>>> 477f18de35107c6ec7ac6d6b9f4df0a32925082d + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed, isFailoverRequest) } }; } @@ -92,11 +76,7 @@ export function getConfigurationSettingWithTrace( return client.getConfigurationSetting(configurationSettingId, actualGetOptions); } -<<<<<<< HEAD -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean, isCdnUsed: boolean): string { -======= -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean, isFailoverRequest: boolean): string { ->>>>>>> 477f18de35107c6ec7ac6d6b9f4df0a32925082d +export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean, isCdnUsed: boolean, isFailoverRequest: boolean): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -118,6 +98,9 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt if (isCdnUsed) { tags.push(CDN_USED_TAG); } + if (isFailoverRequest) { + tags.push(FAILOVER_REQUEST_TAG); + } const contextParts: string[] = []; for (const [k, v] of keyValues) { @@ -129,10 +112,6 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt contextParts.push(tag); } - if (isFailoverRequest) { - contextParts.push(FAILOVER_REQUEST_TAG); - } - return contextParts.join(","); } From 50fc5b66860b667a7ee89add790a5283e9d02dc9 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 19 Nov 2024 17:14:32 +0800 Subject: [PATCH 11/52] remove watchAll & reorganize the code --- src/AzureAppConfigurationImpl.ts | 430 ++++++++++++++++--------------- src/RefreshOptions.ts | 8 +- test/refresh.test.ts | 19 -- 3 files changed, 228 insertions(+), 229 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 99e46bfe..b60a52a6 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -63,6 +63,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #isFailoverRequest: boolean = false; // Refresh + #watchAll: boolean = false; #refreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #onRefreshListeners: Array<() => any> = []; /** @@ -75,8 +76,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #featureFlagRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #featureFlagRefreshTimer: RefreshTimer; - // selectors + /** + * selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors + */ #keyValueSelectors: PagedSettingSelector[] = []; + /** + * selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors + */ #featureFlagSelectors: PagedSettingSelector[] = []; constructor( @@ -94,25 +100,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (options?.refreshOptions?.enabled) { - const { watchedSettings, refreshIntervalInMs, watchAll } = options.refreshOptions; - // validate refresh options - if (watchAll !== true) { - if (watchedSettings === undefined || watchedSettings.length === 0) { - throw new Error("Refresh is enabled but no watched settings are specified."); - } else { - for (const setting of watchedSettings) { - if (setting.key.includes("*") || setting.key.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in key of watched settings."); - } - if (setting.label?.includes("*") || setting.label?.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label of watched settings."); - } - this.#sentinels.push(setting); + const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; + if (watchedSettings === undefined || watchedSettings.length === 0) { + this.#watchAll = true; // if no watched settings is specified, then watch all + } else { + for (const setting of watchedSettings) { + if (setting.key.includes("*") || setting.key.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in key of watched settings."); + } + if (setting.label?.includes("*") || setting.label?.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in label of watched settings."); } + this.#sentinels.push(setting); } - } else if (watchedSettings && watchedSettings.length > 0) { - throw new Error("Watched settings should not be specified when registerAll is enabled."); } + // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { @@ -150,6 +152,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#adapters.push(new JsonKeyValueAdapter()); } + get #refreshEnabled(): boolean { + return !!this.#options?.refreshOptions?.enabled; + } + + get #featureFlagEnabled(): boolean { + return !!this.#options?.featureFlagOptions?.enabled; + } + + get #featureFlagRefreshEnabled(): boolean { + return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; + } + + get #requestTraceOptions() { + return { + requestTracingEnabled: this.#requestTracingEnabled, + initialLoadCompleted: this.#isInitialLoadCompleted, + appConfigOptions: this.#options, + isFailoverRequest: this.#isFailoverRequest + }; + } + // #region ReadonlyMap APIs get(key: string): T | undefined { return this.#configMap.get(key); @@ -184,58 +207,125 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // #endregion - get #refreshEnabled(): boolean { - return !!this.#options?.refreshOptions?.enabled; + /** + * Loads the configuration store for the first time. + */ + async load() { + await this.#loadSelectedAndWatchedKeyValues(); + if (this.#featureFlagEnabled) { + await this.#loadFeatureFlags(); + } + // Mark all settings have loaded at startup. + this.#isInitialLoadCompleted = true; } - get #watchAll(): boolean { - return !!this.#options?.refreshOptions?.watchAll; - } + /** + * Constructs hierarchical data object from map. + */ + constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record { + const separator = options?.separator ?? "."; + const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"]; + if (!validSeparators.includes(separator)) { + throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`); + } - get #featureFlagEnabled(): boolean { - return !!this.#options?.featureFlagOptions?.enabled; - } + // construct hierarchical data object from map + const data: Record = {}; + for (const [key, value] of this.#configMap) { + const segments = key.split(separator); + let current = data; + // construct hierarchical data object along the path + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + // undefined or empty string + if (!segment) { + throw new Error(`invalid key: ${key}`); + } + // create path if not exist + if (current[segment] === undefined) { + current[segment] = {}; + } + // The path has been occupied by a non-object value, causing ambiguity. + if (typeof current[segment] !== "object") { + throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The path '${segments.slice(0, i + 1).join(separator)}' has been occupied.`); + } + current = current[segment]; + } - get #featureFlagRefreshEnabled(): boolean { - return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; + const lastSegment = segments[segments.length - 1]; + if (current[lastSegment] !== undefined) { + throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`); + } + // set value to the last segment + current[lastSegment] = value; + } + return data; } - get #requestTraceOptions() { - return { - requestTracingEnabled: this.#requestTracingEnabled, - initialLoadCompleted: this.#isInitialLoadCompleted, - appConfigOptions: this.#options, - isFailoverRequest: this.#isFailoverRequest - }; - } + /** + * Refreshes the configuration. + */ + async refresh(): Promise { + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new Error("Refresh is not enabled for key-values or feature flags."); + } - async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { - const clientWrappers = await this.#clientManager.getClients(); + const refreshTasks: Promise[] = []; + if (this.#refreshEnabled) { + refreshTasks.push(this.#refreshKeyValues()); + } + if (this.#featureFlagRefreshEnabled) { + refreshTasks.push(this.#refreshFeatureFlags()); + } - let successful: boolean; - for (const clientWrapper of clientWrappers) { - successful = false; - try { - const result = await funcToExecute(clientWrapper.client); - this.#isFailoverRequest = false; - successful = true; - clientWrapper.updateBackoffStatus(successful); - return result; - } catch (error) { - if (isFailoverableError(error)) { - clientWrapper.updateBackoffStatus(successful); - this.#isFailoverRequest = true; - continue; - } + // wait until all tasks are either resolved or rejected + const results = await Promise.allSettled(refreshTasks); - throw error; + // check if any refresh task failed + for (const result of results) { + if (result.status === "rejected") { + console.warn("Refresh failed:", result.reason); } } - this.#clientManager.refreshClients(); - throw new Error("All clients failed to get configuration settings."); + // check if any refresh task succeeded + const anyRefreshed = results.some(result => result.status === "fulfilled" && result.value === true); + if (anyRefreshed) { + // successfully refreshed, run callbacks in async + for (const listener of this.#onRefreshListeners) { + listener(); + } + } } + /** + * Registers a callback function to be called when the configuration is refreshed. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable { + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new Error("Refresh is not enabled for key-values or feature flags."); + } + + const boundedListener = listener.bind(thisArg); + this.#onRefreshListeners.push(boundedListener); + + const remove = () => { + const index = this.#onRefreshListeners.indexOf(boundedListener); + if (index >= 0) { + this.#onRefreshListeners.splice(index, 1); + } + }; + return new Disposable(remove); + } + + /** + * Loads configuration settings from App Configuration, either key-value settings or feature flag settings. + * Additionally, updates the `pageEtags` property of the corresponding @see PagedSettingSelector after loading. + * + * @param loadFeatureFlag - Determines which type of configurationsettings to load: + * If true, loads feature flag using the feature flag selectors; + * If false, loads key-value using the key-value selectors. Defaults to false. + */ async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { const selectors = loadFeatureFlag ? this.#featureFlagSelectors : this.#keyValueSelectors; const funcToExecute = async (client) => { @@ -280,26 +370,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Update 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 selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. */ - async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { - for (const sentinel of this.#sentinels) { - const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); - if (matchedSetting) { - 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 }); - if (response) { - sentinel.etag = response.etag; - } else { - sentinel.etag = undefined; - } - } - } - } - async #loadSelectedAndWatchedKeyValues() { const keyValues: [key: string, value: unknown][] = []; const loadedSettings = await this.#loadConfigurationSettings(); @@ -315,10 +387,34 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion for (const [k, v] of keyValues) { - this.#configMap.set(k, v); + this.#configMap.set(k, v); // reset the configuration + } + } + + /** + * 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. + */ + async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { + for (const sentinel of this.#sentinels) { + const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); + if (matchedSetting) { + 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 }); + if (response) { + sentinel.etag = response.etag; + } else { + sentinel.etag = undefined; + } + } } } + /** + * Clears all existing key-values in the local configuration except feature flags. + */ async #clearLoadedKeyValues() { for (const key of this.#configMap.keys()) { if (key !== FEATURE_MANAGEMENT_KEY_NAME) { @@ -341,98 +437,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Load the configuration store for the first time. - */ - async load() { - await this.#loadSelectedAndWatchedKeyValues(); - if (this.#featureFlagEnabled) { - await this.#loadFeatureFlags(); - } - // Mark all settings have loaded at startup. - this.#isInitialLoadCompleted = true; - } - - /** - * Construct hierarchical data object from map. - */ - constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record { - const separator = options?.separator ?? "."; - const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"]; - if (!validSeparators.includes(separator)) { - throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`); - } - - // construct hierarchical data object from map - const data: Record = {}; - for (const [key, value] of this.#configMap) { - const segments = key.split(separator); - let current = data; - // construct hierarchical data object along the path - for (let i = 0; i < segments.length - 1; i++) { - const segment = segments[i]; - // undefined or empty string - if (!segment) { - throw new Error(`invalid key: ${key}`); - } - // create path if not exist - if (current[segment] === undefined) { - current[segment] = {}; - } - // The path has been occupied by a non-object value, causing ambiguity. - if (typeof current[segment] !== "object") { - throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The path '${segments.slice(0, i + 1).join(separator)}' has been occupied.`); - } - current = current[segment]; - } - - const lastSegment = segments[segments.length - 1]; - if (current[lastSegment] !== undefined) { - throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`); - } - // set value to the last segment - current[lastSegment] = value; - } - return data; - } - - /** - * Refresh the configuration store. - */ - async refresh(): Promise { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); - } - - const refreshTasks: Promise[] = []; - if (this.#refreshEnabled) { - refreshTasks.push(this.#refreshKeyValues()); - } - if (this.#featureFlagRefreshEnabled) { - refreshTasks.push(this.#refreshFeatureFlags()); - } - - // wait until all tasks are either resolved or rejected - const results = await Promise.allSettled(refreshTasks); - - // check if any refresh task failed - for (const result of results) { - if (result.status === "rejected") { - console.warn("Refresh failed:", result.reason); - } - } - - // check if any refresh task succeeded - const anyRefreshed = results.some(result => result.status === "fulfilled" && result.value === true); - if (anyRefreshed) { - // successfully refreshed, run callbacks in async - for (const listener of this.#onRefreshListeners) { - listener(); - } - } - } - - /** - * Refresh key-values. + * Refreshes key-values. * @returns true if key-values are refreshed, false otherwise. */ async #refreshKeyValues(): Promise { @@ -469,7 +474,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Refresh feature flags. + * Refreshes feature flags. * @returns true if feature flags are refreshed, false otherwise. */ async #refreshFeatureFlags(): Promise { @@ -487,6 +492,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(needRefresh); } + /** + * Checks whether the key-value collection has changed. + * @param selectors - The @see PagedSettingSelector of the kev-value collection. + * @returns true if key-value collection has changed, false otherwise. + */ async #checkKeyValueCollectionChanged(selectors: PagedSettingSelector[]): Promise { const funcToExecute = async (client) => { for (const selector of selectors) { @@ -515,21 +525,57 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return isChanged; } - onRefresh(listener: () => any, thisArg?: any): Disposable { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); + /** + * Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error. + */ + async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { + const funcToExecute = async (client) => { + return getConfigurationSettingWithTrace( + this.#requestTraceOptions, + client, + configurationSettingId, + customOptions + ); + }; + + let response: GetConfigurationSettingResponse | undefined; + try { + response = await this.#executeWithFailoverPolicy(funcToExecute); + } catch (error) { + if (isRestError(error) && error.statusCode === 404) { + response = undefined; + } else { + throw error; + } } + return response; + } - const boundedListener = listener.bind(thisArg); - this.#onRefreshListeners.push(boundedListener); + async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { + const clientWrappers = await this.#clientManager.getClients(); - const remove = () => { - const index = this.#onRefreshListeners.indexOf(boundedListener); - if (index >= 0) { - this.#onRefreshListeners.splice(index, 1); + let successful: boolean; + for (const clientWrapper of clientWrappers) { + successful = false; + try { + const result = await funcToExecute(clientWrapper.client); + this.#isFailoverRequest = false; + successful = true; + clientWrapper.updateBackoffStatus(successful); + return result; + } catch (error) { + if (isFailoverableError(error)) { + clientWrapper.updateBackoffStatus(successful); + this.#isFailoverRequest = true; + continue; + } + + throw error; } - }; - return new Disposable(remove); + } + + this.#clientManager.refreshClients(); + throw new Error("All clients failed to get configuration settings."); } async #processKeyValues(setting: ConfigurationSetting): Promise<[string, unknown]> { @@ -558,32 +604,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return key; } - /** - * Get a configuration setting by key and label. If the setting is not found, return undefine instead of throwing an error. - */ - async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { - const funcToExecute = async (client) => { - return getConfigurationSettingWithTrace( - this.#requestTraceOptions, - client, - configurationSettingId, - customOptions - ); - }; - - let response: GetConfigurationSettingResponse | undefined; - try { - response = await this.#executeWithFailoverPolicy(funcToExecute); - } catch (error) { - if (isRestError(error) && error.statusCode === 404) { - response = undefined; - } else { - throw error; - } - } - return response; - } - async #parseFeatureFlag(setting: ConfigurationSetting): Promise { const rawFlag = setting.value; if (rawFlag === undefined) { diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts index af51ba2b..27608c0c 100644 --- a/src/RefreshOptions.ts +++ b/src/RefreshOptions.ts @@ -22,13 +22,11 @@ export interface RefreshOptions { /** * One or more configuration settings to be watched for changes on the server. * Any modifications to watched settings will refresh all settings loaded by the configuration provider when refresh() is called. + * + * @remarks + * If no watched settings is specified, all configuration settings will be watched. */ watchedSettings?: WatchedSetting[]; - - /** - * Specifies whether all configuration settings will be watched for changes on the server. - */ - watchAll?: boolean; } export interface FeatureFlagRefreshOptions { diff --git a/test/refresh.test.ts b/test/refresh.test.ts index fcffaee5..04cf63b7 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -47,25 +47,6 @@ describe("dynamic refresh", function () { return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values or feature flags."); }); - it("should only allow non-empty list of watched settings when refresh is enabled", async () => { - const connectionString = createMockedConnectionString(); - const loadWithEmptyWatchedSettings = load(connectionString, { - refreshOptions: { - enabled: true, - watchedSettings: [] - } - }); - const loadWithUndefinedWatchedSettings = load(connectionString, { - refreshOptions: { - enabled: true - } - }); - return Promise.all([ - expect(loadWithEmptyWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified."), - expect(loadWithUndefinedWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified.") - ]); - }); - it("should not allow refresh interval less than 1 second", async () => { const connectionString = createMockedConnectionString(); const loadWithInvalidRefreshInterval = load(connectionString, { From 70bbdeea8568ff7a49adc0796b27271c14fbc2b8 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 19 Nov 2024 17:53:03 +0800 Subject: [PATCH 12/52] add testcase --- src/RefreshOptions.ts | 2 +- test/refresh.test.ts | 58 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts index 27608c0c..d5e4da5f 100644 --- a/src/RefreshOptions.ts +++ b/src/RefreshOptions.ts @@ -24,7 +24,7 @@ export interface RefreshOptions { * Any modifications to watched settings will refresh all settings loaded by the configuration provider when refresh() is called. * * @remarks - * If no watched settings is specified, all configuration settings will be watched. + * If no watched setting is specified, all configuration settings will be watched. */ watchedSettings?: WatchedSetting[]; } diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 04cf63b7..7a5a8cb8 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -302,6 +302,62 @@ describe("dynamic refresh", function () { expect(settings.get("app.settings.fontColor")).eq("red"); }); + it("should refresh key value based on page eTag, if no watched setting is specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // change setting + updateSetting("app.settings.fontColor", "blue"); + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + + it("should refresh key value based on page Etag, only on change", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + + let refreshSuccessfulCount = 0; + settings.onRefresh(() => { + refreshSuccessfulCount++; + }); + + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(refreshSuccessfulCount).eq(0); // no change in feature flags, because page etags are the same. + + // change key value + restoreMocks(); + let changedKVs = [ + { value: "blue", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings(changedKVs); + mockAppConfigurationClientGetConfigurationSetting(changedKVs); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(refreshSuccessfulCount).eq(1); // change in key values, because page etags are different. + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); }); describe("dynamic refresh feature flags", function () { @@ -358,7 +414,7 @@ describe("dynamic refresh feature flags", function () { }); - it("should refresh feature flags only on change, based on page etags", async () => { + it("should refresh feature flags based on page etags, only on change", async () => { // mock multiple pages of feature flags const page1 = [ createMockedFeatureFlag("Alpha_1", { enabled: true }), From d8103a5a77bf15ae59ea94d3bd4c992d265a9603 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 19 Nov 2024 18:33:17 +0800 Subject: [PATCH 13/52] fix lint & update method name --- src/AzureAppConfigurationImpl.ts | 6 +++--- test/refresh.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index b60a52a6..4be6ec06 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -449,7 +449,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // try refresh if any of watched settings is changed. let needRefresh = false; if (this.#watchAll) { - needRefresh = await this.#checkKeyValueCollectionChanged(this.#keyValueSelectors); + needRefresh = await this.#checkConfigurationSettingsChange(this.#keyValueSelectors); } for (const sentinel of this.#sentinels.values()) { const response = await this.#getConfigurationSetting(sentinel, { @@ -483,7 +483,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } - const needRefresh = await this.#checkKeyValueCollectionChanged(this.#featureFlagSelectors); + const needRefresh = await this.#checkConfigurationSettingsChange(this.#featureFlagSelectors); if (needRefresh) { await this.#loadFeatureFlags(); } @@ -497,7 +497,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 #checkKeyValueCollectionChanged(selectors: PagedSettingSelector[]): Promise { + async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { const funcToExecute = async (client) => { for (const selector of selectors) { const listOptions: ListConfigurationSettingsOptions = { diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 7a5a8cb8..3c41d664 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -331,7 +331,7 @@ describe("dynamic refresh", function () { refreshIntervalInMs: 2000 } }); - + let refreshSuccessfulCount = 0; settings.onRefresh(() => { refreshSuccessfulCount++; @@ -339,14 +339,14 @@ describe("dynamic refresh", function () { expect(settings).not.undefined; expect(settings.get("app.settings.fontColor")).eq("red"); - + await sleepInMs(2 * 1000 + 1); await settings.refresh(); expect(refreshSuccessfulCount).eq(0); // no change in feature flags, because page etags are the same. // change key value restoreMocks(); - let changedKVs = [ + const changedKVs = [ { value: "blue", key: "app.settings.fontColor" }, { value: "40", key: "app.settings.fontSize" } ].map(createMockedKeyValue); From 6958ad5e885adb4b7ffb00a7fc8e88481bb65fd2 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 20 Nov 2024 01:59:23 +0800 Subject: [PATCH 14/52] add comment --- src/AzureAppConfigurationImpl.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 4be6ec06..75e3bee2 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -423,6 +423,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + /** + * Loads feature flags from App Configuration to the local configuration. + */ async #loadFeatureFlags() { const loadFeatureFlag = true; const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); From 61e4a65d2110da6d63d7672fb3e1c06f9a67aa34 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 20 Nov 2024 03:00:03 +0800 Subject: [PATCH 15/52] not use conditional request --- src/AzureAppConfigurationImpl.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 53e524d9..001b9fbd 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -510,7 +510,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, - pageEtags: selector.pageEtags + ...(!this.#isCdnUsed && { pageEtags: selector.pageEtags }) // if CDN is used, do not send conditional request }; const pageIterator = listConfigurationSettingsWithTrace( @@ -519,10 +519,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { listOptions ).byPage(); + if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { + return true; // no etag, always refresh + } + + let i = 0; for await (const page of pageIterator) { - if (page._response.status === 200) { // created or changed + if (i > selector.pageEtags.length + 1 || // new page + (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed return true; } + i++; + } + if (i !== selector.pageEtags.length) { // page removed + return true; } } return false; From 4f36a1cde68dce541a400e004cfb65a0be2145ac Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 20 Nov 2024 16:09:18 +0800 Subject: [PATCH 16/52] fix vulnerability --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32f0955b..049483bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1447,9 +1447,9 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", From b74e983d5a11b608e046412d4873db923a3de853 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 2 Dec 2024 18:43:23 +0800 Subject: [PATCH 17/52] update variable name --- src/AzureAppConfigurationImpl.ts | 44 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 0d38ca63..728b5f32 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -65,27 +65,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Refresh #refreshInProgress: boolean = false; - #watchAll: boolean = false; - #refreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #onRefreshListeners: Array<() => any> = []; /** * Aka watched settings. */ #sentinels: ConfigurationSettingId[] = []; - #refreshTimer: RefreshTimer; + #watchAll: boolean = false; + #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #kvRefreshTimer: RefreshTimer; // Feature flags - #featureFlagRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; - #featureFlagRefreshTimer: RefreshTimer; + #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #ffRefreshTimer: RefreshTimer; /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors */ - #keyValueSelectors: PagedSettingSelector[] = []; + #kvSelectors: PagedSettingSelector[] = []; /** * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors */ - #featureFlagSelectors: PagedSettingSelector[] = []; + #ffSelectors: PagedSettingSelector[] = []; // Load balancing #lastSuccessfulEndpoint: string = ""; @@ -125,18 +125,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { - this.#refreshInterval = refreshIntervalInMs; + this.#kvRefreshInterval = refreshIntervalInMs; } } - this.#refreshTimer = new RefreshTimer(this.#refreshInterval); + this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); } - this.#keyValueSelectors = getValidKeyValueSelectors(options?.selectors); + this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); // feature flag options if (options?.featureFlagOptions?.enabled) { // validate feature flag selectors - this.#featureFlagSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); + this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); if (options.featureFlagOptions.refresh?.enabled) { const { refreshIntervalInMs } = options.featureFlagOptions.refresh; @@ -145,11 +145,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { - this.#featureFlagRefreshInterval = refreshIntervalInMs; + this.#ffRefreshInterval = refreshIntervalInMs; } } - this.#featureFlagRefreshTimer = new RefreshTimer(this.#featureFlagRefreshInterval); + this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval); } } @@ -344,7 +344,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.#featureFlagSelectors : this.#keyValueSelectors; + const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; const funcToExecute = async (client) => { const loadedSettings: ConfigurationSetting[] = []; // deep copy selectors to avoid modification if current client fails @@ -376,9 +376,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (loadFeatureFlag) { - this.#featureFlagSelectors = selectorsToUpdate; + this.#ffSelectors = selectorsToUpdate; } else { - this.#keyValueSelectors = selectorsToUpdate; + this.#kvSelectors = selectorsToUpdate; } return loadedSettings; }; @@ -462,14 +462,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #refreshKeyValues(): Promise { // if still within refresh interval/backoff, return - if (!this.#refreshTimer.canRefresh()) { + if (!this.#kvRefreshTimer.canRefresh()) { return Promise.resolve(false); } // try refresh if any of watched settings is changed. let needRefresh = false; if (this.#watchAll) { - needRefresh = await this.#checkConfigurationSettingsChange(this.#keyValueSelectors); + needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); } for (const sentinel of this.#sentinels.values()) { const response = await this.#getConfigurationSetting(sentinel, { @@ -489,7 +489,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { await this.#loadSelectedAndWatchedKeyValues(); } - this.#refreshTimer.reset(); + this.#kvRefreshTimer.reset(); return Promise.resolve(needRefresh); } @@ -499,16 +499,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #refreshFeatureFlags(): Promise { // if still within refresh interval/backoff, return - if (!this.#featureFlagRefreshTimer.canRefresh()) { + if (!this.#ffRefreshTimer.canRefresh()) { return Promise.resolve(false); } - const needRefresh = await this.#checkConfigurationSettingsChange(this.#featureFlagSelectors); + const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); if (needRefresh) { await this.#loadFeatureFlags(); } - this.#featureFlagRefreshTimer.reset(); + this.#ffRefreshTimer.reset(); return Promise.resolve(needRefresh); } From 01a70343b237b8f65cc6c286a99bb1d06508da6d Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 2 Dec 2024 18:50:18 +0800 Subject: [PATCH 18/52] move public method --- src/AzureAppConfigurationImpl.ts | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 728b5f32..a8616344 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -286,6 +286,26 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + /** + * Registers a callback function to be called when the configuration is refreshed. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable { + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new Error("Refresh is not enabled for key-values or feature flags."); + } + + const boundedListener = listener.bind(thisArg); + this.#onRefreshListeners.push(boundedListener); + + const remove = () => { + const index = this.#onRefreshListeners.indexOf(boundedListener); + if (index >= 0) { + this.#onRefreshListeners.splice(index, 1); + } + }; + return new Disposable(remove); + } + async #refreshTasks(): Promise { const refreshTasks: Promise[] = []; if (this.#refreshEnabled) { @@ -315,26 +335,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } - /** - * Registers a callback function to be called when the configuration is refreshed. - */ - onRefresh(listener: () => any, thisArg?: any): Disposable { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); - } - - const boundedListener = listener.bind(thisArg); - this.#onRefreshListeners.push(boundedListener); - - const remove = () => { - const index = this.#onRefreshListeners.indexOf(boundedListener); - if (index >= 0) { - this.#onRefreshListeners.splice(index, 1); - } - }; - return new Disposable(remove); - } - /** * Loads configuration settings from App Configuration, either key-value settings or feature flag settings. * Additionally, updates the `pageEtags` property of the corresponding @see PagedSettingSelector after loading. From b0ab94444588544dd99b34270efed78c194df3f3 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 3 Dec 2024 19:04:15 +0800 Subject: [PATCH 19/52] append etag to url --- src/AzureAppConfigurationImpl.ts | 65 ++++++++++++++++++++++++++------ src/EtagUrlPipelinePolicy.ts | 29 ++++++++++++++ src/load.ts | 14 ++++++- src/requestTracing/utils.ts | 18 ++++++++- 4 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 src/EtagUrlPipelinePolicy.ts diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index b1ee1523..1e80b69e 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -36,12 +36,16 @@ import { RefreshTimer } from "./refresh/RefreshTimer.js"; import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +import { ETAG_LOOKUP_HEADER } from "./EtagUrlPipelinePolicy.js"; type PagedSettingSelector = SettingSelector & { + pageEtags?: string[]; + /** - * Key: page eTag, Value: feature flag configurations + * The etag which has changed after the last refresh. This is used to break the CDN cache. + * It can either be a page etag or etag of a watched setting. */ - pageEtags?: string[]; + latestEtag?: string; }; export class AzureAppConfigurationImpl implements AzureAppConfiguration { @@ -59,7 +63,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { readonly #requestTracingEnabled: boolean; #clientManager: ConfigurationClientManager; #options: AzureAppConfigurationOptions | undefined; - #isCdnUsed: boolean; #isInitialLoadCompleted: boolean = false; #isFailoverRequest: boolean = false; @@ -91,6 +94,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Load balancing #lastSuccessfulEndpoint: string = ""; + // CDN + #isCdnUsed: boolean; + /** + * The etag of a watched setting which has changed after the last refresh. This is used to break the CDN cache. + * This property will not be used when using key value collection based refresh. It could only be used during updateWatchedKeyValuesEtag and refreshKeyValues. + */ + #latestEtag?: string; + constructor( clientManager: ConfigurationClientManager, options: AzureAppConfigurationOptions | undefined, @@ -218,11 +229,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Loads the configuration store for the first time. + * @internal */ async load() { await this.#loadSelectedAndWatchedKeyValues(); + if (this.#watchAll) { + this.#kvSelectors.forEach(selector => selector.latestEtag = selector.pageEtags ? selector.pageEtags[0] : undefined); + } else if (this.#refreshEnabled) { + this.#latestEtag = this.#sentinels.find(s => s.etag !== undefined)?.etag; + } + if (this.#featureFlagEnabled) { await this.#loadFeatureFlags(); + if (this.#featureFlagRefreshEnabled) { + this.#ffSelectors.forEach(selector => selector.latestEtag = selector.pageEtags ? selector.pageEtags[0] : undefined); + } } // Mark all settings have loaded at startup. this.#isInitialLoadCompleted = true; @@ -357,11 +378,26 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ); for (const selector of selectorsToUpdate) { - const listOptions: ListConfigurationSettingsOptions = { + let listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter + labelFilter: selector.labelFilter, }; + if (this.#isCdnUsed) { + // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + if (this.#watchAll && selector.latestEtag) { + listOptions = { + ...listOptions, + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selector.latestEtag }} + }; + } else if (this.#latestEtag) { + listOptions = { + ...listOptions, + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#latestEtag }} + }; + } + } + const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, @@ -422,8 +458,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { 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 }); + // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + const getOptions = this.#isCdnUsed && this.#latestEtag ? { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#latestEtag }}} : {}; + const response = await this.#getConfigurationSetting(sentinel, {...getOptions, onlyIfChanged: false}); // always send non-conditional request if (response) { sentinel.etag = response.etag; } else { @@ -476,14 +513,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); } for (const sentinel of this.#sentinels.values()) { - const response = await this.#getConfigurationSetting(sentinel, { - onlyIfChanged: !this.#isCdnUsed // if CDN is used, do not send conditional request - }); + // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + const getOptions = this.#isCdnUsed && this.#latestEtag ? { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#latestEtag }}} : {}; + const response = await this.#getConfigurationSetting(sentinel, {...getOptions, onlyIfChanged: !this.#isCdnUsed}); // if CDN is used, do not send conditional request if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || (response === undefined && sentinel.etag !== undefined) // deleted ) { sentinel.etag = response?.etag;// update etag of the sentinel + this.#latestEtag = response?.etag; // record the last changed etag needRefresh = true; break; } @@ -527,7 +565,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, - ...(!this.#isCdnUsed && { pageEtags: selector.pageEtags }) // if CDN is used, do not send conditional request + ...(!this.#isCdnUsed && { pageEtags: selector.pageEtags }), // if CDN is used, do not send conditional request + // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + ...(this.#isCdnUsed && selector.latestEtag && { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selector.latestEtag }}}) }; const pageIterator = listConfigurationSettingsWithTrace( @@ -542,8 +582,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let i = 0; for await (const page of pageIterator) { - if (i > selector.pageEtags.length + 1 || // new page + if (i > selector.pageEtags.length + 1 || // new page (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed + selector.latestEtag = page.etag; // record the last changed etag return true; } i++; diff --git a/src/EtagUrlPipelinePolicy.ts b/src/EtagUrlPipelinePolicy.ts new file mode 100644 index 00000000..c30b7484 --- /dev/null +++ b/src/EtagUrlPipelinePolicy.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelinePolicy } from "@azure/core-rest-pipeline"; + +export const ETAG_LOOKUP_HEADER = "Etag-Lookup"; + +/** + * The pipeline policy that retrieves the etag from the request header and appends it to the request URL. After that the etag header is removed from the request. + * @remarks + * The policy position should be perCall. + * The App Configuration service will not recognize the etag query parameter in the url, but this can help to break the CDN cache as the cache entry is based on the URL. + */ +export class EtagUrlPipelinePolicy implements PipelinePolicy { + name: string = "AppConfigurationEtagUrlPolicy"; + + async sendRequest(request, next) { + if (request.headers.has(ETAG_LOOKUP_HEADER)) { + const etag = request.headers.get(ETAG_LOOKUP_HEADER); + request.headers.delete(ETAG_LOOKUP_HEADER); + + const url = new URL(request.url); + url.searchParams.append("etag", etag); + request.url = url.toString(); + } + + return next(request); + } +} diff --git a/src/load.ts b/src/load.ts index 2df15f62..fd37cd1e 100644 --- a/src/load.ts +++ b/src/load.ts @@ -6,6 +6,7 @@ import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { ConfigurationClientManager, instanceOfTokenCredential } from "./ConfigurationClientManager.js"; +import { EtagUrlPipelinePolicy } from "./EtagUrlPipelinePolicy.js"; const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5000; // 5 seconds @@ -75,8 +76,17 @@ export async function loadFromCdn( if (appConfigOptions === undefined) { appConfigOptions = { clientOptions: {}}; } - // Specify the api version that supports sas token authentication - appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, apiVersion: "2024-09-01-preview"}; + + appConfigOptions.clientOptions = { + ...appConfigOptions.clientOptions, + // Specify the api version that supports sas token authentication + apiVersion: "2024-09-01-preview", + // Add etag url policy to append etag to the request url for breaking CDN cache + additionalPolicies: [ + ...(appConfigOptions.clientOptions?.additionalPolicies || []), + { policy: new EtagUrlPipelinePolicy(), position: "perCall" } + ] + }; return await load(cdnEndpoint, emptyTokenCredential, appConfigOptions); } diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index ae51a1c4..3765540d 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -43,8 +43,15 @@ export function listConfigurationSettingsWithTrace( const actualListOptions = { ...listOptions }; if (requestTracingEnabled) { actualListOptions.requestOptions = { + ...actualListOptions.requestOptions, customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed, isFailoverRequest) + ...(actualListOptions.requestOptions?.customHeaders || {}), + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader( + appConfigOptions, + initialLoadCompleted, + isCdnUsed, + isFailoverRequest + ) } }; } @@ -69,8 +76,15 @@ export function getConfigurationSettingWithTrace( if (requestTracingEnabled) { actualGetOptions.requestOptions = { + ...actualGetOptions.requestOptions, customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted, isCdnUsed, isFailoverRequest) + ...(actualGetOptions.requestOptions?.customHeaders || {}), + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader( + appConfigOptions, + initialLoadCompleted, + isCdnUsed, + isFailoverRequest + ) } }; } From c0948019d02e08c620bb9d8fc468122a586c558d Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 4 Dec 2024 14:59:27 +0800 Subject: [PATCH 20/52] update --- src/AzureAppConfigurationImpl.ts | 144 ++++++++++++++++++------------- src/EtagUrlPipelinePolicy.ts | 2 +- 2 files changed, 84 insertions(+), 62 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 1e80b69e..163ab9d2 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -40,13 +40,17 @@ import { ETAG_LOOKUP_HEADER } from "./EtagUrlPipelinePolicy.js"; type PagedSettingSelector = SettingSelector & { pageEtags?: string[]; +}; + +type SettingSelectorCollection = { + selectors: PagedSettingSelector[]; /** - * The etag which has changed after the last refresh. This is used to break the CDN cache. + * The etag which has changed after the last refresh. This is used to append to the request url for breaking the CDN cache. * It can either be a page etag or etag of a watched setting. */ - latestEtag?: string; -}; + etagToBreakCdnCache?: string; +} export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** @@ -85,22 +89,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors */ - #kvSelectors: PagedSettingSelector[] = []; + #kvSelectorCollection: SettingSelectorCollection = { selectors: [] }; /** * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors */ - #ffSelectors: PagedSettingSelector[] = []; + #ffSelectorCollection: SettingSelectorCollection = { selectors: [] }; // Load balancing #lastSuccessfulEndpoint: string = ""; // CDN #isCdnUsed: boolean; - /** - * The etag of a watched setting which has changed after the last refresh. This is used to break the CDN cache. - * This property will not be used when using key value collection based refresh. It could only be used during updateWatchedKeyValuesEtag and refreshKeyValues. - */ - #latestEtag?: string; constructor( clientManager: ConfigurationClientManager, @@ -145,12 +144,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); } - this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); + this.#kvSelectorCollection.selectors = getValidKeyValueSelectors(options?.selectors); // feature flag options if (options?.featureFlagOptions?.enabled) { // validate feature flag selectors - this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); + this.#ffSelectorCollection.selectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); if (options.featureFlagOptions.refresh?.enabled) { const { refreshIntervalInMs } = options.featureFlagOptions.refresh; @@ -233,19 +232,36 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async load() { await this.#loadSelectedAndWatchedKeyValues(); - if (this.#watchAll) { - this.#kvSelectors.forEach(selector => selector.latestEtag = selector.pageEtags ? selector.pageEtags[0] : undefined); - } else if (this.#refreshEnabled) { - this.#latestEtag = this.#sentinels.find(s => s.etag !== undefined)?.etag; - } if (this.#featureFlagEnabled) { await this.#loadFeatureFlags(); + } + + if (this.#isCdnUsed) { + if (this.#watchAll) { // collection monitoring based refresh + // use the first page etag of the first kv selector + const defaultSelector = this.#kvSelectorCollection.selectors.find(s => s.pageEtags !== undefined); + if (defaultSelector && defaultSelector.pageEtags!.length > 0) { + this.#kvSelectorCollection.etagToBreakCdnCache = defaultSelector.pageEtags![0]; + } else { + this.#kvSelectorCollection.etagToBreakCdnCache = undefined; + } + } else if (this.#refreshEnabled) { // watched settings based refresh + // use the etag of the first watched setting (sentinel) + this.#kvSelectorCollection.etagToBreakCdnCache = this.#sentinels.find(s => s.etag !== undefined)?.etag; + } + if (this.#featureFlagRefreshEnabled) { - this.#ffSelectors.forEach(selector => selector.latestEtag = selector.pageEtags ? selector.pageEtags[0] : undefined); + const defaultSelector = this.#ffSelectorCollection.selectors.find(s => s.pageEtags !== undefined); + if (defaultSelector && defaultSelector.pageEtags!.length > 0) { + this.#ffSelectorCollection.etagToBreakCdnCache = defaultSelector.pageEtags![0]; + } else { + this.#ffSelectorCollection.etagToBreakCdnCache = undefined; + } } } - // Mark all settings have loaded at startup. + + // mark all settings have loaded at startup. this.#isInitialLoadCompleted = true; } @@ -369,12 +385,12 @@ 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 selectorCollection = loadFeatureFlag ? this.#ffSelectorCollection : this.#kvSelectorCollection; const funcToExecute = async (client) => { const loadedSettings: ConfigurationSetting[] = []; // deep copy selectors to avoid modification if current client fails - const selectorsToUpdate = JSON.parse( - JSON.stringify(selectors) + const selectorsToUpdate: PagedSettingSelector[] = JSON.parse( + JSON.stringify(selectorCollection.selectors) ); for (const selector of selectorsToUpdate) { @@ -383,19 +399,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { labelFilter: selector.labelFilter, }; + // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL if (this.#isCdnUsed) { - // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - if (this.#watchAll && selector.latestEtag) { - listOptions = { - ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selector.latestEtag }} - }; - } else if (this.#latestEtag) { - listOptions = { - ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#latestEtag }} - }; - } + listOptions = { + ...listOptions, + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.etagToBreakCdnCache ?? "" }} + }; } const pageEtags: string[] = []; @@ -405,21 +414,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { listOptions ).byPage(); for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); + pageEtags.push(page.etag ?? ""); // pageEtags is string[] for (const setting of page.items) { if (loadFeatureFlag === isFeatureFlag(setting)) { loadedSettings.push(setting); } } } + + if (pageEtags.length === 0) { + console.warn(`No page is found in the response of listing key-value selector: key=${selector.keyFilter} and label=${selector.labelFilter}.`); + } selector.pageEtags = pageEtags; } - if (loadFeatureFlag) { - this.#ffSelectors = selectorsToUpdate; - } else { - this.#kvSelectors = selectorsToUpdate; - } + selectorCollection.selectors = selectorsToUpdate; return loadedSettings; }; @@ -457,15 +466,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (matchedSetting) { 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 + // Send a request to retrieve watched key-value since it may be either not loaded or loaded with a different selector // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - const getOptions = this.#isCdnUsed && this.#latestEtag ? { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#latestEtag }}} : {}; + const getOptions = this.#isCdnUsed ? + { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.etagToBreakCdnCache ?? "" } } } : + {}; const response = await this.#getConfigurationSetting(sentinel, {...getOptions, onlyIfChanged: false}); // always send non-conditional request - if (response) { - sentinel.etag = response.etag; - } else { - sentinel.etag = undefined; - } + sentinel.etag = response?.etag; } } } @@ -510,18 +517,20 @@ 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); + needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectorCollection); } for (const sentinel of this.#sentinels.values()) { // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - const getOptions = this.#isCdnUsed && this.#latestEtag ? { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#latestEtag }}} : {}; - const response = await this.#getConfigurationSetting(sentinel, {...getOptions, onlyIfChanged: !this.#isCdnUsed}); // if CDN is used, do not send conditional request + const getOptions = this.#isCdnUsed ? + { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.etagToBreakCdnCache ?? "" } } } : + {}; + const response = await this.#getConfigurationSetting(sentinel, { ...getOptions, onlyIfChanged: !this.#isCdnUsed }); // if CDN is used, do not send conditional request if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || (response === undefined && sentinel.etag !== undefined) // deleted ) { sentinel.etag = response?.etag;// update etag of the sentinel - this.#latestEtag = response?.etag; // record the last changed etag + this.#kvSelectorCollection.etagToBreakCdnCache = sentinel.etag; needRefresh = true; break; } @@ -545,7 +554,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } - const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); + const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectorCollection); if (needRefresh) { await this.#loadFeatureFlags(); } @@ -556,20 +565,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Checks whether the key-value collection has changed. - * @param selectors - The @see PagedSettingSelector of the kev-value collection. + * @param selectorCollection - The @see SettingSelectorCollection of the kev-value collection. * @returns true if key-value collection has changed, false otherwise. */ - async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { + async #checkConfigurationSettingsChange(selectorCollection: SettingSelectorCollection): Promise { const funcToExecute = async (client) => { - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { + for (const selector of selectorCollection.selectors) { + let listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter, - ...(!this.#isCdnUsed && { pageEtags: selector.pageEtags }), // if CDN is used, do not send conditional request - // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - ...(this.#isCdnUsed && selector.latestEtag && { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selector.latestEtag }}}) + labelFilter: selector.labelFilter }; + if (this.#isCdnUsed) { + // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + listOptions = { + ...listOptions, + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.etagToBreakCdnCache ?? "" } }}; + } else { + // send conditional request if cdn is not used + listOptions = { ...listOptions, pageEtags: selector.pageEtags }; + } + const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, client, @@ -577,6 +593,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ).byPage(); if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { + selectorCollection.etagToBreakCdnCache = undefined; return true; // no etag, always refresh } @@ -584,12 +601,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { for await (const page of pageIterator) { if (i > selector.pageEtags.length + 1 || // new page (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed - selector.latestEtag = page.etag; // record the last changed etag + if (this.#isCdnUsed) { + selectorCollection.etagToBreakCdnCache = page.etag; + } return true; } i++; } if (i !== selector.pageEtags.length) { // page removed + if (this.#isCdnUsed) { + selectorCollection.etagToBreakCdnCache = selector.pageEtags[i]; + } return true; } } diff --git a/src/EtagUrlPipelinePolicy.ts b/src/EtagUrlPipelinePolicy.ts index c30b7484..224eedb7 100644 --- a/src/EtagUrlPipelinePolicy.ts +++ b/src/EtagUrlPipelinePolicy.ts @@ -20,7 +20,7 @@ export class EtagUrlPipelinePolicy implements PipelinePolicy { request.headers.delete(ETAG_LOOKUP_HEADER); const url = new URL(request.url); - url.searchParams.append("etag", etag); + url.searchParams.append("_", etag); // _ is a dummy query parameter to break the CDN cache request.url = url.toString(); } From bbf1938b52f02034d35cc57a04cde27758e9efdd Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 4 Dec 2024 15:45:03 +0800 Subject: [PATCH 21/52] update --- src/AzureAppConfigurationImpl.ts | 4 ++-- test/loadBalance.test.ts | 15 ++++++++++----- test/utils/testHelper.ts | 7 +++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 163ab9d2..3f66f83a 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -594,12 +594,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { selectorCollection.etagToBreakCdnCache = undefined; - return true; // no etag, always refresh + return true; // no etag is retrieved from previous request, always refresh } let i = 0; for await (const page of pageIterator) { - if (i > selector.pageEtags.length + 1 || // new page + if (i >= selector.pageEtags.length || // new page (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed if (this.#isCdnUsed) { selectorCollection.etagToBreakCdnCache = page.etag; diff --git a/test/loadBalance.test.ts b/test/loadBalance.test.ts index f0e04b01..a8a0d369 100644 --- a/test/loadBalance.test.ts +++ b/test/loadBalance.test.ts @@ -6,10 +6,15 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { restoreMocks, createMockedConnectionString, sleepInMs, createMockedEndpoint, mockConfigurationManagerGetClients, mockAppConfigurationClientLoadBalanceMode } from "./utils/testHelper.js"; +import { restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs, createMockedEndpoint, mockConfigurationManagerGetClients, mockAppConfigurationClientLoadBalanceMode } from "./utils/testHelper.js"; import { AppConfigurationClient } from "@azure/app-configuration"; import { ConfigurationClientWrapper } from "../src/ConfigurationClientWrapper.js"; +const mockedKVs = [ + { value: "red", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" }, + { value: "30", key: "app.settings.fontSize", label: "prod" } +].map(createMockedKeyValue); const fakeEndpoint_1 = createMockedEndpoint("fake_1"); const fakeEndpoint_2 = createMockedEndpoint("fake_2"); const fakeClientWrapper_1 = new ConfigurationClientWrapper(fakeEndpoint_1, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint_1))); @@ -29,8 +34,8 @@ describe("load balance", function () { it("should load balance the request when loadBalancingEnabled", async () => { mockConfigurationManagerGetClients([fakeClientWrapper_1, fakeClientWrapper_2], false); - mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_1, clientRequestCounter_1); - mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_2, clientRequestCounter_2); + mockAppConfigurationClientLoadBalanceMode([mockedKVs], fakeClientWrapper_1, clientRequestCounter_1); + mockAppConfigurationClientLoadBalanceMode([mockedKVs], fakeClientWrapper_2, clientRequestCounter_2); const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -66,8 +71,8 @@ describe("load balance", function () { clientRequestCounter_1.count = 0; clientRequestCounter_2.count = 0; mockConfigurationManagerGetClients([fakeClientWrapper_1, fakeClientWrapper_2], false); - mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_1, clientRequestCounter_1); - mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_2, clientRequestCounter_2); + mockAppConfigurationClientLoadBalanceMode([mockedKVs], fakeClientWrapper_1, clientRequestCounter_1); + mockAppConfigurationClientLoadBalanceMode([mockedKVs], fakeClientWrapper_2, clientRequestCounter_2); const connectionString = createMockedConnectionString(); // loadBalancingEnabled is default to false diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 3b51596c..fe4d3909 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -103,12 +103,11 @@ function mockAppConfigurationClientListConfigurationSettings(pages: Configuratio }); } -function mockAppConfigurationClientLoadBalanceMode(clientWrapper: ConfigurationClientWrapper, countObject: { count: number }) { - const emptyPages: ConfigurationSetting[][] = []; +function mockAppConfigurationClientLoadBalanceMode(pages: ConfigurationSetting[][], clientWrapper: ConfigurationClientWrapper, countObject: { count: number }) { sinon.stub(clientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { countObject.count += 1; - const kvs = _filterKVs(emptyPages.flat(), listOptions); - return getMockedIterator(emptyPages, kvs, listOptions); + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); }); } From f3ac8314c311aaa25f25deaeabc1535c93cda537 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 5 Dec 2024 11:18:54 +0800 Subject: [PATCH 22/52] add more comments --- src/AzureAppConfigurationImpl.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index a8616344..addb0aca 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -97,13 +97,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#options = options; this.#clientManager = clientManager; - // Enable request tracing if not opt-out + // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); if (options?.trimKeyPrefixes) { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } + // if no selector is specified, always load key values using the default selector: key="*" and label="\0" + this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); + if (options?.refreshOptions?.enabled) { const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; if (watchedSettings === undefined || watchedSettings.length === 0) { @@ -131,11 +134,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); } - this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); - // feature flag options if (options?.featureFlagOptions?.enabled) { - // validate feature flag selectors + // validate feature flag selectors, only load feature flags when enabled this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); if (options.featureFlagOptions.refresh?.enabled) { From 356e664a2354bdb714408bbf77bdf342911f2330 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 13 Dec 2024 16:16:52 +0800 Subject: [PATCH 23/52] fix lint --- src/AzureAppConfigurationImpl.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index ff333e23..baa8ecdf 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -177,10 +177,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { initialLoadCompleted: this.#isInitialLoadCompleted, replicaCount: this.#clientManager.getReplicaCount(), isFailoverRequest: this.#isFailoverRequest - } + }; } - // #region ReadonlyMap APIs get(key: string): T | undefined { return this.#configMap.get(key); From d3c27994e47ad79734b5beb52da42f19bad34a4f Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 18 Dec 2024 12:33:58 +0800 Subject: [PATCH 24/52] resolve merge conflict --- src/AzureAppConfigurationImpl.ts | 31 +++- src/featureManagement/constants.ts | 5 + .../FeatureFlagTracingOptions.ts | 95 ++++++++++ src/requestTracing/constants.ts | 13 ++ src/requestTracing/utils.ts | 17 ++ test/requestTracing.test.ts | 165 ++++++++++++++++-- test/utils/testHelper.ts | 18 +- 7 files changed, 326 insertions(+), 18 deletions(-) create mode 100644 src/requestTracing/FeatureFlagTracingOptions.ts diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index baa8ecdf..916ece49 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -29,11 +29,14 @@ import { SEED_KEY_NAME, VARIANT_KEY_NAME, VARIANTS_KEY_NAME, - CONFIGURATION_VALUE_KEY_NAME + CONFIGURATION_VALUE_KEY_NAME, + CONDITIONS_KEY_NAME, + CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/RefreshTimer.js"; import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; +import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; @@ -61,6 +64,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #options: AzureAppConfigurationOptions | undefined; #isInitialLoadCompleted: boolean = false; #isFailoverRequest: boolean = false; + #featureFlagTracing: FeatureFlagTracingOptions | undefined; // Refresh #refreshInProgress: boolean = false; @@ -99,6 +103,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); + if (this.#requestTracingEnabled) { + this.#featureFlagTracing = new FeatureFlagTracingOptions(); + } if (options?.trimKeyPrefixes) { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); @@ -176,7 +183,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { appConfigOptions: this.#options, initialLoadCompleted: this.#isInitialLoadCompleted, replicaCount: this.#clientManager.getReplicaCount(), - isFailoverRequest: this.#isFailoverRequest + isFailoverRequest: this.#isFailoverRequest, + featureFlagTracing: this.#featureFlagTracing }; } @@ -663,6 +671,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; } + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { + if (featureFlag[CONDITIONS_KEY_NAME] && + featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME] && + Array.isArray(featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME])) { + for (const filter of featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME]) { + this.#featureFlagTracing.updateFeatureFilterTracing(filter[NAME_KEY_NAME]); + } + } + if (featureFlag[VARIANTS_KEY_NAME] && Array.isArray(featureFlag[VARIANTS_KEY_NAME])) { + this.#featureFlagTracing.notifyMaxVariants(featureFlag[VARIANTS_KEY_NAME].length); + } + if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME]) { + this.#featureFlagTracing.usesTelemetry = true; + } + if (featureFlag[ALLOCATION_KEY_NAME] && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME]) { + this.#featureFlagTracing.usesSeed = true; + } + } + return featureFlag; } diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts index 7b6bda8e..564bfbd9 100644 --- a/src/featureManagement/constants.ts +++ b/src/featureManagement/constants.ts @@ -20,3 +20,8 @@ export const VARIANT_KEY_NAME = "variant"; export const VARIANTS_KEY_NAME = "variants"; export const CONFIGURATION_VALUE_KEY_NAME = "configuration_value"; export const ALLOCATION_ID_KEY_NAME = "AllocationId"; +export const CONDITIONS_KEY_NAME = "conditions"; +export const CLIENT_FILTERS_KEY_NAME = "client_filters"; + +export const TIME_WINDOW_FILTER_NAMES = ["TimeWindow", "Microsoft.TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindowFilter"]; +export const TARGETING_FILTER_NAMES = ["Targeting", "Microsoft.Targeting", "TargetingFilter", "Microsoft.TargetingFilter"]; diff --git a/src/requestTracing/FeatureFlagTracingOptions.ts b/src/requestTracing/FeatureFlagTracingOptions.ts new file mode 100644 index 00000000..006d969e --- /dev/null +++ b/src/requestTracing/FeatureFlagTracingOptions.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TIME_WINDOW_FILTER_NAMES, TARGETING_FILTER_NAMES } from "../featureManagement/constants.js"; +import { CUSTOM_FILTER_KEY, TIME_WINDOW_FILTER_KEY, TARGETING_FILTER_KEY, FF_SEED_USED_TAG, FF_TELEMETRY_USED_TAG, DELIMITER } from "./constants.js"; + +/** + * Tracing for tracking feature flag usage. + */ +export class FeatureFlagTracingOptions { + /** + * Built-in feature filter usage. + */ + usesCustomFilter: boolean = false; + usesTimeWindowFilter: boolean = false; + usesTargetingFilter: boolean = false; + usesTelemetry: boolean = false; + usesSeed: boolean = false; + maxVariants: number = 0; + + resetFeatureFlagTracing(): void { + this.usesCustomFilter = false; + this.usesTimeWindowFilter = false; + this.usesTargetingFilter = false; + this.usesTelemetry = false; + this.usesSeed = false; + this.maxVariants = 0; + } + + updateFeatureFilterTracing(filterName: string): void { + if (TIME_WINDOW_FILTER_NAMES.some(name => name === filterName)) { + this.usesTimeWindowFilter = true; + } else if (TARGETING_FILTER_NAMES.some(name => name === filterName)) { + this.usesTargetingFilter = true; + } else { + this.usesCustomFilter = true; + } + } + + notifyMaxVariants(currentFFTotalVariants: number): void { + if (currentFFTotalVariants > this.maxVariants) { + this.maxVariants = currentFFTotalVariants; + } + } + + usesAnyFeatureFilter(): boolean { + return this.usesCustomFilter || this.usesTimeWindowFilter || this.usesTargetingFilter; + } + + usesAnyTracingFeature() { + return this.usesSeed || this.usesTelemetry; + } + + createFeatureFiltersString(): string { + if (!this.usesAnyFeatureFilter()) { + return ""; + } + + let result: string = ""; + if (this.usesCustomFilter) { + result += CUSTOM_FILTER_KEY; + } + if (this.usesTimeWindowFilter) { + if (result !== "") { + result += DELIMITER; + } + result += TIME_WINDOW_FILTER_KEY; + } + if (this.usesTargetingFilter) { + if (result !== "") { + result += DELIMITER; + } + result += TARGETING_FILTER_KEY; + } + return result; + } + + createFeaturesString(): string { + if (!this.usesAnyTracingFeature()) { + return ""; + } + + let result: string = ""; + if (this.usesSeed) { + result += FF_SEED_USED_TAG; + } + if (this.usesTelemetry) { + if (result !== "") { + result += DELIMITER; + } + result += FF_TELEMETRY_USED_TAG; + } + return result; + } +} diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 30a12f43..5a88b0fc 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -54,3 +54,16 @@ export const FAILOVER_REQUEST_TAG = "Failover"; // Compact feature tags export const FEATURES_KEY = "Features"; export const LOAD_BALANCE_CONFIGURED_TAG = "LB"; + +// Feature flag usage tracing +export const FEATURE_FILTER_TYPE_KEY = "Filter"; +export const CUSTOM_FILTER_KEY = "CSTM"; +export const TIME_WINDOW_FILTER_KEY = "TIME"; +export const TARGETING_FILTER_KEY = "TRGT"; + +export const FF_TELEMETRY_USED_TAG = "Telemetry"; +export const FF_MAX_VARIANTS_KEY = "MaxVariants"; +export const FF_SEED_USED_TAG = "Seed"; +export const FF_FEATURES_KEY = "FFFeatures"; + +export const DELIMITER = "+"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 23ec4602..b56c460c 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -3,6 +3,7 @@ import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration"; import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js"; +import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js"; import { AZURE_FUNCTION_ENV_VAR, AZURE_WEB_APP_ENV_VAR, @@ -10,6 +11,9 @@ import { DEV_ENV_VAL, ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED, ENV_KEY, + FEATURE_FILTER_TYPE_KEY, + FF_MAX_VARIANTS_KEY, + FF_FEATURES_KEY, HOST_TYPE_KEY, HostType, KEY_VAULT_CONFIGURED_TAG, @@ -32,6 +36,7 @@ export interface RequestTracingOptions { initialLoadCompleted: boolean; replicaCount: number; isFailoverRequest: boolean; + featureFlagTracing: FeatureFlagTracingOptions | undefined; } // Utils @@ -78,6 +83,9 @@ export function createCorrelationContextHeader(requestTracingOptions: RequestTra 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 + Filter: CSTM+TIME+TRGT + MaxVariants: identify the max number of variants feature flag uses + FFFeatures: Seed+Telemetry UsersKeyVault Failover */ @@ -96,6 +104,15 @@ export function createCorrelationContextHeader(requestTracingOptions: RequestTra } } + const featureFlagTracing = requestTracingOptions.featureFlagTracing; + if (featureFlagTracing) { + keyValues.set(FEATURE_FILTER_TYPE_KEY, featureFlagTracing.usesAnyFeatureFilter() ? featureFlagTracing.createFeatureFiltersString() : undefined); + keyValues.set(FF_FEATURES_KEY, featureFlagTracing.usesAnyTracingFeature() ? featureFlagTracing.createFeaturesString() : undefined); + if (featureFlagTracing.maxVariants > 0) { + keyValues.set(FF_MAX_VARIANTS_KEY, featureFlagTracing.maxVariants.toString()); + } + } + if (requestTracingOptions.isFailoverRequest) { tags.push(FAILOVER_REQUEST_TAG); } diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 311f857e..ed3fdaee 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -5,23 +5,11 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { MAX_TIME_OUT, createMockedConnectionString, createMockedKeyValue, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; +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 "./exportedApi.js"; -class HttpRequestHeadersPolicy { - headers: any; - name: string; - - constructor() { - this.headers = {}; - this.name = "HttpRequestHeadersPolicy"; - } - sendRequest(req, next) { - this.headers = req.headers; - return next(req).then(resp => resp); - } -} +const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; describe("request tracing", function () { this.timeout(MAX_TIME_OUT); @@ -156,6 +144,155 @@ describe("request tracing", function () { restoreMocks(); }); + it("should have filter type in correlation-context header if feature flags use feature filters", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { conditions: { client_filters: [ { name: "Microsoft.TimeWindow" } ] } }), + createMockedFeatureFlag("Alpha_2", { conditions: { client_filters: [ { name: "Microsoft.Targeting" } ] } }), + createMockedFeatureFlag("Alpha_3", { conditions: { client_filters: [ { name: "CustomFilter" } ] } }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("Filter=CSTM+TIME+TRGT")).eq(true); + + restoreMocks(); + }); + + it("should have max variants in correlation-context header if feature flags use variants", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { variants: [ {name: "a"}, {name: "b"}] }), + createMockedFeatureFlag("Alpha_2", { variants: [ {name: "a"}, {name: "b"}, {name: "c"}] }), + createMockedFeatureFlag("Alpha_3", { variants: [] }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("MaxVariants=3")).eq(true); + + restoreMocks(); + }); + + it("should have telemety tag in correlation-context header if feature flags enable telemetry", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("FFFeatures=Telemetry")).eq(true); + + restoreMocks(); + }); + + it("should have seed tag in correlation-context header if feature flags use allocation seed", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }), + createMockedFeatureFlag("Alpha_2", { allocation: {seed: "123"} }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("FFFeatures=Seed+Telemetry")).eq(true); + + restoreMocks(); + }); + describe("request tracing in Web Worker environment", () => { let originalNavigator; let originalWorkerNavigator; diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index d3e9a063..a5812694 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -247,6 +247,20 @@ const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => isReadOnly: false }, props)); +class HttpRequestHeadersPolicy { + headers: any; + name: string; + + constructor() { + this.headers = {}; + this.name = "HttpRequestHeadersPolicy"; + } + sendRequest(req, next) { + this.headers = req.headers; + return next(req).then(resp => resp); + } +} + export { sinon, mockAppConfigurationClientListConfigurationSettings, @@ -265,6 +279,6 @@ export { createMockedFeatureFlag, sleepInMs, - - MAX_TIME_OUT + MAX_TIME_OUT, + HttpRequestHeadersPolicy }; From 6e34f62fcb057c228373b3f77d712cd0a658f5a0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 19 Dec 2024 14:00:20 +0800 Subject: [PATCH 25/52] resolve merge conflict --- src/AzureAppConfigurationImpl.ts | 102 ------------------------------- 1 file changed, 102 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 8e4f5472..700db39d 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -93,19 +93,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors */ -<<<<<<< HEAD #kvSelectorCollection: SettingSelectorCollection = { selectors: [] }; /** * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors */ #ffSelectorCollection: SettingSelectorCollection = { selectors: [] }; -======= - #kvSelectors: PagedSettingSelector[] = []; - /** - * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors - */ - #ffSelectors: PagedSettingSelector[] = []; ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 // Load balancing #lastSuccessfulEndpoint: string = ""; @@ -132,9 +124,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } - // if no selector is specified, always load key values using the default selector: key="*" and label="\0" - this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); - if (options?.refreshOptions?.enabled) { const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; if (watchedSettings === undefined || watchedSettings.length === 0) { @@ -167,13 +156,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // feature flag options if (options?.featureFlagOptions?.enabled) { -<<<<<<< HEAD // validate feature flag selectors this.#ffSelectorCollection.selectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); -======= - // validate feature flag selectors, only load feature flags when enabled - this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 if (options.featureFlagOptions.refresh?.enabled) { const { refreshIntervalInMs } = options.featureFlagOptions.refresh; @@ -213,10 +197,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { initialLoadCompleted: this.#isInitialLoadCompleted, replicaCount: this.#clientManager.getReplicaCount(), isFailoverRequest: this.#isFailoverRequest, -<<<<<<< HEAD isCdnUsed: this.#isCdnUsed, -======= ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 featureFlagTracing: this.#featureFlagTracing }; } @@ -414,7 +395,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * If false, loads key-value using the key-value selectors. Defaults to false. */ async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { -<<<<<<< HEAD const selectorCollection = loadFeatureFlag ? this.#ffSelectorCollection : this.#kvSelectorCollection; const funcToExecute = async (client) => { const loadedSettings: ConfigurationSetting[] = []; @@ -437,22 +417,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; } -======= - const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; - const funcToExecute = async (client) => { - const loadedSettings: ConfigurationSetting[] = []; - // deep copy selectors to avoid modification if current client fails - const selectorsToUpdate = JSON.parse( - JSON.stringify(selectors) - ); - - for (const selector of selectorsToUpdate) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter - }; - ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, @@ -460,18 +424,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { listOptions ).byPage(); for await (const page of pageIterator) { -<<<<<<< HEAD pageEtags.push(page.etag ?? ""); // pageEtags is string[] -======= - pageEtags.push(page.etag ?? ""); ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 for (const setting of page.items) { if (loadFeatureFlag === isFeatureFlag(setting)) { loadedSettings.push(setting); } } } -<<<<<<< HEAD if (pageEtags.length === 0) { console.warn(`No page is found in the response of listing key-value selector: key=${selector.keyFilter} and label=${selector.labelFilter}.`); @@ -480,16 +439,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } selectorCollection.selectors = selectorsToUpdate; -======= - selector.pageEtags = pageEtags; - } - - if (loadFeatureFlag) { - this.#ffSelectors = selectorsToUpdate; - } else { - this.#kvSelectors = selectorsToUpdate; - } ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 return loadedSettings; }; @@ -527,7 +476,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (matchedSetting) { sentinel.etag = matchedSetting.etag; } else { -<<<<<<< HEAD // Send a request to retrieve watched key-value since it may be either not loaded or loaded with a different selector // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL const getOptions = this.#isCdnUsed ? @@ -535,16 +483,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { {}; const response = await this.#getConfigurationSetting(sentinel, {...getOptions, onlyIfChanged: false}); // always send non-conditional request sentinel.etag = response?.etag; -======= - // 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 { - sentinel.etag = undefined; - } ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 } } } @@ -589,7 +527,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // try refresh if any of watched settings is changed. let needRefresh = false; if (this.#watchAll) { -<<<<<<< HEAD needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectorCollection); } for (const sentinel of this.#sentinels.values()) { @@ -604,19 +541,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ) { sentinel.etag = response?.etag;// update etag of the sentinel this.#kvSelectorCollection.etagToBreakCdnCache = sentinel.etag; -======= - 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 ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 needRefresh = true; break; } @@ -640,11 +564,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } -<<<<<<< HEAD const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectorCollection); -======= - const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 if (needRefresh) { await this.#loadFeatureFlags(); } @@ -655,7 +575,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Checks whether the key-value collection has changed. -<<<<<<< HEAD * @param selectorCollection - The @see SettingSelectorCollection of the kev-value collection. * @returns true if key-value collection has changed, false otherwise. */ @@ -677,27 +596,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { listOptions = { ...listOptions, pageEtags: selector.pageEtags }; } -======= - * @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 { - const funcToExecute = async (client) => { - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter, - pageEtags: selector.pageEtags - }; - ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, client, listOptions ).byPage(); -<<<<<<< HEAD if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { selectorCollection.etagToBreakCdnCache = undefined; return true; // no etag is retrieved from previous request, always refresh @@ -719,12 +623,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { selectorCollection.etagToBreakCdnCache = selector.pageEtags[i]; } return true; -======= - for await (const page of pageIterator) { - if (page._response.status === 200) { // created or changed - return true; - } ->>>>>>> 71aebabca61a3e3fa5df2b7112b1236768932408 } } return false; From 14dbe96647ffd1cb03d4be2edeaa64b94d317c50 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 19 Dec 2024 14:05:15 +0800 Subject: [PATCH 26/52] update --- src/AzureAppConfigurationImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 700db39d..d0663162 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -406,7 +406,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { for (const selector of selectorsToUpdate) { let listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter, + labelFilter: selector.labelFilter }; // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL From 8441b32562c91ce596b39b4b30169e6b5df5b93b Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 19 Dec 2024 14:27:28 +0800 Subject: [PATCH 27/52] resolve copilot comment --- src/AzureAppConfigurationImpl.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index d0663162..5998fef1 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -49,10 +49,10 @@ type SettingSelectorCollection = { selectors: PagedSettingSelector[]; /** - * The etag which has changed after the last refresh. This is used to append to the request url for breaking the CDN cache. - * It can either be a page etag or etag of a watched setting. + * This is used to append to the request url for breaking the CDN cache. + * It uses the etag which has changed after the last refresh. It can either be a page etag or etag of a watched setting. */ - etagToBreakCdnCache?: string; + cdnCacheBreakString?: string; } export class AzureAppConfigurationImpl implements AzureAppConfiguration { @@ -252,21 +252,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // use the first page etag of the first kv selector const defaultSelector = this.#kvSelectorCollection.selectors.find(s => s.pageEtags !== undefined); if (defaultSelector && defaultSelector.pageEtags!.length > 0) { - this.#kvSelectorCollection.etagToBreakCdnCache = defaultSelector.pageEtags![0]; + this.#kvSelectorCollection.cdnCacheBreakString = defaultSelector.pageEtags![0]; } else { - this.#kvSelectorCollection.etagToBreakCdnCache = undefined; + this.#kvSelectorCollection.cdnCacheBreakString = undefined; } } else if (this.#refreshEnabled) { // watched settings based refresh // use the etag of the first watched setting (sentinel) - this.#kvSelectorCollection.etagToBreakCdnCache = this.#sentinels.find(s => s.etag !== undefined)?.etag; + this.#kvSelectorCollection.cdnCacheBreakString = this.#sentinels.find(s => s.etag !== undefined)?.etag; } if (this.#featureFlagRefreshEnabled) { const defaultSelector = this.#ffSelectorCollection.selectors.find(s => s.pageEtags !== undefined); if (defaultSelector && defaultSelector.pageEtags!.length > 0) { - this.#ffSelectorCollection.etagToBreakCdnCache = defaultSelector.pageEtags![0]; + this.#ffSelectorCollection.cdnCacheBreakString = defaultSelector.pageEtags![0]; } else { - this.#ffSelectorCollection.etagToBreakCdnCache = undefined; + this.#ffSelectorCollection.cdnCacheBreakString = undefined; } } } @@ -413,7 +413,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#isCdnUsed) { listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.etagToBreakCdnCache ?? "" }} + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheBreakString ?? "" }} }; } @@ -479,7 +479,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Send a request to retrieve watched key-value since it may be either not loaded or loaded with a different selector // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL const getOptions = this.#isCdnUsed ? - { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.etagToBreakCdnCache ?? "" } } } : + { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } } } : {}; const response = await this.#getConfigurationSetting(sentinel, {...getOptions, onlyIfChanged: false}); // always send non-conditional request sentinel.etag = response?.etag; @@ -532,7 +532,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { for (const sentinel of this.#sentinels.values()) { // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL const getOptions = this.#isCdnUsed ? - { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.etagToBreakCdnCache ?? "" } } } : + { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } } } : {}; const response = await this.#getConfigurationSetting(sentinel, { ...getOptions, onlyIfChanged: !this.#isCdnUsed }); // if CDN is used, do not send conditional request @@ -540,7 +540,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { (response === undefined && sentinel.etag !== undefined) // deleted ) { sentinel.etag = response?.etag;// update etag of the sentinel - this.#kvSelectorCollection.etagToBreakCdnCache = sentinel.etag; + this.#kvSelectorCollection.cdnCacheBreakString = sentinel.etag; needRefresh = true; break; } @@ -590,7 +590,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.etagToBreakCdnCache ?? "" } }}; + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheBreakString ?? "" } }}; } else { // send conditional request if cdn is not used listOptions = { ...listOptions, pageEtags: selector.pageEtags }; @@ -603,7 +603,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ).byPage(); if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { - selectorCollection.etagToBreakCdnCache = undefined; + selectorCollection.cdnCacheBreakString = undefined; return true; // no etag is retrieved from previous request, always refresh } @@ -612,7 +612,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (i >= selector.pageEtags.length || // new page (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed if (this.#isCdnUsed) { - selectorCollection.etagToBreakCdnCache = page.etag; + selectorCollection.cdnCacheBreakString = page.etag; } return true; } @@ -620,7 +620,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (i !== selector.pageEtags.length) { // page removed if (this.#isCdnUsed) { - selectorCollection.etagToBreakCdnCache = selector.pageEtags[i]; + selectorCollection.cdnCacheBreakString = selector.pageEtags[i]; } return true; } From 6fe042ac86741c1ea3e228e78883e635aa496f42 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 19 Dec 2024 14:29:57 +0800 Subject: [PATCH 28/52] fix lint --- src/AzureAppConfigurationImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 5998fef1..0a24afc0 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -49,7 +49,7 @@ type SettingSelectorCollection = { selectors: PagedSettingSelector[]; /** - * This is used to append to the request url for breaking the CDN cache. + * This is used to append to the request url for breaking the CDN cache. * It uses the etag which has changed after the last refresh. It can either be a page etag or etag of a watched setting. */ cdnCacheBreakString?: string; From 41ad55466680f7020438db003b4fc473fb0380a9 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 29 Apr 2025 15:39:16 +0800 Subject: [PATCH 29/52] fix lint --- src/AzureAppConfigurationImpl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 6f7cb1e8..38655cfd 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -399,7 +399,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // use the etag of the first watched setting (sentinel) this.#kvSelectorCollection.cdnCacheBreakString = this.#sentinels.find(s => s.etag !== undefined)?.etag; } - + if (this.#featureFlagRefreshEnabled) { const defaultSelector = this.#ffSelectorCollection.selectors.find(s => s.pageEtags !== undefined); if (defaultSelector && defaultSelector.pageEtags!.length > 0) { @@ -409,7 +409,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } } - + this.#isInitialLoadCompleted = true; break; } catch (error) { From 334a047539c681460f41256dfbaa4ccbe2d67c36 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 29 Apr 2025 16:07:16 +0800 Subject: [PATCH 30/52] update testcase --- test/requestTracing.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 546df7bd..fac7f1a3 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -114,7 +114,10 @@ describe("request tracing", function () { it("should have cdn tag in correlation-context header when loadFromCdn is used", async () => { try { await loadFromCdn(fakeEndpoint, { - clientOptions + clientOptions, + startupOptions: { + timeoutInMs: 1 + } }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; @@ -126,7 +129,10 @@ describe("request tracing", function () { it("should not have cdn tag in correlation-context header when load is used", async () => { try { await load(createMockedConnectionString(fakeEndpoint), { - clientOptions + clientOptions, + startupOptions: { + timeoutInMs: 1 + } }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; From e4576050c83fc7b92abd2ebc94c9b9974a4e50fe Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 3 Jun 2025 23:28:57 +0800 Subject: [PATCH 31/52] update --- package-lock.json | 9 +++++---- package.json | 2 +- src/load.ts | 2 -- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1550b65..c93eea9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.2", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.8.0", + "@azure/app-configuration": "^1.9.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" }, @@ -57,9 +57,10 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.8.0.tgz", - "integrity": "sha512-RO4IGZMa3hI1yVhvb5rPr+r+UDxe4VDxbntFZIc5fsUPGqZbKzmGR2wABEtlrC2SU5YX6tL+NS3xWb4vf1M9lQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.9.0.tgz", + "integrity": "sha512-X0AVDQygL4AGLtplLYW+W0QakJpJ417sQldOacqwcBQ882tAPdUVs6V3mZ4jUjwVsgr+dV1v9zMmijvsp6XBxA==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", diff --git a/package.json b/package.json index 08374014..c47a8f62 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "uuid": "^9.0.1" }, "dependencies": { - "@azure/app-configuration": "^1.8.0", + "@azure/app-configuration": "^1.9.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" } diff --git a/src/load.ts b/src/load.ts index ae443cbe..1ef8be4e 100644 --- a/src/load.ts +++ b/src/load.ts @@ -80,8 +80,6 @@ export async function loadFromCdn( appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, - // Specify the api version that supports sas token authentication - apiVersion: "2024-09-01-preview", // Add etag url policy to append etag to the request url for breaking CDN cache additionalPolicies: [ ...(appConfigOptions.clientOptions?.additionalPolicies || []), From c096a50ee0044723fe5d485efdeb924a433032a6 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 4 Jun 2025 13:09:26 +0800 Subject: [PATCH 32/52] update --- src/AzureAppConfigurationImpl.ts | 53 ++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 210b0d2a..f0e3ca93 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -527,7 +527,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { labelFilter: selector.labelFilter }; - // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL if (this.#isCdnUsed) { listOptions = { ...listOptions, @@ -631,18 +631,19 @@ 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. */ - async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { + async #updateWatchedKeyValuesEtag(loadedSettings: ConfigurationSetting[]): Promise { for (const sentinel of this.#sentinels) { - const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); - if (matchedSetting) { - sentinel.etag = matchedSetting.etag; + const loaded = loadedSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); + if (loaded) { + sentinel.etag = loaded.etag; } else { // Send a request to retrieve watched key-value since it may be either not loaded or loaded with a different selector - // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - const getOptions = this.#isCdnUsed ? - { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } } } : - {}; - const response = await this.#getConfigurationSetting(sentinel, {...getOptions, onlyIfChanged: false}); // always send non-conditional request + // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + let getOptions: GetConfigurationSettingOptions = {}; + if (this.#isCdnUsed) { + getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } } }; + } + const response = await this.#getConfigurationSetting(sentinel, getOptions); sentinel.etag = response?.etag; } } @@ -695,12 +696,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#watchAll) { needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectorCollection); } + // if watchAll is true, there should be no sentinels for (const sentinel of this.#sentinels.values()) { - // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - const getOptions = this.#isCdnUsed ? - { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } } } : - {}; - const response = await this.#getConfigurationSetting(sentinel, { ...getOptions, onlyIfChanged: !this.#isCdnUsed }); // if CDN is used, do not send conditional request + // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + let getOptions: GetConfigurationSettingOptions = {}; + if (this.#isCdnUsed) { + // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + getOptions = { + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } }, + }; + } else { + // if CDN is not used, send conditional request + getOptions = { + onlyIfChanged: true + }; + } + const response = await this.#getConfigurationSetting(sentinel, getOptions); if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || (response === undefined && sentinel.etag !== undefined) // deleted @@ -756,13 +767,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; if (this.#isCdnUsed) { - // If cdn is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheBreakString ?? "" } }}; + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheBreakString ?? "" } } + }; } else { - // send conditional request if cdn is not used - listOptions = { ...listOptions, pageEtags: selector.pageEtags }; + // if CDN is not used, add page etags to the listOptions to send conditional request + listOptions = { + ...listOptions, + pageEtags: selector.pageEtags + }; } const pageIterator = listConfigurationSettingsWithTrace( From 811b0e4173c7f8d99213d13ae5c50b46c4f4c4ae Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 4 Jun 2025 13:14:40 +0800 Subject: [PATCH 33/52] fix lint --- src/AzureAppConfigurationImpl.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index f0e3ca93..44d7c6cd 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -702,13 +702,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { let getOptions: GetConfigurationSettingOptions = {}; if (this.#isCdnUsed) { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - getOptions = { + getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } }, }; } else { // if CDN is not used, send conditional request - getOptions = { - onlyIfChanged: true + getOptions = { + onlyIfChanged: true }; } const response = await this.#getConfigurationSetting(sentinel, getOptions); From 03d956d257b6f98afb13af42f56621841b137c49 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 4 Jun 2025 14:15:38 +0800 Subject: [PATCH 34/52] disable replica discovery for CDN --- src/load.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/load.ts b/src/load.ts index 1ef8be4e..3815dc6d 100644 --- a/src/load.ts +++ b/src/load.ts @@ -8,6 +8,7 @@ import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js" import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; import { EtagUrlPipelinePolicy } from "./EtagUrlPipelinePolicy.js"; import { instanceOfTokenCredential } from "./common/utils.js"; +import { ArgumentError } from "./common/error.js"; const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds @@ -75,7 +76,11 @@ export async function loadFromCdn( appConfigOptions?: AzureAppConfigurationOptions ): Promise { if (appConfigOptions === undefined) { - appConfigOptions = { clientOptions: {}}; + appConfigOptions = { + replicaDiscoveryEnabled: false // Disable replica discovery for CDN + }; + } else if (appConfigOptions.replicaDiscoveryEnabled) { + throw new ArgumentError("Replica discovery is not supported when loading from CDN."); } appConfigOptions.clientOptions = { From 96df9d90d43342a60eb01adb1b89aab0ef3a2f44 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 4 Jun 2025 17:26:56 +0800 Subject: [PATCH 35/52] WIP --- src/AzureAppConfigurationImpl.ts | 94 +++++++++++++++----------------- src/common/utils.ts | 23 ++++++++ 2 files changed, 67 insertions(+), 50 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 44d7c6cd..fcbcf384 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -23,7 +23,7 @@ import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; import { Disposable } from "./common/disposable.js"; -import { base64Helper, jsonSorter } from "./common/utils.js"; +import { base64Helper, jsonSorter, getCryptoModule } from "./common/utils.js"; import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME, @@ -77,9 +77,10 @@ type SettingSelectorCollection = { /** * This is used to append to the request url for breaking the CDN cache. - * It uses the etag which has changed after the last refresh. It can either be a page etag or etag of a watched setting. + * It is a hash value calculated from all page etags. + * When the refresh is based on watched settings, the hash value will be calculated from the etags of all watched settings. */ - cdnCacheBreakString?: string; + version?: string; } export class AzureAppConfigurationImpl implements AzureAppConfiguration { @@ -418,21 +419,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // use the first page etag of the first kv selector const defaultSelector = this.#kvSelectorCollection.selectors.find(s => s.pageEtags !== undefined); if (defaultSelector && defaultSelector.pageEtags!.length > 0) { - this.#kvSelectorCollection.cdnCacheBreakString = defaultSelector.pageEtags![0]; + this.#kvSelectorCollection.version = defaultSelector.pageEtags![0]; } else { - this.#kvSelectorCollection.cdnCacheBreakString = undefined; + this.#kvSelectorCollection.version = undefined; } } else if (this.#refreshEnabled) { // watched settings based refresh // use the etag of the first watched setting (sentinel) - this.#kvSelectorCollection.cdnCacheBreakString = this.#sentinels.find(s => s.etag !== undefined)?.etag; + this.#kvSelectorCollection.version = this.#sentinels.find(s => s.etag !== undefined)?.etag; } if (this.#featureFlagRefreshEnabled) { const defaultSelector = this.#ffSelectorCollection.selectors.find(s => s.pageEtags !== undefined); if (defaultSelector && defaultSelector.pageEtags!.length > 0) { - this.#ffSelectorCollection.cdnCacheBreakString = defaultSelector.pageEtags![0]; + this.#ffSelectorCollection.version = defaultSelector.pageEtags![0]; } else { - this.#ffSelectorCollection.cdnCacheBreakString = undefined; + this.#ffSelectorCollection.version = undefined; } } } @@ -531,7 +532,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#isCdnUsed) { listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheBreakString ?? "" }} + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.version ?? "" }} }; } @@ -641,7 +642,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL let getOptions: GetConfigurationSettingOptions = {}; if (this.#isCdnUsed) { - getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } } }; + getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.version ?? "" } } }; } const response = await this.#getConfigurationSetting(sentinel, getOptions); sentinel.etag = response?.etag; @@ -703,7 +704,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#isCdnUsed) { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL getOptions = { - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheBreakString ?? "" } }, + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.version ?? "" } }, }; } else { // if CDN is not used, send conditional request @@ -717,7 +718,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { (response === undefined && sentinel.etag !== undefined) // deleted ) { sentinel.etag = response?.etag;// update etag of the sentinel - this.#kvSelectorCollection.cdnCacheBreakString = sentinel.etag; + this.#kvSelectorCollection.version = sentinel.etag; needRefresh = true; break; } @@ -770,7 +771,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheBreakString ?? "" } } + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.version ?? "" } } }; } else { // if CDN is not used, add page etags to the listOptions to send conditional request @@ -787,7 +788,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ).byPage(); if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { - selectorCollection.cdnCacheBreakString = undefined; + selectorCollection.version = undefined; return true; // no etag is retrieved from previous request, always refresh } @@ -796,7 +797,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (i >= selector.pageEtags.length || // new page (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed if (this.#isCdnUsed) { - selectorCollection.cdnCacheBreakString = page.etag; + selectorCollection.version = page.etag; } return true; } @@ -804,7 +805,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (i !== selector.pageEtags.length) { // page removed if (this.#isCdnUsed) { - selectorCollection.cdnCacheBreakString = selector.pageEtags[i]; + selectorCollection.version = selector.pageEtags[i]; } return true; } @@ -1070,57 +1071,50 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } - 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; - } - } - + const crypto = getCryptoModule(); // Convert to UTF-8 encoded bytes - const data = new TextEncoder().encode(rawAllocationId); - - // In the browser, use crypto.subtle.digest + const payload = new TextEncoder().encode(rawAllocationId); + // In the browser or Node.js 18+, use crypto.subtle.digest if (crypto.subtle) { - const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashBuffer = await crypto.subtle.digest("SHA-256", payload); 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 + // Use the crypto module's hash function else { - const hash = crypto.createHash("sha256").update(data).digest(); + const hash = crypto.createHash("sha256").update(payload).digest(); // Only use the first 15 bytes const first15Bytes = hash.slice(0, 15); - return first15Bytes.toString("base64url"); } } + + async #calculteCacheConsistencyToken(etags: string[]): Promise { + const crypto = getCryptoModule(); + const sortedEtags = etags.sort(); + const rawString = "CacheConsistency\n" + sortedEtags.join("\n"); + // Convert to UTF-8 encoded bytes + const payload = new TextEncoder().encode(rawString); + // In the browser or Node.js 18+, use crypto.subtle.digest + if (crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", payload); + const hashArray = new Uint8Array(hashBuffer); + const base64String = btoa(String.fromCharCode(...hashArray)); + const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64urlString; + } + // Use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(payload).digest(); + return hash.toString("base64url"); + } + } } function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/common/utils.ts b/src/common/utils.ts index 0264a72e..79e85f56 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,6 +1,29 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +export function getCryptoModule(): any { + 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 { + crypto = require("crypto"); + } catch (error) { + console.error("Failed to load the crypto module:", error.message); + throw error; + } + } + return crypto; +} + export function base64Helper(str: string): string { const bytes = new TextEncoder().encode(str); // UTF-8 encoding let chars = ""; From ac33118502860f8f2d17f858ae597bd1745911cb Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 5 Jun 2025 14:32:50 +0800 Subject: [PATCH 36/52] update to latest design --- src/AzureAppConfigurationImpl.ts | 96 ++++++++++++-------------------- 1 file changed, 36 insertions(+), 60 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index fcbcf384..a31ae5b5 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -77,10 +77,11 @@ type SettingSelectorCollection = { /** * This is used to append to the request url for breaking the CDN cache. - * It is a hash value calculated from all page etags. - * When the refresh is based on watched settings, the hash value will be calculated from the etags of all watched settings. + * It uses the etag which has changed after the last refresh. + * It can either be the page etag or etag of a watched setting depending on the refresh monitoring strategy. + * When a watched setting is deleted, the token value will be SHA-256 hash of `ResourceDeleted\n{previous-etag}`. */ - version?: string; + cdnCacheConsistencyToken?: string; } export class AzureAppConfigurationImpl implements AzureAppConfiguration { @@ -413,31 +414,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#featureFlagEnabled) { await this.#loadFeatureFlags(); } - - if (this.#isCdnUsed) { - if (this.#watchAll) { // collection monitoring based refresh - // use the first page etag of the first kv selector - const defaultSelector = this.#kvSelectorCollection.selectors.find(s => s.pageEtags !== undefined); - if (defaultSelector && defaultSelector.pageEtags!.length > 0) { - this.#kvSelectorCollection.version = defaultSelector.pageEtags![0]; - } else { - this.#kvSelectorCollection.version = undefined; - } - } else if (this.#refreshEnabled) { // watched settings based refresh - // use the etag of the first watched setting (sentinel) - this.#kvSelectorCollection.version = this.#sentinels.find(s => s.etag !== undefined)?.etag; - } - - if (this.#featureFlagRefreshEnabled) { - const defaultSelector = this.#ffSelectorCollection.selectors.find(s => s.pageEtags !== undefined); - if (defaultSelector && defaultSelector.pageEtags!.length > 0) { - this.#ffSelectorCollection.version = defaultSelector.pageEtags![0]; - } else { - this.#ffSelectorCollection.version = undefined; - } - } - } - this.#isInitialLoadCompleted = true; break; } catch (error) { @@ -520,7 +496,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const selectorsToUpdate: PagedSettingSelector[] = JSON.parse( JSON.stringify(selectorCollection.selectors) ); - for (const selector of selectorsToUpdate) { if (selector.snapshotName === undefined) { let listOptions: ListConfigurationSettingsOptions = { @@ -529,13 +504,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - if (this.#isCdnUsed) { + if (this.#isCdnUsed && selectorCollection.cdnCacheConsistencyToken) { listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.version ?? "" }} + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheConsistencyToken }} }; } - const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, @@ -630,7 +604,9 @@ 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. + * 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. + * If there is no watched setting(sentinel key), this method does nothing. */ async #updateWatchedKeyValuesEtag(loadedSettings: ConfigurationSetting[]): Promise { for (const sentinel of this.#sentinels) { @@ -641,8 +617,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Send a request to retrieve watched key-value since it may be either not loaded or loaded with a different selector // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL let getOptions: GetConfigurationSettingOptions = {}; - if (this.#isCdnUsed) { - getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.version ?? "" } } }; + if (this.#isCdnUsed && this.#kvSelectorCollection.cdnCacheConsistencyToken) { + getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheConsistencyToken } } }; } const response = await this.#getConfigurationSetting(sentinel, getOptions); sentinel.etag = response?.etag; @@ -699,26 +675,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // if watchAll is true, there should be no sentinels for (const sentinel of this.#sentinels.values()) { - // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + // if CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL let getOptions: GetConfigurationSettingOptions = {}; - if (this.#isCdnUsed) { - // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + if (this.#isCdnUsed && this.#kvSelectorCollection.cdnCacheConsistencyToken) { + // if CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL getOptions = { - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.version ?? "" } }, - }; - } else { - // if CDN is not used, send conditional request - getOptions = { - onlyIfChanged: true + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheConsistencyToken ?? "" } }, }; } - const response = await this.#getConfigurationSetting(sentinel, getOptions); + // send conditional request only when CDN is not used + const response = await this.#getConfigurationSetting(sentinel, { ...getOptions, onlyIfChanged: !this.#isCdnUsed }); if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || (response === undefined && sentinel.etag !== undefined) // deleted ) { - sentinel.etag = response?.etag;// update etag of the sentinel - this.#kvSelectorCollection.version = sentinel.etag; + if (response === undefined) { + this.#kvSelectorCollection.cdnCacheConsistencyToken = + await this.#calculateResourceDeletedCacheConsistencyToken(sentinel.etag!); + } else { + this.#kvSelectorCollection.cdnCacheConsistencyToken = response.etag; + } + sentinel.etag = response?.etag; // update etag of the sentinel needRefresh = true; break; } @@ -767,17 +744,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { labelFilter: selector.labelFilter }; - if (this.#isCdnUsed) { - // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL + if (!this.#isCdnUsed) { + // if CDN is not used, add page etags to the listOptions to send conditional request listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.version ?? "" } } + pageEtags: selector.pageEtags }; - } else { - // if CDN is not used, add page etags to the listOptions to send conditional request + } else if (selectorCollection.cdnCacheConsistencyToken) { + // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL listOptions = { ...listOptions, - pageEtags: selector.pageEtags + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheConsistencyToken } } }; } @@ -788,7 +765,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ).byPage(); if (selector.pageEtags === undefined || selector.pageEtags.length === 0) { - selectorCollection.version = undefined; return true; // no etag is retrieved from previous request, always refresh } @@ -796,8 +772,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { for await (const page of pageIterator) { if (i >= selector.pageEtags.length || // new page (page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed + // 100 kvs will return two pages, one page with 100 items and another empty page + // kv collection change will always be detected by page etag change if (this.#isCdnUsed) { - selectorCollection.version = page.etag; + selectorCollection.cdnCacheConsistencyToken = page.etag; } return true; } @@ -805,7 +783,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (i !== selector.pageEtags.length) { // page removed if (this.#isCdnUsed) { - selectorCollection.version = selector.pageEtags[i]; + selectorCollection.cdnCacheConsistencyToken = selector.pageEtags[i]; } return true; } @@ -1095,11 +1073,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } - async #calculteCacheConsistencyToken(etags: string[]): Promise { + async #calculateResourceDeletedCacheConsistencyToken(etag: string): Promise { const crypto = getCryptoModule(); - const sortedEtags = etags.sort(); - const rawString = "CacheConsistency\n" + sortedEtags.join("\n"); - // Convert to UTF-8 encoded bytes + const rawString = `ResourceDeleted\n${etag}`; const payload = new TextEncoder().encode(rawString); // In the browser or Node.js 18+, use crypto.subtle.digest if (crypto.subtle) { From 3528e877c7d1fa93d76a3394c896bae54189c19b Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 5 Jun 2025 15:04:56 +0800 Subject: [PATCH 37/52] update --- src/load.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/load.ts b/src/load.ts index 3815dc6d..c9dff437 100644 --- a/src/load.ts +++ b/src/load.ts @@ -49,7 +49,8 @@ export async function load( } try { - const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, credentialOrOptions === emptyTokenCredential); + const isCdnUsed: boolean = credentialOrOptions === emptyTokenCredential; + const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, isCdnUsed); await appConfiguration.load(); return appConfiguration; } catch (error) { From 19b70c2e950b0978b091f0f4b8c4574f6e3c3fc3 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 6 Jun 2025 09:56:55 +0800 Subject: [PATCH 38/52] update --- src/AzureAppConfigurationImpl.ts | 26 +++++++++++++------------- src/load.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index a31ae5b5..f529fb2a 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -81,7 +81,7 @@ type SettingSelectorCollection = { * It can either be the page etag or etag of a watched setting depending on the refresh monitoring strategy. * When a watched setting is deleted, the token value will be SHA-256 hash of `ResourceDeleted\n{previous-etag}`. */ - cdnCacheConsistencyToken?: string; + cdnToken?: string; } export class AzureAppConfigurationImpl implements AzureAppConfiguration { @@ -504,10 +504,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - if (this.#isCdnUsed && selectorCollection.cdnCacheConsistencyToken) { + if (this.#isCdnUsed && selectorCollection.cdnToken) { listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheConsistencyToken }} + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnToken }} }; } const pageEtags: string[] = []; @@ -617,8 +617,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Send a request to retrieve watched key-value since it may be either not loaded or loaded with a different selector // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL let getOptions: GetConfigurationSettingOptions = {}; - if (this.#isCdnUsed && this.#kvSelectorCollection.cdnCacheConsistencyToken) { - getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheConsistencyToken } } }; + if (this.#isCdnUsed && this.#kvSelectorCollection.cdnToken) { + getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken } } }; } const response = await this.#getConfigurationSetting(sentinel, getOptions); sentinel.etag = response?.etag; @@ -677,10 +677,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { for (const sentinel of this.#sentinels.values()) { // if CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL let getOptions: GetConfigurationSettingOptions = {}; - if (this.#isCdnUsed && this.#kvSelectorCollection.cdnCacheConsistencyToken) { + if (this.#isCdnUsed && this.#kvSelectorCollection.cdnToken) { // if CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL getOptions = { - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnCacheConsistencyToken ?? "" } }, + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken ?? "" } }, }; } // send conditional request only when CDN is not used @@ -690,10 +690,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { (response === undefined && sentinel.etag !== undefined) // deleted ) { if (response === undefined) { - this.#kvSelectorCollection.cdnCacheConsistencyToken = + this.#kvSelectorCollection.cdnToken = await this.#calculateResourceDeletedCacheConsistencyToken(sentinel.etag!); } else { - this.#kvSelectorCollection.cdnCacheConsistencyToken = response.etag; + this.#kvSelectorCollection.cdnToken = response.etag; } sentinel.etag = response?.etag; // update etag of the sentinel needRefresh = true; @@ -750,11 +750,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ...listOptions, pageEtags: selector.pageEtags }; - } else if (selectorCollection.cdnCacheConsistencyToken) { + } else if (selectorCollection.cdnToken) { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnCacheConsistencyToken } } + requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnToken } } }; } @@ -775,7 +775,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // 100 kvs will return two pages, one page with 100 items and another empty page // kv collection change will always be detected by page etag change if (this.#isCdnUsed) { - selectorCollection.cdnCacheConsistencyToken = page.etag; + selectorCollection.cdnToken = page.etag; } return true; } @@ -783,7 +783,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (i !== selector.pageEtags.length) { // page removed if (this.#isCdnUsed) { - selectorCollection.cdnCacheConsistencyToken = selector.pageEtags[i]; + selectorCollection.cdnToken = selector.pageEtags[i]; } return true; } diff --git a/src/load.ts b/src/load.ts index c9dff437..ffdb97dc 100644 --- a/src/load.ts +++ b/src/load.ts @@ -78,11 +78,15 @@ export async function loadFromCdn( ): Promise { if (appConfigOptions === undefined) { appConfigOptions = { - replicaDiscoveryEnabled: false // Disable replica discovery for CDN + replicaDiscoveryEnabled: false // replica discovery will be enabled by default, disable it for CDN manually }; - } else if (appConfigOptions.replicaDiscoveryEnabled) { + } + if (appConfigOptions.replicaDiscoveryEnabled) { throw new ArgumentError("Replica discovery is not supported when loading from CDN."); } + if (appConfigOptions.loadBalancingEnabled) { + throw new ArgumentError("Load balancing is not supported when loading from CDN."); + } appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, From a30b421f6a8577fcf4571704c112632349af23e4 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 6 Jun 2025 10:06:57 +0800 Subject: [PATCH 39/52] update naming --- src/AzureAppConfigurationImpl.ts | 10 +++++----- src/CdnTokenPipelinePolicy.ts | 29 +++++++++++++++++++++++++++++ src/EtagUrlPipelinePolicy.ts | 29 ----------------------------- src/load.ts | 4 ++-- 4 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 src/CdnTokenPipelinePolicy.ts delete mode 100644 src/EtagUrlPipelinePolicy.ts diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index f529fb2a..d8457f32 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -62,7 +62,7 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { ETAG_LOOKUP_HEADER } from "./EtagUrlPipelinePolicy.js"; +import { CDN_TOKEN_LOOKUP_HEADER } from "./CdnTokenPipelinePolicy.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js"; @@ -507,7 +507,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#isCdnUsed && selectorCollection.cdnToken) { listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnToken }} + requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: selectorCollection.cdnToken }} }; } const pageEtags: string[] = []; @@ -618,7 +618,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL let getOptions: GetConfigurationSettingOptions = {}; if (this.#isCdnUsed && this.#kvSelectorCollection.cdnToken) { - getOptions = { requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken } } }; + getOptions = { requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken } } }; } const response = await this.#getConfigurationSetting(sentinel, getOptions); sentinel.etag = response?.etag; @@ -680,7 +680,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#isCdnUsed && this.#kvSelectorCollection.cdnToken) { // if CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL getOptions = { - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken ?? "" } }, + requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken ?? "" } }, }; } // send conditional request only when CDN is not used @@ -754,7 +754,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL listOptions = { ...listOptions, - requestOptions: { customHeaders: { [ETAG_LOOKUP_HEADER]: selectorCollection.cdnToken } } + requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: selectorCollection.cdnToken } } }; } diff --git a/src/CdnTokenPipelinePolicy.ts b/src/CdnTokenPipelinePolicy.ts new file mode 100644 index 00000000..216222aa --- /dev/null +++ b/src/CdnTokenPipelinePolicy.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelinePolicy } from "@azure/core-rest-pipeline"; + +export const CDN_TOKEN_LOOKUP_HEADER = "cdn-token-lookup"; + +/** + * The pipeline policy that retrieves the CDN token from the request header and appends it to the request URL. After that the lookup header is removed from the request. + * @remarks + * The policy position should be perCall. + * The App Configuration service will not recognize the CDN token query parameter in the url, but this can help to break the CDN cache as the cache entry is based on the URL. + */ +export class CdnTokenPipelinePolicy implements PipelinePolicy { + name: string = "AppConfigurationCdnTokenPolicy"; + + async sendRequest(request, next) { + if (request.headers.has(CDN_TOKEN_LOOKUP_HEADER)) { + const token = request.headers.get(CDN_TOKEN_LOOKUP_HEADER); + request.headers.delete(CDN_TOKEN_LOOKUP_HEADER); + + const url = new URL(request.url); + url.searchParams.append("_", token); // _ is a dummy query parameter to break the CDN cache + request.url = url.toString(); + } + + return next(request); + } +} diff --git a/src/EtagUrlPipelinePolicy.ts b/src/EtagUrlPipelinePolicy.ts deleted file mode 100644 index 224eedb7..00000000 --- a/src/EtagUrlPipelinePolicy.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { PipelinePolicy } from "@azure/core-rest-pipeline"; - -export const ETAG_LOOKUP_HEADER = "Etag-Lookup"; - -/** - * The pipeline policy that retrieves the etag from the request header and appends it to the request URL. After that the etag header is removed from the request. - * @remarks - * The policy position should be perCall. - * The App Configuration service will not recognize the etag query parameter in the url, but this can help to break the CDN cache as the cache entry is based on the URL. - */ -export class EtagUrlPipelinePolicy implements PipelinePolicy { - name: string = "AppConfigurationEtagUrlPolicy"; - - async sendRequest(request, next) { - if (request.headers.has(ETAG_LOOKUP_HEADER)) { - const etag = request.headers.get(ETAG_LOOKUP_HEADER); - request.headers.delete(ETAG_LOOKUP_HEADER); - - const url = new URL(request.url); - url.searchParams.append("_", etag); // _ is a dummy query parameter to break the CDN cache - request.url = url.toString(); - } - - return next(request); - } -} diff --git a/src/load.ts b/src/load.ts index ffdb97dc..85af1ec2 100644 --- a/src/load.ts +++ b/src/load.ts @@ -6,7 +6,7 @@ import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { EtagUrlPipelinePolicy } from "./EtagUrlPipelinePolicy.js"; +import { CdnTokenPipelinePolicy } from "./CdnTokenPipelinePolicy.js"; import { instanceOfTokenCredential } from "./common/utils.js"; import { ArgumentError } from "./common/error.js"; @@ -93,7 +93,7 @@ export async function loadFromCdn( // Add etag url policy to append etag to the request url for breaking CDN cache additionalPolicies: [ ...(appConfigOptions.clientOptions?.additionalPolicies || []), - { policy: new EtagUrlPipelinePolicy(), position: "perCall" } + { policy: new CdnTokenPipelinePolicy(), position: "perCall" } ] }; From 3f052857cb13b682cb6216b064759c527c90475b Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 10 Jun 2025 14:30:19 +0800 Subject: [PATCH 40/52] update --- src/AzureAppConfigurationImpl.ts | 23 ++----------- src/CdnTokenPipelinePolicy.ts | 29 ----------------- src/cdnTokenPipelinePolicy.ts | 56 ++++++++++++++++++++++++++++++++ src/load.ts | 2 +- 4 files changed, 59 insertions(+), 51 deletions(-) delete mode 100644 src/CdnTokenPipelinePolicy.ts create mode 100644 src/cdnTokenPipelinePolicy.ts diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index d8457f32..23134ae6 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -62,7 +62,7 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { CDN_TOKEN_LOOKUP_HEADER } from "./CdnTokenPipelinePolicy.js"; +import { CDN_TOKEN_LOOKUP_HEADER, calculateResourceDeletedCacheConsistencyToken } from "./cdnTokenPipelinePolicy.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js"; @@ -691,7 +691,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ) { if (response === undefined) { this.#kvSelectorCollection.cdnToken = - await this.#calculateResourceDeletedCacheConsistencyToken(sentinel.etag!); + await calculateResourceDeletedCacheConsistencyToken(sentinel.etag!); } else { this.#kvSelectorCollection.cdnToken = response.etag; } @@ -1072,25 +1072,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return first15Bytes.toString("base64url"); } } - - async #calculateResourceDeletedCacheConsistencyToken(etag: string): Promise { - const crypto = getCryptoModule(); - const rawString = `ResourceDeleted\n${etag}`; - const payload = new TextEncoder().encode(rawString); - // In the browser or Node.js 18+, use crypto.subtle.digest - if (crypto.subtle) { - const hashBuffer = await crypto.subtle.digest("SHA-256", payload); - const hashArray = new Uint8Array(hashBuffer); - const base64String = btoa(String.fromCharCode(...hashArray)); - const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - return base64urlString; - } - // Use the crypto module's hash function - else { - const hash = crypto.createHash("sha256").update(payload).digest(); - return hash.toString("base64url"); - } - } } function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/CdnTokenPipelinePolicy.ts b/src/CdnTokenPipelinePolicy.ts deleted file mode 100644 index 216222aa..00000000 --- a/src/CdnTokenPipelinePolicy.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { PipelinePolicy } from "@azure/core-rest-pipeline"; - -export const CDN_TOKEN_LOOKUP_HEADER = "cdn-token-lookup"; - -/** - * The pipeline policy that retrieves the CDN token from the request header and appends it to the request URL. After that the lookup header is removed from the request. - * @remarks - * The policy position should be perCall. - * The App Configuration service will not recognize the CDN token query parameter in the url, but this can help to break the CDN cache as the cache entry is based on the URL. - */ -export class CdnTokenPipelinePolicy implements PipelinePolicy { - name: string = "AppConfigurationCdnTokenPolicy"; - - async sendRequest(request, next) { - if (request.headers.has(CDN_TOKEN_LOOKUP_HEADER)) { - const token = request.headers.get(CDN_TOKEN_LOOKUP_HEADER); - request.headers.delete(CDN_TOKEN_LOOKUP_HEADER); - - const url = new URL(request.url); - url.searchParams.append("_", token); // _ is a dummy query parameter to break the CDN cache - request.url = url.toString(); - } - - return next(request); - } -} diff --git a/src/cdnTokenPipelinePolicy.ts b/src/cdnTokenPipelinePolicy.ts new file mode 100644 index 00000000..b4baca06 --- /dev/null +++ b/src/cdnTokenPipelinePolicy.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelinePolicy } from "@azure/core-rest-pipeline"; +import { getCryptoModule } from "./common/utils.js"; + +const CDN_TOKEN_QUERY_PARAMETER = "_"; +const RESOURCE_DELETED_PREFIX = "ResourceDeleted"; + +export const CDN_TOKEN_LOOKUP_HEADER = "cdn-token-lookup"; + +/** + * The pipeline policy that retrieves the CDN token from the request header and appends it to the request URL. After that the lookup header is removed from the request. + * @remarks + * The policy position should be perCall. + * The App Configuration service will not recognize the CDN token query parameter in the url, but this can help to break the CDN cache as the cache entry is based on the URL. + */ +export class CdnTokenPipelinePolicy implements PipelinePolicy { + name: string = "AppConfigurationCdnTokenPolicy"; + + async sendRequest(request, next) { + if (request.headers.has(CDN_TOKEN_LOOKUP_HEADER)) { + const token = request.headers.get(CDN_TOKEN_LOOKUP_HEADER); + request.headers.delete(CDN_TOKEN_LOOKUP_HEADER); + + const url = new URL(request.url); + url.searchParams.append(CDN_TOKEN_QUERY_PARAMETER, token); // _ is a dummy query parameter to break the CDN cache + request.url = url.toString(); + } + + return next(request); + } +} + +/** + * Calculates a cache consistency token for a deleted resource based on its previous ETag. + * @param etag - The previous ETag of the deleted resource. + */ +export async function calculateResourceDeletedCacheConsistencyToken(etag: string): Promise { + const crypto = getCryptoModule(); + const rawString = `${RESOURCE_DELETED_PREFIX}\n${etag}`; + const payload = new TextEncoder().encode(rawString); + // In the browser or Node.js 18+, use crypto.subtle.digest + if (crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", payload); + const hashArray = new Uint8Array(hashBuffer); + const base64String = btoa(String.fromCharCode(...hashArray)); + const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64urlString; + } + // Use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(payload).digest(); + return hash.toString("base64url"); + } +} diff --git a/src/load.ts b/src/load.ts index 85af1ec2..67e269f1 100644 --- a/src/load.ts +++ b/src/load.ts @@ -6,7 +6,7 @@ import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { CdnTokenPipelinePolicy } from "./CdnTokenPipelinePolicy.js"; +import { CdnTokenPipelinePolicy } from "./cdnTokenPipelinePolicy.js"; import { instanceOfTokenCredential } from "./common/utils.js"; import { ArgumentError } from "./common/error.js"; From 4f9951269cae7bff90c5300a86b790c4fdac9cd9 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 12 Jun 2025 10:44:35 +0800 Subject: [PATCH 41/52] rename api to loadFromAzureFrontDoor --- src/index.ts | 2 +- src/load.ts | 12 ++++++------ test/exportedApi.ts | 2 +- test/requestTracing.test.ts | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 162ecc84..e4c4b818 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,6 @@ export { AzureAppConfiguration } from "./AzureAppConfiguration.js"; export { Disposable } from "./common/disposable.js"; -export { load, loadFromCdn } 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 67e269f1..409ccaff 100644 --- a/src/load.ts +++ b/src/load.ts @@ -66,14 +66,14 @@ export async function load( } /** - * Loads the data from a CDN and returns an instance of AzureAppConfiguration. - * @param cdnEndpoint The URL to the CDN. + * 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 loadFromCdn(cdnEndpoint: URL | string, options?: AzureAppConfigurationOptions): Promise; +export async function loadFromAzureFrontDoor(endpoint: URL | string, options?: AzureAppConfigurationOptions): Promise; -export async function loadFromCdn( - cdnEndpoint: string | URL, +export async function loadFromAzureFrontDoor( + endpoint: string | URL, appConfigOptions?: AzureAppConfigurationOptions ): Promise { if (appConfigOptions === undefined) { @@ -97,5 +97,5 @@ export async function loadFromCdn( ] }; - return await load(cdnEndpoint, emptyTokenCredential, appConfigOptions); + return await load(endpoint, emptyTokenCredential, appConfigOptions); } diff --git a/test/exportedApi.ts b/test/exportedApi.ts index 6765519d..f6b84d16 100644 --- a/test/exportedApi.ts +++ b/test/exportedApi.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { load, loadFromCdn } from "../src"; +export { load, loadFromAzureFrontDoor } from "../src"; diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 591090c9..b626b0ea 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -7,7 +7,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, loadFromCdn } from "./exportedApi.js"; +import { load, loadFromAzureFrontDoor } from "./exportedApi.js"; const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; @@ -111,9 +111,9 @@ describe("request tracing", function () { sinon.restore(); }); - it("should have cdn tag in correlation-context header when loadFromCdn is used", async () => { + it("should have cdn tag in correlation-context header when loadFromAzureFrontDoor is used", async () => { try { - await loadFromCdn(fakeEndpoint, { + await loadFromAzureFrontDoor(fakeEndpoint, { clientOptions, startupOptions: { timeoutInMs: 1 From 08f4732841b26b83ca2e4d1cf87d31f5a4c8a9b6 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 13 Jun 2025 11:13:51 +0800 Subject: [PATCH 42/52] update error message --- src/load.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/load.ts b/src/load.ts index 409ccaff..06453e6e 100644 --- a/src/load.ts +++ b/src/load.ts @@ -82,10 +82,10 @@ export async function loadFromAzureFrontDoor( }; } if (appConfigOptions.replicaDiscoveryEnabled) { - throw new ArgumentError("Replica discovery is not supported when loading from CDN."); + throw new ArgumentError("Replica discovery is not supported when loading from Azure Front Door."); } if (appConfigOptions.loadBalancingEnabled) { - throw new ArgumentError("Load balancing is not supported when loading from CDN."); + throw new ArgumentError("Load balancing is not supported when loading from Azure Front Door."); } appConfigOptions.clientOptions = { From c0ae98a4d69d1823d5d69bf03467d0809cccbaeb Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 26 Jun 2025 11:09:28 +0800 Subject: [PATCH 43/52] add policy to remove authorization header --- src/AzureAppConfigurationImpl.ts | 2 +- .../cdnRequestPipelinePolicy.ts} | 18 +++++++++++++++++- src/load.ts | 9 +++++---- 3 files changed, 23 insertions(+), 6 deletions(-) rename src/{cdnTokenPipelinePolicy.ts => azureFrontDoor/cdnRequestPipelinePolicy.ts} (76%) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 23134ae6..c08e6c2c 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -62,7 +62,7 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { CDN_TOKEN_LOOKUP_HEADER, calculateResourceDeletedCacheConsistencyToken } from "./cdnTokenPipelinePolicy.js"; +import { CDN_TOKEN_LOOKUP_HEADER, calculateResourceDeletedCacheConsistencyToken } from "./azureFrontDoor/cdnRequestPipelinePolicy.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js"; diff --git a/src/cdnTokenPipelinePolicy.ts b/src/azureFrontDoor/cdnRequestPipelinePolicy.ts similarity index 76% rename from src/cdnTokenPipelinePolicy.ts rename to src/azureFrontDoor/cdnRequestPipelinePolicy.ts index b4baca06..75cc3326 100644 --- a/src/cdnTokenPipelinePolicy.ts +++ b/src/azureFrontDoor/cdnRequestPipelinePolicy.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { PipelinePolicy } from "@azure/core-rest-pipeline"; -import { getCryptoModule } from "./common/utils.js"; +import { getCryptoModule } from "../common/utils.js"; const CDN_TOKEN_QUERY_PARAMETER = "_"; const RESOURCE_DELETED_PREFIX = "ResourceDeleted"; @@ -54,3 +54,19 @@ export async function calculateResourceDeletedCacheConsistencyToken(etag: string return hash.toString("base64url"); } } + +/** + * 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); + } +} \ No newline at end of file diff --git a/src/load.ts b/src/load.ts index 409ccaff..79990975 100644 --- a/src/load.ts +++ b/src/load.ts @@ -6,15 +6,15 @@ import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { CdnTokenPipelinePolicy } from "./cdnTokenPipelinePolicy.js"; +import { CdnTokenPipelinePolicy, AnonymousRequestPipelinePolicy } from "./azureFrontDoor/cdnRequestPipelinePolicy.js"; import { instanceOfTokenCredential } from "./common/utils.js"; import { ArgumentError } from "./common/error.js"; const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds -// Empty token credential to be used when loading from CDN +// Empty token credential to be used when loading from Azure Front Door const emptyTokenCredential: TokenCredential = { - getToken: async () => ({ token: "", expiresOnTimestamp: 0 }) + getToken: async () => ({ token: "", expiresOnTimestamp: Number.MAX_SAFE_INTEGER }) }; /** @@ -93,7 +93,8 @@ export async function loadFromAzureFrontDoor( // Add etag url policy to append etag to the request url for breaking CDN cache additionalPolicies: [ ...(appConfigOptions.clientOptions?.additionalPolicies || []), - { policy: new CdnTokenPipelinePolicy(), position: "perCall" } + { policy: new CdnTokenPipelinePolicy(), position: "perCall" }, + { policy: new AnonymousRequestPipelinePolicy(), position: "perRetry" } ] }; From c061b9828e127705515c0566c6ce96e163302979 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 26 Jun 2025 11:20:56 +0800 Subject: [PATCH 44/52] add test --- test/load.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/load.test.ts b/test/load.test.ts index 7806789d..ba8e9214 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -5,7 +5,7 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { load } from "./exportedApi.js"; +import { load, loadFromAzureFrontDoor } from "./exportedApi.js"; import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; const mockedKVs = [{ @@ -113,6 +113,14 @@ describe("load", function () { expect(settings.get("app.settings.fontSize")).eq("40"); }); + it("should load data from Azure Front Door", async () => { + const endpoint = createMockedEndpoint(); + const settings = await loadFromAzureFrontDoor(endpoint); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + it("should throw error given invalid connection string", async () => { return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string"); }); From 3005407e74a362e6665e5f815e5f5695fa7e8af2 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 26 Jun 2025 11:26:38 +0800 Subject: [PATCH 45/52] fix lint --- src/azureFrontDoor/cdnRequestPipelinePolicy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azureFrontDoor/cdnRequestPipelinePolicy.ts b/src/azureFrontDoor/cdnRequestPipelinePolicy.ts index 75cc3326..50bf947d 100644 --- a/src/azureFrontDoor/cdnRequestPipelinePolicy.ts +++ b/src/azureFrontDoor/cdnRequestPipelinePolicy.ts @@ -69,4 +69,4 @@ export class AnonymousRequestPipelinePolicy implements PipelinePolicy { } return next(request); } -} \ No newline at end of file +} From f367161ea9770debc2e33674aff4475a041fe9d0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 26 Jun 2025 12:23:16 +0800 Subject: [PATCH 46/52] update --- src/load.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/load.ts b/src/load.ts index abafbb1d..53daf365 100644 --- a/src/load.ts +++ b/src/load.ts @@ -76,10 +76,8 @@ export async function loadFromAzureFrontDoor( endpoint: string | URL, appConfigOptions?: AzureAppConfigurationOptions ): Promise { - if (appConfigOptions === undefined) { - appConfigOptions = { - replicaDiscoveryEnabled: false // replica discovery will be enabled by default, disable it for CDN manually - }; + if (!appConfigOptions) { + appConfigOptions = {}; } if (appConfigOptions.replicaDiscoveryEnabled) { throw new ArgumentError("Replica discovery is not supported when loading from Azure Front Door."); @@ -87,6 +85,7 @@ export async function loadFromAzureFrontDoor( if (appConfigOptions.loadBalancingEnabled) { throw new ArgumentError("Load balancing is not supported when loading from Azure Front Door."); } + appConfigOptions.replicaDiscoveryEnabled = false; // Disable replica discovery when loading from Azure Front Door appConfigOptions.clientOptions = { ...appConfigOptions.clientOptions, From d74fdd29851979847b3a0144a88c16281c3e72c1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 26 Jun 2025 14:49:08 +0800 Subject: [PATCH 47/52] add cache break token test --- src/requestTracing/utils.ts | 1 + test/exportedApi.ts | 1 + test/load.test.ts | 10 +-- test/loadFromAzureFrontDoor.test.ts | 119 ++++++++++++++++++++++++++++ test/refresh.test.ts | 2 +- 5 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 test/loadFromAzureFrontDoor.test.ts diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index a142c7fd..20ac8b30 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -95,6 +95,7 @@ function applyRequestTracing(requestTracingOptions: actualOptions.requestOptions = { ...actualOptions.requestOptions, customHeaders: { + ...actualOptions.requestOptions?.customHeaders, [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) } }; diff --git a/test/exportedApi.ts b/test/exportedApi.ts index f6b84d16..74ff44f2 100644 --- a/test/exportedApi.ts +++ b/test/exportedApi.ts @@ -2,3 +2,4 @@ // Licensed under the MIT license. export { load, loadFromAzureFrontDoor } from "../src"; +export { CDN_TOKEN_LOOKUP_HEADER } from "../src/azureFrontDoor/cdnRequestPipelinePolicy.js"; diff --git a/test/load.test.ts b/test/load.test.ts index ba8e9214..7806789d 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -5,7 +5,7 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { load, loadFromAzureFrontDoor } from "./exportedApi.js"; +import { load } from "./exportedApi.js"; import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; const mockedKVs = [{ @@ -113,14 +113,6 @@ describe("load", function () { expect(settings.get("app.settings.fontSize")).eq("40"); }); - it("should load data from Azure Front Door", async () => { - const endpoint = createMockedEndpoint(); - const settings = await loadFromAzureFrontDoor(endpoint); - expect(settings).not.undefined; - expect(settings.get("app.settings.fontColor")).eq("red"); - expect(settings.get("app.settings.fontSize")).eq("40"); - }); - it("should throw error given invalid connection string", async () => { return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string"); }); diff --git a/test/loadFromAzureFrontDoor.test.ts b/test/loadFromAzureFrontDoor.test.ts new file mode 100644 index 00000000..b1b546cc --- /dev/null +++ b/test/loadFromAzureFrontDoor.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; +import { CDN_TOKEN_LOOKUP_HEADER, loadFromAzureFrontDoor } from "./exportedApi.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedEndpoint, createMockedKeyValue, sleepInMs } from "./utils/testHelper.js"; +import * as uuid from "uuid"; +import { ListConfigurationSettingsOptions, GetConfigurationSettingOptions } from "@azure/app-configuration"; + +let mockedKVs: any[] = []; + +function updateSetting(key: string, value: any) { + const setting = mockedKVs.find(elem => elem.key === key); + if (setting) { + setting.value = value; + setting.etag = uuid.v4(); + } +} + +describe("load from Azure Front Door", function () { + this.timeout(MAX_TIME_OUT); + + before(() => { + mockedKVs = [ + { value: "red", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings([mockedKVs]); + }); + + after(() => { + restoreMocks(); + }); + + it("should load data from Azure Front Door", async () => { + const endpoint = createMockedEndpoint(); + const settings = await loadFromAzureFrontDoor(endpoint); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should throw error when replica discovery is enabled", async () => { + const endpoint = createMockedEndpoint(); + return expect(loadFromAzureFrontDoor(endpoint, { + replicaDiscoveryEnabled: true + })).eventually.rejectedWith("Replica discovery is not supported when loading from Azure Front Door."); + }); + + it("should throw error when load balancing is enabled", async () => { + const endpoint = createMockedEndpoint(); + return expect(loadFromAzureFrontDoor(endpoint, { + loadBalancingEnabled: true + })).eventually.rejectedWith("Load balancing is not supported when loading from Azure Front Door."); + }); +}); + +let cdnTokenLookup; +const listKvFromAfdCallback = (options: ListConfigurationSettingsOptions) => { + cdnTokenLookup = options.requestOptions?.customHeaders?.[CDN_TOKEN_LOOKUP_HEADER]; +}; +const getKvFromAfdCallback = (options: GetConfigurationSettingOptions) => { + cdnTokenLookup = options.requestOptions?.customHeaders?.[CDN_TOKEN_LOOKUP_HEADER]; +}; +describe("dynamic refresh when loading from Azure Front Door", function () { + this.timeout(MAX_TIME_OUT); + + beforeEach(() => { + mockedKVs = [ + { value: "red", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvFromAfdCallback); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvFromAfdCallback); + }); + + afterEach(() => { + restoreMocks(); + }); + + it("should append cdn token to the watch request", async () => { + const endpoint = createMockedEndpoint(); + const settings = await loadFromAzureFrontDoor(endpoint, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2_000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + updateSetting("app.settings.fontColor", "blue"); + + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(cdnTokenLookup).is.undefined; + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("blue"); + expect(cdnTokenLookup).is.not.undefined; + const previousCdnToken = cdnTokenLookup; + + updateSetting("app.settings.fontColor", "green"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("green"); + expect(cdnTokenLookup).is.not.undefined; + expect(cdnTokenLookup).to.not.eq(previousCdnToken); + }); +}); diff --git a/test/refresh.test.ts b/test/refresh.test.ts index d03d9436..f989b6fe 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -438,7 +438,7 @@ describe("dynamic refresh", function () { }); describe("dynamic refresh feature flags", function () { - this.timeout(10000); + this.timeout(MAX_TIME_OUT); beforeEach(() => { }); From 333eb3ba5be1f46bda4755a9313a8efac915ae7c Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 26 Jun 2025 14:54:51 +0800 Subject: [PATCH 48/52] update request tracing testcase --- test/requestTracing.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index b626b0ea..824a568b 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -121,6 +121,7 @@ describe("request tracing", function () { }); } catch (e) { /* 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); From 486ef1ba5b6f4774ab812c7f39015ea56eb6cd72 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 10 Jul 2025 11:18:50 +0800 Subject: [PATCH 49/52] update --- src/load.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/load.ts b/src/load.ts index 53daf365..4d4122d4 100644 --- a/src/load.ts +++ b/src/load.ts @@ -74,11 +74,8 @@ export async function loadFromAzureFrontDoor(endpoint: URL | string, options?: A export async function loadFromAzureFrontDoor( endpoint: string | URL, - appConfigOptions?: AzureAppConfigurationOptions + appConfigOptions: AzureAppConfigurationOptions = {} ): Promise { - if (!appConfigOptions) { - appConfigOptions = {}; - } if (appConfigOptions.replicaDiscoveryEnabled) { throw new ArgumentError("Replica discovery is not supported when loading from Azure Front Door."); } From 26ff70f1444d6af306bb3c1b8a23713e20a3937a Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 30 Jul 2025 18:30:11 +0800 Subject: [PATCH 50/52] add example --- examples/console-app/.env.template | 2 + examples/console-app/azureFrontDoor.mjs | 36 + examples/console-app/helloworld.mjs | 2 +- examples/console-app/helloworld_aad.mjs | 2 +- examples/console-app/package-lock.json | 878 +++++++++++++++++++++++- examples/console-app/package.json | 2 +- 6 files changed, 915 insertions(+), 7 deletions(-) create mode 100644 examples/console-app/azureFrontDoor.mjs diff --git a/examples/console-app/.env.template b/examples/console-app/.env.template index daf09ed8..304f7fff 100644 --- a/examples/console-app/.env.template +++ b/examples/console-app/.env.template @@ -10,3 +10,5 @@ APPCONFIG_ENDPOINT= AZURE_TENANT_ID= AZURE_CLIENT_ID= AZURE_CLIENT_SECRET= + +AZURE_FRONT_DOOR_ENDPOINT= \ No newline at end of file diff --git a/examples/console-app/azureFrontDoor.mjs b/examples/console-app/azureFrontDoor.mjs new file mode 100644 index 00000000..14a26044 --- /dev/null +++ b/examples/console-app/azureFrontDoor.mjs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as dotenv from "dotenv"; +import { promisify } from "util"; +dotenv.config(); +const sleepInMs = promisify(setTimeout); + +/** + * This example retrives all settings with key following pattern "app.settings.*", i.e. starting with "app.settings.". + * With the option `trimKeyPrefixes`, it trims the prefix "app.settings." from keys for simplicity. + * Value of config "app.settings.message" will be printed. + * + * Below environment variables are required for this example: + * - APPCONFIG_CONNECTION_STRING + */ + +import { loadFromAzureFrontDoor } from "@azure/app-configuration-provider"; +const endpoint = process.env.AZURE_FRONT_DOOR_ENDPOINT; +const settings = await loadFromAzureFrontDoor(endpoint, { + selectors: [{ + keyFilter: "CDN.*" + }], + trimKeyPrefixes: ["CDN."], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 15_000 + } +}); + +while (true) { + await settings.refresh(); + console.log(`Message from Azure Front Door: ${settings.get("Message")}`); + // wait for 30 seconds + await sleepInMs(30_000); +} \ No newline at end of file diff --git a/examples/console-app/helloworld.mjs b/examples/console-app/helloworld.mjs index 5a3558c7..630d386a 100644 --- a/examples/console-app/helloworld.mjs +++ b/examples/console-app/helloworld.mjs @@ -2,7 +2,7 @@ // Licensed under the MIT license. import * as dotenv from "dotenv"; -dotenv.config() +dotenv.config(); /** * This example retrives all settings with key following pattern "app.settings.*", i.e. starting with "app.settings.". diff --git a/examples/console-app/helloworld_aad.mjs b/examples/console-app/helloworld_aad.mjs index 37ac15e8..55a4474d 100644 --- a/examples/console-app/helloworld_aad.mjs +++ b/examples/console-app/helloworld_aad.mjs @@ -2,7 +2,7 @@ // Licensed under the MIT license. import * as dotenv from "dotenv"; -dotenv.config() +dotenv.config(); /** * This example retrives all settings with key following pattern "app.settings.*", i.e. starting with "app.settings.". diff --git a/examples/console-app/package-lock.json b/examples/console-app/package-lock.json index 43ee0f24..35c6c7f4 100644 --- a/examples/console-app/package-lock.json +++ b/examples/console-app/package-lock.json @@ -1,17 +1,19 @@ { - "name": "examples", + "name": "console-app", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@azure/app-configuration-provider": "../", + "@azure/app-configuration-provider": "../../", "@azure/identity": "^4.1.0", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "express": "^4.21.2" } }, "..": { "version": "2.0.0", + "extraneous": true, "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.6.1", @@ -41,6 +43,38 @@ "uuid": "^9.0.1" } }, + "../..": { + "name": "@azure/app-configuration-provider", + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "@azure/app-configuration": "^1.9.0", + "@azure/identity": "^4.2.1", + "@azure/keyvault-secrets": "^4.7.0" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^11.1.2", + "@types/mocha": "^10.0.4", + "@types/node": "^22.7.7", + "@types/sinon": "^17.0.1", + "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^6.6.0", + "@typescript-eslint/parser": "^6.6.0", + "chai": "^4.3.7", + "chai-as-promised": "^7.1.1", + "dotenv": "^16.3.1", + "eslint": "^8.48.0", + "mocha": "^10.2.0", + "nock": "^13.3.3", + "rimraf": "^5.0.1", + "rollup": "^3.29.5", + "rollup-plugin-dts": "^5.3.0", + "sinon": "^15.2.0", + "tslib": "^2.6.0", + "typescript": "^5.6.3", + "uuid": "^9.0.1" + } + }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -53,7 +87,7 @@ } }, "node_modules/@azure/app-configuration-provider": { - "resolved": "..", + "resolved": "../..", "link": true }, "node_modules/@azure/core-auth": { @@ -194,6 +228,19 @@ "node": ">=16" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/agent-base": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", @@ -205,11 +252,130 @@ "node": ">= 14" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -234,6 +400,25 @@ "node": ">=8" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -245,6 +430,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -253,6 +452,66 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -261,6 +520,216 @@ "node": ">=0.8.x" } }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -285,6 +754,33 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -404,11 +900,113 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -425,6 +1023,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -444,6 +1109,12 @@ } ] }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -455,6 +1126,156 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stoppable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", @@ -464,11 +1285,51 @@ "npm": ">=6" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -476,6 +1337,15 @@ "bin": { "uuid": "dist/bin/uuid" } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } } } } diff --git a/examples/console-app/package.json b/examples/console-app/package.json index 9358ff07..176dd352 100644 --- a/examples/console-app/package.json +++ b/examples/console-app/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@azure/app-configuration-provider": "latest", + "@azure/app-configuration-provider": "../../", "@azure/identity": "^4.1.0", "dotenv": "^16.3.1", "express": "^4.21.2" From 4c331c4cea325579c6d3338c515e07da212f25fc Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 13 Aug 2025 16:23:30 +0800 Subject: [PATCH 51/52] update --- rollup.config.mjs | 1 + src/AzureAppConfigurationImpl.ts | 51 ++++++++++++++++---------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 0df0e168..b20d604a 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -7,6 +7,7 @@ export default [ external: [ "@azure/app-configuration", "@azure/keyvault-secrets", + "@azure/core-client", "@azure/core-rest-pipeline", "@azure/identity", "crypto", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index e4d01060..29278be9 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { OperationOptions } from "@azure/core-client"; import { AppConfigurationClient, ConfigurationSetting, @@ -516,7 +517,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ); for (const selector of selectorsToUpdate) { if (selector.snapshotName === undefined) { - let listOptions: ListConfigurationSettingsOptions = { + const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, tagsFilter: selector.tagFilters @@ -524,11 +525,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL if (this.#isCdnUsed && selectorCollection.cdnToken) { - listOptions = { - ...listOptions, - requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: selectorCollection.cdnToken }} - }; + this.#addCdnTokenLookupHeader(listOptions, selectorCollection.cdnToken); } + const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions, @@ -631,10 +630,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { sentinel.etag = loaded.etag; } else { // Send a request to retrieve watched key-value since it may be either not loaded or loaded with a different selector - // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - let getOptions: GetConfigurationSettingOptions = {}; + const getOptions: GetConfigurationSettingOptions = {}; if (this.#isCdnUsed && this.#kvSelectorCollection.cdnToken) { - getOptions = { requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken } } }; + this.#addCdnTokenLookupHeader(getOptions, this.#kvSelectorCollection.cdnToken); } const response = await this.#getConfigurationSetting(sentinel, getOptions); sentinel.etag = response?.etag; @@ -691,16 +689,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // if watchAll is true, there should be no sentinels for (const sentinel of this.#sentinels.values()) { - // if CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - let getOptions: GetConfigurationSettingOptions = {}; + const getOptions: GetConfigurationSettingOptions = { + // send conditional request only when CDN is not used + onlyIfChanged: !this.#isCdnUsed + }; if (this.#isCdnUsed && this.#kvSelectorCollection.cdnToken) { - // if CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - getOptions = { - requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: this.#kvSelectorCollection.cdnToken ?? "" } }, - }; + this.#addCdnTokenLookupHeader(getOptions, this.#kvSelectorCollection.cdnToken); } - // send conditional request only when CDN is not used - const response = await this.#getConfigurationSetting(sentinel, { ...getOptions, onlyIfChanged: !this.#isCdnUsed }); + const response = await this.#getConfigurationSetting(sentinel, getOptions); if ((response?.statusCode === 200 && sentinel.etag !== response?.etag) || (response === undefined && sentinel.etag !== undefined) // deleted @@ -777,7 +773,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (selector.snapshotName) { // skip snapshot selector continue; } - let listOptions: ListConfigurationSettingsOptions = { + const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, tagsFilter: selector.tagFilters @@ -785,16 +781,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (!this.#isCdnUsed) { // if CDN is not used, add page etags to the listOptions to send conditional request - listOptions = { - ...listOptions, - pageEtags: selector.pageEtags - }; + listOptions.pageEtags = selector.pageEtags; } else if (selectorCollection.cdnToken) { - // If CDN is used, add etag to request header so that the pipeline policy can retrieve and append it to the request URL - listOptions = { - ...listOptions, - requestOptions: { customHeaders: { [CDN_TOKEN_LOOKUP_HEADER]: selectorCollection.cdnToken } } - }; + this.#addCdnTokenLookupHeader(listOptions, selectorCollection.cdnToken); } const pageIterator = listConfigurationSettingsWithTrace( @@ -1132,6 +1121,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return first15Bytes.toString("base64url"); } } + + #addCdnTokenLookupHeader(operationOptions: OperationOptions, cdnToken: string): void { + if (!operationOptions.requestOptions) { + operationOptions.requestOptions = {}; + } + if (!operationOptions.requestOptions.customHeaders) { + operationOptions.requestOptions.customHeaders = {}; + } + operationOptions.requestOptions.customHeaders[CDN_TOKEN_LOOKUP_HEADER] = cdnToken; + } } function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { From 5b81582aa063b54185fe358167e30b84351cbcd4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 13 Aug 2025 16:25:22 +0800 Subject: [PATCH 52/52] fix lint --- src/AzureAppConfigurationImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 29278be9..d85c8424 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -527,7 +527,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (this.#isCdnUsed && selectorCollection.cdnToken) { this.#addCdnTokenLookupHeader(listOptions, selectorCollection.cdnToken); } - + const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( this.#requestTraceOptions,