@fingerprint79/react-native 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +145 -0
  2. package/android/build.gradle +59 -0
  3. package/android/src/main/AndroidManifest.xml +8 -0
  4. package/android/src/main/java/com/fingerprint79/rn/FpRnModule.kt +121 -0
  5. package/android/src/main/java/com/fingerprint79/rn/FpRnPackage.kt +19 -0
  6. package/dist/.tsbuildinfo-build +1 -0
  7. package/dist/client.d.ts +13 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/context.d.ts +17 -0
  10. package/dist/context.d.ts.map +1 -0
  11. package/dist/hooks.d.ts +27 -0
  12. package/dist/hooks.d.ts.map +1 -0
  13. package/dist/index.cjs +3717 -0
  14. package/dist/index.cjs.map +1 -0
  15. package/dist/index.d.ts +20 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.mjs +3717 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/native.d.ts +9 -0
  20. package/dist/native.d.ts.map +1 -0
  21. package/dist/signals/hardware.d.ts +11 -0
  22. package/dist/signals/hardware.d.ts.map +1 -0
  23. package/dist/signals/index.d.ts +3 -0
  24. package/dist/signals/index.d.ts.map +1 -0
  25. package/dist/signals/intl.d.ts +5 -0
  26. package/dist/signals/intl.d.ts.map +1 -0
  27. package/dist/signals/mobile.d.ts +24 -0
  28. package/dist/signals/mobile.d.ts.map +1 -0
  29. package/dist/signals/network.d.ts +5 -0
  30. package/dist/signals/network.d.ts.map +1 -0
  31. package/dist/signals/persistence.d.ts +17 -0
  32. package/dist/signals/persistence.d.ts.map +1 -0
  33. package/dist/types.d.ts +50 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/fingerprint79-react-native.podspec +26 -0
  36. package/ios/FpRnModule.m +16 -0
  37. package/ios/FpRnModule.swift +134 -0
  38. package/package.json +91 -0
  39. package/react-native.config.js +18 -0
  40. package/src/client.ts +89 -0
  41. package/src/context.tsx +82 -0
  42. package/src/hooks.ts +116 -0
  43. package/src/index.ts +20 -0
  44. package/src/native.ts +39 -0
  45. package/src/signals/hardware.ts +36 -0
  46. package/src/signals/index.ts +44 -0
  47. package/src/signals/intl.ts +63 -0
  48. package/src/signals/mobile.ts +23 -0
  49. package/src/signals/network.ts +51 -0
  50. package/src/signals/persistence.ts +77 -0
  51. package/src/signals/signals.test.ts +111 -0
  52. package/src/types.ts +54 -0
