Skip to content

Commit 6f98582

Browse files
authored
Minor improvements (#92)
* Improve base64 decode perf. + add tests + fix code formatting * Use LocalStorageCache only when localStorage is available + don't swallow exceptions in LocalStorageCache.get/set * Escape ccetag query string value * Exclude non-source files so they don't pollute autocompletion/intellisense * Update to configcat-common v9.1.0 * Bump version
1 parent 662179e commit 6f98582

File tree

11 files changed

+178
-84
lines changed

11 files changed

+178
-84
lines changed

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "configcat-js",
3-
"version": "9.2.0",
3+
"version": "9.3.0",
44
"description": "ConfigCat is a configuration as a service that lets you manage your features and configurations without actually deploying new code.",
55
"main": "lib/index.js",
66
"types": "lib/index.d.ts",
@@ -33,7 +33,7 @@
3333
"license": "MIT",
3434
"homepage": "https://configcat.com",
3535
"dependencies": {
36-
"configcat-common": "^9.0.0",
36+
"configcat-common": "^9.1.0",
3737
"tslib": "^2.4.1"
3838
},
3939
"devDependencies": {

src/Cache.ts

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,58 @@
1-
import type { IConfigCatCache } from "configcat-common";
1+
import type { IConfigCatCache, IConfigCatKernel } from "configcat-common";
2+
import { ExternalConfigCache } from "configcat-common";
23

34
export class LocalStorageCache implements IConfigCatCache {
4-
set(key: string, value: string): void {
5-
try {
6-
localStorage.setItem(key, this.b64EncodeUnicode(value));
7-
}
8-
catch (ex) {
9-
// local storage is unavailable
5+
static setup(kernel: IConfigCatKernel, localStorageGetter?: () => Storage | null): IConfigCatKernel {
6+
const localStorage = (localStorageGetter ?? getLocalStorage)();
7+
if (localStorage) {
8+
kernel.defaultCacheFactory = options => new ExternalConfigCache(new LocalStorageCache(localStorage), options.logger);
109
}
10+
return kernel;
11+
}
12+
13+
constructor(private readonly storage: Storage) {
14+
}
15+
16+
set(key: string, value: string): void {
17+
this.storage.setItem(key, toUtf8Base64(value));
1118
}
1219

1320
get(key: string): string | undefined {
14-
try {
15-
const configString = localStorage.getItem(key);
16-
if (configString) {
17-
return this.b64DecodeUnicode(configString);
18-
}
21+
const configString = this.storage.getItem(key);
22+
if (configString) {
23+
return fromUtf8Base64(configString);
1924
}
20-
catch (ex) {
21-
// local storage is unavailable or invalid cache value in localstorage
22-
}
23-
return void 0;
2425
}
26+
}
2527

26-
private b64EncodeUnicode(str: string): string {
27-
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (_, p1) {
28-
return String.fromCharCode(parseInt(p1, 16))
29-
}));
30-
}
28+
export function getLocalStorage(): Storage | null {
29+
const testKey = "__configcat_localStorage_test";
30+
31+
try {
32+
const storage = window.localStorage;
33+
storage.setItem(testKey, testKey);
34+
35+
let retrievedItem: string | null;
36+
try { retrievedItem = storage.getItem(testKey); }
37+
finally { storage.removeItem(testKey); }
3138

32-
private b64DecodeUnicode(str: string): string {
33-
return decodeURIComponent(Array.prototype.map.call(atob(str), function (c: string) {
34-
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
35-
}).join(''));
39+
if (retrievedItem === testKey) {
40+
return storage;
41+
}
3642
}
43+
catch (err) { /* intentional no-op */ }
44+
45+
return null;
46+
}
47+
48+
export function toUtf8Base64(str: string): string {
49+
str = encodeURIComponent(str);
50+
str = str.replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)));
51+
return btoa(str);
52+
}
53+
54+
export function fromUtf8Base64(str: string): string {
55+
str = atob(str);
56+
str = str.replace(/[%\x80-\xFF]/g, m => "%" + m.charCodeAt(0).toString(16));
57+
return decodeURIComponent(str);
3758
}

src/ConfigFetcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class HttpConfigFetcher implements IConfigFetcher {
3838
let url = options.getUrl();
3939
if (lastEtag) {
4040
// We are sending the etag as a query parameter so if the browser doesn't automatically adds the If-None-Match header, we can transorm this query param to the header in our CDN provider.
41-
url += '&ccetag=' + lastEtag;
41+
url += "&ccetag=" + encodeURIComponent(lastEtag);
4242
}
4343
httpRequest.open("GET", url, true);
4444
httpRequest.timeout = options.requestTimeoutMs;

src/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IAutoPollOptions, IConfigCatClient, IConfigCatLogger, ILazyLoadingOptions, IManualPollOptions, LogLevel, OverrideBehaviour, SettingValue } from "configcat-common";
2-
import { ExternalConfigCache, FlagOverrides, MapOverrideDataSource, PollingMode } from "configcat-common";
2+
import { FlagOverrides, MapOverrideDataSource, PollingMode } from "configcat-common";
33
import * as configcatcommon from "configcat-common";
44
import { LocalStorageCache } from "./Cache";
55
import { HttpConfigFetcher } from "./ConfigFetcher";
@@ -17,12 +17,11 @@ import CONFIGCAT_SDK_VERSION from "./Version";
1717
*/
1818
export function getClient<TMode extends PollingMode | undefined>(sdkKey: string, pollingMode?: TMode, options?: OptionsForPollingMode<TMode>): IConfigCatClient {
1919
return configcatcommon.getClient(sdkKey, pollingMode ?? PollingMode.AutoPoll, options,
20-
{
20+
LocalStorageCache.setup({
2121
configFetcher: new HttpConfigFetcher(),
2222
sdkType: "ConfigCat-JS",
2323
sdkVersion: CONFIGCAT_SDK_VERSION,
24-
defaultCacheFactory: options => new ExternalConfigCache(new LocalStorageCache(), options.logger)
25-
});
24+
}));
2625
}
2726

