Skip to content

Commit a0a551e

Browse files
wip
1 parent 4682649 commit a0a551e

File tree

10 files changed

+205
-34
lines changed

10 files changed

+205
-34
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"dev": "rollup --config --watch",
3131
"lint": "eslint src/ test/ examples/ --ext .js,.ts,.mjs",
3232
"fix-lint": "eslint src/ test/ examples/ --fix --ext .js,.ts,.mjs",
33-
"test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel"
33+
"test": "mocha out/esm/test/load.test.js out/commonjs/test/load.test.js --parallel"
3434
},
3535
"repository": {
3636
"type": "git",

src/appConfigurationImpl.ts

Lines changed: 95 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import {
1313
isSecretReference,
1414
GetSnapshotOptions,
1515
GetSnapshotResponse,
16-
KnownSnapshotComposition
16+
KnownSnapshotComposition,
17+
ListConfigurationSettingPage
1718
} from "@azure/app-configuration";
18-
import { isRestError } from "@azure/core-rest-pipeline";
19+
import { isRestError, RestError } from "@azure/core-rest-pipeline";
1920
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./appConfiguration.js";
2021
import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js";
2122
import { IKeyValueAdapter } from "./keyValueAdapter.js";
@@ -66,6 +67,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js";
6667
import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js";
6768
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js";
6869
import { ErrorMessages } from "./common/errorMessages.js";
70+
import { TIMESTAMP_HEADER } from "./cdn/constants.js";
6971

7072
const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds
7173

@@ -106,12 +108,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
106108
#watchAll: boolean = false;
107109
#kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
108110
#kvRefreshTimer: RefreshTimer;
111+
#lastKvChangeDetected: Date = new Date(0);
112+
#kvRefreshIncompleted: boolean = false;
109113

110114
// Feature flags
111115
#featureFlagEnabled: boolean = false;
112116
#featureFlagRefreshEnabled: boolean = false;
113117
#ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
114118
#ffRefreshTimer: RefreshTimer;
119+
#lastFfChangeDetected: Date = new Date(0);
120+
#ffRefreshIncompleted: boolean = false;
115121

116122
// Key Vault references
117123
#secretRefreshEnabled: boolean = false;
@@ -131,12 +137,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
131137
// Load balancing
132138
#lastSuccessfulEndpoint: string = "";
133139

140+
// CDN
141+
#isCdnUsed: boolean = false;
142+
134143
constructor(
135144
clientManager: ConfigurationClientManager,
136145
options: AzureAppConfigurationOptions | undefined,
146+
isCdnUsed: boolean
137147
) {
138148
this.#options = options;
139149
this.#clientManager = clientManager;
150+
this.#isCdnUsed = isCdnUsed;
140151

141152
// enable request tracing if not opt-out
142153
this.#requestTracingEnabled = requestTracingEnabled();
@@ -224,7 +235,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
224235
isFailoverRequest: this.#isFailoverRequest,
225236
featureFlagTracing: this.#featureFlagTracing,
226237
fmVersion: this.#fmVersion,
227-
aiConfigurationTracing: this.#aiConfigurationTracing
238+
aiConfigurationTracing: this.#aiConfigurationTracing,
239+
isCdnUsed: this.#isCdnUsed
228240
};
229241
}
230242

@@ -498,6 +510,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
498510
JSON.stringify(selectors)
499511
);
500512

