Skip to content

Commit

Permalink
Minor improvements (#92)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
adams85 committed Jan 9, 2024
1 parent 662179e commit 6f98582
Show file tree
Hide file tree
Showing 11 changed files with 178 additions and 84 deletions.
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
@@ -1,6 +1,6 @@
{
"name": "configcat-js",
"version": "9.2.0",
"version": "9.3.0",
"description": "ConfigCat is a configuration as a service that lets you manage your features and configurations without actually deploying new code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand Down Expand Up @@ -33,7 +33,7 @@
"license": "MIT",
"homepage": "https://configcat.com",
"dependencies": {
"configcat-common": "^9.0.0",
"configcat-common": "^9.1.0",
"tslib": "^2.4.1"
},
"devDependencies": {
Expand Down
71 changes: 46 additions & 25 deletions src/Cache.ts
@@ -1,37 +1,58 @@
import type { IConfigCatCache } from "configcat-common";
import type { IConfigCatCache, IConfigCatKernel } from "configcat-common";
import { ExternalConfigCache } from "configcat-common";

export class LocalStorageCache implements IConfigCatCache {
set(key: string, value: string): void {
try {
localStorage.setItem(key, this.b64EncodeUnicode(value));
}
catch (ex) {
// local storage is unavailable
static setup(kernel: IConfigCatKernel, localStorageGetter?: () => Storage | null): IConfigCatKernel {
const localStorage = (localStorageGetter ?? getLocalStorage)();
if (localStorage) {
kernel.defaultCacheFactory = options => new ExternalConfigCache(new LocalStorageCache(localStorage), options.logger);
}
return kernel;
}

constructor(private readonly storage: Storage) {
}

set(key: string, value: string): void {
this.storage.setItem(key, toUtf8Base64(value));
}

get(key: string): string | undefined {
try {
const configString = localStorage.getItem(key);
if (configString) {
return this.b64DecodeUnicode(configString);
}
const configString = this.storage.getItem(key);
if (configString) {
return fromUtf8Base64(configString);
}
catch (ex) {
// local storage is unavailable or invalid cache value in localstorage
}
return void 0;
}
}

private b64EncodeUnicode(str: string): string {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (_, p1) {
return String.fromCharCode(parseInt(p1, 16))
}));
}
export function getLocalStorage(): Storage | null {
const testKey = "__configcat_localStorage_test";

try {
const storage = window.localStorage;
storage.setItem(testKey, testKey);

let retrievedItem: string | null;
try { retrievedItem = storage.getItem(testKey); }
finally { storage.removeItem(testKey); }

private b64DecodeUnicode(str: string): string {
return decodeURIComponent(Array.prototype.map.call(atob(str), function (c: string) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
}).join(''));
if (retrievedItem === testKey) {
return storage;
}
}
catch (err) { /* intentional no-op */ }

return null;
}

export function toUtf8Base64(str: string): string {
str = encodeURIComponent(str);
str = str.replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)));
return btoa(str);
}

export function fromUtf8Base64(str: string): string {
str = atob(str);
str = str.replace(/[%\x80-\xFF]/g, m => "%" + m.charCodeAt(0).toString(16));
return decodeURIComponent(str);
}
2 changes: 1 addition & 1 deletion src/ConfigFetcher.ts
Expand Up @@ -38,7 +38,7 @@ export class HttpConfigFetcher implements IConfigFetcher {
let url = options.getUrl();
if (lastEtag) {
// 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.
url += '&ccetag=' + lastEtag;
url += "&ccetag=" + encodeURIComponent(lastEtag);
}
httpRequest.open("GET", url, true);
httpRequest.timeout = options.requestTimeoutMs;
Expand Down
7 changes: 3 additions & 4 deletions src/index.ts
@@ -1,5 +1,5 @@
import type { IAutoPollOptions, IConfigCatClient, IConfigCatLogger, ILazyLoadingOptions, IManualPollOptions, LogLevel, OverrideBehaviour, SettingValue } from "configcat-common";
import { ExternalConfigCache, FlagOverrides, MapOverrideDataSource, PollingMode } from "configcat-common";
import { FlagOverrides, MapOverrideDataSource, PollingMode } from "configcat-common";
import * as configcatcommon from "configcat-common";
import { LocalStorageCache } from "./Cache";
import { HttpConfigFetcher } from "./ConfigFetcher";
Expand All @@ -17,12 +17,11 @@ import CONFIGCAT_SDK_VERSION from "./Version";
*/
export function getClient<TMode extends PollingMode | undefined>(sdkKey: string, pollingMode?: TMode, options?: OptionsForPollingMode<TMode>): IConfigCatClient {
return configcatcommon.getClient(sdkKey, pollingMode ?? PollingMode.AutoPoll, options,
{
LocalStorageCache.setup({
configFetcher: new HttpConfigFetcher(),
sdkType: "ConfigCat-JS",
sdkVersion: CONFIGCAT_SDK_VERSION,
defaultCacheFactory: options => new ExternalConfigCache(new LocalStorageCache(), options.logger)
});
}));
}

/**
Expand Down
88 changes: 79 additions & 9 deletions test/CacheTests.ts
@@ -1,14 +1,84 @@
import { assert } from "chai";
import { LocalStorageCache } from "../lib/Cache";
import { LogLevel } from "configcat-common";
import { LocalStorageCache, fromUtf8Base64, getLocalStorage, toUtf8Base64 } from "../src/Cache";
import { FakeLogger } from "./helpers/fakes";
import { createClientWithLazyLoad } from "./helpers/utils";

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

describe("LocalStorageCache cache tests", () => {
it("LocalStorageCache works with non latin 1 characters", () => {
const cache = new LocalStorageCache();
const key = "testkey";
const text = "äöüÄÖÜçéèñışğ⢙✓😀";
cache.set(key, text);
const retrievedValue = cache.get(key);
assert.strictEqual(retrievedValue, text);
for (const input of [
"",
"\n",
"äöüÄÖÜçéèñışğ⢙✓😀",
allBmpChars
]) {
it(`Base64 encode/decode works - input: ${input.slice(0, Math.min(input.length, 128))}`, () => {
assert.strictEqual(fromUtf8Base64(toUtf8Base64(input)), input);
});
}
});

describe("LocalStorageCache cache tests", () => {
it("LocalStorageCache works with non latin 1 characters", () => {
const localStorage = getLocalStorage();
assert.isNotNull(localStorage);

const cache = new LocalStorageCache(localStorage!);
const key = "testkey";
const text = "äöüÄÖÜçéèñışğ⢙✓😀";
cache.set(key, text);
const retrievedValue = cache.get(key);
assert.strictEqual(retrievedValue, text);
assert.strictEqual(window.localStorage.getItem(key), "w6TDtsO8w4TDlsOcw6fDqcOow7HEscWfxJ/DosKi4oSi4pyT8J+YgA==");
});

it("Error is logged when LocalStorageCache.get throws", async () => {
const errorMessage = "Something went wrong.";
const faultyLocalStorage: Storage = {
get length() { return 0; },
clear() { },
getItem() { throw Error(errorMessage); },
setItem() { },
removeItem() { },
key() { return null; }
};

const fakeLogger = new FakeLogger();

const client = createClientWithLazyLoad("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ", { logger: fakeLogger },
kernel => LocalStorageCache.setup(kernel, () => faultyLocalStorage));

try { await client.getValueAsync("stringDefaultCat", ""); }
finally { client.dispose(); }

assert.isDefined(fakeLogger.events.find(([level, eventId, , err]) => level === LogLevel.Error && eventId === 2200 && err instanceof Error && err.message === errorMessage));
});

it("Error is logged when LocalStorageCache.set throws", async () => {
const errorMessage = "Something went wrong.";
const faultyLocalStorage: Storage = {
get length() { return 0; },
clear() { },
getItem() { return null; },
setItem() { throw Error(errorMessage); },
removeItem() { },
key() { return null; }
};

const fakeLogger = new FakeLogger();

const client = createClientWithLazyLoad("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ", { logger: fakeLogger },
kernel => LocalStorageCache.setup(kernel, () => faultyLocalStorage));

try { await client.getValueAsync("stringDefaultCat", ""); }
finally { client.dispose(); }

assert.isDefined(fakeLogger.events.find(([level, eventId, , err]) => level === LogLevel.Error && eventId === 2201 && err instanceof Error && err.message === errorMessage));
});
});
10 changes: 4 additions & 6 deletions test/HttpTests.ts
Expand Up @@ -33,7 +33,7 @@ describe("HTTP tests", () => {
const defaultValue = "NOT_CAT";
assert.strictEqual(defaultValue, await client.getValueAsync("stringDefaultCat", defaultValue));

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

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

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

console.log(logger.messages);

assert.isDefined(logger.messages.find(([level, msg]) => level === LogLevel.Error && msg.startsWith("Unexpected error occurred while trying to fetch config JSON.")));
assert.isDefined(logger.events.find(([level, , msg]) => level === LogLevel.Error && msg.toString().startsWith("Unexpected error occurred while trying to fetch config JSON.")));
}
finally {
server.remove();
Expand Down
34 changes: 17 additions & 17 deletions test/SpecialCharacterTests.ts
@@ -1,31 +1,31 @@
import { assert } from "chai";
import { IConfigCatClient, IEvaluationDetails, IOptions, LogLevel, PollingMode, SettingKeyValue, User } from "configcat-common";
import { IConfigCatClient, IOptions, LogLevel, PollingMode, User } from "configcat-common";
import * as configcatClient from "../src";
import { createConsoleLogger } from "../src";

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

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

const options: IOptions = { logger: createConsoleLogger(LogLevel.Off) };
const options: IOptions = { logger: createConsoleLogger(LogLevel.Off) };

let client: IConfigCatClient;
let client: IConfigCatClient;

beforeEach(function () {
client = configcatClient.getClient(sdkKey, PollingMode.AutoPoll, options);
});
beforeEach(function() {
client = configcatClient.getClient(sdkKey, PollingMode.AutoPoll, options);
});

afterEach(function () {
client.dispose();
});
afterEach(function() {
client.dispose();
});

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

it(`Special characters works - hashed`, async () => {
const actual: string = await client.getValueAsync("specialCharactersHashed", "NOT_CAT", new User("äöüÄÖÜçéèñışğ⢙✓😀"));
assert.strictEqual(actual, "äöüÄÖÜçéèñışğ⢙✓😀");
});
it("Special characters works - hashed", async () => {
const actual: string = await client.getValueAsync("specialCharactersHashed", "NOT_CAT", new User("äöüÄÖÜçéèñışğ⢙✓😀"));
assert.strictEqual(actual, "äöüÄÖÜçéèñışğ⢙✓😀");
});
});
6 changes: 3 additions & 3 deletions test/helpers/fakes.ts
@@ -1,13 +1,13 @@
import { IConfigCatLogger, LogEventId, LogLevel, LogMessage } from "../../src";

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

constructor(public level = LogLevel.Info) { }

reset(): void { this.messages.splice(0); }
reset(): void { this.events.splice(0); }

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

0 comments on commit 6f98582

Please sign in to comment.