diff --git a/.changeset/v4-api-migration.md b/.changeset/v4-api-migration.md
new file mode 100644
index 00000000..5844fab7
--- /dev/null
+++ b/.changeset/v4-api-migration.md
@@ -0,0 +1,133 @@
+---
+'@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({ tags, linkedId, timeout })`.
+- `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`, `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` is replaced by `event_id`, `confidence` is removed, and `suspect_score` is a new optional Smart Signals value; `sealedResult` is renamed to `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`).
+- Every network failure reports `code: 'network_error'` on all platforms. The platform-specific `network_unavailable` (Android), `network_connection` and `network_abort` (web) codes are not emitted.
+
+**Web**
+
+- The web implementation now uses `@fingerprint/agent` (v4) instead of `@fingerprintjs/fingerprintjs-pro-spa`. Install `@fingerprint/agent` as the web peer dependency.
+- ️Caching is available only on **web** and is disabled by default. To enable caching on web, pass the JavaScript agent [cache](https://docs.fingerprint.com/reference/js-agent-start-function#cache) option:
+ ```jsx
+
+
+
+ ```
+
+**iOS**:
+- The new version floor for iOS is **15.1**.
+
+**Android**:
+- The new version floor for Android is **Android 7.0 (API level 24+)**.
+
+## Migration
+
+**Provider**
+
+```diff
+- import { FingerprintJsProProvider } from '@fingerprintjs/fingerprintjs-pro-react-native'
++ import { FingerprintProvider } from '@fingerprintjs/fingerprintjs-pro-react-native'
+
+-
++
+
+-
++
+```
+
+**Hook (`useVisitorData`)**
+
+```diff
+- const { isLoading, error, data, getData } = useVisitorData()
++ const { isLoading, isFetched, error, data, getData } = useVisitorData()
+
+ // positional args + opt-in throwing → single options object, always throws
+- await getData({ userAction: 'login' }, 'user_1234', { timeout: 5000, throwOnError: true })
++ await getData({ tags: { userAction: 'login' }, linkedId: 'user_1234', timeout: 5000 })
+
+- data?.visitorId
+- data?.confidence.score
++ data?.visitor_id
++ data?.suspect_score
+```
+
+**Imperative client**
+
+```diff
+- import { FingerprintJsProAgent } from '@fingerprintjs/fingerprintjs-pro-react-native'
++ import { start } from '@fingerprintjs/fingerprintjs-pro-react-native'
+
+- const client = new FingerprintJsProAgent({ apiKey: 'PUBLIC_API_KEY', region: 'eu' })
+- const visitorId = await client.getVisitorId()
+- const data = await client.getVisitorData()
++ const fp = start({ apiKey: 'PUBLIC_API_KEY', region: 'eu' })
++ const result = await fp.get()
++ result.visitor_id
+```
+
+**Provider / start options**
+
+```diff
+ {
+ apiKey: 'PUBLIC_API_KEY',
+ region: 'eu',
+- endpointUrl: 'https://metrics.example.com',
+- fallbackEndpointUrls: ['https://metrics2.example.com'],
++ endpoints: ['https://metrics.example.com', 'https://metrics2.example.com'],
+- extendedResponseFormat: true,
+- allowUseOfLocationData: true,
+- locationTimeoutMillisAndroid: 5000,
++ android: { allowUseOfLocationData: true, locationTimeoutMillis: 5000 },
++ ios: { allowUseOfLocationData: true },
+ }
+```
+
+**Errors**
+
+```diff
+- import { TooManyRequestError } from '@fingerprintjs/fingerprintjs-pro-react-native'
++ import { isFingerprintError } from '@fingerprintjs/fingerprintjs-pro-react-native'
+
+ try {
+ await fp.get()
+ } catch (error) {
+- if (error instanceof TooManyRequestError) {
++ if (isFingerprintError(error) && error.code === 'too_many_requests') {
+ // handle rate limiting
+ }
+ }
+```
+
+**Web peer dependency**
+
+```diff
+- npm install @fingerprintjs/fingerprintjs-pro-spa
++ npm install @fingerprint/agent
+```
+
+---
\ No newline at end of file
diff --git a/README.md b/README.md
index 8f0f4f38..192a1c28 100644
--- a/README.md
+++ b/README.md
@@ -42,9 +42,9 @@ application to call the native Fingerprint Pro libraries (Android and iOS) and i
- [Dependencies](#dependencies)
- [How to install](#how-to-install)
- [Expo setup](#expo-setup)
- - [1. Add config plugin](#1-add-config-plugin)
- - [2. Rebuild the native code](#2-rebuild-the-native-code)
- - [3. Rebuild the app](#3-rebuild-the-app)
+ - [1. Add config plugin](#1-add-config-plugin)
+ - [2. Rebuild the native code](#2-rebuild-the-native-code)
+ - [3. Rebuild the app](#3-rebuild-the-app)
- [Bare react-native](#bare-react-native-setup)
- [1. Configure iOS dependencies (if developing on iOS)](#1-configure-ios-dependencies-if-developing-on-ios)
- [2. Configure Android dependencies (if developing on Android)](#2-configure-android-dependencies-if-developing-on-android)
@@ -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)
@@ -64,8 +65,8 @@ application to call the native Fingerprint Pro libraries (Android and iOS) and i
- React Native 0.79 or higher is supported (New Architecture only)
- Expo 53.0.0 or higher is supported
-- Android 6.0 (API level 23+) or higher
-- iOS 13+/tvOS 15+, Swift 5.9 or higher (stable releases)
+- Android 7.0 (API level 24+) or higher
+- iOS 15.1+/tvOS 15.1+, Swift 5.9 or higher (stable releases)
- Fingerprint Pro [request filtering](https://dev.fingerprint.com/docs/request-filtering) is not supported right now. Allowed and forbidden origins cannot be used.
@@ -94,7 +95,7 @@ Install the package using your favorite package manager:
```shell
pnpm add @fingerprintjs/fingerprintjs-pro-react-native
```
-
+
## Expo setup
> ℹ️ Our SDK cannot be used in [Expo Go](https://expo.dev/go) because it requires custom native code.
@@ -106,20 +107,20 @@ 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.
@@ -149,7 +150,7 @@ For iOS:
```bash
npx expo run:ios
```
-
+
## Bare react-native setup
### 1. Configure iOS dependencies (if developing on iOS)
@@ -185,22 +186,22 @@ For Gradle versions before 7.0, you likely have an `allprojects` block in `{root
```groovy
allprojects {
- repositories {
- mavenCentral()
- mavenLocal()
- maven {
- // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
- url("$rootDir/../node_modules/react-native/android")
- }
- maven {
- // Android JSC is installed from npm
- url("$rootDir/../node_modules/jsc-android/dist")
- }
- maven {
- url("https://maven.fpregistry.io/releases") // Add this
- }
- google()
+ repositories {
+ mavenCentral()
+ mavenLocal()
+ maven {
+ // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
+ url("$rootDir/../node_modules/react-native/android")
+ }
+ maven {
+ // Android JSC is installed from npm
+ url("$rootDir/../node_modules/jsc-android/dist")
}
+ maven {
+ url("https://maven.fpregistry.io/releases") // Add this
+ }
+ google()
+ }
}
```
@@ -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,42 +242,54 @@ 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()
+ const {isLoading, isFetched, error, data, getData} = useVisitorData()
return (
-
-
-
-
+
+
+
+
)
}
```
+> ℹ️ By default the hook does **not** fetch automatically. Pass `useVisitorData({ immediate: true })`
+> to identify on mount and whenever the request options change.
+
+
+> ⚠️ Caching is available only on **web** and is disabled by default.
+> To enable caching on web, pass the JavaScript agent [cache](https://docs.fingerprint.com/reference/js-agent-start-function#cache) option:
+> ```jsx
+>
+>
+>
+> ```
### API Client approach
+Create a client with `start()` and call `get()`:
+
```javascript
import React, { useEffect } from 'react';
-import { FingerprintJsProAgent } from '@fingerprintjs/fingerprintjs-pro-react-native';
+import { start } from '@fingerprintjs/fingerprintjs-pro-react-native';
-// ...
+// ...
useEffect(() => {
async function getVisitorInfo() {
try {
- const FingerprintClient = new FingerprintJsProAgent({ apiKey: 'PUBLIC_API_KEY', region: 'eu' }); // Region may be 'us', 'eu', or 'ap'
- const visitorId = await FingerprintClient.getVisitorId(); // Use this method if you need only visitorId
- const visitorData = await FingerprintClient.getVisitorData(); // Use this method if you need additional information about visitor
- // use visitor data in your code
+ const fp = start({ apiKey: 'PUBLIC_API_KEY', region: 'eu' }); // Region may be 'us', 'eu', or 'ap'
+ const result = await fp.get();
+ console.log(result.visitor_id, result.event_id);
} catch (e) {
console.error('Error: ', e);
}
@@ -285,38 +298,54 @@ useEffect(() => {
}, []);
```
-### `extendedResponseFormat`
+Inside the React tree you can also get the same client from context with the `useFingerprint()` hook:
-Two types of responses are supported: "default" and "extended". You don't need to pass any parameters to get the "default" response.
-"Extended" is an extended result format that includes geolocation, incognito mode and other information.
-It can be requested using the `extendedResponseFormat`: true parameter. See more details about the responses in the [documentation](https://dev.fingerprint.com/reference/get-function#extendedresult).
+```javascript
+import { useFingerprint } from '@fingerprintjs/fingerprintjs-pro-react-native';
-Providing `extendedResponseFormat` using hooks:
+const fp = useFingerprint();
+const result = await fp.get({ linkedId: 'user_1234' });
+```
-```javascript
-return (
-
-
-
-)
+### Response format
+
+The response is a flat, snake_case object that matches the Fingerprint Server API v4 and the JS agent.
+
+```typescript
+interface FingerprintResponse {
+ visitor_id: string
+ event_id: string
+ suspect_score?: number // present only when Smart Signals are enabled
+ sealed_result: string | null // base64 sealed result, or null when unavailable
+ cache_hit?: boolean // Used only on web, on native always set to `undefined`
+}
```
-Providing `extendedResponseFormat` using the API Client:
+### Error handling
+
+Every failure is thrown as a single `FingerprintError` carrying a machine-friendly `code` (e.g.
+`too_many_requests`) and a resolution-oriented `message`:
```javascript
-const FingerprintClient = new FingerprintJsProAgent({
- apiKey: 'PUBLIC_API_KEY',
- region: 'eu',
- extendedResponseFormat: true,
-})
+import { isFingerprintError } from '@fingerprintjs/fingerprintjs-pro-react-native';
+
+try {
+ await fp.get();
+} catch (error) {
+ if (isFingerprintError(error) && error.code === 'too_many_requests') {
+ // handle rate limiting
+ }
+}
```
### Linking and tagging information
-The `visitorId` provided by Fingerprint Identification is especially useful when combined with information you already know about your users, for example, account IDs, order IDs, etc. To learn more about various applications of the `linkedId` and `tag`, see [Linking and tagging information](https://dev.fingerprint.com/docs/tagging-information).
+The `visitor_id` provided by Fingerprint Identification is especially useful when combined with information you already know about your users, for example, account IDs, order IDs, etc. To learn more about various applications of the `linkedId` and `tags`, see [Linking and tagging information](https://docs.fingerprint.com/docs/tagging-information).
+
+Pass `tags` and `linkedId` in a single options object:
```javascript
-const tag = {
+const tags = {
userAction: 'login',
analyticsId: 'UA-5555-1111-1'
};
@@ -324,12 +353,11 @@ const linkedId = 'user_1234';
// Using hooks
const { getData } = useVisitorData();
-const visitorData = await getData(tag, linkedId);
+const visitorData = await getData({ tags, linkedId });
// Using the client
-const FingerprintClient = new FingerprintJsProAgent({ apiKey: 'PUBLIC_API_KEY'});
-const visitorId = await FingerprintClient.getVisitorId(tag, linkedId);
-const visitor = await FingerprintClient.getVisitorData(tag, linkedId);
+const fp = start({ apiKey: 'PUBLIC_API_KEY' });
+const visitor = await fp.get({ tags, linkedId });
```
### Proximity Detection
@@ -338,24 +366,25 @@ Proximity detection is a complementary, location-based signal available only on
You can find more information in [Android SDK documentation](https://dev.fingerprint.com/docs/native-android-integration#proximity-detection-for-android-devices) or in
[iOS SDK documentation](https://dev.fingerprint.com/docs/ios-sdk#using-location-data-for-proximity-detection).
-The Fingerprint SDK will only collect location data if the `allowUseOfLocationData` option is set to `true`.
+Platform-only options are grouped under `android` and `ios`. The Fingerprint SDK will only collect
+location data if `allowUseOfLocationData` is set to `true`.
```javascript
return (
-
-
-
+
+
+
)
```
-For Android platform it's possible to configure the location retrieval timeout by setting the `locationTimeoutMillisAndroid` option to a desired value. By default, it's set to 5 seconds.
+For the Android platform it's possible to configure the location retrieval timeout by setting the `locationTimeoutMillis` option to a desired value. By default, it's set to 5 seconds.
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.
```javascript
return (
-
-
-
+
+
+
)
```
diff --git a/e2e-app/App.tsx b/e2e-app/App.tsx
index ce773f65..5d9fa5e4 100644
--- a/e2e-app/App.tsx
+++ b/e2e-app/App.tsx
@@ -2,7 +2,7 @@
// but it doesn't work nicely with older RN versions that we also run tests against, so keep SafeAreaView import from react-native for now
/* eslint-disable @typescript-eslint/no-deprecated */
import { Pressable, SafeAreaView, Text, View } from 'react-native'
-import { FingerprintJsProProvider, useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react-native'
+import { FingerprintProvider, isFingerprintError, useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react-native'
import { testIds } from '@/e2e/ids'
import { useEffect } from 'react'
import { testTags } from '@/e2e/tags'
@@ -13,9 +13,11 @@ function InnerApp() {
const { isLoading, error, data, getData } = useVisitorData()
const doGetData = async () => {
- const tags = config.useTags ? testTags : undefined
-
- await getData(tags, config.linkedId)
+ try {
+ await getData({ tags: config.useTags ? testTags : undefined, linkedId: config.linkedId })
+ } catch {
+ // `getData` rejects on failure, but the error is also stored in the hook state and rendered below.
+ }
}
useEffect(() => {
@@ -38,8 +40,22 @@ function InnerApp() {
{isLoading ? Loading... : null}
{error ? (
+ Name:
{error.name}
+
+ Code:
+ {error.code}
+
+ Message:
{error.message}
+
+ {isFingerprintError(error) && (
+ <>
+ Error event id
+ {error.event_id}
+ >
+ )}
+
{error.stack ? {error.stack} : null}
{error.cause ? {JSON.stringify(error.cause)} : null}
@@ -69,8 +85,8 @@ function InnerApp() {
export default function App() {
return (
-
+
-
+
)
}
diff --git a/e2e-app/e2e-web/identification.test.ts b/e2e-app/e2e-web/identification.test.ts
index 1979f2e0..ed5591c7 100644
--- a/e2e-app/e2e-web/identification.test.ts
+++ b/e2e-app/e2e-web/identification.test.ts
@@ -1,10 +1,5 @@
import { test, expect, Page } from '@playwright/test'
-import {
- DecryptionAlgorithm,
- FingerprintJsServerApiClient,
- Region,
- unsealEventsResponse,
-} from '@fingerprintjs/fingerprintjs-pro-server-api'
+import { DecryptionAlgorithm, FingerprintServerApiClient, Region, unsealEventsResponse } from '@fingerprint/node-sdk'
import { testTags } from '../e2e/tags'
import { testIds } from '../e2e/ids'
import { Config } from '../src/config.types'
@@ -59,16 +54,12 @@ async function identifyWithError(page: Page) {
// Wait for the error to be displayed
await page.getByTestId(testIds.errorName).waitFor({ timeout: 10000 })
- // Get the error name and message
+ // Get the error name, code and message
const errorName = await page.getByTestId(testIds.errorName).textContent()
+ const errorCode = await page.getByTestId(testIds.errorCode).textContent()
const errorMessage = await page.getByTestId(testIds.errorMessage).textContent()
- const error = new Error(errorMessage ?? '')
- if (errorName) {
- error.name = errorName
- }
-
- return error
+ return { name: errorName ?? '', code: errorCode ?? '', message: errorMessage ?? '' }
}
test.describe('Web tests', () => {
@@ -81,19 +72,19 @@ test.describe('Web tests', () => {
// Using for...of loop instead of test.describe.each
for (const [region, apiKey, privateApiKey] of basicIdentificationTestData) {
test.describe(`Web Identification on ${region} Region`, () => {
- let client: FingerprintJsServerApiClient
+ let client: FingerprintServerApiClient
test.beforeEach(async ({ page }) => {
if (!apiKey || !privateApiKey) {
throw new Error('API keys are required to run this test')
}
- let serverRegion = Region.Global
+ let serverRegion: Region = Region.Global
if (region === 'eu') {
serverRegion = Region.EU
}
- client = new FingerprintJsServerApiClient({
+ client = new FingerprintServerApiClient({
apiKey: privateApiKey,
region: serverRegion,
})
@@ -106,11 +97,11 @@ test.describe('Web tests', () => {
test('should return visitor data', async ({ page }) => {
const identificationResult = await identify(page)
- expect(identificationResult.visitorId).toMatch(VISITOR_ID_REGEX)
+ expect(identificationResult.visitor_id).toMatch(VISITOR_ID_REGEX)
- const event = await client.getEvent(identificationResult.requestId)
- expect(event.products.identification?.data?.visitorId).toEqual(identificationResult.visitorId)
- expect(event.products.identification?.data?.requestId).toEqual(identificationResult.requestId)
+ const event = await client.getEvent(identificationResult.event_id)
+ expect(event.identification?.visitor_id).toEqual(identificationResult.visitor_id)
+ expect(event.event_id).toEqual(identificationResult.event_id)
})
})
}
@@ -124,7 +115,7 @@ test.describe('Web tests', () => {
// Using for...of loop instead of test.describe.each
for (const [region, apiKey, privateApiKey] of linkedIdTagsTestData) {
test.describe(`Web Identification on ${region} Region with linkedId and tags`, () => {
- let client: FingerprintJsServerApiClient
+ let client: FingerprintServerApiClient
const linkedId = `${Date.now()}-web-test`
test.beforeEach(async ({ page }) => {
@@ -132,12 +123,12 @@ test.describe('Web tests', () => {
throw new Error('API keys are required to run this test')
}
- let serverRegion = Region.Global
+ let serverRegion: Region = Region.Global
if (region === 'eu') {
serverRegion = Region.EU
}
- client = new FingerprintJsServerApiClient({
+ client = new FingerprintServerApiClient({
apiKey: privateApiKey,
region: serverRegion,
})
@@ -152,11 +143,11 @@ test.describe('Web tests', () => {
test('should return visitor data with linkedId and tag', async ({ page }) => {
const identificationResult = await identify(page)
- expect(identificationResult.visitorId).toMatch(VISITOR_ID_REGEX)
+ expect(identificationResult.visitor_id).toMatch(VISITOR_ID_REGEX)
- const event = await client.getEvent(identificationResult.requestId)
- expect(event.products.identification?.data?.linkedId).toEqual(linkedId)
- expect(event.products.identification?.data?.tag).toEqual(testTags)
+ const event = await client.getEvent(identificationResult.event_id)
+ expect(event.linked_id).toEqual(linkedId)
+ expect(event.tags).toEqual(testTags)
})
})
}
@@ -164,15 +155,15 @@ test.describe('Web tests', () => {
test.describe('Web Identification invalid API Key', () => {
test.beforeEach(async ({ page }) => {
await setupPage(page, {
- apiKey: 'invalid',
+ apiKey: '6A0uywtMN49q6yddZNwV',
region: 'us',
})
})
test('should return error', async ({ page }) => {
const error = await identifyWithError(page)
- expect(error.message).toEqual('API key not found')
- expect(error.name).toEqual('ApiKeyNotFoundError')
+ expect(error.name).toEqual('FingerprintError')
+ expect(error.code).toEqual('public_api_key_not_found')
})
})
@@ -193,10 +184,10 @@ test.describe('Web tests', () => {
test('should return sealed visitor data', async ({ page }) => {
const identificationResult = await identify(page)
- expect(identificationResult.requestId).toBeTruthy()
- expect(identificationResult.sealedResult).toBeTruthy()
+ expect(identificationResult.event_id).toBeTruthy()
+ expect(identificationResult.sealed_result).toBeTruthy()
- const unsealedData = await unsealEventsResponse(Buffer.from(identificationResult.sealedResult, 'base64'), [
+ const unsealedData = await unsealEventsResponse(Buffer.from(identificationResult.sealed_result, 'base64'), [
{
key: Buffer.from(encryptionKey ?? '', 'base64'),
algorithm: DecryptionAlgorithm.Aes256Gcm,
@@ -204,7 +195,7 @@ test.describe('Web tests', () => {
])
expect(unsealedData).toBeTruthy()
- expect(unsealedData.products.identification?.data?.requestId).toEqual(identificationResult.requestId)
+ expect(unsealedData.event_id).toEqual(identificationResult.event_id)
})
})
})
diff --git a/e2e-app/e2e/identification.test.ts b/e2e-app/e2e/identification.test.ts
index 4b129b5e..42c0b739 100644
--- a/e2e-app/e2e/identification.test.ts
+++ b/e2e-app/e2e/identification.test.ts
@@ -1,11 +1,6 @@
import { device } from 'detox'
import { expect, it, describe, beforeAll, jest } from '@jest/globals'
-import {
- DecryptionAlgorithm,
- FingerprintJsServerApiClient,
- Region,
- unsealEventsResponse,
-} from '@fingerprintjs/fingerprintjs-pro-server-api'
+import { DecryptionAlgorithm, FingerprintServerApiClient, Region, unsealEventsResponse } from '@fingerprint/node-sdk'
import { testTags } from './tags'
import { DeviceLaunchAppConfig } from 'detox/detox'
import { identify, identifyWithError } from './identify'
@@ -13,6 +8,7 @@ import { wait } from './wait'
import { Config } from '@/src/config.types'
const VISITOR_ID_REGEX = /^[a-zA-Z\d]{20}$/
+const EVENT_ID_REGEX = /^\d{13}\.[a-zA-Z0-9]{6}$/
async function launchApp(params?: DeviceLaunchAppConfig) {
await device.launchApp(params)
@@ -29,7 +25,7 @@ describe.each([
['us', process.env.MINIMUM_US_DEFAULT_PUBLIC_KEY, process.env.MINIMUM_US_DEFAULT_PRIVATE_KEY],
['eu', process.env.DEFAULT_EU_DEFAULT_PUBLIC_KEY, process.env.DEFAULT_EU_DEFAULT_PRIVATE_KEY],
] as const)('React Native Identification on %s Region', (region, apiKey, privateApiKey) => {
- let client: FingerprintJsServerApiClient
+ let client: FingerprintServerApiClient
beforeAll(async () => {
if (!apiKey) {
@@ -53,7 +49,7 @@ describe.each([
break
}
- client = new FingerprintJsServerApiClient({
+ client = new FingerprintServerApiClient({
apiKey: privateApiKey,
region: serverRegion,
})
@@ -69,11 +65,11 @@ describe.each([
it('should return visitor data', async () => {
const identificationResult = await identify()
- expect(identificationResult.visitorId).toMatch(VISITOR_ID_REGEX)
+ expect(identificationResult.visitor_id).toMatch(VISITOR_ID_REGEX)
- const event = await client.getEvent(identificationResult.requestId)
- expect(event.products.identification?.data?.visitorId).toEqual(identificationResult.visitorId)
- expect(event.products.identification?.data?.requestId).toEqual(identificationResult.requestId)
+ const event = await client.getEvent(identificationResult.event_id)
+ expect(event.identification?.visitor_id).toEqual(identificationResult.visitor_id)
+ expect(event.event_id).toEqual(identificationResult.event_id)
})
})
@@ -81,7 +77,7 @@ describe.each([
['us', process.env.MINIMUM_US_DEFAULT_PUBLIC_KEY, process.env.MINIMUM_US_DEFAULT_PRIVATE_KEY],
['eu', process.env.DEFAULT_EU_DEFAULT_PUBLIC_KEY, process.env.DEFAULT_EU_DEFAULT_PRIVATE_KEY],
] as const)('React Native Identification on %s Region with linkedId and tags', (region, apiKey, privateApiKey) => {
- let client: FingerprintJsServerApiClient
+ let client: FingerprintServerApiClient
const linkedId = `${Date.now()}-rn-test`
@@ -107,7 +103,7 @@ describe.each([
break
}
- client = new FingerprintJsServerApiClient({
+ client = new FingerprintServerApiClient({
apiKey: privateApiKey,
region: serverRegion,
})
@@ -125,11 +121,11 @@ describe.each([
it('should return visitor data with linkedId and tag', async () => {
const identificationResult = await identify()
- expect(identificationResult.visitorId).toMatch(VISITOR_ID_REGEX)
+ expect(identificationResult.visitor_id).toMatch(VISITOR_ID_REGEX)
- const event = await client.getEvent(identificationResult.requestId)
- expect(event.products.identification?.data?.linkedId).toEqual(linkedId)
- expect(event.products.identification?.data?.tag).toEqual(testTags)
+ const event = await client.getEvent(identificationResult.event_id)
+ expect(event.linked_id).toEqual(linkedId)
+ expect(event.tags).toEqual(testTags)
})
})
@@ -145,8 +141,11 @@ describe('React Native Identification invalid API Key', () => {
it('should return error', async () => {
const error = await identifyWithError()
- expect(error.message).toEqual('invalid public key')
- expect(error.name).toEqual('ApiKeyNotFoundError')
+ // v4 collapses every error into a single `FingerprintError` discriminated by `code`. The native
+ // SDKs forward the server's code for an unknown public key.
+ expect(error.name).toEqual('FingerprintError')
+ expect(error.code).toEqual('public_api_key_not_found')
+ expect(error.eventId).toMatch(EVENT_ID_REGEX)
})
})
@@ -174,10 +173,10 @@ describe('React Native Identification with sealed results', () => {
it('should return sealed visitor data', async () => {
const identificationResult = await identify()
- expect(identificationResult.requestId).toBeTruthy()
- expect(identificationResult.sealedResult).toBeTruthy()
+ expect(identificationResult.event_id).toBeTruthy()
+ expect(identificationResult.sealed_result).toBeTruthy()
- const unsealedData = await unsealEventsResponse(Buffer.from(identificationResult.sealedResult ?? '', 'base64'), [
+ const unsealedData = await unsealEventsResponse(Buffer.from(identificationResult.sealed_result ?? '', 'base64'), [
{
key: Buffer.from(encryptionKey, 'base64'),
algorithm: DecryptionAlgorithm.Aes256Gcm,
@@ -185,6 +184,6 @@ describe('React Native Identification with sealed results', () => {
])
expect(unsealedData).toBeTruthy()
- expect(unsealedData.products.identification?.data?.requestId).toEqual(identificationResult.requestId)
+ expect(unsealedData.event_id).toEqual(identificationResult.event_id)
})
})
diff --git a/e2e-app/e2e/identify.ts b/e2e-app/e2e/identify.ts
index 80aeb014..d3d2ba67 100644
--- a/e2e-app/e2e/identify.ts
+++ b/e2e-app/e2e/identify.ts
@@ -10,7 +10,12 @@ export async function identify() {
const text = await getElementText(element(by.id(testIds.data)))
- return JSON.parse(text) as { visitorId: string; requestId: string; sealedResult?: string }
+ return JSON.parse(text) as {
+ visitor_id: string
+ event_id: string
+ suspect_score?: number
+ sealed_result: string | null
+ }
}
export async function identifyWithError() {
@@ -19,11 +24,10 @@ export async function identifyWithError() {
.toExist()
.withTimeout(10_000)
- const errorName = await getElementText(element(by.id(testIds.errorName)))
- const errorMessage = await getElementText(element(by.id(testIds.errorMessage)))
+ const name = await getElementText(element(by.id(testIds.errorName)))
+ const code = await getElementText(element(by.id(testIds.errorCode)))
+ const message = await getElementText(element(by.id(testIds.errorMessage)))
+ const eventId = await getElementText(element(by.id(testIds.errorEventId)))
- const error = new Error(errorMessage)
- error.name = errorName
-
- return error
+ return { name, code, message, eventId }
}
diff --git a/e2e-app/e2e/ids.ts b/e2e-app/e2e/ids.ts
index b5fbb137..dc73421a 100644
--- a/e2e-app/e2e/ids.ts
+++ b/e2e-app/e2e/ids.ts
@@ -1,9 +1,11 @@
export const testIds = {
loading: 'loading',
errorName: 'error-name',
+ errorCode: 'error-code',
errorMessage: 'error-message',
errorStack: 'error-stack',
errorCause: 'error-cause',
+ errorEventId: 'error-event-id',
data: 'data',
getData: 'get-data',
uiManager: 'ui-manager',
diff --git a/e2e-app/package.json b/e2e-app/package.json
index d0a0f891..9f6d2db1 100644
--- a/e2e-app/package.json
+++ b/e2e-app/package.json
@@ -20,7 +20,7 @@
"@expo/config": "^57.0.3",
"@expo/metro-runtime": "~57.0.3",
"@fingerprintjs/fingerprintjs-pro-react-native": "../sdk",
- "@fingerprintjs/fingerprintjs-pro-spa": "^1.3.2",
+ "@fingerprint/agent": "^4.1.3",
"dotenv": "^16.4.7",
"expo": "57.0.4",
"expo-build-properties": "~57.0.3",
@@ -35,7 +35,7 @@
"@babel/runtime": "^7.29.7",
"@config-plugins/detox": "^11.0.0",
"@expo/config-plugins": "~57.0.3",
- "@fingerprintjs/fingerprintjs-pro-server-api": "^6.3.0",
+ "@fingerprint/node-sdk": "^7.7.0",
"@jest/globals": "^30.4.1",
"@playwright/test": "^1.60.0",
"@types/jest": "^29.5.14",
diff --git a/e2e-app/pnpm-lock.yaml b/e2e-app/pnpm-lock.yaml
index 7de1b550..bea6bef1 100644
--- a/e2e-app/pnpm-lock.yaml
+++ b/e2e-app/pnpm-lock.yaml
@@ -14,12 +14,12 @@ importers:
'@expo/metro-runtime':
specifier: ~57.0.3
version: 57.0.3(@expo/log-box@57.0.0)(expo@57.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)
+ '@fingerprint/agent':
+ specifier: ^4.1.3
+ version: 4.1.3
'@fingerprintjs/fingerprintjs-pro-react-native':
specifier: ../sdk
version: link:../sdk
- '@fingerprintjs/fingerprintjs-pro-spa':
- specifier: ^1.3.2
- version: 1.3.3
dotenv:
specifier: ^16.4.7
version: 16.6.1
@@ -57,9 +57,9 @@ importers:
'@expo/config-plugins':
specifier: ~57.0.3
version: 57.0.3(typescript@6.0.3)
- '@fingerprintjs/fingerprintjs-pro-server-api':
- specifier: ^6.3.0
- version: 6.11.0
+ '@fingerprint/node-sdk':
+ specifier: ^7.7.0
+ version: 7.7.0
'@jest/globals':
specifier: ^30.4.1
version: 30.4.1
@@ -991,15 +991,12 @@ packages:
resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==}
hasBin: true
- '@fingerprintjs/fingerprintjs-pro-server-api@6.11.0':
- resolution: {integrity: sha512-WkzgfWbxCommi+bMqjEiguYWIsAhAKL0iR4DDktF2ZzE7zATur6YXErDNrfV1QXIx79IlfwJYrjPiztz4QQGIQ==}
- engines: {node: '>=18.17.0'}
-
- '@fingerprintjs/fingerprintjs-pro-spa@1.3.3':
- resolution: {integrity: sha512-jM5k30S9RT/WW6gnmNWACc7UHeK1md9NbZg8kUsdxvPtDeW8xKnA6tb58Rqfkg6kvqHTq5aHFzw9hgsTH3DbNQ==}
+ '@fingerprint/agent@4.1.3':
+ resolution: {integrity: sha512-kD7x3cYpOETFZ6KKOzozwGsCYhApRa/n8sDA+oLjx0jRVarmxisMMUi5UtGJQHdx8jqxU+I/8gcjb+fuSBxhBA==}
- '@fingerprintjs/fingerprintjs-pro@3.12.10':
- resolution: {integrity: sha512-AnHqPsvQ77nHdLAigJHU9GXHTAot8IlBR/B6adUbL95fdtO9JPXaD0HpG4aHABcDHZITXkF0zhtjSIaP1aB6fg==}
+ '@fingerprint/node-sdk@7.7.0':
+ resolution: {integrity: sha512-7FjFJpI4CoV/alS3fGvR6yBo89eSG2jkQOrhHM9CPqgzEYglU2pBdJbQALcJOw7hmJItdV4ozj4+Tx1+lFHpPQ==}
+ engines: {node: '>=18.17.0'}
'@flatten-js/interval-tree@1.1.4':
resolution: {integrity: sha512-o4emRDDvGdkwX18BSVSXH8q27qAL7Z2WDHSN75C8xyRSE4A8UOkig0mWSGoT5M5KaTHZxoLmalFwOTQmbRusUg==}
@@ -6130,14 +6127,9 @@ snapshots:
chalk: 4.1.2
js-yaml: 4.2.0
- '@fingerprintjs/fingerprintjs-pro-server-api@6.11.0': {}
-
- '@fingerprintjs/fingerprintjs-pro-spa@1.3.3':
- dependencies:
- '@fingerprintjs/fingerprintjs-pro': 3.12.10
- tslib: 2.8.1
+ '@fingerprint/agent@4.1.3': {}
- '@fingerprintjs/fingerprintjs-pro@3.12.10': {}
+ '@fingerprint/node-sdk@7.7.0': {}
'@flatten-js/interval-tree@1.1.4': {}
diff --git a/e2e-app/pnpm-workspace.yaml b/e2e-app/pnpm-workspace.yaml
index b22abf00..a21007cd 100644
--- a/e2e-app/pnpm-workspace.yaml
+++ b/e2e-app/pnpm-workspace.yaml
@@ -4,5 +4,5 @@ allowBuilds:
detox: true
dtrace-provider: true
esbuild: true
-
-shamefullyHoist: true
\ No newline at end of file
+
+shamefullyHoist: true
diff --git a/e2e-app/src/global.d.ts b/e2e-app/src/global.d.ts
index 8bf6f312..e165d551 100644
--- a/e2e-app/src/global.d.ts
+++ b/e2e-app/src/global.d.ts
@@ -1,5 +1,5 @@
declare global {
- var __turboModuleProxy: ?unknown
+ var __turboModuleProxy: unknown
var RN$Bridgeless: boolean | undefined
var nativeFabricUIManager: boolean | undefined
}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index fe10b172..f7a471a9 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -50,6 +50,15 @@ const config = [
},
},
},
+ {
+ files: ['sdk/type-tests/**/*.{ts,tsx}'],
+ languageOptions: {
+ parserOptions: {
+ project: 'sdk/tsconfig.type-tests.json',
+ tsconfigRootDir: __dirname,
+ },
+ },
+ },
{
files: ['**/*.{js,cjs,mjs}'],
...tseslint.configs.disableTypeChecked,
diff --git a/package.json b/package.json
index c1369e56..9271045b 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"test:coverage": "cd sdk && pnpm test:coverage",
"test:dts": "cd sdk && pnpm test:dts",
"docs": "cd sdk && pnpm run docs",
- "changeset:version": "changeset version",
+ "changeset:version": "changeset version && bash sdk/scripts/sync-podspec-version.sh",
"changeset:publish": "HUSKY=0 changeset publish"
},
"repository": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a50eb400..c7444cdc 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.5
+ version: 4.1.5
'@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.5':
+ resolution: {integrity: sha512-F1BeUWU60FubV1iLjJj8c0N6rouyEXq4P3KUFKYjM4wRMqcnUtL4l5hmRl/vMmo1t4VFCLku6Li99g5m21fcHg==}
+
'@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.5': {}
+
'@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/README.md b/sdk/README.md
index 8f0f4f38..192a1c28 100644
--- a/sdk/README.md
+++ b/sdk/README.md
@@ -42,9 +42,9 @@ application to call the native Fingerprint Pro libraries (Android and iOS) and i
- [Dependencies](#dependencies)
- [How to install](#how-to-install)
- [Expo setup](#expo-setup)
- - [1. Add config plugin](#1-add-config-plugin)
- - [2. Rebuild the native code](#2-rebuild-the-native-code)
- - [3. Rebuild the app](#3-rebuild-the-app)
+ - [1. Add config plugin](#1-add-config-plugin)
+ - [2. Rebuild the native code](#2-rebuild-the-native-code)
+ - [3. Rebuild the app](#3-rebuild-the-app)
- [Bare react-native](#bare-react-native-setup)
- [1. Configure iOS dependencies (if developing on iOS)](#1-configure-ios-dependencies-if-developing-on-ios)
- [2. Configure Android dependencies (if developing on Android)](#2-configure-android-dependencies-if-developing-on-android)
@@ -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)
@@ -64,8 +65,8 @@ application to call the native Fingerprint Pro libraries (Android and iOS) and i
- React Native 0.79 or higher is supported (New Architecture only)
- Expo 53.0.0 or higher is supported
-- Android 6.0 (API level 23+) or higher
-- iOS 13+/tvOS 15+, Swift 5.9 or higher (stable releases)
+- Android 7.0 (API level 24+) or higher
+- iOS 15.1+/tvOS 15.1+, Swift 5.9 or higher (stable releases)
- Fingerprint Pro [request filtering](https://dev.fingerprint.com/docs/request-filtering) is not supported right now. Allowed and forbidden origins cannot be used.
@@ -94,7 +95,7 @@ Install the package using your favorite package manager:
```shell
pnpm add @fingerprintjs/fingerprintjs-pro-react-native
```
-
+
## Expo setup
> ℹ️ Our SDK cannot be used in [Expo Go](https://expo.dev/go) because it requires custom native code.
@@ -106,20 +107,20 @@ 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.
@@ -149,7 +150,7 @@ For iOS:
```bash
npx expo run:ios
```
-
+
## Bare react-native setup
### 1. Configure iOS dependencies (if developing on iOS)
@@ -185,22 +186,22 @@ For Gradle versions before 7.0, you likely have an `allprojects` block in `{root
```groovy
allprojects {
- repositories {
- mavenCentral()
- mavenLocal()
- maven {
- // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
- url("$rootDir/../node_modules/react-native/android")
- }
- maven {
- // Android JSC is installed from npm
- url("$rootDir/../node_modules/jsc-android/dist")
- }
- maven {
- url("https://maven.fpregistry.io/releases") // Add this
- }
- google()
+ repositories {
+ mavenCentral()
+ mavenLocal()
+ maven {
+ // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
+ url("$rootDir/../node_modules/react-native/android")
+ }
+ maven {
+ // Android JSC is installed from npm
+ url("$rootDir/../node_modules/jsc-android/dist")
}
+ maven {
+ url("https://maven.fpregistry.io/releases") // Add this
+ }
+ google()
+ }
}
```
@@ -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,42 +242,54 @@ 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()
+ const {isLoading, isFetched, error, data, getData} = useVisitorData()
return (
-
-
-
-
+
+
+
+
)
}
```
+> ℹ️ By default the hook does **not** fetch automatically. Pass `useVisitorData({ immediate: true })`
+> to identify on mount and whenever the request options change.
+
+
+> ⚠️ Caching is available only on **web** and is disabled by default.
+> To enable caching on web, pass the JavaScript agent [cache](https://docs.fingerprint.com/reference/js-agent-start-function#cache) option:
+> ```jsx
+>
+>
+>
+> ```
### API Client approach
+Create a client with `start()` and call `get()`:
+
```javascript
import React, { useEffect } from 'react';
-import { FingerprintJsProAgent } from '@fingerprintjs/fingerprintjs-pro-react-native';
+import { start } from '@fingerprintjs/fingerprintjs-pro-react-native';
-// ...
+// ...
useEffect(() => {
async function getVisitorInfo() {
try {
- const FingerprintClient = new FingerprintJsProAgent({ apiKey: 'PUBLIC_API_KEY', region: 'eu' }); // Region may be 'us', 'eu', or 'ap'
- const visitorId = await FingerprintClient.getVisitorId(); // Use this method if you need only visitorId
- const visitorData = await FingerprintClient.getVisitorData(); // Use this method if you need additional information about visitor
- // use visitor data in your code
+ const fp = start({ apiKey: 'PUBLIC_API_KEY', region: 'eu' }); // Region may be 'us', 'eu', or 'ap'
+ const result = await fp.get();
+ console.log(result.visitor_id, result.event_id);
} catch (e) {
console.error('Error: ', e);
}
@@ -285,38 +298,54 @@ useEffect(() => {
}, []);
```
-### `extendedResponseFormat`
+Inside the React tree you can also get the same client from context with the `useFingerprint()` hook:
-Two types of responses are supported: "default" and "extended". You don't need to pass any parameters to get the "default" response.
-"Extended" is an extended result format that includes geolocation, incognito mode and other information.
-It can be requested using the `extendedResponseFormat`: true parameter. See more details about the responses in the [documentation](https://dev.fingerprint.com/reference/get-function#extendedresult).
+```javascript
+import { useFingerprint } from '@fingerprintjs/fingerprintjs-pro-react-native';
-Providing `extendedResponseFormat` using hooks:
+const fp = useFingerprint();
+const result = await fp.get({ linkedId: 'user_1234' });
+```
-```javascript
-return (
-
-
-
-)
+### Response format
+
+The response is a flat, snake_case object that matches the Fingerprint Server API v4 and the JS agent.
+
+```typescript
+interface FingerprintResponse {
+ visitor_id: string
+ event_id: string
+ suspect_score?: number // present only when Smart Signals are enabled
+ sealed_result: string | null // base64 sealed result, or null when unavailable
+ cache_hit?: boolean // Used only on web, on native always set to `undefined`
+}
```
-Providing `extendedResponseFormat` using the API Client:
+### Error handling
+
+Every failure is thrown as a single `FingerprintError` carrying a machine-friendly `code` (e.g.
+`too_many_requests`) and a resolution-oriented `message`:
```javascript
-const FingerprintClient = new FingerprintJsProAgent({
- apiKey: 'PUBLIC_API_KEY',
- region: 'eu',
- extendedResponseFormat: true,
-})
+import { isFingerprintError } from '@fingerprintjs/fingerprintjs-pro-react-native';
+
+try {
+ await fp.get();
+} catch (error) {
+ if (isFingerprintError(error) && error.code === 'too_many_requests') {
+ // handle rate limiting
+ }
+}
```
### Linking and tagging information
-The `visitorId` provided by Fingerprint Identification is especially useful when combined with information you already know about your users, for example, account IDs, order IDs, etc. To learn more about various applications of the `linkedId` and `tag`, see [Linking and tagging information](https://dev.fingerprint.com/docs/tagging-information).
+The `visitor_id` provided by Fingerprint Identification is especially useful when combined with information you already know about your users, for example, account IDs, order IDs, etc. To learn more about various applications of the `linkedId` and `tags`, see [Linking and tagging information](https://docs.fingerprint.com/docs/tagging-information).
+
+Pass `tags` and `linkedId` in a single options object:
```javascript
-const tag = {
+const tags = {
userAction: 'login',
analyticsId: 'UA-5555-1111-1'
};
@@ -324,12 +353,11 @@ const linkedId = 'user_1234';
// Using hooks
const { getData } = useVisitorData();
-const visitorData = await getData(tag, linkedId);
+const visitorData = await getData({ tags, linkedId });
// Using the client
-const FingerprintClient = new FingerprintJsProAgent({ apiKey: 'PUBLIC_API_KEY'});
-const visitorId = await FingerprintClient.getVisitorId(tag, linkedId);
-const visitor = await FingerprintClient.getVisitorData(tag, linkedId);
+const fp = start({ apiKey: 'PUBLIC_API_KEY' });
+const visitor = await fp.get({ tags, linkedId });
```
### Proximity Detection
@@ -338,24 +366,25 @@ Proximity detection is a complementary, location-based signal available only on
You can find more information in [Android SDK documentation](https://dev.fingerprint.com/docs/native-android-integration#proximity-detection-for-android-devices) or in
[iOS SDK documentation](https://dev.fingerprint.com/docs/ios-sdk#using-location-data-for-proximity-detection).
-The Fingerprint SDK will only collect location data if the `allowUseOfLocationData` option is set to `true`.
+Platform-only options are grouped under `android` and `ios`. The Fingerprint SDK will only collect
+location data if `allowUseOfLocationData` is set to `true`.
```javascript
return (
-
-
-
+
+
+
)
```
-For Android platform it's possible to configure the location retrieval timeout by setting the `locationTimeoutMillisAndroid` option to a desired value. By default, it's set to 5 seconds.
+For the Android platform it's possible to configure the location retrieval timeout by setting the `locationTimeoutMillis` option to a desired value. By default, it's set to 5 seconds.
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.
```javascript
return (
-
-
-
+
+
+
)
```
diff --git a/sdk/RNFingerprintjsPro.podspec b/sdk/RNFingerprintjsPro.podspec
index 7e36e6fe..0a49ed60 100644
--- a/sdk/RNFingerprintjsPro.podspec
+++ b/sdk/RNFingerprintjsPro.podspec
@@ -1,14 +1,14 @@
Pod::Spec.new do |s|
s.name = "RNFingerprintjsPro"
- s.version = "1.0.4"
+ s.version = "3.17.1"
s.summary = "Fingerprint Pro visitor identification in a React Native app"
s.description = "Official React Native client for Fingerprint. Best identification solution for React Native."
s.homepage = "https://github.com/fingerprintjs"
s.license = { :type => "MIT", :file => "LICENSE" }
s.author = { "FingerprintJS, Inc" => "support@fingerprint.com" }
s.source = { :git => "https://github.com/fingerprintjs/fingerprintjs-pro-react-native.git", :tag => "main" }
- s.ios.deployment_target = "13.0"
- s.tvos.deployment_target = "15.0"
+ s.ios.deployment_target = "15.1"
+ s.tvos.deployment_target = "15.1"
s.source_files = "ios/**/*.{h,m,mm,swift}"
# Never sweep build artifacts (e.g. generated Codegen headers under ios/build) into the pod's
@@ -19,9 +19,7 @@ Pod::Spec.new do |s|
# s.requires_arc = true
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)
- s.dependency "FingerprintPro", '~> 2.17.0'
+ s.dependency "Fingerprint-iOS", '~> 4.0'
# Wires up the TurboModule/Codegen dependencies (ReactCommon, generated specs, ...) and defines
# `RCT_NEW_ARCH_ENABLED` for the pod when the app is built with the New Architecture. On the old
diff --git a/sdk/__tests__/FingerprintClient.test.ts b/sdk/__tests__/FingerprintClient.test.ts
new file mode 100644
index 00000000..d42ae92e
--- /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({ tags: 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({ tags: '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(Object.assign(new Error('Rate limit reached'), { code: 'too_many_requests' }))
+
+ 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__/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__/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/__tests__/errors.test.ts b/sdk/__tests__/errors.test.ts
index 679c7cd3..082bc017 100644
--- a/sdk/__tests__/errors.test.ts
+++ b/sdk/__tests__/errors.test.ts
@@ -1,22 +1,115 @@
-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)', () => {
+ /**
+ * Mimics the error object React Native builds from a native module promise rejection:
+ * `error.code` from the reject code, `error.message`, and `error.userInfo` from the extra data.
+ */
+ function makeNativeError(code: string, message: string, eventId?: string): Error {
+ const error = new Error(message)
+ Object.assign(error, { code, userInfo: eventId !== undefined ? { eventId } : {} })
+ return error
+ }
+
+ it('maps a native rejection (code + message) into a FingerprintError', () => {
+ const error = unwrapError(makeNativeError('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('restores the event id from userInfo', () => {
+ const error = unwrapError(makeNativeError('too_many_requests', 'Rate limit reached', 'evt_123'))
+ expect(error.code).toBe('too_many_requests')
+ expect(error.message).toBe('Rate limit reached')
+ expect(error.event_id).toBe('evt_123')
})
- it('should return UnknownError with full message inside when something unknown passed', () => {
+ it('falls back to unknown_error for an error without a 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')
+ expect(error.event_id).toBeNull()
+ })
+
+ it('returns the same instance when a FingerprintError is passed through', () => {
+ const original = new FingerprintError({ code: 'failed', message: 'boom' })
+ expect(unwrapError(original)).toBe(original)
+ })
+
+ it('wraps a non-error value as unknown_error', () => {
+ const error = unwrapError('nope')
+ expect(error).toBeInstanceOf(FingerprintError)
+ expect(error.code).toBe('unknown_error')
+ expect(error.message).toBe('nope')
+ })
+})
+
+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.each(['network_connection', 'network_abort'])('collapses the agent %s code into network_error', (code) => {
+ const error = unwrapWebError(makeAgentError(code, 'Network failed'))
+ expect(error.code).toBe('network_error')
+ expect(error.message).toBe('Network failed')
+ })
+
+ 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('should keep error message full even with ":" inside', () => {
- const error = unwrapError(new Error('JsonParsingError:some:strange:message'))
- expect(error).toBeInstanceOf(JsonParsingError)
- expect(error.message).toBe('some:strange:message')
+ 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..a05b3c70 100644
--- a/sdk/__tests__/useVisitorData.test.ts
+++ b/sdk/__tests__/useVisitorData.test.ts
@@ -1,213 +1,388 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { NativeModules } from 'react-native'
-import { RequestOptions, useVisitorData } from '../src'
+import { TagsPrimitive, useVisitorData } from '../src'
import { createWrapper } from './helpers'
+import { NativeVisitorData } from '../src/specs/NativeRNFingerprintjsPro'
// 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 })
+ it('starts in the idle state', () => {
+ const { result } = renderHook(() => useVisitorData(), { wrapper: createWrapper() })
+
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isFetched).toBe(false)
+ expect(result.current.error).toBeUndefined()
+ })
+
+ it('transitions through loading and fetched states', async () => {
+ const { result } = renderHook(() => useVisitorData(), { wrapper: createWrapper() })
act(() => {
void result.current.getData()
})
- expect(result.current.isLoading).toBeTruthy()
+ expect(result.current.isLoading).toBe(true)
await waitFor(() => {
- expect(result.current.isLoading).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 correct return data', async () => {
- const mockedJsonAnswer = {
- visitorId: mockedVisitorId,
- }
+ it('stores the error and rejects when identification fails', async () => {
+ getVisitorData.mockRejectedValueOnce(Object.assign(new Error('Rate limit reached'), { code: 'too_many_requests' }))
+ const { result } = renderHook(() => useVisitorData(), { wrapper: createWrapper() })
- getVisitorData.mockReturnValueOnce(
- Promise.resolve({
- requestId: mockedRequestId,
- confidenceScore: mockedConfidenceScore,
- visitorDataJson: JSON.stringify(mockedJsonAnswer),
+ await act(async () => {
+ await expect(result.current.getData()).rejects.toMatchObject({
+ name: 'FingerprintError',
+ code: 'too_many_requests',
})
+ })
+
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.error).toMatchObject({ name: 'FingerprintError', code: 'too_many_requests' })
+ })
+
+ it('passes top-level options', async () => {
+ const tag = { stringTag: 'foo', numberTag: 0, objectTag: { foo: true, bar: [1, 2, 3] }, boolTag: false }
+ const { result } = renderHook(
+ () =>
+ useVisitorData({
+ tags: tag,
+ linkedId: 'test_id',
+ timeout: 15_000,
+ }),
+ { wrapper: createWrapper() }
)
- const wrapper = createWrapper()
- const { result } = renderHook(() => useVisitorData(), { wrapper })
+
act(() => {
void result.current.getData()
})
- expect(result.current.data).toBeUndefined()
+
await waitFor(() => {
- expect(result.current.data).toStrictEqual({
- visitorId: mockedVisitorId,
- requestId: mockedRequestId,
- confidence: {
- score: mockedConfidenceScore,
- },
- })
- expect(result.current.error).toBeFalsy()
+ expect(getVisitorData).toHaveBeenCalledWith(tag, 'test_id', 15_000)
})
})
- 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('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()).resolves.toBeNull()
+ act(() => {
+ void result.current.getData({ tags: tag, linkedId: 'test_id', timeout: 15_000 })
})
- expect(result.current.isLoading).toBeFalsy()
- 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)
+ await waitFor(() => {
+ expect(getVisitorData).toHaveBeenCalledWith(tag, 'test_id', 15_000)
+ })
})
- 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('merges top and passed options', async () => {
+ const tag = { stringTag: 'foo', numberTag: 0, objectTag: { foo: true, bar: [1, 2, 3] }, boolTag: false }
+ const { result } = renderHook(
+ () =>
+ useVisitorData({
+ tags: tag,
+ }),
+ { 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({ 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,
- })
- })
-
- 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),
+ await waitFor(() => {
+ expect(getVisitorData).toHaveBeenCalledWith(tag, 'test_id', 15_000)
+ })
+ })
+
+ it('fetches automatically when `immediate` is true', async () => {
+ const { result } = renderHook(() => useVisitorData({ immediate: true, linkedId: 'auto' }), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isLoading).toBe(true)
+
+ await waitFor(() => {
+ expect(result.current.isFetched).toBe(true)
+ })
+ expect(getVisitorData).toHaveBeenCalledWith(null, 'auto', null)
+ expect(result.current.data).toStrictEqual(expectedData)
+ })
+
+ it('does not duplicate in-flight requests', async () => {
+ getVisitorData.mockReset()
+ getVisitorData.mockImplementation(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 100)
})
- )
+ return nativeResponse
+ })
+
+ const { result } = renderHook(() => useVisitorData({ linkedId: 'auto' }), {
+ wrapper: createWrapper(),
+ })
- const wrapper = createWrapper()
- const { result } = renderHook(() => useVisitorData(), { wrapper })
act(() => {
- void result.current.getData(mockedTags, mockedLinkedId)
+ void result.current.getData()
+ void result.current.getData()
+ // This call should trigger a new request since the linkedId has changed
+ void result.current.getData({ linkedId: 'linked_id' })
})
+
await waitFor(() => {
- expect(getVisitorData).toHaveBeenCalledWith(mockedTags, mockedLinkedId, null)
+ expect(result.current.isFetched).toBe(true)
})
+ expect(getVisitorData).toHaveBeenCalledTimes(2)
+ expect(getVisitorData).toHaveBeenCalledWith(null, 'auto', null)
+ expect(getVisitorData).toHaveBeenCalledWith(null, 'linked_id', null)
+ expect(result.current.data).toStrictEqual(expectedData)
})
- it('options object with empty timeout should call `getVisitorData` function', async () => {
- const mockedJsonAnswer = {
- visitorId: mockedVisitorId,
- }
+ it('avoids race-condition with multiple requests', async () => {
+ let calls = 0
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const mockResponses = {
+ 1: nativeResponse,
+ 2: { ...nativeResponse, eventId: 'second' },
+ 3: { ...nativeResponse, eventId: 'third' },
+ } as Record
+
+ getVisitorData.mockReset()
+ getVisitorData.mockImplementation(async () => {
+ calls++
+ await new Promise((resolve) => {
+ setTimeout(resolve, (3 - calls) * 10)
+ })
+ return mockResponses[calls]
+ })
- const mockedTags = {
- stringTag: 'foo',
- numberTag: 0,
- ObjectTag: {
- foo: true,
- bar: [1, 2, 3],
- },
- boolTag: false,
- }
+ const { result } = renderHook(() => useVisitorData({ linkedId: 'auto' }), {
+ wrapper: createWrapper(),
+ })
- const mockedLinkedId = 'test_id'
+ act(() => {
+ void result.current.getData({ linkedId: '1' })
+ void result.current.getData({ linkedId: '2' })
+ void result.current.getData({ linkedId: '3' })
+ })
- const options: RequestOptions = { timeout: undefined }
+ await waitFor(() => {
+ expect(result.current.isFetched).toBe(true)
+ expect(getVisitorData).toHaveBeenCalledTimes(3)
+ })
+ expect(result.current.data).toStrictEqual({
+ ...expectedData,
+ event_id: 'third',
+ })
+ })
- 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' })
+
+ // The render with the new options must not expose the previous options' data.
+ expect(result.current).toMatchObject({ isLoading: true, isFetched: false, data: undefined })
+
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,
- }
+ it('fetches and enters loading when `immediate` changes from false to true', async () => {
+ const { result, rerender } = renderHook(
+ ({ immediate }: { immediate: boolean }) => useVisitorData({ immediate, linkedId: 'auto' }),
+ { wrapper: createWrapper(), initialProps: { immediate: false } }
+ )
- const mockedTags = {
- stringTag: 'foo',
- numberTag: 0,
- ObjectTag: {
- foo: true,
- bar: [1, 2, 3],
- },
- boolTag: false,
- }
+ expect(result.current.isLoading).toBe(false)
+ expect(getVisitorData).not.toHaveBeenCalled()
+
+ rerender({ immediate: true })
+
+ expect(result.current.isLoading).toBe(true)
+ await waitFor(() => {
+ expect(result.current.isFetched).toBe(true)
+ })
+ expect(getVisitorData).toHaveBeenCalledWith(null, 'auto', null)
+ expect(getVisitorData).toHaveBeenCalledTimes(1)
+ expect(result.current.data).toStrictEqual(expectedData)
+ })
- const mockedLinkedId = 'test_id'
+ it('leaves loading and ignores the response when `immediate` is disabled during an automatic request', async () => {
+ let resolveRequest!: (value: typeof nativeResponse) => void
+ getVisitorData.mockReset()
+ getVisitorData.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveRequest = resolve
+ })
+ )
- const options: RequestOptions = { timeout: 15_000 }
+ const { result, rerender } = renderHook(({ immediate }: { immediate: boolean }) => useVisitorData({ immediate }), {
+ wrapper: createWrapper(),
+ initialProps: { immediate: true },
+ })
- getVisitorData.mockReturnValueOnce(
- Promise.resolve({
- requestId: mockedRequestId,
- confidenceScore: mockedConfidenceScore,
- visitorDataJson: JSON.stringify(mockedJsonAnswer),
- })
+ await waitFor(() => {
+ expect(getVisitorData).toHaveBeenCalledTimes(1)
+ })
+ expect(result.current.isLoading).toBe(true)
+
+ rerender({ immediate: false })
+ expect(result.current.isLoading).toBe(false)
+
+ await act(async () => {
+ resolveRequest(nativeResponse)
+ await Promise.resolve()
+ })
+
+ expect(result.current).toMatchObject({
+ isLoading: false,
+ isFetched: false,
+ data: undefined,
+ })
+ })
+
+ it('keeps a manual request loading when the options change while `immediate` is false', async () => {
+ let resolveRequest!: (value: typeof nativeResponse) => void
+ getVisitorData.mockReset()
+ getVisitorData.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveRequest = resolve
+ })
)
- const wrapper = createWrapper()
- const { result } = renderHook(() => useVisitorData(), { wrapper })
+ const { result, rerender } = renderHook(({ linkedId }: { linkedId: string }) => useVisitorData({ linkedId }), {
+ wrapper: createWrapper(),
+ initialProps: { linkedId: 'first' },
+ })
+
act(() => {
- void result.current.getData(mockedTags, mockedLinkedId, options)
+ void result.current.getData()
})
+ expect(result.current.isLoading).toBe(true)
+
+ // A change of options must not disturb a manual request that is still in flight.
+ rerender({ linkedId: 'second' })
+ expect(result.current.isLoading).toBe(true)
+
+ await act(async () => {
+ resolveRequest(nativeResponse)
+ await Promise.resolve()
+ })
+
+ expect(getVisitorData).toHaveBeenCalledTimes(1)
+
+ expect(result.current).toMatchObject({
+ isLoading: false,
+ isFetched: true,
+ data: expectedData,
+ })
+ })
+
+ it('keeps a manual request loading when `immediate` is disabled while it is in flight', async () => {
+ let resolveManual!: (value: typeof nativeResponse) => void
+ getVisitorData.mockReset()
+ getVisitorData
+ .mockImplementationOnce(
+ () =>
+ new Promise(() => {
+ // never settles: the automatic request stays in flight for the whole test
+ })
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveManual = resolve
+ })
+ )
+
+ const { result, rerender } = renderHook(({ immediate }: { immediate: boolean }) => useVisitorData({ immediate }), {
+ wrapper: createWrapper(),
+ initialProps: { immediate: true },
+ })
+
await waitFor(() => {
- expect(getVisitorData).toHaveBeenCalledWith(mockedTags, mockedLinkedId, options.timeout)
+ expect(getVisitorData).toHaveBeenCalledTimes(1)
+ })
+
+ // The manual call supersedes the automatic one, so it owns the query state from now on.
+ // Its options differ so that the provider doesn't dedupe it into the pending automatic request.
+ act(() => {
+ void result.current.getData({ linkedId: 'manual' })
})
+ expect(result.current.isLoading).toBe(true)
+
+ rerender({ immediate: false })
+ expect(result.current.isLoading).toBe(true)
+
+ await act(async () => {
+ resolveManual(nativeResponse)
+ await Promise.resolve()
+ })
+
+ expect(result.current).toMatchObject({
+ isLoading: false,
+ isFetched: true,
+ data: expectedData,
+ })
+ })
+
+ 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, tags: tag }),
+ { wrapper: createWrapper(), initialProps: { tag: { userAction: 'login' } } }
+ )
+
+ await waitFor(() => {
+ expect(getVisitorData).toHaveBeenCalledTimes(1)
+ })
+
+ // Same values, brand-new object identity (as would happen with an inline prop).
+ await act(async () => {
+ rerender({ tag: { userAction: 'login' } })
+ await Promise.resolve()
+ })
+
+ expect(getVisitorData).toHaveBeenCalledTimes(1)
})
})
diff --git a/sdk/android/build.gradle b/sdk/android/build.gradle
index b6b3e9f9..aee5cfca 100644
--- a/sdk/android/build.gradle
+++ b/sdk/android/build.gradle
@@ -28,6 +28,11 @@ android {
kotlinOptions {
jvmTarget = "17"
+ // The Fingerprint Android SDK v4 is compiled with Kotlin 2.3.x metadata, which is newer than
+ // the Kotlin compiler RN/Expo uses (currently 2.0.x - 2.2.x across the range of supported RN versions).
+ // Skipping the metadata version check lets this module read the v4 SDK's classes.
+ // Kotlin metadata is forward/backward compatible within the same major version.
+ freeCompilerArgs += ["-Xskip-metadata-version-check"]
}
buildFeatures {
@@ -55,6 +60,6 @@ repositories {
dependencies {
implementation 'com.facebook.react:react-native:+'
- implementation "com.fingerprint.android:pro:$fingerprint_native_sdk_version"
+ implementation "com.fingerprint.android:sdk:$fingerprint_native_sdk_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib"
}
diff --git a/sdk/android/fingerprint.gradle b/sdk/android/fingerprint.gradle
index d0d5a6ed..48a1fe7e 100644
--- a/sdk/android/fingerprint.gradle
+++ b/sdk/android/fingerprint.gradle
@@ -1,4 +1,4 @@
-ext.fingerprint_native_sdk_version = "[2.17.0, 2.18.0)"
+ext.fingerprint_native_sdk_version = "[4.0.0, 5.0.0)"
task printFingerprintNativeSDKVersion {
doLast {
diff --git a/sdk/android/settings.gradle b/sdk/android/settings.gradle
index d41adc08..82e32608 100644
--- a/sdk/android/settings.gradle
+++ b/sdk/android/settings.gradle
@@ -8,5 +8,5 @@ pluginManagement {
plugins {
id 'com.android.library' version '8.6.0' apply false
- id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
+ id 'org.jetbrains.kotlin.android' version '2.3.20' apply false
}
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..8d4caf0e 100644
--- a/sdk/android/src/main/java/com/fingerprintjs/reactnative/RNFingerprintjsProModule.kt
+++ b/sdk/android/src/main/java/com/fingerprintjs/reactnative/RNFingerprintjsProModule.kt
@@ -2,39 +2,42 @@ package com.fingerprintjs.reactnative
import com.facebook.react.bridge.*
import com.facebook.react.module.annotations.ReactModule
-import com.fingerprintjs.android.fpjs_pro.Configuration
-import com.fingerprintjs.android.fpjs_pro.FingerprintJS
-import com.fingerprintjs.android.fpjs_pro.FingerprintJSProResponse
-import com.fingerprintjs.android.fpjs_pro.FingerprintJSFactory
-import com.fingerprintjs.android.fpjs_pro.Error
-import com.fingerprintjs.android.fpjs_pro.ApiKeyRequired
-import com.fingerprintjs.android.fpjs_pro.ApiKeyNotFound
-import com.fingerprintjs.android.fpjs_pro.ApiKeyExpired
-import com.fingerprintjs.android.fpjs_pro.RequestCannotBeParsed
-import com.fingerprintjs.android.fpjs_pro.Failed
-import com.fingerprintjs.android.fpjs_pro.RequestTimeout
-import com.fingerprintjs.android.fpjs_pro.TooManyRequest
-import com.fingerprintjs.android.fpjs_pro.OriginNotAvailable
-import com.fingerprintjs.android.fpjs_pro.HeaderRestricted
-import com.fingerprintjs.android.fpjs_pro.NotAvailableForCrawlBots
-import com.fingerprintjs.android.fpjs_pro.NotAvailableWithoutUA
-import com.fingerprintjs.android.fpjs_pro.WrongRegion
-import com.fingerprintjs.android.fpjs_pro.SubscriptionNotActive
-import com.fingerprintjs.android.fpjs_pro.UnsupportedVersion
-import com.fingerprintjs.android.fpjs_pro.InstallationMethodRestricted
-import com.fingerprintjs.android.fpjs_pro.ResponseCannotBeParsed
-import com.fingerprintjs.android.fpjs_pro.NetworkError
-import com.fingerprintjs.android.fpjs_pro.ClientTimeout
-import com.fingerprintjs.android.fpjs_pro.UnknownError
-import com.fingerprintjs.android.fpjs_pro.InvalidProxyIntegrationHeaders
-import com.fingerprintjs.android.fpjs_pro.InvalidProxyIntegrationSecret
-import com.fingerprintjs.android.fpjs_pro.ProxyIntegrationSecretEnvironmentMismatch
+import com.fingerprint.android.Configuration
+import com.fingerprint.android.Fingerprint
+import com.fingerprint.android.FingerprintFactory
+import com.fingerprint.android.FingerprintResponse
+import com.fingerprint.android.Error
+import com.fingerprint.android.ApiKeyRequired
+import com.fingerprint.android.ApiKeyNotFound
+import com.fingerprint.android.RequestCannotBeParsed
+import com.fingerprint.android.Failed
+import com.fingerprint.android.RequestTimeout
+import com.fingerprint.android.TooManyRequest
+import com.fingerprint.android.WrongRegion
+import com.fingerprint.android.SubscriptionNotActive
+import com.fingerprint.android.SubscriptionNotFound
+import com.fingerprint.android.SubscriptionRestricted
+import com.fingerprint.android.InstallationMethodRestricted
+import com.fingerprint.android.EnvironmentRestricted
+import com.fingerprint.android.ResponseCannotBeParsed
+import com.fingerprint.android.NetworkError
+import com.fingerprint.android.NetworkUnavailableError
+import com.fingerprint.android.ClientTimeout
+import com.fingerprint.android.UnknownError
+import com.fingerprint.android.VisitorNotFound
+import com.fingerprint.android.ServiceUnavailable
+import com.fingerprint.android.FeatureNotEnabled
+import com.fingerprint.android.MissingModule
+import com.fingerprint.android.PayloadTooLarge
+import com.fingerprint.android.InvalidProxyIntegrationHeaders
+import com.fingerprint.android.InvalidProxyIntegrationSecret
+import com.fingerprint.android.ProxyIntegrationSecretEnvironmentMismatch
import java.lang.Exception
@ReactModule(name = RNFingerprintjsProModule.NAME)
class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRNFingerprintjsProSpec(reactContext) {
- private var fpjsClient: FingerprintJS? = null
+ private var fpjsClient: Fingerprint? = null
override fun getName(): String {
return NAME
@@ -43,14 +46,13 @@ class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRN
override fun configure(
apiToken: String,
pluginVersion: String,
- extendedResponseFormat: Boolean,
fallbackEndpointUrls: ReadableArray,
allowUseOfLocationData: Boolean,
locationTimeoutMillis: Double,
regionKey: String?,
endpointUrl: String?
) {
- val factory = FingerprintJSFactory(reactApplicationContext)
+ val factory = FingerprintFactory(reactApplicationContext)
val region = when(regionKey) {
"eu" -> Configuration.Region.EU
"us" -> Configuration.Region.US
@@ -61,9 +63,8 @@ class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRN
val configuration = Configuration(
apiToken,
region,
- endpointUrl = endpointUrl ?: region.endpointUrl,
- extendedResponseFormat,
- fallbackEndpointUrls = fallbackEndpointUrls.toArrayList().filterIsInstance(),
+ endpointUrl ?: region.endpointUrl,
+ fallbackEndpointUrls.toArrayList().filterIsInstance(),
integrationInfo,
allowUseOfLocationData,
locationTimeoutMillis.toLong()
@@ -71,67 +72,36 @@ 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 ->
+ val callback = { result: FingerprintResponse ->
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.eventId)
+ putDouble("suspectScore", (result.suspectScore ?: -1).toDouble())
putString("sealedResult", result.sealedResult ?: "")
}
promise.resolve(visitorData)
}
+ val errorCallback = { error: Error ->
+ // Carry the optional event ID through `userInfo` — RN surfaces it as `error.userInfo.eventId`.
+ // `Error.eventId` defaults to the `"Unknown"` sentinel when the failure never reached the
+ // server, in which case it is omitted so `FingerprintError.event_id` stays `null`.
+ val userInfo = Arguments.createMap().apply {
+ val id = error.eventId
+ if (id.isNotEmpty() && id != UNKNOWN_EVENT_ID) putString("eventId", id)
+ }
+ promise.reject(getErrorCode(error), error.description ?: "", userInfo)
+ }
+ 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)
+ promise.reject("unknown_error", e)
}
}
@@ -143,36 +113,52 @@ class RNFingerprintjsProModule(reactContext: ReactApplicationContext) : NativeRN
?: emptyMap()
}
- 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"
+ // The React Native layer collapses every error into a single `FingerprintError` carrying a
+ // machine-friendly `code` (see `sdk/src/errors.ts`). We reject the promise with this code (surfaced
+ // as `error.code` in JS); codes mirror `@fingerprint/agent`'s `ErrorCode` values so identical
+ // failures report the same `code` on web and native.
+ //
+ // The `else` is intentional even though it currently covers every `Error` subclass: the dependency
+ // range is `[4.0.0, 5.0.0)`, so a future 4.x minor may introduce new error types, which the `else`
+ // degrades to `unknown_error` instead of failing the build.
+ @Suppress("REDUNDANT_ELSE_IN_WHEN")
+ private fun getErrorCode(error: Error): String {
+ return when(error) {
+ is ApiKeyRequired -> "public_api_key_required"
+ is ApiKeyNotFound -> "public_api_key_not_found"
+ is RequestCannotBeParsed -> "request_cannot_be_parsed"
+ is Failed -> "failed"
+ is RequestTimeout -> "request_read_timeout"
+ is TooManyRequest -> "too_many_requests"
+ is WrongRegion -> "wrong_region"
+ is SubscriptionNotActive -> "subscription_not_active"
+ is SubscriptionNotFound -> "subscription_not_found"
+ is SubscriptionRestricted -> "subscription_restricted"
+ is InstallationMethodRestricted -> "installation_method_restricted"
+ is EnvironmentRestricted -> "environment_restricted"
+ is ResponseCannotBeParsed -> "response_cannot_be_parsed"
+ // Both network failures collapse into one code, matching iOS and the web agent.
+ is NetworkError -> "network_error"
+ is NetworkUnavailableError -> "network_error"
+ is ClientTimeout -> "client_timeout"
+ is VisitorNotFound -> "visitor_not_found"
+ is ServiceUnavailable -> "service_unavailable"
+ is FeatureNotEnabled -> "feature_not_enabled"
+ is MissingModule -> "missing_module"
+ is PayloadTooLarge -> "payload_too_large"
+ 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
}
companion object {
const val NAME = "RNFingerprintjsPro"
+
+ // The Android SDK's `Error.eventId` sentinel for "no event id available" (see the SDK's
+ // `Error` sealed class default).
+ private const val UNKNOWN_EVENT_ID = "Unknown"
}
}
diff --git a/sdk/ios/FPJSError+React.swift b/sdk/ios/FPJSError+React.swift
index 2f395527..e5856ac5 100644
--- a/sdk/ios/FPJSError+React.swift
+++ b/sdk/ios/FPJSError+React.swift
@@ -6,47 +6,105 @@
//
import Foundation
-import FingerprintPro
+import Fingerprint
-extension FPJSError {
- var reactDescription: String {
- let description = self.localizedDescription
+// The React Native layer collapses every error into a single `FingerprintError` (see
+// `sdk/src/errors.ts`). We reject the promise with the API v4 snake_case `code`, the human-readable
+// `message`, and — for server errors — the `eventId` carried in the `NSError`'s `userInfo`, which
+// React Native forwards to JS as `error.code` / `error.message` / `error.userInfo.eventId`.
+extension FPError {
+ // The canonical snake_case error code, shared with Android and `@fingerprint/agent`.
+ var reactCode: String {
switch self {
case .invalidURL:
- return "InvalidURL: \(description)"
+ return "invalid_url"
case .invalidURLParams:
- return "InvalidURLParams: \(description)"
+ return "invalid_url_params"
case .apiError(let apiError):
- let reactErrorName = apiError.reactName("apiError")
- let message = apiError.message ?? description
- return "\(reactErrorName): \(message)"
- case .networkError(let networkError):
- return "NetworkError: \(networkError.localizedDescription)"
- case .jsonParsingError(let jsonParsingError):
- return "JsonParsingError: \(jsonParsingError.localizedDescription)"
+ return apiError.reactCode
+ case .networkError:
+ return "network_error"
+ case .jsonParsingError:
+ return "json_parsing_error"
case .invalidResponseType:
- return "InvalidResponseType: \(description)"
+ return "invalid_response_type"
case .clientTimeout:
- return "ClientTimeout: \(description)"
+ return "client_timeout"
case .unknownError:
fallthrough
@unknown default:
- return "UnknownError: \(description)"
+ return "unknown_error"
+ }
+ }
+
+ // A human-readable message. For API errors we prefer the server-provided message.
+ var reactMessage: String {
+ switch self {
+ case .apiError(let apiError):
+ return apiError.message ?? self.localizedDescription
+ case .networkError(let networkError):
+ return networkError.localizedDescription
+ case .jsonParsingError(let jsonParsingError):
+ return jsonParsingError.localizedDescription
+ default:
+ return self.localizedDescription
+ }
+ }
+
+ // The Server API event ID, present only for API errors — populates `FingerprintError.event_id`.
+ var reactEventId: String? {
+ switch self {
+ case .apiError(let apiError):
+ return apiError.reactEventId
+ default:
+ return nil
}
}
}
extension APIError {
- func reactName(_ defaultName: String) -> String {
- let name = self.error?.code?.rawValue ?? defaultName
- return name.firstUppercased
+ // The event ID the Server API attached to the failed request, or `nil` when absent.
+ var reactEventId: String? {
+ let eventId = self.eventId
+ return eventId.isEmpty ? nil : eventId
}
- var message: String? {
- return self.error?.message
+ // The SDK's `APIError.Code` has no explicit raw values, so `rawValue` is the camelCase case name
+ // (e.g. `tooManyRequests`). Map it to the canonical snake_case codes shared with Android and
+ // `@fingerprint/agent` so `FingerprintError.code` is identical across platforms. Falls back to
+ // the generic `failed` code when the server error carries no recognized code.
+ var reactCode: String {
+ guard let code = self.errorDetails?.code else {
+ return "failed"
+ }
+ switch code {
+ case .requestCannotBeParsed: return "request_cannot_be_parsed"
+ case .requestReadTimeout: return "request_read_timeout"
+ case .publicApiKeyRequired: return "public_api_key_required"
+ case .publicApiKeyNotFound: return "public_api_key_not_found"
+ case .subscriptionNotActive: return "subscription_not_active"
+ case .wrongRegion: return "wrong_region"
+ case .featureNotEnabled: return "feature_not_enabled"
+ case .visitorNotFound: return "visitor_not_found"
+ case .tooManyRequests: return "too_many_requests"
+ case .failed: return "failed"
+ case .eventNotFound: return "event_not_found"
+ case .missingModule: return "missing_module"
+ case .payloadTooLarge: return "payload_too_large"
+ case .serviceUnavailable: return "service_unavailable"
+ case .invalidProxyIntegrationSecret: return "invalid_proxy_integration_secret"
+ case .proxyIntegrationSecretEnvironmentMismatch: return "proxy_integration_secret_environment_mismatch"
+ case .invalidProxyIntegrationHeaders: return "invalid_proxy_integration_headers"
+ case .subscriptionRestricted: return "subscription_restricted"
+ case .environmentRestricted: return "environment_restricted"
+ case .subscriptionNotFound: return "subscription_not_found"
+ case .installationMethodRestricted: return "installation_method_restricted"
+ // A future SDK may add codes; fall back to the raw value rather than losing the information.
+ @unknown default: return code.rawValue
+ }
}
-}
-extension StringProtocol {
- var firstUppercased: String { prefix(1).uppercased() + dropFirst() }
+ var message: String? {
+ return self.errorDetails?.message
+ }
}
diff --git a/sdk/ios/JSONTypeConvertor.swift b/sdk/ios/JSONTypeConvertor.swift
index 4cea4c3a..eb61eb97 100644
--- a/sdk/ios/JSONTypeConvertor.swift
+++ b/sdk/ios/JSONTypeConvertor.swift
@@ -6,7 +6,7 @@
//
import Foundation
-import FingerprintPro
+import Fingerprint
class JSONTypeConvertor {
static func convertDictionaryToJSONTypeConvertible(_ dict: [String: Any]) -> [String: JSONTypeConvertible] {
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..b7fd3001 100644
--- a/sdk/ios/RNFingerprintjsPro.swift
+++ b/sdk/ios/RNFingerprintjsPro.swift
@@ -2,7 +2,8 @@
// RNFingerprintjsPro.swift
// RNFingerprintjsPro
//
-import FingerprintPro
+import Fingerprint
+import Foundation
@objc(RNFingerprintjsPro)
class RNFingerprintjsPro: NSObject {
@@ -11,52 +12,37 @@ 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)
- 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)
- }
+ let configuration = Configuration(apiKey: apiToken, region: region, integrationInfo: integrationInfo, allowUseOfLocationData: allowUseOfLocationData)
+ fpjsClient = FingerprintFactory.getInstance(configuration)
}
@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
+ let completionHandler: Fingerprint.VisitorIdResponseBlock = { visitorIdResponseResult in
switch visitorIdResponseResult {
case let .success(visitorDataResponse):
let visitorData: [String: Any] = [
- "requestId": visitorDataResponse.requestId,
- "confidenceScore": visitorDataResponse.confidence,
- "visitorDataJson": visitorDataResponse.asJSON(),
+ "visitorId": visitorDataResponse.visitorId,
+ "eventId": visitorDataResponse.eventId,
+ "suspectScore": visitorDataResponse.suspectScore ?? -1,
"sealedResult": visitorDataResponse.sealedResult ?? "",
]
resolve(visitorData)
case .failure(let error):
- let description = error.reactDescription
- reject("Error: ", description, error)
+ // Reject with the structured code/message, carrying the optional event ID through the
+ // `NSError`'s `userInfo` — RN surfaces it as `error.code` / `error.userInfo.eventId`.
+ var userInfo: [String: Any] = [:]
+ if let eventId = error.reactEventId {
+ userInfo["eventId"] = eventId
+ }
+ let nsError = NSError(domain: "FingerprintPro", code: 0, userInfo: userInfo)
+ reject(error.reactCode, error.reactMessage, nsError)
}
}
diff --git a/sdk/ios/RNFingerprintjsPro.xcodeproj/project.pbxproj b/sdk/ios/RNFingerprintjsPro.xcodeproj/project.pbxproj
index c67aef03..751dbfa6 100644
--- a/sdk/ios/RNFingerprintjsPro.xcodeproj/project.pbxproj
+++ b/sdk/ios/RNFingerprintjsPro.xcodeproj/project.pbxproj
@@ -3,11 +3,13 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 46;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
10F8301B27A9CBEC00149386 /* RNFingerprintjsPro.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F8301A27A9CBEC00149386 /* RNFingerprintjsPro.swift */; };
+ 7791DF533046F87B00BEC6A6 /* Fingerprint in Frameworks */ = {isa = PBXBuildFile; productRef = 7791DF523046F87B00BEC6A6 /* Fingerprint */; };
+ 7791DF553046F9D900BEC6A6 /* FPJSError+React.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7791DF543046F9D900BEC6A6 /* FPJSError+React.swift */; };
B3E7B58A1CC2AC0600A0062D /* RNFingerprintjsPro.mm in Sources */ = {isa = PBXBuildFile; fileRef = B3E7B5891CC2AC0600A0062D /* RNFingerprintjsPro.mm */; };
/* End PBXBuildFile section */
@@ -27,6 +29,7 @@
10F8301927A9CBEC00149386 /* RNFingerprintjsPro-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNFingerprintjsPro-Bridging-Header.h"; sourceTree = ""; };
10F8301A27A9CBEC00149386 /* RNFingerprintjsPro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNFingerprintjsPro.swift; sourceTree = ""; };
134814201AA4EA6300B7C361 /* libRNFingerprintjsPro.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNFingerprintjsPro.a; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7791DF543046F9D900BEC6A6 /* FPJSError+React.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FPJSError+React.swift"; sourceTree = ""; };
7DC52BF728242E1900F78B99 /* RNFingerprintjsPro.podspec */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = RNFingerprintjsPro.podspec; path = ../RNFingerprintjsPro.podspec; sourceTree = ""; };
B3E7B5891CC2AC0600A0062D /* RNFingerprintjsPro.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNFingerprintjsPro.mm; sourceTree = ""; };
/* End PBXFileReference section */
@@ -36,6 +39,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 7791DF533046F87B00BEC6A6 /* Fingerprint in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -55,6 +59,7 @@
children = (
7DC52BF728242E1900F78B99 /* RNFingerprintjsPro.podspec */,
B3E7B5891CC2AC0600A0062D /* RNFingerprintjsPro.mm */,
+ 7791DF543046F9D900BEC6A6 /* FPJSError+React.swift */,
10F8301A27A9CBEC00149386 /* RNFingerprintjsPro.swift */,
134814211AA4EA7D00B7C361 /* Products */,
10F8301927A9CBEC00149386 /* RNFingerprintjsPro-Bridging-Header.h */,
@@ -105,6 +110,9 @@
en,
);
mainGroup = 58B511D21A9E6C8500147676;
+ packageReferences = (
+ 7791DF513046F87B00BEC6A6 /* XCRemoteSwiftPackageReference "fingerprint-ios" */,
+ );
productRefGroup = 58B511D21A9E6C8500147676;
projectDirPath = "";
projectRoot = "";
@@ -119,6 +127,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 7791DF553046F9D900BEC6A6 /* FPJSError+React.swift in Sources */,
B3E7B58A1CC2AC0600A0062D /* RNFingerprintjsPro.mm in Sources */,
10F8301B27A9CBEC00149386 /* RNFingerprintjsPro.swift in Sources */,
);
@@ -218,7 +227,11 @@
"$(SRCROOT)/../../../React/**",
"$(SRCROOT)/../../react-native/React/**",
);
- LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
LIBRARY_SEARCH_PATHS = "$(inherited)";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_NAME = RNFingerprintjsPro;
@@ -239,7 +252,11 @@
"$(SRCROOT)/../../../React/**",
"$(SRCROOT)/../../react-native/React/**",
);
- LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
LIBRARY_SEARCH_PATHS = "$(inherited)";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_NAME = RNFingerprintjsPro;
@@ -271,6 +288,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ 7791DF513046F87B00BEC6A6 /* XCRemoteSwiftPackageReference "fingerprint-ios" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/fingerprintjs/fingerprint-ios";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 4.0.0;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 7791DF523046F87B00BEC6A6 /* Fingerprint */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 7791DF513046F87B00BEC6A6 /* XCRemoteSwiftPackageReference "fingerprint-ios" */;
+ productName = Fingerprint;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = 58B511D31A9E6C8500147676 /* Project object */;
}
diff --git a/sdk/package.json b/sdk/package.json
index da0fa044..12ae3e0d 100644
--- a/sdk/package.json
+++ b/sdk/package.json
@@ -17,7 +17,8 @@
"copy:license": "cp ../LICENSE ./LICENSE",
"test": "jest",
"test:coverage": "jest --coverage",
- "test:dts": "tsc -p tsconfig.check.json",
+ "test:dts": "tsc -p tsconfig.check.json && pnpm test:types:compatibility",
+ "test:types:compatibility": "tsc -p tsconfig.type-tests.json",
"docs": "typedoc src/index.ts --out ../docs --readme ./README.md --tsconfig ./tsconfig.sdk.json"
},
"files": [
@@ -59,7 +60,7 @@
"author": "FingerprintJS, Inc (https://fingerprint.com)",
"license": "MIT",
"devDependencies": {
- "@fingerprintjs/fingerprintjs-pro-spa": "^1.3.2",
+ "@fingerprint/agent": "^4.1.5",
"@react-native/babel-preset": "^0.85.3",
"@react-native/eslint-config": "0.76.6",
"@react-native/jest-preset": "^0.86.0",
@@ -83,14 +84,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/scripts/sync-podspec-version.sh b/sdk/scripts/sync-podspec-version.sh
new file mode 100755
index 00000000..8b68ab22
--- /dev/null
+++ b/sdk/scripts/sync-podspec-version.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Sets the podspec s.version to the version in package.json.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+version=$(node -p "require('./package.json').version")
+podspec=RNFingerprintjsPro.podspec
+
+sed -E "s/^([[:space:]]*s\.version[[:space:]]*=[[:space:]]*).*/\1\"$version\"/" "$podspec" > "$podspec.tmp"
+mv "$podspec.tmp" "$podspec"
+
+grep -E '^\s*s\.version' "$podspec"
diff --git a/sdk/src/FingerprintClient.ts b/sdk/src/FingerprintClient.ts
new file mode 100644
index 00000000..dc24e5d1
--- /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, TagsValue } 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: TagsValue | 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?.tags),
+ 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..09ba42a8
--- /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,
+ cache: web?.cache,
+ integrationInfo: [`fingerprint-pro-react-native/${packageVersion}/web`],
+ })
+ }
+
+ public async get(options?: GetOptions): Promise {
+ try {
+ const result = await this.agent.get({
+ tags: options?.tags,
+ 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,
+ cache_hit: result.cache_hit,
+ }
+ } 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)
+}
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..24b34f35
--- /dev/null
+++ b/sdk/src/FingerprintProvider.tsx
@@ -0,0 +1,56 @@
+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'
+import { usePromiseStore } from './usePromiseStore'
+
+/**
+ * Provides the {@link FingerprintContext} to its child components.
+ *
+ * @group Hooks approach
+ *
+ * @example
+ * ```jsx
+ *
+ *
+ *
+ * ```
+ */
+export function FingerprintProvider({ children, ...options }: PropsWithChildren) {
+ const { doRequest } = usePromiseStore()
+
+ // `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) => {
+ return doRequest(() => client.get(getOptions), getOptions)
+ },
+ [client, doRequest]
+ )
+
+ const contextValue = useMemo(() => ({ client, getVisitorData }), [client, getVisitorData])
+
+ return {children}
+}
diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts
index 45922fce..2b421633 100644
--- a/sdk/src/errors.ts
+++ b/sdk/src/errors.ts
@@ -1,364 +1,122 @@
-/**
- * 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 ?')
+ }
+}
+
+/**
+ * The known values of {@link FingerprintError.code}, emmited by one or multiple platforms (web, iOS, and Android).
+ *
+ * The list is not exhaustive: a newer native SDK or the web agent may emit a code not yet included
+ * here, so {@link FingerprintError.code} accepts any string. This union exists to give editor
+ * autocomplete for the common cases (e.g. in `error.code === '...'` checks).
+ *
+ * @group Errors
+ */
+export type ErrorCode =
+ // Server / API v4 errors (emitted on every platform)
+ | 'failed'
+ | 'request_cannot_be_parsed'
+ | 'request_read_timeout'
+ | 'response_cannot_be_parsed'
+ | 'too_many_requests'
+ | 'public_api_key_required'
+ | 'public_api_key_not_found'
+ | 'subscription_not_active'
+ | 'subscription_restricted'
+ | 'wrong_region'
+ | 'feature_not_enabled'
+ | 'visitor_not_found'
+ | 'event_not_found'
+ | 'missing_module'
+ | 'payload_too_large'
+ | 'service_unavailable'
+ | 'environment_restricted'
+ | 'installation_method_restricted'
+ | 'invalid_proxy_integration_secret'
+ | 'invalid_proxy_integration_headers'
+ | 'proxy_integration_secret_environment_mismatch'
+ | 'sandboxed_iframe'
+ // Client-side errors — every platform reports network failures as `network_error`
+ | 'network_error'
+ // Client-side errors — native (iOS / Android)
+ | 'invalid_url'
+ | 'invalid_url_params'
+ | 'json_parsing_error'
+ | 'invalid_response_type'
+ | 'client_timeout'
+ | 'unknown_error'
+ // Client-side errors — web (`@fingerprint/agent`)
+ | 'csp_block'
+ | 'invalid_endpoint'
+ | 'handle_agent_data'
+ | 'script_load_fail'
+ | 'bundle_not_defined'
+ | 'bad_response_format'
+ | 'server_error'
+ | 'api_key_missing'
+ | 'api_key_invalid'
+ | 'cache_misconfigured'
+ | 'endpoints_misconfigured'
+ | 'wrong_worker_option'
+ | 'worker_initialization_failed'
-/**
- * Something wrong with network
- *
- * @group Errors
- */
-export class NetworkError extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'NetworkError'
- }
-}
+// `string & {}` keeps TypeScript from widening the union back to `string`, so known codes stay
+// available for autocomplete while any other string remains assignable.
+type FingerprintErrorCode = ErrorCode | (string & {})
/**
- * Error while parsing JSON response
+ * The single error type thrown by the SDK on every platform.
*
- * @group Errors
- */
-export class JsonParsingError extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'JsonParsingError'
- }
-}
-
-/**
- * Bad response type
+ * It mirrors the error of `@fingerprint/agent`: a machine-friendly {@link FingerprintError.code}
+ * plus a resolution-oriented `message`.
*
* @group Errors
- */
-export class InvalidResponseTypeError extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'InvalidResponseType'
- }
-}
-
-/**
- * Client-side timeout error
*
- * @group Errors
+ * @example
+ * ```ts
+ * try {
+ * await client.get()
+ * } catch (error) {
+ * if (isFingerprintError(error) && error.code === 'too_many_requests') {
+ * // handle rate limiting
+ * }
+ * }
+ * ```
*/
-export class ClientTimeoutError extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'ClientTimeoutError'
- }
-}
+export class FingerprintError extends Error {
+ public readonly name = 'FingerprintError'
-/**
- * Other error
- *
- * @group Errors
- */
-export class UnknownError extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'UnknownError'
- }
-}
-
-/**
- * Unexpected proxy integration implementation or networking error
- *
- * @group Errors
- */
-export class InvalidProxyIntegrationHeadersError extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'InvalidProxyIntegrationHeadersError'
- }
-}
+ /**
+ * A machine-friendly code for the type of this error, e.g. `too_many_requests`. Known values are
+ * listed in {@link ErrorCode}; any other string is still possible (see that type's note).
+ */
+ public readonly code: FingerprintErrorCode
-/**
- * Proxy integration secret is missing or invalid
- *
- * @group Errors
- */
-export class InvalidProxyIntegrationSecretError extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'InvalidProxyIntegrationSecretError'
- }
-}
+ /**
+ * 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
-/**
- * Proxy integration secret is from the different env with Public key
- *
- * @group Errors
- */
-export class ProxyIntegrationSecretEnvironmentMismatch extends Error {
- constructor(message: string) {
+ constructor({ code, message, event_id }: { code: FingerprintErrorCode; message?: string; event_id?: string | null }) {
super(message)
- this.name = 'ProxyIntegrationSecretEnvironmentMismatch'
- }
+ 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)
+ }
+}
+
+/**
+ * Type guard to check whether an error is a {@link FingerprintError}.
+ *
+ * @group Errors
+ */
+export function isFingerprintError(error: unknown): error is FingerprintError {
+ return error instanceof FingerprintError
+}
+
+export function getErrorMessage(error: unknown) {
+ return error instanceof Error ? error.message : String(error)
}
-
-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/getOptionsCacheKey.ts b/sdk/src/getOptionsCacheKey.ts
new file mode 100644
index 00000000..90a3b9f3
--- /dev/null
+++ b/sdk/src/getOptionsCacheKey.ts
@@ -0,0 +1,60 @@
+import { UseVisitorDataOptions } from './useVisitorData'
+
+function sortObjectKeys(value: unknown, seen = new Set