513+
let upToDate: boolean = true;
501514
for (const selector of selectorsToUpdate) {
502515
if (selector.snapshotName === undefined) {
503516
const listOptions: ListConfigurationSettingsOptions = {
@@ -519,6 +532,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
519532
loadedSettings.set(setting.key, setting);
520533
}
521534
}
535+
const timestamp = this.#getResponseTimestamp(page);
536+
// all pages must be later than last change detected to be considered up-to-date
537+
upToDate &&= (timestamp > (loadFeatureFlag ? this.#lastFfChangeDetected : this.#lastKvChangeDetected));
522538
}
523539
selector.pageEtags = pageEtags;
524540
} else { // snapshot selector
@@ -547,8 +563,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
547563

548564
if (loadFeatureFlag) {
549565
this.#ffSelectors = selectorsToUpdate;
566+
this.#ffRefreshIncompleted = !upToDate;
550567
} else {
551568
this.#kvSelectors = selectorsToUpdate;
569+
this.#kvRefreshIncompleted = !upToDate;
552570
}
553571
return Array.from(loadedSettings.values());
554572
};
@@ -605,11 +623,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
605623
} else {
606624
// Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing
607625
const { key, label } = sentinel;
608-
const response = await this.#getConfigurationSetting({ key, label });
609-
if (response) {
610-
sentinel.etag = response.etag;
611-
} else {
626+
const response = await this.#getConfigurationSetting({ key, label }, { onlyIfChanged: false });
627+
if (isRestError(response)) { // watched key not found
612628
sentinel.etag = undefined;
629+
} else {
630+
sentinel.etag = response.etag;
613631
}
614632
}
615633
}
@@ -661,22 +679,36 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
661679
let needRefresh = false;
662680
if (this.#watchAll) {
663681
needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors);
664-
}
665-
for (const sentinel of this.#sentinels.values()) {
666-
const response = await this.#getConfigurationSetting(sentinel, {
667-
onlyIfChanged: true
668-
});
669-
670-
if (response?.statusCode === 200 // created or changed
671-
|| (response === undefined && sentinel.etag !== undefined) // deleted
672-
) {
673-
sentinel.etag = response?.etag;// update etag of the sentinel
674-
needRefresh = true;
675-
break;
682+
} else {
683+
const getOptions: GetConfigurationSettingOptions = {
684+
// send conditional request only when CDN is not used
685+
onlyIfChanged: !this.#isCdnUsed
686+
};
687+
for (const sentinel of this.#sentinels.values()) {
688+
const response: GetConfigurationSettingResponse | RestError =
689+
await this.#getConfigurationSetting(sentinel, getOptions);
690+
691+
if (isRestError(response)) { // sentinel key not found
692+
if (sentinel.etag !== undefined) {
693+
// previously existed, now deleted
694+
sentinel.etag = undefined;
695+
const timestamp = this.#getResponseTimestamp(response);
696+
if (timestamp > this.#lastKvChangeDetected) {
697+
this.#lastKvChangeDetected = timestamp;
698+
}
699+
needRefresh = true;
700+
break;
701+
}
702+
} else if (response.statusCode === 200 && sentinel.etag !== response?.etag) {
703+
// change detected
704+
sentinel.etag = response?.etag;// update etag of the sentinel
705+
needRefresh = true;
706+
break;
707+
}
676708
}
677709
}
678710

679-
if (needRefresh) {
711+
if (needRefresh || this.#kvRefreshIncompleted) {
680712
for (const adapter of this.#adapters) {
681713
await adapter.onChangeDetected();
682714
}
@@ -697,8 +729,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
697729
return Promise.resolve(false);
698730
}
699731

700-
const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors);
701-
if (needRefresh) {
732+
const refreshFeatureFlag = true;
733+
const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors, refreshFeatureFlag);
734+
if (needRefresh || this.#ffRefreshIncompleted) {
702735
await this.#loadFeatureFlags();
703736
}
704737

