From f77251293ae89dd8cec0d489b4a14b8ac18995b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 1 Nov 2023 14:56:40 +0200 Subject: [PATCH 1/7] feat: support passing loadOptions in .init() method as well --- README.md | 18 +++++++++++---- __tests__/client.test.ts | 31 +++++++++++++++++++++++++ src/client.ts | 49 ++++++++++++++++++++++++++-------------- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a865d3c..fc2fd73 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,8 @@ import { FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-spa'; -// It can receive multiple parameters but the only required one is `loadOptions`, -// which contains the public API key const fpjsClient = new FpjsClient({ + // You can also pass these options later in `.init()` method loadOptions: { apiKey: "", // endpoint: ["", FingerprintJSPro.defaultEndpoint], @@ -104,7 +103,8 @@ const fpjsClient = new FpjsClient({ } }); ``` -You can learn more about different load options here in the [JS Agent documentation](https://dev.fingerprint.com/docs/js-agent#initializing-the-agent). + +> ⚠️ You must provide `loadOptions` which contain your public API key either in the constructor or in the `init` method. If you don't, the SDK will throw an error. You can learn more about different load options here in the [JS Agent documentation](https://dev.fingerprint.com/docs/js-agent#initializing-the-agent). ### 1 - Init the JS agent @@ -113,6 +113,7 @@ Before you start making identification requests to the Fingerprint Pro API, you ```js // with async/await await fpjsClient.init() + const visitorData = await fpjsClient.getVisitorData() // with promises @@ -120,7 +121,16 @@ const visitorData = fpjsClient.init().then(() => { return fpjsClient.getVisitorData() }) ``` - +:You can also pass the load options here +```js +await fpjsClient.init({ + apiKey: "", + // endpoint: ["", FingerprintJSPro.defaultEndpoint], + // scriptUrlPattern: ["", FingerprintJSPro.defaultScriptUrlPattern], + // region: "eu" + } +) +``` ### 2 - Calling an API The `getVisitorData` method returns visitor identification data based on the request [options](https://dev.fingerprint.com/docs/js-agent#visitor-identification). Set `ignoreCache` to `true` to make a request to the API even if the data is present in the cache. diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 5c31d47..18f934e 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -59,6 +59,37 @@ describe(`SPA client`, () => { ) }) + it('should support passing loadOptions in .init()', async () => { + const client = new FpjsClient() + + await client.init(getDefaultLoadOptions()) + + await expect(client.getVisitorData()).resolves.not.toThrow() + + expect(loadSpy).toBeCalledTimes(1) + }) + + it('should merge loadOptions passed in .init() and in constructor', async () => { + const client = new FpjsClient({ + loadOptions: { + ...getDefaultLoadOptions(), + integrationInfo: ['integrationInfo1'], + }, + }) + + await client.init({ + integrationInfo: ['integrationInfo2'], + region: 'eu', + }) + + expect(loadSpy).toBeCalledTimes(1) + expect(loadSpy).toHaveBeenCalledWith({ + ...getDefaultLoadOptions(), + region: 'eu', + integrationInfo: ['integrationInfo1', 'integrationInfo2', `fingerprintjs-pro-spa/${packageInfo.version}`], + }) + }) + it('should allow calling .init() again in case of errors thrown by agent', async () => { const client = new FpjsClient({ loadOptions: getDefaultLoadOptions() }) diff --git a/src/client.ts b/src/client.ts index 1e1ead6..e122552 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ import * as FingerprintJS from '@fingerprintjs/fingerprintjs-pro' -import { GetOptions } from '@fingerprintjs/fingerprintjs-pro' +import { GetOptions, LoadOptions } from '@fingerprintjs/fingerprintjs-pro' import { CacheKey, CacheManager, @@ -50,7 +50,9 @@ export interface CustomAgent { load: (options: FingerprintJS.LoadOptions) => Promise } -export interface FpjsSpaOptions extends FpjsClientOptions { +export interface FpjsSpaOptions + extends Omit, + Pick, 'loadOptions'> { customAgent?: CustomAgent } @@ -59,7 +61,7 @@ export interface FpjsSpaOptions extends FpjsClientOptions { */ export class FpjsClient { private cacheManager: CacheManager - private loadOptions: FingerprintJS.LoadOptions + private readonly loadOptions?: FingerprintJS.LoadOptions private agent: FingerprintJS.Agent private agentPromise: Promise | null private readonly customAgent?: CustomAgent @@ -67,9 +69,9 @@ export class FpjsClient { private inFlightRequests = new Map>() - constructor(options: FpjsSpaOptions) { + constructor(options?: FpjsSpaOptions) { this.agentPromise = null - this.customAgent = options.customAgent + this.customAgent = options?.customAgent this.agent = { get: () => { @@ -77,12 +79,9 @@ export class FpjsClient { }, } - this.loadOptions = { - ...options.loadOptions, - integrationInfo: [...(options.loadOptions.integrationInfo || []), `fingerprintjs-pro-spa/${packageInfo.version}`], - } + this.loadOptions = options?.loadOptions - if (options.cache && options.cacheLocation) { + if (options?.cache && options?.cacheLocation) { console.warn( 'Both `cache` and `cacheLocation` options have been specified in the FpjsClient configuration; ignoring `cacheLocation` and using `cache`.' ) @@ -90,10 +89,10 @@ export class FpjsClient { let cache: ICache - if (options.cache) { + if (options?.cache) { cache = options.cache } else { - this.cacheLocation = options.cacheLocation || CacheLocation.SessionStorage + this.cacheLocation = options?.cacheLocation || CacheLocation.SessionStorage if (!cacheFactory(this.cacheLocation)) { throw new Error(`Invalid cache location "${this.cacheLocation}"`) @@ -102,26 +101,42 @@ export class FpjsClient { this.cacheLocation = CacheLocation.Memory } - cache = cacheFactory(this.cacheLocation)(options.cachePrefix) + cache = cacheFactory(this.cacheLocation)(options?.cachePrefix) } - if (options.cacheTimeInSeconds && options.cacheTimeInSeconds > MAX_CACHE_LIFE) { + if (options?.cacheTimeInSeconds && options.cacheTimeInSeconds > MAX_CACHE_LIFE) { throw new Error(`Cache time cannot exceed 86400 seconds (24 hours)`) } - const cacheTime = options.cacheTimeInSeconds ?? DEFAULT_CACHE_LIFE + const cacheTime = options?.cacheTimeInSeconds ?? DEFAULT_CACHE_LIFE this.cacheManager = new CacheManager(cache, cacheTime) } /** * Loads FPJS JS agent with certain settings and stores the instance in memory * [https://dev.fingerprint.com/docs/js-agent#agent-initialization] + * + * @param passedLoadOptions Additional load options to be passed to the agent, they will be merged with load options provided in the constructor. */ - public async init() { + public async init(passedLoadOptions?: Partial) { + if (!this.loadOptions && !passedLoadOptions) { + throw new TypeError('No load options provided') + } + + const loadOptions: FingerprintJS.LoadOptions = { + ...this.loadOptions!, + ...passedLoadOptions!, + integrationInfo: [ + ...(this.loadOptions?.integrationInfo || []), + ...(passedLoadOptions?.integrationInfo || []), + `fingerprintjs-pro-spa/${packageInfo.version}`, + ], + } + if (!this.agentPromise) { const agentLoader = this.customAgent ?? FingerprintJS this.agentPromise = agentLoader - .load(this.loadOptions) + .load(loadOptions) .then((agent) => { this.agent = agent return agent From c1013183523fa8140c6df8a568eb93e0113c946a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 1 Nov 2023 15:12:08 +0200 Subject: [PATCH 2/7] feat: add `cacheHit` flag to response that indicates if it was retrieved from cache --- README.md | 10 ++++++++ __tests__/client.test.ts | 49 ++++++++++++++++++++++++++++++++++++---- src/client.ts | 26 +++++++++++++++------ src/global.ts | 5 ++++ 4 files changed, 78 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fc2fd73..ea0f791 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,16 @@ const fpjsClient = new FpjsClient({ Cache keys are based on the combination of _GetOptions_. For example, API responses for calls with `extendedResult: true` and `extendedResult: false` are stored independently. +You can check if your response was retrieved from cache like this: +```js + +const result = await fpjsClient.getVisitorData({ extendedResult: true }) + +// true if cache was hit +console.log(result.cacheHit) + +``` + #### Creating a custom cache The SDK can use a custom cache store implemented inside your application. This is useful when a different data store is more convenient in your environment, such as a hybrid mobile app. diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 18f934e..6a6d5f4 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -235,6 +235,29 @@ describe(`SPA client`, () => { expect(result2?.visitorId).toBe(mockVisitorId) }) + it('should return cached response on second call if it exists', async () => { + const client = new FpjsClient({ + loadOptions: getDefaultLoadOptions(), + cacheLocation: CacheLocation.LocalStorage, + }) + await client.init() + + const result1 = await client.getVisitorData() + expect(result1).toEqual({ + visitorId: mockVisitorId, + cacheHit: false, + }) + + const result2 = await client.getVisitorData() + + expect(result2).toEqual({ + visitorId: mockVisitorId, + cacheHit: true, + }) + + expect(agentGetMock).toHaveBeenCalledTimes(1) + }) + it('should remove in-flight request even if it throws', async () => { agentGetMock.mockReset().mockRejectedValue(new Error()) @@ -251,6 +274,7 @@ describe(`SPA client`, () => { await expect(client.getVisitorData({}, true)).resolves.toEqual({ visitorId: mockVisitorId, + cacheHit: false, }) expect(agentGetMock).toHaveBeenCalledTimes(2) @@ -280,8 +304,14 @@ describe(`SPA client`, () => { const result2 = await getCall2 expect(agentGetMock).toHaveBeenCalledTimes(0) - expect(result1?.visitorId).toBe(mockVisitorId) - expect(result2?.visitorId).toBe(mockVisitorId) + expect(result1).toEqual({ + visitorId: mockVisitorId, + cacheHit: true, + }) + expect(result2).toEqual({ + visitorId: mockVisitorId, + cacheHit: true, + }) }) it(`shouldn't get cached data if there is an in-flight request already if options are different`, async () => { @@ -308,8 +338,14 @@ describe(`SPA client`, () => { const result2 = await getCall2 expect(agentGetMock).toHaveBeenCalledTimes(1) - expect(result1?.visitorId).toBe(mockVisitorId) - expect(result2?.visitorId).toBe(mockVisitorId) + expect(result1).toEqual({ + visitorId: mockVisitorId, + cacheHit: true, + }) + expect(result2).toEqual({ + visitorId: mockVisitorId, + cacheHit: false, + }) }) it(`shouldn't get cached data if a flag to ignore cache is set to true`, async () => { @@ -332,7 +368,10 @@ describe(`SPA client`, () => { const result = await client.getVisitorData(options, true) expect(agentGetMock).toHaveBeenCalledTimes(1) - expect(result?.visitorId).toBe(mockVisitorId) + expect(result).toEqual({ + visitorId: mockVisitorId, + cacheHit: false, + }) }) }) diff --git a/src/client.ts b/src/client.ts index e122552..61f0a3c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,7 +11,7 @@ import { MAX_CACHE_LIFE, SessionStorageCache, } from './cache' -import { CacheLocation, FpjsClientOptions, VisitorData } from './global' +import { CacheLocation, FpjsClientOptions, FpjsSpaResponse, VisitorData } from './global' import * as packageInfo from '../package.json' const cacheLocationBuilders: Record ICache> = { @@ -158,7 +158,10 @@ export class FpjsClient { * @param options * @param ignoreCache if set to true a request to the API will be made even if the data is present in cache */ - public async getVisitorData(options: GetOptions = {}, ignoreCache = false) { + public async getVisitorData( + options: GetOptions = {}, + ignoreCache = false + ): Promise>> { const cacheKey = FpjsClient.makeCacheKey(options) const key = cacheKey.toKey() @@ -169,7 +172,7 @@ export class FpjsClient { this.inFlightRequests.set(key, promise) } - return (await this.inFlightRequests.get(key)) as VisitorData + return (await this.inFlightRequests.get(key)) as FpjsSpaResponse> } /** @@ -186,19 +189,28 @@ export class FpjsClient { return new CacheKey(options) } - private async _identify(options: GetOptions, ignoreCache = false) { + private async _identify( + options: GetOptions, + ignoreCache = false + ): Promise>> { const key = FpjsClient.makeCacheKey(options) if (!ignoreCache) { const cacheResult = await this.cacheManager.get(key) if (cacheResult) { - return cacheResult + return { + ...cacheResult, + cacheHit: true, + } } } - const agentResult = await this.agent.get(options) + const agentResult = (await this.agent.get(options)) as VisitorData await this.cacheManager.set(key, agentResult) - return agentResult + return { + ...agentResult, + cacheHit: false, + } } } diff --git a/src/global.ts b/src/global.ts index 3797d57..595ad38 100644 --- a/src/global.ts +++ b/src/global.ts @@ -5,6 +5,11 @@ export type VisitorData = TExtended extends f ? FingerprintJS.GetResult : FingerprintJS.ExtendedGetResult +export type FpjsSpaResponse = T & { + // Indicates whether the response was retrieved from cache. + cacheHit: boolean +} + export enum CacheLocation { Memory = 'memory', LocalStorage = 'localstorage', From a9aa598f4ff16115e7c4b49be9f0126687f870c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Thu, 2 Nov 2023 12:07:07 +0200 Subject: [PATCH 3/7] feat: introduce `getVisitorDataFromCache` and `isInCache` options --- README.md | 8 ++++ __tests__/client.test.ts | 89 ++++++++++++++++++++++++++++++++++++++++ src/client.ts | 18 ++++++++ 3 files changed, 115 insertions(+) diff --git a/README.md b/README.md index ea0f791..b94f2c5 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,15 @@ const result = await fpjsClient.getVisitorData({ extendedResult: true }) // true if cache was hit console.log(result.cacheHit) +``` + +You can also use the following API to retrieve responses from cache: +```js +// Checks if request matching given options is present in cache +await fpjsClient.isInCache({ extendedResult: true }) +// Returns cached visitor data based on the request options, or undefined if the data is not present in cache +const cachedResult = await fpjsClient.getVisitorDataFromCache({ extendedResult: true }) ``` #### Creating a custom cache diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 6a6d5f4..fe3794d 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -195,6 +195,95 @@ describe(`SPA client`, () => { }) }) + describe('getVisitorDataFromCache', () => { + const mockVisitorId = 'abc123' + const cachePrefix = 'get_visitor_data_from_cache_test' + let agentGetMock: jest.Mock + + beforeEach(() => { + agentGetMock = jest.fn(async () => { + return { + visitorId: mockVisitorId, + } as FingerprintJS.GetResult + }) + // @ts-ignore + jest.spyOn(FingerprintJS, 'load').mockImplementation(async () => { + return { + get: agentGetMock, + } + }) + }) + + afterEach(() => { + localStorage.clear() + }) + + it('should return response if it is cached, and undefined if it is not', async () => { + const client = new FpjsClient({ + loadOptions: getDefaultLoadOptions(), + cacheLocation: CacheLocation.LocalStorage, + cachePrefix, + }) + await client.init() + + const response = await client.getVisitorData() + + const cachedResponse = await client.getVisitorDataFromCache() + + expect(cachedResponse).toEqual({ + ...response, + cacheHit: true, + }) + + expect(agentGetMock).toHaveBeenCalledTimes(1) + + const notCachedResponse = await client.getVisitorDataFromCache({ extendedResult: true }) + + expect(notCachedResponse).toBeUndefined() + }) + }) + + describe('isInCache', () => { + const mockVisitorId = 'abc123' + const cachePrefix = 'is_in_cache_test' + let agentGetMock: jest.Mock + + beforeEach(() => { + agentGetMock = jest.fn(async () => { + return { + visitorId: mockVisitorId, + } as FingerprintJS.GetResult + }) + // @ts-ignore + jest.spyOn(FingerprintJS, 'load').mockImplementation(async () => { + return { + get: agentGetMock, + } + }) + }) + + afterEach(() => { + localStorage.clear() + }) + + it('should return true if response is cached', async () => { + const client = new FpjsClient({ + loadOptions: getDefaultLoadOptions(), + cacheLocation: CacheLocation.LocalStorage, + cachePrefix, + }) + await client.init() + + await client.getVisitorData() + await client.getVisitorData() + + expect(agentGetMock).toHaveBeenCalledTimes(1) + await expect(client.isInCache()).resolves.toEqual(true) + await expect(client.isInCache({ extendedResult: true })).resolves.toEqual(false) + await expect(client.isInCache({ tag: 'tag' })).resolves.toEqual(false) + }) + }) + describe('getVisitorData', () => { const mockVisitorId = 'abc123' const cachePrefix = 'cache_test' diff --git a/src/client.ts b/src/client.ts index 61f0a3c..f4aecf2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -175,6 +175,24 @@ export class FpjsClient { return (await this.inFlightRequests.get(key)) as FpjsSpaResponse> } + /** + * Returns cached visitor data based on the request options, or undefined if the data is not present in cache + * */ + public async getVisitorDataFromCache( + options: GetOptions = {} + ): Promise> | undefined> { + const cacheKey = FpjsClient.makeCacheKey(options) + const cacheResult = await this.cacheManager.get(cacheKey) + return cacheResult ? { ...cacheResult, cacheHit: true } : undefined + } + + /** + * Checks if request matching given options is present in cache + * */ + public async isInCache(options: GetOptions = {}) { + return Boolean(await this.getVisitorDataFromCache(options)) + } + /** * Clears visitor data from cache regardless of the cache implementation */ From 7fa6443bdad3a5b03ec233b52e02793809680c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Thu, 16 Nov 2023 15:55:42 +0200 Subject: [PATCH 4/7] chore: apply suggestion Co-authored-by: Juraj Uhlar --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b94f2c5..90949cb 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ const visitorData = fpjsClient.init().then(() => { return fpjsClient.getVisitorData() }) ``` -:You can also pass the load options here +You can also pass the `loadOptions` into the `init` method here. They will be merged with the options passed to the constructor. ```js await fpjsClient.init({ apiKey: "", From 65de484b2b4f5a3cab5a7a1ced37cf0a9f2c1ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Thu, 16 Nov 2023 15:57:38 +0200 Subject: [PATCH 5/7] refactor: simplify FpjsSpaOptions type --- src/client.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index f4aecf2..beef5cf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -50,10 +50,9 @@ export interface CustomAgent { load: (options: FingerprintJS.LoadOptions) => Promise } -export interface FpjsSpaOptions - extends Omit, - Pick, 'loadOptions'> { +export interface FpjsSpaOptions extends Omit { customAgent?: CustomAgent + loadOptions?: FpjsClientOptions['loadOptions'] } /** From e64ec876293cb7706f9d10fda3e2a26f5821c64d Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 17 Nov 2023 10:39:55 +0000 Subject: [PATCH 6/7] docs(readme): fix callout --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c73921..997d732 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ const fpjsClient = new FpjsClient({ }) ``` -> [!INFO] +> [!NOTE] > You must provide `loadOptions` containing your public API key either in the constructor or in the `init` method. If you don't, the SDK will throw an error. You can learn more about different load options here in the [JS Agent documentation](https://dev.fingerprint.com/docs/js-agent#initializing-the-agent). ### 3. Initialize the JS Agent From 29bb11f95a52c63fe7c78a147e377e35be43ebc9 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 17 Nov 2023 10:42:57 +0000 Subject: [PATCH 7/7] docs(readme): caching h2 --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 997d732..d07d522 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ If you'd like to have a similar SPA wrapper for the OSS version of FingerprintJS - [Requirements](#requirements) - [Installation](#installation) - [Getting Started](#getting-started) +- [Caching](#caching) - [Support and Feedback](#support-and-feedback) - [Documentation](#documentation) - [License](#license) @@ -137,7 +138,7 @@ const visitorData = fpjsClient.getVisitorData({ extendedResult: true }).then((vi See the [JS Agent API reference](https://dev.fingerprint.com/docs/js-agent) for more details. -### Caching +## Caching Fingerprint Pro usage is billed per API call. To avoid unnecessary API calls, it is a good practice to cache identification results. The SDK provides three ways to cache visitor data out of the box: @@ -188,7 +189,7 @@ const cachedResult = await fpjsClient.getVisitorDataFromCache({ extendedResult: You can also use your custom cache implementation as described below. -#### Creating a custom cache +### Creating a custom cache The SDK can use a custom cache store implemented inside your application. This is useful when a different data store is more convenient in your environment, such as a hybrid mobile app. @@ -206,7 +207,7 @@ You can provide an object to the `cache` property of the SDK configuration that We export the internal `InMemoryCache`, `LocalStorageCache`, `SessionStorageCache`, and `CacheStub` implementations, so you can wrap your custom cache around these implementations if you wish. -#### Cache time +### Cache time Use the `cacheTimeInSeconds` client constructor option to set a custom cache time. To ensure high identification accuracy we recommend not to cache visitors data for longer than 24 hours. If you pass a value higher than 86400 (60 _ 60 _ 24), the `FpjsClient` constructor will throw an error.