Skip to content

Commit d267ac7

Browse files
authored
Minor improvements (#22)
* Improve base64 decode perf. + add tests + fix code formatting * Don't swallow exceptions in LocalStorageCache.get/set * Send etag as query param (ccetag) when SDK runs in browser * Exclude non-source files so they don't pollute autocompletion/intellisense * Update to configcat-common v9.1.0 * Bump version
1 parent 7c152d0 commit d267ac7

11 files changed

+176
-103
lines changed

Diff for: package-lock.json

+9-9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "configcat-js-chromium-extension",
3-
"version": "2.1.0",
3+
"version": "2.2.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
"homepage": "https://configcat.com",
3434
"dependencies": {
3535
"@types/chrome": "0.0.193",
36-
"configcat-common": "^9.0.0",
36+
"configcat-common": "^9.1.0",
3737
"tslib": "^2.4.1"
3838
},
3939
"devDependencies": {

Diff for: src/Cache.ts

+28-26
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,39 @@
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-
async set(key: string, value: string): Promise<void> {
5-
try {
6-
await chrome.storage.local.set({ [key]: this.b64EncodeUnicode(value) });
7-
}
8-
catch (ex) {
9-
// chrome storage is unavailable
5+
static setup(kernel: IConfigCatKernel, localStorageGetter?: () => chrome.storage.LocalStorageArea | null): IConfigCatKernel {
6+
const localStorage = localStorageGetter?.() ?? window.chrome?.storage?.local;
7+
if (localStorage) {
8+
kernel.defaultCacheFactory = options => new ExternalConfigCache(new LocalStorageCache(localStorage), options.logger);
109
}
10+
return kernel;
1111
}
1212

13-
async get(key: string): Promise<string | undefined> {
14-
try {
15-
const cacheObj = await chrome.storage.local.get(key);
16-
const configString = cacheObj[key];
17-
if (configString) {
18-
return this.b64DecodeUnicode(configString);
19-
}
20-
}
21-
catch (ex) {
22-
// chrome storage is unavailable or invalid cache value.
23-
}
13+
constructor(private readonly storage: chrome.storage.LocalStorageArea) {
2414
}
2515

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-
}));
16+
async set(key: string, value: string): Promise<void> {
17+
await this.storage.set({ [key]: toUtf8Base64(value) });
3018
}
3119

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(''));
20+
async get(key: string): Promise<string | undefined> {
21+
const cacheObj = await this.storage.get(key);
22+
const configString = cacheObj[key];
23+
if (configString) {
24+
return fromUtf8Base64(configString);
25+
}
3626
}
3727
}
28+
29+
export function toUtf8Base64(str: string): string {
30+
str = encodeURIComponent(str);
31+
str = str.replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)));
32+
return btoa(str);
33+
}
34+
35+
export function fromUtf8Base64(str: string): string {
36+
str = atob(str);
37+
str = str.replace(/[%\x80-\xFF]/g, m => "%" + m.charCodeAt(0).toString(16));
38+
return decodeURIComponent(str);
39+
}

Diff for: src/ConfigFetcher.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ export class HttpConfigFetcher implements IConfigFetcher {
1818
}
1919

2020
try {
21+
let url = options.getUrl();
22+
if (lastEtag) {
23+
// We are sending the etag as a query parameter so if the browser doesn't automatically adds the If-None-Match header, we can transform this query param to the header in our CDN provider.
24+
url += "&ccetag=" + encodeURIComponent(lastEtag);
25+
}
2126
// NOTE: It's intentional that we don't specify the If-None-Match header.
2227
// The browser automatically handles it, adding it manually would cause an unnecessary CORS OPTIONS request.
23-
const response = await fetch(options.getUrl(), requestInit);
28+
// In case the browser doesn't handle it, we are transforming the ccetag query parameter to the If-None-Match header
29+
const response = await fetch(url, requestInit);
2430

2531
const { status: statusCode, statusText: reasonPhrase } = response;
2632
if (statusCode === 200) {

Diff for: src/index.ts

+3-4
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-Chromium",
2323
sdkVersion: CONFIGCAT_SDK_VERSION,
24-
defaultCacheFactory: options => new ExternalConfigCache(new LocalStorageCache(), options.logger)
25-
});
24+
}));
2625
}
2726