@@ -730,7 +763,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
730763
* @param selectors - The @see PagedSettingSelector of the kev-value collection.
731764
* @returns true if key-value collection has changed, false otherwise.
732765
*/
733-
async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise<boolean> {
766+
async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[], refreshFeatureFlag: boolean = false): Promise<boolean> {
734767
const funcToExecute = async (client) => {
735768
for (const selector of selectors) {
736769
if (selector.snapshotName) { // skip snapshot selector
@@ -739,20 +772,41 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
739772
const listOptions: ListConfigurationSettingsOptions = {
740773
keyFilter: selector.keyFilter,
741774
labelFilter: selector.labelFilter,
742-
tagsFilter: selector.tagFilters,
743-
pageEtags: selector.pageEtags
775+
tagsFilter: selector.tagFilters
744776
};
745777

778+
if (!this.#isCdnUsed) {
779+
// if CDN is not used, add page etags to the listOptions to send conditional request
780+
listOptions.pageEtags = selector.pageEtags;
781+
}
782+
746783
const pageIterator = listConfigurationSettingsWithTrace(
747784
this.#requestTraceOptions,
748785
client,
749786
listOptions
750787
).byPage();
751788

789+
if (selector.pageEtags === undefined || selector.pageEtags.length === 0) {
790+
return true; // no etag is retrieved from previous request, always refresh
791+
}
792+
793+
let i = 0;
752794
for await (const page of pageIterator) {
753-
if (page._response.status === 200) { // created or changed
795+
if (i >= selector.pageEtags.length || // new page
796+
(page._response.status === 200 && page.etag !== selector.pageEtags[i])) { // page changed
797+
const timestamp = this.#getResponseTimestamp(page);
798+
if (refreshFeatureFlag) {
799+
if (timestamp > this.#lastFfChangeDetected) {
800+
this.#lastFfChangeDetected = timestamp;
801+
}
802+
} else {
803+
if (timestamp > this.#lastKvChangeDetected) {
804+
this.#lastKvChangeDetected = timestamp;
805+
}
806+
}
754807
return true;
755808
}
809+
i++;
756810
}
757811
}
758812
return false;
@@ -763,9 +817,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
763817
}
764818

765819
/**
766-
* Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error.
820+
* Gets a configuration setting by key and label. If the setting is not found, return the error instead of throwing it.
767821
*/
768-
async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise<GetConfigurationSettingResponse | undefined> {
822+
async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise<GetConfigurationSettingResponse | RestError> {
769823
const funcToExecute = async (client) => {
770824
return getConfigurationSettingWithTrace(
771825
this.#requestTraceOptions,
@@ -775,12 +829,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
775829
);
776830
};
777831

778-
let response: GetConfigurationSettingResponse | undefined;
832+
let response: GetConfigurationSettingResponse | RestError;
779833
try {
780834
response = await this.#executeWithFailoverPolicy(funcToExecute);
781835
} catch (error) {
782836
if (isRestError(error) && error.statusCode === 404) {
783-
response = undefined;
837+
// configuration setting not found, return the error
838+
return error;
784839
} else {
785840
throw error;
786841
}
@@ -1088,6 +1143,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
10881143
return first15Bytes.toString("base64url");
10891144
}
10901145
}
1146+
1147+
#getResponseTimestamp(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date {
1148+
let header: string | undefined;
1149+
if (isRestError(response)) {
1150+
header = response.response?.headers.get(TIMESTAMP_HEADER) ?? undefined;
1151+
} else {
1152+
header = response._response.headers.get(TIMESTAMP_HEADER) ?? undefined;
1153+
}
1154+
return header ? new Date(header) : new Date();
1155+
}
10911156
}
10921157

10931158
function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { PipelinePolicy } from "@azure/core-rest-pipeline";
5+
6+
/**
7+
* The pipeline policy that remove the authorization header from the request to allow anonymous access to the Azure Front Door.
8+
* @remarks
9+
* 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
10+
*/
11+
export class AnonymousRequestPipelinePolicy implements PipelinePolicy {
12+
name: string = "AppConfigurationAnonymousRequestPolicy";
13+
14+
async sendRequest(request, next) {
15+
if (request.headers.has("authorization")) {
16+
request.headers.delete("authorization");
17+
}
18+
return next(request);
19+
}
20+
}

src/cdn/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export const TIMESTAMP_HEADER = "x-ms-date";

src/common/errorMessages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const enum ErrorMessages {
2020
INVALID_LABEL_FILTER = "The characters '*' and ',' are not supported in label filters.",
2121
INVALID_TAG_FILTER = "Tag filter must follow the format 'tagName=tagValue'",
2222
CONNECTION_STRING_OR_ENDPOINT_MISSED = "A connection string or an endpoint with credential must be specified to create a client.",
23+
REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door.",
24+
LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door."
2325
}
2426

2527
export const enum KeyVaultReferenceErrorMessages {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

44
export { AzureAppConfiguration } from "./appConfiguration.js";
55
export { Disposable } from "./common/disposable.js";
6-
export { load } from "./load.js";
6+
export { load, loadFromAzureFrontDoor } from "./load.js";
77
export { KeyFilter, LabelFilter } from "./types.js";
88
export { VERSION } from "./version.js";

src/load.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ import { AzureAppConfiguration } from "./appConfiguration.js";
66
import { AzureAppConfigurationImpl } from "./appConfigurationImpl.js";
77
import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js";
88
import { ConfigurationClientManager } from "./configurationClientManager.js";
9+
import { AnonymousRequestPipelinePolicy } from "./cdn/cdnRequestPipelinePolicy.js";
910
import { instanceOfTokenCredential } from "./common/utils.js";
11+
import { ArgumentError } from "./common/errors.js";
12+
import { ErrorMessages } from "./common/errorMessages.js";
1013

1114
const MIN_DELAY_FOR_UNHANDLED_ERROR_IN_MS: number = 5_000;
1215

16+
// Empty token credential to be used when loading from Azure Front Door
17+
const emptyTokenCredential: TokenCredential = {
18+
getToken: async () => ({ token: "", expiresOnTimestamp: Number.MAX_SAFE_INTEGER })
19+
};
20+
1321
/**
1422
* Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration.
1523
* @param connectionString The connection string for the App Configuration store.
@@ -42,7 +50,8 @@ export async function load(
4250
}
4351

4452
try {
45-
const appConfiguration = new AzureAppConfigurationImpl(clientManager, options);
53+
const isCdnUsed: boolean = credentialOrOptions === emptyTokenCredential;
54+
const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, isCdnUsed);
4655
await appConfiguration.load();
4756
return appConfiguration;
4857
} catch (error) {
@@ -56,3 +65,34 @@ export async function load(
5665
throw error;
5766
}
5867
}
68+
69+
/**
70+
* Loads the data from Azure Front Door (CDN) and returns an instance of AzureAppConfiguration.
71+
* @param endpoint The URL to the Azure Front Door.
72+
* @param appConfigOptions Optional parameters.
73+
*/
74+
export async function loadFromAzureFrontDoor(endpoint: URL | string, options?: AzureAppConfigurationOptions): Promise<AzureAppConfiguration>;
75+
76+
export async function loadFromAzureFrontDoor(
77+
endpoint: string | URL,
78+
appConfigOptions: AzureAppConfigurationOptions = {}
79+
): Promise<AzureAppConfiguration> {
80+
if (appConfigOptions.replicaDiscoveryEnabled) {
81+
throw new ArgumentError(ErrorMessages.REPLICA_DISCOVERY_NOT_SUPPORTED);
82+
}
83+
if (appConfigOptions.loadBalancingEnabled) {
84+
throw new ArgumentError(ErrorMessages.LOAD_BALANCING_NOT_SUPPORTED);
85+
}
86+
appConfigOptions.replicaDiscoveryEnabled = false; // Disable replica discovery when loading from Azure Front Door
87+
88+
appConfigOptions.clientOptions = {
89+
...appConfigOptions.clientOptions,
90+
// Add etag url policy to append etag to the request url for breaking CDN cache
91+
additionalPolicies: [
92+
...(appConfigOptions.clientOptions?.additionalPolicies || []),
93+
{ policy: new AnonymousRequestPipelinePolicy(), position: "perRetry" }
94+
]
95+
};
96+
97+
return await load(endpoint, emptyTokenCredential, appConfigOptions);
98+
}

src/requestTracing/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount";
5151
export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault";
5252
export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault";
5353
export const FAILOVER_REQUEST_TAG = "Failover";
54+
export const CDN_USED_TAG = "CDN";
5455

5556
// Compact feature tags
5657
export const FEATURES_KEY = "Features";

0 commit comments

Comments
 (0)