diff --git a/package-lock.json b/package-lock.json index cebad0e..f7f3cfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "configcat-js", - "version": "9.2.0", + "version": "9.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "configcat-js", - "version": "9.2.0", + "version": "9.3.0", "license": "MIT", "dependencies": { - "configcat-common": "^9.0.0", + "configcat-common": "^9.1.0", "tslib": "^2.4.1" }, "devDependencies": { @@ -3865,9 +3865,9 @@ } }, "node_modules/configcat-common": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.0.0.tgz", - "integrity": "sha512-kLI7PSkysn7pks/QsESX0lRkvL/vVc6CL2LlIk2XAd2PazPN/FaaOO/3hU4IbGG+kT3dwggDvGbioiMaYW1YHw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.1.0.tgz", + "integrity": "sha512-jaYyCAdSZ8Su0Jr1Dx9eIzXEb66yN0M6Vc7b6ceGAQZ0RzsWDdcAVyRACOLZTH6Zwfz8Sb0o4O9b6QZl2PvUfw==", "dependencies": { "tslib": "^2.4.1" } @@ -14873,9 +14873,9 @@ } }, "configcat-common": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.0.0.tgz", - "integrity": "sha512-kLI7PSkysn7pks/QsESX0lRkvL/vVc6CL2LlIk2XAd2PazPN/FaaOO/3hU4IbGG+kT3dwggDvGbioiMaYW1YHw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/configcat-common/-/configcat-common-9.1.0.tgz", + "integrity": "sha512-jaYyCAdSZ8Su0Jr1Dx9eIzXEb66yN0M6Vc7b6ceGAQZ0RzsWDdcAVyRACOLZTH6Zwfz8Sb0o4O9b6QZl2PvUfw==", "requires": { "tslib": "^2.4.1" } diff --git a/package.json b/package.json index 4a89a32..4376b09 100644 --- a/package.json +++ b/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", @@ -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": { diff --git a/src/Cache.ts b/src/Cache.ts index 3e7e51c..f3b0d00 100644 --- a/src/Cache.ts +++ b/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); } diff --git a/src/ConfigFetcher.ts b/src/ConfigFetcher.ts index 31e286a..024a3e5 100644 --- a/src/ConfigFetcher.ts +++ b/src/ConfigFetcher.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 59e69eb..a16bb4e 100644 --- a/src/index.ts +++ b/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"; @@ -17,12 +17,11 @@ import CONFIGCAT_SDK_VERSION from "./Version"; */ export function getClient(sdkKey: string, pollingMode?: TMode, options?: OptionsForPollingMode): 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) - }); + })); } /** diff --git a/test/CacheTests.ts b/test/CacheTests.ts index 9b4454b..c80c113 100644 --- a/test/CacheTests.ts +++ b/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)); + }); }); diff --git a/test/HttpTests.ts b/test/HttpTests.ts index 7ee647b..6458606 100644 --- a/test/HttpTests.ts +++ b/test/HttpTests.ts @@ -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(); @@ -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(); @@ -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(); @@ -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(); diff --git a/test/SpecialCharacterTests.ts b/test/SpecialCharacterTests.ts index dcb95e1..80a28a7 100644 --- a/test/SpecialCharacterTests.ts +++ b/test/SpecialCharacterTests.ts @@ -1,5 +1,5 @@ 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"; @@ -7,25 +7,25 @@ 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, "äöüÄÖÜçéèñışğ⢙✓😀"); + }); }); diff --git a/test/helpers/fakes.ts b/test/helpers/fakes.ts index 034b7ef..6b59058 100644 --- a/test/helpers/fakes.ts +++ b/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]); } } diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts index f0e9134..d6f34a9 100644 --- a/test/helpers/utils.ts +++ b/test/helpers/utils.ts @@ -1,4 +1,4 @@ -import { ExternalConfigCache, IConfigCatKernel } from "configcat-common"; +import { IConfigCatKernel } from "configcat-common"; import { ConfigCatClient } from "configcat-common/lib/ConfigCatClient"; import { AutoPollOptions, LazyLoadOptions, ManualPollOptions } from "configcat-common/lib/ConfigCatClientOptions"; import { IConfigCatClient, IJSAutoPollOptions, IJSLazyLoadingOptions, IJSManualPollOptions } from "../../src"; @@ -8,17 +8,17 @@ import sdkVersion from "../../src/Version"; const sdkType = "ConfigCat-JS"; -export function createClientWithAutoPoll(sdkKey: string, options?: IJSAutoPollOptions): IConfigCatClient { - const configCatKernel: IConfigCatKernel = { configFetcher: new HttpConfigFetcher(), sdkType, sdkVersion, defaultCacheFactory: options => new ExternalConfigCache(new LocalStorageCache(), options.logger) }; +export function createClientWithAutoPoll(sdkKey: string, options?: IJSAutoPollOptions, setupKernel?: (kernel: IConfigCatKernel) => IConfigCatKernel): IConfigCatClient { + const configCatKernel: IConfigCatKernel = (setupKernel ?? LocalStorageCache.setup)({ configFetcher: new HttpConfigFetcher(), sdkType, sdkVersion }); return new ConfigCatClient(new AutoPollOptions(sdkKey, configCatKernel.sdkType, configCatKernel.sdkVersion, options, configCatKernel.defaultCacheFactory, configCatKernel.eventEmitterFactory), configCatKernel); } -export function createClientWithManualPoll(sdkKey: string, options?: IJSManualPollOptions): IConfigCatClient { - const configCatKernel: IConfigCatKernel = { configFetcher: new HttpConfigFetcher(), sdkType, sdkVersion, defaultCacheFactory: options => new ExternalConfigCache(new LocalStorageCache(), options.logger) }; +export function createClientWithManualPoll(sdkKey: string, options?: IJSManualPollOptions, setupKernel?: (kernel: IConfigCatKernel) => IConfigCatKernel): IConfigCatClient { + const configCatKernel: IConfigCatKernel = (setupKernel ?? LocalStorageCache.setup)({ configFetcher: new HttpConfigFetcher(), sdkType, sdkVersion }); return new ConfigCatClient(new ManualPollOptions(sdkKey, configCatKernel.sdkType, configCatKernel.sdkVersion, options, configCatKernel.defaultCacheFactory, configCatKernel.eventEmitterFactory), configCatKernel); } -export function createClientWithLazyLoad(sdkKey: string, options?: IJSLazyLoadingOptions): IConfigCatClient { - const configCatKernel: IConfigCatKernel = { configFetcher: new HttpConfigFetcher(), sdkType, sdkVersion, defaultCacheFactory: options => new ExternalConfigCache(new LocalStorageCache(), options.logger) }; +export function createClientWithLazyLoad(sdkKey: string, options?: IJSLazyLoadingOptions, setupKernel?: (kernel: IConfigCatKernel) => IConfigCatKernel): IConfigCatClient { + const configCatKernel: IConfigCatKernel = (setupKernel ?? LocalStorageCache.setup)({ configFetcher: new HttpConfigFetcher(), sdkType, sdkVersion }); return new ConfigCatClient(new LazyLoadOptions(sdkKey, configCatKernel.sdkType, configCatKernel.sdkVersion, options, configCatKernel.defaultCacheFactory, configCatKernel.eventEmitterFactory), configCatKernel); } diff --git a/tsconfig.json b/tsconfig.json index 5498f60..2b49e29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,11 @@ "target": "ESNext", "importHelpers": true, "moduleResolution": "Node" - } + }, + "exclude": [ + "**/node_modules/", + "lib/", + "dist/", + "samples/" + ] } \ No newline at end of file