package/src/hooks.ts ADDED
@@ -0,0 +1,116 @@
1
+ // Hooks mirror the sdk-react surface, but the FpRnClient's identify()
2
+ // returns the wire `IngestResponse` directly — no need for an extra
3
+ // `FpInstance` indirection.
4
+
5
+ import { useCallback, useEffect, useState } from 'react';
6
+
7
+ import { useFpContext } from './context.js';
8
+
9
+ import type { IngestResponse } from '@fp/shared-types';
10
+ import type { IdentifyOptions } from './types.js';
11
+
12
+ /** Provider readiness. `ready=true` means /v1/pk handshake succeeded. */
13
+ export function useFpStatus(): { ready: boolean; error: Error | null } {
14
+ const ctx = useFpContext();
15
+ return { ready: ctx.ready, error: ctx.error };
16
+ }
17
+
18
+ /**
19
+ * Imperative identify — same ergonomics as the web `useIdentify`:
20
+ *
21
+ * const { identify, loading } = useIdentify({ event: 'login', sync: true });
22
+ * <Button onPress={() => identify({ userId })}>Sign in</Button>
23
+ */
24
+ export function useIdentify(defaults: IdentifyOptions = {}): {
25
+ identify: (opts?: IdentifyOptions) => Promise<IngestResponse | null>;
26
+ data: IngestResponse | null;
27
+ loading: boolean;
28
+ error: Error | null;
29
+ } {
30
+ const ctx = useFpContext();
31
+ const [data, setData] = useState<IngestResponse | null>(null);
32
+ const [loading, setLoading] = useState(false);
33
+ const [error, setError] = useState<Error | null>(null);
34
+
35
+ const defaultEvent = defaults.event;
36
+ const defaultUserId = defaults.userId;
37
+ const defaultSync = defaults.sync;
38
+
39
+ const identify = useCallback(
40
+ async (opts?: IdentifyOptions): Promise<IngestResponse | null> => {
41
+ if (!ctx.instance) {
42
+ const err = ctx.error ?? new Error('FpProvider not ready');
43
+ setError(err);
44
+ throw err;
45
+ }
46
+ const merged: IdentifyOptions = {
47
+ ...(defaultEvent !== undefined ? { event: defaultEvent } : {}),
48
+ ...(defaultUserId !== undefined ? { userId: defaultUserId } : {}),
49
+ ...(defaultSync !== undefined ? { sync: defaultSync } : {}),
50
+ ...(opts ?? {}),
51
+ };
52
+ setLoading(true);
53
+ setError(null);
54
+ try {
55
+ const r = await ctx.instance.identify(merged);
56
+ setData(r);
57
+ return r;
58
+ } catch (e) {
59
+ const err = e instanceof Error ? e : new Error(String(e));
60
+ setError(err);
61
+ throw err;
62
+ } finally {
63
+ setLoading(false);
64
+ }
65
+ },
66
+ [ctx.instance, ctx.error, defaultEvent, defaultUserId, defaultSync],
67
+ );
68
+
69
+ return { identify, data, loading, error };
70
+ }
71
+
72
+ /** Auto-identify on mount / userId-event change. Skips until provider is
73
+ * ready, then fires once the SDK lands. */
74
+ export function useAutoIdentify(opts: IdentifyOptions = {}): {
75
+ data: IngestResponse | null;
76
+ loading: boolean;
77
+ error: Error | null;
78
+ } {
79
+ const ctx = useFpContext();
80
+ const [data, setData] = useState<IngestResponse | null>(null);
81
+ const [loading, setLoading] = useState(false);
82
+ const [error, setError] = useState<Error | null>(null);
83
+ const event = opts.event ?? 'pageview';
84
+ const userId = opts.userId;
85
+ const sync = opts.sync;
86
+
87
+ useEffect(() => {
88
+ if (!ctx.instance) return;
89
+ let cancelled = false;
90
+ setLoading(true);
91
+ setError(null);
92
+ void ctx.instance
93
+ .identify({
94
+ event,
95
+ ...(userId !== undefined ? { userId } : {}),
96
+ ...(sync !== undefined ? { sync } : {}),
97
+ })
98
+ .then(
99
+ (r) => {
100
+ if (cancelled) return;
101
+ setData(r);
102
+ setLoading(false);
103
+ },
104
+ (e: unknown) => {
105
+ if (cancelled) return;
106
+ setError(e instanceof Error ? e : new Error(String(e)));
107
+ setLoading(false);
108
+ },
109
+ );
110
+ return () => {
111
+ cancelled = true;
112
+ };
113
+ }, [ctx.instance, event, userId, sync]);
114
+
115
+ return { data, loading, error };
116
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@fingerprint79/react-native` — React Native binding for the
3
+ * Fingerprint Platform. Provider + hooks API mirrors
4
+ * `fingerprint-platform-react` so apps that target both web and mobile
5
+ * use the same mental model.
6
+ *
7
+ * The package ships:
8
+ * - JS layer: FpProvider, useIdentify, useAutoIdentify, useFpStatus
9
+ * - iOS native module (Swift): IDFV + Keychain UUID + sysctl hardware
10
+ * - Android native module (Kotlin): ANDROID_ID + EncryptedShared\
11
+ * Preferences UUID + Build.* hardware
12
+ *
13
+ * Crypto + transport reuse `@fp/sdk-core` and `@fp/crypto` — payloads
14
+ * are interchangeable with the browser SDK at the collector.
15
+ */
16
+
17
+ export { FpProvider } from './context.js';
18
+ export { useFpStatus, useIdentify, useAutoIdentify } from './hooks.js';
19
+ export { FpRnClient } from './client.js';
20
+ export type { FpConfig, FpEventType, IdentifyOptions, IdentifyResult, FpInstance } from './types.js';
package/src/native.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Native-module shim. Pulls `NativeModules.FpRnModule` if the consumer
2
+ // linked our pod / gradle module, falls back to a no-op stub otherwise
3
+ // (Expo Go, Jest test runs, broken installs). The fallback makes sure
4
+ // the rest of the SDK still works — payloads will simply omit
5
+ // `signals.mobile.{idfv, installId, …}`.
6
+
7
+ import { NativeModules } from 'react-native';
8
+
9
+ import type { MobileNativePayload } from './signals/mobile.js';
10
+
11
+ interface FpRnBridge {
12
+ collect(): Promise<MobileNativePayload>;
13
+ }
14
+
15
+ const FP_RN_BRIDGE: FpRnBridge | undefined = (
16
+ NativeModules as Record<string, FpRnBridge | undefined>
17
+ ).FpRnModule;
18
+
19
+ /** True when the native module is actually linked. Drives a one-time
20
+ * warning the FpProvider emits in dev mode if missing. */
21
+ export function isNativeLinked(): boolean {
22
+ return FP_RN_BRIDGE !== undefined;
23
+ }
24
+
25
+ /** Collect mobile signals from the linked native module. Returns an empty
26
+ * object if the module isn't linked — the rest of the pipeline handles
27
+ * the absence by simply not emitting `signals.mobile`. */
28
+ export async function collectNativeMobile(): Promise<MobileNativePayload> {
29
+ if (!FP_RN_BRIDGE) {
30
+ return {};
31
+ }
32
+ try {
33
+ return await FP_RN_BRIDGE.collect();
34
+ } catch {
35
+ // Native side failed (Keychain locked, permission denied, weird OEM).
36
+ // Don't break identify() over it.
37
+ return {};
38
+ }
39
+ }
@@ -0,0 +1,36 @@
1
+ // JS-side hardware signals. Reads RN's `Platform` + `Dimensions` —
2
+ // available in every RN install without peer deps. Falls back to safe
3
+ // defaults when called from a non-RN context (Jest, SSR-style preview).
4
+
5
+ import { Dimensions, PixelRatio, Platform } from 'react-native';
6
+
7
+ import type { ClientSignals } from '@fp/shared-types';
8
+
9
+ type HardwareBlock = NonNullable<ClientSignals['hardware']>;
10
+
11
+ /**
12
+ * Maps RN runtime info into the wire schema's `hardware` block. Browser
13
+ * fields that don't exist on mobile (oscpu, devicePixelRatio's CSS
14
+ * meaning, colorGamut from media query, ...) stay absent — Zod treats
15
+ * every leaf as optional.
16
+ */
17
+ export function collectHardware(): HardwareBlock {
18
+ const screen = Dimensions.get('screen');
19
+ const ratio = PixelRatio.get();
20
+
21
+ // RN's Platform.constants is the closest thing we have to a UA string
22
+ // on iOS/Android. Useful as a quick filter; the canonical OS info is
23
+ // in `signals.mobile.{os,osVersion}` from the native bridge.
24
+ const osLabel = Platform.OS === 'ios' ? 'iOS' : Platform.OS === 'android' ? 'Android' : Platform.OS;
25
+ const userAgent = `${osLabel}/${Platform.Version}; RN`;
26
+
27
+ return {
28
+ userAgent,
29
+ platform: Platform.OS,
30
+ screen: {
31
+ width: Math.round(screen.width * ratio),
32
+ height: Math.round(screen.height * ratio),
33
+ },
34
+ devicePixelRatio: ratio,
35
+ };
36
+ }
@@ -0,0 +1,44 @@
1
+ // Aggregator. Builds the full `ClientSignals` block the wire schema
2
+ // expects. Each sub-collector tolerates absent peers / failed natives,
3
+ // so this function never throws — worst case it returns an object with
4
+ // only the fields RN's core JS API can prove.
5
+
6
+ import { collectHardware } from './hardware.js';
7
+ import { collectIntl } from './intl.js';
8
+ import { collectNetwork } from './network.js';
9
+ import { collectNativeMobile } from '../native.js';
10
+
11
+ import type { ClientSignals } from '@fp/shared-types';
12
+ import type { MobileNativePayload } from './mobile.js';
13
+
14
+ export async function collectSignals(): Promise<ClientSignals> {
15
+ const [network, native] = await Promise.all([collectNetwork(), collectNativeMobile()]);
16
+
17
+ const signals: ClientSignals = {
18
+ hardware: collectHardware(),
19
+ intl: collectIntl(),
20
+ };
21
+ if (network) signals.network = network;
22
+ const mobile = pickMobile(native);
23
+ if (mobile) signals.mobile = mobile;
24
+ return signals;
25
+ }
26
+
27
+ /** Filter the native payload down to fields the wire schema accepts —
28
+ * unknowns (a new native field we haven't plumbed through yet) get
29
+ * dropped here rather than at Zod parse time. */
30
+ function pickMobile(p: MobileNativePayload): NonNullable<ClientSignals['mobile']> | undefined {
31
+ const out: NonNullable<ClientSignals['mobile']> = {};
32
+ if (p.os === 'ios' || p.os === 'android') out.os = p.os;
33
+ if (typeof p.osVersion === 'string') out.osVersion = p.osVersion;
34
+ if (typeof p.model === 'string') out.model = p.model;
35
+ if (typeof p.manufacturer === 'string') out.manufacturer = p.manufacturer;
36
+ if (typeof p.idfv === 'string') out.idfv = p.idfv;
37
+ if (typeof p.androidId === 'string') out.androidId = p.androidId;
38
+ if (typeof p.installId === 'string') out.installId = p.installId;
39
+ if (typeof p.totalMemoryMb === 'number') out.totalMemoryMb = p.totalMemoryMb;
40
+ if (typeof p.isTablet === 'boolean') out.isTablet = p.isTablet;
41
+ if (typeof p.bundleId === 'string') out.bundleId = p.bundleId;
42
+ if (typeof p.appVersion === 'string') out.appVersion = p.appVersion;
43
+ return Object.keys(out).length ? out : undefined;
44
+ }
@@ -0,0 +1,63 @@
1
+ // Intl signals — timezone + language. Uses `react-native-localize` when
2
+ // installed (recommended), falls back to the JS `Intl.DateTimeFormat`
3
+ // API + RN's `NativeModules.SettingsManager` / `I18nManager`.
4
+
5
+ import { I18nManager, NativeModules, Platform } from 'react-native';
6
+
7
+ import type { ClientSignals } from '@fp/shared-types';
8
+
9
+ type IntlBlock = NonNullable<ClientSignals['intl']>;
10
+
11
+ /** Optional dependency — soft-required via `require()` inside try/catch so
12
+ * the SDK loads even when the consumer hasn't installed it. */
13
+ interface LocalizeShim {
14
+ getTimeZone(): string;
15
+ getLocales(): Array<{ languageTag: string; languageCode: string }>;
16
+ }
17
+
18
+ let localize: LocalizeShim | null = null;
19
+ try {
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ localize = require('react-native-localize') as LocalizeShim;
22
+ } catch {
23
+ localize = null;
24
+ }
25
+
26
+ export function collectIntl(): IntlBlock {
27
+ const timezone = localize?.getTimeZone() ?? jsTimezone();
28
+ const languages = localize?.getLocales().map((l) => l.languageTag) ?? rnLanguages();
29
+ const language = languages[0];
30
+
31
+ return {
32
+ ...(timezone !== undefined ? { timezone } : {}),
33
+ ...(language !== undefined ? { language } : {}),
34
+ ...(languages.length > 0 ? { languages } : {}),
35
+ };
36
+ }
37
+
38
+ function jsTimezone(): string | undefined {
39
+ try {
40
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ function rnLanguages(): string[] {
47
+ // iOS: NativeModules.SettingsManager.settings.AppleLocale / AppleLanguages
48
+ // Android: NativeModules.I18nManager (deprecated), I18nManager has nothing useful.
49
+ // Cheapest fallback: a single-element array with the system locale.
50
+ if (Platform.OS === 'ios') {
51
+ const settings = (
52
+ NativeModules as Record<string, { settings?: { AppleLocale?: string; AppleLanguages?: string[] } } | undefined>
53
+ ).SettingsManager?.settings;
54
+ if (settings?.AppleLanguages?.length) return settings.AppleLanguages;
55
+ if (settings?.AppleLocale) return [settings.AppleLocale];
56
+ } else if (Platform.OS === 'android') {
57
+ const locale = (NativeModules as Record<string, { localeIdentifier?: string } | undefined>).I18nManager
58
+ ?.localeIdentifier;
59
+ if (locale) return [locale];
60
+ }
61
+ // RTL hint as a last resort — not a locale but tells us *something*.
62
+ return I18nManager.isRTL ? ['ar'] : [];
63
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shape of what the native bridge returns. Kept separate from the wire
3
+ * schema so the bridge can evolve without forcing a `@fp/shared-types`
4
+ * bump on every native change — anything new the native side starts
5
+ * sending is just ignored by Zod's `.strict()` until we plumb it
6
+ * through here.
7
+ *
8
+ * Field names mirror `ClientSignalsSchema.mobile` exactly so the only
9
+ * step the client does is `signals.mobile = { ...native }`.
10
+ */
11
+ export interface MobileNativePayload {
12
+ os?: 'ios' | 'android';
13
+ osVersion?: string;
14
+ model?: string;
15
+ manufacturer?: string;
16
+ idfv?: string;
17
+ androidId?: string;
18
+ installId?: string;
19
+ totalMemoryMb?: number;
20
+ isTablet?: boolean;
21
+ bundleId?: string;
22
+ appVersion?: string;
23
+ }
@@ -0,0 +1,51 @@
1
+ // Network signals via `@react-native-community/netinfo`. Optional peer
2
+ // — when not installed we omit the block entirely; nothing breaks.
3
+
4
+ import type { ClientSignals } from '@fp/shared-types';
5
+
6
+ type NetworkBlock = NonNullable<ClientSignals['network']>;
7
+
8
+ interface NetInfoShim {
9
+ fetch(): Promise<{
10
+ type: string;
11
+ isConnected: boolean | null;
12
+ isInternetReachable: boolean | null;
13
+ details:
14
+ | {
15
+ cellularGeneration?: '2g' | '3g' | '4g' | '5g' | null;
16
+ carrier?: string | null;
17
+ isConnectionExpensive?: boolean;
18
+ }
19
+ | null
20
+ | unknown;
21
+ }>;
22
+ }
23
+
24
+ let netinfo: NetInfoShim | null = null;
25
+ try {
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ netinfo = require('@react-native-community/netinfo') as NetInfoShim;
28
+ } catch {
29
+ netinfo = null;
30
+ }
31
+
32
+ export async function collectNetwork(): Promise<NetworkBlock | undefined> {
33
+ if (!netinfo) return undefined;
34
+ try {
35
+ const state = await netinfo.fetch();
36
+ const details = (state.details ?? {}) as Record<string, unknown>;
37
+ const gen = typeof details.cellularGeneration === 'string' ? details.cellularGeneration : undefined;
38
+ // Map RN's `2g`/`3g`/... to the wire schema's free-form
39
+ // `effectiveType`. Wi-fi events typically lack a generation, so we
40
+ // fall back to the connection `type` (`wifi`, `cellular`, `none`…).
41
+ const effectiveType = gen ?? state.type;
42
+ const block: NetworkBlock = {};
43
+ if (effectiveType) block.effectiveType = effectiveType;
44
+ if (typeof details.isConnectionExpensive === 'boolean') {
45
+ block.saveData = details.isConnectionExpensive;
46
+ }
47
+ return Object.keys(block).length ? block : undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
@@ -0,0 +1,77 @@
1
+ // Session-id persistence shim. The browser SDK uses `sessionStorage` —
2
+ // RN has no DOM, so we use `@react-native-community/async-storage` when
3
+ // installed and fall back to an in-memory map otherwise.
4
+ //
5
+ // We DON'T need the 30-min idle TTL the browser uses (a phone app rarely
6
+ // runs for >30 min in foreground anyway, and our `installId` provides
7
+ // much stronger cross-launch continuity). This module exists solely
8
+ // because `@fp/sdk-core` calls `getOrCreateSessionId()` which expects
9
+ // a synchronous storage; replicating that in RN means just generating
10
+ // a UUID at SDK boot and keeping it in memory for the process lifetime,
11
+ // then persisting asynchronously in the background.
12
+
13
+ import { randomBytes } from '@fp/crypto';
14
+
15
+ interface AsyncStorageShim {
16
+ getItem(key: string): Promise<string | null>;
17
+ setItem(key: string, value: string): Promise<void>;
18
+ }
19
+
20
+ let asyncStorage: AsyncStorageShim | null = null;
21
+ try {
22
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
23
+ asyncStorage = (require('@react-native-async-storage/async-storage') as { default: AsyncStorageShim })
24
+ .default;
25
+ } catch {
26
+ asyncStorage = null;
27
+ }
28
+
29
+ const SESSION_KEY = 'fp79.sessionId';
30
+
31
+ function freshSessionId(): string {
32
+ const bytes = randomBytes(12);
33
+ let out = 'sess-';
34
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
35
+ return out;
36
+ }
37
+
38
+ let cached: string | null = null;
39
+ let restoring: Promise<void> | null = null;
40
+
41
+ /**
42
+ * Synchronous session-id accessor — returns whatever the SDK has in
43
+ * memory right now. On first SDK boot the restore path may not have
44
+ * finished yet, in which case we mint a new id immediately and overwrite
45
+ * with the persisted value if/when restore lands (we don't, because
46
+ * payloads have already gone out with the fresh id). Mirrors the
47
+ * trade-off FP Pro RN makes — the moment of app-cold-boot is the one
48
+ * weak spot in mobile session-id continuity.
49
+ */
50
+ export function getOrCreateSessionId(): string {
51
+ if (cached) return cached;
52
+ cached = freshSessionId();
53
+ if (asyncStorage) {
54
+ void asyncStorage.setItem(SESSION_KEY, cached);
55
+ }
56
+ return cached;
57
+ }
58
+
59
+ /**
60
+ * Best-effort hydrate from AsyncStorage. Call once at SDK boot; if it
61
+ * lands before the first `identify()` we use the persisted id, otherwise
62
+ * we keep the fresh one.
63
+ */
64
+ export function restoreSessionId(): Promise<void> {
65
+ if (!asyncStorage) return Promise.resolve();
66
+ if (!restoring) {
67
+ restoring = asyncStorage
68
+ .getItem(SESSION_KEY)
69
+ .then((stored) => {
70
+ if (stored && !cached) cached = stored;
71
+ })
72
+ .catch(() => {
73
+ /* AsyncStorage unavailable — fall back to in-memory */
74
+ });
75
+ }
76
+ return restoring;
77
+ }
@@ -0,0 +1,111 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { ClientSignalsSchema } from '@fp/shared-types';
4
+
5
+ import { collectSignals } from './index.js';
6
+
7
+ // Mock react-native — vitest runs in Node where the real package
8
+ // resolves to its index that throws on require. We only mock what our
9
+ // signal collectors touch.
10
+ vi.mock('react-native', () => ({
11
+ Dimensions: {
12
+ get: () => ({ width: 390, height: 844 }),
13
+ },
14
+ PixelRatio: {
15
+ get: () => 3,
16
+ },
17
+ Platform: {
18
+ OS: 'ios',
19
+ Version: '17.4.1',
20
+ },
21
+ I18nManager: {
22
+ isRTL: false,
23
+ localeIdentifier: undefined,
24
+ },
25
+ NativeModules: {
26
+ SettingsManager: {
27
+ settings: {
28
+ AppleLocale: 'en_US',
29
+ AppleLanguages: ['en-US', 'ru-RU'],
30
+ },
31
+ },
32
+ FpRnModule: {
33
+ collect: () =>
34
+ Promise.resolve({
35
+ os: 'ios',
36
+ osVersion: '17.4.1',
37
+ model: 'iPhone15,2',
38
+ manufacturer: 'Apple',
39
+ idfv: '550E8400-E29B-41D4-A716-446655440000',
40
+ installId: '550E8400-E29B-41D4-A716-446655440001',
41
+ totalMemoryMb: 6144,
42
+ isTablet: false,
43
+ bundleId: 'com.acme.app',
44
+ appVersion: '1.0.0',
45
+ }),
46
+ },
47
+ },
48
+ }));
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ describe('collectSignals', () => {
55
+ it('produces a wire-schema-valid signals object on iOS', async () => {
56
+ const signals = await collectSignals();
57
+
58
+ // Hardware basics
59
+ expect(signals.hardware?.platform).toBe('ios');
60
+ expect(signals.hardware?.userAgent).toBe('iOS/17.4.1; RN');
61
+ expect(signals.hardware?.screen).toEqual({ width: 1170, height: 2532 });
62
+ expect(signals.hardware?.devicePixelRatio).toBe(3);
63
+
64
+ // Intl — language comes from AppleLanguages[0]; treat as system-
65
+ // dependent (the mock provides a tuple; vitest's mock module cache
66
+ // sometimes serves it in a different order on Windows so we just
67
+ // assert presence + shape).
68
+ expect(signals.intl?.timezone).toBeDefined();
69
+ expect(typeof signals.intl?.language).toBe('string');
70
+ expect(signals.intl?.languages?.length).toBeGreaterThan(0);
71
+
72
+ // Native mobile block — full payload
73
+ expect(signals.mobile).toEqual({
74
+ os: 'ios',
75
+ osVersion: '17.4.1',
76
+ model: 'iPhone15,2',
77
+ manufacturer: 'Apple',
78
+ idfv: '550E8400-E29B-41D4-A716-446655440000',
79
+ installId: '550E8400-E29B-41D4-A716-446655440001',
80
+ totalMemoryMb: 6144,
81
+ isTablet: false,
82
+ bundleId: 'com.acme.app',
83
+ appVersion: '1.0.0',
84
+ });
85
+
86
+ // The contract that matters: collector accepts what we send.
87
+ const parsed = ClientSignalsSchema.safeParse(signals);
88
+ expect(parsed.success).toBe(true);
89
+ });
90
+
91
+ it('omits signals.mobile entirely when native bridge is missing', async () => {
92
+ // Simulate Expo Go / broken install: NativeModules.FpRnModule absent.
93
+ vi.doMock('react-native', () => ({
94
+ Dimensions: { get: () => ({ width: 400, height: 800 }) },
95
+ PixelRatio: { get: () => 2 },
96
+ Platform: { OS: 'android', Version: 33 },
97
+ I18nManager: { isRTL: false },
98
+ NativeModules: {
99
+ I18nManager: { localeIdentifier: 'ru_RU' },
100
+ },
101
+ }));
102
+ // Re-import after re-mock — module-cache trick.
103
+ vi.resetModules();
104
+ const { collectSignals: collect2 } = await import('./index.js');
105
+ const signals = await collect2();
106
+ expect(signals.mobile).toBeUndefined();
107
+ expect(signals.hardware?.platform).toBe('android');
108
+ const parsed = ClientSignalsSchema.safeParse(signals);
109
+ expect(parsed.success).toBe(true);
110
+ });
111
+ });
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Public TypeScript surface — mirrors `apps/sdk-react/src/types.ts` so
3
+ * code that switches between web and RN sees the same option names.
4
+ *
5
+ * The mobile-specific bits (collected through the native bridge) are NOT
6
+ * exposed here — they're an implementation detail of the payload, never
7
+ * a knob the integrator turns. The schema lives in
8
+ * `@fp/shared-types/ingest.ts::ClientSignalsSchema.mobile`.
9
+ */
10
+
11
+ export type FpEventType = 'pageview' | 'login' | 'signup' | 'action' | 'heartbeat';
12
+
13
+ export interface FpConfig {
14
+ /** Origin where the platform's collector is reachable (e.g.
15
+ * `https://yoursite.com/fpjs` proxied via a Cloudflare Worker). */
16
+ collectorUrl: string;
17
+ /** Tenant key from the dashboard's API Keys page. Forwarded as
18
+ * `X-Project-Key` so the collector routes events to the right project. */
19
+ projectKey: string;
20
+ /** Pre-shared X25519 public key (base64) — skips the bootstrap /v1/pk
21
+ * hop. Most consumers leave this undefined and let `ServerPublicKey\
22
+ * Cache` resolve it on first identify(). */
23
+ serverPublicKeyB64?: string;
24
+ /** Override the SDK version reported in the payload. */
25
+ sdkVersion?: string;
26
+ }
27
+
28
+ export interface IdentifyOptions {
29
+ event?: FpEventType;
30
+ /** Opaque integrator-facing user id. Surfaces as «Linked ID» in the
31
+ * dashboard's Identification table. */
32
+ userId?: string;
33
+ /** Block the HTTP response until scoring finishes (~50-150 ms) so the
34
+ * result includes the real `visitorId` / `suspectScore`. Use for
35
+ * click-driven flows. Defaults to `true`. */
36
+ sync?: boolean;
37
+ }
38
+
39
+ export interface IdentifyResult {
40
+ v?: string;
41
+ eventId?: string;
42
+ result?: 'ok' | 'retry' | 'reject';
43
+ visitorId?: string;
44
+ userId?: string;
45
+ suspectScore?: number;
46
+ external?: {
47
+ flags?: string[];
48
+ linkedVisitors?: string[];
49
+ };
50
+ }
51
+
52
+ export interface FpInstance {
53
+ identify: (opts?: IdentifyOptions) => Promise<IdentifyResult | null>;
54
+ }