2827
/**

Diff for: test/CacheTests.ts

+88-26
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,94 @@
11
import { assert } from "chai";
2-
import { LocalStorageCache } from "../lib/Cache";
3-
import { resolve } from "path";
4-
5-
let localStorage = {}
6-
7-
global.chrome = <any>{
8-
storage: {
9-
local: {
10-
clear: () => {
11-
localStorage = {}
12-
},
13-
set: (toMergeIntoStorage: any) => {
14-
localStorage = { ...localStorage, ...toMergeIntoStorage }
15-
},
16-
get: () => {
17-
return localStorage;
18-
}
19-
},
2+
import { LogLevel } from "configcat-common";
3+
import { LocalStorageCache, fromUtf8Base64, toUtf8Base64 } from "../src/Cache";
4+
import { FakeLogger } from "./helpers/fakes";
5+
import { createClientWithLazyLoad } from "./helpers/utils";
6+
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);
2012
}
21-
}
13+
}
14+
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);
23+
});
24+
}
25+
});
2226

2327
describe("LocalStorageCache cache tests", () => {
24-
it("LocalStorageCache works with non latin 1 characters", async () => {
25-
const cache = new LocalStorageCache();
26-
const key = "testkey";
27-
const text = "äöüÄÖÜçéèñışğ⢙✓😀";
28-
await cache.set(key, text);
29-
const retrievedValue = await cache.get(key);
30-
assert.strictEqual(retrievedValue, text);
28+
it("LocalStorageCache works with non latin 1 characters", async () => {
29+
30+
const fakeLocalStorage = createFakeLocalStorage();
31+
const cache = new LocalStorageCache(fakeLocalStorage);
32+
const key = "testkey";
33+
const text = "äöüÄÖÜçéèñışğ⢙✓😀";
34+
await cache.set(key, text);
35+
const retrievedValue = await cache.get(key);
36+
assert.strictEqual(retrievedValue, text);
37+
assert.strictEqual((await fakeLocalStorage.get(key))[key], "w6TDtsO8w4TDlsOcw6fDqcOow7HEscWfxJ/DosKi4oSi4pyT8J+YgA==");
38+
});
39+
40+
it("Error is logged when LocalStorageCache.get throws", async () => {
41+
const errorMessage = "Something went wrong.";
42+
const faultyLocalStorage = Object.assign(createFakeLocalStorage(), {
43+
get() { return Promise.reject(new Error(errorMessage)); }
3144
});
45+
46+
const fakeLogger = new FakeLogger();
47+
48+
const client = createClientWithLazyLoad("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ", { logger: fakeLogger },
49+
kernel => LocalStorageCache.setup(kernel, () => faultyLocalStorage));
50+
51+
try { await client.getValueAsync("stringDefaultCat", ""); }
52+
finally { client.dispose(); }
53+
54+
assert.isDefined(fakeLogger.events.find(([level, eventId, , err]) => level === LogLevel.Error && eventId === 2200 && err instanceof Error && err.message === errorMessage));
55+
});
56+
57+
it("Error is logged when LocalStorageCache.set throws", async () => {
58+
const errorMessage = "Something went wrong.";
59+
const faultyLocalStorage = Object.assign(createFakeLocalStorage(), {
60+
set() { return Promise.reject(new Error(errorMessage)); }
61+
});
62+
63+
const fakeLogger = new FakeLogger();
64+
65+
const client = createClientWithLazyLoad("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ", { logger: fakeLogger },
66+
kernel => LocalStorageCache.setup(kernel, () => faultyLocalStorage));
67+
68+
try { await client.getValueAsync("stringDefaultCat", ""); }
69+
finally { client.dispose(); }
70+
71+
assert.isDefined(fakeLogger.events.find(([level, eventId, , err]) => level === LogLevel.Error && eventId === 2201 && err instanceof Error && err.message === errorMessage));
72+
});
3273
});
74+
75+
function createFakeLocalStorage(): chrome.storage.LocalStorageArea {
76+
let localStorage: { [key: string]: any } = {};
77+
78+
return <Partial<chrome.storage.LocalStorageArea>>{
79+
set(items: { [key: string]: any }) {
80+
localStorage = { ...localStorage, ...items };
81+
return Promise.resolve();
82+
},
83+
get(keys?: string | string[] | { [key: string]: any } | null) {
84+
let result = localStorage;
85+
if (typeof keys === "string") {
86+
result = { [keys]: localStorage[keys] };
87+
}
88+
else if (keys != null) {
89+
throw new Error("Not implemented.");
90+
}
91+
return Promise.resolve(result);
92+
}
93+
} as chrome.storage.LocalStorageArea;
94+
}

Diff for: test/HttpTests.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("HTTP tests", () => {
3131
const defaultValue = "NOT_CAT";
3232
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
3333

34-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Request timed out while trying to fetch config JSON.")));
34+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Request timed out while trying to fetch config JSON.")));
3535
}
3636
finally {
3737
fetchMock.reset();
@@ -56,7 +56,7 @@ describe("HTTP tests", () => {
5656
const defaultValue = "NOT_CAT";
5757
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
5858

59-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Your SDK Key seems to be wrong.")));
59+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Your SDK Key seems to be wrong.")));
6060
}
6161
finally {
6262
fetchMock.reset();
@@ -80,7 +80,7 @@ describe("HTTP tests", () => {
8080
const defaultValue = "NOT_CAT";
8181
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
8282

83-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Unexpected HTTP response was received while trying to fetch config JSON:")));
83+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Unexpected HTTP response was received while trying to fetch config JSON:")));
8484
}
8585
finally {
8686
fetchMock.reset();
@@ -105,9 +105,7 @@ describe("HTTP tests", () => {
105105
const defaultValue = "NOT_CAT";
106106
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));
107107

108-
console.log(logger.messages);
109-
110-
assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Unexpected error occurred while trying to fetch config JSON.")));
108+
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Unexpected error occurred while trying to fetch config JSON.")));
111109
}
112110
finally {
113111
fetchMock.reset();

Diff for: test/SpecialCharacterTests.ts

+17-17
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
});

Diff for: test/helpers/fakes.ts

+4-4
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
}
13-
}
13+
}

0 commit comments

Comments
 (0)