From 30501cc772e4eaa3460d405af927e860b7690af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Mon, 10 Aug 2026 12:22:28 +0200 Subject: [PATCH 001/109] feat: prepare public API for V4 migration INTER-2392 --- pnpm-lock.yaml | 24 +- .../FingerprintJsProProvider.test.ts | 343 ------------- sdk/__tests__/errors.test.ts | 90 +++- sdk/__tests__/helpers.tsx | 22 +- sdk/__tests__/setup.ts | 5 +- sdk/__tests__/useVisitorData.test.ts | 240 +++------ sdk/package.json | 6 +- sdk/src/FingerprintContext.ts | 28 ++ sdk/src/FingerprintJsProAgent.ts | 108 ---- sdk/src/FingerprintJsProAgent.web.ts | 113 ----- sdk/src/FingerprintJsProContext.ts | 18 - sdk/src/FingerprintJsProProvider.tsx | 66 --- sdk/src/FingerprintProvider.tsx | 48 ++ sdk/src/errors.ts | 378 ++------------ sdk/src/index.ts | 55 +- sdk/src/specs/NativeRNFingerprintjsPro.ts | 20 +- sdk/src/types.ts | 471 +++++++----------- sdk/src/unwrapError.ts | 124 +---- sdk/src/unwrapError.web.ts | 112 +---- sdk/src/useFingerprint.ts | 29 ++ sdk/src/useVisitorData.ts | 135 +++-- 21 files changed, 648 insertions(+), 1787 deletions(-) delete mode 100644 sdk/__tests__/FingerprintJsProProvider.test.ts create mode 100644 sdk/src/FingerprintContext.ts delete mode 100644 sdk/src/FingerprintJsProAgent.ts delete mode 100644 sdk/src/FingerprintJsProAgent.web.ts delete mode 100644 sdk/src/FingerprintJsProContext.ts delete mode 100644 sdk/src/FingerprintJsProProvider.tsx create mode 100644 sdk/src/FingerprintProvider.tsx create mode 100644 sdk/src/useFingerprint.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a50eb400..2b165990 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,9 +74,9 @@ importers: sdk: devDependencies: - '@fingerprintjs/fingerprintjs-pro-spa': - specifier: ^1.3.2 - version: 1.3.2 + '@fingerprint/agent': + specifier: ^4.1.3 + version: 4.1.3 '@react-native/babel-preset': specifier: ^0.85.3 version: 0.85.3(@babel/core@7.29.7) @@ -1300,6 +1300,9 @@ packages: resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} hasBin: true + '@fingerprint/agent@4.1.3': + resolution: {integrity: sha512-kD7x3cYpOETFZ6KKOzozwGsCYhApRa/n8sDA+oLjx0jRVarmxisMMUi5UtGJQHdx8jqxU+I/8gcjb+fuSBxhBA==} + '@fingerprintjs/changesets-changelog-format@0.2.0': resolution: {integrity: sha512-1jI+J1r8OKUtjlFfQW4bmQ8u4E1097zPaKw5fUCgAcahLPgwW4Ae+Keoc253CSyyaKgTu2v/wniUGchmPaXAsg==} @@ -1317,12 +1320,6 @@ packages: prettier: ^3.0.0 typescript-eslint: ^8.0.0 - '@fingerprintjs/fingerprintjs-pro-spa@1.3.2': - resolution: {integrity: sha512-s1YGsx1XQLmjU+av4UrUHNxyzwPHyZRB0GXJQFOJK8ZHCYc2SNukxnJmZA++bNBa8twU3wW+QgSJhA4Prjnd0g==} - - '@fingerprintjs/fingerprintjs-pro@3.12.0': - resolution: {integrity: sha512-zWCOunrsD6Op/9g0E3dAI2PRUfWvPlsNi0svJUoq+mqz4W2kuCsEVomQRRROTnEbqK/foTruBjYMZnRj0j/F3A==} - '@fingerprintjs/prettier-config-dx-team@0.3.0': resolution: {integrity: sha512-si/0/OKpYFEhf3oK1K6/CvWcQXy6k7bMez7XUrUmvJJRA0Pvh+Z37rAfsoDQYD1dxQNf2Iu/GO43u/dgAx2mkw==} @@ -7282,6 +7279,8 @@ snapshots: chalk: 4.1.2 js-yaml: 4.2.0 + '@fingerprint/agent@4.1.3': {} + '@fingerprintjs/changesets-changelog-format@0.2.0': dependencies: '@changesets/types': 6.1.0 @@ -7307,13 +7306,6 @@ snapshots: transitivePeerDependencies: - '@types/eslint' - '@fingerprintjs/fingerprintjs-pro-spa@1.3.2': - dependencies: - '@fingerprintjs/fingerprintjs-pro': 3.12.0 - tslib: 2.8.1 - - '@fingerprintjs/fingerprintjs-pro@3.12.0': {} - '@fingerprintjs/prettier-config-dx-team@0.3.0': dependencies: prettier: 3.2.4 diff --git a/sdk/__tests__/FingerprintJsProProvider.test.ts b/sdk/__tests__/FingerprintJsProProvider.test.ts deleted file mode 100644 index b995acf3..00000000 --- a/sdk/__tests__/FingerprintJsProProvider.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { useContext } from 'react' -import { renderHook } from '@testing-library/react' -import { createWrapper, getDefaultLoadOptions, renderProvider } from './helpers' -import { FingerprintJsProContext } from '../src/FingerprintJsProContext' -import { NativeModules } from 'react-native' -import { FingerprintJsProAgent } from '../src' - -const { configure, getVisitorId, getVisitorData } = - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - NativeModules.RNFingerprintjsPro as unknown as Record - -const mockedVisitorId = 'some visitor id' -const mockedRequestId = 'some request id' -const mockedConfidenceScore = 0.99 - -const pluginVersion = '__VERSION__' - -describe(`FingerprintJsProProvider`, () => { - it('should pass options to agent with regular result', () => { - const options = getDefaultLoadOptions() - options.region = 'us' - options.endpointUrl = 'https://example.com' - - const wrapper = createWrapper(options) - renderHook(() => useContext(FingerprintJsProContext), { - wrapper, - }) - - expect(configure).toHaveBeenCalledWith( - options.apiKey, - pluginVersion, - false, - [], - false, - 5000, - options.region, - options.endpointUrl - ) - }) - - it('should pass fallbackEndpointUrls as array', () => { - const options = getDefaultLoadOptions() - options.region = 'us' - options.endpointUrl = 'https://example.com' - options.fallbackEndpointUrls = ['https://example2.com', 'https://example3.com'] - - const wrapper = createWrapper(options) - renderHook(() => useContext(FingerprintJsProContext), { - wrapper, - }) - - expect(configure).toHaveBeenCalledWith( - options.apiKey, - pluginVersion, - false, - options.fallbackEndpointUrls, - false, - 5000, - options.region, - options.endpointUrl - ) - }) - - it('should pass options to agent with extended result', () => { - const options = getDefaultLoadOptions() - options.region = 'us' - options.endpointUrl = 'https://example.com' - options.extendedResponseFormat = true - - const wrapper = createWrapper(options) - renderHook(() => useContext(FingerprintJsProContext), { - wrapper, - }) - - expect(configure).toHaveBeenCalledWith( - options.apiKey, - pluginVersion, - options.extendedResponseFormat, - [], - false, - 5000, - options.region, - options.endpointUrl - ) - }) - - it('should pass options to agent with allowuseContextOfLocationData and locationTimeoutMillisAndroid', () => { - const options = getDefaultLoadOptions() - options.region = 'us' - options.endpointUrl = 'https://example.com' - options.allowUseOfLocationData = true - options.locationTimeoutMillisAndroid = 6000 - - const wrapper = createWrapper(options) - renderHook(() => useContext(FingerprintJsProContext), { - wrapper, - }) - - expect(configure).toHaveBeenCalledWith( - options.apiKey, - pluginVersion, - false, - [], - true, - options.locationTimeoutMillisAndroid, - options.region, - options.endpointUrl - ) - }) - - it('should call `getVisitorId` function when there is no timeout', () => { - const options = getDefaultLoadOptions() - const fingerprintClient = new FingerprintJsProAgent(options) - void fingerprintClient.getVisitorId() - - expect(getVisitorId).toHaveBeenCalledWith(null, null, null) - }) - - it('should pass timeout to `getVisitorId` function', () => { - const options = getDefaultLoadOptions() - options.requestOptions = { timeout: 18_000 } - const fingerprintClient = new FingerprintJsProAgent(options) - void fingerprintClient.getVisitorId() - - expect(getVisitorId).toHaveBeenCalledWith(null, null, options.requestOptions.timeout) - }) - - it('should pass timeout to `getVisitorId` function when timeout is 0', () => { - const options = getDefaultLoadOptions() - options.requestOptions = { timeout: 0 } - const fingerprintClient = new FingerprintJsProAgent(options) - void fingerprintClient.getVisitorId() - - expect(getVisitorId).toHaveBeenCalledWith(null, null, options.requestOptions.timeout) - }) - - it('For `getVisitorId` function timeout from params should be more important than the timeout from client configuration', () => { - const options = getDefaultLoadOptions() - const clientTimeout = 10 - const getRequestTimeout = 200 - options.requestOptions = { timeout: clientTimeout } - const fingerprintClient = new FingerprintJsProAgent(options) - - getVisitorId.mockReturnValueOnce(Promise.resolve(mockedVisitorId)) - - void fingerprintClient.getVisitorId(undefined, undefined, { timeout: getRequestTimeout }) - - expect(getVisitorId).toHaveBeenCalledWith(null, null, getRequestTimeout) - }) - - it('should call `getVisitorData` function when there is no timeout', () => { - const options = getDefaultLoadOptions() - const fingerprintClient = new FingerprintJsProAgent(options) - - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) - ) - - void fingerprintClient.getVisitorData() - - expect(getVisitorData).toHaveBeenCalledWith(null, null, null) - }) - - it('should pass timeout to `getVisitorData` function', () => { - const options = getDefaultLoadOptions() - options.requestOptions = { timeout: 18_000 } - const fingerprintClient = new FingerprintJsProAgent(options) - - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) - ) - - void fingerprintClient.getVisitorData() - - expect(getVisitorData).toHaveBeenCalledWith(null, null, options.requestOptions.timeout) - }) - - it('should pass timeout to `getVisitorData` function when timeout is 0', () => { - const options = getDefaultLoadOptions() - options.requestOptions = { timeout: 0 } - const fingerprintClient = new FingerprintJsProAgent(options) - - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) - ) - - void fingerprintClient.getVisitorData() - - expect(getVisitorData).toHaveBeenCalledWith(null, null, options.requestOptions.timeout) - }) - - it('For `getVisitorData` function timeout from params should be more important than the timeout from client configuration', () => { - const options = getDefaultLoadOptions() - const clientTimeout = 10 - const getRequestTimeout = 200 - options.requestOptions = { timeout: clientTimeout } - const fingerprintClient = new FingerprintJsProAgent(options) - - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) - ) - - void fingerprintClient.getVisitorData(undefined, undefined, { timeout: getRequestTimeout }) - - expect(getVisitorData).toHaveBeenCalledWith(null, null, getRequestTimeout) - }) - - describe('agent params changes propagation', () => { - const pluginVersion = '__VERSION__' - - beforeEach(() => { - configure.mockClear() - }) - - it('configures the agent once on mount', () => { - renderProvider({ apiKey: 'key-1' }) - - expect(configure).toHaveBeenCalledTimes(1) - expect(configure).toHaveBeenLastCalledWith('key-1', pluginVersion, false, [], false, 5000, null, null) - }) - - it('reconfigures the agent when a param changes by value', () => { - const { rerenderWithParams } = renderProvider({ apiKey: 'key-1' }) - expect(configure).toHaveBeenCalledTimes(1) - - rerenderWithParams({ apiKey: 'key-2' }) - - expect(configure).toHaveBeenCalledTimes(2) - expect(configure).toHaveBeenLastCalledWith('key-2', pluginVersion, false, [], false, 5000, null, null) - }) - - it('propagates changes across every configure argument', () => { - const { rerenderWithParams } = renderProvider({ apiKey: 'key' }) - expect(configure).toHaveBeenLastCalledWith('key', pluginVersion, false, [], false, 5000, null, null) - - rerenderWithParams({ - apiKey: 'key', - region: 'eu', - endpointUrl: 'https://example.com', - extendedResponseFormat: true, - allowUseOfLocationData: true, - locationTimeoutMillisAndroid: 6000, - }) - - expect(configure).toHaveBeenCalledTimes(2) - expect(configure).toHaveBeenLastCalledWith( - 'key', - pluginVersion, - true, - [], - true, - 6000, - 'eu', - 'https://example.com' - ) - }) - - it('propagates changes to array params (fallbackEndpointUrls)', () => { - const { rerenderWithParams } = renderProvider({ apiKey: 'key', fallbackEndpointUrls: ['https://a.example'] }) - expect(configure).toHaveBeenLastCalledWith( - 'key', - pluginVersion, - false, - ['https://a.example'], - false, - 5000, - null, - null - ) - - rerenderWithParams({ apiKey: 'key', fallbackEndpointUrls: ['https://a.example', 'https://b.example'] }) - - expect(configure).toHaveBeenCalledTimes(2) - expect(configure).toHaveBeenLastCalledWith( - 'key', - pluginVersion, - false, - ['https://a.example', 'https://b.example'], - false, - 5000, - null, - null - ) - }) - - it('propagates changes to nested object params (requestOptions)', () => { - const { rerenderWithParams } = renderProvider({ apiKey: 'key', requestOptions: { timeout: 1000 } }) - expect(configure).toHaveBeenCalledTimes(1) - - rerenderWithParams({ apiKey: 'key', requestOptions: { timeout: 2000 } }) - - // `requestOptions` is not forwarded to `configure`, but changing it by value must still rebuild the - // agent so the new request timeout takes effect. - expect(configure).toHaveBeenCalledTimes(2) - }) - - it('does not reconfigure when re-rendered with value-equal params but fresh object identities', () => { - const { rerenderWithParams } = renderProvider({ - apiKey: 'key', - requestOptions: { timeout: 5000 }, - fallbackEndpointUrls: ['https://a.example'], - }) - expect(configure).toHaveBeenCalledTimes(1) - - // Same values, brand-new object/array identities on every prop (as would happen with inline props). - rerenderWithParams({ - apiKey: 'key', - requestOptions: { timeout: 5000 }, - fallbackEndpointUrls: ['https://a.example'], - }) - - expect(configure).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/sdk/__tests__/errors.test.ts b/sdk/__tests__/errors.test.ts index 679c7cd3..3a7d0113 100644 --- a/sdk/__tests__/errors.test.ts +++ b/sdk/__tests__/errors.test.ts @@ -1,22 +1,90 @@ -import { ApiKeyRequiredError, JsonParsingError, UnknownError } from '../src/errors' +import { FingerprintError, isFingerprintError } from '../src/errors' import { unwrapError } from '../src/unwrapError' +import { unwrapError as unwrapWebError } from '../src/unwrapError.web' -describe('unwrapError function', () => { - it('should return ApiKeyRequiredError when TokenRequired error passed', () => { - const error = unwrapError(new Error('TokenRequired:some message')) - expect(error).toBeInstanceOf(ApiKeyRequiredError) - expect(error.message).toBe('some message') +describe('unwrapError (native)', () => { + it('parses the ":" format into a FingerprintError', () => { + const error = unwrapError(new Error('too_many_requests:Rate limit reached')) + expect(error).toBeInstanceOf(FingerprintError) + expect(error.code).toBe('too_many_requests') + expect(error.message).toBe('Rate limit reached') + expect(error.event_id).toBeNull() }) - it('should return UnknownError with full message inside when something unknown passed', () => { + it('falls back to unknown_error and keeps the full message when there is no code', () => { const error = unwrapError(new Error('Something broken')) - expect(error).toBeInstanceOf(UnknownError) + expect(error).toBeInstanceOf(FingerprintError) + expect(error.code).toBe('unknown_error') expect(error.message).toBe('Something broken') }) - it('should keep error message full even with ":" inside', () => { - const error = unwrapError(new Error('JsonParsingError:some:strange:message')) - expect(error).toBeInstanceOf(JsonParsingError) + it('keeps the message intact even with ":" inside it', () => { + const error = unwrapError(new Error('bad_response_format:some:strange:message')) + expect(error.code).toBe('bad_response_format') expect(error.message).toBe('some:strange:message') }) + + it('returns the same instance when a FingerprintError is passed through', () => { + const original = new FingerprintError({ code: 'failed', message: 'boom' }) + expect(unwrapError(original)).toBe(original) + }) +}) + +describe('unwrapError (web)', () => { + /** + * Builds an error shaped like the one thrown by `@fingerprint/agent`. + */ + function makeAgentError(code: string, message: string, event_id: string | null = null): Error { + const error = new Error(message) + error.name = 'FingerprintError' + Object.assign(error, { code, event_id }) + return error + } + + it('re-wraps an agent FingerprintError, preserving code, message and event_id', () => { + const error = unwrapWebError(makeAgentError('too_many_requests', 'Rate limit reached', 'evt_123')) + expect(error).toBeInstanceOf(FingerprintError) + expect(error.name).toBe('FingerprintError') + expect(error.code).toBe('too_many_requests') + expect(error.message).toBe('Rate limit reached') + expect(error.event_id).toBe('evt_123') + }) + + it('preserves a null event_id from the agent error', () => { + const error = unwrapWebError(makeAgentError('failed', 'boom')) + expect(error.code).toBe('failed') + expect(error.event_id).toBeNull() + }) + + it('wraps a plain Error as unknown_error with a null event_id', () => { + const error = unwrapWebError(new Error('boom')) + expect(error).toBeInstanceOf(FingerprintError) + expect(error.code).toBe('unknown_error') + expect(error.message).toBe('boom') + expect(error.event_id).toBeNull() + }) + + it('wraps a non-error value as unknown_error', () => { + const error = unwrapWebError('nope') + expect(error).toBeInstanceOf(FingerprintError) + expect(error.code).toBe('unknown_error') + expect(error.message).toBe('nope') + }) + + it('returns the same instance when the SDK FingerprintError is passed through', () => { + const original = new FingerprintError({ code: 'failed', message: 'boom', event_id: 'evt_9' }) + expect(unwrapWebError(original)).toBe(original) + }) +}) + +describe('isFingerprintError', () => { + it('recognizes a FingerprintError', () => { + expect(isFingerprintError(new FingerprintError({ code: 'failed' }))).toBe(true) + }) + + it('rejects a plain Error and non-errors', () => { + expect(isFingerprintError(new Error('nope'))).toBe(false) + expect(isFingerprintError({ code: 'failed' })).toBe(false) + expect(isFingerprintError(null)).toBe(false) + }) }) diff --git a/sdk/__tests__/helpers.tsx b/sdk/__tests__/helpers.tsx index 6cebc80d..c05f649a 100644 --- a/sdk/__tests__/helpers.tsx +++ b/sdk/__tests__/helpers.tsx @@ -1,30 +1,30 @@ import React, { PropsWithChildren } from 'react' import { render, RenderResult } from '@testing-library/react' -import { FingerprintJsProAgentParams, FingerprintJsProProvider } from '../src' +import { FingerprintProvider, StartOptions } from '../src' -export const getDefaultLoadOptions = (): FingerprintJsProAgentParams => ({ +export const getDefaultLoadOptions = (): StartOptions => ({ apiKey: 'test_api_key', }) export const createWrapper = - (loadOptions: FingerprintJsProAgentParams = getDefaultLoadOptions()) => - ({ children }: PropsWithChildren) => {children} + (loadOptions: StartOptions = getDefaultLoadOptions()) => + ({ children }: PropsWithChildren) => {children} /** - * Renders the provider with the given params and exposes a typed `rerenderWithParams` helper so tests - * can change `fingerprintJsProAgentParams` across renders without inlining JSX. + * Renders the provider with the given options and exposes a typed `rerenderWithParams` helper so tests + * can change the start options across renders without inlining JSX. */ export const renderProvider = ( - params: FingerprintJsProAgentParams + params: StartOptions ): RenderResult & { - rerenderWithParams: (nextParams: FingerprintJsProAgentParams) => void + rerenderWithParams: (nextParams: StartOptions) => void } => { - const utils = render() + const utils = render() return { ...utils, - rerenderWithParams: (nextParams: FingerprintJsProAgentParams) => { - utils.rerender() + rerenderWithParams: (nextParams: StartOptions) => { + utils.rerender() }, } } diff --git a/sdk/__tests__/setup.ts b/sdk/__tests__/setup.ts index ceecf20e..9dd495f3 100644 --- a/sdk/__tests__/setup.ts +++ b/sdk/__tests__/setup.ts @@ -1,7 +1,6 @@ jest.mock('react-native', () => { const RNFingerprintjsPro = { configure: jest.fn(), - getVisitorId: jest.fn(), getVisitorData: jest.fn(), } @@ -14,5 +13,9 @@ jest.mock('react-native', () => { NativeModules: { RNFingerprintjsPro, }, + Platform: { + OS: 'android', + select: (options: { android?: T; ios?: T; default?: T }): T | undefined => options.android ?? options.default, + }, } }) diff --git a/sdk/__tests__/useVisitorData.test.ts b/sdk/__tests__/useVisitorData.test.ts index d7153a7b..b28da7d6 100644 --- a/sdk/__tests__/useVisitorData.test.ts +++ b/sdk/__tests__/useVisitorData.test.ts @@ -1,213 +1,133 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { NativeModules } from 'react-native' -import { RequestOptions, useVisitorData } from '../src' +import { useVisitorData } from '../src' import { createWrapper } from './helpers' // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const { getVisitorData } = NativeModules.RNFingerprintjsPro as unknown as Record -const mockedVisitorId = 'some visitor id' -const mockedRequestId = 'some request id' -const mockedConfidenceScore = 0.99 +const nativeResponse = { + visitorId: 'some-visitor-id', + eventId: 'some-event-id', + suspectScore: 42, + sealedResult: '', +} + +const expectedData = { + visitor_id: 'some-visitor-id', + event_id: 'some-event-id', + suspect_score: 42, + sealed_result: null, +} describe('useVisitorData', () => { - it('should provide FingerprintJsProContext', () => { - const wrapper = createWrapper() - const { - result: { current }, - } = renderHook(() => useVisitorData(), { wrapper }) - expect(current).toBeDefined() + beforeEach(() => { + getVisitorData.mockReset() + getVisitorData.mockResolvedValue(nativeResponse) }) - it('should correct update isLoading state', async () => { - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) - - act(() => { - void result.current.getData() - }) + it('starts in the idle state', () => { + const { result } = renderHook(() => useVisitorData(), { wrapper: createWrapper() }) - expect(result.current.isLoading).toBeTruthy() - - await waitFor(() => { - expect(result.current.isLoading).toBeFalsy() - }) + expect(result.current.data).toBeUndefined() + expect(result.current.isLoading).toBe(false) + expect(result.current.isFetched).toBe(false) + expect(result.current.error).toBeUndefined() }) - it('should correct return data', async () => { - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } + it('transitions through loading and fetched states', async () => { + const { result } = renderHook(() => useVisitorData(), { wrapper: createWrapper() }) - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) - ) - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) act(() => { void result.current.getData() }) - expect(result.current.data).toBeUndefined() + + expect(result.current.isLoading).toBe(true) + await waitFor(() => { - expect(result.current.data).toStrictEqual({ - visitorId: mockedVisitorId, - requestId: mockedRequestId, - confidence: { - score: mockedConfidenceScore, - }, - }) - expect(result.current.error).toBeFalsy() + expect(result.current.isFetched).toBe(true) }) + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toStrictEqual(expectedData) + expect(result.current.error).toBeUndefined() }) - it('should store errors and return null by default', async () => { - const error = new Error('Unknown error') - getVisitorData.mockReturnValueOnce(Promise.reject(error)) - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) + it('stores the error and rejects when identification fails', async () => { + getVisitorData.mockRejectedValueOnce(new Error('too_many_requests:Rate limit reached')) + const { result } = renderHook(() => useVisitorData(), { wrapper: createWrapper() }) await act(async () => { - await expect(result.current.getData()).resolves.toBeNull() + await expect(result.current.getData()).rejects.toMatchObject({ + name: 'FingerprintError', + code: 'too_many_requests', + }) }) - expect(result.current.isLoading).toBeFalsy() + expect(result.current.isLoading).toBe(false) expect(result.current.data).toBeUndefined() - expect(result.current.error).toBeInstanceOf(Error) - expect(result.current.error?.name).toBe('UnknownError') - expect(result.current.error?.message).toContain(error.message) + expect(result.current.error).toMatchObject({ name: 'FingerprintError', code: 'too_many_requests' }) }) - it('should rethrow errors when throwOnError is enabled', async () => { - const error = new Error('Unknown error') - getVisitorData.mockReturnValueOnce(Promise.reject(error)) - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) + it('passes tag, linkedId and timeout from a single options object', async () => { + const tag = { stringTag: 'foo', numberTag: 0, objectTag: { foo: true, bar: [1, 2, 3] }, boolTag: false } + const { result } = renderHook(() => useVisitorData(), { wrapper: createWrapper() }) - await act(async () => { - await expect(result.current.getData(undefined, undefined, { throwOnError: true })).rejects.toMatchObject({ - name: 'UnknownError', - message: error.message, - }) + act(() => { + void result.current.getData({ tag, linkedId: 'test_id', timeout: 15_000 }) }) - expect(result.current.isLoading).toBeFalsy() - expect(result.current.data).toBeUndefined() - expect(result.current.error).toMatchObject({ - name: 'UnknownError', - message: error.message, + await waitFor(() => { + expect(getVisitorData).toHaveBeenCalledWith(tag, 'test_id', 15_000) }) }) - it('should pass linkedId and tags to `getData` function', async () => { - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } - - const mockedTags = { - stringTag: 'foo', - numberTag: 0, - ObjectTag: { - foo: true, - bar: [1, 2, 3], - }, - boolTag: false, - } - - const mockedLinkedId = 'test_id' - - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) - ) - - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) - act(() => { - void result.current.getData(mockedTags, mockedLinkedId) + it('fetches automatically when `immediate` is true', async () => { + const { result } = renderHook(() => useVisitorData({ immediate: true, linkedId: 'auto' }), { + wrapper: createWrapper(), }) + await waitFor(() => { - expect(getVisitorData).toHaveBeenCalledWith(mockedTags, mockedLinkedId, null) + expect(result.current.isFetched).toBe(true) }) + expect(getVisitorData).toHaveBeenCalledWith(null, 'auto', null) + expect(result.current.data).toStrictEqual(expectedData) }) - it('options object with empty timeout should call `getVisitorData` function', async () => { - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } - - const mockedTags = { - stringTag: 'foo', - numberTag: 0, - ObjectTag: { - foo: true, - bar: [1, 2, 3], - }, - boolTag: false, - } - - const mockedLinkedId = 'test_id' - - const options: RequestOptions = { timeout: undefined } - - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) + it('re-runs automatically when the request options change while `immediate` is true', async () => { + const { result, rerender } = renderHook( + ({ linkedId }: { linkedId: string }) => useVisitorData({ immediate: true, linkedId }), + { wrapper: createWrapper(), initialProps: { linkedId: 'first' } } ) - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) - act(() => { - void result.current.getData(mockedTags, mockedLinkedId, options) + await waitFor(() => { + expect(result.current.isFetched).toBe(true) }) + expect(getVisitorData).toHaveBeenLastCalledWith(null, 'first', null) + expect(getVisitorData).toHaveBeenCalledTimes(1) + + rerender({ linkedId: 'second' }) + await waitFor(() => { - expect(getVisitorData).toHaveBeenCalledWith(mockedTags, mockedLinkedId, null) + expect(getVisitorData).toHaveBeenLastCalledWith(null, 'second', null) }) + expect(getVisitorData).toHaveBeenCalledTimes(2) }) - it('non-empty timeout should pass the timeout to `getVisitorData` function', async () => { - const mockedJsonAnswer = { - visitorId: mockedVisitorId, - } - - const mockedTags = { - stringTag: 'foo', - numberTag: 0, - ObjectTag: { - foo: true, - bar: [1, 2, 3], - }, - boolTag: false, - } - - const mockedLinkedId = 'test_id' - - const options: RequestOptions = { timeout: 15_000 } - - getVisitorData.mockReturnValueOnce( - Promise.resolve({ - requestId: mockedRequestId, - confidenceScore: mockedConfidenceScore, - visitorDataJson: JSON.stringify(mockedJsonAnswer), - }) + it('does not re-run when re-rendered with value-equal options but a fresh identity', async () => { + const { rerender } = renderHook( + ({ tag }: { tag: Record }) => useVisitorData({ immediate: true, tag }), + { wrapper: createWrapper(), initialProps: { tag: { userAction: 'login' } } } ) - const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData(), { wrapper }) - act(() => { - void result.current.getData(mockedTags, mockedLinkedId, options) - }) await waitFor(() => { - expect(getVisitorData).toHaveBeenCalledWith(mockedTags, mockedLinkedId, options.timeout) + expect(getVisitorData).toHaveBeenCalledTimes(1) }) + + // Same values, brand-new object identity (as would happen with an inline prop). + rerender({ tag: { userAction: 'login' } }) + + // Give any spurious effect a chance to fire before asserting it did not. + await Promise.resolve() + expect(getVisitorData).toHaveBeenCalledTimes(1) }) }) diff --git a/sdk/package.json b/sdk/package.json index a9a0f895..9e0ae781 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -59,7 +59,7 @@ "author": "FingerprintJS, Inc (https://fingerprint.com)", "license": "MIT", "devDependencies": { - "@fingerprintjs/fingerprintjs-pro-spa": "^1.3.2", + "@fingerprint/agent": "^4.1.3", "@react-native/babel-preset": "^0.85.3", "@react-native/eslint-config": "0.76.6", "@react-native/jest-preset": "^0.86.0", @@ -83,14 +83,14 @@ "typescript": "6.0.3" }, "peerDependencies": { - "@fingerprintjs/fingerprintjs-pro-spa": "^1.3.2", + "@fingerprint/agent": "^4.1.3", "expo": ">=53.0.0", "react": "*", "react-dom": "*", "react-native": ">=0.79.0" }, "peerDependenciesMeta": { - "@fingerprintjs/fingerprintjs-pro-spa": { + "@fingerprint/agent": { "optional": true }, "expo": { diff --git a/sdk/src/FingerprintContext.ts b/sdk/src/FingerprintContext.ts new file mode 100644 index 00000000..2dd8de66 --- /dev/null +++ b/sdk/src/FingerprintContext.ts @@ -0,0 +1,28 @@ +import { createContext } from 'react' +import type { FingerprintClient, FingerprintResponse, GetOptions } from './types' +import { FingerprintContextNotAvailableError } from './errors' + +const stub = (): never => { + throw new FingerprintContextNotAvailableError() +} + +/** + * The Fingerprint context. + * + * @group Hooks approach + */ +export interface FingerprintContextInterface { + /** + * The imperative client. `undefined` until a {@link FingerprintProvider} is mounted. + */ + client: FingerprintClient | undefined + /** + * Performs an identification request using the provider's client. + */ + getVisitorData: (options?: GetOptions) => Promise +} + +export const FingerprintContext = createContext({ + client: undefined, + getVisitorData: stub, +}) diff --git a/sdk/src/FingerprintJsProAgent.ts b/sdk/src/FingerprintJsProAgent.ts deleted file mode 100644 index bf4731a8..00000000 --- a/sdk/src/FingerprintJsProAgent.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { UnknownError } from './errors' -import RNFingerprintjsPro, { type NativeVisitorData } from './specs/NativeRNFingerprintjsPro' -import type { FingerprintJsProAgentParams, ProAgent, RequestOptions, Tags, VisitorData, VisitorId } from './types' -import { unwrapError } from './unwrapError' -import { isTruthy } from './utils' - -const packageVersion = '__VERSION__' - -/** - * - * @group API Client approach - */ -export class FingerprintJsProAgent implements ProAgent { - /** - * Initialises FingerprintJS Pro Agent with certain settings - * - * @param params - */ - private requestOptions: RequestOptions = {} - - constructor({ - apiKey, - region, - endpointUrl, - fallbackEndpointUrls = [], - extendedResponseFormat = false, - requestOptions = {}, - allowUseOfLocationData = false, - locationTimeoutMillisAndroid = 5000, - }: FingerprintJsProAgentParams) { - try { - RNFingerprintjsPro.configure( - apiKey, - packageVersion, - extendedResponseFormat, - fallbackEndpointUrls, - allowUseOfLocationData, - locationTimeoutMillisAndroid, - region ?? null, - endpointUrl ?? null - ) - this.requestOptions = requestOptions - } catch (e) { - console.error('RNFingerprintjsPro configure error: ', e) - } - } - - /** - * Returns visitor identifier based on the request options {@link https://dev.fingerprint.com/docs/native-android-integration#get-the-visitor-identifier | more info in the documentation page} - * - * @param tags is a customer-provided value or an object that will be saved together with the analysis event and will be returned back to you in a webhook message or when you search for the visit in the server API. {@link https://dev.fingerprint.com/docs/js-agent#tag | more info in the documentation page} - * @param linkedId is a way of linking current analysis event with a custom identifier. This will allow you to filter visit information when using the Server API {@link https://dev.fingerprint.com/docs/js-agent#linkedid | more info in the documentation page} - * @param options is used to configure requests with different settings - */ - public async getVisitorId(tags?: Tags, linkedId?: string, options?: RequestOptions): Promise { - try { - const timeout = options?.timeout ?? this.requestOptions.timeout - return await RNFingerprintjsPro.getVisitorId(tags ?? null, linkedId ?? null, timeout ?? null) - } catch (error) { - if (error instanceof Error) { - throw unwrapError(error) - } else { - throw new UnknownError(String(error)) - } - } - } - - /** - * Returns visitor identification data based on the request options {@link https://dev.fingerprint.com/docs/native-android-integration#get-the-visitor-identifier | more info in the documentation page} - * - * Provide `extendedResponseFormat` option in the {@link constructor} to get response in the {@link https://dev.fingerprint.com/docs/native-android-integration#response-format | extended format} - * - * @param tags is a customer-provided value or an object that will be saved together with the analysis event and will be returned back to you in a webhook message or when you search for the visit in the server API. {@link https://dev.fingerprint.com/docs/js-agent#tag | more info in the documentation page} - * @param linkedId is a way of linking current analysis event with a custom identifier. This will allow you to filter visit information when using the Server API {@link https://dev.fingerprint.com/docs/js-agent#linkedid | more info in the documentation page} - * @param options is used to configure requests with different settings - */ - public async getVisitorData(tags?: Tags, linkedId?: string, options?: RequestOptions): Promise { - try { - const timeout = options?.timeout ?? this.requestOptions.timeout - const visitorData: NativeVisitorData = await RNFingerprintjsPro.getVisitorData( - tags ?? null, - linkedId ?? null, - timeout ?? null - ) - const { requestId, confidenceScore, visitorDataJson, sealedResult } = visitorData - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const result = { - ...JSON.parse(visitorDataJson), - requestId, - confidence: { - score: confidenceScore, - }, - } as VisitorData - - if (isTruthy(sealedResult)) { - result['sealedResult'] = sealedResult - } - - return result - } catch (error) { - if (error instanceof Error) { - throw unwrapError(error) - } else { - throw new UnknownError(String(error)) - } - } - } -} diff --git a/sdk/src/FingerprintJsProAgent.web.ts b/sdk/src/FingerprintJsProAgent.web.ts deleted file mode 100644 index 5c7b2297..00000000 --- a/sdk/src/FingerprintJsProAgent.web.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { UnknownError } from './errors' -import type { FingerprintJsProAgentParams, ProAgent, RequestOptions, Tags, VisitorData, VisitorId } from './types' -import { FingerprintJSPro, FpjsClient } from '@fingerprintjs/fingerprintjs-pro-spa' -import { unwrapError } from './unwrapError' -import { isDefined } from './utils' - -const packageVersion = '__VERSION__' - -/** - * - * @group API Client approach - * @platform web - */ -export class FingerprintJsProAgent implements ProAgent { - private readonly requestOptions: RequestOptions = {} - - private readonly extendedResponseFormat: boolean = false - - private readonly client: FpjsClient - - private readonly agentPromise: Promise - - constructor({ - apiKey, - region, - endpointUrl, - fallbackEndpointUrls = [], - extendedResponseFormat = false, - requestOptions = {}, - scriptUrlPattern, - storageKey, - urlHashing, - remoteControlDetection, - cache, - cachePrefix, - cacheLocation, - cacheTimeInSeconds, - }: FingerprintJsProAgentParams) { - const endpoints: string[] = [] - if (isDefined(endpointUrl)) { - endpoints.push(endpointUrl) - } - endpoints.push(...fallbackEndpointUrls) - - this.client = new FpjsClient({ - loadOptions: { - apiKey, - region, - endpoint: endpoints.length > 0 ? endpoints : undefined, - scriptUrlPattern, - storageKey, - urlHashing, - remoteControlDetection, - integrationInfo: [`fingerprint-pro-react-native/${packageVersion}/web`], - }, - cache, - cachePrefix, - cacheLocation, - cacheTimeInSeconds, - }) - this.agentPromise = this.client.init() - this.requestOptions = requestOptions - this.extendedResponseFormat = extendedResponseFormat - } - - /** - * @inheritDoc - * */ - public async getVisitorId(tags?: Tags, linkedId?: string, options?: RequestOptions): Promise { - try { - const timeout = options?.timeout ?? this.requestOptions.timeout - - const agent = await this.agentPromise - const result = await agent.get({ - extendedResult: this.extendedResponseFormat, - tag: tags, - linkedId, - timeout, - }) - - return result.visitorId - } catch (error) { - if (error instanceof Error) { - throw unwrapError(error) - } - - throw new UnknownError(String(error)) - } - } - - /** - * @inheritDoc - * */ - public async getVisitorData(tags?: Tags, linkedId?: string, options?: RequestOptions): Promise { - try { - const timeout = options?.timeout ?? this.requestOptions.timeout - const agent = await this.agentPromise - - return await agent.get({ - tag: tags, - linkedId, - extendedResult: this.extendedResponseFormat, - timeout, - }) - } catch (error) { - if (error instanceof Error) { - throw unwrapError(error) - } else { - throw new UnknownError(String(error)) - } - } - } -} diff --git a/sdk/src/FingerprintJsProContext.ts b/sdk/src/FingerprintJsProContext.ts deleted file mode 100644 index d51a3f31..00000000 --- a/sdk/src/FingerprintJsProContext.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createContext } from 'react' -import { Tags, VisitorData, RequestOptions } from './types' - -const stub = (): never => { - throw new Error('You forgot to wrap your component in .') -} - -const initialContext = { - visitorId: '', - getVisitorData: stub, -} - -export interface FingerprintJsProContextInterface { - visitorId: string - getVisitorData: (tags?: Tags, linkedId?: string, options?: RequestOptions) => Promise -} - -export const FingerprintJsProContext = createContext(initialContext) diff --git a/sdk/src/FingerprintJsProProvider.tsx b/sdk/src/FingerprintJsProProvider.tsx deleted file mode 100644 index 5b8830bc..00000000 --- a/sdk/src/FingerprintJsProProvider.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { FingerprintJsProAgent } from './FingerprintJsProAgent' -import { FingerprintJsProContext } from './FingerprintJsProContext' -import { FingerprintJsProAgentParams, RequestOptions, Tags } from './types' -import { deepEqual } from './utils' - -/** - * Provides the FingerprintJsProContext to its child components. - * - * @example - * ```jsx - * - * - * - * ``` - * @group Hooks approach - */ -export function FingerprintJsProProvider({ - children, - ...fingerprintJsProAgentParams -}: PropsWithChildren) { - // `fingerprintJsProAgentParams` is a fresh object on every render (rest spread), so we cannot depend on - // its identity. Keep a stable reference that only changes when the params change by value. This also - // spares consumers from having to memoize inline object/array props (e.g. `requestOptions`). - const [stableAgentParams, setStableAgentParams] = useState(fingerprintJsProAgentParams) - if ( - !Object.is(stableAgentParams, fingerprintJsProAgentParams) && - !deepEqual(stableAgentParams, fingerprintJsProAgentParams) - ) { - setStableAgentParams(fingerprintJsProAgentParams) - } - - const [client, setClient] = useState(() => new FingerprintJsProAgent(stableAgentParams)) - const [visitorId, setVisitorId] = useState('') - - const getVisitorData = useCallback( - async (tags?: Tags, linkedId?: string, requestOptions?: RequestOptions) => { - const result = await client.getVisitorData(tags, linkedId, requestOptions) - setVisitorId(result.visitorId) - return result - }, - [client] - ) - - const firstRenderRef = useRef(true) - - useEffect(() => { - if (firstRenderRef.current) { - firstRenderRef.current = false - } else { - setClient(new FingerprintJsProAgent(stableAgentParams)) - } - }, [stableAgentParams]) - - const contextValue = useMemo(() => { - return { - visitorId, - getVisitorData, - } - }, [visitorId, getVisitorData]) - - return {children} -} diff --git a/sdk/src/FingerprintProvider.tsx b/sdk/src/FingerprintProvider.tsx new file mode 100644 index 00000000..2ca1e7b1 --- /dev/null +++ b/sdk/src/FingerprintProvider.tsx @@ -0,0 +1,48 @@ +import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { start } from './FingerprintClient' +import { FingerprintContext } from './FingerprintContext' +import type { GetOptions, StartOptions } from './types' +import { deepEqual } from './utils' + +/** + * Provides the {@link FingerprintContext} to its child components. + * + * @group Hooks approach + * + * @example + * ```jsx + * + * + * + * ``` + */ +export function FingerprintProvider({ children, ...options }: PropsWithChildren) { + // `options` is a fresh object on every render (rest spread), so we cannot depend on its identity. + // Keep a stable reference that only changes when the options change by value. This also spares + // consumers from having to memoize inline object props (e.g. `android`, `web`). + const [stableOptions, setStableOptions] = useState(options) + if (!Object.is(stableOptions, options) && !deepEqual(stableOptions, options)) { + setStableOptions(options) + } + + const [client, setClient] = useState(() => start(stableOptions)) + + const firstRenderRef = useRef(true) + useEffect(() => { + if (firstRenderRef.current) { + firstRenderRef.current = false + } else { + setClient(start(stableOptions)) + } + }, [stableOptions]) + + const getVisitorData = useCallback((getOptions?: GetOptions) => client.get(getOptions), [client]) + + const contextValue = useMemo(() => ({ client, getVisitorData }), [client, getVisitorData]) + + return {children} +} diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index 45922fce..a273965e 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -1,364 +1,56 @@ -/** - * Something was wrong while building URL for identification request - * - * @group Errors - */ -export class InvalidUrlError extends Error { - constructor(message: string) { - super(message) - this.name = 'InvalidURL' - } -} - -/** - * Something was wrong with params while building URL for identification request - * - * @group Errors - */ -export class InvalidURLParamsError extends Error { - constructor(message: string) { - super(message) - this.name = 'InvalidURLParams' - } -} - -/** - * Unknown API error - * - * @group Errors - */ -export class ApiError extends Error { - constructor(message: string) { - super(message) - this.name = 'ApiError' - } -} - -/** - * Token is missing in the request - * - * @group Errors - */ -export class ApiKeyRequiredError extends Error { - constructor(message: string) { - super(message) - this.name = 'ApiKeyRequiredError' - } -} - -/** - * Wrong token - * - * @group Errors - */ -export class ApiKeyNotFoundError extends Error { - constructor(message: string) { - super(message) - this.name = 'ApiKeyNotFoundError' - } -} - -/** - * API token is outdated - * - * @group Errors - */ -export class ApiKeyExpiredError extends Error { - constructor(message: string) { - super(message) - this.name = 'ApiKeyExpiredError' - } -} - -/** - * Wrong request format (content, parameters) - * - * @group Errors - */ -export class RequestCannotBeParsedError extends Error { - constructor(message: string) { - super(message) - this.name = 'RequestCannotBeParsedError' - } -} - -/** - * Request failed - * - * @group Errors - */ -export class FailedError extends Error { - constructor(message: string) { - super(message) - this.name = 'FailedError' - } -} - -/** - * Server timeout - * - * @group Errors - */ -export class RequestTimeoutError extends Error { - constructor(message: string) { - super(message) - this.name = 'RequestTimeoutError' - } -} - -/** - * Request limit for token reached - * - * @group Errors - */ -export class TooManyRequestError extends Error { - constructor(message: string) { - super(message) - this.name = 'TooManyRequestError' - } -} - -/** - * Request Filtering deny origin - * - * @group Errors - */ -export class OriginNotAvailableError extends Error { - constructor(message: string) { - super(message) - this.name = 'OriginNotAvailableError' - } -} - -/** - * Request Filtering deny some headers - * - * @group Errors - */ -export class HeaderRestrictedError extends Error { - constructor(message: string) { - super(message) - this.name = 'HeaderRestrictedError' - } -} - -/** - * Request Filtering deny this hostname - * - * @group Errors - */ -export class HostnameRestrictedError extends Error { - constructor(message: string) { - super(message) - this.name = 'HostnameRestrictedError' - } -} - -/** - * Request Filtering deny crawlers - * - * @group Errors - */ -export class NotAvailableForCrawlBotsError extends Error { - constructor(message: string) { - super(message) - this.name = 'NotAvailableForCrawlBotsError' - } -} - -/** - * Request Filtering deny empty UA - * - * @group Errors - */ -export class NotAvailableWithoutUAError extends Error { - constructor(message: string) { - super(message) - this.name = 'NotAvailableWithoutUAError' - } -} - -/** - * API key does not match the selected region - * - * @group Errors - */ -export class WrongRegionError extends Error { - constructor(message: string) { - super(message) - this.name = 'WrongRegionError' - } -} - -/** - * Subscription is not active for the provided API key - * - * @group Errors - */ -export class SubscriptionNotActiveError extends Error { - constructor(message: string) { - super(message) - this.name = 'SubscriptionNotActiveError' - } -} - -/** - * Something wrong with used API version - * - * @group Errors - */ -export class UnsupportedVersionError extends Error { - constructor(message: string) { - super(message) - this.name = 'UnsupportedVersionError' - } -} - -/** - * - * - * @group Errors - */ -export class InstallationMethodRestrictedError extends Error { - constructor(message: string) { - super(message) - this.name = 'InstallationMethodRestrictedError' - } -} - -/** - * Error while parsing the response - * - * @group Errors - */ -export class ResponseCannotBeParsedError extends Error { - constructor(message: string) { - super(message) - this.name = 'ResponseCannotBeParsedError' +export class FingerprintContextNotAvailableError extends Error { + constructor() { + super('FingerprintContext not available. Did you forget to wrap your component in ?') } } /** - * Something wrong with network + * The single error type thrown by the SDK on every platform. * - * @group Errors - */ -export class NetworkError extends Error { - constructor(message: string) { - super(message) - this.name = 'NetworkError' - } -} - -/** - * Error while parsing JSON response + * It mirrors the error of `@fingerprint/agent`: a machine-friendly {@link FingerprintError.code} + * plus a resolution-oriented `message`. This replaces the ~30 error classes exported in v3. * * @group Errors - */ -export class JsonParsingError extends Error { - constructor(message: string) { - super(message) - this.name = 'JsonParsingError' - } -} - -/** - * Bad response type * - * @group Errors + * @example + * ```ts + * try { + * await client.get() + * } catch (error) { + * if (isFingerprintError(error) && error.code === 'too_many_requests') { + * // handle rate limiting + * } + * } + * ``` */ -export class InvalidResponseTypeError extends Error { - constructor(message: string) { - super(message) - this.name = 'InvalidResponseType' - } -} +export class FingerprintError extends Error { + public readonly name = 'FingerprintError' -/** - * Client-side timeout error - * - * @group Errors - */ -export class ClientTimeoutError extends Error { - constructor(message: string) { - super(message) - this.name = 'ClientTimeoutError' - } -} + /** + * A machine-friendly code for the type of this error, e.g. `too_many_requests`. + */ + public readonly code: string -/** - * Other error - * - * @group Errors - */ -export class UnknownError extends Error { - constructor(message: string) { - super(message) - this.name = 'UnknownError' - } -} + /** + * A unique id to refer to the error. Available only for errors that originate on the server side, + * otherwise `null`. + */ + public readonly event_id: string | null -/** - * Unexpected proxy integration implementation or networking error - * - * @group Errors - */ -export class InvalidProxyIntegrationHeadersError extends Error { - constructor(message: string) { - super(message) - this.name = 'InvalidProxyIntegrationHeadersError' - } -} - -/** - * Proxy integration secret is missing or invalid - * - * @group Errors - */ -export class InvalidProxyIntegrationSecretError extends Error { - constructor(message: string) { + constructor({ code, message, event_id }: { code: string; message?: string; event_id?: string | null }) { super(message) - this.name = 'InvalidProxyIntegrationSecretError' + this.code = code + this.event_id = event_id ?? null + // Restore the prototype chain so `instanceof` works after transpilation to ES5-era targets. + Object.setPrototypeOf(this, FingerprintError.prototype) } } /** - * Proxy integration secret is from the different env with Public key + * Type guard to check whether an error is a {@link FingerprintError}. * * @group Errors */ -export class ProxyIntegrationSecretEnvironmentMismatch extends Error { - constructor(message: string) { - super(message) - this.name = 'ProxyIntegrationSecretEnvironmentMismatch' - } +export function isFingerprintError(error: unknown): error is FingerprintError { + return error instanceof FingerprintError || (error instanceof Error && error.name === 'FingerprintError') } - -export type IdentificationError = - | InvalidUrlError - | InvalidURLParamsError - | ApiError - | ApiKeyRequiredError - | ApiKeyNotFoundError - | ApiKeyExpiredError - | RequestCannotBeParsedError - | FailedError - | RequestTimeoutError - | TooManyRequestError - | OriginNotAvailableError - | HeaderRestrictedError - | NotAvailableForCrawlBotsError - | NotAvailableWithoutUAError - | WrongRegionError - | SubscriptionNotActiveError - | UnsupportedVersionError - | InstallationMethodRestrictedError - | ResponseCannotBeParsedError - | NetworkError - | JsonParsingError - | InvalidResponseTypeError - | ClientTimeoutError - | UnknownError - | InvalidProxyIntegrationHeadersError - | InvalidProxyIntegrationSecretError - | ProxyIntegrationSecretEnvironmentMismatch diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 43f8fe67..9533c5e6 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,50 +1,9 @@ -export { FingerprintJsProAgent } from './FingerprintJsProAgent' -export { FingerprintJsProProvider } from './FingerprintJsProProvider' -export { useVisitorData } from './useVisitorData' +export * from './FingerprintClient' +export * from './FingerprintProvider' +export * from './FingerprintContext' +export * from './useVisitorData' +export * from './useFingerprint' -export type { - FingerprintJsProAgentParams, - VisitorQueryContext, - GetDataOptions, - Region, - Tags, - Tag, - VisitorData, - ShortVisitorData, - ExtendedVisitorData, - Confidence, - SeenAt, - IpLocation, - RequestOptions, -} from './types' +export * from './errors' -export { - InvalidUrlError, - InvalidURLParamsError, - ApiError, - ApiKeyRequiredError, - ApiKeyNotFoundError, - ApiKeyExpiredError, - RequestCannotBeParsedError, - FailedError, - RequestTimeoutError, - TooManyRequestError, - OriginNotAvailableError, - HeaderRestrictedError, - NotAvailableForCrawlBotsError, - NotAvailableWithoutUAError, - WrongRegionError, - SubscriptionNotActiveError, - UnsupportedVersionError, - InstallationMethodRestrictedError, - ResponseCannotBeParsedError, - NetworkError, - JsonParsingError, - InvalidResponseTypeError, - ClientTimeoutError, - UnknownError, - HostnameRestrictedError, - InvalidProxyIntegrationHeadersError, - InvalidProxyIntegrationSecretError, - ProxyIntegrationSecretEnvironmentMismatch, -} from './errors' +export * from './types' diff --git a/sdk/src/specs/NativeRNFingerprintjsPro.ts b/sdk/src/specs/NativeRNFingerprintjsPro.ts index d1baea39..653a3282 100644 --- a/sdk/src/specs/NativeRNFingerprintjsPro.ts +++ b/sdk/src/specs/NativeRNFingerprintjsPro.ts @@ -3,16 +3,17 @@ import { TurboModuleRegistry } from 'react-native' import type { Double, UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes' /** - * Raw visitor data returned by the native module. The visitor payload is delivered as a JSON - * string (`visitorDataJson`) that the SDK parses and merges with the top-level fields. + * Raw visitor data returned by the native module. * - * Codegen requires the numeric field to be typed as {@link Double} and every field to be - * non-nullable, so `sealedResult` is an empty string (rather than absent) when unavailable. + * Codegen requires every field to be non-nullable and numbers to be typed as {@link Double}, so the + * optional API v4 fields use sentinels the JS layer normalizes away: + * - `suspectScore` is `-1` when the field is absent (Smart Signals disabled). + * - `sealedResult` is an empty string when unavailable. */ export interface NativeVisitorData { - requestId: string - confidenceScore: Double - visitorDataJson: string + visitorId: string + eventId: string + suspectScore: Double sealedResult: string } @@ -27,7 +28,6 @@ export interface Spec extends TurboModule { configure( apiToken: string, pluginVersion: string, - extendedResponseFormat: boolean, fallbackEndpointUrls: string[], allowUseOfLocationData: boolean, locationTimeoutMillis: Double, @@ -35,9 +35,7 @@ export interface Spec extends TurboModule { endpointUrl: string | null ): void - getVisitorId(tags: UnsafeObject | null, linkedId: string | null, timeout: Double | null): Promise - - getVisitorData(tags: UnsafeObject | null, linkedId: string | null, timeout: Double | null): Promise + getVisitorData(tag: UnsafeObject | null, linkedId: string | null, timeout: Double | null): Promise } export default TurboModuleRegistry.getEnforcing('RNFingerprintjsPro') diff --git a/sdk/src/types.ts b/sdk/src/types.ts index f5869a4f..02fcd125 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -1,408 +1,299 @@ -import type { CacheLocation, FpjsClientOptions, ICache } from '@fingerprintjs/fingerprintjs-pro-spa' +import type { FingerprintError } from './errors' -export interface ProAgent { - /** - * Returns visitor identifier based on the request options. - * - * @param tags - Custom tags attached to the event. - * @param linkedId - Event linkage identifier. - * @param options - Custom request options. - */ - getVisitorId(tags?: Tags, linkedId?: string, options?: RequestOptions): Promise +/** + * The {@link https://dev.fingerprint.com/docs/regions | region} of your application. + * The parameter is optional because the agent detects the region automatically using the provided + * API key. Nevertheless, we recommend always specifying it. Otherwise, the default region is `us`. + * + * @group Types and interfaces + */ +export type Region = 'us' | 'eu' | 'ap' - /** - * Returns visitor identification data based on the request options. - * - * @param tags - Custom tags attached to the event. - * @param linkedId - Event linkage identifier. - * @param options - Custom request options. - */ - getVisitorData(tags?: Tags, linkedId?: string, options?: RequestOptions): Promise -} +/** + * One or many endpoint URLs. + * If multiple URLs are given, the agent will try them one by one until it finds a working one. + * + * @group Types and interfaces + */ +export type EndpointUrl = string | string[] -export interface RequestOptions { - /** - * Custom timeout for the request in milliseconds - */ - timeout?: number -} /** - * Configuration options for the {@link FingerprintJsProProvider} and {@link FingerprintJsProAgent} + * Options shared across every platform (web, iOS, Android). * * @group Types and interfaces */ -export interface FingerprintJsProAgentParams { +export interface SharedStartOptions { /** - * your public API key that authenticates the agent with the API + * Your public API key that authenticates the agent with the Fingerprint API. */ apiKey: string /** - * which region to use + * Which region to use. */ region?: Region /** - * server API URL, should be only used with the Custom subdomain - */ - endpointUrl?: string - /** - * provide array of endpoints to specify fallbacks + * API endpoint(s). Should only be used with a custom subdomain or proxy integration. */ - fallbackEndpointUrls?: string[] + endpoints?: EndpointUrl +} + +/** + * Android-only options. + * + * @group Types and interfaces + */ +export interface AndroidStartOptions { /** - * set this flag to get response in extended format + * Set this flag to allow the SDK to collect location data. */ - extendedResponseFormat?: boolean + allowUseOfLocationData?: boolean /** - * Custom request options + * Location retrieval timeout in milliseconds. + * The SDK will delay identification up to the specified timeout to collect the device location. + * If it cannot collect the location information within the specified time, identification continues + * without location information. + * + * @default 5000 */ - requestOptions?: RequestOptions + locationTimeoutMillis?: number +} + +/** + * iOS-only options. + * + * @group Types and interfaces + */ +export interface IosStartOptions { /** - * set this flag to allow Fingerprint SDK to collect location data on Mobile platforms + * Set this flag to allow the SDK to collect location data. */ allowUseOfLocationData?: boolean - /** - * location retrieval timeout for Android platform - * The SDK will delay identification up to the specified timeout to collect the device location. - * If it cannot collect the location information within the specified time, - * identification continues without location information. - */ - locationTimeoutMillisAndroid?: number +} + +/** + * Hashes URL parts before sending them to the Fingerprint server. + * Сan be used to hide sensitive data (tokens, payment info, etc) that these URLs may contain. + * + * @group Types and interfaces + */ +export interface UrlHashing { + /** Set to `true` to hash the path part of the URL (between the origin and `?`) */ + path?: boolean + /** Set to `true` to hash the query part of the URL (between `?` and `#`) */ + query?: boolean + /** Set to `true` to hash the fragment part of the URL (after `#`) */ + fragment?: boolean +} +/** + * Configuration for caching agent results on web. Disabled by default when not provided. + * + * @group Types and interfaces + */ +export interface CacheConfig { + /** The storage location for the cache. */ + storage: 'sessionStorage' | 'localStorage' | 'agent' /** - * The pattern of the JS agent script URL. - * If multiple endpoints are given, the agent will try them one by one until it finds a working one. - * If an empty array is given, the agent will throw an error. - * - * @platform web + * The cache duration in seconds. Can be a predefined value + * (`'optimize-cost'` = 1 hour, `'aggressive'` = 12 hours) or a custom number. Max value is `43200` (12 hours). */ - scriptUrlPattern?: FpjsClientOptions['loadOptions']['scriptUrlPattern'] + duration: 'optimize-cost' | 'aggressive' | number + /** Optional prefix for cache keys. Defaults to none. */ + cachePrefix?: string +} +/** + * Web-only options. Mirrors the `StartOptions` of `@fingerprint/agent`. + * + * @group Types and interfaces + */ +export interface WebStartOptions { /** * Override storages name (cookies, localStorage, etc). * Should only be used when the default name conflicts with some of your existing names. - * @default '_vid' * - * @platform web + * @default '_vid_' */ - storageKey?: FpjsClientOptions['loadOptions']['storageKey'] - + storageKeyPrefix?: string /** - * Hashes URL parts before sending them to Fingerprint the server. - * The sources of URLs: window.location.href, document.referrer. - * Сan be used to hide sensitive data (tokens, payment info, etc) that these URLs may contain. - * - * Example of URL stripping 'https://example.com/path?token=secret#anchor' -> 'https://example.com/oK-fhlv2N-ZzaBf0zlUuTN97jDbqdbwlTB0fCvdEtb8?E1kifZXhuoBEZ_zkQa60jyxcaHNX3QFaydaaIEtL7j0#eb-w4rp2udRHYG3bzElINPBaTBHesFLnS0nqMHo8W80' - * - * @platform web + * Hashes URL parts before sending them to the Fingerprint server. */ - urlHashing?: FpjsClientOptions['loadOptions']['urlHashing'] - + urlHashing?: UrlHashing /** * Enables data collection for remote control detection. - * Once enabled, please contact our support team to active the result exposure. - * @see https://fingerprint.com/support/ + * Once enabled, please contact our support team to activate the result exposure. * * @default false - * * @see https://dev.fingerprint.com/docs/smart-signals-overview#remote-control-tools-detection - * - * @platform web - */ - remoteControlDetection?: FpjsClientOptions['loadOptions']['remoteControlDetection'] - - /** - * Defines which built-in cache mechanism the client should use. - * - * @platform web - */ - cacheLocation?: CacheLocation - - /** - * Custom cache implementation. Takes precedence over the `cacheLocation` property. - * - * @platform web - */ - cache?: ICache - - /** - * Duration in seconds for which data is stored in cache. Cannot exceed 86_400 (24h) because caching data - * for longer than 24 hours can negatively affect identification accuracy. - * - * @platform web */ - cacheTimeInSeconds?: number + remoteControlDetection?: boolean /** - * Custom prefix for localStorage and sessionStorage cache keys. Will be ignored if `cache` is provided. - * - * @platform web + * Enables caching the result of the `get` call. */ - cachePrefix?: string + cache?: CacheConfig } -export interface QueryResult { +/** + * Configuration options for {@link start} and {@link FingerprintProvider}. + * + * Shared options live at the top level; platform-only options are grouped under `android`, `ios`, + * and `web` so an option cannot silently no-op on a platform that does not support it. + * + * @group Types and interfaces + */ +export interface StartOptions extends SharedStartOptions { /** - * Visitor identification data + * Android-only options. */ - data?: TData + android?: AndroidStartOptions /** - * Request state after calling `getData()` + * iOS-only options. */ - isLoading?: boolean + ios?: IosStartOptions /** - * Error message in case of failed `getData()` call + * Web-only options. */ - error?: TError + web?: WebStartOptions } /** - * The {@link https://dev.fingerprint.com/docs/regions | region} of your application. - * The parameter is fully optional because JS agent detects the regions automatically using the provided API key. - * Nevertheless, we recommend always specifying the parameter. Otherwise, the default region us. + * Any simple value or an object (not arrays). The size must not exceed 16KB. + * * @group Types and interfaces */ -export type Region = 'eu' | 'us' | 'ap' +export type TagPrimitive = string | number | boolean /** - * Visitor identification data - * * @group Types and interfaces */ -export type VisitorData = ShortVisitorData | ExtendedVisitorData +export interface TagObject { + [key: string]: TagValue | TagValue[] +} /** - * Main identification information about the visitor + * `Tag` is a user-provided value or object that will be saved together with the analysis event and + * returned back to you in a webhook message or when searching for the visit in the Server API. + * * @group Types and interfaces */ -export interface ShortVisitorData { - /** - * The visitor identifier - */ - visitorId: string - /** - * The current request identifier. It's different for every request. - */ - requestId: string - /** - * A confidence score that tells how much the agent is sure about the visitor identifier - */ - confidence: Confidence - /** - * See more details in the Sealed Client Results(https://dev.fingerprint.com/docs/sealed-client-results) guide. The field will miss if Sealed Client Results are disabled or unavailable for another reason. - */ - sealedResult?: string -} +export type TagValue = null | TagPrimitive | TagObject /** - * Represents the probability of accurate identification. + * Alias of {@link TagValue}. * * @group Types and interfaces */ -export interface Confidence { - /** - * A number between 0 and 1 that tells how much the agent is sure about the visitor identifier. - * The higher the number, the higher the chance of the visitor identifier to be true. - */ - score: number - /** - * Additional details about the score as a human-readable text - */ - comment?: string -} +export type Tag = TagValue /** - * All known identification information about the visitor + * Options for a single identification request. * * @group Types and interfaces */ -export interface ExtendedVisitorData extends ShortVisitorData { - /** - * If true, this visitor was found and visited before. - * If false, this visitor wasn't found and probably didn't visit before. - */ - visitorFound: boolean +export interface GetOptions { /** - * IP address. Only IPv4 are returned. - * @example - * '191.14.35.17' + * A way of linking the current identification event with a custom identifier. This can be helpful + * to be able to filter API visit information later. */ - ip: string + linkedId?: string /** - * IP address location. Can be empty for anonymous proxies + * A user-provided value or object that will be returned back to you in a webhook message. */ - ipLocation?: IpLocation + tag?: TagValue /** - * OS name. - * @example - * 'iOS' - * @example - * 'Android' + * Custom timeout for the request, in milliseconds. */ - os: string - /** - * OS version - * @example - * '10.13.6' - */ - osVersion: string - /** - * Device. - * @example - * 'Samsung SM-J330F' - */ - device: string - /** - * When the visitor was seen for the first time - */ - firstSeenAt: SeenAt - /** - * When the visitor was seen previous time - */ - lastSeenAt: SeenAt + timeout?: number } /** - * {@link https://dev.fingerprint.com/docs/geolocation | IP address location}. Can be empty for anonymous proxies. + * Visitor identification data returned by the Fingerprint API v4. + * + * The shape is snake_case (matching the Server API v4 and the JS agent), so it can be forwarded to + * your backend unchanged and is identical across web, iOS, and Android. * * @group Types and interfaces */ -export interface IpLocation { - /** - * IP address location detection radius. Smaller values (<50mi) are business/residential, - * medium values (50 < x < 500) are cellular towers (usually), - * larger values (>= 500) are cloud IPs or proxies, VPNs. - * Can be missing, in case of Tor/proxies. - */ - accuracyRadius?: number - /** - * Latitude - * Can be missing, in case of Tor/proxies. - * @example - * -19.8975 - */ - latitude?: number +export interface FingerprintResponse { /** - * Longitude - * Can be missing, in case of Tor/proxies. - * @example - * -43.9625 + * The visitor identifier. */ - longitude?: number + visitor_id: string /** - * Timezone of the IP address location - * @example - * 'America/Chicago' + * A unique id associated with the successful identification request. */ - timezone?: string + event_id: string /** - * Postal code, when available + * Suspect score. Present only if Smart Signals are enabled. */ - postalCode?: string + suspect_score?: number /** - * City, when available - */ - city?: { - name: string - } - /** - * Administrative subdivisions array (for example states|provinces -> counties|parishes). - * Can be empty or missing. - * When not empty, can contain only top-level administrative units within a country, e.g. a state. - */ - subdivisions?: [ - { - isoCode: string - name: string - }, - ] - /** - * Country, when available. Will be missing for Tor/anonymous proxies. - */ - country?: { - name: string - code: string - } - /** - * Continent, when available. Will be missing for Tor/anonymous proxies. + * Sealed result - the encrypted `/events` Server API response for this `event_id`, encoded in + * base64. `null` if Sealed Results are disabled or unavailable. + * + * @see https://dev.fingerprint.com/docs/sealed-client-results */ - continent?: { - name: string - code: string - } + sealed_result: string | null } /** + * The imperative client returned by {@link start}. * - * @group Types and interfaces + * @group API Client approach */ -export interface SeenAt { - /** - * The date and time within your subscription. The string format is ISO-8601. - * @example - * '2022-03-16T05:18:24.610Z' - * new Date(result.firstSeenAt.subscription) - */ - subscription: string | null +export interface FingerprintClient { /** - * The date and time across all subscription. The string format is ISO-8601. - * @example - * '2022-03-16T05:18:24.610Z' - * new Date(result.firstSeenAt.global) + * Performs an identification request and returns the visitor data. + * Rejects with a {@link FingerprintError} when identification fails. */ - global: string | null + get(options?: GetOptions): Promise } /** - * Tags are returned in the webhook response so make sure the map you are passing to the library represents a valid JSON. - * - * @group Types and interfaces + * @group Hooks approach */ -export type Tags = { [K in string]: Tag | Tag[] } +export type QueryIdle = { + data: undefined + isLoading: false + isFetched: false + error: undefined +} /** - * Tags are returned in the webhook response so make sure the map you are passing to the library represents a valid JSON. - * - * @group Types and interfaces + * @group Hooks approach */ -export type Tag = string | number | boolean | Tags - -export interface VisitorQueryResult extends QueryResult { - /** - * Visitor identification data - */ - data?: VisitorData +export type QueryLoading = { + data: undefined + isLoading: true + isFetched: false + error: undefined } /** - * Configuration for {@link VisitorQueryContext.getData}. - * * @group Hooks approach */ -export interface GetDataOptions extends RequestOptions { - /** - * Whether `getData` rejects when identification fails. - * The error is also stored in the query state. - * - * Defaults to `false`. - */ - throwOnError?: boolean +export type QueryFetched = { + data: TData + isLoading: false + isFetched: true + error: undefined } /** * @group Hooks approach */ -export interface VisitorQueryContext extends VisitorQueryResult { - /** - * Retrieve visitor identification data using your public API key. - * - * By default, errors are stored in {@link VisitorQueryResult.error} and this method returns `null` - * in case of error. - * Set {@link GetDataOptions.throwOnError} to `true` to rethrow and reject the returned promise. - * - * @param tags is a customer-provided value or an object that will be saved together with the analysis event and will be returned back to you in a webhook message or when you search for the visit in the server API. {@link https://dev.fingerprint.com/docs/js-agent#tag | more info in the documentation page} - * @param linkedId is a way of linking current analysis event with a custom identifier. This will allow you to filter visit information when using the Server API {@link https://dev.fingerprint.com/docs/js-agent#linkedid | more info in the documentation page} - * @param options configures the request and error handling. - */ - getData: (tags?: Tags, linkedId?: string, options?: GetDataOptions) => Promise +export type QueryError = { + data: undefined + isLoading: false + isFetched: false + error: TError } -export type VisitorId = string +/** + * Discriminated union describing the state of an identification query. Checking `isLoading`, + * `isFetched`, or `error` narrows `data` to the correct type. + * + * @group Hooks approach + */ +export type QueryResult = + QueryIdle | QueryLoading | QueryFetched | QueryError diff --git a/sdk/src/unwrapError.ts b/sdk/src/unwrapError.ts index 58752a21..07607192 100644 --- a/sdk/src/unwrapError.ts +++ b/sdk/src/unwrapError.ts @@ -1,104 +1,26 @@ -import { - ApiError, - ApiKeyExpiredError, - ApiKeyNotFoundError, - ApiKeyRequiredError, - ClientTimeoutError, - FailedError, - HeaderRestrictedError, - HostnameRestrictedError, - IdentificationError, - InstallationMethodRestrictedError, - InvalidProxyIntegrationHeadersError, - InvalidProxyIntegrationSecretError, - InvalidResponseTypeError, - InvalidUrlError, - InvalidURLParamsError, - JsonParsingError, - NetworkError, - NotAvailableForCrawlBotsError, - NotAvailableWithoutUAError, - OriginNotAvailableError, - ProxyIntegrationSecretEnvironmentMismatch, - RequestCannotBeParsedError, - RequestTimeoutError, - ResponseCannotBeParsedError, - SubscriptionNotActiveError, - TooManyRequestError, - UnknownError, - UnsupportedVersionError, - WrongRegionError, -} from './errors' +import { FingerprintError } from './errors' -export function unwrapError(error: Error): IdentificationError { - const [errorType, ...errorMessageParts] = error.message.split(':') - const errorMessage = errorMessageParts.join(':') - console.log('unwrapError', { - errorType, - errorMessageParts, - errorMessage, - }) - switch (errorType) { - case 'InvalidURL': - return new InvalidUrlError(errorMessage) - case 'InvalidURLParams': - return new InvalidURLParamsError(errorMessage) - case 'ApiError': - return new ApiError(errorMessage) - // Api Errors block - case 'ApiKeyRequired': - case 'TokenRequired': - return new ApiKeyRequiredError(errorMessage) - case 'ApiKeyNotFound': - case 'TokenNotFound': - return new ApiKeyNotFoundError(errorMessage) - case 'ApiKeyExpired': - case 'TokenExpired': - return new ApiKeyExpiredError(errorMessage) - case 'RequestCannotBeParsed': - return new RequestCannotBeParsedError(errorMessage) - case 'Failed': - return new FailedError(errorMessage) - case 'RequestTimeout': - return new RequestTimeoutError(errorMessage) - case 'TooManyRequest': - return new TooManyRequestError(errorMessage) - case 'OriginNotAvailable': - return new OriginNotAvailableError(errorMessage) - case 'HeaderRestricted': - return new HeaderRestrictedError(errorMessage) - case 'HostnameRestricted': - return new HostnameRestrictedError(errorMessage) - case 'NotAvailableForCrawlBots': - return new NotAvailableForCrawlBotsError(errorMessage) - case 'NotAvailableWithoutUA': - return new NotAvailableWithoutUAError(errorMessage) - case 'WrongRegion': - return new WrongRegionError(errorMessage) - case 'SubscriptionNotActive': - return new SubscriptionNotActiveError(errorMessage) - case 'UnsupportedVersion': - return new UnsupportedVersionError(errorMessage) - case 'InstallationMethodRestricted': - return new InstallationMethodRestrictedError(errorMessage) - case 'ResponseCannotBeParsed': - return new ResponseCannotBeParsedError(errorMessage) - case 'InvalidProxyIntegrationHeaders': - return new InvalidProxyIntegrationHeadersError(errorMessage) - case 'InvalidProxyIntegrationSecret': - return new InvalidProxyIntegrationSecretError(errorMessage) - case 'ProxyIntegrationSecretEnvironmentMismatch': - return new ProxyIntegrationSecretEnvironmentMismatch(errorMessage) - // end of API Errors block - case 'ClientTimeout': - return new ClientTimeoutError(errorMessage) - case 'NetworkError': - return new NetworkError(errorMessage) - case 'JsonParsingError': - return new JsonParsingError(errorMessage) - case 'InvalidResponseType': - return new InvalidResponseTypeError(errorMessage) - default: - return new UnknownError(error.message) +/** + * Native implementation. + * + * The native modules reject with a message in the form `":"`, where `` is the + * API v4 error code (e.g. `too_many_requests`). We split it back into a {@link FingerprintError}. + */ +export function unwrapError(error: unknown): FingerprintError { + if (error instanceof FingerprintError) { + return error } + + if (error instanceof Error) { + const separatorIndex = error.message.indexOf(':') + if (separatorIndex === -1) { + return new FingerprintError({ code: 'unknown_error', message: error.message }) + } + + const code = error.message.slice(0, separatorIndex).trim() + const message = error.message.slice(separatorIndex + 1).trim() + return new FingerprintError({ code: code || 'unknown_error', message: message || error.message }) + } + + return new FingerprintError({ code: 'unknown_error', message: String(error) }) } diff --git a/sdk/src/unwrapError.web.ts b/sdk/src/unwrapError.web.ts index 400e53ae..a9f07d17 100644 --- a/sdk/src/unwrapError.web.ts +++ b/sdk/src/unwrapError.web.ts @@ -1,101 +1,25 @@ -import { - ApiKeyExpiredError, - ApiKeyNotFoundError, - ApiKeyRequiredError, - ClientTimeoutError, - FailedError, - HeaderRestrictedError, - HostnameRestrictedError, - IdentificationError, - InstallationMethodRestrictedError, - InvalidProxyIntegrationHeadersError, - InvalidProxyIntegrationSecretError, - NetworkError, - OriginNotAvailableError, - ProxyIntegrationSecretEnvironmentMismatch, - RequestCannotBeParsedError, - RequestTimeoutError, - ResponseCannotBeParsedError, - SubscriptionNotActiveError, - TooManyRequestError, - UnknownError, - UnsupportedVersionError, - WrongRegionError, -} from './errors' -import { FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-spa' +import { FingerprintError } from './errors' +import { isFingerprintError } from '@fingerprint/agent' /** - * Web implementation for error handling. Note: not all errors from native SDK are available on the web. + * Web implementation. * - * @platform web - * */ -export function unwrapError(error: Error): IdentificationError { - switch (error.message) { - // Api Errors block - case FingerprintJSPro.ERROR_API_KEY_MISSING: - return new ApiKeyRequiredError(error.message) - - case FingerprintJSPro.ERROR_API_KEY_INVALID: - return new ApiKeyNotFoundError(error.message) - - case FingerprintJSPro.ERROR_API_KEY_EXPIRED: - return new ApiKeyExpiredError(error.message) - - case FingerprintJSPro.ERROR_BAD_REQUEST_FORMAT: - return new RequestCannotBeParsedError(error.message) - - case FingerprintJSPro.ERROR_GENERAL_SERVER_FAILURE: - return new FailedError(error.message) - - case FingerprintJSPro.ERROR_SERVER_TIMEOUT: - return new RequestTimeoutError(error.message) - - case FingerprintJSPro.ERROR_RATE_LIMIT: - return new TooManyRequestError(error.message) - - case FingerprintJSPro.ERROR_FORBIDDEN_ORIGIN: - return new OriginNotAvailableError(error.message) - - case FingerprintJSPro.ERROR_FORBIDDEN_HEADER: - return new HeaderRestrictedError(error.message) - - case FingerprintJSPro.ERROR_FORBIDDEN_ENDPOINT: - return new HostnameRestrictedError(error.message) - - case FingerprintJSPro.ERROR_WRONG_REGION: - return new WrongRegionError(error.message) - - case FingerprintJSPro.ERROR_SUBSCRIPTION_NOT_ACTIVE: - return new SubscriptionNotActiveError(error.message) - - case FingerprintJSPro.ERROR_UNSUPPORTED_VERSION: - return new UnsupportedVersionError(error.message) - - case FingerprintJSPro.ERROR_INSTALLATION_METHOD_RESTRICTED: - return new InstallationMethodRestrictedError(error.message) - - case FingerprintJSPro.ERROR_BAD_RESPONSE_FORMAT: - return new ResponseCannotBeParsedError(error.message) - - // end of API Errors block - case FingerprintJSPro.ERROR_CLIENT_TIMEOUT: - return new ClientTimeoutError(error.message) - - case FingerprintJSPro.ERROR_NETWORK_CONNECTION: - case FingerprintJSPro.ERROR_NETWORK_ABORT: - case FingerprintJSPro.ERROR_NETWORK_RESTRICTED: - return new NetworkError(error.message) - - case FingerprintJSPro.ERROR_INVALID_PROXY_INTEGRATION_HEADERS: - return new InvalidProxyIntegrationHeadersError(error.message) - - case FingerprintJSPro.ERROR_PROXY_INTEGRATION_SECRET_ENVIRONMENT_MISMATCH: - return new ProxyIntegrationSecretEnvironmentMismatch(error.message) + * `@fingerprint/agent` already throws its own `FingerprintError` (structurally identical to ours). + * We re-wrap it in the SDK's {@link FingerprintError} so `instanceof` checks work against a single + * class regardless of platform. + */ +export function unwrapError(error: unknown): FingerprintError { + if (error instanceof FingerprintError) { + return error + } - case FingerprintJSPro.ERROR_INVALID_PROXY_INTEGRATION_SECRET: - return new InvalidProxyIntegrationSecretError(error.message) + if (isFingerprintError(error)) { + return new FingerprintError({ code: error.code, message: error.message, event_id: error.event_id }) + } - default: - return new UnknownError(error.message) + if (error instanceof Error) { + return new FingerprintError({ code: 'unknown_error', message: error.message }) } + + return new FingerprintError({ code: 'unknown_error', message: String(error) }) } diff --git a/sdk/src/useFingerprint.ts b/sdk/src/useFingerprint.ts new file mode 100644 index 00000000..358269ba --- /dev/null +++ b/sdk/src/useFingerprint.ts @@ -0,0 +1,29 @@ +import { useContext } from 'react' +import { FingerprintContext } from './FingerprintContext' +import type { FingerprintClient } from './types' +import { FingerprintContextNotAvailableError } from './errors' + +/** + * Returns the {@link FingerprintClient} from the nearest {@link FingerprintProvider}. + * + * Use this when you need the imperative client from inside the React tree (e.g. in an event handler + * or effect) but do not want the query-state management of {@link useVisitorData}. + * + * @group Hooks approach + * + * @example + * ```ts + * const fp = useFingerprint() + * + * useEffect(() => { + * fp.get({ linkedId: 'user_1234' }).then(setResult) + * }, [fp]) + * ``` + */ +export function useFingerprint(): FingerprintClient { + const { client } = useContext(FingerprintContext) + if (!client) { + throw new FingerprintContextNotAvailableError() + } + return client +} diff --git a/sdk/src/useVisitorData.ts b/sdk/src/useVisitorData.ts index c3588710..0aae3eee 100644 --- a/sdk/src/useVisitorData.ts +++ b/sdk/src/useVisitorData.ts @@ -1,69 +1,104 @@ -import { useCallback, useContext, useState } from 'react' -import { FingerprintJsProContextInterface, FingerprintJsProContext } from './FingerprintJsProContext' -import { GetDataOptions, QueryResult, VisitorQueryContext, VisitorData, Tags } from './types' -import { IdentificationError } from './errors' -import { isTruthy } from './utils' +import { useCallback, useContext, useEffect, useState } from 'react' +import { FingerprintContext } from './FingerprintContext' +import { FingerprintError } from './errors' +import type { FingerprintResponse, GetOptions, QueryResult } from './types' +import { deepEqual } from './utils' /** - * Use the `useVisitorData` hook in your components to perform identification requests with the FingerprintJS API. + * Options for {@link useVisitorData}: the request options plus hook-specific configuration. + * + * @group Hooks approach + */ +export type UseVisitorDataOptions = GetOptions & { + /** + * Controls automatic visitor data fetching. When `true`, the hook fetches after mounting and + * whenever the request options change. + * + * Defaults to `false` on React Native (unlike `@fingerprint/react`, which defaults to `true`) + * because there is no cache on native and eager fetching increases identification costs. + * + * @default false + */ + immediate?: boolean +} + +/** + * Return value of {@link useVisitorData}: the query state plus an imperative `getData`. + * + * @group Hooks approach + */ +export type UseVisitorDataReturn = QueryResult & { + /** + * Performs an identification request and returns the visitor data. + * Rejects with a {@link FingerprintError} when identification fails; the error is also stored in + * the query state. + */ + getData: (options?: GetOptions) => Promise +} + +const IDLE_STATE: QueryResult = { + data: undefined, + isLoading: false, + isFetched: false, + error: undefined, +} + +/** + * Use the `useVisitorData` hook in your components to perform identification requests with the + * Fingerprint API. + * + * @group Hooks approach * * @example * ```jsx - * const { - * // Request state - * isLoading, - * // Error info - * error, - * // Visitor info - * data, - * // A method to be called to initiate request - * getData, - * } = useVisitorData(); - * ``` + * const { data, isLoading, isFetched, error, getData } = useVisitorData() * - * @group Hooks approach + * // later, e.g. in an event handler: + * await getData({ linkedId: 'user_1234' }) + * ``` */ -export function useVisitorData(): VisitorQueryContext { - const { getVisitorData } = useContext(FingerprintJsProContext) - const [state, setState] = useState>({}) +export function useVisitorData(options: UseVisitorDataOptions = {}): UseVisitorDataReturn { + const { immediate = false, ...getOptions } = options + + const { getVisitorData } = useContext(FingerprintContext) + const [state, setState] = useState>(IDLE_STATE) - const getData = useCallback( - async (tags?: Tags, linkedId?: string, options?: GetDataOptions) => { - let result: VisitorData | null = null + // Keep a stable reference to the request options so the `immediate` effect only re-runs when they + // change by value, not on every render. + const [stableGetOptions, setStableGetOptions] = useState(getOptions) + if (!Object.is(stableGetOptions, getOptions) && !deepEqual(stableGetOptions, getOptions)) { + setStableGetOptions(getOptions) + } + + const getData = useCallback( + async (requestOptions?: GetOptions) => { + setState({ data: undefined, isLoading: true, isFetched: false, error: undefined }) try { - setState((state) => ({ ...state, isLoading: true })) - result = await getVisitorData(tags, linkedId, options) - setState((state) => ({ - ...state, - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - data: result as VisitorData, - isLoading: false, - error: undefined, - })) + const data = await getVisitorData(requestOptions) + setState({ data, isLoading: false, isFetched: true, error: undefined }) + return data } catch (error) { - setState((state) => ({ - ...state, + setState({ data: undefined, + isLoading: false, + isFetched: false, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - error: error as IdentificationError, - })) - if (isTruthy(options?.throwOnError)) { - throw error - } - } finally { - setState((state) => (state.isLoading === true ? { ...state, isLoading: false } : state)) + error: error as FingerprintError, + }) + throw error } - return result }, [getVisitorData] ) - const { isLoading, data, error } = state + useEffect(() => { + if (immediate) { + // eslint-disable-next-line react-hooks/set-state-in-effect + getData(stableGetOptions).catch(() => { + // error is already captured in the query state + }) + } + }, [immediate, stableGetOptions, getData]) - return { - isLoading, - data, - error, - getData, - } + return { ...state, getData } } From 42c901a8f0f28db62ab6755294f344f66e9b0ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Mon, 10 Aug 2026 13:47:02 +0200 Subject: [PATCH 002/109] feat: prepare iOS and Android modules for API v4 INTER-2394 INTER-2393 --- sdk/RNFingerprintjsPro.podspec | 4 +- sdk/__tests__/FingerprintClient.test.ts | 78 ++++++++++++ sdk/__tests__/FingerprintProvider.test.ts | 102 +++++++++++++++ sdk/android/fingerprint.gradle | 3 + .../reactnative/RNFingerprintjsProModule.kt | 120 +++++++----------- sdk/ios/FPJSError+React.swift | 35 ++--- sdk/ios/RNFingerprintjsPro.mm | 10 +- sdk/ios/RNFingerprintjsPro.swift | 41 ++---- sdk/src/FingerprintClient.ts | 95 ++++++++++++++ sdk/src/FingerprintClient.web.ts | 59 +++++++++ 10 files changed, 416 insertions(+), 131 deletions(-) create mode 100644 sdk/__tests__/FingerprintClient.test.ts create mode 100644 sdk/__tests__/FingerprintProvider.test.ts create mode 100644 sdk/src/FingerprintClient.ts create mode 100644 sdk/src/FingerprintClient.web.ts diff --git a/sdk/RNFingerprintjsPro.podspec b/sdk/RNFingerprintjsPro.podspec index 7e36e6fe..1c03c100 100644 --- a/sdk/RNFingerprintjsPro.podspec +++ b/sdk/RNFingerprintjsPro.podspec @@ -20,7 +20,9 @@ Pod::Spec.new do |s| s.dependency "React-Core" # See: https://guides.cocoapods.org/using/the-podfile.html#specifying-pod-versions - # ~> 2.8.2 means >= 2.8.2 and < 2.9.0 (pessimistic operator) + # ~> 2.17.0 means >= 2.17.0 and < 2.18.0 (pessimistic operator) + # TODO(v4): bump to `'~> 4.0'` once FingerprintPro v4 is published to CocoaPods. The Swift bridge is + # already reshaped to the v4 response contract; the v4 SDK also requires Xcode 16 / Swift 6 / iOS 14. s.dependency "FingerprintPro", '~> 2.17.0' # Wires up the TurboModule/Codegen dependencies (ReactCommon, generated specs, ...) and defines diff --git a/sdk/__tests__/FingerprintClient.test.ts b/sdk/__tests__/FingerprintClient.test.ts new file mode 100644 index 00000000..195f3fa7 --- /dev/null +++ b/sdk/__tests__/FingerprintClient.test.ts @@ -0,0 +1,78 @@ +import { NativeModules } from 'react-native' +import { FingerprintError, start } from '../src' + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const { getVisitorData } = NativeModules.RNFingerprintjsPro as unknown as Record + +const nativeResponse = { + visitorId: 'some-visitor-id', + eventId: 'some-event-id', + suspectScore: 42, + sealedResult: 'sealed-base64', +} + +describe('start().get()', () => { + beforeEach(() => { + getVisitorData.mockReset() + getVisitorData.mockResolvedValue(nativeResponse) + }) + + it('normalizes the native response into the snake_case shape', async () => { + const result = await start({ apiKey: 'key' }).get() + + expect(result).toStrictEqual({ + visitor_id: 'some-visitor-id', + event_id: 'some-event-id', + suspect_score: 42, + sealed_result: 'sealed-base64', + }) + }) + + it('maps a negative suspectScore to undefined and empty sealedResult to null', async () => { + getVisitorData.mockResolvedValueOnce({ + visitorId: 'v', + eventId: 'e', + suspectScore: -1, + sealedResult: '', + }) + + const result = await start({ apiKey: 'key' }).get() + + expect(result.suspect_score).toBeUndefined() + expect(result.sealed_result).toBeNull() + }) + + it('passes an object tag through unchanged, plus linkedId and timeout', async () => { + const tag = { userAction: 'login', count: 1, ok: true } + await start({ apiKey: 'key' }).get({ tag, linkedId: 'user_1234', timeout: 15_000 }) + + expect(getVisitorData).toHaveBeenCalledWith(tag, 'user_1234', 15_000) + }) + + it('wraps a primitive tag under a `tag` key for the native contract', async () => { + await start({ apiKey: 'key' }).get({ tag: 'login' }) + + expect(getVisitorData).toHaveBeenCalledWith({ tag: 'login' }, null, null) + }) + + it('sends nulls when no options are provided', async () => { + await start({ apiKey: 'key' }).get() + + expect(getVisitorData).toHaveBeenCalledWith(null, null, null) + }) + + it('rejects with a FingerprintError carrying the parsed code', async () => { + getVisitorData.mockRejectedValueOnce(new Error('too_many_requests:Rate limit reached')) + + const error = await start({ apiKey: 'key' }) + .get() + .catch((e: unknown) => e) + + expect(error).toBeInstanceOf(FingerprintError) + expect(error).toMatchObject({ + name: 'FingerprintError', + code: 'too_many_requests', + message: 'Rate limit reached', + }) + }) +}) diff --git a/sdk/__tests__/FingerprintProvider.test.ts b/sdk/__tests__/FingerprintProvider.test.ts new file mode 100644 index 00000000..8c6eaf21 --- /dev/null +++ b/sdk/__tests__/FingerprintProvider.test.ts @@ -0,0 +1,102 @@ +import { useContext } from 'react' +import { renderHook } from '@testing-library/react' +import { createWrapper, getDefaultLoadOptions, renderProvider } from './helpers' +import { FingerprintContext } from '../src' +import { NativeModules } from 'react-native' + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const { configure } = NativeModules.RNFingerprintjsPro as unknown as Record + +const pluginVersion = '__VERSION__' + +describe('FingerprintProvider', () => { + beforeEach(() => { + configure.mockClear() + }) + + it('configures with a single endpoint as endpointUrl', () => { + const options = getDefaultLoadOptions() + options.region = 'us' + options.endpoints = 'https://example.com' + + renderHook(() => useContext(FingerprintContext), { wrapper: createWrapper(options) }) + + expect(configure).toHaveBeenCalledWith(options.apiKey, pluginVersion, [], false, 5000, 'us', 'https://example.com') + }) + + it('splits multiple endpoints into endpointUrl + fallbacks', () => { + const options = getDefaultLoadOptions() + options.endpoints = ['https://primary.com', 'https://fallback1.com', 'https://fallback2.com'] + + renderHook(() => useContext(FingerprintContext), { wrapper: createWrapper(options) }) + + expect(configure).toHaveBeenCalledWith( + options.apiKey, + pluginVersion, + ['https://fallback1.com', 'https://fallback2.com'], + false, + 5000, + null, + 'https://primary.com' + ) + }) + + it('forwards Android location options on Android', () => { + const options = getDefaultLoadOptions() + options.android = { allowUseOfLocationData: true, locationTimeoutMillis: 6000 } + + renderHook(() => useContext(FingerprintContext), { wrapper: createWrapper(options) }) + + expect(configure).toHaveBeenCalledWith(options.apiKey, pluginVersion, [], true, 6000, null, null) + }) + + describe('start options changes propagation', () => { + it('configures the client once on mount', () => { + renderProvider({ apiKey: 'key-1' }) + + expect(configure).toHaveBeenCalledTimes(1) + expect(configure).toHaveBeenLastCalledWith('key-1', pluginVersion, [], false, 5000, null, null) + }) + + it('reconfigures when an option changes by value', () => { + const { rerenderWithParams } = renderProvider({ apiKey: 'key-1' }) + expect(configure).toHaveBeenCalledTimes(1) + + rerenderWithParams({ apiKey: 'key-2' }) + + expect(configure).toHaveBeenCalledTimes(2) + expect(configure).toHaveBeenLastCalledWith('key-2', pluginVersion, [], false, 5000, null, null) + }) + + it('propagates changes across every configure argument', () => { + const { rerenderWithParams } = renderProvider({ apiKey: 'key' }) + + rerenderWithParams({ + apiKey: 'key', + region: 'eu', + endpoints: 'https://example.com', + android: { allowUseOfLocationData: true, locationTimeoutMillis: 6000 }, + }) + + expect(configure).toHaveBeenCalledTimes(2) + expect(configure).toHaveBeenLastCalledWith('key', pluginVersion, [], true, 6000, 'eu', 'https://example.com') + }) + + it('does not reconfigure when re-rendered with value-equal options but fresh identities', () => { + const { rerenderWithParams } = renderProvider({ + apiKey: 'key', + endpoints: ['https://a.example'], + android: { locationTimeoutMillis: 5000 }, + }) + expect(configure).toHaveBeenCalledTimes(1) + + rerenderWithParams({ + apiKey: 'key', + endpoints: ['https://a.example'], + android: { locationTimeoutMillis: 5000 }, + }) + + expect(configure).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/sdk/android/fingerprint.gradle b/sdk/android/fingerprint.gradle index d0d5a6ed..b4de1e40 100644 --- a/sdk/android/fingerprint.gradle +++ b/sdk/android/fingerprint.gradle @@ -1,3 +1,6 @@ +// TODO(v4): bump to the Android SDK v4 range (e.g. "[4.0.0, 5.0.0)") once it is published to +// maven.fpregistry.io. The Kotlin module is already reshaped to the v4 response contract; the v4 SDK +// also requires Kotlin 2.x / Java 11 / Android API 23+. ext.fingerprint_native_sdk_version = "[2.17.0, 2.18.0)" task printFingerprintNativeSDKVersion { diff --git a/sdk/android/src/main/java/com/fingerprintjs/reactnative/RNFingerprintjsProModule.kt b/sdk/android/src/main/java/com/fingerprintjs/reactnative/RNFingerprintjsProModule.kt index 694f8c31..3aa3c8e2 100644 --- a/sdk/android/src/main/java/com/fingerprintjs/reactnative/RNFingerprintjsProModule.kt +++ b/sdk/android/src/main/java/com/fingerprintjs/reactnative/RNFingerprintjsProModule.kt @@ -43,7 +43,6 @@ class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRN override fun configure( apiToken: String, pluginVersion: String, - extendedResponseFormat: Boolean, fallbackEndpointUrls: ReadableArray, allowUseOfLocationData: Boolean, locationTimeoutMillis: Double, @@ -62,7 +61,9 @@ class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRN apiToken, region, endpointUrl = endpointUrl ?: region.endpointUrl, - extendedResponseFormat, + // TODO(v4): the `extendedResponseFormat` flag is removed in the Android SDK v4 (the response is + // always flat). While pinned to v2.17 we request the non-extended format explicitly. + extendedResponseFormat = false, fallbackEndpointUrls = fallbackEndpointUrls.toArrayList().filterIsInstance(), integrationInfo, allowUseOfLocationData, @@ -71,64 +72,26 @@ class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRN fpjsClient = factory.createInstance(configuration) } - override fun getVisitorId(tags: ReadableMap?, linkedId: String?, timeout: Double?, promise: Promise) { - getVisitorIdInternal(tags, linkedId, timeout?.toInt(), promise) - } - - override fun getVisitorData(tags: ReadableMap?, linkedId: String?, timeout: Double?, promise: Promise) { - getVisitorDataInternal(tags, linkedId, timeout?.toInt(), promise) - } - - private fun getVisitorIdInternal(tags: ReadableMap?, linkedId: String?, timeout: Int?, promise: Promise) { - try { - if (timeout != null) { - fpjsClient?.getVisitorId( - timeout, - getTags(tags), - linkedId ?: "", - { result -> promise.resolve(result.visitorId) }, - { error -> promise.reject("Error: ", getErrorDescription(error)) } - ) - } else { - fpjsClient?.getVisitorId( - getTags(tags), - linkedId ?: "", - { result -> promise.resolve(result.visitorId) }, - { error -> promise.reject("Error: ", getErrorDescription(error)) } - ) - } - } catch (e: Exception) { - promise.reject("Error: ", e) - } - } - - private fun getVisitorDataInternal(tags: ReadableMap?, linkedId: String?, timeout: Int?, promise: Promise) { + override fun getVisitorData(tag: ReadableMap?, linkedId: String?, timeout: Double?, promise: Promise) { try { val callback = { result: FingerprintJSProResponse -> + // TODO(v4): map from the flat `FingerprintResponse` — `eventId` replaces `requestId` and a + // real `suspectScore` replaces the `-1` sentinel used here (v3 has no suspect score). val visitorData = Arguments.createMap().apply { - putString("requestId", result.requestId) - putDouble("confidenceScore", result.confidenceScore.score.toDouble()) - putString("visitorDataJson", result.asJson) + putString("visitorId", result.visitorId) + putString("eventId", result.requestId) + putDouble("suspectScore", -1.0) putString("sealedResult", result.sealedResult ?: "") } promise.resolve(visitorData) } + val errorCallback = { error: Error -> promise.reject("Error: ", getErrorDescription(error)) } + val timeoutMillis = timeout?.toInt() - if (timeout != null) { - fpjsClient?.getVisitorId( - timeout, - getTags(tags), - linkedId ?: "", - callback, - { error -> promise.reject("Error: ", getErrorDescription(error)) } - ) + if (timeoutMillis != null) { + fpjsClient?.getVisitorId(timeoutMillis, getTags(tag), linkedId ?: "", callback, errorCallback) } else { - fpjsClient?.getVisitorId( - getTags(tags), - linkedId ?: "", - callback, - { error -> promise.reject("Error: ", getErrorDescription(error)) } - ) + fpjsClient?.getVisitorId(getTags(tag), linkedId ?: "", callback, errorCallback) } } catch (e: Exception) { promise.reject("Error: ", e) @@ -143,33 +106,38 @@ class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRN ?: emptyMap() } + // The React Native layer collapses every error into a single `FingerprintError` carrying a + // machine-friendly `code` (see `sdk/src/errors.ts`). We reject with a `":"` string + // that the JS `unwrapError` splits back apart, so the prefix must be the API v4 snake_case code. + // + // TODO(v4): align these codes with the final Android SDK v4 error classes. private fun getErrorDescription(error: Error): String { - val errorType = when(error) { - is ApiKeyRequired -> "ApiKeyRequired" - is ApiKeyNotFound -> "ApiKeyNotFound" - is ApiKeyExpired -> "ApiKeyExpired" - is RequestCannotBeParsed -> "RequestCannotBeParsed" - is Failed -> "Failed" - is RequestTimeout -> "RequestTimeout" - is TooManyRequest -> "TooManyRequest" - is OriginNotAvailable -> "OriginNotAvailable" - is HeaderRestricted -> "HeaderRestricted" - is NotAvailableForCrawlBots -> "NotAvailableForCrawlBots" - is NotAvailableWithoutUA -> "NotAvailableWithoutUA" - is WrongRegion -> "WrongRegion" - is SubscriptionNotActive -> "SubscriptionNotActive" - is UnsupportedVersion -> "UnsupportedVersion" - is InstallationMethodRestricted -> "InstallationMethodRestricted" - is ResponseCannotBeParsed -> "ResponseCannotBeParsed" - is NetworkError -> "NetworkError" - is ClientTimeout -> "ClientTimeout" - is UnknownError -> "UnknownError" - is InvalidProxyIntegrationHeaders -> "InvalidProxyIntegrationHeaders" - is InvalidProxyIntegrationSecret -> "InvalidProxyIntegrationSecret" - is ProxyIntegrationSecretEnvironmentMismatch -> "ProxyIntegrationSecretEnvironmentMismatch" - else -> "UnknownError" + val code = when(error) { + is ApiKeyRequired -> "public_api_key_required" + is ApiKeyNotFound -> "public_api_key_not_found" + is ApiKeyExpired -> "public_api_key_expired" + is RequestCannotBeParsed -> "request_cannot_be_parsed" + is Failed -> "failed" + is RequestTimeout -> "request_timeout" + is TooManyRequest -> "too_many_requests" + is OriginNotAvailable -> "origin_not_available" + is HeaderRestricted -> "header_restricted" + is NotAvailableForCrawlBots -> "not_available_for_crawl_bots" + is NotAvailableWithoutUA -> "not_available_without_ua" + is WrongRegion -> "wrong_region" + is SubscriptionNotActive -> "subscription_not_active" + is UnsupportedVersion -> "unsupported_version" + is InstallationMethodRestricted -> "installation_method_restricted" + is ResponseCannotBeParsed -> "response_cannot_be_parsed" + is NetworkError -> "network_error" + is ClientTimeout -> "client_timeout" + is UnknownError -> "unknown_error" + is InvalidProxyIntegrationHeaders -> "invalid_proxy_integration_headers" + is InvalidProxyIntegrationSecret -> "invalid_proxy_integration_secret" + is ProxyIntegrationSecretEnvironmentMismatch -> "proxy_integration_secret_environment_mismatch" + else -> "unknown_error" } - return errorType + ":" + error.description + return code + ":" + error.description } companion object { diff --git a/sdk/ios/FPJSError+React.swift b/sdk/ios/FPJSError+React.swift index 2f395527..f50c174b 100644 --- a/sdk/ios/FPJSError+React.swift +++ b/sdk/ios/FPJSError+React.swift @@ -8,45 +8,48 @@ import Foundation import FingerprintPro +// The React Native layer collapses every error into a single `FingerprintError` carrying a +// machine-friendly `code` (see `sdk/src/errors.ts`). Native rejects with a `": "` +// string that the JS `unwrapError` splits back apart, so the prefix here must be the API v4 +// snake_case error code. +// +// TODO(v4): align these codes with the final FingerprintPro v4 `APIError.Code` enum. While pinned to +// v2.17 we normalize the client-side cases to snake_case and forward the server-provided code for +// `apiError` (Server API v4 codes are already snake_case). extension FPJSError { var reactDescription: String { let description = self.localizedDescription switch self { case .invalidURL: - return "InvalidURL: \(description)" + return "invalid_url: \(description)" case .invalidURLParams: - return "InvalidURLParams: \(description)" + return "invalid_url_params: \(description)" case .apiError(let apiError): - let reactErrorName = apiError.reactName("apiError") + let code = apiError.reactCode("failed") let message = apiError.message ?? description - return "\(reactErrorName): \(message)" + return "\(code): \(message)" case .networkError(let networkError): - return "NetworkError: \(networkError.localizedDescription)" + return "network_error: \(networkError.localizedDescription)" case .jsonParsingError(let jsonParsingError): - return "JsonParsingError: \(jsonParsingError.localizedDescription)" + return "json_parsing_error: \(jsonParsingError.localizedDescription)" case .invalidResponseType: - return "InvalidResponseType: \(description)" + return "invalid_response_type: \(description)" case .clientTimeout: - return "ClientTimeout: \(description)" + return "client_timeout: \(description)" case .unknownError: fallthrough @unknown default: - return "UnknownError: \(description)" + return "unknown_error: \(description)" } } } extension APIError { - func reactName(_ defaultName: String) -> String { - let name = self.error?.code?.rawValue ?? defaultName - return name.firstUppercased + func reactCode(_ defaultCode: String) -> String { + return self.error?.code?.rawValue ?? defaultCode } var message: String? { return self.error?.message } } - -extension StringProtocol { - var firstUppercased: String { prefix(1).uppercased() + dropFirst() } -} diff --git a/sdk/ios/RNFingerprintjsPro.mm b/sdk/ios/RNFingerprintjsPro.mm index 030ac544..94d051cf 100644 --- a/sdk/ios/RNFingerprintjsPro.mm +++ b/sdk/ios/RNFingerprintjsPro.mm @@ -4,7 +4,6 @@ @interface RCT_EXTERN_MODULE(RNFingerprintjsPro, NSObject) RCT_EXTERN_METHOD(configure:(NSString *)apiToken pluginVersion:(NSString *)pluginVersion - extendedResponseFormat:(BOOL)extendedResponseFormat fallbackEndpointUrls:(NSArray *)fallbackEndpointUrls allowUseOfLocationData:(BOOL)allowUseOfLocationData locationTimeoutMillis:(double)locationTimeoutMillis @@ -12,14 +11,7 @@ @interface RCT_EXTERN_MODULE(RNFingerprintjsPro, NSObject) endpointUrl:(NSString * _Nullable)endpointUrl ) -RCT_EXTERN_METHOD(getVisitorId:(NSDictionary *)tags - linkedId:(NSString *)linkedId - timeout:(NSNumber * _Nullable)timeout - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject -) - -RCT_EXTERN_METHOD(getVisitorData:(NSDictionary *)tags +RCT_EXTERN_METHOD(getVisitorData:(NSDictionary *)tag linkedId:(NSString *)linkedId timeout:(NSNumber * _Nullable)timeout resolve:(RCTPromiseResolveBlock)resolve diff --git a/sdk/ios/RNFingerprintjsPro.swift b/sdk/ios/RNFingerprintjsPro.swift index d927ebeb..c3dfc324 100644 --- a/sdk/ios/RNFingerprintjsPro.swift +++ b/sdk/ios/RNFingerprintjsPro.swift @@ -11,46 +11,29 @@ class RNFingerprintjsPro: NSObject { @objc static func requiresMainQueueSetup() -> Bool { false } - @objc(configure:pluginVersion:extendedResponseFormat:fallbackEndpointUrls:allowUseOfLocationData:locationTimeoutMillis:region:endpointUrl:) - public func configure(_ apiToken: String, _ pluginVersion: String, _ extendedResponseFormat: Bool, _ fallbackEndpointUrls: [String], _ allowUseOfLocationData: Bool, _ locationTimeoutMillis: Double, _ region: String?, _ endpointUrl: String?) -> Void { + @objc(configure:pluginVersion:fallbackEndpointUrls:allowUseOfLocationData:locationTimeoutMillis:region:endpointUrl:) + public func configure(_ apiToken: String, _ pluginVersion: String, _ fallbackEndpointUrls: [String], _ allowUseOfLocationData: Bool, _ locationTimeoutMillis: Double, _ region: String?, _ endpointUrl: String?) -> Void { let region = RNFingerprintjsPro.parseRegion(region, endpoint: endpointUrl, endpointFallbacks: fallbackEndpointUrls) let integrationInfo = [("fingerprint-pro-react-native", pluginVersion)] - let configuration = Configuration(apiKey: apiToken, region: region, integrationInfo: integrationInfo, extendedResponseFormat: extendedResponseFormat, allowUseOfLocationData: allowUseOfLocationData) + // TODO(v4): the `extendedResponseFormat` flag is removed in FingerprintPro v4 (the response is + // always flat). While pinned to v2.17 we request the non-extended format explicitly. + let configuration = Configuration(apiKey: apiToken, region: region, integrationInfo: integrationInfo, extendedResponseFormat: false, allowUseOfLocationData: allowUseOfLocationData) fpjsClient = FingerprintProFactory.getInstance(configuration) } - @objc(getVisitorId:linkedId:timeout:resolve:reject:) - public func getVisitorId(tags: [String: Any]?, linkedId: String?, timeout: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { - let metadata = RNFingerprintjsPro.prepareMetadata(linkedId, tags: tags) - - let completionHandler: FingerprintPro.VisitorIdBlock = { visitorIdResult in - switch visitorIdResult { - case .success(let visitorId): - resolve(visitorId) - case .failure(let error): - let description = error.reactDescription - reject("Error: ", description, error) - } - } - - if let timeout = timeout?.doubleValue { - fpjsClient?.getVisitorId(metadata, timeout: timeout / 1000, completion: completionHandler) - } else { - fpjsClient?.getVisitorId(metadata, completion: completionHandler) - } - } - @objc(getVisitorData:linkedId:timeout:resolve:reject:) - public func getVisitorData(tags: [String: Any]?, linkedId: String?, timeout: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { - let metadata = RNFingerprintjsPro.prepareMetadata(linkedId, tags: tags) + public func getVisitorData(tag: [String: Any]?, linkedId: String?, timeout: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + let metadata = RNFingerprintjsPro.prepareMetadata(linkedId, tags: tag) let completionHandler: FingerprintPro.VisitorIdResponseBlock = { visitorIdResponseResult in switch visitorIdResponseResult { case let .success(visitorDataResponse): + // TODO(v4): map from the flat `FingerprintResponse` — `eventId` replaces `requestId` and + // a real `suspectScore` replaces the `-1` sentinel used here (v3 has no suspect score). let visitorData: [String: Any] = [ - "requestId": visitorDataResponse.requestId, - "confidenceScore": visitorDataResponse.confidence, - "visitorDataJson": visitorDataResponse.asJSON(), + "visitorId": visitorDataResponse.visitorId, + "eventId": visitorDataResponse.requestId, + "suspectScore": -1, "sealedResult": visitorDataResponse.sealedResult ?? "", ] resolve(visitorData) diff --git a/sdk/src/FingerprintClient.ts b/sdk/src/FingerprintClient.ts new file mode 100644 index 00000000..53c19a0f --- /dev/null +++ b/sdk/src/FingerprintClient.ts @@ -0,0 +1,95 @@ +import { Platform } from 'react-native' +import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes' +import RNFingerprintjsPro, { type NativeVisitorData } from './specs/NativeRNFingerprintjsPro' +import type { FingerprintClient, FingerprintResponse, GetOptions, StartOptions, TagValue } from './types' +import { unwrapError } from './unwrapError' +import { isDefined, isTruthy } from './utils' + +const packageVersion = '__VERSION__' + +const DEFAULT_LOCATION_TIMEOUT_MILLIS = 5000 + +/** + * Splits the {@link StartOptions.endpoints} value into a primary endpoint plus fallbacks, matching + * the positional native `configure` contract. + */ +function normalizeEndpoints(endpoints: StartOptions['endpoints']): { endpointUrl: string | null; fallbacks: string[] } { + if (!isDefined(endpoints)) { + return { endpointUrl: null, fallbacks: [] } + } + const list = Array.isArray(endpoints) ? endpoints : [endpoints] + const [primary, ...fallbacks] = list + return { endpointUrl: list.length > 0 ? primary : null, fallbacks } +} + +/** + * The native SDKs model tags as a string→value map. Objects pass through unchanged; primitives and + * arrays are wrapped under a `tag` key so both iOS and Android receive a valid object. + */ +function toNativeTag(tag: TagValue | undefined): UnsafeObject | null { + if (!isDefined(tag)) { + return null + } + if (typeof tag === 'object' && !Array.isArray(tag)) { + return tag + } + return { tag } +} + +function normalizeResponse(data: NativeVisitorData): FingerprintResponse { + return { + visitor_id: data.visitorId, + event_id: data.eventId, + suspect_score: data.suspectScore >= 0 ? data.suspectScore : undefined, + sealed_result: isTruthy(data.sealedResult) ? data.sealedResult : null, + } +} + +class NativeFingerprintClient implements FingerprintClient { + constructor(options: StartOptions) { + const { apiKey, region, endpoints, android, ios } = options + const { endpointUrl, fallbacks } = normalizeEndpoints(endpoints) + + const allowUseOfLocationData = + (Platform.OS === 'ios' ? ios?.allowUseOfLocationData : android?.allowUseOfLocationData) ?? false + const locationTimeoutMillis = android?.locationTimeoutMillis ?? DEFAULT_LOCATION_TIMEOUT_MILLIS + + RNFingerprintjsPro.configure( + apiKey, + packageVersion, + fallbacks, + allowUseOfLocationData, + locationTimeoutMillis, + region ?? null, + endpointUrl + ) + } + + public async get(options?: GetOptions): Promise { + try { + const data = await RNFingerprintjsPro.getVisitorData( + toNativeTag(options?.tag), + options?.linkedId ?? null, + options?.timeout ?? null + ) + return normalizeResponse(data) + } catch (error) { + throw unwrapError(error) + } + } +} + +/** + * Creates a Fingerprint client with the given options. + * + * @group API Client approach + * + * @example + * ```ts + * const fp = start({ apiKey: 'PUBLIC_API_KEY', region: 'eu' }) + * const result = await fp.get({ linkedId: 'user_1234' }) + * ``` + */ +export function start(options: StartOptions): FingerprintClient { + return new NativeFingerprintClient(options) +} diff --git a/sdk/src/FingerprintClient.web.ts b/sdk/src/FingerprintClient.web.ts new file mode 100644 index 00000000..a1ebd4c4 --- /dev/null +++ b/sdk/src/FingerprintClient.web.ts @@ -0,0 +1,59 @@ +import * as Fingerprint from '@fingerprint/agent' +import type { FingerprintClient, FingerprintResponse, GetOptions, StartOptions } from './types' +import { unwrapError } from './unwrapError' + +const packageVersion = '__VERSION__' + +/** + * Web implementation backed by `@fingerprint/agent`. + * + * @group API Client approach + * @platform web + */ +class WebFingerprintClient implements FingerprintClient { + private readonly agent: Fingerprint.Agent + + constructor({ apiKey, region, endpoints, web }: StartOptions) { + this.agent = Fingerprint.start({ + apiKey, + region, + endpoints, + storageKeyPrefix: web?.storageKeyPrefix, + urlHashing: web?.urlHashing, + remoteControlDetection: web?.remoteControlDetection, + cache: web?.cache, + integrationInfo: [`fingerprint-pro-react-native/${packageVersion}/web`], + }) + } + + public async get(options?: GetOptions): Promise { + try { + const result = await this.agent.get({ + tag: options?.tag, + linkedId: options?.linkedId, + timeout: options?.timeout, + }) + + return { + // `visitor_id` is only omitted by the backend in zeroTrust mode; default to '' to keep the + // cross-platform shape stable. + visitor_id: result.visitor_id ?? '', + event_id: result.event_id, + suspect_score: result.suspect_score, + sealed_result: result.sealed_result ? result.sealed_result.base64() : null, + } + } catch (error) { + throw unwrapError(error) + } + } +} + +/** + * Creates a Fingerprint client with the given options. + * + * @group API Client approach + * @platform web + */ +export function start(options: StartOptions): FingerprintClient { + return new WebFingerprintClient(options) +} From 687dff3b0780ebf776c2af645fdb48c72ff9a24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Tue, 11 Aug 2026 10:02:18 +0200 Subject: [PATCH 003/109] feat(e2e): migrate tests and SDK to API v4 format INTER-2395 --- .changeset/v4-api-migration.md | 33 ++++++++ README.md | 110 +++++++++++++++---------- e2e-app/App.tsx | 15 ++-- e2e-app/e2e-web/identification.test.ts | 35 ++++---- e2e-app/e2e/identification.test.ts | 26 +++--- e2e-app/e2e/identify.ts | 17 ++-- e2e-app/e2e/ids.ts | 1 + e2e-app/package.json | 2 +- 8 files changed, 150 insertions(+), 89 deletions(-) create mode 100644 .changeset/v4-api-migration.md diff --git a/.changeset/v4-api-migration.md b/.changeset/v4-api-migration.md new file mode 100644 index 00000000..078609e6 --- /dev/null +++ b/.changeset/v4-api-migration.md @@ -0,0 +1,33 @@ +--- +'@fingerprintjs/fingerprintjs-pro-react-native': major +--- + +Migrated the SDK to Fingerprint API v4 and realigned the public API with `@fingerprint/react`. This is a breaking change on every platform (web, iOS, Android). + +**Renamed API** + +- `FingerprintJsProProvider` → `FingerprintProvider`. +- `FingerprintJsProAgent` (constructed with `new`) → `start(options)`, returning a client with a single `get(options)` method (`getVisitorId`/`getVisitorData` are removed). +- Added `useFingerprint()` to read the client from context. + +**Single options object** + +- `getData(tags, linkedId, options)` → `getData({ tag, linkedId, timeout })`. `tags` is renamed to `tag`. +- `useVisitorData` now returns a discriminated union (`data`/`isLoading`/`isFetched`/`error`) and `getData` always throws on error — the `throwOnError` option is removed. `immediate` defaults to `false`. + +**Grouped provider/start options** + +- Platform-only options are nested: `android` (`allowUseOfLocationData`, `locationTimeoutMillis`), `ios` (`allowUseOfLocationData`), `web` (`storageKeyPrefix`, `urlHashing`, `remoteControlDetection`, `cache`). `locationTimeoutMillisAndroid` moves to `android.locationTimeoutMillis`. +- `endpointUrl` + `fallbackEndpointUrls` are replaced by a single `endpoints` (string or string[]). + +**snake_case response** + +- The response is now the flat, snake_case `FingerprintResponse` (`visitor_id`, `event_id`, `suspect_score?`, `sealed_result`), matching the Server API v4 and JS agent. `requestId` → `event_id`, `confidence` → `suspect_score`, `sealedResult` → `sealed_result`. The nested extended fields (`ipLocation`, `firstSeenAt`, etc.) and the `extendedResponseFormat` option are removed (v4 always returns the flat format). + +**Single error type** + +- The ~28 error classes are replaced by a single `FingerprintError` (`{ name, code, event_id }`) plus an `isFingerprintError` type guard. Discriminate on `error.code` (e.g. `too_many_requests`). + +**Web** + +- The web implementation now uses `@fingerprint/agent` (v4) instead of `@fingerprintjs/fingerprintjs-pro-spa`. Install `@fingerprint/agent` as the web peer dependency. diff --git a/README.md b/README.md index 8f0f4f38..da213874 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ application to call the native Fingerprint Pro libraries (Android and iOS) and i - [Usage](#usage) - [Hooks approach](#hooks-approach) - [API Client approach](#api-client-approach) - - [`extendedResponseFormat`](#extendedresponseformat) + - [Response format](#response-format) + - [Error handling](#error-handling) - [Linking and tagging information](#linking-and-tagging-information) - [API Reference](#api-reference) - [Additional Resources](#additional-resources) @@ -106,18 +107,18 @@ To use the SDK on the web, install the peer dependency with your preferred packa - [NPM](https://npmjs.org): ```shell - npm install @fingerprintjs/fingerprintjs-pro-spa --save + npm install @fingerprint/agent --save ``` - [Yarn](https://yarnpkg.com): ```shell - yarn add @fingerprintjs/fingerprintjs-pro-spa + yarn add @fingerprint/agent ``` - [PNPM](https://pnpm.js.org): ```shell - pnpm add @fingerprintjs/fingerprintjs-pro-spa + pnpm add @fingerprint/agent ``` Then, use the SDK as you would with the native version. @@ -213,20 +214,20 @@ To identify visitors, you need a Fingerprint Pro account (you can [sign up for f ### Hooks approach -Configure the SDK by wrapping your application in FingerprintJsProProvider. +Configure the SDK by wrapping your application in `FingerprintProvider`. ```javascript // src/index.js import React from 'react'; import { AppRegistry } from 'react-native'; -import { FingerprintJsProProvider } from '@fingerprintjs/fingerprintjs-pro-react-native'; +import { FingerprintProvider } from '@fingerprintjs/fingerprintjs-pro-react-native'; import App from './App'; import { name as appName } from './app.json'; const WrappedApp = () => ( - + - + ) AppRegistry.registerComponent(appName, () => WrappedApp); @@ -241,17 +242,18 @@ import {Button, SafeAreaView, Text, View} from 'react-native' import {useVisitorData} from '@fingerprintjs/fingerprintjs-pro-react-native' export default function App() { - const {isLoading, error, data, getData} = useVisitorData() + // `getData` rejects on failure; the error is also available in `error`. + const {isLoading, isFetched, error, data, getData} = useVisitorData() return ( -