Skip to content

Commit d0ecd09

Browse files
load from azure front door
1 parent a0a551e commit d0ecd09

File tree

4 files changed

+282
-18
lines changed

4 files changed

+282
-18
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/load.test.js out/commonjs/test/load.test.js --parallel"
33+
"test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel"
3434
},
3535
"repository": {
3636
"type": "git",

src/appConfigurationImpl.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js";
6767
import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js";
6868
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js";
6969
import { ErrorMessages } from "./common/errorMessages.js";
70-
import { TIMESTAMP_HEADER } from "./cdn/constants.js";
70+
import { TIMESTAMP_HEADER } from "./cdn/constants.js";
7171

7272
const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds
7373

@@ -534,7 +534,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
534534
}
535535
const timestamp = this.#getResponseTimestamp(page);
536536
// all pages must be later than last change detected to be considered up-to-date
537-
upToDate &&= (timestamp > (loadFeatureFlag ? this.#lastFfChangeDetected : this.#lastKvChangeDetected));
537+
if (loadFeatureFlag) {
538+
upToDate &&= (timestamp > this.#lastFfChangeDetected);
539+
} else {
540+
const temp = this.#lastKvChangeDetected;
541+
upToDate &&= (timestamp > temp);
542+
}
538543
}
539544
selector.pageEtags = pageEtags;
540545
} else { // snapshot selector
@@ -688,20 +693,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
688693
const response: GetConfigurationSettingResponse | RestError =
689694
await this.#getConfigurationSetting(sentinel, getOptions);
690695

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;
696+
const isDeleted = isRestError(response) && sentinel.etag !== undefined; // previously existed, now deleted
697+
const isChanged =
698+
!isRestError(response) &&
699+
response.statusCode === 200 &&
700+
sentinel.etag !== response.etag; // etag changed
701+
702+
if (isDeleted || isChanged) {
703+
sentinel.etag = isChanged ? (response as GetConfigurationSettingResponse).etag : undefined;
704+
const timestamp = this.#getResponseTimestamp(response);
705+
if (timestamp > this.#lastKvChangeDetected) {
706+
this.#lastKvChangeDetected = timestamp;
701707
}
702-
} else if (response.statusCode === 200 && sentinel.etag !== response?.etag) {
703-
// change detected
704-
sentinel.etag = response?.etag;// update etag of the sentinel
705708
needRefresh = true;
706709
break;
707710
}
@@ -1147,9 +1150,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
11471150
#getResponseTimestamp(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date {
11481151
let header: string | undefined;
11491152
if (isRestError(response)) {
1150-
header = response.response?.headers.get(TIMESTAMP_HEADER) ?? undefined;
1153+
header = response.response?.headers?.get(TIMESTAMP_HEADER) ?? undefined;
11511154
} else {
1152-
header = response._response.headers.get(TIMESTAMP_HEADER) ?? undefined;
1155+
header = response._response.headers?.get(TIMESTAMP_HEADER) ?? undefined;
11531156
}
11541157
return header ? new Date(header) : new Date();
11551158
}

test/cdn.test.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
/* eslint-disable @typescript-eslint/no-unused-expressions */
5+
import * as chai from "chai";
6+
import chaiAsPromised from "chai-as-promised";
7+
chai.use(chaiAsPromised);
8+
const expect = chai.expect;
9+
10+
import { AppConfigurationClient } from "@azure/app-configuration";
11+
import { loadFromAzureFrontDoor } from "../src/index.js";
12+
import { createMockedKeyValue, createMockedFeatureFlag, getCachedIterator, sinon, restoreMocks, createMockedAzureFrontDoorEndpoint, sleepInMs, MAX_TIME_OUT } from "./utils/testHelper.js";
13+
import { TIMESTAMP_HEADER } from "../src/cdn/constants.js";
14+
15+
function createTimestampHeaders(timestamp: string | Date) {
16+
const value = timestamp instanceof Date ? timestamp.toUTCString() : new Date(timestamp).toUTCString();
17+
return {
18+
get: (name: string) => name.toLowerCase() === TIMESTAMP_HEADER ? value : undefined
19+
};
20+
}
21+
22+
describe("loadFromAzureFrontDoor", function() {
23+
this.timeout(MAX_TIME_OUT);
24+
25+
afterEach(() => {
26+
restoreMocks();
27+
});
28+
29+
it("should load key-values and feature flags", async () => {
30+
const kv1 = createMockedKeyValue({ key: "app.color", value: "red" });
31+
const kv2 = createMockedKeyValue({ key: "app.size", value: "large" });
32+
const ff = createMockedFeatureFlag("Beta");
33+
34+
const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings");
35+
36+
stub.onCall(0).returns(getCachedIterator([
37+
{ items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
38+
]));
39+
stub.onCall(1).returns(getCachedIterator([
40+
{ items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
41+
]));
42+
43+
const endpoint = createMockedAzureFrontDoorEndpoint();
44+
const appConfig = await loadFromAzureFrontDoor(endpoint, {
45+
selectors: [{ keyFilter: "app.*" }],
46+
featureFlagOptions: {
47+
enabled: true
48+
}
49+
});
50+
51+
expect(appConfig.get("app.color")).to.equal("red");
52+
expect(appConfig.get("app.size")).to.equal("large");
53+
expect((appConfig.get<any>("feature_management").feature_flags as any[]).find(ff => ff.id === "Beta")).not.undefined;
54+
});
55+
56+
it("should refresh key-values if any page changes", async () => {
57+
const kv1 = createMockedKeyValue({ key: "app.key1", value: "value1" });
58+
const kv2 = createMockedKeyValue({ key: "app.key2", value: "value2" });
59+
const kv2_updated = createMockedKeyValue({ key: "app.key2", value: "value2-updated" });
60+
const kv3 = createMockedKeyValue({ key: "app.key3", value: "value3" });
61+
62+
const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings");
63+
64+
stub.onCall(0).returns(getCachedIterator([
65+
{ items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
66+
]));
67+
68+
stub.onCall(1).returns(getCachedIterator([
69+
{ items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
70+
]));
71+
stub.onCall(2).returns(getCachedIterator([
72+
{ items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
73+
]));
74+
75+
stub.onCall(3).returns(getCachedIterator([
76+
{ items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
77+
]));
78+
stub.onCall(4).returns(getCachedIterator([
79+
{ items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
80+
]));
81+
82+
stub.onCall(5).returns(getCachedIterator([
83+
{ items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
84+
]));
85+
stub.onCall(6).returns(getCachedIterator([
86+
{ items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
87+
]));
88+
89+
const endpoint = createMockedAzureFrontDoorEndpoint();
90+
const appConfig = await loadFromAzureFrontDoor(endpoint, {
91+
selectors: [{ keyFilter: "app.*" }],
92+
refreshOptions: {
93+
enabled: true,
94+
refreshIntervalInMs: 1000
95+
}
96+
});
97+
98+
expect(appConfig.get("app.key1")).to.equal("value1");
99+
expect(appConfig.get("app.key2")).to.equal("value2");
100+
101+
await sleepInMs(1000);
102+
await appConfig.refresh();
103+
104+
expect(appConfig.get("app.key2")).to.equal("value2-updated");
105+
106+
await sleepInMs(1000);
107+
await appConfig.refresh();
108+
109+
expect(appConfig.get("app.key2")).to.be.undefined;
110+
111+
await sleepInMs(1000);
112+
await appConfig.refresh();
113+
114+
expect(appConfig.get("app.key3")).to.equal("value3");
115+
});
116+
117+
it("should refresh feature flags if any page changes", async () => {
118+
const ff = createMockedFeatureFlag("Beta");
119+
const ff_updated = createMockedFeatureFlag("Beta", { enabled: false });
120+
121+
const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings");
122+
123+
stub.onCall(0).returns(getCachedIterator([
124+
{ items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
125+
]));
126+
stub.onCall(1).returns(getCachedIterator([
127+
{ items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
128+
]));
129+
130+
stub.onCall(2).returns(getCachedIterator([
131+
{ items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
132+
]));
133+
stub.onCall(3).returns(getCachedIterator([
134+
{ items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
135+
]));
136+
137+
const endpoint = createMockedAzureFrontDoorEndpoint();
138+
const appConfig = await loadFromAzureFrontDoor(endpoint, {
139+
featureFlagOptions: {
140+
enabled: true,
141+
refresh: {
142+
enabled: true,
143+
refreshIntervalInMs: 1000
144+
}
145+
}
146+
});
147+
148+
let featureFlags = appConfig.get<any>("feature_management").feature_flags;
149+
expect(featureFlags[0].id).to.equal("Beta");
150+
expect(featureFlags[0].enabled).to.equal(true);
151+
152+
await sleepInMs(1000);
153+
await appConfig.refresh();
154+
155+
featureFlags = appConfig.get<any>("feature_management").feature_flags;
156+
expect(featureFlags[0].id).to.equal("Beta");
157+
expect(featureFlags[0].enabled).to.equal(false);
158+
});
159+
160+
it("should keep refreshing key value until cache expires", async () => {
161+
const sentinel = createMockedKeyValue({ key: "sentinel", value: "initial value" });
162+
const sentinel_updated = createMockedKeyValue({ key: "sentinel", value: "updated value" });
163+
const kv1 = createMockedKeyValue({ key: "app.key1", value: "value1" });
164+
const kv2 = createMockedKeyValue({ key: "app.key2", value: "value2" });
165+
const kv2_updated = createMockedKeyValue({ key: "app.key2", value: "value2-updated" });
166+
167+
const getStub = sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting");
168+
const listStub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings");
169+
170+
getStub.onCall(0).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:00Z") }, ...sentinel } as any));
171+
getStub.onCall(1).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any));
172+
getStub.onCall(2).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any));
173+
174+
getStub.onCall(3).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any));
175+
getStub.onCall(4).returns(Promise.resolve({ statusCode: 200, _response: { headers: createTimestampHeaders("2025-09-07T00:00:01Z") }, ...sentinel_updated } as any));
176+
177+
listStub.onCall(0).returns(getCachedIterator([
178+
{ items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } }
179+
]));
180+
listStub.onCall(1).returns(getCachedIterator([
181+
{ items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } // cache has not expired
182+
]));
183+
listStub.onCall(2).returns(getCachedIterator([
184+
{ items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } // cache has expired
185+
]));
186+
187+
const endpoint = createMockedAzureFrontDoorEndpoint();
188+
const appConfig = await loadFromAzureFrontDoor(endpoint, {
189+
selectors: [{ keyFilter: "app.*" }],
190+
refreshOptions: {
191+
enabled: true,
192+
refreshIntervalInMs: 1000,
193+
watchedSettings: [
194+
{ key: "sentinel" }
195+
]
196+
}
197+
});
198+
199+
expect(appConfig.get("app.key2")).to.equal("value2");
200+
201+
await sleepInMs(1000);
202+
await appConfig.refresh();
203+
204+
// cdn cache hasn't expired, even if the sentinel key changed, key2 should still return the old value
205+
expect(appConfig.get("app.key2")).to.equal("value2");
206+
207+
await sleepInMs(1000);
208+
await appConfig.refresh();
209+
210+
// cdn cache has expired, key2 should return the updated value even if sentinel remains the same
211+
expect(appConfig.get("app.key2")).to.equal("value2-updated");
212+
});
213+
});
214+
/* eslint-enable @typescript-eslint/no-unused-expressions */

test/utils/testHelper.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,49 @@ function getMockedIterator(pages: ConfigurationSetting[][], kvs: ConfigurationSe
102102
return mockIterator as any;
103103
}
104104

105+
function getCachedIterator(pages: Array<{
106+
items: ConfigurationSetting[];
107+
response?: any;
108+
}>) {
109+
const iterator: AsyncIterableIterator<any> & { byPage(): AsyncIterableIterator<any> } = {
110+
[Symbol.asyncIterator](): AsyncIterableIterator<any> {
111+
return this;
112+
},
113+
next() {
114+
while (pages.length > 0) {
115+
pages.shift();
116+
}
117+
if (pages.length === 0) {
118+
return Promise.resolve({ done: true, value: undefined });
119+
}
120+
const value = pages[0].items.shift();
121+
return Promise.resolve({ done: !value, value });
122+
},
123+
byPage(): AsyncIterableIterator<any> {
124+
return {
125+
[Symbol.asyncIterator](): AsyncIterableIterator<any> { return this; },
126+
next() {
127+
const page = pages.shift();
128+
if (!page) {
129+
return Promise.resolve({ done: true, value: undefined });
130+
}
131+
const etag = _sha256(JSON.stringify(page.items));
132+
133+
return Promise.resolve({
134+
done: false,
135+
value: {
136+
items: page.items,
137+
etag,
138+
_response: page.response
139+
}
140+
});
141+
}
142+
};
143+
}
144+
};
145+
return iterator as any;
146+
}
147+
105148
/**
106149
* Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting.
107150
* E.g.
@@ -232,6 +275,8 @@ function restoreMocks() {
232275

233276
const createMockedEndpoint = (name = "azure") => `https://${name}.azconfig.io`;
234277

278+
const createMockedAzureFrontDoorEndpoint = (name = "appconfig") => `https://${name}.b01.azurefd.net`;
279+
235280
const createMockedConnectionString = (endpoint = createMockedEndpoint(), secret = "secret", id = "b1d9b31") => {
236281
const toEncodeAsBytes = Buffer.from(secret);
237282
const returnValue = toEncodeAsBytes.toString("base64");
@@ -313,9 +358,11 @@ export {
313358
mockAppConfigurationClientLoadBalanceMode,
314359
mockConfigurationManagerGetClients,
315360
mockSecretClientGetSecret,
361+
getCachedIterator,
316362
restoreMocks,
317363

318364
createMockedEndpoint,
365+
createMockedAzureFrontDoorEndpoint,
319366
createMockedConnectionString,
320367
createMockedTokenCredential,
321368
createMockedKeyVaultReference,

0 commit comments

Comments
 (0)