2827
/**

test/CacheTests.ts

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,84 @@
11
import { assert } from "chai";
2-
import { LocalStorageCache } from "../lib/Cache";
2+
import { LogLevel } from "configcat-common";
3+
import { LocalStorageCache, fromUtf8Base64, getLocalStorage, toUtf8Base64 } from "../src/Cache";
4+
import { FakeLogger } from "./helpers/fakes";
5+
import { createClientWithLazyLoad } from "./helpers/utils";
36

7+
describe("Base64 encode/decode test", () => {
8+
let allBmpChars = "";
9+
for (let i = 0; i <= 0xFFFF; i++) {
10+
if (i < 0xD800 || 0xDFFF < i) { // skip lone surrogate chars
11+
allBmpChars += String.fromCharCode(i);
12+
}
13+
}
414

5-
describe("LocalStorageCache cache tests", () => {
6-
it("LocalStorageCache works with non latin 1 characters", () => {
7-
const cache = new LocalStorageCache();
8-
const key = "testkey";
9-
const text = "äöüÄÖÜçéèñışğ⢙✓😀";
10-
cache.set(key, text);
11-
const retrievedValue = cache.get(key);
12-
assert.strictEqual(retrievedValue, text);
15+
for (const input of [
16+
"",
17+
"\n",
18+
"äöüÄÖÜçéèñışğ⢙✓😀",
19+
allBmpChars
20+
]) {
21+
it(`Base64 encode/decode works - input: ${input.slice(0, Math.min(input.length, 128))}`, () => {
22+
assert.strictEqual(fromUtf8Base64(toUtf8Base64(input)), input);
1323
});
24+
}
25+
});
26+
27+
describe("LocalStorageCache cache tests", () => {
28+
it("LocalStorageCache works with non latin 1 characters", () => {
29+
const localStorage = getLocalStorage();
30+
assert.isNotNull(localStorage);
31+
32+
const cache = new LocalStorageCache(localStorage!);
33+
const key = "testkey";
34+
const text = "äöüÄÖÜçéèñışğ⢙✓😀";
35+
cache.set(key, text);
36+
const retrievedValue = cache.get(key);
37+
assert.strictEqual(retrievedValue, text);
38+
assert.strictEqual(window.localStorage.getItem(key), "w6TDtsO8w4TDlsOcw6fDqcOow7HEscWfxJ/DosKi4oSi4pyT8J+YgA==");
39+
});
40+
41+
it("Error is logged when LocalStorageCache.get throws", async () => {
42+
const errorMessage = "Something went wrong.";
43+
const faultyLocalStorage: Storage = {
44+
get length() { return 0; },
45+
clear() { },
46+
getItem() { throw Error(errorMessage); },
47+
setItem() { },
48+
removeItem() { },
49+
key() { return null; }
50+
};
51+
52+
const fakeLogger = new FakeLogger();
53+
54+
const client = createClientWithLazyLoad("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ", { logger: fakeLogger },
55+
kernel => LocalStorageCache.setup(kernel, () => faultyLocalStorage));
56+
57+
try { await client.getValueAsync("stringDefaultCat", ""); }
58+
finally { client.dispose(); }
59+
60+
assert.isDefined(fakeLogger.events.find(([level, eventId, , err]) => level === LogLevel.Error && eventId === 2200 && err instanceof Error && err.message === errorMessage));
61+
});
62+
63+
it("Error is logged when LocalStorageCache.set throws", async () => {
64+
const errorMessage = "Something went wrong.";
65+
const faultyLocalStorage: Storage = {
66+
get length() { return 0; },
67+
clear() { },
68+
getItem() { return null; },
69+
setItem() { throw Error(errorMessage); },
70+
removeItem() { },
71+
key() { return null; }
72+
};
73+
74+
const fakeLogger = new FakeLogger();
75+
76+
const client = createClientWithLazyLoad("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ", { logger: fakeLogger },
77+
kernel => LocalStorageCache.setup(kernel, () => faultyLocalStorage));
78+
79+
try { await client.getValueAsync("stringDefaultCat", ""); }
80+
finally { client.dispose(); }
81+
82+
assert.isDefined(fakeLogger.events.find(([level, eventId, , err]) => level === LogLevel.Error && eventId === 2201 && err instanceof Error && err.message === errorMessage));
83+
});
1484
});

test/HttpTests.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe("HTTP tests", () => {
3333
const defaultValue = "NOT_CAT";
3434
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
3535

36-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Request timed out while trying to fetch config JSON.")));
36+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Request timed out while trying to fetch config JSON.")));
3737
}
3838
finally {
3939
server.remove();
@@ -61,7 +61,7 @@ describe("HTTP tests", () => {
6161
const defaultValue = "NOT_CAT";
6262
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
6363

64-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Your SDK Key seems to be wrong.")));
64+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Your SDK Key seems to be wrong.")));
6565
}
6666
finally {
6767
server.remove();
@@ -89,7 +89,7 @@ describe("HTTP tests", () => {
8989
const defaultValue = "NOT_CAT";
9090
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
9191

92-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Unexpected HTTP response was received while trying to fetch config JSON:")));
92+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Unexpected HTTP response was received while trying to fetch config JSON:")));
9393
}
9494
finally {
9595
server.remove();
@@ -117,9 +117,7 @@ describe("HTTP tests", () => {
117117
const defaultValue = "NOT_CAT";
118118
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
119119

120-
console.log(logger.messages);
121-
122-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Unexpected error occurred while trying to fetch config JSON.")));
120+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Unexpected error occurred while trying to fetch config JSON.")));
123121
}
124122
finally {
125123
server.remove();

test/SpecialCharacterTests.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
import { assert } from "chai";
2-
import { IConfigCatClient, IEvaluationDetails, IOptions, LogLevel, PollingMode, SettingKeyValue, User } from "configcat-common";
2+
import { IConfigCatClient, IOptions, LogLevel, PollingMode, User } from "configcat-common";
33
import * as configcatClient from "../src";
44
import { createConsoleLogger } from "../src";
55

66
const sdkKey = "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/u28_1qNyZ0Wz-ldYHIU7-g";
77

88
describe("Special characters test", () => {
99

10-
const options: IOptions = { logger: createConsoleLogger(LogLevel.Off) };
10+
const options: IOptions = { logger: createConsoleLogger(LogLevel.Off) };
1111

12-
let client: IConfigCatClient;
12+
let client: IConfigCatClient;
1313

14-
beforeEach(function () {
15-
client = configcatClient.getClient(sdkKey, PollingMode.AutoPoll, options);
16-
});
14+
beforeEach(function() {
15+
client = configcatClient.getClient(sdkKey, PollingMode.AutoPoll, options);
16+
});
1717

18-
afterEach(function () {
19-
client.dispose();
20-
});
18+
afterEach(function() {
19+
client.dispose();
20+
});
2121

22-
it(`Special characters works - cleartext`, async () => {
23-
const actual: string = await client.getValueAsync("specialCharacters", "NOT_CAT", new User("äöüÄÖÜçéèñışğ⢙✓😀"));
24-
assert.strictEqual(actual, "äöüÄÖÜçéèñışğ⢙✓😀");
25-
});
22+
it("Special characters works - cleartext", async () => {
23+
const actual: string = await client.getValueAsync("specialCharacters", "NOT_CAT", new User("äöüÄÖÜçéèñışğ⢙✓😀"));
24+
assert.strictEqual(actual, "äöüÄÖÜçéèñışğ⢙✓😀");
25+
});
2626

27-
it(`Special characters works - hashed`, async () => {
28-
const actual: string = await client.getValueAsync("specialCharactersHashed", "NOT_CAT", new User("äöüÄÖÜçéèñışğ⢙✓😀"));
29-
assert.strictEqual(actual, "äöüÄÖÜçéèñışğ⢙✓😀");
30-
});
27+
it("Special characters works - hashed", async () => {
28+
const actual: string = await client.getValueAsync("specialCharactersHashed", "NOT_CAT", new User("äöüÄÖÜçéèñışğ⢙✓😀"));
29+
assert.strictEqual(actual, "äöüÄÖÜçéèñışğ⢙✓😀");
30+
});
3131
});

test/helpers/fakes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { IConfigCatLogger, LogEventId, LogLevel, LogMessage } from "../../src";
22

33
export class FakeLogger implements IConfigCatLogger {
4-
messages: [LogLevel, string][] = [];
4+
events: [LogLevel, LogEventId, LogMessage, any?][] = [];
55

66
constructor(public level = LogLevel.Info) { }
77

8-
reset(): void { this.messages.splice(0); }
8+
reset(): void { this.events.splice(0); }
99

1010
log(level: LogLevel, eventId: LogEventId, message: LogMessage, exception?: any): void {
11-
this.messages.push([level, message.toString()]);
11+
this.events.push([level, eventId, message, exception]);
1212
}
1313
}

0 commit comments

Comments
 (0)