diff --git a/README.md b/README.md index 3ee8b74..d07d522 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,17 @@ Documentation

- # Fingerprint Pro SPA [Fingerprint](https://fingerprint.com/) is a device intelligence platform offering 99.5% accurate visitor identification -This library is designed to be used in single-page-application framework wrappers for the Fingerprint Pro JavaScript Agent. -It has multiple built-in caching mechanisms with recommended default settings. +This library is designed to be used in single-page-application framework wrappers for the Fingerprint Pro JavaScript Agent. +It has multiple built-in caching mechanisms with recommended default settings. If you just need the Fingerprint Pro [JS agent](https://www.npmjs.com/package/@fingerprintjs/fingerprintjs-pro), you can use it directly, without this wrapper. If you're looking for a framework-specific integration, we have dedicated SDKs for [React (including Next, Preact)](https://github.com/fingerprintjs/fingerprintjs-pro-react), [Vue](https://github.com/fingerprintjs/fingerprintjs-pro-vue), [Svelte](https://github.com/fingerprintjs/fingerprintjs-pro-svelte) and [Angular](https://github.com/fingerprintjs/fingerprintjs-pro-angular). -**This SDK works with Fingerprint Pro, it will not work with the open-source FingerprintJS version!** -Learn more about the [difference between Pro and OSS](https://dev.fingerprint.com/docs/pro-vs-open-source). +**This SDK works with Fingerprint Pro, it will not work with the open-source FingerprintJS version!** +Learn more about the [difference between Pro and OSS](https://dev.fingerprint.com/docs/pro-vs-open-source). If you'd like to have a similar SPA wrapper for the OSS version of FingerprintJS, consider [raising an issue in our issue tracker](https://github.com/fingerprintjs/fingerprintjs-pro-spa/issues). ## Table of Contents @@ -35,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) @@ -77,23 +77,21 @@ In order to identify visitors you'll need a Fingerprint Pro account (you can [si Create a `FpjsClient` instance before rendering or initializing your application. You should only have one instance of the client. You need to specify your public API key and other configuration options based on your chosen region and active integration. ```js -import { - FpjsClient, - FingerprintJSPro -} from '@fingerprintjs/fingerprintjs-pro-spa'; +import { FpjsClient, 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: "", + apiKey: '', // endpoint: ["", FingerprintJSPro.defaultEndpoint], // scriptUrlPattern: ["", FingerprintJSPro.defaultScriptUrlPattern], // region: "eu" - } -}); + }, +}) ``` -You can learn more about different load options in the [JS Agent API Reference](https://dev.fingerprint.com/docs/js-agent#initializing-the-agent). + +> [!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 @@ -102,6 +100,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 @@ -110,6 +109,17 @@ const visitorData = fpjsClient.init().then(() => { }) ``` +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: '', + // endpoint: ["", FingerprintJSPro.defaultEndpoint], + // scriptUrlPattern: ["", FingerprintJSPro.defaultScriptUrlPattern], + // region: "eu" +}) +``` + ### 4. Identify visitors The `getVisitorData` method returns visitor identification data based on the request [options](https://dev.fingerprint.com/docs/js-agent#get-options). @@ -126,50 +136,70 @@ const visitorData = fpjsClient.getVisitorData({ extendedResult: true }).then((vi }) ``` -See the [JS Agent API reference](https://dev.fingerprint.com/docs/js-agent) for more details. +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: -* Session storage (default) - `sessionStorage` -* Local storage - `localStorage` -* Memory - `memory` -* No cache - `nocache` - +- Session storage (default) - `sessionStorage` +- Local storage - `localStorage` +- Memory - `memory` +- No cache - `nocache` + You can specify the `cacheLocation` option when creating the `FpjsClient`: ```js const fpjsClient = new FpjsClient({ loadOptions: { - apiKey: "your-fpjs-public-api-key" + apiKey: 'your-fpjs-public-api-key', }, - cacheLocation: 'localstorage' + cacheLocation: 'localstorage', // You can also use the provided TypeScript enum // cacheLocation: CacheLocation.LocalStorage -}); +}) ``` 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 ignore the cached result for a specific API call by passing `{ ignoreCache: true }` to the `getVisitorData()` method. -* You can also use your custom cache implementation as described below. - > [!NOTE] -> If you use data from [`extendedResult`](https://dev.fingerprint.com/docs/js-agent#extendedresult), pay additional attention to your caching strategy. -> Some fields, for example, `ip` or `lastSeenAt`, might change over time for the same visitor. Use `getVisitorData({ ignoreCache: true })` to fetch the latest identification results. +> If you use data from [`extendedResult`](https://dev.fingerprint.com/docs/js-agent#extendedresult), pay additional attention to your caching strategy. Some fields, for example, `ip` or `lastSeenAt`, might change over time for the same visitor. + +You can ignore the cached result for a specific API call and using `{ ignoreCache: true }`: -#### Creating a custom cache +```js +const visitorData = await fpjsClient.getVisitorData({ ignoreCache: true }) +``` + +Check if your response was retrieved from cache using the returned `cacheHit` flag: + +```js +const { cacheHit, ...visitorData } = await fpjsClient.getVisitorData() +``` + +Use `getVisitorDataFromCache` to directly 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 }) +``` + +You can also use your custom cache implementation as described below. + +### 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. You can provide an object to the `cache` property of the SDK configuration that implements the following functions. All the functions can return a Promise or a static value. -| Signature | Return type | Description | -| -------------------------------- | ------------------------------ |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `get(key)` | Promise or object | Returns the item from the cache with the specified key, or `undefined` if it was not found | -| `set(key: string, object: any) ` | Promise or void | Sets an item into the cache | -| `remove(key)` | Promise or void | Removes a single item from the cache at the specified key, or no-op if the item was not found | +| Signature | Return type | Description | +| -------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `get(key)` | Promise or object | Returns the item from the cache with the specified key, or `undefined` if it was not found | +| `set(key: string, object: any) ` | Promise or void | Sets an item into the cache | +| `remove(key)` | Promise or void | Removes a single item from the cache at the specified key, or no-op if the item was not found | | `allKeys()` | Promise or string [] | Returns the list of all keys. By default, the keys we use are prefixed with `@fpjs@client@` but you can pass your own custom prefix as an option when you create the FpjsClient | > [!NOTE] @@ -177,8 +207,9 @@ 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 -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. +### 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. ## Support and feedback @@ -187,6 +218,7 @@ To report problems, ask questions, or provide feedback, please use [Issues](http ## Documentation This library uses [Fingerprint Pro](https://fingerprint.com/github/) under the hood. + - To learn more about Fingerprint Pro read our [product documentation](https://dev.fingerprint.com/docs). - To learn more about this SDK, there is a [Typedoc-generated SDK Reference](https://fingerprintjs.github.io/fingerprintjs-pro-spa) available. diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 5c31d47..fe3794d 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() }) @@ -164,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' @@ -204,6 +324,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()) @@ -220,6 +363,7 @@ describe(`SPA client`, () => { await expect(client.getVisitorData({}, true)).resolves.toEqual({ visitorId: mockVisitorId, + cacheHit: false, }) expect(agentGetMock).toHaveBeenCalledTimes(2) @@ -249,8 +393,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 () => { @@ -277,8 +427,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 () => { @@ -301,7 +457,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 1e1ead6..beef5cf 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, @@ -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> = { @@ -50,8 +50,9 @@ export interface CustomAgent { load: (options: FingerprintJS.LoadOptions) => Promise } -export interface FpjsSpaOptions extends FpjsClientOptions { +export interface FpjsSpaOptions extends Omit { customAgent?: CustomAgent + loadOptions?: FpjsClientOptions['loadOptions'] } /** @@ -59,7 +60,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 +68,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 +78,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 +88,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 +100,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 @@ -143,7 +157,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() @@ -154,7 +171,25 @@ export class FpjsClient { this.inFlightRequests.set(key, promise) } - return (await this.inFlightRequests.get(key)) as VisitorData + 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)) } /** @@ -171,19 +206,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',